✅ 개요 (Overview)
웹 애플리케이션 개발 시 클라이언트-서버 구조가 일반적이지만, 서버 간 통신이 필요한 경우도 자주 발생한다.
서버에서 다른 API로 요청을 보내야 하는 경우, Spring Boot에서는 다양한 HTTP 클라이언트 옵션을 제공한다.
타팀의 프로젝트에서 해당 문제가 발생했다고 하여 정말 처음보는 현상이라 궁금해서 나도 찾아보며 같이 해결해보았다.
Spring 공식 문서에서 RestTemplate이 향후 deprecated 될 것이라는 안내를 보고, 새롭게 도입된 RestClient를 사용해보기로 했다고 한다.
그런데 RestClient로 FastAPI 서버와 통신하는 과정에서 해당 문제가 발생했다...
[참고 : uvicorn==0.29.0 ; python_version == "3.11"]
🔍 문제 현상 (Issue)
RestClient를 사용하여 FastAPI 서버와 통신을 시도했다.
FastAPI 서버에 통신을 시도했을 때, 422 Unprocessable Entity 오류가 발생했다.
가장 이상했던 점은 코드를 전혀 변경하지 않고 ngrok을 통해 동일한 FastAPI 서버에 요청을 보냈을 때는 정상적으로 작동했다는 것.
이러한 현상에 대해 FastAPI 서버의 로그를 확인해보니 다음과 같은 메시지가 표시되었다.

