지난 글에서 출력 가드의 검사 항목 중 하나로 민감정보 마스킹을 짚었다. 그런데 개인정보만은 출력 가드에서 처리하면 대개 늦는다. 답이 만들어질 무렵이면 원문은 이미 여러 곳에 복사되어 있기 때문이다.
개인정보(PII)는 그 자체로, 혹은 다른 정보와 합쳐 특정한 사람을 식별할 수 있는 정보를 말한다. 이름·연락처·주소처럼 곧바로 사람을 가리키는 것이 있고, 생년월일·우편번호·성별처럼 하나로는 안 되지만 몇 개가 겹치면 개인이 좁혀지는 것이 있다. 뒤쪽을 준식별자라고 부르는데, 마스킹 설계에서 자주 빠지는 것이 이쪽이다.
그리고 마스킹은 그 정보를 원래 값 대신 다른 표시로 바꿔 두는 처리를 통틀어 부르는 말이다. 통째로 지우기도 하고, 일부만 별표로 덮기도 하고, 자리표로 바꿔 두었다가 나중에 되돌리기도 한다. 무엇을 고를지는 뒤에서 다룬다.
이 글은 그 처리를 어떻게 할 것인가보다 어디에 걸 것인가부터 시작한다. 순서를 그렇게 잡는 이유는 단순하다. 자리를 잘못 잡으면 아무리 좋은 탐지기를 붙여도 이미 새어 나간 뒤이고, 자리를 제대로 잡으면 탐지기가 어설퍼도 새는 양이 한 자리로 줄어든다. 마스킹에서 가장 큰 차이를 만드는 결정은 정규식의 품질이 아니라 그 정규식이 서 있는 위치다.
마스킹 지점
원문의 복사 경로
사용자가 이름과 전화번호가 든 문장을 보냈다고 하자. 그 문자열은 어디에 남는가. 한 번 세어 보면 대개 여덟 곳쯤 나온다.
- 웹 서버와 애플리케이션 접근 로그
- 요청 본문을 통째로 남기는 디버그 로그
- 프롬프트 전문을 저장하는 관측·트레이싱 도구
- 모델 공급자에게 보낸 요청 기록
- 앞부분을 재사용하는 프롬프트 캐시
- 문서를 색인해 둔 벡터 데이터베이스
- 품질 분석용으로 쌓는 대화 데이터셋
- 운영자가 보는 검토 화면
여기서 눈여겨볼 것은 목록의 길이가 아니라 이 저장소들이 서로 다른 팀의 것이라는 점이다. 접근 로그는 인프라 팀이 만들고, 트레이싱은 플랫폼 팀이 붙이고, 대화 데이터셋은 데이터 팀이 쌓고, 검토 화면은 운영 팀이 쓴다. 경로를 하나씩 막으려면 네 팀의 일정을 맞춰야 하고, 새 저장소가 생길 때마다 그 협의를 처음부터 다시 해야 한다. 여덟 곳을 여덟 번 막는 일은 한 번으로 끝나지 않고 조직이 커지는 속도만큼 계속 늘어난다.
입구와 출구
출력 가드는 이 목록에서 마지막 하나 앞에만 서 있다. 그래서 지우는 자리는 입구여야 한다. 입구에서 한 번 처리하면 뒤의 모든 저장소가 자동으로 깨끗해지고, 출구에서 처리하면 저 목록을 하나씩 따로 막아야 한다.
여기에 성질이 하나 더 있다. 로그와 캐시와 벡터 인덱스는 쓰기가 끝나는 순간 확정된다. 잘못 들어간 값을 나중에 찾아 지우려면 저장소마다 검색 방법이 다르고, 백업과 복제본까지 따라가야 하며, 이미 그 데이터를 읽어 간 하위 파이프라인은 손댈 방법이 없다. 로그를 지웠다고 해서 그 로그로 만든 대시보드와 데이터셋이 함께 지워지지는 않는다. 반면 입구에서 걸러진 값은 애초에 존재한 적이 없어서 지울 것도 없다.
그래서 자리를 고르는 기준은 하나로 정리된다. 되돌릴 수 없는 지점을 먼저 찾고, 그 앞에 마스킹을 세운다. 그 지점은 서비스마다 다르다. 요청 본문을 그대로 남기는 프레임워크를 쓴다면 되돌릴 수 없는 지점은 우리 비즈니스 로직보다 앞, 즉 미들웨어다. API 게이트웨이가 요청을 통째로 기록한다면 그 지점은 아예 애플리케이션 밖이고, 그러면 마스킹도 게이트웨이 안에 있어야 한다. 마스킹은 "우리 코드의 첫 줄"이 아니라 "이 문자열이 처음 디스크에 닿는 지점"보다 앞에 있어야 한다. 이 둘이 같은 자리라고 가정하는 데서 대부분의 사고가 시작된다.
입력 단계에 무엇을 더 세울 수 있는지는 입력 가드에서 따로 다뤘다. 개인정보 마스킹은 그중에서도 가장 앞에 서야 하는 항목이다. 정규화나 금칙어 검사보다 앞이다. 뒤쪽 검사가 실패해 예외를 던지면 그 예외 메시지에 원문이 실려 로그로 나가기 때문이다.
사후 재사용 경로
마스킹을 잘 만들어 두고도 새는 자리가 셋 있다.
벡터 데이터베이스. 문서를 색인할 때 원문 조각을 함께 저장하는 구성이 흔한데, 그 조각에 개인정보가 들어 있으면 검색 결과로 다시 흘러나온다. 색인 시점에도 같은 처리를 걸어야 한다. 색인은 하루에 한 번 배치로 도는 일이 많아 요청 경로와 코드가 완전히 갈라져 있고, 그래서 요청 경로의 마스킹이 아무리 촘촘해도 여기는 그대로 비어 있다.
모델 학습·개선 데이터. 대화 로그를 파인튜닝이나 평가 묶음으로 재사용할 때, 마스킹이 안 된 원본을 쓰면 개인정보가 모델 가중치나 평가 데이터에 남는다. 가중치에 들어간 값은 삭제 요청이 와도 지울 방법이 마땅치 않다. 학습 데이터에서 개인정보를 되뽑아내는 공격이 실제로 어떤 모양인지는 AI와 프라이버시에 정리해 두었다.
운영자 화면. 상담사나 관리자가 보는 대화 검토 화면은 대개 원문을 그대로 보여 준다. 사람이 봐야 하는 정보는 남기되, 필요 없는 항목은 이 화면에서도 가리고 누가 언제 봤는지 기록을 남긴다.
셋의 공통점은 요청이 끝난 뒤에 원문을 다시 쓰는 자리라는 것이다. 마스킹을 요청 처리 흐름 안에만 두면 이 셋은 구조적으로 빠진다. 요청 흐름의 입구를 아무리 단단히 막아도, 그 뒤에 남은 원본을 나중에 다시 읽는 코드가 따로 있으면 입구는 우회된다.
식별자와 준식별자
직접 식별자
이름·전화번호·이메일·주민등록번호·계좌번호·카드번호·주소·차량번호처럼 값 하나로 사람을 좁히는 것들이다. 형식이 정해진 항목이 많아 기계로 찾기 쉽고, 그래서 마스킹 설계는 대개 여기서 시작한다. 문제는 여기서 끝난다는 것이다. 직접 식별자 목록을 채우고 나면 일이 끝난 것 같은 느낌이 들지만, 실제로 사람이 특정되는 경로는 대부분 이 목록 바깥에 있다.
준식별자
생년월일·우편번호·성별·직업·소속·진료과·가입일 같은 값들이다. 하나로는 수십만 명이 걸리지만 겹치면 급격히 좁아진다. 널리 인용되는 결과가 하나 있다. 미국 1990년 인구조사 자료로 계산했을 때 우편번호·생년월일·성별 셋만으로 인구의 약 87%가 유일하게 특정되었다. 숫자 자체는 미국 이야기지만 원리는 어디서나 같다.
원리는 곱셈이다. 생년월일은 백 년 치면 대략 3만 6천 가지, 성별은 둘, 우편번호는 한국의 다섯 자리 체계에서 3만 개가 넘는다. 셋을 곱하면 20억 가지가 넘는 칸이 생기고 인구는 5천만이니 한 칸에 평균 0.02명이 들어간다. 대부분의 칸은 비어 있고, 차 있는 칸에는 한 명뿐이라는 뜻이다. 조합의 가짓수가 인구보다 커지는 순간 사람은 이름 없이도 특정된다.
그래서 준식별자는 개별로 판단하면 늘 "이 정도는 괜찮다"가 되고, 묶어서 판단해야 답이 달라진다. 마스킹 정책을 항목별 목록으로만 적으면 이 판단이 들어갈 자리가 없다. 항목 셋이 한 문장에 같이 나오는 경우를 따로 세어 보고, 그 조합이 흔하면 셋 중 하나를 일반화한다. 생년월일을 연령대로 바꾸는 처리가 이 자리에서 나온다. 1990년 3월 7일이 30대로 바뀌면 3만 6천 가지가 예닐곱 가지로 줄고, 칸 하나에 수백만 명이 들어가 특정이 무너진다.
도메인별 목록
의료면 병명·처방·수진 이력, 금융이면 잔액·거래처·연체 여부, 채용이면 이전 직장과 재직 기간, 교육이면 학교·학년·반이 그런 값이다. 일반적인 PII 목록에는 안 나오지만 그 도메인에서는 사람을 좁히거나 그 자체로 민감하다.
그래서 PII 목록은 남의 것을 가져다 쓰는 것이 아니라 우리 데이터에서 뽑는 것이다. 실제 트래픽 몇백 건을 열어 "이 문장에서 사람을 좁히는 조각이 무엇인가"를 표시해 보면 목록의 절반쯤은 예상 밖의 항목으로 채워진다. 사번, 매장 코드, 사내 메신저 아이디, 계약 번호, 단말기 일련번호처럼 조직 안에서만 통하는 식별자가 특히 그렇다. 이런 값은 밖에서 보면 무의미한 문자열이지만 우리 데이터베이스와 합치면 곧바로 한 사람이다. 그리고 그 데이터베이스는 우리 회사 안에 있으므로, 사내 로그에 남는 이 값들은 사실상 실명과 같다.
탐지 방식
탐지 방법은 셋이고 잘하는 것이 서로 다르다.
| 방법 | 강한 항목 | 약한 항목 | 비용 |
|---|---|---|---|
| 정규식 · 체크섬 | 전화, 이메일, 카드번호, 계좌, 주민등록번호 | 이름, 주소, 조직 | 사실상 0 |
| 개체명 인식 모델 | 이름, 주소, 조직, 지명 | 오탈자, 구어체, 드문 이름 | 10~30ms |
| 문맥 판정 모델 | 문장으로만 드러나는 식별 정보 | 비용과 지연 | 수백 ms |
정규식과 체크섬
정규식으로 시작한다. 형식이 정해진 항목은 여기서 거의 다 잡히고, 체크섬이 있는 항목은 오탐까지 줄일 수 있다. 체크섬은 값의 자릿수들로 정해진 계산을 해서 특정 결과가 나오는지 보는 검사다. 카드번호의 Luhn 검증이 대표적인데, 오른쪽부터 한 칸 걸러 두 배로 만들어 모두 더한 값이 10으로 나누어떨어지는지 본다. 무작위 16자리가 우연히 이를 통과할 확률은 10분의 1이므로, 숫자가 길게 붙은 문자열을 카드번호로 오인하는 일이 열 배 줄어든다.
체크섬을 조건으로 걸 때 조심할 것이 하나 있다. 검증을 통과한 것만 지우는 규칙은 위험하다. 형식은 맞는데 검증에 걸리는 값은 사용자가 한 자리 잘못 친 번호이거나, 번호 체계가 개편되어 옛 검증식이 더는 성립하지 않는 값이다. 둘 다 진짜 개인정보이고, 오히려 오타가 난 번호가 그대로 로그에 남는 쪽이 더 나쁘다. 체크섬은 처리 방식의 우선순위를 정하는 데 쓴다. 통과하면 확실하니 삭제하고, 통과하지 못하면 자리표로 바꿔 둔다. 지울지 말지 자체를 가르는 데는 쓰지 않는다.
개체명 인식
개체명 인식(NER)은 문장에서 사람·장소·조직 같은 이름을 찾아 표시해 주는 모델이다. 이름과 주소는 정규식으로 못 잡으므로 여기서 이 모델이 필요하다.
다만 성능이 도메인을 크게 탄다. 대부분의 공개 모델은 뉴스 기사나 위키 문서로 학습되어 있는데, 상담 대화는 문장이 짧고 주어가 생략되고 오타가 많다. "어제 김대리님이랑 통화했는데요"에서 "김대리"를 사람으로 잡을지 직함으로 흘려보낼지가 학습 데이터에 따라 갈린다. 그래서 공개 모델의 벤치마크 점수를 그대로 우리 재현율로 옮겨 적으면 안 된다. 우리 트래픽으로 다시 재기 전까지 그 숫자는 우리 숫자가 아니다.
문맥 판정
"어제 오후에 3층 창가 자리에서 넘어지신 분"에는 이름도 번호도 없다. 그런데 그 매장에서는 한 사람으로 좁혀진다. 정규식도 개체명 인식도 여기서는 아무것도 찾지 못한다. 언어 모델에 문장을 통째로 넣고 "여기서 개인이 특정되는가"를 묻는 방법이 있지만 비용과 지연이 크다.
그리고 이 방법에는 구조적인 함정이 하나 있다. 판정을 하려면 원문을 또 다른 모델에 보내야 한다. 마스킹하려던 값을 마스킹하기 전에 한 번 더 밖으로 내보내는 셈이다. 외부 API를 판정에 쓰면 그 API의 요청 기록이 위 목록의 아홉 번째 항목으로 추가된다. 이 경로를 쓸 거라면 판정 모델은 우리가 통제하는 자리에서 돌려야 하고, 그럴 수 없다면 문맥 판정은 포기하고 그 영역은 접근 통제로 막는 편이 낫다.
목적지별 임계값
재현율은 실제로 있는 개인정보 중 몇 개를 찾았는가이고, 정밀도는 찾았다고 표시한 것 중 몇 개가 진짜였는가다. 탐지기의 임계값을 낮추면 재현율이 오르고 정밀도가 떨어진다. 올리면 반대다.
숫자로 한 번 따라가 보자. 표본 300건에 사람이 표시한 개인정보 조각이 1,200개 있었고, 탐지기가 1,150개를 표시했으며 그중 1,090개가 실제로 개인정보였다고 하자. 재현율은 1,090을 1,200으로 나눈 90.8%, 정밀도는 1,090을 1,150으로 나눈 94.8%다. 놓친 것이 110개, 엉뚱하게 지운 것이 60개다.
같은 텍스트라도 목적지가 다르면 정책이 달라야 한다. 로그로 나가는 경로에서는 60개를 더 지워도 잃는 것이 거의 없으니 임계값을 낮춰 재현율을 올린다. 모델로 가는 경로에서는 110개를 더 잡으려다 오탐이 60개에서 200개로 늘면 답이 망가지므로 임계값을 높인다. 탐지기는 하나를 쓰되 그 뒤의 결정만 경로별로 갈라 두면 된다.
그리고 재현율 90.8%가 실제로 무엇을 뜻하는지 한 번 곱해 본다. 하루 요청이 10만 건이고 요청당 개인정보 조각이 평균 넷이면 하루 40만 개다. 9.2%를 놓치면 3만 6,800개가 로그에 남고, 한 달이면 백만 개가 넘는다. 재현율은 백분율로 보면 훌륭해 보이고 절대 수로 보면 사고다. 그래서 실무에서는 탐지기 하나로 끝내지 않고 로그 파이프라인 끝에 성긴 그물을 하나 더 둔다. 두 그물이 독립적으로 9%씩 놓치면 둘 다 통과할 확률은 1%가 안 된다.
한국어 마스킹
번호 꼴
주민등록번호는 13자리이고 앞 여섯 자리가 생년월일, 일곱 번째 자리가 성별과 출생 세기를 나타낸다. 하이픈이 있기도 없기도 하고, 앞자리 여섯만 적기도 하고, 성별 자리까지 일곱 자리에서 끊어 적기도 한다.
전화번호는 더 심하다. 010-1234-5678, 01012345678, 010 1234 5678, 010.1234.5678, +82 10-1234-5678, +82-10-1234-5678이 전부 같은 번호다. 국제 표기에서는 앞의 0이 빠지므로 국내 표기용 정규식으로는 잡히지 않는다. 여기에 국번을 뺀 1234-5678, 지역번호가 두 자리인 02-123-4567, 대표번호 1588-1234 꼴이 더 붙는다.
꼴을 하나씩 늘리다 보면 정규식이 길어지고 오탐이 늘어난다. 여덟 자리에서 열한 자리 사이의 숫자 덩어리는 주문번호일 수도, 운송장번호일 수도, 사번일 수도 있다. 그래서 실무에서는 숫자 덩어리를 먼저 넓게 잡고 그다음에 앞뒤 문맥으로 거른다. "연락처", "전화", "번호로", "로 연락"이 근처에 있으면 전화번호 쪽으로 기울고, "주문", "운송장", "조회"가 있으면 반대쪽으로 기운다. 완벽하지는 않지만 정규식 하나를 계속 길게 늘리는 것보다 고치기 쉽고, 새 꼴이 나타났을 때 규칙 한 줄만 더하면 된다.
이름과 일반 명사
한국어 이름은 대개 두세 글자이고 사전에 있는 낱말과 자주 겹친다. 이슬, 하늘, 미래, 보람, 다솜, 초롱은 전부 이름이면서 보통 명사다. "이슬이 맺혔다"와 "이슬이 전화했다"를 가르는 것은 서술어뿐인데, 짧은 상담 문장에서는 그 서술어마저 자주 생략된다.
성이 앞에 붙으면 훨씬 쉬워진다. "김하늘"은 이름일 가능성이 압도적이다. 그래서 성 목록을 쓰는 규칙이 실제로 잘 먹는다. 흔한 성 백여 개 뒤에 한두 글자가 붙은 덩어리를 후보로 잡는 방식이다. 다만 이 규칙도 "이번", "박스", "정말", "임시"처럼 성으로 시작하는 낱말을 함께 잡으므로 사전으로 한 번 걸러야 한다. 정규식과 개체명 인식을 붙여 쓰는 자리가 여기다. 성 규칙으로 후보를 넓게 만들고, 사전으로 확실한 낱말을 빼고, 남은 것을 모델에 물어 결정한다.
조사와 경계
영어라면 이름의 끝은 공백이다. 한국어는 조사가 곧바로 붙는다. "민수가", "민수는", "민수에게", "민수한테서"가 전부 한 덩어리로 붙어 있다. 탐지기가 이름만 잡아 자리표로 바꾸면 조사가 밖에 남고, 조사까지 덩어리째 잡으면 자리표가 조사를 삼켜 문장이 깨진다.
정답은 경계를 이름 끝에 두고 조사는 남기는 것이다. 그러면 모델이 읽는 문장에서 격이 유지되어 누가 누구에게 무엇을 했는지가 그대로 보이고, 복원할 때도 자리표만 원래 값으로 바꾸면 된다. 조사까지 지우면 문장이 "…에게 연락"에서 "… 연락"으로 무너져 모델이 관계를 못 읽고, 그러면 마스킹 때문에 답이 틀린다.
자리표 자체에도 조사 문제가 따라온다. 한국어 조사는 앞 글자의 받침에 따라 갈린다. "가"와 "이", "는"과 "은", "를"과 "을"이 그렇다. 자리표는 받침이 없는 것처럼 보이니 모델이 "가"를 붙여 문장을 만들었는데 복원한 이름이 "박민준"이면 "박민준가"가 된다. 복원 단계에서 자리표 바로 뒤의 조사를 원래 값의 받침에 맞춰 고쳐 주는 처리를 한 줄 넣어 두면 이 어긋남이 통째로 사라진다. 규칙 자체는 열 줄이 안 되는데, 없으면 사용자가 보는 모든 문장에서 어색함이 눈에 띈다.
치환과 복원
치환 방식
찾은 다음에 무엇을 할지는 그 값이 뒤에서 필요한지에 달렸다.
| 방식 | 결과 | 되돌리기 | 쓰는 자리 |
|---|---|---|---|
| 삭제 | 사라진다 | 불가 | 다시 안 쓸 값 |
| 부분 마스킹 | 010-****-5678 |
불가 | 사람이 확인만 하면 되는 화면 |
| 자리표 치환 | <PHONE_1> |
요청 안에서만 가능 | 모델 호출 |
| 토큰화 | 무의미한 키 | 금고를 통해 가능 | 시스템 간 전달 |
| 일반화 | 생년월일을 연령대로 | 불가 | 분석·통계 |
| 해싱 | 고정 길이 값 | 불가하나 대조는 가능 | 중복 판정, 카운트 |
고르는 질문은 하나다. 그 값이 뒤에서 다시 필요한가, 필요하다면 누구에게 필요한가. 사용자에게만 필요하면 자리표, 다른 시스템에도 필요하면 토큰화, 아무도 값 자체는 필요 없고 같은 사람인지만 알면 되면 해싱, 통계만 내면 되면 일반화다.
해싱에는 함정이 하나 있다. 전화번호는 010 뒤에 여덟 자리가 붙으므로 경우의 수가 1억이다. 흔한 해시 함수로 1억 개를 미리 계산해 표를 만드는 데 걸리는 시간은 노트북으로 몇 분이고, 그 표가 있으면 해시값을 원래 번호로 되돌리는 것은 조회 한 번이다. 주민등록번호는 앞 여섯 자리가 생년월일이라 범위가 더 좁다. 식별자 해싱은 익명화가 아니다. 반드시 비밀 키를 섞은 해시를 쓰고, 그렇게 해도 "가명화"이지 익명화가 아니라는 점을 전제로 다룬다. 가명화된 값은 키를 아는 사람에게는 여전히 개인정보이고, 그래서 키의 관리 수준이 곧 데이터의 보호 수준이 된다.
자리표
모델 호출 경로에서 실무의 기본값은 자리표 치환이다.
입력에서 발견한 값을 <PERSON_1> 같은 자리표로 바꿔 모델에 보내고, 응답에 남은 자리표를 원래 값으로 되돌려 사용자에게 보여 준다. 모델은 실명을 한 번도 보지 않지만 답은 자연스럽게 나온다.
이 방식이 잘 도는 이유는 대부분의 작업에서 모델이 그 값의 내용을 알 필요가 없기 때문이다. "이 고객에게 회신 예정이라고 답하라"는 지시를 수행하는 데 실제 전화번호가 필요하지는 않다. 모델에게 필요한 것은 "여기에 전화번호가 하나 있다"는 사실과 "그것이 문장의 어느 자리에 놓이는가"뿐이고, 자리표는 그 둘을 그대로 전달한다. 값을 몰라도 되는 작업이 이렇게 많다는 점이 이 방식의 전제이자 한계다. 값을 실제로 계산에 써야 하는 작업이라면 자리표는 답이 아니다.
같은 값은 같은 자리표를 받아야 한다. 한 요청 안에서 같은 이름이 두 번 나오면 둘 다 <PERSON_1>이어야 모델이 같은 사람으로 읽는다. 반대로 요청이 다르면 같은 사람이라도 다른 번호를 받는 편이 안전하다. 요청을 건너 번호가 고정되면 그 번호 자체가 사람을 가리키는 식별자가 되어, 로그에서 자리표만 세도 한 사람의 행동이 이어진다.
복원 표의 수명
복원 표는 자리표와 원래 값의 짝이다. 이 표를 저장하면 앞의 처리가 전부 무의미해진다. 요청을 처리하는 동안 메모리에만 두고 끝나면 버린다. 캐시나 로그에 남기는 순간 그 저장소가 새 유출 경로가 되고, 심지어 원문보다 더 나쁘다. 원문은 문장 속에 섞여 있지만 복원 표는 "이 값이 개인정보다"라는 표시까지 붙어 정리된 형태이기 때문이다.
실무에서 이 원칙이 걸리는 자리가 둘 있다. 하나는 스트리밍이다. 답을 토막으로 흘려보내면 요청이 끝나기 전에 복원해야 하고, 자리표가 토막 경계에 걸쳐 잘릴 수 있다. 그래서 스트리밍 복원은 자리표 최대 길이만큼 버퍼를 물고 가면서 처리한다. 다른 하나는 재시도와 비동기 작업이다. 요청을 큐에 넣어 나중에 처리하는 구조라면 복원 표도 어딘가에 남아야 하는데, 그 순간 "메모리에만 둔다"는 원칙이 깨진다. 이때는 표를 저장하되 원문과 같은 등급으로 다룬다. 암호화하고, 만료를 짧게 걸고, 접근 기록을 남긴다. 복원 표는 원문의 사본이지 처리 과정의 메타데이터가 아니다.
자리표 손상
모델이 자리표를 변형할 수 있다. 번역하거나, 조사를 붙이거나, 형식을 바꿔 돌려주면 복원이 실패한다. 그래서 복원 단계에서 남은 자리표가 있는지, 사라진 자리표가 있는지 확인하고 실패하면 재생성으로 넘긴다. 자리표는 모델이 건드리기 어려운 형태로 만드는 편이 좋다.
검사는 양쪽으로 한다. 입력에 넣은 자리표 집합과 출력에서 찾은 집합을 비교해서, 사라진 것이 있으면 모델이 자리표를 먹었거나 변형한 것이고, 늘어난 것이 있으면 모델이 없던 자리표를 지어낸 것이다. 두 경우 모두 그대로 내보내면 안 된다. 사라진 쪽은 문장에 구멍이 생기고, 늘어난 쪽은 복원할 값이 없어 자리표가 사용자 화면에 그대로 나간다.
전부 가릴 필요는 없다. 위 그림에서 주문번호는 그대로 두었다. 답하는 데 필요하고, 그 자체로 사람을 가리키지 않기 때문이다. 필요한 것까지 가리면 모델은 "고객님의 주문"처럼 특정할 수 없는 답을 내고, 그건 안전한 것이 아니라 기능이 고장 난 것이다.
과잉 마스킹
기능 손상
개인정보 처리를 강하게 걸면 안전해 보이지만, 실제로는 기능이 조용히 망가진다. 조용하다는 것이 핵심이다. 유출은 사고가 되어 보고되지만 과잉 마스킹은 "답이 좀 애매하다"는 인상으로만 남고 아무 지표에도 안 잡힌다.
배송 문의에서 주소를 전부 가리면 "배송지가 어디로 되어 있나요"에 답할 수 없다. 이름을 전부 가리면 "동명이인 중 어느 분인가요"를 물을 수 없다. 의료·법률 도메인에서는 마스킹된 텍스트가 문맥을 잃어 요약 자체가 틀리기도 한다. 진료 기록에서 나이와 성별을 지우면 같은 증상이 전혀 다른 판단으로 이어지고, 계약서에서 당사자를 전부 자리표로 바꾸면 누가 누구에게 무엇을 지는지가 사라진다.
판단 기준은 하나다. 이 값이 이 작업에 필요한가. 필요하면 남기고 접근을 통제하고, 필요 없으면 지운다. "일단 다 가린다"는 정책은 결정을 미루는 방식이지 안전한 기본값이 아니다. 결정을 미루면 그 비용은 사라지지 않고 사용자가 받는 답의 품질로 옮겨 갈 뿐이다.
기능 단위 정책
그래서 마스킹 정책은 항목 단위가 아니라 기능 단위로 적는다. 같은 전화번호라도 배송 문의에서와 통계 집계에서 다르게 다뤄야 하므로, "전화번호는 자리표"라는 한 줄로는 정책이 성립하지 않는다.
기능: 배송 문의 응답
모델 입력:
이름: 자리표
전화: 자리표
주소: 그대로 # 답에 필요
주문번호: 그대로
로그:
주소: 상세주소만 삭제
나머지: 자리표
분석 데이터셋:
전부: 자리표, 30일 후 삭제
이렇게 적어 두면 좋은 점이 하나 더 있다. 새 기능을 만들 때 "여기 개인정보 정책은 뭐죠"라는 질문에 답할 자리가 생긴다는 것이다. 정책이 코드 안에 흩어져 있으면 그 질문에 아무도 답하지 못하고, 답하지 못하면 안전한 쪽으로 기울어 전부 가리게 되고, 그러면 위에서 말한 조용한 고장이 시작된다. 표 한 장은 그 기울어짐을 막는 장치다. 그리고 나중에 유출이 났을 때 "어디가 뚫렸나"를 이 표와 대조해 좁힐 수 있다.
검증
두 오류
마스킹은 재현율을 재기 어렵다는 특징이 있다. 놓친 것을 세려면 놓친 것이 무엇인지 알아야 하는데, 그걸 알면 이미 안 놓쳤을 것이기 때문이다. 그래서 검증은 "우리 탐지기가 무엇을 못 보는가"를 탐지기 바깥에서 알아내는 일이 된다.
그리고 놓친 것만 세면 반쪽이다. 앞 절의 과잉 마스킹은 어떤 경보에도 안 걸리면서 답을 계속 나쁘게 만든다. 그러니 지표는 늘 둘을 함께 본다. 놓친 개수와 과하게 지운 개수다. 하나만 보면 임계값을 한쪽으로 끝까지 밀어붙이는 것이 언제나 정답처럼 보인다. 그리고 두 오류의 비용은 경로마다 다르므로 하나의 기준선을 두지 말고 목적지별로 따로 잰다. 가드레일을 재는 방식 전반은 가드레일 테스트에서 따로 다뤘다.
라벨링 표본
실제 트래픽에서 몇백 건을 뽑아 사람이 개인정보 위치를 표시하고, 탐지기가 몇 개를 찾았는지 센다. 가장 정확하고, 위 절에서 계산한 재현율과 정밀도가 나오는 자리도 여기다.
문제는 그 작업 자체가 원문을 사람에게 보이는 일이라는 것이다. 개인정보를 안 새게 하려고 개인정보를 사람에게 보여 주는 셈이므로 접근 통제 아래에서 하고, 표본을 만든 사람과 본 사람을 기록으로 남기고, 다 쓴 표본은 정해진 기한에 지운다. 그리고 표본은 한 번 만들면 끝이 아니다. 트래픽의 성격이 바뀌면 예전 표본으로 잰 숫자는 더 이상 지금을 설명하지 못하므로 주기적으로 다시 뽑는다.
카나리아
카나리아는 실제 같지만 가짜인 개인정보를 만들어 스테이징 트래픽에 흘려보내고, 로그·트레이스·분석 저장소를 그 값으로 검색해 보는 방법이다. 하나라도 나오면 그 경로에 구멍이 있다는 뜻이다.
이 방법의 장점은 셋이다. 저장소를 하나씩 감사하는 것보다 훨씬 싸고, 사람이 원문을 볼 필요가 없고, 새 저장소가 추가됐을 때 자동으로 잡힌다. 세 번째가 특히 중요하다. 위에서 여덟 곳을 세었지만 그 목록은 시간이 지나면 늘어나고, 늘어난 항목을 아무도 목록에 적어 주지 않는다. 카나리아는 목록을 몰라도 검색으로 찾아내므로 목록이 낡는 문제에서 자유롭다.
카나리아 값은 진짜 형식을 정확히 따르되 실제로는 존재하지 않아야 한다. 형식이 어긋나면 탐지기가 못 잡는 것이 당연해져 검사가 무의미해지고, 실제로 쓰이는 번호를 골랐다면 그 번호의 주인에게 우리가 사고를 내는 것이다.
출구 감시
로그 수집 파이프라인 끝에서 정규식 몇 개로 표본을 훑는다. 마스킹을 우회하는 새 경로가 생겼을 때 알림이 온다. 앞 절에서 말한 두 번째 그물이 이것이고, 여기서는 재현율보다 한 번이라도 걸리는가가 중요하므로 검사 항목을 굳이 늘리지 않는다. 전화번호와 주민등록번호 꼴 둘만 봐도 대부분의 구멍은 드러난다.
셋 중 하나만 고른다면 카나리아다. 만들기 쉽고, 사람이 원문을 볼 필요가 없고, "우리는 지우고 있다"는 믿음을 실제로 검증해 주는 유일한 장치다. 나머지 둘은 카나리아가 켜진 다음에 붙인다.
정리하면, 개인정보는 요청 하나의 문제가 아니라 데이터가 지나가는 경로 전체의 문제다. 무엇을 지우는가보다 어디서 지우는가가 먼저이고, 얼마나 많이 지우는가보다 필요한 것을 남겼는가가 먼저이며, 잘 만들었는가보다 실제로 지워지고 있는지를 확인할 장치가 있는가가 먼저다. 그리고 그 셋의 답은 모두 같은 자리를 가리킨다. 지우는 자리는 항상 가장 앞이다.
읽어주셔서 감사합니다. 😊

