korean-pii-gateway 개발기

LLM에 보내기 전 한국어 개인정보를 걸러내는 프록시를 만들어 PyPI에 올렸습니다. 오탐을 줄인 방법과 토큰 없는 배포까지.

korean-pii-gateway 개발기

사내 문서를 요약하려고 LLM에 붙여넣습니다. 그 문서에 고객 전화번호나 주민등록번호가 섞여 있다면, 그대로 외부 API로 나갑니다.

붙여넣는 사람이 매번 눈으로 걸러내길 기대할 수는 없습니다. 사람이 실수하지 않도록 교육하는 것보다, 실수해도 새어 나가지 않는 길목을 만드는 편이 확실합니다.

그래서 LLM으로 나가는 요청을 검사하는 게이트웨이를 만들었습니다. 코드는 GitHub에 있고 PyPI에도 올려뒀습니다.

이 글에서는 왜 base_url 한 줄만 바꾸는 구조로 만들었는지, 탐지율보다 오탐을 먼저 잡은 이유는 무엇인지, 그리고 API 토큰 없이 PyPI에 배포하는 방법을 정리합니다.

pip install korean-pii-gateway

도입 비용이 0에 가까워야 한다

가장 먼저 정한 제약은 기존 코드를 고치지 않아도 된다는 것이었습니다. 아무리 좋은 필터도 애플리케이션을 전부 수정해야 한다면 도입되지 않습니다.

그래서 OpenAI 호환 프록시 형태로 만들었습니다. 클라이언트는 주소 한 줄만 바꾸면 됩니다.

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8500/v1",   # 이 줄만 바꿉니다
    api_key="...",
)

요청이 게이트웨이를 지나가면서 메시지 본문이 검사되고, 개인정보가 있으면 마스킹되거나 차단된 뒤 업스트림으로 전달됩니다. SDK도 프롬프트도 나머지 코드도 그대로입니다.

패키지는 두 개로 나눴습니다.

  • korean-pii: 탐지·마스킹 엔진. 외부 의존성이 하나도 없습니다
  • korean-pii-gateway: 위 엔진을 사용하는 FastAPI 프록시

엔진에 의존성을 두지 않은 건 의도한 선택입니다. 개인정보를 다루는 코드에 서드파티 패키지가 딸려오면 그만큼 공급망 검토 대상이 늘어납니다. 정규식과 표준 라이브러리만으로 충분한 일이었습니다.

진짜 어려운 건 탐지가 아니라 오탐입니다

주민등록번호를 찾는 정규식은 5분이면 씁니다. 문제는 그 정규식이 주민번호가 아닌 것도 잔뜩 잡는다는 데 있습니다.

13자리 숫자는 세상에 흔합니다. 주문번호, 로그 ID, 타임스탬프, 상품 코드가 전부 후보가 됩니다. 이걸 다 개인정보로 판정해 차단하면 사용자는 며칠 만에 게이트웨이를 꺼버립니다. 오탐이 많은 보안 도구는 결국 우회됩니다.

그래서 탐지기마다 검증 단계를 넣었습니다.

1. 주민등록번호 — 체크섬 검증

주민등록번호 마지막 자리는 검증용 숫자입니다. 앞 12자리에 정해진 가중치를 곱해 합을 구하면 마지막 자리가 나옵니다. 무작위 13자리가 이 검증을 통과할 확률은 약 1/11입니다.

여기에 규칙을 하나 더 뒀습니다.

하이픈이 없는 13자리 숫자는 체크섬이 맞아야만 개인정보로 판정합니다.

901231-1234567처럼 하이픈이 있으면 사람이 주민번호로 쓴 의도가 분명합니다. 반면 9012311234567은 우연한 숫자열일 가능성이 훨씬 높습니다. 형식이 애매할수록 검증을 더 요구하는 쪽이 실사용에서 덜 성가십니다.

생년월일이 실제 존재하는 날짜인지, 성별 자리가 유효한지도 함께 봅니다.

2. 카드번호 — Luhn 검증