에러를 분석해보니, 4가지 Line이 눈에 띄었다.
- Unsupported upgrade request - websocket / h2c 등과 같은 http 업그레이드 실패
- No request body - 요청 본문이 비어있다는 메시지
- 'connection': 'Upgrade, HTTP2-Settings' 및 'upgrade': 'h2c' - HTTP/2 관련 헤더
- Invalid HTTP request received - 서버가 요청을 제대로 인식하지 못함
원인 분석 (Root Cause) 및 해결 과정 (Fix Process) 1
442 ERROR??
가장 먼저 눈에 뜬 에러는 "422 Unprocessable Entity"
찾아보니 FastAPI에서 422 에러는 자주 발생하는 에러였다.
아래링크 예시와 같이, 아주 조금만 형식이 Miss Match 되거나 여타 사소한 이유로도 많이 발생하는 에러이다.
https://do-hyeon.tistory.com/entry/Fast-API-Endpoint-Validation-422-Error-Unprocessable-Entity
FastAPI에서 422 오류는 주로 요청 본문(body)이 누락되었거나, 예상 데이터 구조와 일치하지 않을 때 발생한다고 한다.
https://www.youtube.com/watch?v=9qvAaNoQ6gc&t=2s
https://www.squash.io/quickly-resolving-422-unprocessable-entity-in-fastapi/
처음에는 단순히 헤더나 Body의 형식이 예상 형식과 달라서 발생한 문제인 줄 알았다.
처음 의심했던 원인 : 직렬화?
나 또한 백엔드 개발 중 "Jackson ObjectMapper " 이 녀석 때문에 애를 많이 먹었어서 직렬화 문제라고 생각했다.
처음에는 RestClientConfig.java 의 converters를 여러 가지로 변경해보았다...
- ex) RestClient가 JSON 필드를 camelCase로 직렬화하는 반면, FastAPI의 Pydantic 모델은 snake_case를 기대하는 경우
그러나 문제가 해결되지 않았고, 검색을 통해 살펴보니 많은 블로그에서 Content-Type 헤더 문제 문제일 수도 있다는 글이 많았다.
- ex) RestClient가 Content-Type: application/json;charset=UTF-8과 같은 형식을 사용하는 반면, FastAPI가 정확히 application/json만 기대하는 경우
RestClient client = RestClient.builder()
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
하지만 이어서 문제가 발생한 팀이 전체 에러 로그를 보내줬고, ngrok을 통해 동일 요청을 보내면 정상적으로 작동한다는 말도 듣게 되었다.
사라진 Body
에러 로그에서 "[Low-level log] No request body" 에러가 명확히 나타나므로,
FastAPI가 POST 요청에서 body를 받지 못해 422 오류가 발생한 것은 확실해졌다.
Invalid HTTP request received
이어서 문제 원인으로 의심되는 로그도 있었다.
'connection': 'Upgrade, HTTP2-Settings' 및 'upgrade': 'h2c'
실제 원인 추측 1: HTTP/2 프로토콜 업그레이드 문제
로그를 자세히 분석한 결과, 확실한 것은 HTTP 프로토콜 버전 불일치가 원인이라는 것이다.
HttpClient 기본 동작
참고 : 이 팀에서는 TLS가 아닌 HTTP 프로토콜을 사용하고 있다.
핵심은 Java HttpClient는 h2c 업그레이드를 기본으로 시도한다...
먼저, RestClient는 HttpClient를 기본적으로 사용한다.
Java 21의 HttpClient 기본 동작은 다음과 같다.
- Java의 표준 HttpClient는 HTTPS(보안 연결)에서는 ALPN을 통해 HTTP/2(h2)를 우선적으로 시도하고,
서버가 지원하지 않으면 HTTP/1.1로 폴백[다운그레이드]한다. - 즉, 비보안 연결(HTTP)에서는 'h2c'(HTTP/2 over cleartext) 프로토콜 업그레이드를 시도한다.
// 기본 설정은 HTTP/2 선호
HttpClient client = HttpClient.newHttpClient();
// 명시적으로 HTTP/2만 사용
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.build();
// 명시적으로 HTTP/1.1만 사용
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.build();
조금 더 나아가서 HTTP/2의 연결 방식을 디테일 하게 알아보자.
관심 없으면 h2c 작동 방식은 넘어가도 된다.
HTTP/2 중에서도 아까 말했듯 TLS 기반의 HTTPS 통신 (h2)와 평문 기반의 HTTP 통신 (h2c)가 있다.
[참고] HTTP 1.1 / HTTP 2 / h2 / h2c
1) HTTP 1.1
- 가장 널리 쓰이는 HTTP 버전
- 텍스트 기반 프로토콜 (헤더 등 사람이 읽을 수 있음)
- 단일 커넥션에서 요청/응답 순차 처리 (HOL blocking)
→ 여러 요청을 동시에 처리하기 어려움 (동시성 낮음) - 커넥션 재사용 (Keep-Alive) 도입됨
2) HTTP/2 ( = h2)
- 이진(binary) 기반 프로토콜
→ 텍스트 대신 프레임 단위로 통신 - 멀티플렉싱(Multiplexing) 지원
→ 하나의 TCP 커넥션으로 여러 요청 동시 처리 가능 - 헤더 압축 (HPACK)
→ 중복된 헤더 제거로 성능 개선 - 서버 푸시 지원 가능
- 일반적으로 TLS(HTTPS) 위에서 동작함
h2 = TLS 기반의 HTTP/2
즉, https:// 접속 시 h2가 사용됨
3) h2c (HTTP/2 Cleartext 평문)
- h2c = 비암호화(HTTP) 기반 HTTP/2
- Upgrade 헤더를 통해 클라이언트가 HTTP/1.1 → HTTP/2로 업그레이드 요청 가능
POST /something HTTP/1.1
Host: example.com
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: (base64 settings)
- 서버가 이를 받아들이면 101 Switching Protocols 로 업그레이드
그러나 대부분의 브라우저는 h2c를 지원하지 않음!!
→ 주로 gRPC, 내부 통신, IoT 등에서 사용됨
[참고] h2c(HTTP/2 Cleartext) 작동 방식
h2c는 암호화되지 않은 일반 HTTP 연결에서 HTTP/2 프로토콜을 사용하기 위한 메커니즘이다.
RFC 7540에 적힌 '프로토콜 업그레이드' 과정을 살펴보자.
연결 과정
참고 ㅣ https://datatracker.ietf.org/doc/html/rfc7230#section-6.7
https://datatracker.ietf.org/doc/html/rfc7540#section-3.2
- 초기 HTTP/1.1 요청: 클라이언트는 먼저 HTTP/1.1 형식의 요청을 보내지만, 특별한 헤더를 추가하여 HTTP/2로의 업그레이드를 요한다.
여기서 중요한 것은GET / HTTP/1.1 Host: server.example.com Connection: Upgrade, HTTP2-Settings Upgrade: h2c HTTP2-Settings: <base64url encoding of HTTP/2 SETTINGS payload>- Connection: Upgrade, HTTP2-Settings: 연결 업그레이드 요청과 HTTP/2 설정 포함
- Upgrade: h2c: HTTP/2 cleartext로 업그레이드 요청
- HTTP2-Settings: HTTP/2 프레임 설정 정보(base64url 인코딩)
- 서버 응답 시나리오:
- 서버가 HTTP/2를 지원하는 경우:
101 응답 이후, 연결은 HTTP/2 프레임 형식으로 전환되며, 클라이언트는 다시 GET 요청을 HTTP/2 형식으로 보낼 필요 없이 계속 통신한다.
HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: h2c [이후 HTTP/2 프레임 시작] - 서버가 HTTP/2를 지원하지 않는 경우:
서버는 Upgrade 헤더를 무시하고 일반 HTTP/1.1 응답을 반환함. 클라이언트는 이를 감지하고 HTTP/1.1 통신을 계속해야 한다.
HTTP/1.1 200 OK Content-Type: text/html ... [HTTP/1.1 응답 내용]
- 서버가 HTTP/2를 지원하는 경우:

