상품 리뷰 500건을 AI로 분류하는 스크립트를 돌렸는데, 340번째에서 JSONDecodeError가 나면서 멈췄습니다. 로그를 열어 보니 모델이 JSON 앞에 “물론이죠! 아래는 분석 결과입니다.”라는 인사말을 한 줄 붙였더군요. 이런 식으로 AI JSON 응답 파싱이 실패하면, 그때까지 쓴 API 비용은 그대로 날아가고 처음부터 다시 돌려야 합니다. 500건에 20분이 걸리는 작업이라면 하루에 두세 번만 실패해도 한 시간이 사라집니다. 이 글에서는 프롬프트로 부탁하는 수준을 넘어서, 실무에서 실패율을 사실상 0에 가깝게 만드는 네 단계를 순서대로 다룹니다.
먼저, JSON은 왜 깨지는가
실패 사례를 모아 보면 패턴이 몇 가지로 정리됩니다. 무엇을 막아야 하는지 알아야 대책도 세울 수 있으니, 실제로 돌아오는 응답들을 먼저 보겠습니다.
# 우리가 기대한 응답
{"sentiment": "positive", "score": 0.9}
# 실제로 돌아오는 응답들
(1) 앞뒤에 설명이 붙는 경우
물론이죠! 아래는 분석 결과입니다.
{"sentiment": "positive", "score": 0.9}
도움이 되셨길 바랍니다.
(2) 마크다운 코드펜스로 감싸는 경우
```json
{"sentiment": "positive", "score": 0.9}
```
(3) 작은따옴표와 파이썬 리터럴이 섞이는 경우
{'sentiment': 'positive', 'score': 0.9, 'verified': True}
(4) 마지막 항목 뒤에 쉼표가 남는 경우
{"sentiment": "positive", "score": 0.9,}
(5) 응답이 길어서 중간에 잘린 경우
{"sentiment": "positive", "score": 0.9, "keywords": ["배송이 빠
여기서 중요한 건 (1)부터 (4)까지와 (5)의 성격이 완전히 다르다는 점입니다. 앞의 네 가지는 내용은 멀쩡한데 포장이 잘못된 경우라 후처리로 살릴 수 있습니다. 하지만 (5)는 응답 자체가 중간에 끊긴 것이라 어떤 파서로도 복구할 수 없습니다. 원인이 다르니 해결책도 다른 곳에서 찾아야 합니다.
그런데 많은 코드가 이 구분 없이 이렇게만 쓰여 있습니다.
import json
# 100건 중 3~5건에서 여기서 죽는다
result = json.loads(response_text)
# json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
평소에는 잘 돌아가다가 배치를 크게 돌릴 때만 터지기 때문에, 문제를 늦게 발견하게 되는 전형적인 구조입니다.
1단계: 프롬프트만 믿지 않는다
“설명 없이 JSON만 출력해”라고 프롬프트에 적는 것은 여전히 기본이고, 실제로 실패율을 상당히 낮춰 줍니다. 다만 이건 부탁이지 강제가 아닙니다. 입력이 길거나 애매하거나, 모델이 바뀌거나, 온도(temperature) 설정이 높으면 언제든 흔들립니다. 프롬프트 단계에서 해 둘 만한 것은 이 정도입니다.
- 출력 형식을 말로 설명하지 말고 예시 JSON을 그대로 한 번 보여줍니다. 형식 설명 열 줄보다 예시 하나가 정확합니다.
- “코드 블록이나 설명 없이, 여는 중괄호로 시작해서 닫는 중괄호로 끝나야 한다”처럼 금지 사항을 구체적으로 적습니다.
- 값이 없을 때 무엇을 넣을지 미리 정해 줍니다. 안 정해 두면 모델이
null, 빈 문자열, “해당 없음”을 그때그때 다르게 씁니다. - 분류 항목은 자유 서술이 아니라 선택지를 나열해서 고르게 합니다.
여기까지가 프롬프트로 할 수 있는 전부입니다. 이제 진짜 강제 수단으로 넘어갑니다.
2단계: API의 구조화 출력 기능으로 강제한다
요즘 주요 모델 API에는 출력 형태를 스키마로 못박는 기능이 있습니다. 모델이 자유롭게 문장을 쓴 뒤 우리가 파싱하는 게 아니라, 애초에 스키마에 맞는 값만 생성하도록 제약을 거는 방식입니다. 인사말이 끼어들 자리 자체가 없어지므로 원인을 없애는 해결책에 가깝습니다.
클로드(Claude) API에서는 도구(tool) 정의를 이 용도로 씁니다. 실제로 함수를 실행할 생각이 없어도, 받고 싶은 구조를 도구의 입력 스키마로 선언하고 그 도구를 반드시 쓰라고 지정하면 됩니다.
import anthropic
client = anthropic.Anthropic() # ANTHROPIC_API_KEY 환경변수를 읽는다
schema = {
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"],
},
"score": {"type": "number"},
"keywords": {"type": "array", "items": {"type": "string"}},
},
"required": ["sentiment", "score", "keywords"],
}
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
tools=[{
"name": "save_analysis",
"description": "리뷰 분석 결과를 구조화해서 저장한다.",
"input_schema": schema,
}],
# 이 줄이 핵심이다. 모델에게 이 도구를 반드시 쓰라고 못박는다.
tool_choice={"type": "tool", "name": "save_analysis"},
messages=[{"role": "user", "content": "이 리뷰를 분석해줘: " + review_text}],
)
# 파싱이 필요 없다. 이미 파이썬 dict다.
data = next(b.input for b in resp.content if b.type == "tool_use")
print(data["sentiment"], data["score"])
마지막 줄을 보면 json.loads가 아예 없습니다. 응답의 tool_use 블록에 담겨 오는 input이 이미 파싱된 딕셔너리이기 때문입니다. 파싱 단계가 사라지면 파싱 실패도 사라집니다.
스키마를 선언하기가 번거로운 간단한 작업이라면, 모델 답변의 첫 글자를 미리 채워 넣는 방법도 있습니다. 답변이 이미 여는 중괄호로 시작해 버렸으니 모델은 그 뒤를 이어 쓸 수밖에 없습니다.
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[
{"role": "user", "content": "이 리뷰를 JSON으로 분석해줘: " + review_text},
# 모델의 답변 첫 글자를 우리가 대신 써 준다.
# 모델은 이 뒤를 이어서 쓸 수밖에 없으므로 인사말이 끼어들 자리가 없다.
{"role": "assistant", "content": "{"},
],
)
# 우리가 넣은 여는 중괄호를 다시 붙여줘야 완전한 JSON이 된다.
raw = "{" + resp.content[0].text
다른 제공사도 이름만 다를 뿐 비슷한 기능이 있습니다. 함수 호출(function calling) 방식은 대체로 공통이고, 응답 형식을 JSON 스키마로 직접 지정하는 옵션을 따로 두기도 합니다. 쓰는 모델의 공식 문서에서 지원 여부와 스키마 제약을 먼저 확인하세요. 스키마 기능마다 지원하는 타입 범위가 조금씩 달라서, 중첩이 깊거나 조건부 필드가 있는 스키마는 거절당하기도 합니다.
방식별 비교
| 방식 | 강제력 | 적용 난이도 | 언제 쓰나 |
|---|---|---|---|
| 프롬프트로 부탁 | 약함 | 아주 쉬움 | 어떤 경우든 기본으로 깔고 간다 |
| 답변 첫 글자 채워넣기 | 중간 | 아주 쉬움 | 구조가 단순하고 호출이 적을 때 |
| 후처리로 추출 | 중간 | 쉬움 | 모델·제공사를 자유롭게 바꿔야 할 때 |
| 도구/함수 스키마 | 강함 | 중간 | 배치 처리 등 실패가 비싼 작업 |
| 스키마 검증 + 재시도 | 보완용 | 쉬움 | 위 방식들과 항상 함께 |
3단계: 그래도 뚫릴 때를 위한 방어 코드
구조화 출력을 쓸 수 없는 상황도 있습니다. 로컬 모델을 돌리거나, 스키마 기능을 지원하지 않는 옛 버전을 써야 하거나, 여러 제공사를 갈아 끼우는 구조라면 결국 문자열을 받아서 처리해야 합니다. 이때 쓸 추출 함수는 실패할수록 더 거칠게 시도하는 계단 구조로 짜는 게 좋습니다.
import json
import re
def extract_json(text):
"""모델 응답 문자열에서 JSON 객체를 최대한 건져낸다."""
text = text.strip()
# 1) 마크다운 코드펜스 제거
fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL)
if fence:
text = fence.group(1).strip()
# 2) 그대로 파싱해 보고, 되면 끝
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 3) 앞뒤 잡담이 붙은 경우: 첫 여는 괄호부터 마지막 닫는 괄호까지만 잘라낸다
starts = [i for i in (text.find("{"), text.find("[")) if i != -1]
ends = [i for i in (text.rfind("}"), text.rfind("]")) if i != -1]
if starts and ends:
sliced = text[min(starts):max(ends) + 1]
try:
return json.loads(sliced)
except json.JSONDecodeError:
pass
# 4) 여기까지 왔으면 포기하고 호출한 쪽에 알린다
raise ValueError("JSON을 찾지 못했습니다: " + text[:200])
세 번째 단계가 실무에서 가장 많이 걸립니다. 앞뒤 잡담을 잘라내는 것만으로 대부분의 실패가 해결되기 때문입니다. 다만 여기서 한 걸음 더 나가지는 마세요. 작은따옴표를 큰따옴표로 바꾸거나 트레일링 쉼표를 지우는 식의 자동 교정을 넣고 싶어지는데, 문자열 값 안에 들어 있던 따옴표까지 건드려서 내용을 조용히 망가뜨립니다. 깨진 걸 억지로 고치는 것보다 예외를 던지고 재시도하는 편이 안전합니다.
4단계: 스키마 검증과 재시도로 마무리한다
JSON 파싱이 성공했다고 끝이 아닙니다. 문법은 맞는데 score가 숫자 대신 "0.9점" 문자열로 오거나, 필수 필드가 통째로 빠져 있거나, 분류 값이 정해 둔 선택지 밖의 단어일 수 있습니다. 이런 값이 그대로 데이터베이스에 들어가면 며칠 뒤 엉뚱한 곳에서 문제가 터집니다. 파이썬이라면 pydantic, 자바스크립트라면 zod 같은 검증 라이브러리로 한 겹 더 거르는 게 좋습니다.
from pydantic import BaseModel, Field, ValidationError
from typing import Literal
class Analysis(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
score: float = Field(ge=0, le=1)
keywords: list[str]
def analyze(review_text, max_retry=2):
last_error = None
for attempt in range(max_retry + 1):
raw = call_model(review_text, error_hint=last_error)
try:
return Analysis(**extract_json(raw))
except (ValueError, ValidationError) as e:
# 무엇이 틀렸는지를 다음 요청에 그대로 넣어준다.
# 막연히 "다시 해줘"라고 하는 것보다 성공률이 눈에 띄게 높다.
last_error = str(e)
print("시도 " + str(attempt + 1) + " 실패:", last_error[:120])
raise RuntimeError("재시도를 모두 소진했습니다: " + str(last_error))
재시도할 때 에러 메시지를 다음 요청에 함께 넣어 주는 것이 요령입니다. “score는 0과 1 사이 숫자여야 하는데 문자열이 왔다”라고 알려 주면 모델이 그 부분을 고쳐서 다시 만듭니다. 그냥 같은 요청을 반복하면 같은 이유로 또 실패할 확률이 높습니다.
재시도 횟수는 두세 번이면 충분합니다. 그 이상 실패한다면 일시적인 흔들림이 아니라 프롬프트나 스키마 설계가 잘못된 것이므로, 무한정 재시도하며 비용을 태우기보다 예외를 던져 사람이 보게 하는 게 낫습니다.
주의할 점
앞에서 “복구 불가능”이라고 했던 (5)번, 응답이 잘리는 경우가 실무에서는 가장 성가십니다. 이건 파싱 문제가 아니라 길이 제한 문제입니다.
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
...
)
# 응답이 길이 제한에 걸려 잘렸는지 반드시 확인한다.
# 이 경우 JSON은 100% 깨져 있으므로, 파싱을 시도하기 전에 걸러내는 편이 낫다.
if resp.stop_reason == "max_tokens":
raise RuntimeError("응답이 잘렸습니다. max_tokens를 올리거나 입력을 나누세요.")
입력 건수가 늘어나면 어느 순간 출력이 max_tokens를 넘기고, 그때부터 JSON이 항상 깨집니다. 파서를 아무리 고쳐도 안 고쳐지는데 원인을 엉뚱한 데서 찾게 되므로, 파싱을 시도하기 전에 stop_reason부터 확인하는 습관을 들여 두면 디버깅 시간을 크게 아낄 수 있습니다.
나머지 자잘한 함정도 정리해 둡니다.
- 한 번에 100건씩 묶어서 처리하지 마세요. 배치 크기가 크면 잘릴 확률이 올라가고, 한 건이 깨지면 100건을 다 버려야 합니다. 10~20건씩 나누는 편이 전체적으로 빠릅니다.
- 온도를 낮추세요. 구조화된 추출 작업은 창의성이 필요 없습니다. 0에 가깝게 두면 형식이 훨씬 안정적입니다.
- 한글이
\uc548같은 형태로 와도 정상입니다. 유니코드 이스케이프는 표준 JSON이라 파서가 알아서 되돌립니다. 저장할 때ensure_ascii=False만 챙기면 됩니다. - 스트리밍과 JSON은 궁합이 나쁩니다. 중간 조각은 언제나 미완성 JSON이라 파싱할 수 없습니다. 굳이 필요하다면 부분 파싱 라이브러리를 쓰거나, 스트리밍을 포기하는 게 간단합니다.
- 실패한 원본 응답을 로그에 남기세요. 예외 메시지만 남기면 나중에 원인을 재현할 수 없습니다. 이게 없어서 같은 문제를 두 번 조사하는 경우가 많습니다.
정리하면 순서는 이렇습니다. 구조화 출력으로 원인을 없애고, 안 되면 후처리로 건지고, 그다음 스키마로 검증하고, 마지막에 재시도로 덮는다. 위에서부터 막을수록 비용도 디버깅 시간도 적게 듭니다.
마무리
지금 돌리고 있는 스크립트가 있다면, 가장 작은 첫걸음은 json.loads를 호출하는 자리를 찾아 그 위에 stop_reason 확인 한 줄과 코드펜스 제거 한 줄을 넣는 것입니다. 10분이면 되고, 경험상 실패의 절반 이상이 이 두 줄에서 사라집니다. 그러고도 남는 실패가 있을 때 도구 스키마 방식으로 옮겨 가면 됩니다.
다음 글에서는 윈도우 환경에서 Node.js로 .cmd 파일을 실행할 때 EINVAL 오류로 프로세스가 죽는 문제를, 원인부터 해결책까지 짚어 보겠습니다.