본문으로 건너뛰기

요청 본문이 통째로 사라졌다 — h2c 업그레이드와 목(mock)이 못 잡는 층

· 약 2분
heymoa-server · heymoa-web

heymoa-serverheymoa-ai로 분석을 요청하는데, ai 쪽에서 본문이 비어서 도착했다. 헤더는 멀쩡했고 경로도 맞았고 상태 코드도 200이었다. 서버 테스트는 전부 초록이었다.

원인은 우리 코드가 아니라 두 런타임의 HTTP 버전 협상이었다.

증상

항목내용
보내는 쪽Spring RestClient · JdkClientHttpRequestFactory
받는 쪽FastAPI · uvicorn (h11)
관측된 것ai 핸들러에서 body가 빈 값. 예외 없음
서버 테스트전부 통과

원인

JDK HttpClient는 기본적으로 HTTP/2를 먼저 시도한다. 평문 구간에서는 h2c 업그레이드를 거는데, uvicorn이 쓰는 h11HTTP/1.1 전용이다.

업그레이드 협상이 어긋나는 과정에서 요청 라인과 헤더는 살아남고 본문이 유실됐다. 양쪽 다 자기 기준으로는 "정상"이라 어느 쪽도 에러를 내지 않았다.

고친 방법

ai 전용 factory를 만들어 HTTP/1.1로 고정했다. 전역 설정을 바꾸지 않은 이유는, 다른 외부 연동에서는 HTTP/2가 문제가 없었고 경계마다 상대 런타임이 다르기 때문이다. 버전 협상은 클라이언트의 전역 성질이 아니라 그 경계의 성질이다.

진짜 교훈은 테스트 쪽이었다

이 버그가 통과한 이유는 명확하다.

MockRestServiceServer소켓을 타지 않는다.

Spring의 MockRestServiceServerRestClient의 요청을 가로채서 검증한다. 요청 객체는 완벽하게 만들어졌으므로 검증은 통과한다. 그런데 버그는 그 아래층, 실제 TCP 연결과 프로토콜 협상에서 났다. 목이 대신해 준 층이 정확히 깨진 층이었다.

회귀 테스트를 MockWebServer로 옮겼다. 실제 소켓을 열고 실제 HTTP를 말하므로 같은 실수가 다시 나면 잡힌다.

테스트 도구검증하는 층이 버그를 잡나
MockRestServiceServer요청 객체 조립
MockWebServer소켓 · 프로토콜 · 와이어 포맷

목은 자기가 대신한 층을 검증하지 못한다. 당연한 말인데, 어느 층을 대신하고 있는지는 대개 의식하지 않는다.

같은 계열의 사례가 더 있었다

경계에서 난 문제는 이것만이 아니었다. 전부 "양쪽 다 자기 기준으로는 정상" 이라는 공통점이 있다.

  • 콜백이 전부 400 — server가 보내는 origin(사설 IP)과 ai의 SERVER_BASE_URL(공개 도메인)이 어긋났다. same_origin 가드는 정상 작동했고 설정이 낡았던 것이다
  • 챗봇 스트리밍이 뭉쳐서 나옴X-Accel-Buffering: nonginx가 없는 구간(ai→server)에 붙어 있었고, 정작 필요한 구간(server→브라우저)에는 없었다

세 건 모두 코드 리뷰로는 안 잡힌다. 한쪽 저장소만 보면 옳기 때문이다.

정리된 목록은 System Architecture §9에 있다.