Engineering · Retrieval · RAG

날짜 경계처럼 보인 도구 멈춤의 실체: HWP 파서가 남긴 UTF-16 서로게이트와 보이지 않던 도구 오류

8월 5일 이후 보도자료만 멈추던 에이전트 도구를 첨부 형식별 전수 대조로 추적해, HWP 파서가 한컴 사설 영역 문자를 UTF-16 서로게이트 쌍으로 남겨 UTF-8 인코딩이 실패한 것과 도구 오류 이벤트가 UI로 전달되지 않던 것을 찾았습니다. 서로게이트를 무손실 복원하는 정규화를 발원지와 공통 길목에 적용했습니다.

기간
2026.08 – 2026.08
소속
고객사 구축 · 은행
역할
원인 분석·백엔드 수정

Key Results

16 → 0
짝 없는 서로게이트실패하던 날짜의 HWP 첨부, 워크플로우 파드 안 사본 코드로 before/after
9,080자
복원 후 추출된 본문같은 파일, 사설 영역 문자 U+F02B1 보존 확인
28건
전수 대조한 7월분 첨부오염 여부가 날짜가 아니라 사설 영역 문자 포함 여부와 일치

은행 폐쇄망의 XGEN 캔버스 에이전트는 공공기관의 보도자료 공개 API를 도구로 호출해 날짜별 보도자료와 첨부 문서를 받아 오고, 첨부 본문을 추출해 답변에 씁니다. 어느 시점부터 특정 날짜를 조회하면 실행 로그에 도구 호출 블록만 뜨고 결과 블록이 끝내 오지 않았습니다. 에이전트는 관측 없이 답변을 만들었고, 타임아웃이 아니라 즉시 멈췄습니다.

8월 4일 이전 날짜는 정상이고 5일 이후만 실패해 날짜 경계처럼 보였지만, 원인은 날짜와 무관했습니다. 문서 파서가 HWP 첨부에 든 한컴 사설 영역(PUA) 글머리표를 UTF-16 서로게이트 쌍 그대로 반환하면서 문자열이 UTF-8로 인코딩되지 못했고, 그 실패를 알리는 이벤트가 UI로 전달되지 않아 추적이 며칠 길어졌습니다.

이 글은 그 추적 과정입니다. 그럴듯했던 가설 열한 개를 배제한 근거, 첨부를 전수 대조해 기준을 "날짜"에서 "파일 내용"으로 바꾼 과정, 서로게이트를 지우지 않고 복원하기로 한 판단, 그리고 방어 지점을 발원지와 공통 길목으로 나눠 고른 이유를 다룹니다.

첨부 본문이 에이전트에게 도달하는 경로

문제가 난 에이전트는 에이전트 노드, 답변 출력 노드, 등록 API 불러오기 도구, 문서 양식 편집기 도구로 구성돼 있었습니다. 등록 API 도구는 첨부 저장과 본문 추출 옵션이 켜져 있어, API 응답을 받은 뒤 첨부 파일을 내려받아 오브젝트 스토리지에 저장하고, 외부 문서 파서 패키지로 본문을 추출해 도구 결과에 함께 싣습니다. 그 도구 결과 문자열은 두 곳으로 나갑니다. 하나는 LLM에게 관측으로 보내는 HTTP 요청 바디이고, 다른 하나는 캔버스 실행 로그를 그리는 tool_result 이벤트의 SSE 전송입니다.

[등록 API 도구]  첨부 저장 ON, 본문 추출 ON
     │  조회 HTTP 200  ← 여기까지는 항상 정상 (단독 테스트가 통과한 이유)
     ▼
응답 처리
     ├─ 첨부 다운로드 → 오브젝트 스토리지 저장
     └─ 첨부 본문 추출 → 외부 문서 파서 (HWP는 OLE 경로)
                          ★ 한컴 PUA 문자를 서로게이트 쌍으로 반환
     ▼
도구 결과 문자열 (짝 없는 서로게이트 포함)
     ├─→ LLM 관측: HTTP 바디 UTF-8 인코딩 실패
     └─→ tool_result 이벤트: SSE 전송 UTF-8 인코딩 실패
              ★ 도구 오류 이벤트가 UI로 가지 않음 → 호출 블록만 남음

에이전트의 스트리밍 경로는 두 갈래입니다. 콜백 핸들러 경로와 astream_events 경로가 있고, 캔버스는 후자를 씁니다. 두 경로 모두 도구 시작과 도구 종료는 UI 이벤트로 보내야 하고, 도구 오류도 마찬가지여야 합니다. 이 전제가 어긋나 있었다는 것은 원인을 찾은 뒤에야 알았습니다.

증상: 도구 단독은 되는데 에이전트만 멈춘다

