우리 팀이 LLM 기반 데이터 추출 파이프라인을 프로덕션에 올린 건 작년 말이었다. GPT-4o에 프롬프트로 "JSON으로 응답해줘"라고 쓰고, 응답을 json.loads()로 파싱하는 단순한 구조. 테스트에서는 100번 돌려도 한 번도 안 깨졌다. 프로덕션에 배포한 첫 주, 에러율 2.3%.
2.3%가 뭐 대수냐고? 하루 요청 10만 건 기준으로 2,300건이 실패한다. 그 중 일부가 결제 관련 데이터 추출이었고, 3주 만에 CS 팀에서 "왜 결제 내역이 빠져있냐"는 문의가 쏟아졌다. 장애 리포트 3건. 전부 원인은 같았다 — 모델이 가끔 JSON을 안 줬다.
깨지는 패턴은 예측 불가능했다
파싱 실패 로그를 분석해보면 규칙성이 있을 거라 생각했다. 없었다.
어떤 때는 마크다운 코드 펜스로 감싸서 나왔다 — ```json\n{...}\n```. 어떤 때는 "여기 결과입니다:"라는 친절한 설명이 앞에 붙었다. trailing comma가 들어가는 경우도 있었고, 필드명이 camelCase에서 snake_case로 슬쩍 바뀌는 케이스도 있었다. 심지어 같은 프롬프트, 같은 입력인데 temperature 0으로 설정해도 가끔 깨졌다.
방어 코드를 추가했다. 정규식으로 코드 펜스 제거, 앞뒤 텍스트 트리밍, 파싱 실패 시 재시도 3회. 코드가 200줄 넘어갔다. 그래도 에러율은 0.8%에서 더 안 내려갔다. 이 0.8%를 잡으려고 프롬프트를 20번 넘게 고쳤는데, 한 패턴을 막으면 다른 패턴이 튀어나왔다. 두더지 잡기.
Structured Output로 전환한 과정
OpenAI의 Structured Outputs 기능이 GA 된 후 바로 적용을 검토했다. 원리는 간단하다 — JSON Schema를 API에 넘기면, 모델이 해당 스키마에 맞는 출력만 생성하도록 제약을 건다. constrained decoding이라고 부르는 기술인데, 토큰 생성 단계에서 스키마에 맞지 않는 토큰의 확률을 0으로 만든다.
전환 작업 자체는 이틀이면 될 줄 알았는데 일주일 걸렸다. 이유가 여러 가지였다.
스키마 설계가 생각보다 까다롭다. 기존 프롬프트에서는 "상황에 따라 이 필드는 없을 수도 있어"라고 자연어로 설명하면 됐다. JSON Schema에서는 optional 필드, union type, nullable 등을 명확하게 정의해야 한다. 우리 추출 대상이 12종류의 문서였는데, 각각의 스키마를 정의하는 데만 3일이 갔다.
nested object 깊이 제한. Structured Outputs에 nesting depth 제한이 있다는 걸 배포 직전에 알았다. 우리 스키마 중 하나가 5단계 중첩이었는데 4단계로 평탄화하느라 반나절 삽질. 문서에 써 있긴 한데, 누가 그걸 미리 읽겠나.
enum 값 관리 문제. 카테고리 필드를 enum으로 정의했더니, 새로운 카테고리가 추가될 때마다 스키마를 업데이트하고 재배포해야 했다. 결국 자주 바뀌는 필드는 enum 대신 string으로 두고 후처리에서 검증하는 방식으로 타협. 타입 안전성과 운영 편의성 사이의 트레이드오프다.
Anthropic의 Claude도 tool use를 통해 비슷한 구조화된 출력을 지원한다. 우리는 일부 파이프라인을 Claude로 돌리고 있어서 양쪽 다 적용했는데, 각 프로바이더마다 스키마 문법이 미묘하게 달라서 어댑터 레이어를 하나 더 만들어야 했다. 멀티 모델 운영의 숨은 비용.
전환 결과
파싱 에러율: 2.3% → 0%. 한 달 운영하면서 파싱 에러 단 한 건도 없었다.
대신 다른 것들이 움직였다. 레이턴시가 평균 15% 증가했다. constrained decoding이 토큰 생성 속도를 떨어뜨린다. 우리는 배치 처리라 큰 문제가 아니었지만, 실시간 응답이 필요한 서비스라면 이건 진지하게 고려해야 할 수치다. 비용도 소폭 올랐다. 스키마 정보가 input token에 포함되니까 약 8% 증가. 다만 재시도 로직이 사라지면서 전체 API 호출 횟수가 줄었고, 총비용은 오히려 5% 정도 감소했다.
그리고 방어 코드 200줄을 전부 삭제했다. 코드 리뷰에서 팀원이 "이거 진짜 다 지워도 되는 거 맞아?"라고 세 번 물어봤다.
형식은 믿어도 된다, 의미는 아니다
Structured Output가 보장하는 건 "스키마에 맞는 형태의 출력이 나온다"는 것뿐이다. 값이 정확한지는 별개의 문제다. required 필드에 빈 문자열이 들어오거나, 숫자 필드에 0이 채워지는 건 스키마 위반이 아니다. 우리가 실수한 건 도입 후 "이제 모델 출력을 믿어도 되겠지"라는 안도감이 생긴 것이다.
지금은 구조 검증(Structured Output)과 의미 검증(후처리 룰 엔진)을 분리해서 운영한다. 모양이 맞는지는 API가 보장하고, 내용이 맞는지는 우리가 검증한다. 이 분리가 명확해지니까 오히려 검증 코드가 더 깔끔해졌다. 뭘 믿고 뭘 의심해야 하는지 경계가 생긴 셈이다.