1. 배경: 개인정보를 LLM에 넣지 않고 가입을 끝내기
KT 유선가입 에이전트는 대화만으로 실제 가입까지 완결하는 상용 서비스였습니다. 그러려면 실명, 생년월일, 연락처 같은 개인식별정보(PII)를 다뤄야 합니다. 본인인증을 거치고, 가입 자격을 판정하고, 최종 주문까지 이어져야 하기 때문입니다.
문제는 이 정보가 LLM에 전달되어서는 안 된다는 점입니다.
LLM 호출은 외부 API를 경유합니다. 프롬프트에 실명과 생년월일이 들어가는 순간 그 데이터는 시스템 경계를 벗어납니다. 로그에 남을 수도 있고, 멀티턴이 누적되면 대화 히스토리에 계속 실려 반복 전송됩니다.
그렇다고 개인정보 없이 가입을 진행할 수는 없습니다. 필요하지만 넘길 수 없는 데이터를 어떻게 다룰 것인가가 과제였습니다.
2. 문제 정의: 상태와 개인정보가 한곳에 있었다
초기 구조에서는 대화에 필요한 모든 정보를 하나의 상태 객체에 담고, 그 상태를 매 턴 LLM 컨텍스트에 주입하고 있었습니다.
상태 객체에는 진행 단계와 선택한 상품 같은 대화 제어 정보가 있었고, 여기에 본인인증으로 받은 개인정보가 같은 레벨에 섞여 있었습니다.
이 구조에는 두 가지 결함이 있었습니다.
첫째, 개인정보가 매 턴 외부로 나갔습니다. LLM은 실명을 알 필요가 없습니다. 알아야 하는 것은 "본인인증이 완료되었는가" 뿐입니다. 다만 상태 전체를 주입하다 보니 불필요한 데이터까지 함께 전송됐습니다.
둘째, 자격 판정이 LLM에 의존했습니다. 미성년자나 외국인처럼 가입 조건에 해당하지 않는 경우를 걸러내야 하는데, 이 판정을 LLM 추론에 맡기면 확률적으로 오판이 발생합니다. 자격 판정은 틀리면 안 되는 종류의 결정입니다.
3. 기술적 난점
단순히 개인정보를 빼면 될 것 같지만, 그러면 대화가 진행되지 않습니다.
LLM은 다음에 무엇을 물어야 할지 판단해야 하고, 그러려면 어디까지 진행됐는지를 알아야 합니다. "이름을 아직 안 받았다"는 사실은 알아야 하지만 "이름이 무엇인지"는 몰라도 됩니다.
따라서 정보의 존재 여부와 정보의 내용을 분리해야 했습니다. 이 둘이 같은 필드에 있으면 분리가 불가능합니다.
또 하나의 난점은 검증이었습니다. 개인정보 경로는 실제 데이터로 테스트하기 어렵고, 본인인증 분기는 조건이 많아 수동으로 전부 확인할 수 없었습니다.
4. 대안 검토
1안. 개인정보를 마스킹해서 전달한다
실명을 홍*동 형태로 가공해 프롬프트에 넣습니다.
- 장점: 구현이 간단합니다.
- 단점: 여전히 외부로 전송됩니다. 마스킹은 노출 범위를 줄일 뿐 전송 자체를 막지 못하며, 생년월일처럼 마스킹해도 식별 가능한 정보가 있습니다.
2안. 개인정보 처리만 별도 서비스로 분리한다
PII를 다루는 구간을 독립 서비스로 떼어냅니다.
- 장점: 경계가 명확합니다.
- 단점: 서비스 간 상태 동기화가 필요해지고, 대화 흐름이 두 시스템에 걸쳐 끊깁니다. 비선형 대화에서 정합성 유지 비용이 큽니다.
3안. 상태 객체 내부를 계층화하고, LLM에는 슬롯 충족 여부만 전달한다
민감 데이터를 상태 객체 내 별도 영역에 격리하고, 워크플로에는 완료 여부(boolean)만 노출합니다.
- 장점: 하나의 상태 객체를 유지하면서 전송 경로를 차단합니다.
- 단점: 상태 스키마를 계층 구조로 재설계해야 합니다.
5. 설계 결정 및 근거
3안을 채택했습니다. 근거는 LLM이 실제로 필요로 하는 정보의 수준이었습니다. 대화 흐름을 제어하는 데 필요한 것은 값이 아니라 값의 유무입니다.
5.1 상태 객체의 계층 분리
상태 객체를 두 영역으로 나눴습니다.
상태 객체
├── 워크플로 영역 → LLM 컨텍스트에 주입 (O)
│ 현재 단계, 직전 단계, 라우터 판정,
│ 슬롯 충족 여부(true/false), 경유 이력
│
└── 주문 영역 → LLM 에 전달하지 않음 (X)
실명, 생년월일, 연락처, 주문번호
· AES 암호화 저장
· 세션 종료 시 전량 파기LLM 워크플로에는 "본인인증 완료: true" 만 전달됩니다. 실제 인증 데이터는 주문 영역에 남아 있고, 최종 주문 API를 호출할 때 백엔드가 직접 참조합니다.
이 구조의 핵심은 LLM이 개인정보에 접근할 경로 자체를 없앤 것입니다. 프롬프트로 "개인정보를 출력하지 마"라고 지시하는 방식은 확률적으로 실패하지만, 컨텍스트에 데이터가 없으면 출력할 방법이 존재하지 않습니다.
민감 데이터에는 AES 암호화를 적용하고, 세션이 종료되면 상태 객체를 전량 파기하도록 했습니다.
5.2 자격 검증의 코드 이관
가입 자격 판정은 LLM에서 완전히 분리해 코드로 옮겼습니다.
본인인증 응답 페이로드를 실시간으로 파싱해 미성년자, 외국인 등 가입 불가 조건을 판정합니다. 이 판정은 결정론적으로 동작하며 추론이 개입하지 않습니다.
부수 효과가 있었습니다. 자격 미달 케이스를 진입 시점에 걸러내면서, 최종 주문 단계까지 갔다가 실패하는 트래픽이 줄었습니다. 그 결과 남은 주문 데이터가 유효한 아웃바운드 영업 데이터로서 품질을 갖게 됐습니다.
5.3 단위·종단 테스트의 이원화
보안 구조를 만들고 나니 검증이 문제였습니다. 상태가 계층화되면서 확인해야 할 경로가 늘었기 때문입니다.
테스트를 두 층위로 나눴습니다.
단위 렌더링 점검 · 개별 발화에 대해 화면에 무엇이 나오는지 확인합니다. 노출되는 버튼, 카드 개수, 컴포넌트 종류 같은 것들입니다. 발화 하나가 입력이고 렌더링 결과가 출력입니다.
종단 시나리오 검증 · 세션 전체 흐름을 하나의 단위로 봅니다. 진입부터 최종 주문까지 이어지는 경로에서 상태가 유지되는지, 중간에 이탈했다 복귀해도 정합성이 지켜지는지 확인합니다.
이 둘을 분리한 이유는 실패 원인이 다르기 때문입니다. 단위 테스트가 깨지면 응답 생성이나 UI 매핑 문제이고, 종단 테스트가 깨지면 상태 전이 설계 문제입니다. 두 계층을 통합하면 실패 원인을 특정할 수 없습니다.
특히 검증 범위를 비선형 엣지 케이스까지 넓혔습니다. 본인인증 분기, 단계 도약, 이전 단계 복귀, 타 에이전트 경유 후 복귀처럼 복잡도가 높은 시나리오를 명시적으로 포함했습니다. 단일턴만 검증할 때는 보이지 않던 결함이 여기서 드러났습니다.
운영 이관을 위해 장애 시나리오별 대응 절차를 담은 운영 런북을 함께 작성했습니다.
6. 실험 결과
| 항목 | 개선 전 | 개선 후 |
|---|---|---|
| LLM 컨텍스트 내 개인정보 | 상태 전체 주입 | 전달 경로 차단 (완료 여부만) |
| 민감 데이터 보관 | 상태 객체에 평문 혼재 | 주문 영역 격리 · AES 암호화 · 세션 종료 시 파기 |
| 자격 판정 | LLM 추론 | 코드 기반 결정론적 판정 |
| 테스트 구조 | 단일 계층 | 단위 렌더링 / 종단 시나리오 이원화 |
| 검증 범위 | 단일턴 | 멀티턴 · 비선형 엣지 케이스 포함 |
| 답변 성공률 | 78% | 98%+ |
답변 성공률이 78%에서 98%로 오른 것은 한 번의 개선이 아니라, 실패 케이스 원인 분류 → 파이프라인 수정 → 재평가의 회귀 사이클을 정착시킨 결과입니다. 검증 범위를 넓히면 점수는 일시적으로 떨어지지만, 그 편이 실제 사용 환경에 가깝습니다.
7. 결론 및 시사점
보안은 지시가 아니라 구조에서 확보됩니다. "개인정보를 출력하지 마"라는 프롬프트는 대부분의 경우 지켜지지만 보장은 아닙니다. 컨텍스트에 데이터를 넣지 않으면 유출 경로가 존재하지 않습니다. 이 차이는 확률과 불가능의 차이입니다.
LLM에 무엇을 주지 않을지가 설계의 출발점입니다. 상태를 통째로 주입하는 편이 구현은 쉽습니다. 그러나 필요한 최소 정보만 추리는 과정에서 개인정보 격리와 토큰 절감이 동시에 해결됐습니다. 무엇이 정말 필요한지 따지는 작업은 보안과 비용 양쪽에 이득을 줍니다.
확정성이 필요한 판정은 코드가 담당해야 합니다. 자격 검증처럼 틀리면 사고로 직결되는 결정을 확률적 추론에 맡길 이유가 없습니다. LLM은 의도를 파악하고 문장을 생성하는 데 강하며, 조건을 비교하고 판정하는 일은 코드가 더 잘합니다.
테스트 계층은 실패 원인에 따라 나눠야 합니다. 단위와 종단을 분리하니 결함이 발생했을 때 어느 계층의 문제인지 즉시 특정할 수 있었습니다. 테스트를 나누는 기준은 규모가 아니라 무엇이 깨졌는지 알려주는가였습니다.
