200 OK, content: null — AI API를 기반으로 구축할 때 실제로 무엇이 고장 나는가
요약
AI API를 활용한 서비스 구축 시 발생하는 예기치 못한 실패 사례와 대응 방안을 다룹니다. HTTP 200 응답에도 불구하고 내용이 null로 반환되는 상황을 통해, 단순 예외 처리를 넘어선 정교한 방어 로직과 상세한 로깅의 중요성을 강조합니다.
핵심 포인트
- HTTP 200 응답이 반드시 성공적인 데이터 반환을 의미하지 않음
- 단순 KeyError/TypeError 외에 null 값에 대한 명시적 검증 필요
- 문제 해결을 위해 모델뿐만 아니라 서비스 제공 호스트(provider) 정보 로깅 필수
- API 파이프라인의 안정성을 위한 다중 벤더 폴백 전략 권장
어느 날 아침, 우리의 SEO 카피 생성기가 AttributeError: 'NoneType' object has no attribute 'strip' 오류와 함께 작동을 멈췄습니다. API 호출은 finish_reason: "stop"과 함께 200 OK를 반환했습니다. message.content가 null이었다는 점을 제외하면, 완전히 정상적으로 보이는 응답이었습니다.
그 null을 추적하기 전에 몇 가지 배경 설명을 드리겠습니다. 우리는 당신이 사랑하는 사람에 대한 개인화된 노래를 쓰고 제작하는 작은 웹 서비스인 Hitou를 만들고 있습니다. LLM이 당신이 들려주는 이야기로부터 가사를 쓰고, 음악 모델이 이를 노래하며, 몇 분 후에는 선물 페이지와 함께 트랙이 완성됩니다. 성공적인 경로(happy path)를 구축하는 데는 몇 주가 걸렸습니다. 그 이후의 모든 과정은 운영 환경에서 AI API가 어떻게 실패하는지를 배워가는 과정이었습니다.
이 포스트는 현장 기록입니다. 실제로 유료 사용자에게 도달했거나(또는 도달할 뻔했던) 실패 모드들, 그리고 이러한 일이 두 번 다시 발생하지 않도록 막아준 방어책들에 대해 다룹니다.
파이프라인 요약
마법사(wizard)가 이야기(노래의 대상, 상황, 내부 농담 등)를 수집합니다. LLM은 이를 JSON 형식의 구조화된 가사(절, 후렴구, 스타일 노트 등)로 변환합니다. 가사는 제공자 API를 통해 Suno v5로 전달되며, API는 두 개의 렌더링된 테이크(takes)와 단어 단위의 타임스탬프(timestamps)를 반환합니다. 우리는 미리보기를 자르고, 타임스탬프를 이용해 가라오케 스타일의 하이라이트 트랙을 제작하며, FastAPI 앱을 통해 전체 서비스를 제공합니다. 두 개의 음악 제공자가 스위치 뒤에 위치합니다. 하나는 기본(primary), 다른 하나는 폴백(fallback)으로 설정되어 있어, 특정 벤더의 상태가 좋지 않더라도 주문이 중단되지 않도록 합니다.
단순해 보입니다. 흥미로운 점은 이 파이프라인의 모든 화살표가 상태 코드(status code)로는 절대 알 수 없는 방식으로 실패할 수 있는 제3자 API라는 점입니다.
사례 연구 #1: 200 OK, content: null
다시 그 null 이야기로 돌아가 보겠습니다. LLM 라우터(우리는 OpenRouter를 사용합니다)에 대해 알아야 할 점이 있습니다. 하나의 모델 슬러그(slug)는 여러 독립적인 호스트에 의해 서비스되며, 라우터는 요청마다 하나를 선택합니다. 우리는 결국 해당 모델을 서비스하는 모든 호스트에 대해 동일한 프롬프트를 실행했습니다. 그중 정확히 하나가 고장 나 있었습니다. 해당 호스트는 정상적인 finish_reason과 함께 깔끔한 200 응답을 반환했지만, 전체 완성 내용을 reasoning 필드에 담고 content에는 null을 반환했습니다.
이제 저희 코드에 반영된 세 가지 교훈입니다:
- 예외 가드 (exception guard)의 형태가 잘못되었을 가능성이 높습니다. 저희는 응답 파싱(parsing) 주변에
except (KeyError, IndexError, TypeError)를 사용했습니다. 하지만 이 상황에서는 그 중 어떤 것도 발생하지 않았습니다. 키(key)는 존재하고, 값(value)이null이기 때문입니다. 이 실패는 두 번의 호출이 더 지난 후에야, 전혀 관련 없어 보이는 코드에서AttributeError로 나타났습니다. - 모델뿐만 아니라 호스트 (host)도 로그에 남기세요. 응답 본문(및 모든 스트리밍 청크 (streaming chunk))에는
provider필드가 포함되어 있습니다. 이를 로그에 남기지 않으면, "모델이 불안정하다"는 것과 "33개의 호스트 중 하나가 고장 났다"는 것을 구분할 수 없습니다. 후자의 경우 설정 수준에서 해결할 수 있습니다. - 데코레이션 (decoration)을 제거한 후 비어 있는지 확인하세요. 동일한 유형의 호스트 버그는 내부에 아무것도 없는 빈 fenced
json코드 블록을 반환할 수 있습니다. 만약 펜스 (fence)를 제거하기 전에if not content를 확인한다면, 이 쓰레기 데이터는 그대로 통과됩니다.
해결책은 지루했습니다. 바로 그게 핵심입니다. 환경 변수 (env var)에 의해 구동되는 요청별 provider: {"ignore": [...]} 리스트를 만들고, 배포 중단 없이 즉시 조치할 수 있는 비상 레버로서 라우터 대시보드에 계정 수준의 무시(ignore) 리스트를 추가했습니다.
실전 사례 #2: "None"이라는 제목의 노래
일주일 후, 동일한 형태의 버그가 한 단계 더 깊은 곳에서 다시 나타났습니다. 이번에는 실제로 비용이 발생했습니다.
LLM 응답은 이제 검증되었습니다. null이 아니며, 코드 펜스를 제거한 후에도 비어 있지 않고, JSON이 파싱되었으며, 필요한 모든 섹션이 갖춰져 있었습니다. 저희의 검증 로직이 놓친 것은 **리프 타입 (leaf types)**이었습니다. 키는 존재할 수 있고, 구조는 올바르게 보일 수 있지만, 값은 여전히 null일 수 있습니다. 그리고 저희 코드는 다음과 같이 작성되어 있었습니다:
lyrics = str(payload.get("lyrics", ""))
방어적으로 보이나요? 그렇지 않습니다. .get()의 기본값은 키가 누락되었을 때만 적용됩니다. 페이로드(payload)가 {"lyrics": null}일 때, 키는 존재하므로 .get()은 None을 반환하고, str(None)은 문자열 `
그래서 우리는 "None"이라는 단어를 전문적으로 노래하도록 음악 API에 비용을 지불했습니다. 단어 타임스탬프 (word-timestamps) 페이로드 내의 관련 null 값이 유료 고객의 가라오케 하이라이트에 "None"이라는 글자를 그대로 집어넣은 것입니다. 아무것도 충돌(crash)하지 않았습니다. 코드베이스의 어떤 예외 처리기(exception handler)도 이를 잡아낼 수 없었습니다.
올바른 관용구(idiom)는 토큰 하나가 다릅니다:
lyrics = str(payload.get("lyrics") or "")
일주일 사이에 두 번이나 이 문제로 데이고 난 후, 우리는 리뷰(review)가 이를 잡아낼 것이라고 믿는 것을 그만두고 코드베이스 전체의 AST (Abstract Syntax Tree)를 탐색하는 테스트를 작성했습니다:
def _offenders(tree: ast.AST, where: str) -> list[str]:
"""str(<x>.get(<key>, "<literal>")) 호출을 모두 표시합니다."""
found = []
...
왜 grep이 아니라 AST일까요? grep은 실제 위반 사례를 놓쳤기 때문입니다: 인덱싱이 중간에 포함된 다중 행 호출(str(messages[-1].get("content", "")))이 바로 그것입니다. AST는 호출이 어떻게 포맷되어 있는지 상관하지 않습니다.
우리는 의도적으로 str(...)만 차단(gate)했습니다. float(None)은 모든 에러 경로가 잡아낼 수 있는 명확한 TypeError를 발생시키지만, str(None)은 조용히(silently) 성능을 저하시키며 사용자에게 전달됩니다. 조용한 실패를 차단하십시오. 명확한 실패는 스스로 잡힙니다.
"경계(boundary)에 그냥 Pydantic을 적용하세요"라는 말은 타당한 답변이며, 엄격한 응답 모델(response models)을 사용했다면 이 문제도 잡아냈을 것입니다. 하지만 실제로 우리는 여러 외부 경계(LLM 라우터, 두 곳의 음악 제공업체, 전사(transcription) API)를 가지고 있으며, 이들은 서로 다른 속도로 진화하고 있고, 그들 모두가 아직 완전한 타입 모델(typed model)을 갖출 만큼의 자격을 갖춘 것은 아닙니다. AST 차단 방식은 아무도 모델링할 엄두를 내지 못한 경계들을 포함하여, 코드베이스 내의 모든 str(.get())을 커버하는 단 30줄짜리 테스트입니다.
전쟁 이야기 #3: 단어 타임스탬프는 거짓말을 한다 (세 가지 구체적인 방식)
전쟁 이야기 #3: 단어 타임스탬프는 거짓말을 한다 (세 가지 구체적인 방식)
- 유령 첫 줄(Phantom first lines). 얼라이너(aligner)가 때때로 시작 부분을 두 번 방출합니다. 하나는 모든 단어 시작이 2초 미만으로 압축된 복사본(인트로 연주곡에 맞춰 빠르게 지나감)이고, 그 직후에 실제 노래하는 줄이 나옵니다. 감지 방법: 첫 번째 부분이 <2초 동안 지속되는 두 개의 인접한 동일 라인을 찾습니다. 압축된 부분을 버립니다.
- 압축된 줄(Squeezed lines). 0.85초 안에 여섯 단어가 밀집되어 노래하는 줄이 나오고, 이 빼앗긴 시간은 다음 단어에 덤프되어 약 3초로 늘어납니다. 핵심은 트랙 자체의 템포와 비교하여 감지하는 것입니다: 각 라인의 단어당 속도를 중앙값 간 단어 간 단계(median inter-word step)와 비교하고, 그 라인을 실제 시간 창 전체에 걸쳐 균등하게 재분배합니다.
- 끝이 침묵을 흡수한다(Ends absorb silence). 단어의
endS가 연주곡의 공백을 삼킬 수 있습니다. 우리는 11초를 측정했습니다. 하이라이트가 단어 끝을 따라가면 심하게 표류합니다. 시작점을 따르세요.
위 세 가지 모두에 적용되는 포괄적인 정책은 다음과 같습니다: 노래방 비트를 망가뜨린 노래방(no karaoke beats broken karaoke)은 없습니다. 노멀라이저(normalizer)는 신뢰할 수 없는 모든 것에 대해 None을 반환합니다. 너무 적은 단어, 임계값을 초과하는 문자 오류율(건강한 노래 트랙의 경우 벤더의 CER 지표에서 약 0.20-0.25를 측정하므로, 0.4를 초과하면 거부), 비단조 시간 스탬프, 트랙 지속 시간을 지난 시간 스탬프 등이 해당됩니다. 페이지는 기능 없이 조용히 렌더링됩니다. 우아한 성능 저하는 운영(ops) 측면뿐만 아니라 제품 결정입니다.
전쟁 이야기 #4: 생성에 3~9분이 걸린다. 때로는 30분까지도 걸린다.
음악 생성은 느리고 가끔 매우 느립니다: 동일한 API, 동일한 페이로드임에도 불구하고 평소의 39분 대신 2530분이 걸리기도 합니다. 초기에는 긴 꼬리(long tail)를 실패로 간주했습니다—시간 초과, 환불, 재생성. 이는 정확히 잘못된 접근 방식입니다. 왜냐하면 유료 렌더링을 다시 생성하는 것은 비용을 두 배로 늘리는 반면, 원래 작업은 종종 여전히 진행 중이기 때문입니다.
현재 우리가 실행하는 것:
- 재개(Resume)하되, 절대 다시 생성(re-create)하지 마세요. 모든 생성 작업은 해당 제공자(provider)의 작업 ID(task id)를 저장합니다. 멈춘 것처럼 보이는 모든 것은 다시 제출(re-submit)하는 것이 아니라, 동일한 작업에 대해 폴링(polling)을 수행하여 _재개(resumed)_해야 합니다. 제공자 전환(provider switch)은 이름으로 어댑터(adapter)를 해결하므로, 기본 제공자(primary)를 변경했더라도 진행 중인 작업은 항상 해당 작업을 생성한 제공자에서 재개됩니다.
- 안전망으로서의 리컨실러(reconciler). 백그라운드 루프가 진행이 멈춘 주문들(폴링 도중 프로세스 재시작, 워커(worker) 충돌 등)을 훑으며 해당 제공자의 작업에 다시 연결(re-attach)합니다. 배포 스크립트는 리컨실러의 하트비트(heartbeat)를 포함한 상태 확인(health check)을 기다리므로, "앱은 실행 중이지만 스위퍼(sweeper)가 죽은 상태"라면 고객에게 장애를 일으키는 대신 배포 자체가 실패하게 됩니다.
- 비대칭적 폴백(Asymmetric fallback). 기본 제공자가 정상일 경우 → 새 주문은 기본 제공자로 가며, 에러 발생 시 폴백(fallback)합니다. 기본 제공자가 수동으로 꺼진 경우 → 체인(chain)은 오직 폴백 용도로만 작동합니다. "비활성화된" 제공자로 조용히 라우팅을 계속하는 킬 스위치(kill switch)는 진정한 킬 스위치가 아닙니다. 재개(Resume) 작업은 체인을 완전히 우회합니다(위 내용 참조).
과거의 우리에게 해주고 싶은 말
- 상태 코드(Status codes)는 장식일 뿐입니다. 위의 모든 장애는 200 OK라는 포장지에 싸여 도착했습니다. 경계 지점(boundary)에서 데이터가 시스템에 들어오기 전에, 받은 데이터의 _형태와 내용(shape and content)_을 검증하세요.
str(x.get(k, ""))는 버그입니다.str(x.get(k) or "")라고 작성하세요. 코드베이스가 파일 하나보다 크다면, AST 게이트(AST gate)를 작성하세요. 단 30줄이면 충분하며, 결코 지치지 않습니다.- 모델 뒤에 있는 호스트(host)를 식별하세요. 라우팅된 API를 사용한다는 것은 당신의 "불안정한 모델(flaky model)"이 설정 수준의 수정만으로 해결 가능한 고장 난 기계 한 대일 수 있음을 의미합니다.
- 쓰레기(garbage)를 내보내느니 기능 저하(degradation)를 택하세요. 기능 블록 하나가 누락되는 것은 괜찮습니다. 생일 축하 노래에서 "None"이라는 단어가 노래되는 것은 괜찮지 않습니다.
- 오래 걸리고 유료인 작업에는 재시도(retry)보다 재개(resume)가 낫습니다. 작업 ID(task id)를 저장하세요. "멈춘 상태"를 "다시 구매(re-buy)"가 아닌 "다시 연결(re-attach)"로 취급하세요.
이 중 어느 것도 이국적인 엔지니어링이 아닙니다. 이는 "데모는 작동한다"와 "낯선 이들이 돈을 지불한다" 사이의 화려하지 않은 계층입니다. 만약 당신이 2026년에 LLM과 생성형 오디오(generative audio)를 결합하고 있다면, 실제 작업의 대부분은 바로 이곳에 존재하게 될 것입니다.
이 모든 배관 작업(plumbing)이 무엇을 만들어내는지 궁금하시다면: hitou.site — 누군가의 이야기를 단 몇 분 만에 완성된 노래로 바꿔줍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기