증상은 다음과 같았습니다. 실행 로그에 도구 호출 블록만 뜨고 결과 블록이 오지 않습니다. 에이전트는 그 상태로 관측 없이 답변을 생성합니다. 30초 타임아웃을 쓰지 않고 즉시 멈춥니다. 8월 4일 이전은 정상이고 8월 5일 이후만 실패합니다. 반면 API 도구 설정 UI의 단독 테스트는 내부망과 폐쇄망 모두 전 날짜에서 HTTP 200으로 정상이었습니다.

"도구 단독은 되는데 에이전트만 안 된다"는 비대칭 때문에 처음에는 에이전트 쪽만 팠습니다. 개발 환경에서는 재현이 되지 않아 가설이 계속 죽었습니다. 나중에 보니 실제 원인은 첨부 저장을 켰을 때만 실행되는 구간에 있었는데, 초기 재현에서는 첨부 저장을 끈 채 도구를 실행해 그 구간을 계속 비껴가고 있었습니다.

HTTP 200이라는 신호도 믿을 수 없었습니다. 조사 중에 확인한 이 공개 API의 제약은 다음과 같습니다. 인증키당 하루 30회 한도가 있고, 한도를 넘으면 HTTP 200에 0건을 돌려주며 조용히 실패합니다. 조회 기간은 21일까지 정상이고 42일이면 오류 코드를 돌려줍니다. 날짜는 YYYY-MM-DD 형식만 유효하고, 다른 형식은 0건이거나 HTML 오류 페이지를 돌려줍니다. 연도를 잘못 넣으면 오류 없이 그 연도의 자료를 정상 반환해 그럴듯한 오답이 됩니다. 단독 테스트의 HTTP 200은 응답의 앞부분이 정상이라는 것만 증명할 뿐, 그 뒤의 첨부 처리에 대해서는 아무것도 말해 주지 않았습니다.

하나씩 배제한 가설들

원인을 찾기까지 세운 가설과, 각각을 배제한 근거입니다.

폐기한 가설 배제한 근거
날짜 검증·범위 보정 코드가 있다 에디터·컨트롤러 전수 grep, 해당 코드 없음
도구 호출 경로 자체의 결함 파드에서 실제 실행, 8/4·8/5·8/10·8/11 전부 정상
첨부 다운로드 실패 전 날짜 200 OK (8/11 HWP 2.9MB 포함)
다운로드 프록시 미설정 폐쇄망에서 8/4 첨부를 실제로 수신
첨부 URL과 파일명 목록의 짝 어긋남 전 날짜 2:2로 정합
저장소 폴더 전량 스캔 비용 파일 메타 테이블 총 228행, 최대 폴더 54개
UTC와 KST의 9시간 차이 7일 차이를 만들 수 없음
폐쇄망 서버 시계 지연 에이전트가 오늘 날짜를 정확히 답변
문서 양식 편집기의 파일 목록 힌트 저장소에 없는 7/21도 조회됨
Qwen3 thinking 토큰 고갈 확률적 현상이라 8/4의 재현적 성공을 설명하지 못함
응답·첨부 크기 8/5 HWP가 235KB로 가장 작은데 실패

가설을 모두 지우고 나니 남은 것은 도구 결과를 실제로 채우는 첨부 본문이었습니다. 크기로는 설명되지 않았고(8/5 HWP가 가장 작았습니다), 날짜 경계도 근거가 없었습니다. 첨부를 날짜 단위가 아니라 파일 단위로, 내용까지 직접 봐야 했습니다.

날짜가 아니라 파일 내용: 첨부 전수 대조

보도자료 첨부를 날짜별로 전부 받아 본문을 추출하고, 결과 문자열에 짝 없는 서로게이트가 몇 개 있는지와 UTF-8 인코딩이 되는지를 시험했습니다.

날짜 형식 짝 없는 서로게이트 UTF-8 인코딩 검출된 PUA
8/4 HWP ×2 0 성공 -
8/5 HWP 16 실패 U+F02B1~F02B4
8/10 HWP 14 실패 U+F02B1~F02B4
8/11 HWP 4 실패 U+F02B1
8/11 HWPX 0 성공 -
전 날짜 PDF 0 성공 -

7월분(7/21~7/31) HWP·HWPX 28건도 전수로 훑었습니다. 7/21, 7/22, 7/24, 7/29, 7/30, 7/31은 오염됐고 7/23, 7/26, 7/27, 7/28은 정상이었습니다. "8월 5일 경계"는 우연이었습니다. 실제 기준은 그날 첨부에 한컴 PUA 문자가 들어갔는지였고, HWPX(ZIP 기반)와 PDF는 28건 전수에서 무결했으므로 HWP(OLE) 파서 경로만의 문제였습니다. 공개 기관이 7월 말부터 HWPX로 전환하는 중이라 시간이 지나면 자연히 줄겠지만, HWP가 남아 있는 한 재발합니다.

