21장. AI 거버넌스와 보안, 온프레미스 인프라
개인정보 찾기와 가리기
병원 접수 창구는 진료 기록을 연구팀에 넘길 때 이름과 주민등록번호를 지우고 "환자 A"처럼 바꿉니다. 같은 환자의 기록은 늘 "환자 A"로 적어야 연구는 할 수 있습니다. 상담 기록을 LLM 에 보내거나 로그에 남길 때도 같은 일을 합니다.
1번에서 글 속의 전화번호, 이메일, 주민등록번호, 카드, 계좌를 찾습니다. 2번 마스킹은 [전화] 처럼 종류만 남기고 가립니다. 3번 가명 처리는 같은 값을 늘 같은 가짜 표시([전화_3f2a])로 바꿉니다. 4번에서 권한 있는 사람만 원래 값을 되찾습니다. "환자 A"가 가명 처리입니다.
개인정보의 꼴
| 종류 | 꼴 | 함정 |
|---|---|---|
| 전화 | 010-0000-1234, 01000001234, 010 0000 1234 | 하이픈 없는 것, 띄어 쓴 것 |
| 이메일 | user@example.com | 비교적 쉽다 |
| 주민등록번호 | 900101-1234567 | 주문번호 20261019-1835668 의 뒷부분과 닮았다 |
| 카드 | 4111-1111-1111-1111, 4012 8888 8888 1881 | 띄어 쓴 것 |
| 계좌 | 501-73-525265 | 은행마다 자리 수가 다르고 날짜(2026-10-22)와 닮았다 |
| 이름 | 김하늘 | 정규식으로는 거의 잡을 수 없다. 개체명 인식 모델이 필요하다 |
정규식으로 찾기
import re
memo = "주문번호 20261019-1835668, 연락처 010 0000 4487, 생년 900101-1234567"
naive = re.compile(r"\d{6}-\d{7}") # 주민번호처럼 생긴 것
careful = re.compile(r"(?<!\d)\d{6}-[1-4]\d{6}(?!\d)") # 앞뒤에 숫자가 붙지 않고, 뒷자리 첫 숫자가 1~4
# 결과: 경계를 두지 않으면 주문번호의 뒷부분까지 주민번호로 잡는다
print("단순:", naive.findall(memo))
print("경계:", careful.findall(memo))코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
\d | digit(숫자)의 줄임말이다. 0~9 가운데 한 글자다 |
{6}, {7} | 바로 앞 꼴이 정확히 6번, 7번 이어진다 |
[1-4] | 1, 2, 3, 4 가운데 한 글자다 |
(?<!\d) | negative lookbehind(뒤돌아보아 아니기)다. 바로 앞 글자가 숫자가 아닐 때만 맞는다. 그 글자는 결과에 넣지 않는다 |
(?!\d) | negative lookahead(앞을 보아 아니기)다. 바로 뒤 글자가 숫자가 아닐 때만 맞는다 |
re.findall(...) | 맞는 자리를 모두 찾아 목록으로 돌려준다 |
이 코드가 뜻하는 것
주차장의 번호판 인식 카메라를 떠올려 봅니다. "숫자 여섯-숫자 일곱"처럼 생긴 것만 찾으면, 옆 트럭에 적힌 긴 전화번호의 끝부분까지 번호판으로 읽습니다. 그래서 번호판 테두리, 곧 앞뒤에 다른 숫자가 붙어 있지 않은지도 함께 봅니다. 여기서 번호판 꼴이 \d{6}-\d{7}, 테두리 확인이 (?<!\d) 와 (?!\d) 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
memo = ... | 주문번호, 연락처, 생년(모두 가짜)이 섞인 메모 | 여러 숫자가 적힌 주차장 |
naive = ... | 꼴만 보는 정규식 | 테두리를 안 보는 카메라 |
careful = ... | 앞뒤 경계와 뒷자리 첫 숫자까지 보는 정규식 | 테두리를 보는 카메라 |
단순 정규식은 주문번호 20261019-1835668 에서 앞의 '20' 을 버리고 261019-1835668 을 주민번호로 잡았습니다. 경계를 둔 정규식은 900101-1234567 하나만 잡았습니다.
작게 줄여서 눈으로 보기
경계와 [1-4] 가 하나씩 무엇을 걸러 내는지, 지어낸 숫자 넷으로 봅니다.
import re
for s in ["900101-1234567", "20261019-1835668", "900101-9234567", "900101-12345678"]:
plain = re.findall(r"\d{6}-\d{7}", s)
edge = re.findall(r"(?<!\d)\d{6}-[1-4]\d{6}(?!\d)", s)
print(f"{s:17s} 단순={plain} 경계={edge}")900101-1234567 단순=['900101-1234567'] 경계=['900101-1234567']
20261019-1835668 단순=['261019-1835668'] 경계=[]
900101-9234567 단순=['900101-9234567'] 경계=[]
900101-12345678 단순=['900101-1234567'] 경계=[]| 결과 | 읽는 법 |
|---|---|
900101-1234567 | 꼴이 맞아 둘 다 잡는다 |
20261019-1835668 | 단순은 가운데부터 잡는다. 경계는 바로 앞에 숫자 '0' 이 붙어 있어 버린다 |
900101-9234567 | 뒷자리 첫 숫자가 9 라 [1-4] 에 맞지 않는다 |
900101-12345678 | 뒷자리가 8자리다. 단순은 앞 7자리만 떼어 잡고, 경계는 뒤에 숫자 '8' 이 붙어 있어 버린다 |
경계를 두면 "더 긴 숫자의 일부"를 잘못 잡는 일이 줄어듭니다. 그래도 정규식은 꼴만 보므로, 꼴이 같은 다른 번호는 여전히 잡을 수 있습니다.
주의할 점
- 이 장의 개인정보는 모두 지어낸 것입니다. 전화번호는 가운데를 0000 으로, 이메일은 example.com 으로, 카드는 결제 시험용으로 공개된 번호로 두었습니다. 실제 개인정보로 실습하지 않습니다.
- 개인정보는 받지 않는 것이 가장 안전합니다. 상담 화면에서 주민등록번호 칸을 없애는 것이 가리는 코드보다 낫습니다. 법(개인정보 보호법)이 정한 처리 기준도 확인합니다.