LLM에게 JSON을 강제하는 방법과, 그래도 깨질 때의 처리
출력 형식 강제 옵션부터 코드 펜스·잘림·스키마 불일치 대응까지, 구조화된 응답을 안정적으로 받기 위한 검증과 재시도 설계를 정리했습니다.
JSON으로 달라고 했는데 설명이 붙어 온다
LLM 응답을 코드에서 쓰려면 구조화된 형태여야 합니다. 그런데 "JSON으로만 답해줘"라고 적어도 앞에 설명 문장이 붙거나, 코드 펜스로 감싸여 오거나, 마지막 중괄호가 빠진 채 잘려 오는 일이 생깁니다.
이걸 프롬프트 문구로만 해결하려 하면 성공률이 올라갈 뿐 0으로 떨어지지 않습니다. 자주 쓰는 기능이라면 그 잔여 확률이 매일 사고로 나타납니다. 구조를 강제하는 장치와 실패 처리를 함께 갖춰야 합니다.
1단계: API가 제공하는 강제 기능을 먼저 쓴다
대부분의 제공자가 출력 형식을 강제하는 옵션을 제공합니다. 이름은 다르지만 크게 두 종류입니다.
JSON 모드는 응답이 유효한 JSON이 되도록 보장합니다. 다만 "유효한 JSON"일 뿐, 우리가 원하는 필드가 들어 있다는 보장은 아닙니다.
스키마 지정은 한 걸음 더 나아가 필드 이름과 타입까지 맞춰줍니다. 제공자에 따라 함수 호출(툴) 기능으로 같은 효과를 내기도 합니다.
const res = await client.chat.completions.create({
model: MODEL,
messages,
response_format: { type: "json_object" }, // 제공자별 옵션명은 문서 확인
});
이 옵션을 쓸 때는 프롬프트에도 원하는 구조를 명시해야 합니다. 형식만 강제되고 내용은 모델이 정하기 때문입니다. 기대하는 키와 타입을 예시로 한 번 보여주는 것이 가장 효과적입니다.
2단계: 그래도 깨지는 경우를 처리한다
옵션을 켜도 깨질 수 있습니다. 대표적인 세 가지입니다.
코드 펜스로 감싸여 온다. 형식 강제 옵션을 못 쓰는 모델이나 폴백 경로에서 자주 나옵니다.
최대 토큰에 걸려 잘린다. 출력 상한에 도달하면 문장 중간에서 끝납니다. JSON이 닫히지 않은 상태로 도착합니다.
유효한 JSON인데 스키마가 다르다. 필드 이름이 살짝 다르거나, 배열이어야 할 곳에 객체가 오거나, 숫자여야 할 값이 문자열로 옵니다.
앞의 두 가지는 정리 함수로, 세 번째는 검증으로 걸러야 합니다.
function parseJsonResponse(raw) {
if (!raw) throw new Error("empty response");
const cleaned = raw
.trim()
.replace(/^```(?:json)?\s*/i, "") // 코드 펜스 제거
.replace(/```\s*$/, "");
return JSON.parse(cleaned); // 잘린 응답은 여기서 예외
}
정리 후에도 파싱이 실패하면 그건 재시도해야 할 상황입니다. 억지로 복구하려 들지 마세요. 잘린 JSON을 중괄호 개수 세어 이어 붙이는 코드를 종종 보는데, 잘린 지점이 문자열 중간이면 의미가 바뀐 값이 통과합니다. 깨진 데이터를 받아들이는 것보다 실패하는 편이 낫습니다.
3단계: 검증을 재시도 조건으로 만든다
파싱이 됐다고 끝이 아닙니다. 필요한 필드가 있는지, 타입이 맞는지 확인해야 합니다. 여기서 실패하면 역시 재시도 대상입니다.
검증을 파싱 단계에 묶어두면 재시도 로직이 단순해집니다.
async function generateStructured(messages, validate, { maxAttempts = 3 } = {}) {
let lastError;
for (let i = 0; i < maxAttempts; i++) {
try {
const res = await client.chat.completions.create({
model: MODEL, messages, response_format: { type: "json_object" },
});
const data = parseJsonResponse(res.choices[0]?.message?.content);
return validate(data); // 스키마 불일치면 예외를 던지게 한다
} catch (err) {
lastError = err; // 파싱 실패와 검증 실패 모두 재시도
await sleep(1000 * 3 ** i);
}
}
throw lastError;
}
스키마 검증 라이브러리를 쓰면 validate를 직접 짜지 않아도 됩니다. 핵심은 검증 실패가 예외로 올라와 재시도 루프에 걸리는 구조입니다. 검증 결과를 조용히 무시하고 기본값으로 채우면, 잘못된 데이터가 그대로 저장소에 들어갑니다.
잘리는 문제는 재시도로 안 풀린다
최대 토큰에 걸려 잘리는 경우는 몇 번을 다시 해도 같은 곳에서 잘립니다. 이건 재시도가 아니라 설계로 풀어야 합니다.
- 출력 상한을 넉넉히 잡습니다. 기대 길이의 두 배 정도가 안전합니다.
- 응답 구조를 얕게 만듭니다. 중첩이 깊을수록 같은 내용에 더 많은 토큰이 듭니다.
- 긴 목록은 나눠 요청합니다. 한 번에 50개를 받는 것보다 10개씩 다섯 번이 안정적입니다.
- 불필요한 필드를 빼세요. 쓰지 않을 설명 필드가 출력의 절반을 차지하는 경우가 흔합니다.
응답에 마침 이유(finish reason)가 함께 오는 API라면, 그 값이 길이 초과인지 확인해 로그에 남겨두세요. 잘림과 다른 실패를 구분하지 못하면 원인을 계속 못 찾습니다.
어디까지 강제할지도 설계다
모든 필드를 필수로 두면 모델이 억지로 값을 채웁니다. 모르는 것에 대해 그럴듯한 값을 만들어내는 상황이 여기서 생깁니다.
모르면 모른다고 답할 수 있는 자리를 만들어두는 편이 낫습니다. 값을 null로 둘 수 있게 하거나, confidence 같은 필드를 두고 낮은 값이면 사람이 확인하도록 분기하는 방식입니다. 구조를 강제하는 것과 내용을 강요하는 것은 다릅니다.
재시도는 몇 번이 적당한가요?
2~3회면 충분합니다. 그 이상 실패한다면 일시적 문제가 아니라 프롬프트나 스키마 설계의 문제일 가능성이 높습니다. 재시도 횟수를 늘리는 대신 실패를 기록하고 알림을 보내 원인을 확인하는 편이 낫습니다. 그리고 재시도 사이에는 간격을 두세요. 제공자 쪽 일시 오류라면 즉시 재시도는 상황을 악화시킵니다.
실패했을 때 사용자에게 무엇을 보여줘야 하나요?
기능에 따라 다릅니다. 결과가 없으면 화면을 구성할 수 없는 기능이라면 실패를 명확히 알리고 다시 시도할 수단을 주는 것이 맞습니다. 부가 기능이라면 그 부분만 비워두고 나머지를 정상적으로 보여주는 편이 낫습니다. 어느 쪽이든 "생성 실패"를 빈 결과로 위장하지는 마세요. 사용자는 결과가 없는 것인지 실패한 것인지 구분할 수 없습니다.
스키마를 자주 바꿔도 되나요?
바꾸는 것 자체는 문제없지만, 이미 저장된 데이터가 옛 스키마를 따르고 있다는 점을 고려해야 합니다. 저장할 때 스키마 버전을 함께 기록해두면 나중에 읽는 쪽에서 분기할 수 있습니다. 이 한 줄이 나중에 마이그레이션 비용을 크게 줄여줍니다.
실패율을 재보는 것부터
같은 프롬프트를 여러 번 호출해 파싱과 검증 실패율을 재보면, 지금 구조가 얼마나 안정적인지 숫자로 나옵니다. 감이 아니라 그 숫자를 보고 어디를 손댈지 정하는 편이 빠릅니다.
AI 기능이 들어간 프로젝트를 만들었다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 실제 사용자 입력은 개발 중에 넣어본 것보다 훨씬 다양해서, 구조가 깨지는 조건이 그때 드러나는 경우가 많습니다.