메커니즘은 이렇습니다. U+F02B1 같은 한컴 글머리표는 기본 다국어 평면 밖의 사설 영역 코드포인트라, UTF-16에서는 서로게이트 쌍(U+F02B1이면 0xDB80, 0xDEB1) 두 개로 표현됩니다. HWP(OLE) 파서는 이 두 반쪽을 합치지 않고 각각 별개의 코드포인트로 Python 문자열에 넣었고, Python str은 서로게이트를 자동으로 합치지 않습니다. 그 문자열을 UTF-8로 인코딩하는 순간 예외가 납니다.

UnicodeEncodeError: 'utf-8' codec can't encode characters in position 7497-7498:
                    surrogates not allowed

첨부 본문이 실린 도구 결과는 LLM에 보낼 HTTP 바디를 만들 때와 tool_result 이벤트를 SSE로 보낼 때, 두 곳에서 모두 이 예외를 냈습니다.

추적이 며칠 걸린 직접 원인은 따로 있었습니다. 캔버스가 쓰는 astream_events 경로는 도구 시작과 종료를 UI 이벤트로 보내면서, 도구 오류만 로그와 trace에 기록하고 이벤트 큐에는 아무것도 넣지 않았습니다. 콜백 핸들러 경로는 이미 오류 이벤트를 보내고 있어 두 스트리밍 경로가 어긋나 있었고, 캔버스는 보내지 않는 쪽을 쓰고 있었습니다. 그래서 도구가 예외로 죽어도 UI와 trace 어디에도 흔적이 없고, 서버 로그의 [TOOL_ERROR] 한 줄만 남았습니다. 여기에 등록 API 도구의 예외 처리가 HTTP 라이브러리 계열과 ValueError, TypeError만 잡고 있어, 그 밖의 예외는 도구 밖으로 나가 사라졌습니다.

지우지 않고 복원하기: 방어 방식과 위치

방식에서는 네 가지를 비교했습니다.

선택지 장점 단점 판단
서로게이트를 모두 제거 구현이 단순함 글머리표도 문서 내용인데 손실됨 버림
utf-16 surrogatepass 왕복으로 복원, 짝 없는 것만 제거 내용 손실 없음, 정상 문자열은 비용 0 파서의 원인은 그대로 남음 채택
문서 파서 패키지 수정 근본 해결 별도 패키지라 이번 수정 범위 밖이고, 같은 파서를 쓰는 다른 서비스와 함께 판단해야 함 후속 검토
PUA를 글머리표 문자(ㅁ, ㅇ 등)로 매핑 가독성 향상 매핑 기준을 따로 정해야 함 파서 수정과 함께 판단

서로게이트 쌍은 원래 하나의 문자이므로, UTF-16으로 인코딩할 때 surrogatepass로 반쪽들을 그대로 통과시킨 뒤 다시 디코딩하면 원래 코드포인트로 무손실 복원됩니다. 쌍을 이루지 못한 반쪽만 제거합니다. 정상 문자열은 정규식 검사에서 바로 반환되므로 추가 비용이 없습니다.

def sanitize_surrogates(value: str) -> str:
    if not isinstance(value, str) or not _SURROGATE_RE.search(value):
        return value                      # 정상 경로는 여기서 끝 (비용 없음)
    try:
        value = value.encode("utf-16", "surrogatepass").decode("utf-16")
    except (UnicodeDecodeError, UnicodeEncodeError):
        pass
    if _SURROGATE_RE.search(value):
        value = _SURROGATE_RE.sub("", value)
    return value

위치는 두 층으로 나눴습니다. 첫째 층은 이번 결함의 경로입니다. 파서 출력 직후인 첨부 본문 추출 함수(발원지)와, 등록 API 도구가 결과를 반환하기 직전(dict·list 재귀, 최종 방어선)에 적용했습니다. 같은 자리에서 도구의 예외 처리를 일반 Exception까지 넓혔습니다.

둘째 층은 재발 방지입니다. 이번 수정은 등록 API 도구의 HWP 첨부 경로 하나를 고친 것이고, 외부 파서나 외부 API가 인코딩할 수 없는 문자를 흘리는 일은 어느 도구에서든 다시 생길 수 있습니다. 그래서 모든 도구 출력과 모든 SSE 청크가 반드시 지나는 길목 두 곳에도 같은 정규화를 넣었습니다. 하나는 에이전트가 도구 목록 전체를 감싸는 도구 출력 마스커로, 문서 양식 편집기·MCP·DB·API 도구를 모두 덮습니다. 다른 하나는 캔버스 실행 SSE의 keepalive 래퍼로, 응답이 UTF-8로 바뀌기 직전의 모든 청크를 덮습니다. Python 문자열이 UTF-8로 인코딩되지 못하는 원인은 서로게이트가 유일하므로, 이 두 길목에서 걸러내면 이 실패 부류는 구조적으로 차단됩니다.