신용카드 번호는 Luhn 알고리즘으로 검증할 수 있습니다. 16자리 숫자라고 다 카드번호는 아니므로 이 검증을 통과한 것만 잡습니다.

3. 회귀를 막는 261개의 테스트

전화번호·계좌번호·이메일과 API 키 같은 시크릿 패턴도 탐지합니다. 주변 단어로 판단하는 문맥 기반 탐지기도 따로 뒀습니다.

현재 261개의 테스트로 이 판정들을 고정해두고 있습니다. 탐지 규칙을 손볼 때 조용히 오탐이 늘어나는 것이 가장 위험한 회귀이기 때문입니다.

OpenAI와 Anthropic, 두 프로토콜을 지원하기

처음에는 OpenAI 형식만 지원했습니다. 그런데 Claude를 쓰는 경우 /v1/messages는 요청 구조가 다릅니다. 메시지 안에 system 블록, tool_result, tool_use.input, 문서 블록이 따로 있고 스트리밍 응답 형식도 다릅니다.

그래서 Anthropic 네이티브 경로를 별도로 지원하도록 했습니다. 검사 대상은 텍스트가 들어갈 수 있는 모든 위치로 넓히되, 이미지 같은 비텍스트 블록은 건드리지 않고 그대로 통과시킵니다.

에러도 각 프로토콜 형식에 맞춰 돌려줍니다. 클라이언트 SDK가 파싱할 수 있어야 하기 때문입니다. 프록시가 규격에 없는 에러를 뱉으면 애플리케이션 쪽에서 예외 처리가 꼬입니다.

API 토큰 없이 PyPI에 배포하기

배포는 GitHub Actions에서 Trusted Publishing으로 합니다. 태그를 푸시하면 워크플로가 돌고 PyPI에 자동으로 올라갑니다.

jobs:
  publish:
    environment: pypi
    permissions:
      id-token: write        # OIDC 토큰 발급 권한
    steps:
      - uses: pypa/gh-action-pypi-publish@release/v1

눈여겨볼 건 API 토큰이 어디에도 없다는 점입니다. GitHub Actions가 OIDC로 신원을 증명하면 PyPI가 그 신원을 보고 발행을 허용하는 방식입니다.

  • 저장소 시크릿에 장수명 토큰을 보관하지 않아도 됩니다
  • 토큰 유출 경로 자체가 사라집니다
  • 만료·교체 관리가 필요 없습니다

개인정보를 다루는 도구를 만들면서 정작 배포 파이프라인에 토큰을 심어두는 건 앞뒤가 맞지 않는다고 생각했습니다.

이 도구가 하지 못하는 것

README에도 적어뒀지만 반복할 가치가 있는 부분입니다.

이 게이트웨이는 보조 방어 계층이며, 어떤 법적·규제적 컴플라이언스도 보장하지 않습니다.

정규식 기반 탐지에는 원리적 한계가 있습니다. 이름이나 주소처럼 형식이 정해지지 않은 개인정보는 이 방식으로 안정적으로 잡을 수 없습니다. 게이트웨이를 통과했다고 해서 "개인정보가 없다"고 말할 수 없다는 뜻입니다.

그래서 이 도구의 위치는 분명합니다. 실수로 붙여넣는 흔한 사고를 줄이는 안전망이지, 규정 준수를 대신해주는 장치가 아닙니다. 보안 도구가 실제보다 강해 보이면 사람들이 그만큼 방심하기 때문에, 한계를 앞에 적어두는 편이 낫다고 판단했습니다.

정리하며

만들면서 가장 오래 고민한 건 탐지율이 아니라 실제로 켜둔 채로 쓸 수 있는가였습니다.

  • 도입은 한 줄이어야 합니다: 그렇지 않으면 도입 자체가 안 됩니다
  • 오탐이 적어야 합니다: 그렇지 않으면 사용자가 꺼버립니다
  • 한계를 정확히 말해야 합니다: 그렇지 않으면 잘못된 안심을 줍니다

코드와 설치 방법은 GitHub 저장소에 있습니다. Open WebUI 필터와 Claude Code 플러그인 형태로도 쓸 수 있게 해뒀습니다.