설정에 없는 규칙이 마스킹을 계속할 때: 정규식 경계, 성공으로 기록된 쓰기 실패, findall 그룹 튜플
날짜·금액이 마스킹되고 지운 정책이 계속 적용되던 현상을 경계 없는 정규식 템플릿, 쓰기 실패를 성공으로 기록하던 컨트롤러, 재승인 때 사라지는 예외, 그룹 정규식에서 튜플을 반환하는 findall로 나눠 고쳤습니다. 쌍둥이 정책은 복합 FK로 막는 테이블 통합안으로 정리했습니다.
Key Results
- 5종 → 0
- 내장 정규식 템플릿 오탐날짜·일시·전표·타임스탬프 입력 5종, Python re와 JS RegExp 결과 동일, 정탐 유실 0
- 4/4
- 그룹 정규식 시나리오 통과배포 파드에서 캡처 그룹 3개 시험 규칙으로 실행, 실패 0·시험 규칙 잔존 0
- 102건 중 0건
- 삭제된 정책을 가리키는 유령 매핑외부망 데모 환경 DB 직접 쿼리, PII 매핑 기준
은행에 구축한 XGEN은 사용자 입력, DB 조회 결과, 도구 결과, RAG 컨텍스트, 업로드 문서에 PII 마스킹을 적용합니다. 7월 말부터 한 달 동안 마스킹에 관한 제보가 이어졌습니다. 정상 계좌번호 일부가 가려진다, 날짜와 금액이 갑자기 마스킹되는데 관리 설정에는 그런 규칙이 없다, 지운 정책이 계속 적용된다, 정보검색 노드를 사전 검색 모드로 실행하면 에이전트 노드 전체가 멈춘다.
증상은 모두 같은 마스킹 파이프라인에서 나왔지만 원인은 서로 달랐고, 몇 개는 겹쳐서 서로의 추적을 막고 있었습니다. 경계 없는 정규식이 날짜를 가렸고, 관리자가 그 정책을 지우려 하자 삭제가 조용히 실패했고, 실패가 성공으로 기록돼 "설정에 없는 규칙이 적용된다"는 상태가 됐습니다. 통제 정책 로그는 적용하지 않은 정책까지 "마스킹됨"으로 적고 있어, 그 로그로 검증하는 사람은 반드시 잘못 짚게 돼 있었습니다.
이 글은 그 원인들을 하나씩 분리한 과정입니다. 정규식 경계, 쓰기 실패의 반환값, 재승인 동기화, 대화 이력 되먹임, re.findall의 그룹 튜플을 차례로 다루고, 각 수정의 위치와 범위를 왜 그렇게 정했는지, 구조적으로 남은 문제를 어떤 설계로 막으려 했는지를 적었습니다.
정책이 쓰이고 읽히는 경로
정책을 쓰는 쪽은 관리 설정의 통제 정책 관리입니다. PII 탭과 금칙어 탭은 정책 이름을 기준으로 생성·수정·삭제하고, 에이전트별 관리 탭은 id를 기준으로 에이전트마다 적용과 예외를 정합니다. 통제 정책 로그 탭은 탐지 이력을 읽기만 합니다. 읽는 쪽은 에이전트 실행입니다.
관리 설정 > 통제 정책 관리 [쓰는 쪽]
├ PII 탭 · 금칙어 탭 (정책 이름 기준 CRUD)
├ 에이전트별 관리 탭 (id 기준 적용·예외)
└ 통제 정책 로그 탭 (읽기만)
↓
PostgreSQL: PII 정책 · 금칙어 정책 · 에이전트-정책 매핑 · 정책 이벤트 로그
↓
에이전트 실행 [읽는 쪽]
├ 입력 · DB 조회 노드 ┐
├ 도구 결과 (DB·MCP·API) ├ 매 호출 DB 직조회, 정책을 고치면 즉시 반영
├ RAG 컨텍스트 ┘
└ 문서 임베딩 ─────────── 마스킹된 채 벡터 DB에 저장, 정책 변경이 소급되지 않음두 가지 성질이 이후의 판단에 계속 쓰였습니다. 첫째, 실행 경로는 매 호출 DB를 직접 읽으므로 캐시로 인한 stale 상태가 없고, 문서 임베딩만 되돌릴 수 없습니다. 둘째, 전역 마스킹 스위치는 집행 플래그입니다. 꺼도 탐지와 로그 적재는 계속되고 출력만 원문이 되는 모니터링 모드입니다. 그래서 로그에 남은 탐지 기록은 "실제로 가렸다"는 뜻이 아닐 수 있습니다.
7월의 두 신호: 계좌번호 오탐과 지워지지 않는 정책
첫 신호는 "PII 정책을 삭제해도 삭제되지 않고 유지되는 것 같다"는 문의였습니다. 삭제 API에는 기본 정책을 보호하는 조건이 걸려 있어, 기본 정책 표시가 참인 정책은 404로 삭제되지 않았습니다. 그런데 전수 조사해 보니 생성 API는 그 값을 항상 거짓으로 넣고, 시드·마이그레이션 어디에도 참으로 만드는 경로가 없었습니다. 실질 효과는 누군가 DB에서 값을 바꾸면 그 정책이 UI 표시도 없이 영구히 삭제 불가가 되는 함정뿐이라, 보호 조건을 걷어냈습니다. 이때 프론트엔드가 삭제 실패 시 오류 표시 없이 모달만 닫는다는 점과, 캡처 그룹이 2개 이상인 정규식이 마스킹을 중단시킬 수 있다는 잠재 결함도 함께 기록했지만 그 자리에서 고치지는 않았습니다. 둘 다 한 달여 사이에 실제 장애로 돌아왔습니다.
다음 날에는 정상 계좌번호 일부가 7****처럼 가려진다는 제보가 왔습니다. 폐쇄망의 활성 정책 중 일반전화 0\d{1,2}-?\d{3,4}-?\d{4}와 휴대전화 01[016789]-?\d{3,4}-?\d{4}가 하이픈을 선택으로 두고, 문자열 중간부터 매치하고 있었습니다. 12자리 계좌번호의 가운데가 전화번호로 잡힌 것입니다. "질문도 마스킹된다"는 부분은 입력·출력·저장이 같은 마스킹 함수를 쓰는 정상 배선이었습니다.
조치는 정책 정규식에 하이픈 필수와 앞뒤 숫자 경계 (?<!\d)…(?!\d)를 넣는 UPDATE였습니다. PostgreSQL의 ~ 연산자(POSIX 정규식)는 lookbehind를 지원하지 않아 DB 안에서는 검증할 수 없었고, 엔진과 같은 Python re로 계좌번호는 원문 유지, 전화번호 형식 3종은 마스킹 유지를 확인했습니다. 기존 로그는 이미 옛 정규식으로 마스킹된 채 저장돼 소급되지 않으므로 새 질문으로 확인했고, 당일 해결 확인을 받았습니다.
날짜·금액 오탐과 좀비 정책: 겹쳐 있던 두 원인
8월 하순의 제보는 더 혼란스러웠습니다. "금액이나 날짜가 갑자기 마스킹된다. PII 탭에는 그럴 만한 규칙이 없다. DB를 보니 이상한 계좌번호 규칙이 마스킹했다고 뜬다." 왜 날짜가 걸리는지와 왜 지운 규칙이 살아 있는지, 두 질문이 섞여 있었습니다. 따로 추적하니 하나의 사슬이 나왔습니다.
① 제품이 경계 앵커 없는 계좌번호 정규식 템플릿을 기본 제공
② 관리자가 그 템플릿으로 정책 생성 → 정책 테이블에 저장
③ 날짜 "2026-08-21"의 "026-08-21"이 마스킹됨
④ 관리자가 정책을 삭제 → 삭제가 조용히 실패 (200 OK, 이력엔 "삭제")
⑤ UI에서는 사라졌지만 DB에 남아 계속 마스킹 = 좀비 정책
⑥ "설정에 없는 규칙이 적용된다" → 원인 추적 봉쇄①의 템플릿은 정규식 빌더가 제공하는 계좌번호 템플릿 \d{3}-\d{2,6}-\d{2,6}이었습니다. 경계가 없어 긴 숫자열 안쪽을 부분 매칭했고, 휴대전화 템플릿도 같은 문제가 있었습니다.
| 템플릿 | 입력 | 마스킹되는 부분 |
|---|---|---|
| 계좌번호 | 2026-08-21 (날짜) |
026-08-21 |
| 계좌번호 | 2026-08-21 10:30 (일시) |
026-08-21 |
| 계좌번호 | 20260821-001-02 (전표) |
821-001-02 |
| 휴대전화 | 20260101120000 (타임스탬프) |
0101120000 |
| 휴대전화 | TX20260101234567 (전표) |
0101234567 |
정책 이름은 "계좌번호"라, PII 설정 목록만 봐서는 날짜가 왜 가려지는지 알 수 없습니다.
핵심은 ④였습니다. DB 계층은 쓰기 실패를 예외가 아니라 반환값으로 알리는데, 삭제 컨트롤러가 그 값을 버리고 있었습니다.
# DB 계층: 0건이면 False, 예외도 삼키고 False
affected = execute_update_delete(query, values)
return affected is not None and affected > 0
# 컨트롤러: 반환값을 확인하지 않고 성공으로 기록
db.delete_by_condition(PIIPolicy, {"policy_name": name})
write_policy_change_history(..., change_kind="삭제")
return {"message": "PII policy deleted successfully"} # 200 OKDB에는 행이 남고, 변경 이력에는 "삭제"가 남고, UI에서는 사라진 것처럼 보이는데, 엔진은 계속 그 규칙으로 마스킹합니다. 프론트엔드는 실패해도 catch에서 모달만 닫았습니다. 에이전트별 적용 토글의 쓰기 지점도 같은 구조라, 토글이 걸리지 않은 채 운영 로그에는 "적용", "예외"가 성공으로 남았습니다.
남은 잔재와 간헐 크래시: 캐시도 확률도 아니었다
좀비 정책을 고친 뒤에도 "적용했다가 취소했는데 이전 적용이 잔재로 남는다"는 지적이 남았습니다. 저장소 네 곳과 두 브랜치를 전수로 조사하고 반증 검증을 거쳤습니다. 먼저 기각된 가설들이 있었습니다. 정책 캐시는 존재하지 않았습니다. 매 호출 DB 직조회였고 과거의 캐시는 이미 폐기된 상태였습니다. 삭제된 정책 id를 가리키는 유령 매핑은 정리 훅이 있었고, 외부망 데모 환경 DB를 직접 조회하니 PII 매핑 102건 중 0건이었습니다. 정책 수가 조회 기본 한도를 넘는다는 가설은 한도가 500이라 전제가 거짓이었습니다.
확정된 원인은 넷이었습니다. 첫째, 배포나 거버넌스 재승인으로 워크플로우가 배포 단계에 들어갈 때마다 동기화 함수가 그 에이전트의 매핑을 조건 없이 통째로 지우고 활성 정책 전부를 "적용"으로 다시 넣었습니다. 관리자가 걸어 둔 예외가 재승인 한 번에 흔적 없이 사라지고, 재삽입 대상이 아닌 가드레일 예외는 지워지기만 했습니다. 둘째, 문서 색인은 마스킹된 텍스트를 청킹·임베딩 전에 원문에 덮어써 벡터 DB에 영구히 저장합니다. 셋째, 마스킹된 답변이 대화 이력으로 다음 턴에 다시 주입돼, LLM이 정책을 끈 뒤에도 "이 대화에서 이 값은 가려서 부른다"는 전례를 따랐습니다. 넷째, 탐지 샘플을 만들 때 적용 여부와 무관하게 마스킹 결과를 미리 계산하고, 전역 스위치 판정은 로그 적재 뒤에 있었습니다. 실제 마스킹은 건너뛰는데 로그에만 "원문 → "가 남으니, 로그로 검증하면 "껐는데 여전히 마스킹된다"는 결론밖에 나올 수 없었습니다.
잔재를 조사하던 날 또 하나의 사고가 있었습니다. PII 정책을 지웠는데 14초 뒤 같은 단어가 다시 마스킹됐습니다. 이번에는 삭제 실패가 아니라, 금칙어 테이블에 글자 하나까지 같은 정규식을 가진 다른 이름의 정책이 남아 있었던 것입니다. 두 테이블은 컬럼 13개가 모두 같았고, 정책을 가리키는 FK가 하나도 없었으며, 매핑 테이블은 정책 종류와 id의 다형 참조라 FK를 걸 수 없는 형태였습니다. 관리자가 지운 탭에는 흔적이 없으니 구조가 이 사고를 만들고 있었습니다.
8월 말에는 정보검색 노드를 사전 검색 모드로 실행하면 에이전트 노드 전체가 죽는 장애가 폐쇄망에서 났습니다.
File ".../<PII 마스킹 함수>.py", line 99, in mask_piis_in_text
{"original": m, "masked": pattern.sub(mask_str, m) if enforced else m}
TypeError: expected string or bytes-like object, got 'tuple're.findall은 캡처 그룹이 2개 이상이면 전체 매치가 아니라 그룹 튜플을 돌려줍니다. 관리자가 계좌번호 계열 정규식을 그룹까지 써서 등록하면, 그 튜플이 감사 로그 샘플을 만드는 pattern.sub()에 들어가 예외가 납니다. 샘플 생성이 본문 마스킹보다 앞에 있어, 로그를 만들다가 노드 전체가 죽었습니다. 검색 도구 모드도 안전하지 않았습니다. 도구 출력 마스커가 같은 함수를 예외 처리 없이 부르므로, 멀쩡해 보인 것은 LLM이 검색 도구를 부르지 않았거나 결과에 매치가 없었던 운이었습니다.
어떤 때는 나고 어떤 때는 안 나서 확률처럼 보였지만, 네 조건이 동시에 맞을 때만 나는 결정론적 결함이었습니다. 활성 규칙이 있다는 것과 그 정규식에 그룹이 2개 이상이라는 것은 항상 참이었고, 집행 조건은 마스킹 적용·전역 스위치 ON·예외 아님이면 참입니다. 매번 달라지는 것은 그 정규식이 이번 텍스트에 매치되는지뿐이었습니다. 날짜가 든 파일명과 본문이 검색되면 터지고 아니면 넘어갔습니다. 제품 기본 라인은 이미 몇 달 전에 finditer의 group(0)으로 고쳐져 있었고, 고객 배포 라인에 포팅되지 않은 상태였습니다.
설계 판단: 검사는 한 곳에서, 제약은 DB에서
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 호출부마다 쓰기 반환값 검사 | 국소 변경 | 메서드마다 성공·실패 표현이 달라 언젠가 하나가 빠짐 | 버림 |
| 가드 모듈 한 곳에서 판정, 실패면 500 후 이력 기록, 삭제는 재읽기 검증 | 실패가 성공으로 기록되지 않음 | 조용히 실패하던 요청이 오류로 드러남 | 채택 |
| 정책 테이블 통합 후 id 재매핑 | 스키마가 단순해짐 | 참조 780행 수정, 겹치는 id 재매핑 필요 | 버림 |
통합 테이블 + (policy_kind, legacy_id) 복합 FK·CASCADE + 활성 정규식 부분 유니크 인덱스 |
참조 행 무수정, 쌍둥이를 구조적으로 차단 | 폐쇄망 운영계 DDL이라 점검 시간 필요 | 설계안, 검토 대기 |
쓰기 결과의 성공·실패 표현은 메서드마다 달랐습니다.
| 메서드 | 성공 | 실패 |
|---|---|---|
| 조건 삭제 | True |
False |
| 수정 | {"result": "success"} |
False |
| 삽입 | {"result": "success", "id": …} |
None |
| 조건 일괄 수정 | {"success": True, …} |
{"success": False, …} |
이 넷을 호출부마다 해석하면 언젠가 하나가 어긋납니다. 그래서 판정을 가드 모듈 한 곳에 모으고, 실패면 500으로 끊은 뒤에야 변경 이력과 운영 로그를 쓰게 했습니다. 삭제는 반환값이 참이어도 한 번 더 읽어 실제로 사라졌는지 확인합니다. 동명 행이 여럿이었거나 다른 커넥션이 되살린 경우를 여기서 잡습니다. 컨트롤러 3곳과 신규 가드 1개, 에이전트 토글 쓰기 지점까지 모두 이 가드를 지나게 했고, 프론트엔드는 실패를 토스트로 알리고 목록을 다시 읽게 했습니다. 행이 그대로 남아 있는 것을 보여 줘야 "안 지워졌다"가 전달되기 때문입니다.
검수에서 제 수정의 결함 두 건도 잡았습니다. import 줄을 치환하면서 원래 줄의 뒷부분을 물고 가, 가드 모듈에 없는 심볼을 import하고 있었습니다. 문법은 맞아 py_compile은 통과했지만 그대로 나갔다면 코어 서비스가 기동하지 못했을 것이고, 실행 파드에서 실제로 import해 보는 단계에서 잡혔습니다. 또 삽입 성공을 id 유무로 판정했는데, id는 비어 있는 채로 시작해 일부 경로에서 채워지지 않으므로 정상 생성이 500이 될 수 있었습니다. 실제 계약인 result == "success"로만 판정하게 고쳤습니다.
정규식 템플릿은 숫자로 시작하고 끝나는 8종에 (?<!\d)…(?!\d) 경계 가드를 추가했습니다. 이메일, IPv4, URL처럼 숫자 경계 문제가 없는 템플릿은 그대로 뒀습니다. 템플릿은 정규식 빌더 UI의 목록일 뿐이고 저장해야 정책이 되므로, 수정은 앞으로 만드는 정책에만 반영됩니다. 이미 저장된 정책을 위해 정리 SQL을 따로 준비했습니다. 좀비인지 먼저 확인하고, 삭제 대신 비활성화로 효과를 본 뒤 지우거나, 계속 쓸 규칙이면 앵커를 붙입니다.
UPDATE pii_policies
SET regex_pattern = '(?<!\d)' || regex_pattern || '(?!\d)',
version = version + 1
WHERE id = <id>;재승인 동기화는 지우기 전에 예외 집합을 읽어 두었다가 그 정책은 예외로 다시 넣고, 삭제 범위를 PII·금칙어로 한정하고, 예외 조회가 실패하면 "전부 적용"으로 덮어쓰지 않고 동기화를 건너뛰게 했습니다. 로그는 정책별로 집행 여부를 계산해 기록하고, 미적용 건의 샘플은 원문 그대로 둡니다. 탐지 자체는 감사 목적이라 계속 적재합니다.
findall은 finditer의 group(0)으로 바꿨습니다. 그룹이 없는 정규식에서는 두 방식의 결과가 완전히 같고 반환 타입도 문자열 리스트로 같아, 정상 동작하던 규칙은 결과가 바뀌지 않고 실패하던 규칙만 정상화됩니다. 새 방식을 만든 것이 아니라 제품 기본 라인에서 검증된 코드를 워크플로우 서비스 2곳, 문서 서비스 3곳에 옮긴 것입니다. 쌍둥이 정책은 통합 설계 이전에 코드 수준에서 신규 생성을 막는 조치를 먼저 반영했습니다.
결과
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 계좌번호 오탐 | 12자리 계좌 일부 마스킹 | 계좌 원문 유지, 전화번호 3종 마스킹 유지 | 운영 정책 UPDATE 후 Python re, 새 질문으로 확인 |
| 내장 템플릿 오탐 | 5종 | 0종, 정탐 유실 0 | 서버 Python re와 UI 정규식 테스트의 JS RegExp 결과 동일 |
| 쓰기 가드 | 실패해도 200·성공 이력 | 실패 시 500, 이력 미기록 | 단위 테스트 12건, 실행 파드 실제 import |
| 재승인 후 예외 | 소실 | 유지 | 테스트 3/3, 의존성 설치 컨테이너에서 브랜치별 |
| 로그 집행 여부 | 미적용도 "마스킹됨" | 집행 여부 기록 | 테스트 3/3, 마스킹 반환값 스위치×예외 4조합 4/4 동일 |
| 그룹 정규식 | TypeError로 노드 중단 | 4/4 시나리오 통과 | 배포 파드, 캡처 그룹 3개 시험 규칙 |
템플릿 가드로 사라진 매칭은 모두 다른 템플릿이 이미 정확히 잡던 중복 오탐이었습니다. 주민번호를 휴대전화 템플릿이, 카드번호를 계좌번호 템플릿이 겹쳐 잡던 것입니다. 그룹 정규식 시나리오는 공유 개발 환경이라 실사용 텍스트와 절대 겹치지 않는 접두어로 시험 규칙을 잠깐 넣었습니다. 그룹 3개 매칭은 죽지 않고 마스킹됐고, 매치 없음은 원문 그대로, 날짜가 든 파일명은 오탐 없이 원문 그대로, 한 문장 여러 건은 모두 마스킹됐습니다. 검증 뒤 시험 규칙을 지웠습니다. 외부망 데모 환경에서 원래 크래시가 나지 않은 이유도 이때 확인했습니다. 그곳 규칙에는 이미 앵커가 적용돼 있었고 캡처 그룹이 하나도 없었습니다.
회고와 남은 과제
가장 급한 남은 과제는 문서 임베딩 경로입니다. 업로드 시점의 정책으로 마스킹된 청크가 벡터 DB에 영구히 저장되고, 고객 배포 라인의 문서 경로는 마스킹 미적용 설정이어도 ****로 치환하며 에이전트별 예외도 검사하지 않습니다. 이미 임베딩된 문서는 정책을 어떻게 바꿔도 풀리지 않아 재임베딩이 필요합니다. 컬렉션 재색인 액션을 둘지, 원문을 저장하고 검색 반환 시점에 마스킹하도록 옮길지는 구조 변경이라 별도 과제로 남겼습니다.
정책 식별자도 정리해야 합니다. 정책 이름이 사실상 기본키인데 UNIQUE 제약이 없고, 두 정책 테이블의 id 값이 서로 겹칩니다. 제약을 먼저 걸면 기존 중복 때문에 마이그레이션이 실패해 서비스가 뜨지 않으므로, 진단, 정리, 제약 순서를 지켜야 합니다. 테이블 통합 설계는 착수 승인과 폐쇄망 점검 창을 기다리고 있습니다.
배포할 때 예상해야 할 변화도 있습니다. 실패를 드러내기 시작하면 그동안 조용히 실패하던 요청이 오류로 보입니다. "갑자기 삭제가 안 된다"는 문의는 새 결함이 아니라 원래 반영되지 않던 것이 드러나는 것이라, 배포 전에 미리 알리기로 했습니다. 마스킹 해제를 확인할 때는 대화 이력 되먹임 때문에 반드시 새 대화로 검증해야 합니다.
배운 것을 정리하면 이렇습니다. 실패를 반환값으로 알리는 API는 한 곳에서 검사해야 하고, 변경 이력은 쓰기가 성공한 뒤에 남겨야 합니다. "실패했는데 성공으로 기록된" 상태는 장애보다 추적을 더 많이 막습니다. 사용자가 정규식을 직접 넣는 기능이라면 finditer와 group(0)이 기본이어야 하고, 로그를 만들다가 본업을 죽여서는 안 됩니다. 정규식 템플릿을 기본 제공한다면 경계 앵커까지 제품이 책임져야 합니다. 관리자는 그것을 검증된 것으로 믿고 그대로 쓰기 때문입니다. 그리고 한쪽 브랜치에서만 고친 결함은 반드시 다른 쪽에서 다시 만나게 됩니다.