고객 문의나 상담 기록을 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-3456 | 4자리 × 4, 구분자는 – 또는 공백 또는 없음 |
| 주민번호 | 900101-1234567 | 앞 6자리는 월(01~12)·일(01~31) 범위, 뒤 첫 자리 1~4 |
| 전화 | 010-1234-5678, 02-345-6789 | 01x 또는 지역번호로 시작, 구분자 – . 공백 |
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에 넣어도 되는지는 회사 보안 정책을 먼저 확인해야 합니다.
마무리
보내기 전에 가리고, 답이 오면 되돌린다. 핵심은 숫자 경계가 있는 정규식, 토큰-원래 값 표, 그리고 정규식 한 번으로 되돌리기입니다.
가장 작은 첫걸음은 이렇습니다.
- 위
mask.py를 저장하고, AI에 넣으려던 문서를 텍스트 파일로 만들어python -X utf8 mask.py 파일.txt로 돌려 봅니다. - “가린 항목” 목록과 결과 본문을 훑어보고, 이름처럼 남은 개인정보가 없는지 확인합니다.
- 자주 쓰는 형식(사번, 계좌번호 등)이 있으면 PATTERNS에 한 줄씩 추가합니다. 추가할 때마다 날짜·금액이 잘못 잡히지 않는지 샘플로 꼭 다시 돌려 보세요.
다음 글에서는 반대 방향, AI 답변에 들어 있는 마크다운 표를 CSV·엑셀 파일로 바꾸는 스크립트를 직접 돌려 보겠습니다.