개인정보 마스킹 파이썬 — AI에 문서 넣기 전 전화·이메일·주민번호를 정규식으로 가리고 되돌리기 (실측)

고객 문의나 상담 기록을 ChatGPT·Claude 같은 AI에 붙여 넣고 “정리해 줘”라고 하면 편하지만, 그 안의 전화번호·이메일·주민번호까지 그대로 외부 서비스로 나갑니다. 이 글에서는 개인정보 마스킹 파이썬 스크립트를 표준 라이브러리만으로 만들어, 보내기 전에 개인정보를 [전화_1] 같은 토큰으로 바꾸고 AI 답변이 돌아오면 원래 값으로 되돌리는 과정을 직접 돌려 봅니다. 처음 실행에서는 16자리 주문번호가 카드번호로 잘못 분류됐고, 인터넷에 흔한 단순 전화번호 패턴은 7건 중 4건이 주민번호·카드번호의 조각이었습니다. 1만 건짜리 기록은 0.177초에 가려졌지만, 되돌리기를 무심코 replace 반복으로 짰더니 24.9초가 걸렸고 정규식 한 번으로 바꾸자 0.016초가 됐습니다. 환경은 윈도우 11, Python 3.12.10입니다.

무엇을 어떻게 가릴까 — 토큰으로 바꾸고 표를 남긴다

그냥 ***로 지우면 AI가 “1번 고객에게 연락하세요”처럼 대상을 구분하지 못합니다. 그래서 값마다 [전화_1], [이메일_2]처럼 종류와 번호가 붙은 토큰으로 바꾸고, 토큰과 원래 값을 짝지은 표를 내 컴퓨터에만 남깁니다. AI는 토큰만 보고 일을 하고, 답변 속 토큰은 표를 보고 원래 값으로 되돌립니다. 같은 전화번호가 여러 번 나오면 같은 토큰을 씁니다.

이번에 다룬 항목은 네 가지입니다.

종류예시 (가짜 값)패턴의 핵심
이메일hong.gd+shop@example.com@ 앞뒤 문자, 점으로 이어진 도메인
카드(16자리)1234-5678-9012-34564자리 × 4, 구분자는 – 또는 공백 또는 없음
주민번호900101-1234567앞 6자리는 월(01~12)·일(01~31) 범위, 뒤 첫 자리 1~4
전화010-1234-5678, 02-345-678901x 또는 지역번호로 시작, 구분자 – . 공백
import re

# 순서: 이메일 → 16자리(카드) → 주민번호 → 전화
PATTERNS = [
    ('이메일', re.compile(r'[\w.+-]+@[\w-]+(?:\.[\w-]+)+')),
    ('카드', re.compile(r'(?<!\d)\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}(?!\d)')),
    ('주민번호', re.compile(r'(?<!\d)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])[- ]?[1-4]\d{6}(?!\d)')),
    ('전화', re.compile(r'(?<!\d)(?:01[016789]|0[2-6]\d?)[- .]?\d{3,4}[- .]?\d{4}(?!\d)')),
]

모든 숫자 패턴의 앞뒤에 (?<!\d)와 (?!\d)를 붙인 게 가장 중요합니다. “앞뒤에 숫자가 붙어 있지 않을 때만”이라는 뜻인데, 이게 없으면 긴 숫자의 일부만 잘려 나가는 문제가 생깁니다. 뒤에서 실측으로 보여 드리겠습니다.

전체 스크립트

"""AI에 넣기 전 개인정보 가리기: 정규식 마스킹 + 되돌리기 (표준 라이브러리만)"""
import re
import sys

PATTERNS = [
    ('이메일', re.compile(r'[\w.+-]+@[\w-]+(?:\.[\w-]+)+')),
    ('카드', re.compile(r'(?<!\d)\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}(?!\d)')),
    ('주민번호', re.compile(r'(?<!\d)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])[- ]?[1-4]\d{6}(?!\d)')),
    ('전화', re.compile(r'(?<!\d)(?:01[016789]|0[2-6]\d?)[- .]?\d{3,4}[- .]?\d{4}(?!\d)')),
]


