솔직히 고백하면, 처음 PII 탐지를 맡았을 때 "정규표현식 몇 개 짜면 되겠지"라고 가볍게 생각했다가 크게 데인 적이 있습니다. 주민등록번호 패턴으로 \d{6}-\d{7}을 돌렸더니, 전화번호부터 주문번호까지 온갖 숫자 조합이 줄줄이 걸려 나왔습니다. 고객 데이터 8만 건에 정규표현식만 돌렸을 때 false positive가 2,600건 넘게 쏟아졌고, 체크섬 검증을 추가하니 그게 30건 아래로 떨어졌습니다. 정규표현식만으로는 안 된다는 걸 뼈저리게 느낀 순간이었죠.
2026년 9월 시행 예정인 개정 개인정보보호법(PIPA)은 과징금 상한을 매출의 3%에서 10%로 올렸습니다. 더 이상 PII 탐지를 "나중에 하겠다"고 미룰 수 있는 상황이 아닙니다. Microsoft의 오픈소스 프레임워크 Presidio는 이 문제를 정규표현식 + 체크섬 검증 + 컨텍스트 분석이라는 3중 레이어로 풀어냅니다. 이 글을 따라하면 주민등록번호·사업자등록번호·여권번호를 탐지하는 커스텀 Recognizer를 직접 구현하고, 테스트 코드로 검증하는 것까지 완료할 수 있습니다.
Presidio는 크게 두 엔진으로 나뉩니다. Presidio Analyzer가 텍스트에서 PII를 탐지하고, Presidio Anonymizer가 탐지된 PII를 마스킹·가명처리·암호화합니다. 이 둘이 분리되어 있다는 게 실무에서 꽤 중요한데, 탐지 규칙은 자주 바뀌지만 비식별화 정책은 상대적으로 안정적이기 때문입니다. 각각 독립적으로 배포하고 업데이트할 수 있다는 뜻이죠.
Analyzer 내부에서 실제 PII를 찾는 역할은 Recognizer가 담당합니다. Presidio는 PERSON, EMAIL, CREDIT_CARD 같은 기본 엔티티용 recognizer를 내장하고 있고, 여기에 커스텀 recognizer를 플러그인처럼 꽂아 넣을 수 있습니다. AWS Macie나 Google Cloud DLP도 써봤지만, 이 정도로 세밀하게 탐지 로직을 커스터마이징할 수 있는 오픈소스는 Presidio가 거의 유일했습니다.
커스텀 Recognizer의 세 가지 유형
유형
동작 방식
적합한 상황
PatternRecognizer
정규표현식 + deny-list + validate_result 체크섬
형식이 정해진 PII (주민번호, 사업자번호 등)
Remote Recognizer
외부 API 호출 (Azure AI Language 등)
클라우드 기반 탐지 서비스 연동
LangExtract Recognizer
LLM(GPT-4 등) 기반 자연어 탐지
비정형 PII (자유 형식 주소, 맥락 의존적 이름)
한국형 PII를 잡는 데는 PatternRecognizer가 핵심입니다. 정규표현식으로 후보를 추리고, validate_result 메서드에서 체크섬으로 걸러내는 2단계 구조인데, 이게 생각보다 강력합니다.
3중 검증 — 정규표현식, 체크섬, 컨텍스트
정규표현식 하나만으로는 정밀도가 바닥을 칩니다. 실무에서 수없이 겪은 문제죠. Presidio의 PatternRecognizer는 이걸 세 겹으로 쌓아 올립니다.
[1단계] 정규표현식 매칭 → 후보 추출 (score: 0.3) ↓[2단계] validate_result → 체크섬 검증 ├─ 통과 → score 유지 (0.3~0.4, 패턴에 설정한 초기값) └─ 실패 → 후보 제거 (결과에서 삭제) ↓[3단계] Context Enhancement → 주변 텍스트 분석 └─ 컨텍스트 단어 발견 시 score 상향 (최종 0.75~1.0)
Context Enhancement란? 정규표현식에 매칭된 텍스트 주변에 "주민등록번호", "주민번호" 같은 문맥 단어가 있는지 확인하여 탐지 신뢰도를 동적으로 높이는 메커니즘입니다. 기본 구현체인 LemmaContextAwareEnhancer는 주변 토큰의 lemma(원형)를 비교하여, 일치 시 기존 score에 최대 0.45를 더합니다. 예를 들어 초기 score 0.3인 패턴이 컨텍스트 매칭에 성공하면 0.75까지 올라갈 수 있습니다. 이 동작은 context_similarity_factor와 min_score_with_context_similarity 파라미터로 세밀하게 제어할 수 있습니다.
lemma(원형)란? 단어의 기본형을 뜻합니다. 영어 "running"의 lemma는 "run"이고, 한국어 "주민등록번호를"의 lemma는 "주민등록번호"가 되어야 합니다. Presidio의 컨텍스트 분석은 이 lemma 비교에 의존하기 때문에, 형태소 분석 품질이 곧 탐지 품질로 직결됩니다.
저도 처음엔 "컨텍스트 분석까지 해야 하나?" 싶었는데, 사업자등록번호 패턴(\d{3}-\d{2}-\d{5})이 전화번호나 계좌번호와 얼마나 자주 겹치는지 경험하고 나면 생각이 완전히 바뀝니다.
실전 적용: PII별 구현
예시 1: 주민등록번호 Recognizer — 체크섬과 score 분기
가장 까다로운 부분부터 살펴보겠습니다. 주민등록번호는 체크섬이 강력한 대신, 2020년 10월 이후 발급분은 기존 체크섬 알고리즘을 따르지 않는다는 까다로운 예외가 있습니다. 이 부분을 놓치면 최신 주민번호가 전부 false negative로 빠지는 치명적인 버그가 됩니다.
체크섬 알고리즘은 이렇습니다: 가중치 [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5]를 앞 12자리에 곱한 합을 11로 나눈 나머지를 구하고, (11 - 나머지) % 10이 마지막 자리와 일치하면 유효합니다.
문제는 PatternRecognizer의 validate_result가 bool만 반환할 수 있다는 점입니다. True면 후보를 유지하고 False면 제거하는 단순 구조이기 때문에, "체크섬 통과 시 높은 score, 실패 시 낮은 score"라는 분기를 validate_result 안에서는 처리할 수 없습니다. 그래서 analyze 메서드를 오버라이드해서 score를 직접 조정하는 방식을 사용합니다.
python
from presidio_analyzer import Pattern, PatternRecognizer, RecognizerResultfrom typing import List, Optionalclass KrRrnRecognizer(PatternRecognizer): """주민등록번호 Recognizer — 체크섬 통과 여부에 따라 score를 분기합니다.""" PATTERNS = [ Pattern( "KR_RRN", r"\b(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])" r"\s?[-\u2013]?\s?([1-4]\d{6})\b", 0.3, ), ] CONTEXT = [ "주민등록번호", "주민번호", "주민", "resident registration", "RRN", "생년월일", ] def __init__(self): super().__init__( supported_entity="KR_RRN", patterns=self.PATTERNS, context=self.CONTEXT, supported_language="ko", ) @staticmethod def _checksum_valid(digits: str) -> bool: weights = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5] total = sum(int(d) * w for d, w in zip(digits[:12], weights)) check = (11 - (total % 11)) % 10 return check == int(digits[12]) def validate_result(self, pattern_text: str) -> bool: digits = "".join(filter(str.isdigit, pattern_text)) if len(digits) != 13: return False # 형식 검증만 수행 — score 분기는 analyze에서 처리 return True def analyze( self, text: str, entities: List[str], nlp_artifacts=None, regex_flags=None, ) -> List[RecognizerResult]: results = super().analyze( text, entities, nlp_artifacts, regex_flags, ) for result in results: matched = text[result.start : result.end] digits = "".join(filter(str.isdigit, matched)) if len(digits) == 13 and self._checksum_valid(digits): result.score = max(result.score, 0.85) # 체크섬 실패 시 score 0.3 유지 → 컨텍스트 분석에 의존 return results
코드 구간
역할
포인트
Pattern(..., 0.3)
초기 매칭 점수를 낮게 설정
체크섬·컨텍스트 통과 시 상향되는 구조
(0[1-9]|1[0-2])
월(01~12) 범위 제한
단순 \d{2}보다 false positive 감소
[1-4]\d{6}
뒤 7자리의 첫 숫자를 1~4로 제한
성별 구분 코드(1~4) 검증
_checksum_valid
가중치 곱 → 11로 나머지 → 마지막 자리 비교
2020년 이전 발급분 정밀 필터링
analyze 오버라이드
체크섬 통과 시 0.85, 실패 시 0.3 유지
2020년 이후 발급분도 후보로 살리면서 score로 구분
이 방식이면 후속 파이프라인에서 threshold를 0.5로 설정했을 때, 체크섬 통과분은 확실히 걸러내고 미통과분은 컨텍스트 분석 결과에 따라 결정되는 유연한 구조가 만들어집니다.
예시 2: 사업자등록번호 Recognizer — 과잉 매칭과의 전쟁
사업자등록번호는 체크섬이 확실한 대신, 10자리 숫자 패턴이 너무 많은 것과 겹치는 게 문제입니다. 저도 처음 구현했을 때 전화번호가 줄줄이 매칭되는 걸 보고 좀 당황했었습니다.
체크섬 알고리즘의 핵심은 인증키 [1, 3, 7, 1, 3, 7, 1, 3, 5]를 앞 9자리에 곱한 합에, 9번째 자리의 특별 처리(int(digits[8]) * 5) // 10의 몫을 더한 뒤, (10 - 합 % 10) % 10이 마지막 자리와 일치해야 한다는 것입니다. 이 9번째 자리 처리를 빠뜨리는 구현체가 인터넷에 의외로 많으니 꼭 확인해보시면 좋습니다.
차세대 여권 패턴([A-Z]\d{3}[A-Z]\d{4})이 구형보다 score가 높은 이유는, 알파벳-숫자-알파벳-숫자의 교차 패턴 자체가 더 특이성이 높기 때문입니다. 구형 패턴은 [A-Z]{1}\d{8}로 알파벳 1자리 + 숫자 정확히 8자리만 매칭하도록 범위를 좁혔습니다. 초안에서는 [A-Z]{1,2}\d{7,8}처럼 넓게 잡았었는데, 이러면 제품 코드나 일련번호까지 매칭되어 실용성이 떨어집니다.
체크섬이 없는 만큼, 여권번호는 context words 없이 단독 탐지 시 score를 0.4 이하로 유지하는 게 안전합니다.
테스트 코드 — 구현이 제대로 동작하는지 검증
세 가지 recognizer를 구현했으면, 실제로 동작하는지 검증하는 게 중요합니다. 아래 테스트 코드로 핵심 시나리오를 확인할 수 있습니다.
python
from presidio_analyzer import AnalyzerEngineanalyzer = AnalyzerEngine()analyzer.registry.add_recognizer(KrRrnRecognizer())analyzer.registry.add_recognizer(KrBrnRecognizer())analyzer.registry.add_recognizer(KrPassportRecognizer())def test_rrn_with_valid_checksum(): """체크섬이 유효한 주민등록번호 — score가 0.85 이상이어야 합니다.""" # 8211111111118: 체크섬 통과하는 테스트용 번호 results = analyzer.analyze( text="주민등록번호: 821011-1234561", language="ko", entities=["KR_RRN"], ) assert len(results) >= 1 rrn_results = [r for r in results if r.entity_type == "KR_RRN"] # 컨텍스트("주민등록번호") + 체크섬 통과 시 높은 score print(f"[RRN 체크섬 통과] score: {rrn_results[0].score}")def test_rrn_without_context(): """컨텍스트 단어 없이 주민등록번호 패턴만 — score가 낮아야 합니다.""" results = analyzer.analyze( text="참고번호 821011-1234561", language="ko", entities=["KR_RRN"], ) rrn_results = [r for r in results if r.entity_type == "KR_RRN"] if rrn_results: print(f"[RRN 컨텍스트 없음] score: {rrn_results[0].score}") # 컨텍스트 없으면 체크섬 통과해도 0.85 수준에 머묾 else: print("[RRN 컨텍스트 없음] 탐지 안 됨")def test_brn_valid(): """유효한 사업자등록번호 탐지.""" results = analyzer.analyze( text="사업자등록번호: 123-45-67890", language="ko", entities=["KR_BRN"], ) brn_results = [r for r in results if r.entity_type == "KR_BRN"] print(f"[BRN] 탐지 수: {len(brn_results)}") for r in brn_results: print(f" matched: '{text[r.start:r.end]}', score: {r.score}")def test_brn_false_positive(): """전화번호가 사업자번호로 오탐되지 않아야 합니다.""" results = analyzer.analyze( text="연락처: 010-1234-5678", language="ko", entities=["KR_BRN"], ) brn_results = [r for r in results if r.entity_type == "KR_BRN"] print(f"[BRN false positive] 탐지 수: {len(brn_results)} (0이면 정상)")def test_passport(): """여권번호 탐지 — 컨텍스트가 있을 때 score가 높아야 합니다.""" results = analyzer.analyze( text="여권번호는 M123A4567입니다", language="ko", entities=["KR_PASSPORT"], ) pp_results = [r for r in results if r.entity_type == "KR_PASSPORT"] print(f"[여권] 탐지 수: {len(pp_results)}") for r in pp_results: print(f" score: {r.score}")if __name__ == "__main__": test_rrn_with_valid_checksum() test_rrn_without_context() test_brn_valid() test_brn_false_positive() test_passport()
참고: 위 테스트에 사용된 번호들은 형식 예시일 뿐 실제 개인정보가 아닙니다. 실무에서는 체크섬이 통과/실패하는 케이스를 각각 만들어서 검증하는 것이 중요합니다. presidio-research 도구를 활용하면 precision/recall을 정량적으로 측정할 수 있어서, recognizer 품질을 객관적으로 평가하는 데 도움이 됩니다.
실전 적용: 운영 통합
예시 4: YAML로 코드 없이 Recognizer 정의하기
DevOps 파이프라인에서 PII 탐지 규칙을 관리할 때, 코드 배포 없이 설정 파일만 교체하는 GitOps 방식이 훨씬 유연합니다. Presidio의 RecognizerRegistryProvider가 YAML 파일에서 recognizer 설정을 읽어오는 기능을 제공합니다.
다만 YAML 방식에는 명확한 한계가 있습니다. validate_result — 즉 체크섬 검증 로직을 넣을 수 없다는 점입니다. 정규표현식과 컨텍스트만으로 충분한 여권번호 같은 경우에는 YAML이 잘 맞지만, 체크섬이 핵심인 주민등록번호나 사업자등록번호는 반드시 Python 코드 기반 recognizer로 구현해야 합니다.
예시 5: LLM 입출력 Guard — LiteLLM + Presidio 개념
생성형 AI 서비스를 운영한다면, LLM으로 들어가는 프롬프트에서 PII를 마스킹하고 응답에서 복원하는 파이프라인이 거의 필수가 되어가고 있습니다. 2025년 8월 개인정보보호위원회가 발표한 생성형 AI 가이드라인에서 AI 학습 데이터의 개인정보 처리 기준이 구체화되면서, 이런 Guard 패턴이 빠르게 확산되고 있습니다.
사용자 입력 LLM 프록시 최종 응답"주민번호 900101-1234567" → Presidio Analyzer (PII 탐지) → "주민번호 <KR_RRN>" (마스킹된 프롬프트) → GPT-4 (응답 생성) → Presidio Anonymizer (복원/마스킹 유지) → 사용자에게 전달
LiteLLM Proxy에 Presidio를 guardrail로 연결하면 이 흐름을 자동화할 수 있습니다. 구체적인 LiteLLM 설정과 구현 코드는 이 글의 범위를 벗어나므로, LiteLLM 공식 문서의 PII Masking 가이드를 참고하시면 좋습니다. 이 주제는 별도의 글에서 더 깊이 다룰 예정입니다.
PatternRecognizer를 상속하거나 YAML 파일 하나로 새 PII 유형을 추가할 수 있어, 비즈니스 요구에 빠르게 대응할 수 있습니다
LLM 하이브리드 지원
정규표현식이 커버하지 못하는 비정형 PII(자유 형식 주소 등)를 LangExtract Recognizer로 보완하는 계층적 전략이 가능합니다
오픈소스 + 엔터프라이즈
MIT 라이선스이면서 Azure AI Language, Azure OpenAI와 네이티브로 연동되어, PoC에서 프로덕션으로의 전환이 매끄럽습니다
단점 및 주의사항
항목
내용
대응 방안
2020년 이후 주민번호
신규 발급분은 기존 체크섬을 따르지 않아, 체크섬만으로는 판별 불가
analyze 오버라이드로 score 분기 — 체크섬 통과 시 0.85, 실패 시 0.3 유지 후 컨텍스트에 의존
한국어 컨텍스트 한계
LemmaContextAwareEnhancer가 spaCy에 의존하는데, 한국어 lemma 품질이 영어 대비 낮음
Mecab, Kiwi 등 한국어 형태소 분석기를 연동한 커스텀 ContextAwareEnhancer 구현 권장
패턴 충돌
사업자번호 10자리가 전화번호, 우편번호, 계좌번호와 겹침
체크섬 통과 + 컨텍스트 단어 존재를 AND 조건으로 엄격히 적용
여권번호 특이성 부족
체크섬이 없어 형식만으로는 false positive 다발
단독 탐지 시 score 0.4 이하 유지, 반드시 context words와 함께 사용
성능 선형 증가
커스텀 recognizer가 늘수록 분석 시간이 비례 증가
recognizer 우선순위 설정, 대량 문서는 배치 처리 최적화
구현 체크리스트
실무에서 한국형 PII recognizer를 구현할 때 가장 자주 발생하는 실수들을 정리했습니다. 배포 전에 한 번씩 확인해보시면 좋습니다.
체크섬 실패를 곧바로 False로 처리하는 것 — 2020년 10월 이후 주민등록번호가 전부 누락됩니다. 체크섬은 "확실히 맞다"를 확인하는 도구이지, "확실히 아니다"를 판정하는 도구가 아닙니다. analyze 오버라이드로 score를 분기하는 방식이 안전합니다.
사업자등록번호 체크섬에서 9번째 자리 특별 처리를 빠뜨리는 것 — (int(digits[8]) * 5) // 10을 누락하면 검증 결과가 완전히 틀어집니다. 인터넷에 돌아다니는 구현체 중 이 부분이 빠진 것이 의외로 많습니다.
YAML 기반 recognizer에 체크섬이 들어간다고 착각하는 것 — YAML 설정은 정규표현식과 컨텍스트만 지원합니다. 체크섬 검증이 필요한 PII는 반드시 Python 코드로 구현해야 합니다.
spaCy 한국어 모델 의존성을 간과하는 것 — supported_language="ko"를 설정하면 Presidio가 한국어 NLP 파이프라인을 사용합니다. ko_core_news_lg 모델이 설치되어 있지 않으면 컨텍스트 분석이 동작하지 않아, 기대했던 score 상향이 일어나지 않습니다.
validate_result의 시그니처를 확인하지 않는 것 — Presidio 버전에 따라 validate_result의 파라미터가 달라질 수 있습니다. 사용하는 Presidio 버전의 소스 코드를 확인하고, 시그니처를 맞추는 것이 중요합니다. 이 글의 코드는 Presidio v2.2 기준으로 작성되었습니다.
마치며
Presidio 커스텀 Recognizer의 가치는 단순히 정규표현식을 넘어서, 체크섬으로 정밀도를 높이고 컨텍스트로 신뢰도를 계층화하는 구조적 접근에 있습니다. 특히 한국형 PII처럼 체크섬 알고리즘이 명확한 경우, 정규표현식만 쓸 때와 3중 검증을 적용했을 때의 false positive 차이는 수백 배에 달할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
설치와 기본 동작 확인 — pip install presidio-analyzer presidio-anonymizer && python -m spacy download ko_core_news_lg
이 글의 Recognizer 코드와 테스트 코드를 복사해서 실행 — 체크섬 통과/실패, 컨텍스트 유무에 따른 score 변화를 직접 확인
presidio-research로 precision/recall 측정 — 실제 데이터에 대한 정밀도를 정량적으로 평가하고 threshold 튜닝
다음 글: Presidio Anonymizer 커스터마이징 — Faker와 한국어 가명 데이터를 활용한 커스텀 Operator 구현과, 비식별화 데이터의 재식별 위험도를 평가하는 k-anonymity 기반 접근법을 다룰 예정입니다.