엑셀 요청이 항상 실패하던 문서 생성 도구: 폴백 대신 계산되는 데이터 시트로 변환하기
LLM이 마크다운으로 넘긴 엑셀 요청이 항상 실패하던 렌더러 분기를 단순 폴백이 아닌 데이터 시트 변환으로 고쳐 숫자·표시형식·틀고정을 살렸습니다. 셀 검색 도구 부재와 반복 한도 계산 오류로 편집이 중단되던 문제, 코드 생성 없이 차트를 그리는 경로도 함께 정리했습니다.
Key Results
- 1,000행 약 1초
- 마크다운 표 → XLSX 변환과 렌더변환 0.27초 + 렌더 0.79초, 실 컨테이너 측정
- 8,327건 예외 0
- 퍼즈 입력셀 값 5,727 · 표 렌더 600 · 문서 구조 2,000, 수정 후 기준
- 중단 → 4턴 완료
- 조건 행 찾아 열 채우기 요청수정 전 실사용 요청은 10번째 LLM 호출에서 반복 한도 초과, 수정 후 로컬 스택에서 같은 요청 E2E(120행 시트)
XGEN의 문서 양식 편집기는 에이전트가 도구 호출로 Word, PowerPoint, 한글, 엑셀 문서를 만들고 고치게 해 주는 노드입니다. 은행 구축 환경에서 이 노드로 엑셀을 만들면 ".xlsx generation requires 'sheets'"라는 오류로 실패한다는 제보가 들어왔습니다. 그보다 앞서 엑셀 시트를 편집하는 요청은 "모델이 최대 탐색 횟수에 도달했습니다"로 중단되고 있었습니다.
두 결함 모두 LLM이 아니라 도구 쪽 계약의 문제였습니다. 생성 쪽은 확장자마다 다른 인자를 요구하는 렌더러 분기가 LLM의 자연스러운 호출 습관과 어긋나 있었고, 편집 쪽은 엑셀에서 조건에 맞는 셀을 찾을 도구가 없는 데다 반복 한도가 실제보다 적게 계산되고 있었습니다.
이 글은 두 결함의 원인과, 생성 실패를 단순 폴백으로 덮지 않고 "계산되는 데이터 시트"로 변환하기로 한 판단을 다룹니다. 숫자 파싱을 새로 짜지 않은 이유, 퍼센트와 앞자리 0 같은 표기를 어떻게 다뤘는지, 회귀 표면이 0이라고 말할 수 있었던 근거, 그리고 차트를 코드 생성 없이 그리게 만든 구조까지 정리했습니다.
문서 양식 편집기 노드의 구조
문서 양식 편집기는 에이전트 노드의 tools 입력에 연결되어 문서 생성, 구조 조회, 셀·문단 편집, 텍스트 검색 같은 도구 30여 개를 LLM에 제공합니다. LLM은 사용자 요청을 보고 도구와 인자를 고르고, 도구는 결과 파일을 원본을 덮지 않고 결과물 폴더에 새 파일로 저장한 뒤 파일 저장소 DB에 등록합니다. 문서 처리 엔진은 document-adapter 라이브러리이고, 폐쇄망 빌드는 PyPI에 접근할 수 없어 vendor wheel로 포함합니다.
생성 도구는 파일 확장자를 보고 렌더러를 고릅니다. 이 분기가 이번 결함의 출발점이었습니다.
| 확장자 | 사용하는 인자 | 다른 인자 |
|---|---|---|
.docx .pptx .hwpx |
markdown |
sheets 무시 |
.xlsx |
sheets (시트 명세 JSON) |
markdown을 참조하지 않음 |
편집 쪽에서 중요한 구조는 에이전트의 반복 한도입니다. 에이전트 노드에는 최대 반복 횟수 설정이 있고, 실행기는 이 값으로 LangGraph의 recursion limit을 계산합니다. 한도를 넘으면 실행이 중단되고 사용자에게는 "최대 탐색 횟수에 도달" 오류로 보입니다.
두 가지 실패: 생성 오류와 탐색 한도 초과
먼저 드러난 것은 편집 쪽이었습니다. 고객 데모 환경의 실사용 요청은 "재무상태표 시트에서 계정명이 '단기'로 시작하는 행 옆에 열을 하나 만들어 a로 채워 달라"였고, 즉시 탐색 한도 오류로 끝났습니다. 파드 로그에 남은 도구 호출 순서는 다음과 같았습니다.
inspect_document
insert_column ← 셀 값 인자에 JSON 배열이 아닌 값 하나 → 파싱 실패, 1턴 낭비
insert_column ← 배열로 재시도, 성공
get_cell(row=0, col=1) … get_cell(row=6, col=1) ← 계정명 열을 행마다 하나씩 조회
→ 10번째 LLM 호출에서 GraphRecursionError (recursion limit 31)생성 쪽 제보는 8월 하순에 들어왔습니다. 엑셀을 만들어 달라는 요청에 LLM이 생성 도구를 호출하면 "문서 생성 실패: .xlsx generation requires 'sheets' (sheet spec list)"가 반환됐습니다.
원인: 인자 계약과 반복 한도의 어긋남
생성 실패는 호출 체인 세 단계를 따라가면 보입니다.
LLM → create_document(filename=".xlsx", markdown="…", sheets_json="")
도구 래퍼 sheets = None
if sheets_json.strip(): ← 빈 문자열이라 진입하지 않음
_create(…, sheets=None)
엔진 생성 함수 .xlsx 분기: if sheets is None → ValueError(".xlsx generation requires `sheets`")
도구 래퍼 except ValueError → "문서 생성 실패: …" ← 사용자가 본 문자열sheets_json의 기본값이 빈 문자열이라, LLM이 그 인자를 넘기지 않으면 자동으로 이 경로를 탑니다. LLM이 Word 문서를 만들 때의 습관대로 본문을 markdown에 담고 파일명만 .xlsx로 주면 항상 이 경로로 실패했습니다. markdown과 sheets를 함께 넘기면 "pass either …, not both"로 실패했습니다. 사용자에게는 둘 다 같은 "문서 생성 실패"로 보였습니다. 제품 기본 라인도 코드가 같아, 특정 고객 환경이 아니라 설계상 폴백이 없던 제품 공통 결함이었습니다.
편집 중단의 원인은 셋이었습니다. 주범은 XLSX·CSV에 셀 검색 도구가 없었던 것입니다. 텍스트 검색 도구는 Word와 한글 전용이었고, 구조 조회의 미리보기는 4행뿐이라, "단기로 시작하는 행"을 찾을 방법이 get_cell을 한 칸씩 부르는 것밖에 없었습니다. 둘째, 반복 한도 계산이 틀려 있었습니다. 실행기는 recursion limit을 max_iterations*2+1(최대 15회면 31)로 계산했는데, 에이전트에 요약 미들웨어가 적용돼 있어 한 반복이 모델 전처리, 모델, 도구의 3노드가 됐습니다. 15회를 약속하고 실제로는 10회만 준 셈이고, 10×3=30 다음인 31번째 노드에서 중단됐습니다. 셋째, 열 삽입 도구가 셀 값 인자를 엄격하게 파싱해, 배열이 아닌 값 하나를 넘기면 즉시 실패하고 한 턴을 낭비했습니다.
판단: 폴백이 아니라 변환
생성 실패를 없애는 가장 빠른 방법은 .xlsx에 markdown이 오면 그 텍스트를 문서형 "보고서 시트" 하나에 그대로 렌더하는 폴백이었습니다. 오류는 사라지지만, 표의 숫자가 문자열로 들어갑니다. 보기에는 표인데 합계, 정렬, 차트가 모두 동작하지 않습니다.
| 항목 | 보고서 시트 (단순 폴백) | 데이터 시트 (채택) |
|---|---|---|
| 표의 숫자 | '120000' 문자열 |
120000 정수 |
| 표시형식 | General | #,##0"원" |
| 틀고정 | 없음 | A2 |
| 열 너비 | 고정 | 내용 기반 |
사용자가 엑셀을 요청한 의도는 계산되는 표입니다. 그래서 안전망이 아니라 변환을 택했습니다. 마크다운의 표는 숫자 셀과 표시형식, 틀고정을 가진 데이터 시트로, 헤딩·문단·불릿·인용·코드 같은 서술은 보고서 시트로 나눕니다. 엔진이 한 시트에 마크다운 렌더와 표 데이터를 함께 쓰는 것을 금지하므로 시트를 나눠야 했습니다. 표 바로 앞의 헤딩은 시트 이름으로 승격하고, 표가 하나도 없으면 기존처럼 보고서 시트 하나를 만듭니다. 변환 중 예외가 나면 보고서 시트로 폴백합니다. 폴백은 버린 것이 아니라 마지막 방어선으로 남겼습니다.
숫자 파서는 새로 짜지 않았습니다. 레포에는 이미 마크다운 표 파서가 4벌 있었고, 천단위·소수·통화 기호·단위(원, %, 건, 개월 등) 인식과 전화번호·날짜·버전 배제 규칙이 검증된 템플릿 표현식의 숫자 파서가 있었습니다. 이것을 그대로 재사용했습니다. 새로 짰다면 숫자 해석 규칙이 레포에 한 벌 더 늘어났을 것입니다.
숫자로 바꾸면 안 되는 것도 정했습니다. 000123은 앞자리 0이 사라지지 않도록 문자열로 둡니다. 날짜, 전화번호, 1.2.3 같은 버전은 파서가 거부합니다. 3 apples, 0.5%p, 1,000- 같은 표기는 파서의 접미사 정규식이 넓어 통과해 버리므로 단위 화이트리스트로 막았습니다. 비고처럼 텍스트가 우세한 열은 숫자가 섞여 있어도 열 전체를 문자열로 유지하고(숫자 비율 0.5 기준), =SUM(...) 같은 수식은 살아 있는 수식으로 그대로 통과시킵니다. LLM이 자주 빠뜨리는 표 구분행은 파서를 느슨하게 고치는 대신, 연속 2줄 이상이고 셀 수가 모두 같은 파이프 행일 때만 전처리로 보충했습니다. 파서의 엄격함에는 이유가 있습니다. | - | - | 같은 행을 구분행으로 삼키면 데이터 행이 사라지기 때문입니다.
변경 지점은 .xlsx이면서 markdown이 비어 있지 않은 경우로 한정했습니다.
if ext == ".xlsx" and markdown.strip(): # 새 변환 경로의 진입 조건이 조건이 참이던 경로는 패치 전에 100% 실패하던 곳입니다. sheets_json을 준 호출은 조건이 거짓이라 새 코드를 타지 않고, Word·PowerPoint·한글은 확장자가 달라 타지 않습니다. 손대는 지점이 원래 전부 실패하던 곳이므로 회귀 표면이 0입니다. 노드 정의 파일은 한 줄도 바꾸지 않았습니다. 실행기는 노드 id로 살아 있는 Python 클래스를 가져오고 저장된 워크플로우 JSON은 파라미터 값만 들고 있으므로, 기존 캔버스는 재배포만으로 적용됩니다.
앞서 차트를 정리할 때도 같은 원칙을 따랐습니다. 그전까지 에이전트가 차트를 그리려면 프롬프트에 pandas와 matplotlib을 명시해 코드를 짜게 하는 수밖에 없었고, 매번 다른 코드가 나와 결과가 흔들렸습니다. 게다가 코드 샌드박스는 stdout만 반환해, 그린 그림이 나올 경로가 구조적으로 없었습니다. 프롬프트를 다듬어서 해결될 문제가 아니었으므로, LLM은 차트 종류를 고르고 데이터만 채우고, 렌더링은 기존 UI 컴포넌트가 맡는 차트 생성기 노드를 만들었습니다. 사분위수처럼 계산이 필요한 값도 LLM이 아니라 컴포넌트가 원본 표본에서 직접 구합니다.
구현
변환 함수는 공개 API 하나로 두었습니다.
"| 분기 | 매출 |" → headers = ["분기", "매출"]
"| 1Q | 1,200,000원 |" → rows = [["1Q", 1200000]]
number_formats = {"B": '#,##0"원"'}표시형식 규칙은 다음과 같습니다. 천단위 구분이 하나라도 있으면 #,##0, 없으면 0이고, 소수 자릿수는 열의 최대치를 씁니다. 통화 기호와 단위는 열 전체가 같을 때만 물려줍니다. 퍼센트는 Excel의 참 퍼센트로 저장합니다. 5.2%는 값 0.052와 0.0% 서식이 되어, 표기는 원문과 같으면서 곱셈과 차트 축이 맞습니다. 통화 기호와 음수가 함께 있으면 "$"0.00;-"$"0.00처럼 두 구획으로 써서 Excel이 $-100이 아니라 -$100으로 표시하게 했습니다. 회계식 괄호 음수 (1,200원)은 -1200으로, 부호가 통화 기호 앞에 있는 -$100은 -100으로 정규화합니다.
편집 쪽은 엔진의 XLSX·CSV 어댑터에 find_text를 구현했습니다. 전 시트의 셀 값을 검색하고, 결과 좌표가 그대로 get_cell, set_cell의 좌표가 되게 했습니다. 엔진의 기존 텍스트 매치 구조를 재사용해 도구 래퍼는 설명만 갱신하면 됐습니다. 반복 한도는 실측 근거를 주석으로 남기고 max_iterations*3+2로 바꿨으며, 열 삽입 도구는 값 하나가 오면 한 칸짜리 리스트로 받아들이게 했습니다. 도구 가이드에는 "조건에 맞는 셀이나 행을 찾을 때는 find_text 한 번, get_cell 반복 금지"와 "희소한 열은 빈 열을 삽입한 뒤 set_cell로 채우기"를 넣었습니다. 두 번째 규칙은 1차 E2E에서 모델이 119칸짜리 희소 배열을 한 번에 쓰다가 한 곳이 어긋난 것을 보고 추가했습니다.
엑셀 차트 삽입은 원래 PowerPoint에만 걸려 있었습니다. 확장자 if를 늘리면 포맷이 늘 때마다 같은 분기가 번식하므로, 지원 포맷을 라이브러리가 스스로 선언하게 두었습니다.
add_chart(filename, …)
└─ 확장자 하드코딩 없음
└─ 라이브러리의 add_chart 먼저 호출
└─ NotImplementedForFormat 예외일 때만
└─ 등록된 포맷별 writer로 위임 (.xlsx)라이브러리가 나중에 XLSX 차트를 구현하면 writer는 저절로 비켜섭니다. XLSX 삽입은 데이터 표와 네이티브 차트를 담은 새 시트를 만들고 원본 시트는 그대로 둡니다. 차트가 표를 셀 참조로 물고 있어, 값을 고치면 차트도 따라 바뀝니다. LLM이 실제로 읽는 것은 도구 객체에 등록된 설명이라, docstring만 고쳐서는 안 되고 LLM 노출 설명을 포맷 중립으로 바꿔야 엑셀 요청에도 도구를 호출했습니다.
결과
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
.xlsx • markdown만 |
실패 (requires 'sheets') |
보고서·데이터 2시트 생성 | 실 컨테이너, 엔진 0.19.0 |
.xlsx • 두 인자 모두 |
실패 (not both) |
sheets 우선 |
같은 조건 |
.xlsx • sheets_json, .docx • markdown |
성공 | 생성물 내용 동일 | 셀 값·타입·서식·너비·틀고정, 문단·표 비교 |
| 구분행 누락 표 | 파이프가 글자로 남음 | 정상 표와 동일 결과 | 같은 조건 |
| 1,000행 표 | - | 변환 0.27초 + 렌더 0.79초 | 실 컨테이너 |
| 퍼즈 입력 | 공백 괄호 셀 4종 IndexError | 8,327건 예외 0 | 셀 값 5,727, 표 렌더 600, 문서 구조 2,000 |
| 조건 행 찾아 열 채우기 | 10번째 LLM 호출에서 한도 초과 | 4턴 완료, 5행 정좌표 | 이전은 실사용 요청 파드 로그, 이후는 로컬 스택 E2E(120행 시트, Claude Sonnet) |
| XLSX 차트 삽입 | PowerPoint 전용 | 11종 삽입 성공, 원본 시트 보존 | 로컬 스택 |
실제 셀 타입도 확인했습니다. 1,200,000원은 정수 1200000과 #,##0"원", 5.2%는 0.052와 0.0%, 102건은 102와 0"건", (800원)은 -800, -$100.00은 -100과 두 구획 서식으로 저장됐습니다. 표시형식 13종은 저장과 재로드 왕복에서 보존됐고, 회귀 테스트 50건을 두 브랜치에서 각각 통과했습니다. 안 타는 경로의 동일성은 바이트가 아니라 생성물 내용으로 비교했습니다. XLSX는 zip 압축 특성상 프로세스가 다르면 크기가 1바이트씩 흔들려 바이트 비교를 근거로 쓸 수 없었습니다.
퍼즈는 실제 결함을 하나 잡았습니다. ( )처럼 괄호 안이 공백뿐인 셀에서 IndexError가 났고, 바깥 폴백이 이를 삼켜 문서 전체가 조용히 보고서 시트로 열화됐습니다. 원인은 "" in "₩$€¥£"가 참이라는 데 있었습니다. 빈 문자열이 통화 기호 검사를 통과해 text[0] 첨자 오류로 이어졌습니다. 괄호를 벗긴 뒤 비면 원문을 반환하게 하고, 예외 흡수 지점을 셀 단위로 옮겼습니다. 바깥 폴백은 문서를 통째로 되돌리므로, 셀 하나의 예상 못 한 표기가 표 전체를 잃게 해서는 안 됩니다. 편집 E2E에서는 구조 조회, find_text(5건 매치), 헤더만 넣은 열 삽입, set_cell 5회를 한 턴에 병렬로 실행하는 4턴 만에 끝났고, 다른 셀의 오염은 없었습니다.
회고와 남은 과제
행 수에 따른 처리 시간은 선형이었습니다. 100행은 변환 0.03초와 렌더 0.10초, 5,000행은 1.54초와 4.01초, 20,000행은 6.29초와 16.31초였고, 폭 1,000열과 시트 300개도 정상이었습니다. 실제 병목은 변환이 아니라 앞단의 LLM 출력 토큰입니다. 20,000행 표는 마크다운으로 약 1MB(약 27만 토큰)라 어떤 모델도 한 번에 쓰지 못합니다. 현실적인 구간은 수백에서 1,000행, 1초 안팎이고, 진짜 대용량은 sheets_json 직접 지정이나 DB 노드가 파일을 직접 만드는 경로로 가야 합니다. 이 제약은 패치가 만든 것이 아니라 원래 구조입니다.
판단에 따른 한계도 있습니다. 퍼센트를 참 퍼센트로 저장하면 셀을 직접 열었을 때 0.052로 보입니다. 리터럴 표기를 원하면 서식을 바꾸는 한 줄 변경으로 돌릴 수 있습니다. △1,000 같은 한국식 음수 표기는 증가와 감소 양쪽 관례가 있어 모호하므로 텍스트로 둡니다. 구분행 복구는 좁은 조건에서만 동작합니다.
재배포 후 테스트는 새 대화로 시작해야 합니다. 이미 실패를 받은 대화를 이어 가면 그 맥락이 프롬프트에 남아 LLM이 계속 같은 방식으로 호출합니다. 그리고 변환은 어디까지나 구제책입니다. 표가 목적인 산출물이라면 에이전트 시스템 프롬프트에 ".xlsx는 sheets_json으로"를 명시하는 편이 낫습니다. 열의 의미를 아는 쪽이 직접 명세를 주는 것이 항상 정확하기 때문입니다. 이번 작업에서 남은 기준은, 도구의 인자 계약은 LLM이 실제로 호출하는 습관에 맞춰야 하고, 실패를 덮는 폴백보다 사용자의 의도를 살리는 변환이 낫다는 것입니다.