def luhn_ok(digits):
    """카드번호 체크섬(Luhn). 실제 카드번호는 통과하고, 주문번호 같은 숫자는 대부분 떨어진다"""
    total = 0
    for i, ch in enumerate(reversed(digits)):
        n = int(ch)
        if i % 2 == 1:
            n = n * 2 - 9 if n > 4 else n * 2
        total += n
    return total % 10 == 0


def mask(text):
    """개인정보를 [종류_번호] 토큰으로 바꾸고, 되돌릴 때 쓸 표를 함께 돌려준다"""
    table = {}
    seen = {}
    counts = {}
    for kind, pattern in PATTERNS:
        def repl(m):
            value = m.group(0)
            if value not in seen:
                label = kind
                if kind == '카드' and not luhn_ok(re.sub(r'\D', '', value)):
                    label = '긴번호'   # 카드는 아니어도 16자리 번호라 가리긴 한다
                counts[label] = counts.get(label, 0) + 1
                token = '[' + label + '_' + str(counts[label]) + ']'
                seen[value] = token
                table[token] = value
            return seen[value]
        text = pattern.sub(repl, text)
    return text, table


TOKEN = re.compile(r'\[[^\[\]_]+_\d+\]')


def unmask(text, table):
    """AI 답변 속 토큰을 원래 값으로 되돌린다 (표에 없는 토큰은 그대로 둔다)"""
    return TOKEN.sub(lambda m: table.get(m.group(0), m.group(0)), text)


if __name__ == '__main__':
    # 사용법: python mask.py 입력파일
    with open(sys.argv[1], encoding='utf-8') as f:
        original = f.read()
    masked, table = mask(original)
    print(masked)
    print('--- 가린 항목 ---')
    for token, value in table.items():
        print(token, '=', value)

mask()는 가린 텍스트와 되돌리기용 표를 함께 돌려주고, unmask()는 AI 답변에서 토큰 모양의 문자열을 찾아 표에 있으면 원래 값으로 바꿉니다. 표에 없는 토큰은 그대로 둡니다. luhn_ok()는 카드번호 검사용인데, 왜 넣었는지는 아래 첫 실행 결과에서 설명합니다.

첫 실행 — 주문번호가 카드번호로 잡혔다

가짜 고객 문의 3건을 담은 sample.txt를 만들었습니다. 일부러 날짜, 금액, 송장번호, 16자리 주문번호처럼 개인정보가 아닌 숫자도 섞었습니다.

[고객 문의 3건 정리 요청]
1) 홍길동 고객(010-1234-5678, hong.gd+shop@example.com)이 10월 28일 주문한 건 환불 요청.
   주민번호 900101-1234567 확인 완료. 결제 카드 1234-5678-9012-3456.
2) 김영희 고객 연락처 01098765432, 사무실 02-345-6789. 이메일 yh_kim@corp.example.co.kr
3) 박철수 고객 전화 010 2222 3333, 주문번호 2026103012345678, 접수일 2026-10-30.
   결제금액 1,250,000원, 송장번호 6543-2109-8765.

Luhn 검사를 넣기 전 첫 버전으로 돌린 결과입니다. 실행은 0.061초 걸렸습니다.

$ python -X utf8 mask.py sample.txt
[고객 문의 3건 정리 요청]
1) 홍길동 고객([전화_1], [이메일_1])이 10월 28일 주문한 건 환불 요청.
   주민번호 [주민번호_1] 확인 완료. 결제 카드 [카드_1].
2) 김영희 고객 연락처 [전화_2], 사무실 [전화_3]. 이메일 [이메일_2]
3) 박철수 고객 전화 [전화_4], 주문번호 [카드_2], 접수일 2026-10-30.
   결제금액 1,250,000원, 송장번호 6543-2109-8765.

--- 가린 항목 ---
[이메일_1] = hong.gd+shop@example.com
[이메일_2] = yh_kim@corp.example.co.kr
[카드_1] = 1234-5678-9012-3456
[카드_2] = 2026103012345678
[주민번호_1] = 900101-1234567
[전화_1] = 010-1234-5678
[전화_2] = 01098765432
[전화_3] = 02-345-6789
[전화_4] = 010 2222 3333