실패 시나리오 예상
그리고 공식 문서에도 다음과 같은 실패 예상 시나리오가 작성되어 있다.
[나중에 여기서 해답이 나온다...]
h2c 업그레이드 과정에서 다음과 같은 문제가 발생할 수 있다.
1. 부분적인 구현: 일부 서버는 업그레이드 요청을 인식하지만 적절히 처리하지 못할 수 있습니다.
2. 본문 손실: 업그레이드 요청에 본문(body)이 포함된 경우, 서버가 HTTP/2로 전환하는 과정에서 이 본문이 손실될 수 있습니다.
3. chunked 인코딩 문제: HTTP/2는 HTTP/1.1의 chunked 인코딩을 사용하지 않고 자체 프레임 구조를 사용하므로, 전환 과정에서 충돌이 발생할 수 있습니다.
4. 헤더 처리 불일치: HTTP/2는 헤더 압축 및 처리 방식이 HTTP/1.1과 다르므로, 전환 과정에서 헤더 정보가 잘못 해석될 수 있습니다.
h2c 지원 서버 문제?
FastAPI 자체는 HTTP/2(h2)나 h2c(h2 cleartext) 프로토콜 협상 기능을 직접 제공하지 않는다.
FastAPI는 ASGI(Asynchronous Server Gateway Interface) 표준을 따르는 웹 프레임워크이며,
실제 네트워크 계층의 프로토콜 협상(HTTP/1.1, HTTP/2, h2c 등)은 FastAPI가 아닌 ASGI 서버(Hypercorn 등)이 담당한다.
하지만, 현재 우리가 사용하는 ASGI 서버인 Uvicorn 또한 공식적으로 h2c를 지원하지 않는다...
https://github.com/fastapi/fastapi/issues/80
https://github.com/fastapi/fastapi/issues/2907
그래서 자꾸 아래와 같은 에러를 마주한 것이다.
WARNING: Unsupported upgrade request.
=> h2c 업그레이드 요청은 무시
클라이언트가 Upgrade: h2c 헤더와 함께 POST(데이터가 있는 body) 요청을 보내도, Uvicorn은 Upgrade 헤더를 무시하고 오류를 낸다.
h2c 권장 X
h2c는 암호화 없이 HTTP/2의 성능 이점을 활용할 수 있게 해주지만, 모든 서버가 이를 지원하는 것은 아니다.
특히 서로 다른 기술 스택 간 통신 시 프로토콜 호환성 이슈가 발생할 수 있으므로, 클라이언트 측에서 명시적인 프로토콜 버전 설정이 중요하다.
대부분의 프로덕션 환경에서는 보안상의 이유로 h2(HTTPS 기반 HTTP/2)를 사용하는 것이 권장되며, h2c는 주로 개발 환경이나 내부 네트워크 통신에 제한적으로 사용된다.
... 이렇게 끝???
원인 분석 (Root Cause) 및 해결 과정 (Fix Process) 2
글을 쓰다보니 갑자기 추가적인 궁금증이 생겼다.
ChatGPT는 400 Bad Request를 띄워주고, 아예 연결이 끊긴다고 답변했다.
0.19.0 Uvicorn의 버전은 HTTP/2 업그레이드 요청을 받으면 "Unsupported upgrade request" 경고와 함께 400 Bad Request를 반환한다.
OK! 그럼 400 실패는 실패고, 왜 Body는 왜 비어있는거야...?
그런데 왜 Body는 비어 있는건데?
그리고 Uvicorn Github Issue를 좀 더 찾아보았다.
#1501
Hi!
According to the HTTP 1.1 Specification a server may refuse an upgrade request by ignoring it and responding with a normal HTTP response instead (see RFC7230 Section 6.7). For me this behavior would be useful when running the application behind a proprietary reverse proxy that tries to upgrade to websocket (which I don't want).
Currently uvicorn responds with No supported WebSocket library detected. Please use 'pip install uvicorn[standard]', or install 'websockets' or 'wsproto' manually. and status code 400 or proceeds to upgrade to upgrade if a library is found.
My proposal is to add a configuration option "--ws-ignore-upgrade" and make the http implementations (http11, httptools) ignore the upgrade if the option is set. I'd like to contribute that change as well, if you agree with this request.
I'd also introduce a new method to both protocol implementations should_upgrade(self, event) -> bool, so the behaviour could be overwritten by someone needing more complex logic to decide whether to upgrade or not.
#1659
I know that HTTP/2 is out of scope of this project. But I am not asking to support HTTP/2.
More and more http clients try to upgrade the connection to HTTP/2. Uvicorn is free to not honor this request. Unfortunately, instead of ignoring the upgrade request, Uvicorn responds with status 400 Bad Request and the message "Unsupported upgrade request".
According to https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism the server should just ignore the upgrade request:
If the server decides to upgrade the connection, it sends back a 101 Switching Protocols response status with an Upgrade header that specifies the protocol(s) being switched to. If it does not (or cannot) upgrade the connection, it ignores the Upgrade header and sends back a regular response (for example, a 200 OK).
We continue to encounter this issue because the HttpClient class of modern OpenJDK versions tries to upgrade the connection to HTTP/2 by default. Uvicorn should just ignore these headers and process the requests as if these headers were not present.
https://github.com/encode/uvicorn/issues/1501
https://github.com/encode/uvicorn/discussions/1659
중요 내용은 다음과 같다.
Uvicorn이 HTTP 요청의 Upgrade 헤더를 어떻게 처리하느냐에 대한 문제를 제기하고 있다.
HTTP Upgrade 요청을 "무시"하지 않고, 거부하거나 오류를 낸다.
그러나 HTTP/1.1 명세(RFC 7230 6.7절)에 따르면
- 서버가 HTTP/2.2을 지원하지 않는 경우 Upgrade 요청을 무시하고, 그냥 일반 응답(예: 200 OK)을 반환해도 된다.
RFC와 Mozilla 문서에 따라 Upgrade 헤더를 무시하는 것이 표준 동작임을 근거로 코드가 수정된 것을 알 수 있다.
https://github.com/encode/uvicorn/pull/1661

웹소켓 업그레이드 외의 모든 Upgrade 요청을 기본적으로 무시하도록 변경
웹소켓 업그레이드도 무시할 수 있도록 ws_ignore_upgrades 설정 옵션 추가

Unsupported upgrade request는 왜 계속 뜨는데?
#1661 이슈 해결 당시 Comment를 보니까..... "Warning 메시지는 그대로 두는게 나을 것 같아요!"라는 글이 남아 있었다...
아래 왜 굳이 둬야 하냐... 불필요한 로그가 많이 뜬다..라고 답변하였지만, 그리고 Commet가 끝났다.

https://github.com/encode/uvicorn/pull/2360
그리고 WebSocket Upgrade는 에러가 안뜨는데, h2c Upgrade에 대해서는 에러가 뜬다는 Issue를 발견했고, 이를 0.30.6 버전에서 수정한 듯 보인다..

문제가 생긴 팀은 uvicorn==0.29.0 ; python_version == "3.11" 을 사용중이었다.


그렇다는 것은 "h2c 업그레이드에서 오류를 뱉거나 연결을 막지 않고, 정상적으로 무시하는 과정"은 이루어진 것이다.
그럼 HTTP/1.1 연결까지는 이루어졌어야 한다는 이야기이다...
즉,
1. Uvicorn 0.19.0+는 Upgrade: h2c 헤더를 무시하고 HTTP/1.1로 요청을 처리함.
2. 이 동작은 RFC 7230 Section 6.7에 명시된 대로, 서버가 업그레이드 요청을 무시하고 일반 응답을 반환하는 것!
3. 이후 HTTP/1.1 연결이 정상적으로 이루어지고, Uvicorn의 파서(httptools)가 요청 본문을 정확히 읽어야 한다.
그런데.........
Body가 없는 진짜 이유 : Chunked Transfer Encoding
다음과 같은 글을 마주한다..
https://nilgil.com/blog/spring-http-transfer-method-changes/
Spring RestClient/RestTemplate 요청이 실패하는 이유 - 데이터 타입에 따른 전송 방식 차이
Spring Framework 6.1에서 RestClient/RestTemplate의 요청 본문 전송 방식이 변경되었습니다. 데이터 타입에 따른 전송 방식 차이와 Chunked Transfer Encoding 관련 문제 해결 방법을 알아봅니다.
nilgil.com
왜 RestClient가 청크 전송을 하는지 등은 위 문서를 참고하고, 또 다른 문제 원인은 다음과 같다.
이번에는 SpringBoot 전송 측의 형식 문제였다.
https://github.com/spring-projects/spring-framework/issues/32995
Spring 6.1+ 버전 이상에서 body가 객체(Object)일 때,
body를 직렬화(예: JSON)하는 과정에서 크기를 알 수 없으므로, Transfer-Encoding: chunked 방식으로 전송한다는 것이다.
즉, body를 여러 청크로 쪼개 스트리밍 전송하고, Content-Length 헤더는 없다!
(이는 메모리 사용을 줄일 수 있고, 대용량 데이터 처리에 유리하기 때문)
그러나, Uvicorn 공식문서 Header 처리를 보면
Content-Length 생략 시: chunked 인코딩 사용됨. 필요 시 Transfer-Encoding 헤더 자동 설정.
Transfer-Encoding 우선: Transfer-Encoding이 설정되면 Content-Length는 무시됨.
https://www.uvicorn.org/server-behavior/#http-headers
물론 이것은 "응답"에 관한 Header 처리이지만, Chunked를 처리하는 데에는 문제가 없다는 것.
실제 아래 링크 등을 들어가보면, 응답 받을 수 있다는 걸 확인할 수 있음.
....뭐가 문제일까
SpringBoot RestClient(HttpClient)와 Uvicorn(Httptools) 뜯어보기
RestClient의 HttpClient에서 h2c와 chunked를 같이 보내는 코드 동작 원리
h2c(HTTP/2 cleartext)와 chunked transfer encoding을 동시에 사용하는 경우에 대해 살펴보면, 이는 주로 Spring RestClient 또는 RestTemplate이 내부적으로 HTTP 요청을 보낼 때 발생할 수 있다.
실제 패킷 예시를 보면, 아래와 같이 요청 헤더에 Upgrade: h2c와 Transfer-encoding: chunked가 함께 포함될 수 있다.
우리의 사례도 아래와 비슷하다.
POST / HTTP/1.1
Connection: Upgrade, HTTP2-Settings
Host: localhost:8080
HTTP2-Settings: ...
Transfer-encoding: chunked
Upgrade: h2c
User-Agent: Java-http-client/21.0.5
Content-Type: application/json
RestClient(또는 RestTemplate)는 내부적으로 JdkClientHttpRequestFactory나 SimpleClientHttpRequestFactory를 사용한다.
이때, 본문(Content-Length)이 명확하지 않으면, 자동으로 Transfer-encoding: chunked 헤더를 추가하고, 데이터를 스트리밍 방식으로 전송한다.
만약 HTTP/2 업그레이드를 시도하는 경우(Upgrade: h2c), 이 헤더도 POST 요청과 함께 추가된다.
// jdk.internal.net.http.Http1Request
if (contentLength == 0) {
systemHeadersBuilder.setHeader("Content-Length", "0");
} else if (contentLength > 0) {
systemHeadersBuilder.setHeader("Content-Length", Long.toString(contentLength));
streaming = false;
} else {
streaming = true;
systemHeadersBuilder.setHeader("Transfer-encoding", "chunked");
}
하지만, 일부 서버는 chunked transfer encoding을 지원하지 않거나 h2c 업그레이드와 함께 chunked 인코딩을 지원하지 않을 수 있으며, 이 경우 요청이 실패할 수 있다.
결론 : 업그레이드 시도는 GET에 더 일반적이지만...HttpClient 요청은 RFC 7230에 따라 문제가 없다!
Uvicorn의 httptools 파서 문제
나는 이제 "Upgrade: h2c + Transfer-Encoding: chunked 조합"의 문제라고 생각했다.
이 과정에서의 Body 파싱 실패가 일어난 것이다.
먼저
SpringBoot의 RestClient 또는 RestTemplate을 사용할 때,
(내부적으로 JdkClientHttpRequestFactory나 SimpleClientHttpRequestFactory를 통해 HTTP 요청을 보냄)
본문(Content-Length)이 명확하지 않으면 자동으로 Transfer-Encoding: chunked 헤더를 추가하고 데이터를 스트리밍 방식으로 전송한다. 또한 HTTP/2 업그레이드를 시도하는 경우(Upgrade: h2c), 이 헤더도 POST 요청과 함께 추가된다!
실제 RestClient의 전송 패킷 예시는 다음과 같다.
POST / HTTP/1.1
Connection: Upgrade, HTTP2-Settings
Host: localhost:8080
HTTP2-Settings: ...
Transfer-Encoding: chunked
Upgrade: h2c
User-Agent: Java-http-client/21.0.5
Content-Type: application/json
그러나 Uvicorn은 h2c 업그레이드와 함께 chunked 인코딩을 지원하지 않는 것 같았다...
Uvicorn의 httptools 파서 코드를 뜯어 보았다.
https://github.com/encode/uvicorn/blob/master/uvicorn/protocols/http/httptools_impl.py
uvicorn/uvicorn/protocols/http/httptools_impl.py at master · encode/uvicorn
An ASGI web server, for Python. 🦄. Contribute to encode/uvicorn development by creating an account on GitHub.
github.com
Uvicorn은 httptools(자세히는 httptools 혹은 h11)를 사용하여 HTTP 요청을 파싱한다.
그러나 Upgrade: h2c와 Transfer-Encoding: chunked 헤더가 함께 포함된 요청을 처리할 때 문제가 발생하는 것을 코드상에서 확인할 수 있었다.
1. on_header() 처리
Upgrade: h2c는 self.headers에 저장되고, _get_upgrade()에서 추출된다.
2. on_headers_complete() 처리
parser.should_upgrade()가 True인 경우, Uvicorn은 self._should_upgrade()를 호출한다.
def _get_upgrade(self) -> bytes | None:
connection = []
upgrade = None
for name, value in self.headers:
if name == b"connection":
connection = [token.lower().strip() for token in value.split(b",")]
if name == b"upgrade":
upgrade = value.lower()
if b"upgrade" in connection:
return upgrade
return None
def _should_upgrade_to_ws(self) -> bool:
if self.ws_protocol_class is None:
return False
return True
def _should_upgrade(self) -> bool:
upgrade = self._get_upgrade()
return upgrade == b"websocket" and self._should_upgrade_to_ws()
self._should_upgrade()는 오직 "websocket"만 통과시키므로
이외의 Upgrade는 False를 반환한다.
이때 on_headers_complete()에서는
- self.parser.should_upgrade() → True (llhttp가 업그레이드 요청 감지)
- self._should_upgrade() → False (h2c는 websocket 아님)
결과적으로 아무 작업도 하지 않고 그냥 빠져나옴 (업그레이드 무시)
def on_headers_complete(self) -> None:
...
if self.parser.should_upgrade() and self._should_upgrade():
return # WebSocket인 경우만 처리
...
h2c를 지원하지 않으니, 업그레이드는 무시되는 것은 정상적인 흐름이지만...
3. data_received() 처리
httptools.HttpRequestParser는 내부적으로 llhttp 엔진을 사용하고, 이 엔진은 헤더 완료 후 업그레이드 플래그가 true인 경우 강제로 예외를 던져 parser.feed_data()를 중단시킨다. (내부 동작이므로... 넘어가자)
중요한 건 이 예외는 data_received()에서 catch 된다.
위에서 업그레이드 무시했더라도, llhttp는 내부적으로 upgrade = True라서 httptools.HttpParserUpgrade 예외를 발생시키기 된다.
def data_received(self, data: bytes) -> None:
try:
self.parser.feed_data(data)
except httptools.HttpParserUpgrade:
if self._should_upgrade():
self.handle_websocket_upgrade()
else:
self._unsupported_upgrade_warning()
대신 self._unsupported_upgrade_warning() 로그만 남고 요청은 정상 처리로 이어진다.
def _unsupported_upgrade_warning(self) -> None:
self.logger.warning("Unsupported upgrade request.")
if not self._should_upgrade_to_ws():
msg = "No supported WebSocket library detected. Please use \"pip install 'uvicorn[standard]'\", or install 'websockets' or 'wsproto' manually." # noqa: E501
self.logger.warning(msg)
계속 마주한...그 ... 에러... 발견...
4. 문제 발생
그럼에도 불구하고, 이후 바디 파싱 콜백인 on_body()가 호출되지 않는다.
이유는 httptools가 여전히 업그레이드 모드로 남아 chunked 바디를 파싱하지 않기 때문...
결과적으로 FastAPI에서는 body == b"" 상태로 들어오게 된다!!!!!!!!!!!!
빈 Body...
결국 on_headers_complete() 이후에 data_received()로 이어지는 건 예외 흐름을 강제로 잡기 위한 구조적 설계이며, 여기서 resume_after_upgrade() 같은 처리가 없으면 body 파싱은 이루어지지 않는다는 것.
코드 분석 (httptools_impl.py 및 parser.pyx)
httptools_impl.py
httptools_impl.py에서 parser.should_upgrade()와 self._should_upgrade()를 함께 확인하는 로직은 다음과 같다
def on_headers_complete(self) -> None:
# (중략)
if self.parser.should_upgrade() and self._should_upgrade():
return
parser.pyx
또한, parser.pyx에서는 should_upgrade 메서드가 llhttp_t 구조체의 upgrade 플래그를 확인하여 업그레이드 여부를 판단한다.
def should_upgrade(self):
cdef cparser.llhttp_t* parser = self._cparser
return bool(parser.upgrade)
이러한 구조로 인해, Upgrade: h2c와 Transfer-Encoding: chunked 헤더가 함께 포함된 요청은 업그레이드로 인식되지만,
실제로는 파싱 처리되지 않아 바디 파싱이 누락되는 문제가 발생하는 것이다.
오픈 소스 Issue 올리기
이번 문제는 단순히 HTTP/2 업그레이드 요청을 무시한다고 끝나는 것이 아니라,
업그레이드를 무시한 이후에도 파서가 여전히 '업그레이드 상태'로 남아 바디를 무시하는 구조적 문제였다.
이를 해결하기 위해서는 다음 두 곳에 Issue를 올려두었다.
1. httptools 파서 수정
업그레이드를 무시하는 경우(Upgrade: h2c 등),
파서가 계속 업그레이드 상태로 남지 않도록 명시적으로 상태를 초기화해야 한다.
# parser.pyx
cdef int cb_on_headers_complete(cparser.llhttp_t* parser) except -1:
cdef HttpParser pyparser = <HttpParser>parser.data
try:
if parser.upgrade and not pyparser._should_upgrade():
cparser.llhttp_resume_after_upgrade(parser) # 상태 초기화!
pyparser._on_headers_complete()
except BaseException as ex:
pyparser._last_error = ex
return -1
return 0
이 코드는 https://github.com/MagicStack/httptools/issues/124 이슈와 연결된다.
2. Uvicorn에서 resume 처리 보완
Uvicorn에서는 업그레이드가 무시된 경우에도 명시적으로 파서의 resume 처리를 트리거해줄 필요가 있다.
이를 위해 httptools_impl.py에 다음과 같은 보완 코드가 필요하다.
# uvicorn/protocols/http/httptools_impl.py
def on_headers_complete(self) -> None:
if self.upgrade and self.upgrade.lower() != b"websocket":
self.parser.resume_after_upgrade() # 파서 상태 초기화
super().on_headers_complete()
이로써 parser 내부 상태가 정상적으로 HTTP/1.1 처리를 재개하게 되고,
on_body() 및 on_message_complete() 콜백도 정상적으로 호출되며 FastAPI가 request body를 읽을 수 있게 된다.
이 또한 https://github.com/encode/uvicorn/discussions/2637에 이슈를 올려 두었다.
[추가] RestTemplate과 ngrok이 작동한 이유
RestTemplate의 경우 기본적으로 HTTP/1.1만 사용하므로 프로토콜 업그레이드 시도 없이 FastAPI와 호환된다.
또한 RestTemplate은 내부적으로 SimpleClientHttpRequestFactory를 사용하며, 객체를 직렬화할 때 Content-Length를 명확히 계산해 Content-Length 헤더를 붙여 보낸다.
→ Upgrade나 Transfer-Encoding: chunked 헤더가 붙지 않음
이전 링크 https://nilgil.com/blog/spring-http-transfer-method-changes/ 참고
ngrok은 프록시 역할을 수행하며, 요청을 중계하면서 여러 역할을 수행한다.
클라이언트가 Upgrade: h2c, Transfer-Encoding: chunked로 요청을 보내더라도,
ngrok은 이를 HTTP/1.1로 번역하면서 Content-Length를 계산해 재전송하거나
또는 Transfer-Encoding: chunked가 포함되어 있어도 바디가 유실되지 않도록 전체 요청을 buffer로 보내준다.
즉, ngrok이 요청을 중계하는 과정에서 httptools 파서가 혼동하지 않도록 "깨끗한" HTTP/1.1 요청으로 정제해주기 때문에 문제가 발생하지 않았다.
코드적 해결
(1) 버전 명시적 설정
RestClient에서 사용하는 HttpClient의 HTTP 버전을 명시적으로 HTTP/1.1로 설정했다.
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1) // HTTP/1.1 명시적 지정
.build();
RestClient restClient = RestClient.builder()
.httpClient(client)
.build();
- 이 설정을 통해 HTTP/2 업그레이드 시도를 방지하고, 처음부터 HTTP/1.1으로 통신하도록 하면 문제가 없어진다.
(2) RestTemplate 사용
기본적으로 HTTP/1.1 사용
(3) Ngrok 등의 중계자 사용
(4) FastAPI에서 HTTP/2 지원 추가
서버 실행 시 HTTP 버전 명시적 지정
uvicorn main:app --host 0.0.0.0 --port 8000 --http h11
pip install 'uvicorn[standard]' # 또는 pip install websockets wsproto
📚 회고 및 개선점 (Reflection)
이번 문제 해결 과정에서 얻은 교훈과 개선점을 정리하며 마무리하겠다.
1. 기술 간 "사소한 차이"가 문제의 본질이 될 수 있다
Spring의 RestClient와 기존 RestTemplate은 같은 요청을 보내는 것처럼 보이지만,
- 내부 구현 방식 (e.g. chunked 전송, 업그레이드 헤더 처리 등)
- 기본 설정값 (e.g. HTTP 버전, 직렬화 처리)
이 다르면 완전히 다른 결과를 낳을 수 있다. 이번처럼 업그레이드 협상 실패 + chunked body 유실 같은 예외적 상황은, 기술 사양을 이해하지 않으면 파악이 어려운 영역이었다.
2. 추상화 너머의 동작 원리를 아는 것이 중요하다
단순히 RestClient.post().body(obj).retrieve()와 같은 추상화된 코드만 봤다면 절대 이 문제의 원인을 찾지 못했을 것이다. Java의 HttpClient 내부 동작, RFC 7230/7540의 내용, HTTP 버전 협상 흐름까지 직접 확인해가며 원인을 추적한 과정이 이번 해결의 핵심이었다.
3. "잘 되는 경우"도 중요한 단서가 된다
ngrok을 통해 요청하면 잘 되고, RestTemplate으로 하면 잘 되는데… 라며 보통 넘어가기 마련이다. 또한 오히려 이러한 경험은 흔히 "헷갈리는 증상"으로 느끼고 짜증만 날 수도 있다. 하지만 그런 차이점이야말로 문제를 국소화하고 원인을 특정하는 중요한 힌트였다. 제대로 된 비교 실험과 로깅이 문제 해결을 크게 도와주었다.
4. 오픈소스 이슈 기여를 겁내지 말자
이번 문제를 근본적으로 해결하려면 단순히 내 코드에서 해결할 수 있는 일이 아니었다. 결국 httptools의 파서 상태 처리 문제와 uvicorn의 구현 부족이 겹친 것이 원인이라고 판단했다. GitHub에 이슈를 올리고 개선 방향을 제시하는 과정을 통해 실제 커뮤니티에 기여하게 되었다. 생각보다 오픈소스 이슈는 활발히 관리되고 있었다. 첫 이슈 기여였고, 정말 100퍼센트 나의 문제 원인 파악이 맞는지 또한 아직 의구심이 들지만, 설령 틀렸더라도 또 거기서 전문가의 의견을 듣고 나누다보면 그 또한 성장하는 길일 것이다.
마무리하며
이 문서를 통해 Spring Boot 3.2의 RestClient와 FastAPI(Uvicorn) 간 통신 문제를 해결하는 과정을 정리해보았다.
겉으로 보기엔 단순한 "요청 바디가 안 들어옴" 문제였지만, 그 이면에는 HTTP 프로토콜 버전 협상, 클라이언트 구현 방식, ASGI 서버 파서의 상태 처리 등 다양한 레이어의 이슈가 복합적으로 작용하고 있었다.
이번 트러블슈팅은 단순한 버그 해결이 아니라, 웹 통신의 기본 원리를 깊이 이해하는 계기였고,
문제 원인을 찾고 공유하는 과정 자체가 오픈소스 커뮤니티에 대한 작지만 실질적인 기여가 되었다.
이 글이 비슷한 문제로 고통받는 누군가에게 작은 도움이 되길 바랍니다. 🙏