AI 지원 코파일럿(Copilot)에서 에이전트형(Agentic) 고객 서비스로
요약
AI 코파일럿에서 에이전트형 고객 서비스 시스템으로 전환하며 겪은 개인정보(PII) 유출 문제와 보안 설계의 교훈을 다룹니다. 단순 모델 출력 감시를 넘어, 데이터가 유입되고 도구 결과로 전달되는 전체 경로에서의 보안 중요성을 강조합니다.
핵심 포인트
- 모델 출력뿐만 아니라 도구 결과(tool result)를 통한 PII 유출 경로를 차단해야 함
- 데이터 비식별화(Redact) 대신 금고(Vault)를 통한 토큰화 방식 권장
- 단순 코파일럿과 달리 에이전트 시스템은 데이터 흐름 전체에 대한 엄격한 평가가 필요함
- 정규 표현식과 체크섬을 활용한 식별자 유형 관리의 효율성
요약 (TL;DR)
- 유입 단계에서의 토큰화(Tokenizing)는 반환 경로(return path)에 아무런 도움이 되지 않습니다. 에이전트가 CRM을 쿼리하고 가공되지 않은 신원 정보가 도구 결과(tool result)로서 모델 컨텍스트(model context)로 직접 들어온다는 증거가 있습니다.
- 탐지기(Detector)는 언어 모델(Language Model)이 되어서는 안 됩니다. 당신이 방어하고자 하는 대상은 입력(input)이기 때문입니다.
- 정규 표현식(Regex)이 제안하고, 체크섬(Checksum)이 확인합니다. 식별자 유형(identifier type)을 추가하는 데는 20줄이면 충분합니다.
- 금고(Vault)로 토큰화하되, 비식별화(Redact)하지 마세요. 비식별화는 답변을 보내야 하는 가교를 끊어버립니다.
- 삭제(Erasure)는 데이터가 실제로 어디로 갔는지 알려주며, GDPR은 이를 실행할 수 있는 쿼리를 보유할 것을 요구합니다.
- 우리가 출시한 가장 중요한 기능은 상담원이 실제로 보낸 이메일을 첨부할 수 있는 능력이었습니다.
나는 내 AI 지원 시스템에서 개인 정보가 유출될 수 있는 곳이 단 한 군데뿐이라고 생각했습니다.
하지만 세 군데가 있었습니다. 모델은 유출 경로가 아니었습니다.
파트 2에서 나는 다음과 같은 단언(assertion)을 포함하여 우리가 추가한 테스트 목록을 나열했습니다:
PII(개인 식별 정보)가 가설 텍스트(hypothesis text)로 유출되지 않음
나는 CI에서 해당 테스트가 통과(green)되는 것을 지켜보았고, 문제가 해결되었다고 보고했습니다. 하지만 그렇지 않았습니다. 그 단언은 모델의 _출력(output)_을 감시하는데, 이는 세 개의 문 중 하나일 뿐이며 가장 덜 위험한 문이었습니다.
파트 3은 프로덕션화(productionization)가 거의 완료되었다는 내용과 함께 다음과 같은 약속으로 끝났습니다. 이 포스트가 조용한 실패, 우리가 예측하지 못한 동작, 그리고 두 번째 반복(iteration)을 변화시킨 피드백 루프를 다룰 것이라는 약속 말입니다. 해당 평가는 이 세 가지 모두의 완벽한 예시입니다. 왜냐하면 그 평가에 대해서는 그 어떤 것도 실패한 적이 없기 때문입니다. 잘못된 문을 지켜보는 동안, 그것은 매번 통과했습니다.
두 달 후, 솔직한 버전을 말씀드리겠습니다. 시스템은 규모가 아니라 성격이 변했습니다. 두 개의 런타임 컨테이너(runtime containers)에서 27개의 서비스로 늘어났으며, 그중 거의 대부분은 우리가 계획했던 기능에서 나온 것이 아니었습니다.
파트 3 시점에 내가 가졌던 것은 **코파일럿(copilot)**이었습니다. 지원 상담원 옆에 앉아 증거를 건네주는 무언가 말이죠. 그 포스트를 다시 읽어보니, 나는 내가 증명한 것보다 더 많은 것을 주장했습니다. 나는 프로세스의 한 단계에 대해 하나의 수집(ingest) 작업이 작동하도록 만들었을 뿐입니다. 그것은 고객 서비스를 운영하는 것과는 다릅니다.
코파일럿(Copilot)은 고객의 개인 데이터가 어디로 가는지 무시할 수 있습니다. 어쨌든 사람이 모든 것을 읽기 때문입니다. 한 사람이 하나의 카드(card)를 보고 있으므로 작업 큐(work queue)도 필요하지 않습니다. 코파일럿은 실제로 무엇이 전송되었는지 알 필요도 없습니다. 무언가를 처리했다고 주장한 적이 없기 때문입니다.
하지만 고객 서비스 시스템(customer-service system)은 이 중 그 어느 것도 무시할 수 없습니다. 그리고 이 모든 것들은 평가(eval) 과정과 동일한 방식으로 발생했습니다. 아무런 오류도 발생하지 않았고, 경고(alert)도 울리지 않았으며, 모든 체크 항목은 녹색(green)이었습니다.
Lesson 12: 회신 경로(return path)는 하나의 문이다
티켓(ticket)이 도착합니다. 증거 에이전트(evidence agents)들이 병렬로 실행됩니다. 하나는 CRM에 이 고객이 누구인지 묻고, 하나는 텔레메트리(telemetry)에 무엇이 트리거되었는지 묻고, 하나는 프로비저닝(provisioning)을 확인합니다. 각 결과는 모델 컨텍스트(model context)가 됩니다.
이제 개인 데이터의 흐름을 추적해 보겠습니다:
고객 이메일 ──► [strip] ──► "{a3f1...}가 충돌을 보고함" ──┐
├──► 모델 컨텍스트
CRM 조회 ──────────────────► {"name": "Jan Bakker", ──┘
...
두 데이터 모두 동일한 컨텍스트 윈도우(context window)에 도착합니다. 그중 하나만이 스트리퍼(stripper)를 통과했습니다.
수집 토큰화(Intake tokenization)는 두 번째 경로를 전혀 건드리지 않았습니다. 당신은 정문(front door)을 청소했지만, 옆문(side door)을 통해 동일한 데이터가 그대로 다시 들어왔습니다.
따라서 경계(boundary)는 하나가 아니라 세 개입니다:
┌────────────────────┐
고객 메일 ──1──► │ │
│ 모델 컨텍스트 │ ──3──► 고객에게 회신
... │ │
└────────────────────┘
세 개의 문이 있으며, 이들은 동일한 수준의 보장을 제공하지 않습니다. 나는 3번 문에 계측(instrumented)을 수행했고 1번 문을 부분적으로 커버했습니다. 하지만 회신 경로(return path)는 전혀 고려하지 않았습니다. 또한 이는 세 개 중 가장 어려운 경로입니다. 데이터가 당신이 신뢰하는 시스템으로부터, 당신이 제어할 수 없는 형태로, 당신이 의도적으로 보낸 쿼리(query)에 대한 응답으로 들어오기 때문입니다.
이 경로에 대한 선행 기술(prior art)도 가장 적습니다. 사용자 입력(user input)을 삭제(scrubbing)하거나 모델 출력(model output)을 필터링하는 것에 대한 지침은 풍부합니다. 하지만 회신 경로에 대해서는 이를 경계(boundary)로 취급하는 사양(specification)을 전혀 찾을 수 없었습니다.
Lesson 13: 탐지기(detector)는 언어 모델(language model)이 될 수 없다
모델이 특정 데이터가 개인정보인지 판단하기 전에 가공되지 않은(raw) 개인 데이터를 먼저 확인해야 한다면, 당신은 이미 실패한 것입니다. 탐지(Detection)는 모델이 텍스트를 전혀 보기 전에 완료되어야 합니다.
이로 인해 명백한 접근 방식은 배제되며, 두 가지 방식으로 나뉘는 결정론적(deterministic)인 방식만 남게 됩니다. Microsoft Presidio는 인식기 레지스트리(recognizer registry), 컨텍스트 점수 산정(context scoring) 및 교체 단계를 제공합니다. spaCy는 지원되는 언어당 하나의 모델을 사용하여 모호한 대상, 사람, 조직 및 위치를 태깅하는 로케일별 개체명 인식(NER, Named Entity Recognition)을 제공합니다.
정규 표현식(regex)이 제안하고, 체크섬(checksum)이 확인한다
spaCy는 "회계부의 Maria"와 같은 표현에는 능숙하지만, 9자리 국가 식별 번호에는 무용지물입니다. 구조화된 식별자는 개체(entity)처럼 보이지 않고, 단순히 숫자로 보이기 때문입니다.
이 지점이 바로 확장 가능한 부분입니다. 식별자 유형을 추가하는 것은 패턴과 검증기(validator)를 더하는 것이며, 실제 작업은 검증기가 수행합니다. 네덜란드 BSN은 체크섬을 포함하고 있으므로, 의도적으로 낮은 점수로 느슨하게 매칭된 결과는 산술 연산을 통해 확인되거나 폐기됩니다.
class NlBsnRecognizer(PatternRecognizer):
def __init__(self):
super().__init__(
...
정규 표현식만 사용한다면 메시지 내의 모든 8~9자리 숫자(주문 번호, 타임스탬프, 버전 코드 등)를 토큰화할 것입니다. 체크섬이 바로 정밀도를 높여주는 요소입니다. 세금 번호, IBAN 또는 우편번호와 같은 모든 추가 식별자도 동일한 20줄의 코드로 처리됩니다.
재현율(Recall)이 정밀도(precision)보다 중요하다, 스택 트레이스(stack trace)를 제외하면
영어가 아닌 언어의 밀집된 비즈니스 텍스트에 대한 NER은 노이즈가 많습니다. 일반적인 도로 주소는 로케일별 인식기를 추가하기 전까지 비식별화(redaction)되지 않은 채 남아 있었습니다. 기본 전화번호 인식기의 지역 목록에 우리 지역이 포함되지 않았기 때문입니다. 과도한 비식별화는 약간의 컨텍스트를 잃게 만들지만, 유출된 비밀 정보는 모든 것을 잃게 만듭니다.
한 가지 예외는 이 편향이 맹목적일 수 없음을 증명합니다. 스택 트레이스 내의 .NET 네임스페이스(namespace)는 사람의 이름과 정확히 똑같이 읽힙니다. 엄격하게 마킹된 기술적 영역 내부에서는 사람 및 위치 범위(span)가 제외됩니다.
버그는 오직 실제 텍스트만이 가르쳐준다
두 가지 서로 다른 메커니즘에서 생성된 범위(span)가 부분적으로 겹칩니다:
"contact Jan Bakker at jan.bakker@acme.nl"
└─ PERSON ─┘
└────── EMAIL ──────┘
...
하나를 유지하고 다른 하나를 삭제하면, 삭제된 범위(span)의 뒷부분이 메시지에 일반 텍스트(cleartext)로 남게 됩니다. 겹치는 범위들을 합집합으로 병합하고 전체 서브스트링(substring)을 토큰화(tokenize)하면, 감지된 모든 문자가 포함됩니다.
레슨 14: 마스킹(redact)하지 말고 토큰화(tokenize)하라
값을 검게 칠해 가리는 것(Blacking out)은 당연해 보이는 선택입니다. 하지만 그것은 틀린 방법입니다.
이 시스템의 목적은 실제 주소에 있는 실제 사람에게 답변을 보내는 것입니다. 데이터가 들어오는 과정에서 데이터를 파괴해 버리면, 아무것도 다시 보낼 수 없습니다.
따라서 값들은 플레이스홀더(placeholder)로 대체되며, 원본은 범위가 지정된 볼트(scoped vault)에 암호화되어 저장되었다가 인간 검토 단계에서 다시 복원됩니다. 모델은 키(key)를 보유하지도 않고 게이트(gate)를 호출하지도 않는 텍스트-투-텍스트(text-to-text) 함수 상태를 유지합니다. 게이트는 데이터 경로에 있는 결정론적 미들웨어(deterministic middleware)이므로, 프롬프트 인젝션(prompt-injection)으로 무력화하거나 건너뛸 수 없습니다.
가져올 만한 가치가 있는 두 가지 세부 사항이 있습니다.
토큰은 아무것도 드러내서는 안 됩니다.
{PERSON_1} mailed {PERSON_2} about {COMPANY_1} 두 명의 사람, 한 개의 회사, 모두 입력됨
{a3f1c8de-...} mailed {7b02e914-...} about {c5d9...} 아무것도 드러내지 않음
카운터(counter)는 메시지에 몇 명의 서로 다른 사람이 있는지 독자에게 알려줍니다. 카테고리는 각 값이 무엇이었는지 알려줍니다. 그것이 바로 당신이 넘겨주지 않으려 했던 메타데이터(metadata)이므로, 토큰은 무작위 UUID이며 그 외의 것은 아무것도 아니어야 합니다.
매처(matcher)는 "중괄호 안의 모든 것"이 아니라 폐쇄형 허용 목록(closed allowlist)이어야 합니다:
_OPEN = re.compile(r"\{[^}]+\}") # {SOMETHING_1}까지 매칭됨
_CLOSED = re.compile(r"\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-
r"[0-9a-f]{4}-[0-9a-f]{12}\}")
개방형 패턴을 사용하면, 공격자가 고객 지원 이메일에 {SOMETHING_1}이라고 작성할 경우, 당신의 마스킹(masking) 단계는 이를 이미 처리된 것으로 간주하고 해당 범위를 건너뛰게 됩니다. 당신은 공격자에게 한 번에 한 영역씩 탐지기를 끌 수 있는 방법을 제공한 셈입니다.
레슨 15: 삭제(erasure)는 데이터가 실제로 어디로 갔는지 알려준다
GDPR(개인정보보호법) 하에서 고객은 귀하에게 자신의 개인 데이터를 삭제하도록 요청할 수 있으며, 귀하는 실제로 이를 수행해야 합니다. 코파일럿(Copilot)은 이미 해당 기록을 소유하고 있는 시스템으로부터 데이터를 읽어오기 때문에 이 문제를 회피합니다. 하지만 귀하가 직접 고객 이메일을 수집(Ingest)하고 저장하는 순간, 귀하는 해당 복사본을 제어하게 되며, "이 사람에 관한 모든 것을 삭제하라"는 요청은 귀하가 실행할 수 있어야 하는 쿼리(Query)가 됩니다.
이는 귀하가 그동안 피해왔던 질문을 던지게 만듭니다: 이 모든 데이터가 결국 어디로 흘러 들어갔는가?
제가 생각했던 것보다 훨씬 더 복잡했습니다. 단 한 번의 삭제 작업이 상호작용(Interactions), 케이스(Cases), 감사 로그(Audit log), 상담사 노트(Operator notes), 첨부 파일 블롭(Attachment blobs), 지식 저장소(Knowledge store), 그리고 여러 범위(Scopes)에 걸친 볼트(Vault)에까지 도달해야 합니다. 하나라도 놓치면 규정을 준수한 것이 아닙니다. 단지 준수했다고 보고만 한 셈이 됩니다.
저에게 가장 큰 가르침을 준 간극은 제가 더 이상 생각하지 않았던 테이블 하나였습니다. 메일박스 폴러(Mailbox poller)는 중복 제거(Deduplication)를 위해 자신이 확인한 모든 메시지의 장부(Ledger)를 유지합니다. 여기에는 가공되지 않은 본문(Raw body)이 저장됩니다. 이것은 중복 제거용 장부로 설계되었기 때문에 생명주기(Lifecycle) 경로가 전혀 없었습니다: 보존 기간(Retention)도, 만료(Expiry)도 없었습니다. 아무도 저장소라고 생각하지 않은 테이블에 고객의 가공되지 않은 메일이 영구적으로 보관되어 있었던 것입니다.
해결책은 버그보다 더 흥미롭습니다. 왜냐하면 뻔한 수리 방법이 틀렸기 때문입니다:
-- 잘못된 방법: 중복 제거 키(Dedup key)가 함께 삭제되므로, 다음 델타 동기화(Delta sync) 시
-- 방금 삭제한 메일을 기쁘게 다시 수집(Re-ingests)하게 됩니다.
DELETE FROM inbox_entries WHERE lower(sender) = :email;
...
행(Rows)을 삭제해서는 안 됩니다. 멱등성(Idempotency)을 깨뜨리는 삭제는 삭제했던 데이터를 다시 생성합니다. 또한 lower() 함수에도 주목하십시오. 저장된 발신자(Sender)는 메일 API에서 가져온 가공되지 않은 텍스트이므로, 대소문자가 일치하지 않으면 성공했다고 보고하면서도 실제로는 데이터가 충분히 삭제되지 않는(Under-redact) 상황이 조용히 발생할 수 있습니다.
데이터가 그저 쌓이기만 하는 모든 테이블은, 귀하가 별도의 결정을 내리지 않음으로써 내린 '보존(Retention) 결정'의 결과물입니다.
레슨 16: 나는 프로세스가 아닌 단일 단계를 위해서만 작동하게 만들었다
파트 3에서는 챗봇이 단지 수집(Ingest) 단계일 뿐이며, 어떤 채널이든 동일한 엔드포인트(Endpoint)로 데이터를 공급할 수 있다고 주장했습니다. 그 부분은 맞았습니다. 이제 메일박스 폴러(Mailbox poller)가 오케스트레이터(Orchestrator)의 변경 없이 정확히 그 역할을 수행합니다.
하지만 제가 설명한 것은 생각보다 작은 성과였습니다. 진정한 고객 서비스(Customer service)는 하나의 단계가 아닙니다. 그것은 접수(Intake), 분류(Triage), 소유권 할당(Ownership), 초안 작성(Drafting), 검토(Review), 발송(Sending), 그리고 그 이후에 어떤 일이 일어났는지 파악하는 과정 전체를 의미합니다.
또한 단일 채널도 아닙니다. 단위는 메시지가 아니라 **케이스(Case)**입니다. 왜냐하면 동일한 고객 문제가 이메일로 도착하고, 라이브 채팅(Live chat)으로 이어지며, 전화로 후속 조치(Follow-up)가 이루어질 수 있기 때문입니다:
case
├── thread EMAIL "업데이트 후 인쇄 실패"
├── thread CHAT 이틀 후 후속 조치
...
음성(Voice)과 채팅(Chat)은 아직 구축되지 않았습니다. 지금 이들을 모델링하는 것이 다음 채널이 나올 때 다시 처음부터 재작성(Rewrite)해야 하는 상황을 방지하는 길입니다.
어쨌든 채팅 채널은 그 과정의 중간 단계만을 다루었을 뿐입니다. 스트림 형태의 케이스를 처리하는 검토자(Reviewer)에게는 필터링(Filter), 검색(Search), 점유(Claim), 재할당(Reassign), 승인(Approve) 기능이 포함된 작업 목록(Worklist)이 필요합니다. 어댑티브 카드(Adaptive card)는 단일 결과물을 보여주기에는 좋은 인터페이스지만, 대기열(Queue)을 관리하기에는 형편없는 인터페이스이기에, 운영자 경험(Operator experience)은 채팅을 벗어나 별도의 애플리케이션이 되었습니다.
이 중 그 어느 것도 AI 작업이 아닙니다. 이 모든 것들은 AI 작업이 한 명 이상의 사람에 의해 사용될 수 있기 위해 선행되어야 했던 것들이며, 이것이 바로 데모(Demo)와 서비스(Service) 사이의 실질적인 차이점입니다.
레슨 17: "배포됨(Deployed)"은 이진법(Binary)이 아니다
배포(Deployment) 역시 파트 2의 평가(Eval)와 동일한 문제를 가지고 있음이 드러났습니다. 모든 체크 항목은 초록색(Green)이었지만, 잘못된 것을 감시하고 있었습니다.
지난 두 달간 가장 교훈적이었던 실패 사례는 다음과 같습니다. **오래된 이미지(Stale image)**를 배포하는 동안 파이프라인이 초록색(Green)을 유지한 사건입니다. 빌드(Build)는 성공했습니다. 배포(Deploy)도 성공했습니다. 상태 확인(Health checks)도 통과했습니다. 하지만 실행 중인 리비전(Revision)은 이전 코드였습니다.
모든 신호가 '예(Yes)'라고 말하고 있었습니다.
이것이 바로 배포가 하나의 단계(Step)가 아닌 프로토콜(Protocol)이 된 이유입니다:
build ─► push ─► deploy green ─► verify replicas ─► flip traffic ─► watch
│ fail
└─► roll back, blue keeps serving
제자리 업데이트 (in-place update) 대신 블루-그린 (Blue-green) 방식을 사용하고, 전환 (flip) 전 복제본 검증 (replica verification)을 수행하며, 컷오버 (cutover) 실패 시 자동 롤백 (automatic rollback)을 수행합니다. 여기에 더해, 한 이미지에서 누락된 의존성 (dependency)으로 인해 발생하는 운영 중단 (production outage) 상황을 가정해 보십시오. 이는 테스트된 모든 곳에는 존재하지만, 실제로 실행되는 곳에는 없는 상황입니다.
배포 파이프라인 (deploy pipeline) 그 자체도 당신에게 거짓말을 할 수 있는 하나의 시스템입니다.
레슨 18: 루프에는 핵심석 (keystone)이 필요하며, 그것은 매력적이지 않다
이 시기에 수행된 가장 큰 규모의 엔지니어링 작업은 지식 및 신뢰성 서브시스템 (knowledge and reliability subsystem)이었습니다. 하지만 우리가 출시한 가장 중요한 것은 훨씬 더 작은 것이었습니다. 바로 운영자가 실제로 회신한 이메일을 해당 답변이 처리한 케이스 (case)에 연결하는 능력이었습니다.
이것은 마치 관리 업무 (admin)처럼 들릴 수도 있습니다. 하지만 이것이 바로 핵심석 (keystone)입니다.
시스템은 답변 초안을 작성합니다. 사람이 이를 편집하여 전송하는데, 종종 자신의 메일 클라이언트에서 직접 보냅니다. 그 순간 시스템은 자신이 무엇을 제안했는지는 알지만, 실제로 무엇이 말해졌는지는 전혀 알지 못합니다.
케이스 (case)와 사람이 실제로 보낸 회신 사이의 기록된 연결이 없다는 것은 그라운드 트루스 (ground truth, 실측값)가 없음을 의미합니다. 그리고 그라운드 트루스가 없다면, 그 상위에 있는 모든 학습 기계 (learning machinery)는 연극에 불과합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기