LLM 폴백을 뒀는데, 이미 죽어 있었습니다
이중화를 해뒀으니 한쪽이 죽어도 서비스는 산다 — 이 안심이 깨지는 데 필요했던 건 폴백 경로를 실제로 호출해본 단 한 번이었습니다. 내부 프로젝트 관리 도구의 AI 채팅에 외부 LLM을 주력과 폴백 이중으로 붙여뒀는데, 폴백은 이미 오래전에 죽어 있었습니다. 다이어그램에는 줄이 두 개 그려져 있었지만, 실제로 동작하는 줄은 하나뿐이었습니다.
폴백은 언제부턴가 조용히 죽어 있었습니다
고장의 원인은 우리 코드가 아니라 바깥이었습니다. 폴백으로 쓰던 provider가 API를 개편하면서 응답 구조가 바뀌었습니다. 기존에 outputs 필드에 담기던 응답이 steps 쪽으로 옮겨가는 식의 변경이었고, 우리 파싱 코드는 옛 구조를 기준으로 짜여 있었습니다. 폴백을 호출하면 응답 자체는 오는데 파싱이 빈손으로 끝나는 상태였습니다.
문제는 이 고장이 아무 신호도 내지 않았다는 점입니다. 평소에는 주력 provider가 멀쩡히 응답하니 폴백이 호출될 일 자체가 없었습니다. 폴백이 죽었다는 사실은 주력이 죽는 순간에야 드러나는 구조였고, 그 순간은 곧 서비스 전면 장애를 뜻했습니다. 평소에 안 쓰이는 경로가 조용히 썩어가는 이 현상을 fallback rot이라고 부르는데, 정확히 그 상태였습니다.

다이어그램에는 두 줄, 실제로는 한 줄
이 상황을 정리하면 이렇게 됩니다. 아키텍처 다이어그램에는 provider가 두 개 그려져 있고, 장애 대응 시나리오에도 폴백 전환이 적혀 있습니다. 그런데 실제로 트래픽을 받아낼 수 있는 provider는 하나뿐입니다. 주력이 사실상 단일 장애점인데, 문서와 머릿속에는 이중화가 있다고 믿고 있으니 별도의 대비도 하지 않게 됩니다.
사무실 벽에 걸린 소화기와 같은 구조입니다. 걸려 있다는 사실이 화재 대응 능력을 보장하지 않고, 압력 게이지를 주기적으로 확인한 소화기만 불이 났을 때 실제로 나옵니다. 폴백도 마찬가지로 걸려 있는 것과 나오는 것은 완전히 다른 문제였습니다. 검증 안 된 이중화가 단일 구성보다 위험할 수 있는 이유가 여기 있습니다. 처음부터 provider가 하나뿐이었다면 여기가 죽으면 끝이라는 긴장이라도 유지되는데, 죽은 폴백은 그 긴장까지 없애버립니다.

복구 — 존재가 아니라 동작을 만들기
복구는 세 단계로 진행했습니다.
provider SDK를 개편된 버전으로 마이그레이션
바뀐 응답 구조(outputs에서 steps로 이동)에 맞춰 파싱 코드 수정
도구 호출 왕복까지 포함한 e2e 테스트로 실제 동작 확인
마지막 단계가 핵심이었습니다. 단순히 응답이 오는지만 보면 파싱이 반쯤 깨진 상태도 통과할 수 있습니다. AI 채팅은 도구 호출을 끼고 도는 구조라, 모델이 도구를 부르고 결과를 받아 다시 답하는 왕복 전체가 폴백 경로에서 끝까지 돌아야 살아있다고 말할 수 있었습니다. 그 왕복이 통과된 뒤에야 이중화가 다시 이중화가 됐습니다.
폴백 경로에 정기적으로 불을 넣기로 했습니다
이번 일로 운영 원칙 하나가 바뀌었습니다. 이중화는 존재가 아니라 동작으로 검증한다는 것입니다. 폴백은 평소에 안 쓰이므로 살아있음을 증명할 기회가 없고, 외부 provider API는 예고 없이 바뀝니다. 그래서 주기적으로 폴백 경로에 합성 요청을 실제로 태워보고, 응답 파싱까지 통과하는지 확인하는 쪽으로 방향을 잡았습니다.
주기적 합성 헬스체크 — 폴백 경로로 실제 요청을 보내고 파싱 결과까지 검증
계약 테스트는 장애 비용이 큰 경로에만 선택 적용 — 전부 붙이면 유지비가 커짐
매번 태우면 비용·레이트리밋 부담이 늘어 주기와 범위로 균형
폴백이 살아있어도 주력과 품질 차가 크면 장애 시 품질 급락이라는 별개 문제가 남음
폴백을 처음부터 실제로 도는 상태로 설계했던 경험도 있습니다. SSE 스트림이 끊겨도 진행률 표시가 살아남도록 폴링 폴백을 깔아둔 경우였는데, 그때는 폴백 경로가 운영 중에도 자연스럽게 돌게 만들어서 죽으면 바로 드러나는 구조였습니다. 이번 사건과 대비되는 살아있는 폴백의 예입니다.

덤으로 나온 발견 두 가지
폴백을 살리는 과정에서 무관한 발견이 두 건 있었습니다. 하나는 어떤 기능이 무관한 커밋에 섞여 들어가면서 사고로 삭제돼 있었다는 것입니다. 여러 변경을 한 커밋에 묶으면 리뷰어 눈에 개별 변경이 안 보이고, 기능 하나가 소리 없이 사라져도 아무도 모릅니다. 이번에 발견해 복원했지만, 커밋 위생이 기능 생존과 직결된다는 걸 실감한 장면이었습니다.
다른 하나는 자가호스팅 엔드포인트 하나가 404를 반환하고 있었다는 것입니다. 처음엔 인증 토큰 문제인 줄 알았는데, 확인해보니 프록시 엔드포인트 자체가 없는 상태였습니다. 이건 코드로 고칠 수 있는 문제가 아니라서, 인프라 콘솔에서 사람이 확인할 몫으로 분리했습니다. 코드 문제와 인프라 문제를 초반에 갈라내는 것만으로도 헛수고가 줄었습니다.
폴백·DR 경로를 마지막으로 실제 호출해본 시점이 언제인지 확인한다
폴백 검증 기준을 '응답이 온다'가 아니라 '파싱과 도구 왕복까지 통과한다'로 잡는다
외부 provider의 API 개편·deprecation 공지를 받아볼 채널을 확보한다
폴백 경로에 주기적 합성 헬스체크를 걸되 비용·레이트리밋과 균형을 잡는다
무관한 변경을 한 커밋에 묶지 않는다 — 기능이 리뷰에서 소리 없이 사라진다
폴백은 평소에 쓰이지 않는 코드입니다. 쓰이지 않는 코드는 검증되지 않고, 검증되지 않은 코드는 정작 필요한 순간에 배신합니다. 이중화를 점검할 때 다이어그램의 줄 개수가 아니라, 오늘 당장 실제로 트래픽을 받아낼 수 있는 줄의 개수를 세보시길 권합니다. 우리는 두 줄인 줄 알았다가 한 줄이었다는 걸 뒤늦게 알았고, 지금은 정기적으로 두 줄 모두에 불을 넣어 확인하고 있습니다.