공백으로 띄운 010 2222 3333, 하이픈 없는 01098765432, 지역번호 02-345-6789, +가 들어간 이메일까지 다 잡았고, 날짜 2026-10-30, 금액 1,250,000원, 12자리 송장번호는 건드리지 않았습니다. 문제는 주문번호 2026103012345678이 [카드_2]로 분류된 것입니다. 16자리 숫자라서 카드 패턴에 그대로 맞았습니다. 그리고 결과에서 보듯 이름(홍길동, 김영희)은 그대로 남습니다. 정규식은 모양이 정해진 값만 가릴 수 있습니다.

카드번호는 Luhn 검사로 한 번 더 — 하지만 10%는 통과한다

실제 카드번호는 마지막 자리가 체크섬(Luhn 알고리즘)이라 계산이 맞아야 합니다. 몇 개를 직접 검사해 봤습니다.

[3] Luhn 검사:
    1234-5678-9012-3456 False
    2026103012345678 False
    4111-1111-1111-1111 True
    5500 0000 0000 0004 True

주문번호는 떨어졌고, 널리 쓰이는 테스트 카드번호 두 개는 통과했습니다. 그런데 제가 샘플에 대충 넣은 가짜 카드번호 1234-5678-9012-3456도 떨어졌습니다. Luhn을 통과하지 못하면 아예 안 가리게 짰다면, 체크섬이 틀린 번호는 그대로 AI에 나갔을 겁니다.

그래서 최종 스크립트는 Luhn 결과로 라벨만 바꾸고 가리는 건 그대로 했습니다. 통과하면 [카드_1], 아니면 [긴번호_1]입니다. 개인정보 도구는 몇 개 더 가리는 쪽이 놓치는 쪽보다 낫다고 판단했습니다. 실제로 1만 건 벤치마크에서 무작위 16자리 주문번호 10,000개 중 1,021개(약 10%)가 Luhn을 통과했습니다. 체크섬이 한 자리뿐이라 무작위 숫자도 10분의 1은 맞기 때문입니다. Luhn만으로 “이건 카드다, 아니다”를 확정할 수는 없다는 뜻입니다.

인터넷에 흔한 단순 패턴을 쓰면 생기는 일

검색하면 자주 나오는 전화번호 정규식 \d{2,3}-?\d{3,4}-?\d{4}를 앞뒤 경계 없이 같은 샘플에 돌려 봤습니다.

[1] 단순 전화 패턴이 잡은 것:
    010-1234-5678
    900101-1234
    234-5678-9012
    01098765432
    02-345-6789
    20261030123
    543-2109-8765

7건 중 진짜 전화번호는 3건뿐이었습니다. 900101-1234는 주민번호 앞부분, 234-5678-9012는 카드번호 가운데, 20261030123은 주문번호 앞 11자리, 543-2109-8765는 송장번호 일부입니다. 이대로 치환하면 주민번호 뒷자리 일부가 남는 식으로 반쯤 가려진 숫자가 AI에 나갑니다. 공백으로 띄운 010 2222 3333은 오히려 놓쳤습니다.

처음에는 “카드·주민번호를 전화보다 먼저 적용해야 조각이 안 생긴다”고 예상하고 적용 순서를 뒤집어 다시 돌려 봤는데, 경계 조건을 넣은 패턴에서는 순서를 바꿔도 가린 결과가 똑같았습니다. 순서보다 앞뒤 숫자 경계가 핵심이었습니다.

1만 건 실측 — 되돌리기를 replace로 짜면 25초

가짜 상담 기록 1만 줄(줄마다 전화·이메일·16자리 주문번호·금액, 약 84만 자)을 만들어 가리기와 되돌리기 시간을 쟀습니다. 처음 버전의 unmask()는 표의 토큰마다 text.replace(token, value)를 반복하는 단순한 방식이었습니다.

$ python -X utf8 bench.py
입력: 10000줄, 836715자
마스킹: 0.177초, 토큰 30000개 {'이메일': 10000, '긴번호': 8979, '카드': 1021, '전화': 10000}
정규식 한 번으로 되돌리기: 0.016초, 원문과 같음 True
토큰마다 replace 반복: 24.869초, 원문과 같음 True
단계1회차2회차
마스킹 (토큰 3만 개)0.177초0.163초
되돌리기: 토큰마다 replace 반복24.869초27.721초
되돌리기: 정규식 한 번 + 표 조회0.016초0.018초

