개인정보 필터링이 왜 LLM 시스템의 기본 설계가 되는가
개인정보 필터링은 출력 품질을 위한 보조 기능이 아니라, LLM 시스템에 데이터를 넘기기 전에 반드시 정의해야 하는 경계 조건입니다. OWASP의 생성형 보안 가이드에서도 민감 정보 노출을 주요 위험으로 다루고 있고, 입력과 출력 모두에 대해 필터링과 검증을 적용하라고 권고합니다. 단순히 사용자 입력만 조심하면 끝나는 문제가 아니라, 시스템 프롬프트, 도구 호출 결과, 로그, 모니터링 데이터까지 함께 봐야 합니다.
실무에서는 “사용자가 직접 쓴 문장만 검사하면 되겠지”라고 생각하기 쉽습니다. 그런데 실제로는 상담 이력, 주문 정보, CRM 메모, 내부 문서 조각이 함께 프롬프트에 합쳐지는 경우가 많습니다. 이때 개인정보가 한 번 섞이면 어디서 들어왔는지 추적하기 어려워집니다. 그래서 필터링은 모델 호출 직전 한 번만 하는 방식보다, 데이터 수집 단계와 조합 단계에서 여러 번 거는 편이 유지보수에 유리합니다.
개인정보 필터링은 어디에서 해야 하는가
개인정보 필터링은 한 지점에서 끝내는 구조보다, 입력 전처리, 저장 전 마스킹, 모델 호출 직전 스크럽, 출력 후 검증의 네 단계로 나누어 보는 편이 더 정확합니다. 특히 OWASP는 입력과 출력 모두에 필터링과 검증을 적용하고, 민감한 제어는 시스템 프롬프트 내부가 아니라 외부 시스템에서 강제하라고 설명합니다.
1. 입력 수집 단계
사용자가 입력한 원문을 그대로 다음 단계로 넘기지 말고, 먼저 탐지와 분류를 거쳐야 합니다. 여기서는 이메일, 전화번호, 주민등록번호, 카드번호 같은 명확한 패턴뿐 아니라 주소, 사람 이름, 계정 식별자처럼 문맥 기반 탐지도 같이 고려해야 합니다. 정규식만으로는 커버가 부족하고, NER 기반 탐지나 룰 기반 탐지를 혼합하는 이유가 여기에 있습니다.
2. 저장 및 로그 단계
이 단계가 생각보다 중요합니다. 모델 호출 전에 마스킹을 잘해도, API 요청 로그나 에러 로그에 원문이 남아버리면 설계 의도가 무너집니다. OpenAI의 API 데이터 제어 문서도 기본적으로 abuse monitoring 로그가 생성될 수 있고, 기본 보존 기간이 최대 30일이라고 설명합니다. 민감한 워크로드에서는 데이터 보존 설정과 로깅 범위를 같이 봐야 하는 이유입니다.
3. 모델 호출 직전 단계
가장 실무적인 지점입니다. 여러 소스에서 모은 텍스트를 하나의 프롬프트로 합치기 직전에 마지막 스크럽을 한 번 더 거는 방식이 보통 가장 효과적입니다. 특히 검색 기반 응답이나 내부 문서 요약처럼 외부 입력과 내부 데이터가 섞이는 경우에는, 이 마지막 단계가 빠지면 필터링 누락이 자주 생깁니다.
4. 출력 검증 단계
입력만 깨끗하다고 끝나지 않습니다. 모델이 내부 문맥을 재구성하면서 민감 정보를 다시 드러내는 경우도 있기 때문입니다. OWASP는 출력도 검증하고, 하위 시스템으로 전달하기 전에는 정해진 포맷과 허용 범위를 코드로 확인하라고 권고합니다. 이 부분은 챗봇뿐 아니라 요약, 자동 응답, 티켓 분류에도 그대로 적용됩니다.
개인정보 필터링 방식은 제거, 마스킹, 가명처리로 나눠서 봐야 합니다
개인정보 필터링이라고 해서 항상 삭제만 하는 것은 아닙니다. 어떤 값은 완전히 제거해야 하고, 어떤 값은 부분 마스킹이면 충분하며, 어떤 값은 같은 사람을 연속 대화에서 식별해야 하므로 가명처리가 더 적합합니다. 이 차이를 먼저 정리해야 이후 코드와 정책이 흔들리지 않습니다.
완전 제거
모델이 해당 값을 전혀 알 필요가 없으면 제거가 가장 단순합니다. 예를 들어 고객의 실제 전화번호나 카드번호가 답변 생성에 필요 없다면 아예 삭제하는 편이 낫습니다. 필요한 정보보다 더 많이 넘기지 않는 데이터 최소화 원칙은 보안과 유지보수 양쪽에서 모두 유리합니다. NIST도 식별 위험을 줄이기 위해 데이터 최소화와 비식별화 관점을 꾸준히 강조해 왔습니다.
부분 마스킹
형태는 유지하되 원문을 숨겨야 할 때 적합합니다. 예를 들어 user@example.com을 u•••@example.com으로 바꾸거나, 010-1234-5678을 010-****-5678로 바꾸는 방식입니다. 상담 화면, 관리자 확인, 응답 설명처럼 “이 값이 존재했다”는 사실은 남겨야 할 때 많이 씁니다.
가명처리 또는 토큰화
같은 사용자를 흐름 안에서 계속 구분해야 한다면 가명처리가 더 낫습니다. 예를 들어 김민수, 전화번호, 고객 ID를 모두 USER_17 같은 토큰으로 바꾸면 모델은 문맥을 유지할 수 있고, 원문은 별도 안전 영역에서만 복원할 수 있습니다. Google Cloud의 Sensitive Data Protection 문서도 마스킹 외에 pseudonymization과 de-identification을 별도 기법으로 설명합니다.
실무에서는 탐지 정확도보다 정책 분리가 더 중요합니다
많이 놓치는 부분이 있습니다. 개인정보 필터링을 “탐지 엔진 성능 문제”로만 보면 금방 막힙니다. 실제로는 어떤 데이터를 막을지, 어떤 데이터는 남길지, 누가 예외를 승인할지, 원문 복원이 가능한지, 로그에는 어느 수준까지 남길지를 나누는 정책 설계가 더 중요합니다.
예를 들어 Microsoft Presidio도 탐지가 자동화되어 있어 모든 민감 정보를 완벽하게 찾는다고 보장하지는 않으며, 추가적인 보호 체계를 함께 써야 한다고 명시합니다. 이 말은 곧 탐지기 하나로 모든 문제를 해결할 수 없다는 뜻입니다. 그래서 운영에서는 탐지기, 룰셋, 예외 목록, 차단 정책, 감사 로그를 분리해서 보는 편이 낫습니다.
LLM 앞단에 두는 개인정보 필터링 아키텍처
개인정보 필터링은 라이브러리 하나 붙인다고 끝나지 않습니다. 구조를 단순하게 잡으면 보통 아래 흐름이 가장 관리하기 쉽습니다.
Client
-> API Server
-> PII Detector
-> Policy Engine
-> Redactor / Tokenizer
-> Prompt Builder
-> LLM Provider
-> Output Validator
-> Response
여기서 중요한 것은 역할을 섞지 않는 것입니다. PII Detector는 탐지만 하고, Policy Engine은 차단/허용/치환 규칙을 결정하며, Redactor는 실제 텍스트를 바꾸고, Prompt Builder는 정제된 데이터만 사용해야 합니다. 이 구조가 좋은 이유는 추후 탐지기를 교체하더라도 비즈니스 정책을 그대로 유지할 수 있기 때문입니다.
시스템 프롬프트 안에 “개인정보는 답변하지 마라” 같은 문장을 넣는 것만으로는 충분하지 않습니다. OWASP도 민감 제어와 권한 제어는 LLM 바깥 시스템에서 강제하는 방식을 권장합니다. 이 부분은 실무에서 특히 중요합니다. 프롬프트 문장 하나에 정책을 의존하면 디버깅도 어렵고, 재현성도 흔들립니다.
TypeScript 기준으로 보는 간단한 적용 예시
아래 예시는 NestJS 서비스 앞단에서 텍스트를 검사하고, 필요한 경우 마스킹된 값만 프롬프트 빌더로 넘기는 흐름입니다. 실제 서비스에서는 이메일, 전화번호, 계좌번호, 이름, 내부 고객 ID 등을 엔티티 단위로 분리해 관리하는 편이 좋습니다.
type EntityType =
| 'EMAIL'
| 'PHONE'
| 'RESIDENT_ID'
| 'ACCOUNT_ID'
| 'PERSON_NAME';
interface DetectedEntity {
type: EntityType;
start: number;
end: number;
value: string;
}
interface SanitizeResult {
sanitizedText: string;
entities: DetectedEntity[];
blocked: boolean;
reason?: string;
}
class PiiSanitizer {
sanitize(text: string): SanitizeResult {
const entities: DetectedEntity[] = [];
const emailRegex = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
const phoneRegex = /\b01[016789]-?\d{3,4}-?\d{4}\b/g;
let sanitizedText = text.replace(emailRegex, (match, offset) => {
entities.push({
type: 'EMAIL',
start: offset,
end: offset + match.length,
value: match,
});
const [id, domain] = match.split('@');
return `${id.slice(0, 1)}***@${domain}`;
});
sanitizedText = sanitizedText.replace(phoneRegex, (match, offset) => {
entities.push({
type: 'PHONE',
start: offset,
end: offset + match.length,
value: match,
});
return match.replace(/\d(?=\d{4})/g, '*');
});
const blocked = entities.some((e) => e.type === 'RESIDENT_ID');
return {
sanitizedText,
entities,
blocked,
reason: blocked ? '민감 식별자는 마스킹이 아니라 차단 대상으로 처리합니다.' : undefined,
};
}
}
이 예시는 일부 패턴만 보여주는 수준입니다. 실무에서는 정규식만으로 끝내지 않고, 탐지 결과를 다시 정책 엔진이 받아서 “마스킹”, “가명처리”, “차단”, “원문 유지 허용” 중 하나를 결정하게 만드는 편이 확장성이 좋습니다.
개인정보 필터링에서 자주 틀리는 부분
프롬프트만 가리면 된다고 생각하는 경우
실제로는 로그, 캐시, 에러 메시지, 분석 이벤트, 운영자 화면이 더 먼저 새는 경우가 많습니다. 모델 호출부만 가리고 주변 시스템이 원문을 들고 있으면 보호가 완성되지 않습니다.
탐지 실패를 0으로 만들려는 경우
탐지 정확도를 올리는 일은 중요하지만, 완전 탐지를 목표로만 잡으면 오히려 설계가 경직됩니다. 이 경우에는 미탐 가능성을 전제로 출력 검증, 저장 제한, 권한 분리, 짧은 보존 기간을 함께 두는 편이 더 낫습니다.
가명처리 없이 무조건 삭제하는 경우
요약, 분류, 상담 맥락 유지처럼 동일 인물을 대화 안에서 구분해야 하는 작업에서는 무조건 삭제가 오히려 품질을 해칠 수 있습니다. 이럴 때는 토큰화가 더 적절합니다. 다만 토큰과 원문 매핑 테이블은 반드시 별도 안전 영역에 두어야 합니다.
시스템 프롬프트에 정책을 모두 넣는 경우
이 방식은 처음에는 쉬워 보이지만, 정책 변경 이력 관리와 테스트가 어렵습니다. 권한, 차단, 허용 범위 같은 규칙은 코드와 설정에서 검증 가능하게 두고, 프롬프트는 설명 역할에 가깝게 두는 편이 안정적입니다.
도입할 때 먼저 정해야 하는 체크리스트
개인정보 필터링을 붙이기 전에 아래 질문부터 정리하면 방향이 훨씬 선명해집니다.
첫째, 어떤 필드를 절대 LLM에 보내면 안 되는지 목록이 있어야 합니다.
둘째, 어떤 필드는 마스킹 후 허용 가능한지 구분해야 합니다.
셋째, 동일 사용자 추적이 필요한 업무인지 판단해야 합니다.
넷째, 요청 본문과 응답 본문이 로그에 남는지 확인해야 합니다.
다섯째, 공급자 보존 정책과 자사 보존 정책을 따로 확인해야 합니다. OpenAI는 비즈니스 데이터에 대해 기본 학습 비사용, 보존 제어, 일부 환경의 zero data retention 옵션을 안내하고 있습니다. 이런 옵션이 있다고 해서 애플리케이션 내부 로그까지 자동으로 안전해지는 것은 아니므로, 내부 저장 정책은 별도로 설계해야 합니다.
개인정보 필터링은 기능이 아니라 경계 설계입니다
개인정보 필터링은 챗봇에 덧붙이는 옵션이 아니라, LLM 앞뒤에 데이터 경계를 세우는 설계라고 보는 편이 맞습니다. 입력 단계에서 탐지하고, 정책으로 판단하고, 마스킹 또는 가명처리를 적용하고, 호출 직전 다시 확인하고, 출력과 로그까지 검증해야 흐름이 닫힙니다.
정리하면 기준은 단순합니다. 모델이 몰라도 되는 값은 보내지 않고, 알아야 하지만 원문이 불필요한 값은 치환하고, 연속 문맥에 필요한 식별자는 가명처리하며, 정책 집행은 프롬프트가 아니라 시스템에서 강제하는 것입니다. 이 정도 기준만 지켜도 개인정보 필터링은 훨씬 관리 가능한 문제로 바뀝니다.