파서 출력  ── 정규화 (첨부 본문 추출, 발원지)
도구 반환  ── 재귀 정규화 (등록 API 도구) + 일반 예외까지 잡는 백스톱
도구 결과  ── 정규화 (도구 출력 마스커)        ← 에이전트의 모든 도구
SSE 청크   ── 정규화 (SSE keepalive 래퍼)       ← 캔버스의 모든 실행 청크
도구 예외  ── tool_error 이벤트 전송 (astream_events 경로) ← UI에 표시

보이지 않던 오류는 콜백 경로와 같은 형태의 tool_error 이벤트를 astream_events 경로에서도 보내게 해 해결했습니다. 프론트엔드는 이 이벤트 타입을 이미 처리하고 있어 백엔드만 고치면 됐습니다.

부분 실패 동작도 확인했습니다. 첨부 본문 추출은 이미 파일별로 예외를 잡아, 실패한 파일은 "첨부파일 처리 안내"와 사유로, 성공한 파일은 본문으로 컨텍스트에 함께 싣습니다. 여러 첨부 중 일부가 깨져도 에이전트는 추출된 것까지로 답변하고, 무엇이 빠졌는지도 봅니다.

검증

실행 중인 워크플로우 파드 안에서, 운영 코드는 건드리지 않고 임시 디렉터리에 복사한 코드에 패치를 적용해 실제 8/5 HWP 첨부로 before/after를 비교했습니다.

지표 이전 이후 측정 조건
짝 없는 서로게이트 16 0 8/5 HWP 첨부, 파드 안 사본 코드
UTF-8 인코딩 실패 성공 같은 파일
JSON 직렬화 후 UTF-8 인코딩 실패 성공 같은 파일
추출 본문 - 9,080자, 문서 상태 extracted 같은 파일
PUA U+F02B1 보존 - 보존 같은 파일
회귀 테스트 - 9건 통과 정규화 8건, 첨부 본문 1건

변경 파일은 모두 컴파일 검사를 통과했고, diff에서 삭제된 줄은 의도한 교체분 3줄뿐이었습니다. 서로게이트는 소스에 리터럴로 쓸 수 없어 테스트에서는 chr(0xDB80) + chr(0xDEB1)로 재현 문자열을 만들었습니다.

머지 후 확인해 보니 이 저장소는 테스트를 자동 실행하지 않았습니다. CI 설정이 없고, 프로젝트 설정에 pytest 의존성이 없고, 런타임 이미지에도 pytest가 없습니다. 동작 영향이 0이라 테스트 파일 152줄은 별도 변경으로 제거하고 기능 수정은 유지했으며, 재현 조건은 작업 기록에 남겼습니다.

회고와 남은 과제

가장 큰 한계는 같은 파서를 쓰는 다른 경로입니다. 문서 파서는 별도 패키지이고, 문서 업로드·인덱싱 서비스에서 20곳 넘게 쓰입니다. 이번 방어는 워크플로우 서비스의 경로에만 걸려 있어, HWP를 컬렉션에 올리면 같은 자리에서 실패할 수 있습니다. 소비하는 쪽에서 막는 것은 빠른 조치였고, 파서 자체의 수정이 근본 해결입니다. 그때 PUA 문자를 글머리표로 매핑할지도 함께 정해야 합니다.

폐쇄망 배포 후 확인할 것도 정해 두었습니다. 7/21과 7/30은 8월 5일 이전이지만 첨부가 오염돼 있어, 수정 전이었다면 실패했어야 할 날짜입니다. 배포 후 이 날짜들이 정상 조회되면 원인을 최종 확정하고, 8/5·8/10·8/11을 다시 조회해 첨부 저장까지 완주하는지도 봅니다.

돌아보면 시간을 가장 많이 쓴 것은 결함 자체보다 결함이 보이지 않았다는 사실이었습니다. 두 스트리밍 경로 중 한쪽만 오류 이벤트를 보내지 않는 비대칭, 재현 환경에서 첨부 저장을 끈 조건의 차이, HTTP 200에 0건을 돌려주는 외부 API의 조용한 실패가 겹쳐 있었습니다. 여기서 얻은 기준은 두 가지입니다. 도구 단독 테스트가 어디까지를 증명하는지 먼저 확인할 것, 그리고 실패가 UI까지 도달하는지를 기능과 별개로 점검할 것입니다. 첨부 다운로드의 30초 고정 타임아웃처럼 같은 경로에서 발견한 다른 문제는 별건으로 분리해 기록했습니다.