수문장 (sumunjang)
AI에게 물어보기 전에, 개인정보는 문 앞에서 멈춘다.
Claude Code나 ChatGPT에 로그·문서·설정 파일을 붙여넣는 순간, 주민등록번호·계좌번호· API 키가 그대로 해외 API 서버로 전송됩니다. 개인정보보호위원회의 「생성형 AI 서비스 이용자를 위한 개인정보 보호 가이드」(2026.5)조차 "이름은 가명으로, 숫자는 XXX로" 라는 수동 마스킹을 권고할 뿐, 이를 자동화하는 무료 도구는 안내되지 않습니다.
수문장은 base_url 한 줄만 바꾸면 동작하는 로컬 게이트웨이입니다. 나가는 요청에서
한국 개인정보를 찾아 가명값으로 바꾸고, 돌아온 답변에서는 원문으로 되돌립니다.
당신 수문장 Anthropic API
│ │ │
│─ 900101-1234568 조회해줘 ─→ │
│ │─ [주민등록번호_1] 조회해줘 ─→│ ← 서버는 원문을 본 적이 없다
│ │←─ [주민등록번호_1]는 ... ──│
│←─ 900101-1234568 는 ... ─│ │ ← 당신 화면에는 원문이 보인다
빠른 시작
# 게이트웨이 실행
uvx sumunjang proxy --port 4000
# Claude Code가 수문장을 거쳐 나가게 한다
export ANTHROPIC_BASE_URL=http://127.0.0.1:4000
claude
파일이나 파이프에도 바로 쓸 수 있습니다.
$ echo "고객 900101-1234568, 010-9876-5432, kim@corp.co.kr" | sumunjang mask -
고객 [주민등록번호_1], [전화번호_1], [이메일_1]
$ sumunjang scan config.env # 찾기만 한다. 발견 시 종료 코드 1 → CI 게이트로 사용
$ sumunjang report # 골든셋으로 탐지 성능을 스스로 채점
무엇을 막는가
검증식으로 판별하는 것 — 값 스스로가 자기가 무엇인지 증명합니다.
| 종류 | 검증 방식 |
|---|---|
| 주민등록번호 | 검증식(가중치 mod 11) 또는 생년월일 정합성 |
| 사업자등록번호 | 검증식(가중치 1371 3713 5) |
| 카드번호 | 카드사 식별번호 대역 + Luhn (국내전용은 예외, 아래) |
| 휴대전화번호 | 통신사 식별번호 + 자릿수 (+82 표기 포함) |
| 이메일 | 형식 + 최상위 도메인 조건 |
| API 키·토큰 | Anthropic / OpenAI / GitHub / AWS / Slack 공표 접두사 |
문맥으로 판별하는 것 — 검증식이 없거나 공개되지 않아, 앞에 붙은 말을 증거로 씁니다.
| 종류 | 왜 형태만으로는 안 되는가 | 앵커 예 |
|---|---|---|
| 계좌번호 | 검증식이 없고 형식이 은행마다 다르다 (3-3-6, 6-2-6, 4-3-6 …) | 계좌:, 예금주, 입금 |
| 여권번호 | 영문 1자 + 숫자 8자. 제품 코드·대기번호와 겹친다 | 여권번호: |
| 운전면허번호 | 검사번호 산출식이 공개돼 있지 않다 | 면허: |
| 이름 | 형태 자체가 없다. 최윤서와 배송팀은 생김새가 같다 |
성명:, 예금주:, 담당: |
이름은 구조화된 문서 한정입니다. 성명: 최윤서처럼 키: 값 형태만 잡고,
어제 김수현 책임이랑 통화했는데 같은 자유서술 속 이름은 잡지 않습니다. 성씨 사전
(상위 50개, 인구 약 97%)으로 한 겹 더 거릅니다 — 커버리지가 아니라 오탐 때문입니다.
연·소·도·선·설 같은 희귀 성씨는 연락·소속·도착·선택·설정 의 첫 음절이기도 해서,
3%를 더 얻자고 오탐을 몇 배로 늘립니다.
표기 변형도 함께 받습니다. 하이픈 없이 붙여 쓴 주민등록번호(8803121000068), 점으로
구분한 전화번호(010.9876.5432), 국가번호를 붙인 전화번호(+82-10-2255-8830), 전각
숫자, 보이지 않는 문자(제로폭)를 끼워 넣은 우회까지. 제로폭은 정규화한 뒤 매칭하되
좌표는 원문 기준으로 되돌리므로 가려지는 위치가 어긋나지 않습니다.
2020년 10월 이후 발급된 주민등록번호는 뒷자리가 임의번호라 검증식을 통과하지 않습니다. 검증식만 관문으로 두면 그 세대를 통째로 놓치므로, 생년월일 정합성을 별도 관문으로 둡니다.
국내전용 카드(9로 시작)는 Luhn을 따르지 않습니다. 검증번호 산출 방식과 위치가 카드사마다 달라서입니다. Luhn만 관문으로 두면 한국 카드를 체계적으로 놓치므로 따로 다룹니다. 반대로 Luhn은 16자리 숫자 열 개 중 하나를 그냥 통과시키므로, 카드사 식별번호 대역이 아닌 숫자(전표번호 등)는 먼저 잘라냅니다.
증거가 없으면 관문을 더 요구한다
규칙이 열 종류지만 원칙은 하나입니다. 값이 스스로 증명하는 만큼만 관문을 덜어줍니다.
| 상황 | 요구하는 것 | 이유 |
|---|---|---|
| 하이픈 있는 주민등록번호 | 검증식 또는 생년월일 | 하이픈 자체가 의도의 증거다 |
| 하이픈 없는 주민등록번호 | 검증식 그리고 생년월일 | 13자리는 밀리초 타임스탬프·상품 바코드와 겹친다 |
| 국제 브랜드 카드 | Luhn | 국제 규격을 따른다 |
| 국내전용 카드 | 구분자 표기 | 검증할 수단이 없다. 사람이 옮겨적은 흔적을 대신 본다 |
같은 원칙이 앵커의 사정거리도 정합니다. 값이 증거를 담고 있으면(검증식, 하이픈 묶음,
생년월일 관문) 앵커는 같은 줄 안에서 느슨해도 됩니다 — 계좌: 국민은행 110-234-567890
처럼 은행 이름이 끼어드는 쪽이 오히려 보통이니까요. 반대로 값이 아무것도 증명하지
못하면(이름, 맨 숫자 계좌번호, 여권번호, 구분자 없는 국내전용 카드) 키: 값 형태를
요구합니다.
이 구분을 처음엔 지키지 않았다가 오탐 셋을 만들었습니다 — 여권 발급 대기열 A00000001,
이체 수수료 정산 2026080612345678, 카드 결제 모듈 롤백 커밋 9410123456789012.
셋 다 앵커 근처에 우연히 놓인 숫자였습니다. 지금은 오탐 골든셋에 박혀 있습니다.
대가도 함께 적습니다. 앵커 없이 붙여 쓴 2020년 10월 이후 발급분, 구분자 없는 국내전용
카드, 자유서술 속 이름과 주소는 놓칩니다. goldenset-gaps/ 에 정답으로 박아 두고
점수로 공표합니다.
겹치는 탐지는 합집합으로 가린다
전화번호와 카드번호는 자릿수를 공유할 수 있습니다. 010-9921-3348-0000-0002 에서
전화번호는 앞 13자, 카드번호는 뒤 19자를 차지합니다. 겹침을 "앞선 탐지가 이겼으니
뒤는 버린다"로 처리하면 카드번호 뒷 8자리가 평문으로 남습니다.
겹치는 탐지는 합집합 한 구간으로 병합해 탐지기가 표시한 바이트를 하나도 남기지 않습니다. 합쳐진 자리의 이름은 민감도 서열을 따릅니다 — 축은 이 값 하나로 다른 문을 얼마나 열 수 있는가, 그 자물쇠를 바꿀 수 있는가 하나입니다. 주민등록번호가 1위인 것은 한국 본인확인의 마스터키이면서 유출돼도 바꿀 수 없기 때문이고, 사업자등록번호가 최하위인 것은 국세청에서 공개 조회되기 때문입니다.
설계 원칙
개인정보가 지나가는 경로에 서드파티 코드를 두지 않습니다.
탐지·마스킹 모듈은 파이썬 표준 라이브러리만 사용합니다. 외부 패키지는 네트워크 계층에만
쓰이며 직접 의존성은 httpx, uvicorn 둘뿐입니다 (SBOM.md).
결정적입니다. 같은 입력이면 언제나 같은 출력이고, API 키도 네트워크도 필요 없습니다. AI 모델을 탑재하지 않으므로 실행할 때마다 결과가 달라지지 않습니다.
조용히 실패하지 않습니다. 복원은 정확히 일치하는 표시만 되돌립니다. 모델이 형태를 바꿔버린 경우 추측해서 복구하지 않습니다 — 잘못된 복원이 미복원보다 위험하기 때문입니다.
스트리밍은 신뢰성을 택했습니다. 조각난 응답에서 가명 표시를 복원하면 청크 경계에서 깨집니다. 그래서 업스트림에는 통짜로 요청해 복원을 끝낸 뒤, 클라이언트에게만 스트리밍 형태로 다시 흘려보냅니다. 첫 응답이 조금 늦는 대신 복원이 깨지지 않습니다.
실환경 검증 기록 (2026-08-06)
Claude Code를 수문장에 연결해 실제 API까지 왕복시켰습니다. 사용자가 입력한 것은 파일 경로 한 줄뿐입니다.
$ claude
> /tmp/결제장애-로그.txt 읽고 왜 결제가 실패했는지 분석해줘
수문장이 기록한 업스트림으로 실제 나간 본문:
user_rrn=[주민등록번호_1] ← 원문: 850312-1000003
user_phone=[전화번호_1]
user_email=[이메일_3]
card=[카드번호_1]
merchant_brn=[사업자등록번호_1]
ANTHROPIC_API_KEY=[시크릿_1]
사용자는 개인정보를 붙여넣은 적이 없습니다. 도구가 파일을 읽어 tool_result에 담는
순간 6종이 전송될 뻔했고, 여기에 도구가 시스템 프롬프트에 넣는 사용자 이메일까지 더해
한 요청에서 8건이 가려졌습니다. 업스트림 본문에 남은 원문은 0건이었습니다.
가려도 답변 품질이 떨어지지 않았습니다. 마스킹된 로그를 받은 모델은 장애 원인을 분석하면서 "로그에 크레덴셜이 평문으로 찍혀 있다"는 보안 문제까지 지적했습니다. 장애 분석에 필요한 것은 누구의 카드인지가 아니라 카드 결제가 실패했다는 구조이기 때문입니다.
이 검증 과정에서 자체 테스트로는 잡히지 않던 결함 두 건도 드러났습니다. 추론(thinking)
블록을 SSE 규약대로 delta로 흘리지 않아 다음 턴 요청이 400으로 거부되던 문제와,
반복되는 값을 "0건"으로 보고해 작동이 멈춘 것처럼 보이던 문제입니다. 둘 다 저장소에
포함된 에코 서버로는 재현되지 않는 종류였습니다.
무엇을 못 잡는지도 점수로 냅니다
골든셋은 고치는 순간 포화됩니다. 어려운 문서를 넣어 점수를 떨어뜨려도, 드러난 결함을 고치고 나면 다시 만점이 되어 처음과 구분이 가지 않습니다. 만점 셋만 내놓으면 "쉬운 것만 골라 놓고 만점이라 하는 것 아니냐" 는 물음에 답할 수 없습니다.
그래서 0점 셋을 나란히 공개합니다.
| 셋 | 의미 | 현재 |
|---|---|---|
goldenset/ |
회귀 기준선. 깨지면 되던 것이 망가진 것 | 재현율 1.000 · 정밀도 1.000 |
goldenset-hard/ |
표기 변형과 오탐 함정 | 재현율 1.000 · 정밀도 1.000 |
goldenset-gaps/ |
못 잡는다고 선언한 것들. 낮은 것이 정상 | 재현율 0.765 |
goldenset-hard/ 는 도입 당시 0.684 / 0.765 였습니다. 그 자리가 드러낸 결함 넷을
고쳐 지금 수치가 됐습니다. goldenset-gaps/ 는 0.235에서 시작했습니다 — 문맥 앵커
규칙으로 계좌·여권·면허·이름과 좁혀 뒀던 관문 셋을 되찾아 0.765가 됐고, 남은 것은
자유서술 속 이름과 주소입니다. gaps 항목이 hard 로 옮겨가는 것이 곧 개선 기록입니다.
sumunjang report # 세 셋을 나란히 채점
이 수치는 독립적인 성능 증명이 아니라 회귀를 감시하는 기준선입니다. 골든셋을 만든 사람이 도구도 만들었습니다.
한계
정직하게 적습니다.
- 자유서술 속 이름과 주소는 못 잡습니다. 이름은
성명: 최윤서같은 구조화된 표기만 잡습니다.어제 김수현 책임이랑 통화했는데는 앵커가 없어 규칙으로 판별할 수 없습니다. 주소는 아예 다루지 않습니다 — 어디서 끝나는지를 규칙으로 정할 수 없습니다. 둘 다goldenset-gaps/G2에 정답으로 박아 두고 0점으로 공표합니다. - 앵커 없는 계좌·여권·면허번호도 못 잡습니다. 검증식이 없거나(계좌) 공개되지
않아서(면허 검사번호), 앞에 붙은 말이 유일한 근거입니다.
정산 항목 110-234-567890처럼 앵커 없이 놓인 숫자는 계좌번호로 보지 않습니다. - 관문을 좁힌 대가가 있습니다. 앵커 없이 붙여 쓴 2020년 10월 이후 주민등록번호와,
앵커 없이 붙여 쓴 국내전용 카드는 놓칩니다. 위 표의
gaps셋에 들어 있습니다. - 이름 탐지에는 남은 오탐 여지가 있습니다.
담당: 배송팀처럼 성씨로 시작하는 3자 낱말이키: 값자리에 오면 이름으로 읽힙니다. 개인정보가 아닌 값을 가리는 쪽이 이름을 흘리는 쪽보다 낫다고 보고 이 방향을 택했습니다. - Luhn은 오탐을 완전히 막지 못합니다. 전치·오타 검출용이라
1111-2222-3333-4444같은 규칙적 반복도 통과합니다. 카드사 식별번호 대역으로 한 겹 더 거르지만 그 안에서는 여전히 통과합니다. - 모델이 가명 표시를 값이 아니라 설명 대상으로 언급하면 복원이 문장을 왜곡합니다.
모델이 "마스킹 표시(
[주민등록번호_1])가 붙어 있었다"고 메타 언급하면 그 자리도 원문으로 되돌아갑니다. 값으로 쓴 것인지 표시를 설명한 것인지 구분하려면 문맥 판단이 필요해 현재 구조로는 완전히 풀 수 없습니다. - 현재 Anthropic Messages API(
/v1/messages)만 지원합니다.
개발
uv venv && uv pip install -e ".[dev]"
uv run pytest
테스트는 API 키도 네트워크도 없이 돕니다. 업스트림은 저장소에 포함된 에코 서버를 실제 ASGI 앱으로 띄워 검증하므로, 누구나 같은 결과를 재현할 수 있습니다.
tests/test_leak.py 는 다른 테스트와 성격이 다릅니다. 카테고리도 좌표도 묻지 않고
"골든셋의 정답 값이 마스킹 결과에 부분 문자열로도 남아 있지 않은가" 하나만 검사합니다.
재현율·정밀도는 스팬이 정확히 일치했는지를 세므로 "절반만 가렸다"는 잡아도 남은 절반이
평문으로 나갔다는 사실 자체는 드러내지 못합니다. 실제로 그 틈으로 결함이 하나 빠져나간
적이 있습니다.
라이선스
Apache License 2.0
2026 오픈소스 개발자대회 출품작 (팀 문지기)
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sumunjang-0.1.0.tar.gz.
File metadata
- Download URL: sumunjang-0.1.0.tar.gz
- Upload date:
- Size: 49.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
97bd066b775b96d10248a6f24af7ebee96ab6062178761d91d3c05080d57f123
|
|
| MD5 |
19a8fc17228c90483ede7337ff95c69a
|
|
| BLAKE2b-256 |
5ee9855aa16f50b2ea48c5084c8b767d19b6a6591fd87350a5b5641db55855dd
|
File details
Details for the file sumunjang-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sumunjang-0.1.0-py3-none-any.whl
- Upload date:
- Size: 42.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e1fb84a4bd7cff4a28ba29bcb00e64f40c31b52d0fd7ddb9751c2b5bc7cb7c33
|
|
| MD5 |
6aef161f60866e80e0ee20120569cae7
|
|
| BLAKE2b-256 |
4dc77bded0081b405877688e19f8b1fd10b6be1a48be28004830a43498531eb9
|