LangChain Structured Output으로 개인정보 마스킹 직접 구현해보기
부부·연인 상담 커뮤니티 글을 저장하기 전 개인정보를 가리려고 LangChain structured output으로 직접 구현해본 과정과, 두 겹의 폴백·한계를 코드와 함께 정리합니다.
정규식만으로는 못 가리는 개인정보
부부·연인 관계 상담 커뮤니티 기능을 만들면서, 사용자가 쓴 글을 저장하기 전에 개인정보를 가려야 하는 상황을 만났다. 개인정보가 그대로 로그·DB에 남으면 안 되기 때문이다. 사용자가 쓰는 글은 대략 이런 식이다.
어제 남편이 또 애 앞에서 소리 지르고, 시댁 얘기까지 꺼내면서... 민준이가 다니는 어린이집 선생님도 눈치챌 정도였어요. 저희 회사(OO물산) 근처 카페에서 상담받고 오는 길인데도 계속 마음이 안 좋네요.
이 문장에서 가려야 할 건 "민준이"(자녀 이름), "OO물산"(회사명) 정도다. 전화번호나 이메일, 주민등록번호처럼 형태가 고정된 값은 정규식으로 잡을 수 있지만, 이런 건 문장 구조를 이해해야 뭐가 개인정보인지 판단할 수 있다. "회사"라는 단어 자체는 개인정보가 아니지만 "OO물산"은 개인정보다 — 이 구분은 정규식 같은 패턴 매칭만으로는 되지 않는다.
그래서 LLM에게 문장을 통째로 맡기고, 구조화된 출력(structured output)으로 결과를 강제하는 방식을 직접 구현해보기로 했다.
원하는 출력 형태를 Pydantic 모델로 못 박기
LLM에게 "개인정보를 가려줘"라고만 시키면 응답 형식이 매번 달라진다. 어떤 때는 마스킹된 문장만 주고, 어떤 때는 "다음과 같이 수정했습니다:" 같은 사족을 붙이고, 어떤 때는 마크다운으로 감싸서 준다. 이걸 파싱하려고 정규식을 또 짜는 건 본말전도라고 생각해서, 처음부터 구조화 출력으로 접근했다.
LangChain의 with_structured_output은 Pydantic 모델을 스키마로 넘기면, 모델이 그 형태에 맞는 JSON을 내도록 강제해준다(내부적으로는 provider의 tool/function calling 메커니즘을 이용해 스키마를 도구 인자로 바인딩하는 방식이다). 나는 이렇게 정의했다.
1from pydantic import BaseModel, Field2 3class PiiMaskingResult(BaseModel):4 masked_text: str = Field(5 description=(6 "실명, 자녀 이름, 회사명, 학교명, 동네·아파트 이름, 전화번호, 이메일 등 "7 "특정 개인을 알아볼 수 있는 표현만 'OO', '아이', '그 동네'처럼 자연스러운 "8 "일반 표현으로 바꾼 글. 감정 표현과 문장의 의미, 어조는 그대로 유지하고, "9 "가릴 정보가 없으면 원문을 그대로 반환한다."10 )11 )12 masked_entities: list[str] = Field(13 default_factory=list,14 description="마스킹 처리된 원본 표현 목록 (없으면 빈 배열)",15 )여기서 중요한 점은 Field(description=...)이 단순 문서화가 아니라 사실상 프롬프트의 일부로 동작한다는 것이다. 이 설명은 모델이 도구를 호출할 때 각 인자가 무엇을 의미하는지 판단하는 근거가 되므로, "실명, 자녀 이름, 회사명... 을 이렇게 바꿔라"는 지시를 필드 설명 안에 녹여 넣은 셈이다. 시스템 프롬프트와 스키마 설명이 이중으로 같은 지시를 하는 구조다.
masked_entities 필드는 실제 서비스 로직에서 안 쓰더라도 일부러 넣었다. 모델이 "무엇을 왜 가렸는지" 스스로 근거를 대게 만들면, 결과를 검수하거나 디버깅할 때 "이 문장에서 뭘 마스킹 대상으로 판단했는지"를 바로 확인할 수 있다는 이점이 있다.
시스템 프롬프트와 휴먼 메시지 분리하기
1from langchain_core.prompts import ChatPromptTemplate2 3_PII_MASKING_PROMPT = ChatPromptTemplate.from_messages([4 (5 "system",6 "당신은 커뮤니티에 올라오는 글에서 개인을 특정할 수 있는 정보를 가려주는 편집자입니다. "7 "실명, 자녀 이름, 구체적인 회사명·학교명·동네 이름, 전화번호, 이메일 주소 등을 찾아 "8 "'OO', '아이', '그 동네' 같은 일반적인 표현으로 자연스럽게 바꾸세요. "9 "감정 표현이나 갈등 내용, 문장의 어조와 의미는 그대로 유지하세요. "10 "가릴 정보가 없으면 원문을 그대로 반환하세요. 반드시 한국어로 답하세요.",11 ),12 ("human", "다음 글을 편집해주세요:\n\n{content}"),13])프롬프트를 짜면서 세 가지를 의식적으로 나눠서 지시했다.
- 역할 부여 — "개인정보를 지우는 필터"가 아니라 "편집자"라고 프레이밍했다. 단순 삭제가 아니라 문장을 자연스럽게 유지한 채로 치환하라는 의도를 역할 설정으로 전달하고 싶었다.
- 대상 목록 나열 — 실명, 자녀 이름, 회사명, 학교명, 동네 이름, 전화번호, 이메일. 구체적인 카테고리를 예시로 주는 게 "개인정보를 가려줘" 한 줄보다 재현율을 높이는 데 도움이 된다.
- 하지 말아야 할 것 명시 — "감정 표현이나 갈등 내용, 문장의 어조와 의미는 그대로 유지하세요." 이 지시가 없으면 LLM이 마스킹 대상이 아닌 부분까지 요약·순화해 원문의 뉘앙스(예: 화가 난 어조)를 함께 지워버릴 여지가 있다. 그래서 마스킹 작업에서는 "무엇을 바꿔야 하는가"뿐 아니라 "무엇을 바꾸지 말아야 하는가"까지 프롬프트에 명시했다.
프롬프트 → 구조화 LLM 체이닝하기
1def _legacy_mask_pii(content: str) -> dict:2 llm = _build_llm()3 if llm is None:4 return _mock_mask_pii(content)5 6 try:7 structured_llm = llm.with_structured_output(PiiMaskingResult)8 chain = _PII_MASKING_PROMPT | structured_llm9 result: PiiMaskingResult = chain.invoke({"content": content})10 return result.model_dump()11 except Exception:12 logger.exception("PII masking failed; using regex fallback")13 return _mock_mask_pii(content)_PII_MASKING_PROMPT | structured_llm처럼 LCEL(LangChain Expression Language)의 파이프 연산자로 "프롬프트 템플릿 → 구조화 출력 LLM"을 체이닝했다. chain.invoke({"content": content})를 호출하면:
- 템플릿에 content를 채워 시스템+휴먼 메시지를 만들고
- LLM이 이 메시지를 받아 PiiMaskingResult 스키마에 맞는 인자로 도구를 호출하고
- 그 결과가 자동으로 PiiMaskingResult 인스턴스로 파싱되어 돌아온다
받는 쪽에서는 result.masked_text, result.masked_entities처럼 타입이 보장된 필드로 바로 접근할 수 있다. JSON 파싱 실패나 마크다운 코드펜스 제거 같은 방어 코드가 필요 없다는 게 이 방식의 실익이다.
두 겹의 폴백
LLM 호출이 실패할 수 있는 지점이 두 군데라, 각각에 폴백을 걸어뒀다.
1_PHONE_RE = re.compile(r"01[016789][-\s]?\d{3,4}[-\s]?\d{4}")2_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")3 4def _mock_mask_pii(content: str) -> dict:5 """API 자격증명 없을 때 mock 응답 (로컬 개발용) — 전화번호·이메일만 정규식으로 가림."""6 masked = _PHONE_RE.sub("OOO-OOOO-OOOO", content)7 masked = _EMAIL_RE.sub("[이메일 가림]", masked)8 entities = ["연락처/이메일"] if masked != content else []9 return {"masked_text": masked, "masked_entities": entities}- llm is None일 때 — API 자격증명이 설정 안 된 로컬 개발 환경에서는 아예 LLM을 호출하지 않고 최소한의 정규식 마스킹으로 대체했다. API 키 없이도 로컬에서 기능이 "그럴듯하게" 동작하게 만들고 싶어서 넣은 개발 편의 장치다.
- except Exception일 때 — API 타임아웃, 요금 한도, 응답 스키마 불일치 등 런타임 실패 시에도 같은 정규식 폴백으로 떨어지게 했다. LLM 마스킹이 실패했다고 원문을 그대로 저장하는 최악의 경우는 피하고 싶었다.
두 경로가 결국 같은 _mock_mask_pii로 수렴하도록 짜서, "폴백 로직을 폴백하는" 이중 분기 없이 하나의 안전망으로 두 가지 실패 상황을 모두 처리하게 만들었다.
이 방식의 장단점
장점
- 정규식으로는 표현 불가능한 문맥적 개인정보(회사명, 동네 이름, 관계 속에서 등장하는 고유명사)까지 잡아낼 수 있다.
- 마스킹 후에도 문장이 자연스럽게 유지된다. 정규식/NER 기반 마스킹은 대상을 [REDACTED]나 0000처럼 뭉개는 방식이라 문장이 부자연스러워지기 쉬운데, LLM은 "그 동네", "아이"처럼 문맥에 맞는 대체어를 고를 수 있다.
- 스키마 기반 구조화 출력 덕분에 파싱 코드가 거의 필요 없다.
한계
- 같은 입력이라도 호출마다 결과가 미세하게 달라질 수 있다(비결정성). 마스킹처럼 "빠짐없이, 항상 같게" 보장돼야 하는 작업에서는 감안해야 할 리스크다.
- 호출당 레이턴시와 토큰 비용이 붙었다. 글 저장 경로처럼 사용자 응답 속도에 민감한 곳에서는 부담이 되는 지연이다.
- "가릴 정보가 없으면 원문을 그대로 반환하라"고 프롬프트에 명시해도, 놓치는 케이스(false negative)를 100% 배제한다는 보장은 프롬프트만으로는 얻을 수 없었다.
정리하면, LLM structured output이 정말 잘 맞는 자리는 "정답이 하나로 정해지지 않는, 자연스러움이 중요한 생성 작업"(톤 조정, 감정 분석, 요약 등)이지, 개인정보 마스킹처럼 빠짐없는 재현성이 요구되는 검증형 작업에는 전용 NER API나 정규식 같은 결정적 알고리즘과 함께 쓰거나 그쪽으로 옮기는 편이 더 안전하다는 것이다.
전용 PII 인식 API로 옮기면서 마주친 디테일
그래서 이후에는 Azure AI Language의 PII Entity Recognition(전용 NER 모델)으로 옮겼다. LLM이 문장을 다시 쓰는 대신, 개인정보로 판단되는 구간의 정확한 문자 좌표(offset, length)를 돌려주기 때문에 항상 같은 입력에 같은 결과가 나온다.
이 과정에서 짚어야 할 문제가 하나 있다. Azure 모델은 "남편", "아내" 같은 호칭도 PersonType이라는 카테고리로 함께 잡아낸다. 이걸 그대로 마스킹하면 "OOO가 나한테 소리질렀어" 같은 문장이 되어버리는데, 관계 상담 서비스에서는 "누가 무엇을 했는지"가 핵심이라 이 정보가 지워지면 글의 의미 자체가 사라진다.
이걸 고정된 단어 목록(예: {"남편", "아내", "와이프", "신랑"} 같은 화이트리스트)으로 처리할 수도 있었지만, 그렇게 하면 목록에 없는 표현("배우자", "그이", "신랑감" 등)은 계속 새로 추가해야 하는 문제가 생긴다. 대신 Azure가 이미 분류해주는 카테고리 단위로 걸러내는 방식을 택했다.
1for entity in entities:2 ...3 # 관계 호칭·역할(남편/아내/상대/배우자/부모 등)은 PersonType으로 잡히는데4 # 개인을 특정하지 않으므로 마스킹하지 않는다. 실명(Person)·전화·주소 등은 계속 마스킹.5 if entity.get("category") == "PersonType":6 continue7 ...entity.get("category")가 "PersonType"이면 그 구간은 마스킹 대상 목록(spans)에 아예 넣지 않고 건너뛴다. 반면 실명은 Person, 전화번호는 PhoneNumber처럼 다른 카테고리로 분류되기 때문에 그대로 마스킹된다. 특정 단어를 나열하는 대신 모델이 이미 매긴 분류 체계에 올라타서, "이 표현이 개인을 특정하는가"와 "이 표현이 관계 속 역할을 가리키는가"를 구분한 셈이다.
여기서 확인할 수 있는 점은, 도메인에 특화된 예외 처리가 필요할 때 항상 하드코딩된 단어 목록부터 만들 필요는 없다는 것이다. 이미 쓰고 있는 API나 모델이 제공하는 분류 체계(카테고리, 라벨, 타입) 중에 원하는 구분과 맞아떨어지는 게 있는지부터 확인하면, 유지보수해야 할 목록 하나를 아예 안 만들어도 된다.
정리
- with_structured_output + Pydantic 모델로 LLM 응답 형식을 강제하면 파싱 코드 없이 타입 안전한 결과를 받을 수 있다.
- Field(description=...)는 문서가 아니라 모델에게 전달되는 지시문의 일부로 취급해야 한다.
- 프롬프트는 "무엇을 바꿔야 하는가"뿐 아니라 "무엇을 바꾸지 말아야 하는가"까지 명시해야 원문의 톤이 보존된다.
- LLM 호출 경로에는 항상 두 가지 실패 지점(미설정/런타임 에러)을 구분해서 각각 폴백을 걸어두는 게 안전하다.
- 다만 이 기법이 잘 맞는 자리는 "정답이 하나로 정해지지 않는, 자연스러움이 중요한 생성 작업"이지, "빠짐없는 재현성이 요구되는 검증형 작업"에는 결정적 알고리즘과 병행하거나 대체하는 편이 낫다.
- 도메인 특화 예외 처리가 필요할 때, 하드코딩된 단어 목록부터 만들기보다 이미 쓰고 있는 API/모델이 제공하는 분류 체계(카테고리·라벨)를 먼저 확인하면 유지보수 부담을 줄일 수 있다.