임베딩은 되돌릴 수 없어서 마스킹부터 했습니다 (PII 두 겹 탐지와 복원 맵 없는 토큰)
메일 본문을 벡터로 만들려면 외부 모델에 원문을 보내야 합니다. 라이브러리 하나로는 한국 주민번호를 못 잡았고, 이메일과 이름은 일부러 안 가렸어요.
[배경 - 벡터는 지울 수 없다]
메일상자에는 의미 기반 검색이 있습니다. “작년에 계약 얘기 나눴던 메일” 처럼 단어가 안 겹쳐도 찾아주는 기능이에요. 이걸 하려면 메일 본문을 임베딩 모델에 넣어 벡터로 만들어야 합니다.
여기서 걸린 게 있었어요. 임베딩 모델은 외부 서비스입니다. 본문을 그대로 보내면 메일 안의 전화번호, 계좌번호, 주민등록번호가 그대로 나갑니다.
메일은 다른 데이터와 성격이 다릅니다. 사용자가 직접 입력한 게 아니라 남이 보낸 내용이에요. 발신자는 자기 정보가 어느 AI 모델로 갈지 동의한 적이 없습니다.
그리고 되돌릴 수가 없어요. 로그라면 지우면 되고 DB 컬럼이면 마스킹하면 되는데, 한 번 임베딩한 벡터는 이미 나간 겁니다. 나중에 정책을 바꿔도 소용없어요.
그래서 임베딩 파이프라인에 마스킹을 먼저 넣기로 했습니다.
[문제 상황 분석 - 라이브러리 하나로는 안 됩니다]
Phileas가 못 잡는 게 있었습니다
PII 탐지 라이브러리를 찾다가 Phileas를 골랐습니다. 이메일, 전화번호, URL, 카드번호를 필터로 제공해요.
this.filters = List.of(
createEmailAddressFilter(configuration),
new PhoneNumberRulesFilter(configuration),
new UrlFilter(configuration, false),
new CreditCardFilter(configuration, false, false, false)
);
돌려보니 한국어 메일에서 못 잡는 게 많았어요.
- 주민등록번호:
900101-1234567형식은 한국에만 있습니다 - 계좌번호: 은행마다 자릿수가 다르고 형식이 정해져 있지 않아요
- 한국 주소: “서울특별시 강남구 테헤란로 123” 같은 구조
- 전화번호: 국제 규칙으로는 잡히는데
010-1234-5678을 확실히 잡으려면 한국 형식이 필요합니다
이건 라이브러리 탓이 아니에요. 국가별 식별자를 전부 커버하는 라이브러리는 없습니다.
그래서 두 겹으로 갔습니다. 라이브러리가 잘하는 건 라이브러리에 맡기고, 한국어 특화 패턴은 직접 씁니다.
private static final Pattern KOREAN_RRN = Pattern.compile("(?<!\\d)\\d{6}-[1-4]\\d{6}(?!\\d)");
private static final Pattern ACCOUNT = Pattern.compile("(?<!\\d)(?:계좌|account|acct|입금)[^\\n\\r\\d]{0,12}(\\d{2,6}[-\\s]?\\d{2,6}[-\\s]?\\d{2,8})(?!\\d)", Pattern.CASE_INSENSITIVE);
private static final Pattern ADDRESS = Pattern.compile("[가-힣A-Za-z0-9 ]{2,40}(?:시|도) ?[가-힣A-Za-z0-9 ]{1,40}(?:구|군) ?[가-힣A-Za-z0-9 ]{1,60}(?:로|길) ?\\d{1,5}(?:-\\d{1,5})?");
private static final Pattern PHONE = Pattern.compile("(?<!\\d)(?:\\+?82[-\\s]?)?0?1[016789][-\\s]?\\d{3,4}[-\\s]?\\d{4}(?!\\d)");
주민번호 패턴에서 뒷자리 첫 글자를 [1-4] 로 제한한 게 의도예요. 그냥 \d{6}-\d{7} 로 하면 날짜가 들어간 문자열이 잘못 걸립니다.
계좌번호는 앞에 단서 단어가 있어야만 잡도록 했어요. 숫자 패턴만으로는 주문번호나 송장번호와 구분이 안 되니까요. 그래서 “계좌”, “입금” 같은 단어를 앞에 요구합니다.
탐지가 겹칩니다
두 겹으로 돌리니 같은 자리를 여러 패턴이 잡았습니다.
이메일 주소 alice@example.com 은 Phileas의 이메일 필터도 잡고 제 이메일 정규식도 잡아요. 그대로 두면 토큰이 두 번 치환되면서 문자열이 깨집니다.
private List<MaskingDetectionResult> resolveOverlaps(List<MaskingDetectionResult> rawDetections) {
List<MaskingDetectionResult> selected = new ArrayList<>();
List<MaskingDetectionResult> candidates = rawDetections.stream()
.sorted(Comparator.comparingInt(MaskingDetectionResult::length).reversed()
.thenComparingInt(MaskingDetectionResult::startInclusive))
.toList();
for (MaskingDetectionResult candidate : candidates) {
boolean overlaps = selected.stream().anyMatch(existing -> existing.overlaps(candidate));
if (!overlaps) {
selected.add(candidate);
}
}
return selected;
}
긴 것을 우선합니다. 겹치면 더 넓은 범위를 가린 쪽을 남겨요.
이유는 안전 쪽으로 기울이기 위해서입니다. 짧은 걸 고르면 가려지지 않은 부분이 남아요. 예를 들어 URL 안에 이메일이 들어 있는 경우, 이메일만 가리면 URL의 나머지가 남습니다.
[해결 방법 - 같은 값은 같은 토큰, 복원 맵은 비운다]
의미를 남기면서 값을 지웁니다
마스킹의 목적이 “지우는 것” 만은 아니에요. 임베딩은 문맥을 벡터로 만드는 작업이라 문장 구조가 무너지면 검색 품질이 떨어집니다.
그래서 값을 지우는 대신 타입이 붙은 토큰으로 바꿉니다.
String identity = detection.piiType().name() + "�" + originalValue;
String token = tokenByIdentity.computeIfAbsent(identity, ignored -> {
int next = counters.merge(detection.piiType(), 1, Integer::sum);
return "[" + detection.piiType().tokenPrefix() + "_" + next + "]";
});
같은 값은 같은 토큰을 받습니다. 본문에 같은 전화번호가 세 번 나오면 세 번 다 [PHONE_1] 이에요.
이게 중요한 이유는 임베딩 모델이 이 반복을 문맥으로 읽기 때문입니다. 전부 다른 토큰으로 바꾸면 “서로 다른 세 개” 로 읽혀서 원래 의미와 달라져요.
타입을 접두어로 남기는 것도 같은 이유입니다. [PHONE_1] 은 “여기 전화번호가 있었다” 는 정보를 남겨요. 완전히 지우면 문장이 어색해집니다.
키를 타입 + � + 원본값 으로 잡은 것도 짚고 갈 부분이에요. � 은 실제 텍스트에 거의 안 나오는 문자라 구분자로 씁니다. 타입을 섞은 건 같은 문자열이 다른 타입으로 잡힐 수 있어서예요.
임베딩용은 복원 맵을 만들지 않습니다
여기가 이 설계의 핵심입니다. 마스킹 결과에 맵이 두 개 있어요.
for (MaskingTokenResult token : tokens) {
if (command.scope() == MaskingScope.CURRENT_CONTEXT) {
restoreTokenMap.putIfAbsent(token.token(), token.originalValue());
} else {
redactedTokenMap.putIfAbsent(token.token(), token.originalValue());
}
}
MaskingScope 가 두 값을 가집니다.
| Scope | 맵 | 용도 |
|---|---|---|
CURRENT_CONTEXT | restoreTokenMap | LLM에게 보내고 응답을 받은 뒤 원래 값으로 되돌린다 |
PAST_CONTEXT | redactedTokenMap | 되돌리지 않는다 |
restore 메서드는 restoreTokenMap 만 봅니다.
public String restore(String text, MaskingResult maskingResult) {
String restored = text;
for (Map.Entry<String, String> entry : maskingResult.restoreTokenMap().entrySet()) {
restored = restored.replace(entry.getKey(), entry.getValue());
}
return restored;
}
그리고 임베딩 파이프라인은 pastContext() 를 씁니다.
MaskingResult maskingResult = phileasMaskingService.mask(embeddableText, MaskingCommand.pastContext());
즉 임베딩 경로에서는 복원 맵이 처음부터 비어 있습니다. 실수로 restore 를 불러도 아무것도 안 되돌아와요. 복원할 수 있는 상태 자체를 안 만드는 겁니다.
“복원하면 안 되는 규칙” 을 코드로 강제한 부분이라 마음에 듭니다. 주석으로 “여기서는 복원하지 마세요” 라고 적는 것보다 낫다고 봤어요.
같은 메일을 두 번 임베딩하지 않습니다
임베딩은 호출할 때마다 돈이 나가니 중복을 막아야 합니다. 문서 ID를 결정적으로 만들었어요.
public UUID createDocumentId(Message message) {
String identity = "mail-embedding:%s:%s".formatted(
message.getThread().getMailAccount().getProvider(),
providerMessageId(message)
);
return UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8));
}
UUID.nameUUIDFromBytes 는 같은 입력에 항상 같은 UUID를 돌려줍니다. 그러니까 같은 메일은 몇 번을 처리해도 같은 문서 ID예요.
랜덤 UUID를 쓰고 별도 매핑 테이블을 두는 방법도 있는데, 그러면 테이블 조회가 하나 늘고 그 테이블과 벡터 스토어가 어긋날 여지가 생깁니다. 계산으로 만들면 그런 게 없어요.
긴 본문은 나누고, 빠진 조각만 채웁니다
메일 본문은 모델 입력 한도를 넘을 수 있어서 나눕니다.
private List<Document> buildChunkDocuments(Message message, UUID documentId, String text) {
List<String> chunks = mailEmbeddingQueryService.splitTextForEmbedding(text);
List<Document> documents = new ArrayList<>();
for (int index = 0; index < chunks.size(); index++) {
UUID chunkDocumentId = index == 0
? documentId
: mailEmbeddingQueryService.createChunkDocumentId(documentId, index);
// ...
}
return documents;
}
첫 조각은 원래 문서 ID를 그대로 쓰고, 이후 조각은 파생 ID를 만듭니다.
public UUID createChunkDocumentId(UUID documentId, int chunkIndex) {
String identity = "mail-embedding-chunk:%s:%d".formatted(documentId, chunkIndex);
return UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8));
}
첫 조각이 원본 ID를 쓰는 게 편해요. 조각이 하나뿐인 짧은 메일은 파생 ID를 안 만들어도 되고, 조각이 여러 개여도 대표 ID로 접근할 수 있습니다.
그리고 조각 단위로 존재 여부를 확인해요.
List<Document> missingDocuments = findMissingDocuments(documents);
if (missingDocuments.isEmpty()) {
log.debug("Mail embedding skipped because all documents already exist. ...");
return;
}
vectorStore.add(missingDocuments);
조각 5개 중 3개까지 저장하고 실패했다면, 재시도할 때 나머지 2개만 저장합니다. 메시지 재시도가 기본인 구조라 이게 없으면 재시도마다 임베딩 비용이 처음부터 나가요.
[성과 - 개선 전후 비교]
저장소의 테스트를 돌렸습니다.
./gradlew :test --tests "com.mailsangja.worker.service.ai.masking.PhileasMaskingServiceTest"
tests="10" skipped="0" failures="0" errors="0" time="0.37"
그중 한국어와 영어가 섞인 본문으로 탐지를 확인하는 테스트가 있어요. 입력은 이렇습니다.
Dear Alice Kim,
담당: 김민수님
이메일 alice@example.com, 전화 010-1234-5678
링크 https://example.com/docs
주소 서울특별시 강남구 테헤란로 123
계좌 국민 123-456-789012
카드 1111-2222-3333-4444
주민번호 900101-1234567
이 입력에서 탐지되는 타입은 여섯 개입니다. PHONE, URL, ADDRESS, ACCOUNT_NUMBER, CARD_NUMBER, KOREAN_RRN 이에요.
그런데 테스트에 이런 단언도 같이 있습니다.
assertThat(detectedTypes).doesNotContain(PiiType.EMAIL, PiiType.PERSON_NAME);
이메일과 사람 이름은 일부러 안 가립니다.
private static final Set<PiiType> DEFAULT_ENABLED_TYPES = EnumSet.complementOf(EnumSet.of(
PiiType.EMAIL,
PiiType.PERSON_NAME
));
이건 검색 품질 때문에 내린 선택이에요. 메일 검색에서 “김부장님이 보낸 계약 메일” 같은 질의는 흔합니다. 이름과 이메일 주소를 가리면 이런 검색이 아예 안 돼요.
정직하게 적으면, 이건 안전 쪽에서 물러선 결정입니다. 이름과 이메일도 개인정보인데, 검색이 서비스의 핵심 기능이라 남겼어요. 탐지 코드는 있고 기본 설정에서만 꺼둔 상태라, 필요하면 켤 수 있습니다.
| 항목 | 마스킹 전 | 마스킹 후 |
|---|---|---|
| 외부 모델에 나가는 값 | 본문 원문 | 전화, 주소, 계좌, 카드, 주민번호가 토큰으로 치환된 텍스트 |
| 이메일, 이름 | 원문 | 여전히 원문 (기본 설정) |
| 복원 가능성 (임베딩 경로) | 해당 없음 | 복원 맵이 비어 있어 불가 |
| 중복 임베딩 | 매번 재계산 | 결정적 문서 ID로 건너뜀 |
[결론]
정리하면 이렇습니다.
- 되돌릴 수 없는 경로에는 되돌릴 수 없는 마스킹을 쓴다. 복원 맵을 안 만드는 것으로 강제할 수 있다
- PII 탐지는 국가별 형식 때문에 라이브러리 하나로 안 된다
- 겹치는 탐지는 넓은 쪽을 남긴다. 안전한 방향으로 기운다
- 임베딩처럼 비용이 나가는 작업은 결정적 ID로 멱등하게 만든다
한계를 적어둘게요.
첫째, 정규식 기반 탐지의 재현율을 안 쟀습니다. 테스트는 제가 만든 예시 문장 하나로 확인해요. 실제 메일에서 몇 퍼센트를 잡는지는 모릅니다. 주민번호처럼 형식이 고정된 건 괜찮겠지만 계좌번호나 주소는 놓치는 게 많을 거예요.
둘째, 이름 탐지가 접두어에 의존합니다.
private static final Pattern KOREAN_PERSON = Pattern.compile("(?:(?:수신|발신|담당|대표|문의|참조|to|from|dear)[::\\s]+)([가-힣]{2,4})(?:\\s?(?:님|대리|과장|부장|팀장|대표))?", Pattern.CASE_INSENSITIVE);
“담당: 김민수님” 은 잡히지만 본문 중간의 “김민수 씨에게 전달했습니다” 는 안 잡혀요. 지금은 기본 설정에서 꺼져 있어 드러나지 않지만, 켜는 순간 이 한계가 문제가 됩니다.
셋째, 마스킹해도 문맥은 남습니다. “김철수 부장님께 010-1234-5678로 연락 주세요” 를 가려도 “[PERSON_1] 부장님께 [PHONE_1]로 연락 주세요” 가 됩니다. 직급과 관계는 그대로예요. 이런 준식별자를 조합하면 사람을 특정할 수 있습니다.
넷째, redactedTokenMap 이 왜 있는지 애매합니다. 복원하지 않을 값을 굳이 맵에 담아둡니다. 로그나 감사 용도라면 그것대로 원본값을 들고 있는 셈이라, 저장하거나 로깅되지 않도록 관리해야 해요.
다섯째, 마스킹 실패 시 동작을 안 정했습니다. Phileas가 예외를 던지면 PHILEAS_DETECTION_FAILED 로 감싸서 던지고, 그 메시지는 재시도를 거쳐 DLQ로 갑니다. 결과적으로 임베딩이 안 되니 안전한 방향이긴 한데, 이게 의도한 정책인지는 문서로 남기지 않았어요.
마스킹을 붙이면서 제일 오래 고민한 게 이름과 이메일이었습니다. 안 가리면 개인정보가 나가고, 가리면 검색이 안 돼요. 결국 기능을 택했는데, 이건 기술 판단이라기보다 제품 판단이라 혼자 정할 일이 아니었다고 지금은 생각합니다.