개인정보(PII) 마스킹 처리 - RAG에서 민감정보 보호하기
지난 글에서 문서의 만료와 폐기를 관리하는 라이프사이클 정책을 다뤘습니다. 이번 글에서는 운영·거버넌스 파트의 마지막 주제로, 문서 안에 포함된 개인정보(PII)가 검색과 답변 과정에서 그대로 노출되지 않도록 마스킹하는 방법을 정리합니다.
1. RAG에서 PII 노출이 왜 특히 위험한가
일반 DB 조회는 필요한 컬럼만 SELECT하면 되지만, RAG는 문서 청크 전체를 통째로 LLM에 넘기고, 그 내용이 그대로 답변에 녹아들 수 있습니다.
문서 원본: "김철수(주민번호 900101-1******, 010-1234-5678)님은
2024년 3월 입사하여 개발팀에 배속되었습니다."
질문: "개발팀에 언제 입사한 사람 있어?"
LLM 답변: "김철수 님이 2024년 3월 개발팀에 입사했으며,
연락처는 010-1234-5678입니다."
질문 자체는 개인정보를 요구하지 않았는데도, 검색된 문서에 포함된 정보가 그대로 답변에 딸려 나옵니다. 지난 접근 제어 글에서 다룬 "누가 이 문서에 접근할 수 있는가"와는 별개로, "접근이 허용된 사람에게조차 불필요한 개인정보를 노출하지 않는다"는 원칙이 추가로 필요합니다.
2. 마스킹을 적용할 두 시점
PII 마스킹은 크게 두 시점에 적용할 수 있고, 실무에서는 이 둘을 함께 씁니다.
[적재 시점 마스킹]
문서 저장 전 → PII 탐지 → 마스킹 처리 → 마스킹된 상태로 벡터 DB에 저장
[응답 시점 마스킹]
검색된 원본 문서 → LLM에 전달 → 생성된 답변 → PII 탐지 → 마스킹 후 사용자에게 반환
적재 시점 마스킹은 원천적으로 안전하지만 한 번 마스킹하면 원본 정보를 복구하기 어렵습니다. 응답 시점 마스킹은 유연하지만 매 요청마다 검사가 필요해 지연이 늘어납니다. 문서 성격에 따라 어느 쪽을 우선할지 결정해야 합니다.
3. PII 탐지하기 - 정규식 기반 기본 필터
주민등록번호, 전화번호, 이메일처럼 형식이 정해진 정보는 정규식만으로도 상당 부분 탐지할 수 있습니다.
import re
PII_PATTERNS = {
"resident_id": r"\d{6}-[1-4]\d{6}",
"phone": r"01[0-9]-?\d{3,4}-?\d{4}",
"email": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
"card_number": r"\d{4}-?\d{4}-?\d{4}-?\d{4}",
}
def mask_pii(text):
masked = text
masked = re.sub(PII_PATTERNS["resident_id"], "[주민번호]", masked)
masked = re.sub(PII_PATTERNS["phone"], "[전화번호]", masked)
masked = re.sub(PII_PATTERNS["email"], "[이메일]", masked)
masked = re.sub(PII_PATTERNS["card_number"], "[카드번호]", masked)
return masked
[적용 전] "김철수(010-1234-5678, kim@company.com)님은 3월 입사"
[적용 후] "김철수([전화번호], [이메일])님은 3월 입사"
이 방식은 빠르고 비용이 들지 않지만, 형식이 정형화된 정보에만 효과적입니다. 사람 이름이나 주소처럼 정형화되지 않은 정보는 정규식만으로 잡아내기 어렵습니다.
4. NER(개체명 인식)로 비정형 PII까지 탐지하기
사람 이름, 주소, 조직명처럼 정해진 형식이 없는 정보는 개체명 인식(Named Entity Recognition) 모델로 탐지합니다.
from transformers import pipeline
ner_pipeline = pipeline("ner", model="klue/bert-base", aggregation_strategy="simple")
def mask_names_and_addresses(text):
entities = ner_pipeline(text)
masked = text
for entity in sorted(entities, key=lambda e: e["start"], reverse=True):
if entity["entity_group"] in ("PS", "LC"): # 인물, 지명
label = "[이름]" if entity["entity_group"] == "PS" else "[주소]"
masked = masked[:entity["start"]] + label + masked[entity["end"]:]
return masked
정규식으로 먼저 형식이 명확한 정보를 걸러내고, 그다음 NER 모델로 이름·주소 같은 비정형 정보를 추가로 탐지하는 2단계 파이프라인이 실무에서 가장 균형 잡힌 접근입니다.
5. 적재 파이프라인에 마스킹 단계 추가하기
지금까지 다뤄온 청킹 이전 단계에 마스킹을 끼워 넣습니다.
@Service
@RequiredArgsConstructor
public class DocumentIngestService {
private final PiiMaskingService piiMaskingService;
private final VectorStore vectorStore;
public void ingest(String rawContent, String source) {
String maskedContent = piiMaskingService.mask(rawContent);
TextSplitter splitter = new TokenTextSplitter(500, 350, 5, 10000, true);
Document document = new Document(maskedContent, Map.of("source", source));
List<Document> chunks = splitter.apply(List.of(document));
vectorStore.add(chunks);
}
}
원본 텍스트가 아니라 마스킹된 텍스트가 임베딩되고 저장되기 때문에, 이후 검색·답변 생성 전 단계에서 PII가 아예 존재하지 않는 상태로 흘러갑니다. 지난 재해 복구 글에서 원본 텍스트를 별도 보관하라고 했던 것과는 별개로, 원본 문서 저장소(S3 등)는 접근 권한을 엄격히 통제된 곳에 따로 두고, 벡터 DB에는 마스킹된 버전만 들어가도록 분리하는 것이 안전합니다.
6. 마스킹된 문서로도 검색이 잘 되는가
여기서 궁금한 점이 하나 생깁니다. "[이름]", "[전화번호]"처럼 마스킹된 텍스트를 임베딩하면 검색 품질이 떨어지지 않을까 하는 우려입니다.
질문: "개발팀에 3월 입사한 사람이 누구야?"
마스킹된 문서: "[이름]님은 2024년 3월 입사하여 개발팀에 배속되었습니다."
실제로는 대부분의 업무 질문이 이름이나 연락처 자체보다 "언제", "어느 팀", "어떤 내용"처럼 마스킹 대상이 아닌 정보를 기준으로 검색되기 때문에, 마스킹이 검색 품질에 주는 영향은 생각보다 제한적입니다. 다만 "김철수가 누구야?"처럼 이름 자체를 검색 키워드로 쓰는 질문에는 애초에 답할 수 없다는 트레이드오프는 감안해야 합니다.
7. 역할 기반으로 마스킹 수준 다르게 적용하기
지난 접근 제어 글에서 다룬 역할(Role) 기반 필터링을 마스킹에도 확장할 수 있습니다. 인사팀처럼 개인정보 취급이 업무상 필요한 역할에는 마스킹을 완화하고, 일반 사용자에게는 강하게 적용하는 방식입니다.
public String mask(String content, Set<String> userRoles) {
if (userRoles.contains("HR_ADMIN")) {
return maskingPolicy.applyMinimal(content); // 최소 마스킹 (통계성 정보만)
}
return maskingPolicy.applyStrict(content); // 전체 마스킹
}
이 경우 마스킹 수준이 다른 두 버전을 각각 별도 벡터로 저장해두고, 검색 시점에 요청자의 역할에 따라 어떤 버전을 사용할지 분기하는 구조가 필요합니다. 지난 접근 제어 글의 메타데이터 필터링 구조를 그대로 확장해서 적용할 수 있습니다.
8. 마스킹 정확도 검증하기
지난 RAG 평가 글에서 다룬 RAGAS처럼, 마스킹도 정기적으로 정확도를 검증해야 합니다. 특히 마스킹이 덜 되어 PII가 새어나가는 False Negative가 가장 위험한 실패 유형입니다.
def evaluate_masking(sample_documents):
leaked_count = 0
for doc in sample_documents:
masked = mask_pii(doc)
if re.search(PII_PATTERNS["resident_id"], masked) or re.search(PII_PATTERNS["phone"], masked):
leaked_count += 1
return leaked_count / len(sample_documents)
정기적으로 샘플 문서를 뽑아 마스킹 누락률을 측정하고, 누락이 발견되면 정규식 패턴이나 NER 모델의 인식 범위를 보완하는 개선 루프를 돌려야 합니다.
9. 정리
- RAG는 검색된 문서 내용이 그대로 답변에 노출되는 구조이기 때문에, 접근 권한이 있는 사용자에게조차 불필요한 개인정보가 새어나가지 않도록 별도의 마스킹이 필요합니다.
- 정형화된 정보는 정규식으로, 이름·주소 같은 비정형 정보는 NER 모델로 탐지하는 2단계 파이프라인이 실무에서 균형 잡힌 접근입니다.
- 마스킹은 적재 시점에 적용해 원천 차단하는 것이 안전하며, 역할에 따라 마스킹 수준을 다르게 적용해야 하는 경우 접근 제어 구조를 확장해서 처리합니다.
'RAG를 활용한 LLM 서비스 구축' 카테고리의 다른 글
| STEP 32 - 셀프호스팅 LLM으로 RAG 구축하기 (0) | 2026.08.05 |
|---|---|
| STEP 30 - 문서 라이프사이클 관리 (0) | 2026.07.27 |
| STEP 29 - RAG 시스템 재해 복구(DR) 전략 (0) | 2026.07.27 |
| STEP 28 - 벡터 DB 스케일링 (1) | 2026.07.24 |
| STEP 27 - RAG 시스템 비용 모니터링 (0) | 2026.07.24 |