새벽 2시의 장애와 데모에서만 작동하는 파서
새벽 2시에 알람이 울렸다. LLM이 갑자기 '기억을 잃었다'. 사용자가 프로젝트 일정을 논의하던 도중, 챗봇은 태연하게 "무엇을 도와드릴까요?"를 반복했다. 원인은 단 한 줄—Redis 만료 정책을 건드린 배포였고, 아무도 메모리 지속성 흐름을 배포 전에 테스트하지 않았다. 같은 시각, 다른 팀에서는 JSON.parse(text) 한 줄이 프로덕션을 조용히 망가뜨리고 있었다. 모델이 어제는 colorCoordination을 쓰고, 오늘은 color_coordination을 반환했기 때문이다. 두 장애의 겉모습은 달라 보이지만 뿌리는 같다. AI 생성물의 동작을 신뢰할 수 있는 구조 위에 올려놓지 않았다는 것.
왜 Mock은 배신하는가
dev.to에 공개된 pytest+Redis 사례는 문제의 핵심을 정확하게 짚는다. LLM 메모리 스토어는 본질적으로 TTL이 붙은 키-값 시스템이다. 세션 ID를 키로, 대화 이력과 벡터 인덱스를 값으로 Redis에 직렬화해 쌓는 구조다. 그런데 많은 팀이 fakeredis나 Python dict로 이 레이어를 목킹한다. 문제는 Mock이 Redis의 실제 동작—레이지 만료, 비동기 삭제, Lua 스크립트, 커넥션 풀 고갈—을 재현하지 못한다는 것이다. 테스트는 통과하고, 프로덕션은 폭발한다.
해법은 '진짜 Redis, 완전 격리, 자동 정리, 재현 가능' 네 가지를 동시에 만족하는 픽스처 설계다. conftest.py에서 uuid로 랜덤 네임스페이스를 생성하고, 테스트가 끝나면 해당 프리픽스 하위 키를 SCAN으로 일괄 삭제한다. 병렬 테스트 간 간섭이 없고, 프로덕션 데이터를 건드릴 일도 없다. CI에서는 docker-compose로 전용 Redis 인스턴스를 올리면 된다. testcontainers-python은 네트워크 불안정 환경에서 이미지 풀링 지연이 있어, 작은 팀에는 미리 준비된 전용 Redis에 직접 연결하는 쪽이 현실적으로 더 낫다.
실제로 커버해야 할 시나리오
이 구조 위에서 테스트가 검증하는 것은 세 가지다. 첫째, 대화 메모리를 쓰고 읽었을 때 데이터가 완전히 일치하는가. 둘째, TTL이 만료된 후 세션 데이터가 실제로 사라지는가—time.sleep(3)으로 기다린 뒤 None이 반환되는지 확인한다. 셋째, 서로 다른 네임스페이스를 쓰는 두 스토어가 동일 키로 저장해도 서로 간섭하지 않는가. 이 세 가지 테스트가 모든 PR 머지 전에 통과해야 한다는 게이트를 걸었더니, 새벽 장애 호출이 사라졌다. 30분짜리 수동 검증이 90초 자동화 회귀 스위트로 바뀐 것은 결과이고, 진짜 변화는 메모리 관련 코드를 건드리면 반드시 검증된다는 팀의 확신이다.
JSON 파싱 지옥을 구조 설계로 끝내는 법
두 번째 문제는 LLM 출력 파싱이다. JSON.parse(text)는 데모에서 작동하고 프로덕션에서 깨진다. 모델이 JSON 앞에 "물론이죠! 분석 결과입니다:"를 붙이거나, 코드 펜스로 감싸거나, 어제와 다른 키 이름을 쓰면 그 즉시 런타임 오류다. 이 불확실성에 대응하는 방식은 두 가지다: 방어적 파서를 계속 쌓거나, 모델이 응답을 생성하는 시점에 구조를 강제하거나.
dev.to에 공개된 Zod+Responses API 사례는 후자를 선택한다. Zod로 출력 스키마를 한 번 정의하면, zodTextFormat 헬퍼가 이를 JSON Schema로 변환해 API에 넘긴다. 모델은 디코딩 단계에서 이 스키마에 맞게 출력이 제약된다. 프롬프트로 "JSON으로 주세요"라고 요청하는 게 아니라, 모델이 물리적으로 스키마 밖의 응답을 낼 수 없게 된다. openai.responses.parse()가 반환하는 output_parsed는 이미 타입이 붙은 객체다. JSON.parse 없이, 캐스팅 없이.
스키마에 실패를 설계하라
이 패턴에서 대부분의 튜토리얼이 건너뛰는 부분이 있다. 실제 입력은 지저분하다. 흐릿한 사진, 사람이 없는 이미지, 인식 불가능한 문서. 이걸 모델이 자유 텍스트로 "분석할 수 없습니다"라고 반환하게 두면, 구조화 계약이 즉시 깨진다. 해법은 실패를 스키마의 일급 시민으로 만드는 것이다. error 필드를 fit, colorCoordination과 나란히 스키마에 정의하고, 시스템 프롬프트에서 모델이 이 필드를 언제 채울지 명확히 지시한다. 그러면 "분석 불가"도 타입이 붙은 정상 응답 경로가 된다.
한 가지 더. 필드를 .string() 대신 .string().nullable().optional()로 선언하는 이유가 있다. 구조화 출력은 모델이 값이 없는 필드에 null을 채울 수 있고, 엄격한 .string()은 이를 검증 오류로 처리한다. 스키마 레이어에서 구조를 보장하고, 비즈니스 완결성은 코드 레이어에서 별도로 검증하는 것이 옳은 분업이다. Zod로 "세 필드가 모두 있거나 에러 필드 하나만 있거나"를 표현하려다 타입 시스템과 싸우지 말고, 플랫 스키마와 if 두 줄로 명확하게 가는 편이 낫다.
AI-First 팀의 품질 설계는 '나중에 고치는 것'이 아니다
두 사례를 나란히 놓으면 공통된 설계 원칙이 보인다. Redis 픽스처도, Zod 스키마도 모두 불확실성을 구조로 흡수한다. 모델의 응답이 달라질 수 있다는 사실, Redis의 만료 동작이 Mock과 다르다는 사실을 전제로 깔고, 그 위에서 신뢰할 수 있는 레이어를 설계한다.
AI-First 팀이 흔히 빠지는 함정은 프로토타입 속도에 취해 이 설계를 미루는 것이다. 90초 회귀 테스트 스위트를 만들기 전까지는 메모리 관련 코드를 건드리는 것 자체가 도박이었다. Zod 스키마 없이 방어적 파서를 쌓는 것은 매 배포마다 '오늘은 모델이 어떤 형식으로 줄까'를 기도하는 것이다. 둘 다 지속 가능하지 않다.
테크 리드가 먼저 설계해야 할 것
내일 당장 적용 가능한 체크리스트는 단순하다. LLM 메모리를 다루는 코드에 fakeredis나 dict 목킹이 있다면 실제 Redis 픽스처로 교체하고, 해당 테스트를 PR 게이트로 등록한다. OpenAI API를 호출하는 코드에 JSON.parse가 있다면 Zod 스키마 + responses.parse()로 전환하고, 실패 케이스를 스키마 안에 명시적으로 정의한다.
속도를 높이는 것과 품질을 유지하는 것이 트레이드오프라는 생각은 틀렸다. 올바른 구조 위에서 개발 속도는 오히려 올라간다. 새벽 알람이 사라지고, JSON 파싱 버그를 디버깅하는 시간이 사라지면, 팀이 실제로 짓고 싶은 것에 집중할 수 있다. AI-First 팀의 프로덕션 품질은 '나중에 고치는 것'이 아니라 '처음부터 설계하는 것'이다.