leo.dev
backend

LLM 구조화 추출과 출력 신뢰 방어

2026.05.274 min read aillmgeminivertex-ainestjs

정부지원사업 공고는 50~100페이지 PDF가 흔하다. 그걸 보고 사람이 사업명·주관기관·접수기간·예산을 등록 폼에 손으로 옮겨 적었다. 분량도 부담이고 오입력도 잦았다. 그래서 공고 원문(URL·PDF·텍스트)을 던지면 LLM이 주요 필드를 파싱해 폼을 미리 채우게 했다.

만들면서 분명해진 건, 이 기능의 어려운 부분이 “추출”이 아니라는 거였다. 모델은 그럴듯한 JSON을 잘 뱉는다. 문제는 그 JSON을 검증 없이 DB에 넣는 순간 모델의 헛소리가 그대로 데이터가 된다는 것이다. 결제에서 외부 PG를 못 믿었듯, 여기선 모델 출력을 못 믿는 걸 전제로 깔았다.

왜 Gemini였나 — 컨텍스트 길이가 갈랐다

모델 선택의 기준은 품질 벤치마크가 아니라 입력 길이였다. 공고 PDF가 100페이지에 달하니, 컨텍스트 윈도가 좁으면 문서를 잘라 넣어야 하고 그러면 뒷부분 필드를 놓친다. 그 시점 기준 Claude의 200K 토큰보다 Gemini의 1M 토큰 컨텍스트가 장문 공고에 결정적이었다. 문서를 통째로 한 번에 넣을 수 있느냐가 정확도를 갈랐다.

대신 모델에 묶이지 않게 Strategy 패턴으로 추상화했다. IAiProvider 인터페이스를 두고 GeminiProvider를 구현했다. 환경변수 하나(AI_PROVIDER)로 프로바이더를 갈아끼울 수 있어, 다른 모델로 옮길 때 호출부는 건드리지 않는다. Claude 프로바이더는 인터페이스만 잡아두고 비워뒀다. 지금 결정은 “Gemini”가 아니라 “언제든 바꿀 수 있게 해두고 Gemini”다.

JSON을 달라고 부탁하지 않는다

LLM에게 “JSON으로 답해줘”라고 프롬프트로 부탁하면, 대체로 주지만 가끔 앞에 설명을 붙이거나 마크다운 코드펜스로 감싼다. 그 “가끔”이 파싱을 깨고 운영 중 에러로 돌아온다.

부탁 대신 강제했다. Gemini의 generationConfigresponseMimeType: 'application/json'responseSchema를 함께 줬다. 응답 형식을 프롬프트의 권유가 아니라 API 제약으로 못박는 것이다.

const result = await this.model.generateContent({
systemInstruction: prompt,
contents: [{ role: 'user', parts }],
generationConfig: {
responseMimeType: 'application/json',
responseSchema: RESPONSE_SCHEMA, // 필드·타입을 스키마로 고정
},
});

스키마로 출력 구조를 고정하니 JSON 파싱 실패가 사실상 사라졌다. “프롬프트로 잘 부탁한다”와 “스키마로 강제한다”는 안정성이 다른 차원이다.

모델이 뱉은 값을 그대로 저장하지 않는다

스키마를 강제해도 까지 옳다는 보장은 없다. 모델은 형식은 맞지만 내용이 틀린 답을 자신 있게 내놓는다. 그래서 파싱한 뒤 한 겹 더 검증했다.

열거형은 화이트리스트로 받는다. 문서 분류 같은 필드는 정해진 코드값(regulation·license·certificate…) 중 하나여야 한다. 모델이 목록에 없는 값을 지어내면 그대로 저장하지 않고 ETC로 떨어뜨린다.

const type = VALID_TYPES.has(parsed.type)
? (parsed.type as CompanyDocumentType)
: CompanyDocumentType.ETC; // 모델이 없는 분류를 지어내면 폴백

길이는 서비스 레이어에서 자른다. 모델이 varchar(200) 칸에 300자를 채워 보내면 DB가 저장 시점에 터진다. 그 전에 서비스에서 truncate해 추출 실패가 저장 에러로 번지지 않게 했다.

얼마나 믿을지를 등급으로 매긴다. 핵심 5개 필드(사업명·주관기관·부처·연도·마감일)가 몇 개나 추출됐는지로 confidence를 high/medium/low로 분류했다. 이 등급은 그냥 메타데이터가 아니다. 화면에서 신뢰도 배지로 보여주고, 추출 결과는 곧장 저장되지 않고 편집 가능한 폼에 prefill된다. 사람이 보고 고친 뒤 등록한다. AI는 빈 칸을 채워주는 보조지 최종 권위가 아니다. 모델을 못 믿는다는 전제가 UX의 마지막 한 단계로 남는다.

입력은 셋, 추출은 하나

소스는 URL·파일·텍스트 세 가지인데, 전처리만 다르고 그 뒤는 같은 추출로 모인다. URL은 HEAD 요청으로 Content-Type을 보고 PDF면 모델에 직접 넘기고 HTML이면 텍스트로 변환한다(변환 결과가 너무 짧으면 JS 렌더링 사이트로 보고 거부). 파일은 S3 버퍼를 base64로, 텍스트는 과금·컨텍스트 보호를 위해 일정 길이로 자른다. 분기는 입구에서 끝나고, 인터페이스 뒤로 들어가면 셋이 한 경로다.

인터페이스를 유지한 덕에 마이그레이션이 두 파일이었다

나중에 AI Studio SDK(API Key 방식)에서 Vertex AI SDK(서비스 계정 방식)로 옮겼다. 운영 환경의 인증을 키 문자열이 아니라 서비스 계정으로 들고 가기 위해서였다. 이때 IAiProvider 인터페이스와 모듈 구조는 그대로 두고 프로바이더 구현 파일만 갈아끼웠다. 처음에 모델을 인터페이스 뒤로 숨겨둔 결정이, SDK를 통째로 바꾸는 일을 호출부에 영향 없는 국소 교체로 만들었다.

그래서

LLM을 붙이는 일의 무게중심은 프롬프트가 아니라 그 출력을 받는 쪽에 있었다. 형식은 스키마로 강제하고, 값은 화이트리스트와 길이 제한으로 거르고, 신뢰도는 등급으로 표시해 사람이 마지막에 확인한다. 모델은 똑똑하지만 자신 있게 틀린다. 그 전제를 코드로 깔아두면, 추출은 편리한 기능이 되고 헛소리는 데이터가 되지 못한다.

↑↓ 이동 열기esc 닫기