요청 본문이 통째로 사라졌다 — h2c 업그레이드와 목(mock)이 못 잡는 층
heymoa-server가 heymoa-ai로 분석을 요청하는데, ai 쪽에서 본문이 비어서 도착했다. 헤더는 멀쩡했고 경로도 맞았고 상태 코드도 200이었다. 서버 테스트는 전부 초록이었다.
원인은 우리 코드가 아니라 두 런타임의 HTTP 버전 협상이었다.
증상
| 항목 | 내용 |
|---|---|
| 보내는 쪽 | Spring RestClient · JdkClientHttpRequestFactory |
| 받는 쪽 | FastAPI · uvicorn (h11) |
| 관측된 것 | ai 핸들러에서 body가 빈 값. 예외 없음 |
| 서버 테스트 | 전부 통과 |
원인
JDK HttpClient는 기본적으로 HTTP/2를 먼저 시도한다. 평문 구간에서는 h2c 업그레이드를 거는데, uvicorn이 쓰는 h11은 HTTP/1.1 전용이다.
업그레이드 협상이 어긋나는 과정에서 요청 라인과 헤더는 살아남고 본문이 유실됐다. 양쪽 다 자기 기준으로는 "정상"이라 어느 쪽도 에러를 내지 않았다.
고친 방법
ai 전용 factory를 만들어 HTTP/1.1로 고정했다. 전역 설정을 바꾸지 않은 이유는, 다른 외부 연동에서는 HTTP/2가 문제가 없었고 경계마다 상대 런타임이 다르기 때문이다. 버전 협상은 클라이언트의 전역 성질이 아니라 그 경계의 성질이다.
진짜 교훈은 테스트 쪽이었다
이 버그가 통과한 이유는 명확하다.
MockRestServiceServer는 소켓을 타지 않는다.
Spring의 MockRestServiceServer는 RestClient의 요청을 가로채서 검증한다. 요청 객체는 완벽하게 만들어졌으므로 검증은 통과한다. 그런데 버그는 그 아래층, 실제 TCP 연결과 프로토콜 협상에서 났다. 목이 대신해 준 층이 정확히 깨진 층이었다.
회귀 테스트를 MockWebServer로 옮겼다. 실제 소켓을 열고 실제 HTTP를 말하므로 같은 실수가 다시 나면 잡힌다.
| 테스트 도구 | 검증하는 층 | 이 버그를 잡나 |
|---|---|---|
MockRestServiceServer | 요청 객체 조립 | ✗ |
MockWebServer | 소켓 · 프로토콜 · 와이어 포맷 | ✓ |
목은 자기가 대신한 층을 검증하지 못한다. 당연한 말인데, 어느 층을 대신하고 있는지는 대개 의식하지 않는다.
같은 계열의 사례가 더 있었다
경계에서 난 문제는 이것만이 아니었다. 전부 "양쪽 다 자기 기준으로는 정상" 이라는 공통점이 있다.
- 콜백이 전부 400 — server가 보내는 origin(사설 IP)과 ai의
SERVER_BASE_URL(공개 도메인)이 어긋났다.same_origin가드는 정상 작동했고 설정이 낡았던 것이다 - 챗봇 스트리밍이 뭉쳐서 나옴 —
X-Accel-Buffering: no가 nginx가 없는 구간(ai→server)에 붙어 있었고, 정작 필요한 구간(server→브라우저)에는 없었다
세 건 모두 코드 리뷰로는 안 잡힌다. 한쪽 저장소만 보면 옳기 때문이다.
정리된 목록은 System Architecture §9에 있다.