값이 아니라 자리로 가린다: DB 결과의 컬럼 단위 PII 마스킹과 감사 로그 실행 신원
전화번호 정규식이 계좌번호를 가리고 이름·주소는 놓치던 문제를 값의 모양 대신 컬럼으로 판단하는 층으로 풀었습니다. 연결·쿼리 템플릿·노드 3층 규칙을 DB 결과가 LLM에 닿기 전에 적용하고, 실측으로 찾은 에이전트 DB 도구 경로의 누락, 비활성 정책의 원문 유출, 비어 있던 감사 로그 실행 신원까지 막았습니다.
Key Results
- 88건
- 백엔드 단위 테스트 통과컬럼 마스킹 엔진·노드·템플릿 병합·fail-closed 포함, 로컬 실행
- NULL 5칸 → 기록
- 감사 로그 실행 신원수정 전 실측 행은 workflow·node·execution·user·username 모두 NULL, 배포 후 재실행에서 workflow·node·사용자 기록 확인
은행 고객 데이터베이스를 조회해 가공한 뒤 에이전트에 넘기는 워크플로우를 두고 요청이 들어왔습니다. LLM이 개인정보를 알아서 가리도록 맡기지 말고, DB를 조회하고 가공하는 단계에서 컬럼을 지정해 로직으로 먼저 가리고 싶다는 것이었습니다. 컬럼은 추가되고 바뀔 수 있으니 그것도 고려해야 했습니다.
조사해 보니 문제는 요청보다 넓었습니다. DB 노드에는 마스킹이 전혀 없었고, 기존 마스킹은 값의 모양만 보는 정규식이라 오탐과 미탐이 같은 원인에서 나오고 있었습니다. 이 글은 "어느 컬럼이 개인정보인가"를 선언하는 층을 정규식 위에 얹은 설계와, 적용 지점을 따라가며 찾은 구멍들(에이전트 DB 도구 경로, 비활성 정책, 비어 있던 감사 로그 신원)을 실측으로 막은 과정을 다룹니다.
마스킹은 에이전트 입구 한 곳에만 있었다
기존 자산은 이미 갖춰져 있었습니다. 관리자가 등록하는 개인정보 정규식 정책과 그것을 순회하며 치환하는 적용기, 에이전트별 예외, 정보보호 관리 UI가 단일 소스로 쓰는 통제 정책 로그가 있었습니다. 문제는 이것이 어디에 걸려 있느냐였습니다.
| 확인 | 결과 |
|---|---|
| DB 노드가 마스킹을 하는가 | 하지 않음. DB 노드 코드 전체에 마스킹 호출 0건 |
| 어디서 마스킹되는가 | 에이전트 입구 한 곳뿐. DB에서 에이전트까지 구간에 원문이 흐름(중간 노드, 실행 입출력 기록, 트레이스, 노드 로그) |
| 워크플로우의 개인정보 데이터 분류는 마스킹인가 | 아님. 실행 권한 게이트. 대상 워크플로우에 걸려 있었지만 아무것도 가리지 않았음 |
| 조회 파라미터는 안전한가 | 아님. 플레이스홀더 해석 결과를 100자로 자르기만 하고 로그에 남겨, 조회 조건의 주민번호·계좌번호가 로그 수집까지 흘렀음 |
DB 조회 ──(원문)──▶ DB 결과 가공 ──(원문)──▶ 에이전트 입구 [정규식 마스킹] ──▶ LLM
│ │
└─ 실행 입출력 · 트레이스 · 노드 로그에 원문 저장오탐과 미탐은 같은 원인에서 나온다
이 설계에는 앞선 사고가 있었습니다. 7월에 폐쇄망에서 정상 계좌번호가 마스킹된다는 제보가 있었는데, 일반전화와 휴대전화 정규식이 하이픈을 선택으로 두고 문자열 중간부터 매치해 12자리 계좌번호의 가운데가 전화번호로 잡힌 것이었습니다. 그때는 정규식에 하이픈 필수와 앞뒤 숫자 경계를 넣어 조치했습니다. PostgreSQL의 POSIX 정규식은 lookbehind를 지원하지 않아 검증은 실제 적용기와 같은 Python 기준으로 했습니다.
하지만 정규식을 아무리 다듬어도 이름, 주소, 하이픈 없는 번호는 모양으로 잡을 수 없습니다. 오탐(계좌번호를 전화번호로)과 미탐(이름·주소를 놓침)은 둘 다 값의 모양만 보고 판단한다는 같은 원인에서 나옵니다. 컬럼을 알면 둘 다 생기지 않습니다. phone 컬럼은 어떤 모양이든 전화번호이고, account_no는 전화번호처럼 생겨도 계좌번호입니다.
phone 010-1234-5678 → 010****5678
cust_ssn 900101-1234567 → ****
account_no (12자리 숫자) → 그대로 (규칙에 없음)LLM에 판단을 맡기는 길은 요청의 전제("LLM에 맡기지 않고 로직으로")에서 이미 제외돼 있었습니다. 남은 선택지를 비교했습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 전역 정규식 보강 | 기존 엔진 그대로 | 모든 워크플로우·모든 텍스트에 적용되어 부작용 반경이 큼. 이름·주소는 여전히 못 잡음 | 최후 그물로 유지 |
| 컬럼 선언 층 (연결·템플릿·노드) | 오탐·미탐 동시 제거, 그 연결·노드에만 적용 | 컬럼 이름을 정확히 알아야 함 | 채택 |
| 플레이스홀더 기반 추론 | 설정 부담 감소 | 이름 추론이 조인 키를 조용히 망가뜨릴 수 있고, 플레이스홀더는 입력이라 결과 컬럼과 이름이 달라 가짜 안심을 줌 | 보류 |
3층 규칙과 병합 원칙
규칙은 세 층입니다. 관리자가 DB 연결에 거는 연결 정책은 그 연결을 쓰는 모든 워크플로우에 강제되고 노드에서 해제할 수 없습니다. 쿼리 템플릿 규칙은 템플릿마다 민감 컬럼을 정합니다. 템플릿은 에이전트가 실행할 수 있는 쿼리의 화이트리스트라, 연결 한 묶음으로는 템플릿마다 다른 결과 컬럼을 다루기 어렵기 때문입니다. 워크플로우 작성자의 노드 규칙은 추가만 됩니다. 병합은 노드, 템플릿, 연결 순서로 쌓아 같은 컬럼이면 연결 정책이 이기고, "규칙에 없는 컬럼" 처리는 세 층 중 가장 엄격한 쪽을 택합니다. 전역 정규식은 걷어내지 않고 최후 그물로 남겼습니다. 층을 걷어내지 않고 얹는 쪽이 회귀 위험이 없기 때문입니다.
규칙 하나는 대상 컬럼, 마스킹 방식, 컬럼이 없는 경우의 동작으로 구성됩니다. 연결 정책은 기존 정책 필드와 같은 자리에 JSON 컬럼 하나로 저장해 신규 테이블을 만들지 않았습니다.
{
"unknown_column_action": "pass",
"rules": [
{"match": "customer.phone", "mode": "partial", "keep_tail": 4, "on_missing": "warn"},
{"match": "*_ssn", "mode": "full", "on_missing": "fail"},
{"match": "cust_id", "mode": "hash"},
{"match": "memo", "mode": "regex", "policy_ids": [2, 3]}
]
}컬럼 지정은 정확한 이름, 표.컬럼, 와일드카드(*_phone), 정규식(/ssn/i)을 지원하고 매칭은 대소문자를 무시합니다. 방식은 전체, 부분(앞뒤 노출 자릿수), 해시, NULL 처리, 컬럼 제외, 정규식 마스킹입니다.
두 가지 판단이 특히 중요했습니다. 첫째, "규칙에 없는 컬럼"을 전체 마스킹으로 두면 나중에 테이블에 컬럼이 추가돼도 자동으로 가려집니다. 스키마 변경은 정책이 모르는 개인정보가 새는 가장 흔한 경로라, 안전한 컬럼만 허용 목록에 올리는 운영이 가능해야 했습니다. 둘째, 해시는 같은 값이 항상 같은 토큰이 됩니다. DB 결과 가공 노드의 출력은 다음 쿼리의 ${ids} IN 절로 들어가는 것이 정식 용법이라, 키를 전체 마스킹하면 2차 쿼리가 통째로 실패합니다. 조인을 유지하면서 가려야 할 때 해시를 씁니다. "LLM으로 가는 출구"와 "다음 쿼리로 가는 출구"를 구분하는 것이 이 설계에서 가장 조심한 부분이었습니다.
컬럼 이름을 손으로 적으면 오타 하나로 그 컬럼이 가려지지 않습니다. 그래서 편집기에 후보를 주되 DB에 접속하지 않고 두 곳에서 모았습니다. 노드에 적힌 쿼리를 sqlglot으로 파싱해(규칙이 결과 컬럼 이름으로 찾으므로 별칭 우선) 뽑고, SELECT *처럼 쿼리에서 뽑을 수 없으면 이미 쌓이고 있는 DB 접근 감사 원장에서 그 연결의 최근 성공 쿼리 30건을 읽습니다. 편집 중에 실제 조회를 보내지 않아 권한·부하 문제가 없고, 새 수집 경로도 만들지 않았습니다.
적용 지점을 따라가다 찾은 구멍들
적용 지점은 가장 이른 곳으로 잡았습니다. DB 조회 노드는 조회 직후 표와 행 데이터 양쪽에 적용하고, 마스킹이 일어났을 때만 표를 재생성해 규칙이 없는 워크플로우는 기존 경로를 그대로 지납니다. 조회 직후에 적용해야 가공 노드, 중간 노드, 실행 기록, 트레이스 어디에도 원문이 남지 않습니다. 조회 파라미터 로그는 값이 지나가는 유일한 길목(호출 4곳)에서 전역 정규식을 적용하고, 마스킹에 실패하면 원문 대신 값을 생략합니다. 같은 조회가 트랜잭션 원장에 이미 기록되므로 이 로그 마스킹은 통제 정책 이벤트를 남기지 않게 해, 정보보호 UI가 같은 조회를 두 번 보여 주지 않게 했습니다.
템플릿 규칙을 추가하면서 템플릿이 실제로 실행되는 경로를 전부 추적했고, 여기서 가장 큰 구멍이 나왔습니다.
| 경로 | 템플릿 매칭 | 변경 전 마스킹 |
|---|---|---|
| DB 조회 노드 (템플릿 선택) | 있음 | 연결 + 노드 규칙 |
| 에이전트 DB 도구 (PostgreSQL·Oracle 쿼리 도구) | 템플릿 방화벽 | 아무 마스킹도 없음. LLM이 원문을 봄 |
| 고정 쿼리 도구 | 없음 | 아무 마스킹도 없음 |
"LLM 이전에 로직으로 미리 처리"가 정작 에이전트가 DB를 직접 부르는 경로에서는 되지 않고 있었습니다. 원인은 반환 형식이었습니다. 에이전트 도구가 쓰는 LangChain SQL 도구는 컬럼명 없는 튜플 문자열을 반환해 컬럼 규칙을 걸 자리가 없었습니다. 규칙(연결 또는 일치 템플릿)이 있을 때만 행을 dict로 받아 마스킹한 뒤 기존과 같은 형식(PostgreSQL은 튜플 리스트 문자열, Oracle은 탭 구분)으로 반환하게 했습니다. 규칙이 없으면 기존 경로를 그대로 지나갑니다.
두 번째 구멍은 노드가 실행 시점에 값을 제대로 넘기는지 실측하다가 나왔습니다. 한글을 〇〇로 치환하는 임시 정책을 만들고, 조회·가공 노드에 그 정책 ID를 쓰는 정규식 규칙을 걸어 실행했습니다.
| 시점 | 정책 상태 | 결과 |
|---|---|---|
| 저장 | - | 워크플로우 JSON의 두 노드에 정책 ID 그대로 |
| 실행 1 | 활성, 한글 패턴 | 한글이 〇〇로 치환, 감사 로그에 정규식 마스킹 기록 |
| 실행 2 | 활성, 영문 패턴으로 변경 (노드 재저장 없음) | 영문이 치환됨. 실행 시점의 정책을 읽음 |
| 실행 3 | 비활성 | 원문 그대로, 감사 로그 없음 |
정책 ID 전달과 실행 시점 바인딩은 정확했지만, 고른 정책이 비활성화되면 정규식이 하나도 풀리지 않아 원문이 흘렀습니다. 편집기는 활성 정책만 보여 주니 사용자는 규칙이 살아 있다고 믿게 됩니다. 정규식이 하나도 풀리지 않으면 경고를 남기고 전체 마스킹으로 닫도록(fail-closed) 고치고, 편집기에는 비활성·삭제 정책 ID를 붉은 칩으로 표시했습니다. 배포 후 같은 상태로 재실행하자 두 컬럼이 ****로 나오고 감사 로그에 기록됐습니다.
감사 로그에 남은 것과 비어 있던 것
감사 로그는 텍스트 정규식 경로와 같은 모양으로 원문·마스킹 쌍을 남깁니다(원문 100자, 컬럼당 10건 상한). 정보보호 관리 UI가 이 행을 단일 소스로 쓰기 때문입니다. 규칙을 지정하지 않은 컬럼은 로그에도 남지 않습니다. "DB 테이블에서도 마스킹 여부와 방식이 보이느냐"는 질문을 받고 실제 행을 열어 봤더니 절반만 맞았습니다. 컬럼 목록, 탐지 20건, 원문·마스킹 샘플, 강제 여부는 있었지만 마스킹 방식이 없었고, 워크플로우·노드·실행·사용자 ID·사용자명 다섯 칸이 전부 NULL이었으며 메타데이터는 빈 객체였습니다. UI에서도 해당 칸이 "-"였고 발생지는 내부 키 그대로였으며 발생지 필터에도 없었습니다.
원인은 신원을 전달하는 방식의 불일치였습니다. 실행기는 DB 조회 노드에 신원(워크플로우, 노드, 실행, 사용자)을 한 키로 묶어 넘깁니다. 쿼리의 ${user_id} 플레이스홀더를 실행자 값이 덮어쓰지 않게 하려는 설계입니다. 트랜잭션 원장은 이 키를 읽어 정상이었지만, 컬럼 마스킹 경로는 개별 인자를 읽어 항상 None이었습니다. 가공 노드는 애초에 신원 주입 대상이 아니었습니다.
두 노드가 원장과 같은 변환 함수로 신원을 읽게 하고, 실행기가 가공 노드에도 신원을 주입하게 했습니다. 정책 이름에는 방식 라벨을, 메타데이터에는 컬럼별 방식과 규칙 출처를 남깁니다. 사용자명은 원장과 같은 규칙(이름, 계정명, 이메일 순)으로 보강했습니다.
policy_names 컬럼:email(부분 마스킹), 컬럼:full_name(해시), 컬럼:password_hash(컬럼 제외)
metadata column_masking: {email: {mode: partial, origin: connection, keep_head: 2}, ...}
신원 workflow_id · node_id · execution_id · username 기록조회 API의 발생지 화이트리스트에도 새 발생지를 추가했습니다. 빠지면 UI에서 필터를 골라도 조용히 무시되고 엑셀 내보내기에 내부 키가 찍히기 때문입니다. 배포 후 재실행에서 방식 라벨과 신원이 채워진 것을 확인했는데, 이 과정에서 메타데이터의 규칙 출처가 전부 "node"로 남는 결함을 하나 더 찾았습니다. 병합 단계에서 부여한 출처 태그를 마스킹 단계의 규칙 재정규화가 버리고 있었고, 재정규화가 출처를 보존하도록 고쳤습니다.
결과
배포 후 시험 연결과 워크플로우로 경로별 실측을 했습니다. 연결 정책(email 부분, IP 전체, 비밀번호 해시 컬럼 제외), 조회 노드 규칙(이름 해시), 가공 노드 규칙(계정명 부분)을 층별로 나눠 걸고, 한 결과에 세 층이 함께 적용되는지 봤습니다.
| id | username | email | full_name | last_login_ip |
| 1 | 관**** | ad**** | <해시 8자리> | **** |비밀번호 해시 컬럼은 결과에 아예 없고, 같은 이름은 같은 해시 토큰이 됐습니다. 에이전트 워크플로우에서 LLM은 마스킹된 값만 나열하며 "일부 마스킹 처리된 상태"라고 스스로 언급했습니다. LLM은 원문을 본 적이 없습니다.
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 에이전트 DB 도구, 템플릿 SQL | 마스킹 없음 | 마스킹된 튜플 | 배포 후 시험 연결, 연결 규칙 + 템플릿 규칙 |
| 같은 도구, 템플릿 밖 SQL | - | 템플릿 방화벽이 거부 | 같은 연결 |
| 에이전트 DB 도구, 템플릿 없는 연결 | 마스킹 없음 | 연결 규칙 적용, 비밀번호 해시 컬럼 제외 | 자유 SQL |
| 비활성 정책의 정규식 규칙 | 원문 그대로 | ****, 감사 로그 기록 |
정책 비활성 상태로 재실행 |
| 감사 로그 실행 신원 5칸 | 전부 NULL | 워크플로우·노드·사용자 기록 | 배포 후 재실행 |
| 컬럼 마스킹 결과의 전역 정규식 재탐지 | - | 5종 모두 재탐지 없음 | 활성 정책(휴대폰·주민번호·이메일) 재적용 |
| 백엔드 단위 테스트 | 70건 (1차) | 88건 통과 | 로컬 실행, 템플릿 병합·fail-closed 추가 후 |
회고와 남은 과제
이번 작업에서 실측이 찾은 결함은 모두 "설정은 맞는데 실제로는 적용되지 않는" 종류였습니다. 에이전트 도구 경로에는 규칙을 걸 자리가 없었고, 비활성 정책은 조용히 원문을 흘렸고, 감사 로그는 칸은 있는데 비어 있었습니다. 단위 테스트는 엔진의 정확성을 보장했지만, 이런 구멍은 배포된 환경에서 경로마다 실제 값을 넣어 보고 저장된 결과를 열어 봐야 드러났습니다.
남은 한계도 적어 둡니다. 컬럼 마스킹 결과에 전역 정규식을 다시 적용해도 걸리지 않는 것은 치환 문자가 숫자 연속을 끊기 때문이고, 이는 현재 정책 기준의 관측이지 보장이 아닙니다. 앞으로 [0-9a-f]{8} 같은 전역 정책이 추가되면 해시 토큰을 다시 잡을 수 있으므로 치환 문자를 정규식에 걸리지 않는 문자로 유지해야 합니다. 규칙은 결과 컬럼 이름으로 대조하므로 별칭을 쓴 쿼리에 원래 이름으로 규칙을 적으면 기본값(건너뛰기)에서는 아무 표시 없이 비켜 갑니다. 이를 줄이려고 후보에 없는 이름은 입력칸에 주황 테두리와 안내를 표시하게 했습니다. 워크플로우의 개인정보 데이터 분류가 마스킹 의도로 쓰였는지 확인과 UI 문구 정리, 템플릿 SQL에서 대상 컬럼 자동완성, 방식 라벨 도입 이전에 쌓인 로그 행 안내가 남아 있습니다.