가리기는 0.2초도 안 걸렸는데, replace 반복 되돌리기는 25초 안팎이었습니다. 토큰 3만 개마다 84만 자 문서 전체를 처음부터 다시 훑기 때문입니다. 토큰 모양([글자_숫자])을 정규식으로 한 번에 찾고 표에서 바로 값을 꺼내는 방식으로 바꾸자 0.016초, 약 1,500배 빨라졌습니다. 두 방식 모두 되돌린 결과가 원문과 완전히 같았습니다(True). 문서 몇 줄이면 차이를 못 느끼지만, 상담 기록을 통째로 처리할 때는 이 차이가 그대로 드러납니다.

AI 답변을 원래 값으로 되돌리기

실제 AI를 호출하지는 않고, 가린 문장에 대해 AI가 이렇게 답했다고 가정한 문장을 손으로 써서 되돌려 봤습니다. 마지막 [전화_2]는 일부러 표에 없는 번호로 넣었습니다.

AI에 보낼 문장: 김영희 고객([전화_1], [이메일_1]) 결제 카드 [카드_1] 로 환불 요청
AI 답변(가정): [전화_1]로 연락해 [카드_1] 결제 취소를 안내하고, 결과를 [이메일_1]로 보내세요. 참고: [전화_2]
되돌린 답변: 010-9876-5432로 연락해 4111-1111-1111-1111 결제 취소를 안내하고, 결과를 yh_kim@corp.example.co.kr로 보내세요. 참고: [전화_2]

표에 있는 토큰 세 개는 원래 값으로 돌아왔고, 표에 없는 [전화_2]는 그대로 남았습니다. 없는 값을 지어내 채우지 않으니, 되돌린 결과에 토큰이 남아 있으면 AI가 토큰을 새로 만들었다는 신호로 보고 확인하면 됩니다. 테스트 카드번호 4111-1111-1111-1111은 Luhn을 통과해서 [카드_1] 라벨이 붙었습니다.

주의할 점

  • 이름·주소는 못 가립니다. 실측에서 홍길동·김영희가 그대로 남았습니다. 고객 명단이 있다면 이름 목록을 따로 치환하거나, 보내기 전에 결과를 눈으로 한 번 확인하세요.
  • 숫자 패턴에는 앞뒤 경계를 꼭 넣으세요. 경계 없는 단순 패턴은 7건 중 4건이 다른 번호의 조각이었습니다.
  • Luhn은 걸러내는 용도가 아니라 라벨용으로. 무작위 16자리의 약 10%가 통과했고, 체크섬이 틀린 가짜·오타 번호는 떨어집니다.
  • 되돌리기 표는 내 컴퓨터에만. 표에는 원래 개인정보가 다 들어 있습니다. AI에 보내는 프롬프트에 표를 같이 붙이지 마세요.
  • 되돌리기는 정규식 한 번으로. replace 반복은 1만 건 기준 25초 안팎이었습니다.
  • 회사 규정이 우선입니다. 마스킹을 했더라도 사내 문서를 외부 AI에 넣어도 되는지는 회사 보안 정책을 먼저 확인해야 합니다.

마무리

보내기 전에 가리고, 답이 오면 되돌린다. 핵심은 숫자 경계가 있는 정규식, 토큰-원래 값 표, 그리고 정규식 한 번으로 되돌리기입니다.

가장 작은 첫걸음은 이렇습니다.

  1. 위 mask.py를 저장하고, AI에 넣으려던 문서를 텍스트 파일로 만들어 python -X utf8 mask.py 파일.txt로 돌려 봅니다.
  2. “가린 항목” 목록과 결과 본문을 훑어보고, 이름처럼 남은 개인정보가 없는지 확인합니다.
  3. 자주 쓰는 형식(사번, 계좌번호 등)이 있으면 PATTERNS에 한 줄씩 추가합니다. 추가할 때마다 날짜·금액이 잘못 잡히지 않는지 샘플로 꼭 다시 돌려 보세요.

다음 글에서는 반대 방향, AI 답변에 들어 있는 마크다운 표를 CSV·엑셀 파일로 바꾸는 스크립트를 직접 돌려 보겠습니다.

댓글 남기기