이번 포스팅에서는 2026년 3월 17일 정식 출시된 Java 26에 포함된 HTTP/3 Client API에 대해서 정리하고자 한다. JEP 517로 도입된 이 기능은 JDK 11 이후 표준 API로 자리잡은 java.net.http.HttpClient에 HTTP/3 지원을 추가한 것이며, TCP 대신 QUIC(UDP) 위에서 동작하는 차세대 프로토콜을 외부 라이브러리 없이 쓸 수 있게 만들어 준 변화다. 기존 HTTP/2 코드에서 .version() 한 줄만 고치면 HTTP/3로 전환되지만, 실전에서는 그 뒤에 조용히 동작하는 Alt-Svc 학습 메커니즘과 네 가지 연결 전략이 오히려 디버깅을 까다롭게 만든다. 이 글은 그 지점들을 중심으로 실제 운영에서 챙겨야 할 부분을 정리한다.
Java 26 HTTP/3을 한 줄로 정의한다면?

Java 26 HTTP/3은 JDK 내장 HttpClient에서 QUIC 기반 HTTP/3을 사용하도록 허용하는 opt-in 기능이다. HttpClient.Version.HTTP_3 값을 클라이언트나 요청에 지정하면 사용이 시작되고, 서버가 HTTP/3을 지원하지 않을 경우 자동으로 HTTP/2 또는 HTTP/1.1로 다운그레이드된다. 기본값은 여전히 HTTP/2이다.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// 가장 간단한 HTTP/3 클라이언트 구성
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://cloudflare-quic.com"))
.GET()
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Protocol: " + response.version());
System.out.println("Status: " + response.statusCode());Java위 코드에서 version(HttpClient.Version.HTTP_3)을 호출한 순간부터 클라이언트로 전송되는 모든 요청은 HTTP/3을 선호한다. 응답 객체의 version()을 찍어 보면 실제 사용된 프로토콜을 확인할 수 있고, fallback 여부를 판단하는 기본 진단 수단으로 쓰인다.
공식 문서 기준 HTTP/3은 IETF가 2022년 RFC 9114로 표준화한 프로토콜이며, TCP 대신 QUIC 전송 계층을 사용한다. 따라서 연결 설정 라운드트립이 단축되고 0-RTT 재개 같은 기능이 가능해진다. Java 26에 JEP 517로 탑재되어 이제 JDK만 있으면 추가 의존성 없이 이 이점을 받을 수 있으며, 상세 API 시그니처는 Oracle HttpClient Java SE 26 Javadoc에서 확인할 수 있다.
왜 TCP 대신 QUIC인가?

Java 26 HTTP/3이 기존 HttpClient와 크게 다른 점은 전송 계층이다. HTTP/2까지는 TCP 위에서 움직였지만, HTTP/3은 UDP 기반 QUIC 위에서 동작하며 TLS 1.3이 프로토콜에 통합되어 있다. 이 구조 덕분에 연결 수립 라운드트립이 단축되고, 네트워크 경로가 변해도 연결이 유지된다.
// HTTP/2 TCP 연결 vs HTTP/3 QUIC 연결 비교 진단
HttpClient tcpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.build();
HttpClient quicClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://quic.nginx.org/"))
.GET()
.build();
long start1 = System.nanoTime();
var r1 = tcpClient.send(request, HttpResponse.BodyHandlers.ofString());
long ms1 = (System.nanoTime() - start1) / 1_000_000;
long start2 = System.nanoTime();
var r2 = quicClient.send(request, HttpResponse.BodyHandlers.ofString());
long ms2 = (System.nanoTime() - start2) / 1_000_000;
System.out.println("HTTP/2: " + r1.version() + " - " + ms1 + "ms");
System.out.println("HTTP/3: " + r2.version() + " - " + ms2 + "ms");Java위 코드는 동일 서버를 대상으로 두 프로토콜의 RTT를 직접 비교하는 진단 템플릿이다. 실제 수치는 네트워크 상태에 따라 달라지지만, TLS 1.3 통합과 0-RTT 덕분에 모바일이나 고지연 구간에서는 HTTP/3이 유리한 결과를 내는 편이다.
한 가지 주의할 점은 QUIC이 UDP를 사용하기 때문에 일부 엔터프라이즈 네트워크에서 UDP 443 포트가 차단된다는 사실이다. 이 경우 HttpClient는 자동으로 HTTP/2로 폴백하므로 애플리케이션 코드에는 오류가 발생하지 않지만, 관측 관점에서는 “내가 HTTP/3이라고 믿고 있던 호출이 사실은 HTTP/2였다”는 흔한 오해가 생긴다. 운영 환경에서는 응답의 version() 로깅을 습관화하는 편이 좋다.
QUIC 위에서 연결은 어떻게 수립되는가?
QUIC 핸드셰이크는 TLS 1.3을 전송 프로토콜에 통합해 암호화와 연결 수립을 한 번에 처리한다. 클라이언트는 첫 INITIAL 패킷 안의 CRYPTO 프레임에 TLS ClientHello를 실어 보내고, 서버가 HANDSHAKE 패킷으로 인증서와 키를 돌려주면 곧바로 1-RTT 암호화 키로 전환된다. TCP가 3-way 핸드셰이크(1-RTT) 후 다시 TLS 핸드셰이크(1-RTT)를 하는 것과 달리, QUIC은 이 둘을 하나로 합쳐 왕복 횟수를 절반으로 줄인다. HttpClient가 내부에서 실제로 이 과정을 밟는지는 로그로 직접 확인할 수 있다.
java -Djdk.httpclient.HttpClient.log=quic Http3Debug.javaShellScriptHttp3Debug는 HTTP_3_URI_ONLY로 강제해 순수 QUIC 경로만 타게 한 예제이고, 위 명령의 실제 출력(JDK 26.0.2)에서 핸드셰이크의 뼈대만 추리면 다음과 같다.
QUIC: QuicClientConnection(1): connectionId: f5d789aca9b1cc3c...
QUIC: QuicClientConnection(1): Send INITIAL data
QUIC: QuicClientConnection(1) OUT: INITIAL(pn:0, size=1200, token[0],
frames:[Padding(740), CryptoFrame(offset=0, length=394)])
QUIC: QuicClientConnection(1) IN: INITIAL(pn:0, size=70,
frames:[AckFrame(largestAcknowledged=0, ...)])
QUIC: QuicClientConnection(1) IN: HANDSHAKE(pn:2, size=1200,
frames:[CryptoFrame(offset=0, length=1132)]) # 서버 인증서
QUIC: QuicClientConnection(1) IN: ONERTT(pn:6,
frames:[HandshakeDoneFrame, CryptoFrame(...), StreamFrame(stream=3, ...)])Plaintext로그를 순서대로 읽으면 QUIC의 설계 의도가 그대로 드러난다. 첫 줄의 connectionId는 IP·포트가 아니라 연결을 식별하는 고유 ID로, 네트워크 경로가 바뀌어도(Wi-Fi에서 LTE로 전환 등) 이 ID로 같은 연결을 이어갈 수 있게 하는 QUIC 연결 마이그레이션의 토대다. 클라이언트가 보낸 INITIAL 패킷의 CryptoFrame(length=394)가 바로 TLS ClientHello이며, 그 앞의 Padding(740)은 패킷을 강제로 1200바이트까지 채운 흔적이다. QUIC은 위조된 출발지로 대량 트래픽을 유발하는 증폭 공격을 막기 위해 첫 패킷을 최소 1200바이트로 규정(RFC 9000)하는데, 실제 구현이 이 규칙을 지키고 있음이 로그에 그대로 보인다. 이어 서버가 HANDSHAKE 패킷의 CryptoFrame(length=1132)로 인증서를 보내고, 마지막 ONERTT 패킷의 HandshakeDoneFrame으로 핸드셰이크가 끝나면서 곧장 애플리케이션 데이터(1-RTT) 공간이 열린다. TCP였다면 SYN·SYN-ACK·ACK를 주고받은 뒤에야 TLS를 시작했을 텐데, QUIC은 첫 패킷부터 암호 협상을 실어 보낸다는 점이 이 로그 한 장에 압축돼 있다.
그렇다면 이 구조가 실제로 더 빠른가? Http3Compare 예제로 동일 서버에 HTTP/2와 HTTP/3을 각각 새 연결로 보내 첫 응답까지의 시간을 재 봤다.
private static long measureHttp3(URI uri) throws Exception {
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3).build();
HttpRequest req = HttpRequest.newBuilder().uri(uri)
.setOption(HttpOption.H3_DISCOVERY, Http3DiscoveryMode.HTTP_3_URI_ONLY)
.GET().build();
long start = System.nanoTime();
client.send(req, HttpResponse.BodyHandlers.ofString());
return (System.nanoTime() - start) / 1_000_000;
}Java위 코드는 HTTP/3을 HTTP_3_URI_ONLY로 강제해 폴백 없이 순수 QUIC 경로만 측정하도록 만든 것이다. 매 측정마다 새 HttpClient를 만들어 연결 재사용 효과를 배제하고 “연결 수립부터 첫 응답까지”를 잰다.
Target : https://cloudflare-quic.com/ (3 rounds, cold connection)
round 1 | HTTP/2 = 193 ms | HTTP/3 = 118 ms
round 2 | HTTP/2 = 51 ms | HTTP/3 = 67 ms
round 3 | HTTP/2 = 61 ms | HTTP/3 = 58 msPlaintext진짜 콜드 상태인 첫 라운드에서 HTTP/3(118ms)이 HTTP/2(193ms)보다 뚜렷하게 빠른데, 이것이 1-RTT 핸드셰이크의 효과다. 반면 2·3라운드는 OS·DNS 캐시가 데워지고 경합 조건이 달라져 차이가 줄거나 역전되기도 한다. 즉 HTTP/3의 이점은 “매 요청이 항상 빠르다”가 아니라 “첫 연결과 고지연·고손실 구간에서 왕복이 줄어든다”에 가깝다. 수치는 네트워크 상태에 크게 좌우되므로 절대값보다 경향으로 읽어야 한다.
요청과 응답은 어떤 스트림을 타는가?
HTTP/3은 하나의 QUIC 연결 안에서 요청마다 독립된 양방향 스트림을 사용한다. 첫 요청은 stream 0을 쓰고, 연결 전체를 제어하는 SETTINGS 같은 정보는 클라이언트의 stream 2와 서버의 stream 3(제어 스트림)로 오간다. 헤더 압축(QPACK)에 필요한 동적 테이블 동기화는 또 다른 단방향 스트림이 담당한다. 스트림이 서로 독립적이기 때문에 한 요청의 패킷이 유실돼도 다른 요청은 막히지 않는다. HTTP/2가 TCP 단일 바이트스트림 위에 스트림을 얹어 겪던 head-of-line 블로킹이 전송 계층에서 근본적으로 사라지는 지점이다.
앞의 quic 로그를 all로 바꿔 애플리케이션 계층까지 보면, 핸드셰이크 직후 양쪽이 제어·QPACK 스트림부터 여는 것이 보인다.
IN: ONERTT(frames:[HandshakeDoneFrame, ..., StreamFrame(stream=3, ...)]) # 서버 제어
IN: ONERTT(frames:[StreamFrame(stream=7, ...)]) # 서버 QPACK encoder
IN: ONERTT(frames:[StreamFrame(stream=11, ...)]) # 서버 QPACK decoder
OUT: ONERTT(frames:[NewConnectionIDFrame(...), StreamFrame(stream=2, ...)]) # 클라 제어PlaintextHTTP/3은 스트림 ID 규칙으로 용도를 구분한다. 서버가 여는 단방향 스트림(3, 7, 11)은 각각 제어·QPACK 인코더·QPACK 디코더이고, 클라이언트도 stream 2로 자신의 제어 스트림을 연다. 그다음 실제 GET 요청이 stream 0(첫 클라이언트 양방향 스트림)에서 시작되는데, 요청 헤더 프레임의 내용은 로그에 이렇게 찍힌다.
HEADERS: H3 HEADERS FRAME (stream=0)
:authority: cloudflare-quic.com
:method: GET
:path: /
:scheme: https
User-Agent: Java-http-client/26.0.2Plaintext요청 헤더가 :method, :path, :scheme, :authority 같은 pseudo-header로 시작하는 것은 HTTP/2와 동일한 바이너리 프레이밍 모델을 따르기 때문이다. 다만 HTTP/3은 헤더 압축에 HPACK 대신 QPACK(RFC 9204)을 쓴다. HPACK은 순서가 보장되는 TCP를 전제로 동적 테이블을 갱신하지만, QUIC 스트림은 서로 순서가 독립적이라 그대로 쓰면 디코딩이 어긋난다. QPACK은 헤더 필드를 별도의 인코더·디코더 스트림으로 분리해 이 문제를 푸는데, 위 로그의 stream 7·11이 정확히 그 역할이다. 서버 응답 역시 stream 0으로 돌아오며, :status: 200과 함께 server: cloudflare, alt-svc: h3=":443" 같은 필드가 QPACK으로 디코딩돼 올라온다.
HEADERS: H3 RESPONSE HEADERS (stream=0)
:status: 200
alt-svc: h3=":443"; ma=86400
content-type: text/html
server: cloudflarePlaintext이 응답 헤더의 alt-svc가 뒤의 “Alt-Svc 헤더가 HttpClient에 남기는 흔적은?” 절에서 다룰 학습 메커니즘의 실제 입력값이다. 정리하면 HTTP/3의 한 요청은 (1) QUIC 연결 위에서 (2) 전용 양방향 스트림을 할당받아 (3) QPACK으로 압축된 HEADERS 프레임과 (4) DATA 프레임을 주고받는 4단 구조로 처리된다. 이 구조가 있기에 같은 연결로 수백 개의 요청을 병렬로 흘려도 서로 간섭하지 않는다.
HttpClient.Version.HTTP_3은 어디에 지정할까?
HttpClient.Version.HTTP_3은 클라이언트 레벨과 요청 레벨 두 곳에서 지정할 수 있다. 클라이언트 레벨은 해당 클라이언트가 보내는 모든 요청에 기본값으로 적용되고, 요청 레벨은 개별 요청에서 클라이언트 설정을 덮어쓴다. 두 설정은 공존하며 요청 레벨이 우선한다.
// 1. 클라이언트 레벨: 이 클라이언트의 모든 요청이 HTTP/3 선호
HttpClient clientLevel = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.connectTimeout(Duration.ofSeconds(10))
.build();
// 2. 요청 레벨: 기본 클라이언트이지만 이 요청만 HTTP/3
HttpClient defaultClient = HttpClient.newBuilder().build();
HttpRequest http3Request = HttpRequest.newBuilder()
.uri(URI.create("https://openjdk.org/"))
.version(HttpClient.Version.HTTP_3)
.GET()
.build();
// 3. 요청 레벨이 클라이언트 레벨을 덮어쓰는 케이스
HttpRequest http2OverrideRequest = HttpRequest.newBuilder()
.uri(URI.create("https://legacy.example.com/"))
.version(HttpClient.Version.HTTP_2)
.GET()
.build();
HttpResponse<String> resp = clientLevel.send(http2OverrideRequest,
HttpResponse.BodyHandlers.ofString());
// resp.version()은 HTTP_2Java위 코드에서 세 번째 예시가 실전에서 자주 쓰인다. 레거시 서버 하나만 예외적으로 HTTP/2를 강제하고 싶을 때 클라이언트 전체를 새로 만들 필요 없이 해당 요청에만 version()을 다시 지정하면 된다. 마이크로서비스 간 통신에서 특정 외부 API만 HTTP/3 미지원일 때 유용한 패턴이다.
주의할 점은 이 두 설정이 모두 “선호(preference)”일 뿐 강제가 아니라는 사실이다. 서버가 HTTP/3을 지원하지 않으면 자동으로 다운그레이드되며, 이 동작을 비활성화하려면 뒤에 설명할 Http3DiscoveryMode.HTTP_3_URI_ONLY 옵션을 사용해야 한다.
Java 26 HTTP/3의 4가지 연결 전략은 어떤 차이인가?

Java 26 HTTP/3은 HttpOption.H3_DISCOVERY 옵션을 통해 세 가지 discovery 모드(ANY, ALT_SVC, HTTP_3_URI_ONLY)를 제공하며, 여기에 옵션을 명시하지 않고 version(HTTP_3)만 설정하는 기본 동작까지 포함하면 실무에서 네 가지 연결 전략으로 정리된다. 전략 선택이 지연시간, 실패율, 디버깅 편의성을 결정한다.
| 전략 | 설정 | 실제 동작 모드 | 적합한 상황 |
|---|---|---|---|
| 낙관적 HTTP/3 | 요청 레벨 version(HTTP_3) | ANY (기본) | HTTP/3 지원 서버에 자주 접속할 때 |
| 병렬 시도 | 클라이언트 레벨 version(HTTP_3) | ANY (기본) | 첫 요청 지연에 민감한 경우 |
| Alt-Svc 학습 | HttpOption.Http3DiscoveryMode.ALT_SVC | ALT_SVC | HTTP/3 지원 여부 불확실한 대상 |
| HTTP/3 전용 | HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY | HTTP_3_URI_ONLY | 테스트/강제 검증 시나리오 |
ANY 모드는 “구현체가 선택한 알고리즘으로 HTTP/3과 HTTP/2 연결을 모두 시도해 먼저 성공한 쪽을 사용”하는 동작이며, 클라이언트/요청 레벨에서 .version(HTTP_3)만 지정한 경우의 기본값이다. 즉 앞의 두 전략은 같은 discovery 모드를 쓰지만 지정 위치가 다르다.
import java.net.http.HttpOption;
import java.net.http.HttpOption.Http3DiscoveryMode;
// 전략 3: Alt-Svc 학습 방식 (보수적)
HttpRequest altSvcRequest = HttpRequest.newBuilder()
.uri(URI.create("https://openjdk.org/"))
.version(HttpClient.Version.HTTP_3)
.setOption(HttpOption.H3_DISCOVERY, Http3DiscoveryMode.ALT_SVC)
.GET()
.build();
// 전략 4: HTTP/3 전용 (폴백 없음 - 테스트용)
HttpRequest strictHttp3Request = HttpRequest.newBuilder()
.uri(URI.create("https://cloudflare-quic.com/"))
.version(HttpClient.Version.HTTP_3)
.setOption(HttpOption.H3_DISCOVERY, Http3DiscoveryMode.HTTP_3_URI_ONLY)
.GET()
.build();Java위 코드에서 ALT_SVC 모드는 첫 요청을 HTTP/1.1 또는 HTTP/2로 보내고 서버 응답에 alt-svc: h3=":443" 헤더가 포함되어 있으면 이후 요청부터 HTTP/3을 사용한다. 반면 HTTP_3_URI_ONLY는 HTTP/3만 시도하고 실패하면 UnsupportedProtocolVersionException을 던지므로, CI 환경에서 “정말로 HTTP/3이 동작하는지” 강제로 검증하는 용도로 쓴다. 주의할 점은 H3_DISCOVERY 옵션은 요청이나 클라이언트의 선호 버전이 HTTP/3일 때만 의미가 있으며, HTTP/2가 선호 버전일 경우 무시된다는 사실이다.
실무에서는 외부 API 호출이 HTTP/3 전환 주체가 되는 경우가 많은데, Google, Cloudflare, Facebook 같은 주요 CDN은 이미 HTTP/3을 지원한다. 이 대상들만 추려 ALT_SVC 전략을 적용하고, 내부 마이크로서비스는 HTTP/2 유지 후 점진적으로 전환하는 단계적 도입이 안전하다.
Alt-Svc 헤더가 HttpClient에 남기는 흔적은?

Alt-Svc는 RFC 7838로 표준화된 HTTP Alternative Services 메커니즘이며, 서버가 응답 헤더로 “나는 이 주소에서 HTTP/3도 지원한다”고 광고하면 HttpClient가 이를 내부 레지스트리에 기록해 이후 요청에 반영한다. 즉 사용자가 명시적으로 HTTP/3을 요청하지 않아도, 같은 클라이언트 인스턴스로 같은 서버에 두 번째 요청을 보내는 순간부터 HTTP/3으로 전환될 수 있다.
// 첫 요청은 HTTP/2, 두 번째 요청은 자동으로 HTTP/3 가능성
HttpClient client = HttpClient.newBuilder().build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://www.google.com/"))
.setOption(HttpOption.H3_DISCOVERY, Http3DiscoveryMode.ALT_SVC)
.GET()
.build();
HttpResponse<String> first = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("First: " + first.version()); // HTTP_2 가능성 높음
HttpResponse<String> second = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Second: " + second.version()); // HTTP_3으로 전환 가능
// 서버가 보낸 alt-svc 헤더 직접 확인
first.headers().firstValue("alt-svc")
.ifPresent(v -> System.out.println("alt-svc: " + v));Java위 코드는 동일 클라이언트로 같은 URL에 두 번 요청할 때 프로토콜이 달라지는 과정을 재현한다. 실제 구글의 응답 헤더는 alt-svc: h3=":443"; ma=2592000, h3-29=":443"; ma=2592000 형태이며, ma(max-age) 값 동안 이 학습 결과가 유지된다.
이 동작은 편리하지만 디버깅 시 혼동을 준다. 같은 코드인데 실행 순서에 따라 다른 프로토콜이 찍히기 때문이다. 관측성이 중요한 프로덕션에서는 모든 HTTP 응답에 response.version()을 로깅 필드로 추가하는 것을 권장한다.
HTTP/2 코드를 HTTP/3으로 어떻게 옮길까?

HTTP/2에서 HTTP/3으로의 마이그레이션은 대부분 .version(HttpClient.Version.HTTP_3) 한 줄 추가로 충분하다. 기존 HttpRequest, HttpResponse, BodyHandler API는 전혀 바뀌지 않았기 때문에 비즈니스 코드를 고칠 필요가 없다. 단 TLS 미사용 http:// URL은 HTTP/3으로 요청할 수 없고, 네트워크 단에서 UDP 443이 허용되는지 확인해야 한다.
// Before: Java 21 HTTP/2 클라이언트
HttpClient oldClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
// After: Java 26 HTTP/3 클라이언트 (버전 한 줄 변경)
HttpClient newClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
// 기존 요청 코드는 변경 불필요
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/v1/orders"))
.header("Authorization", "Bearer " + token)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = newClient.send(request,
HttpResponse.BodyHandlers.ofString());Java위 코드에서 실제로 바뀐 것은 version 필드 하나뿐이며, 타임아웃·리다이렉트·헤더·바디 처리 로직은 그대로 재사용된다. 비즈니스 로직을 건드리지 않아도 된다는 점이 마이그레이션을 빠르게 만든다.
다만 프로덕션 마이그레이션에서는 코드 외 영역을 같이 봐야 한다. 방화벽·로드밸런서의 UDP 443 오픈, 프록시 구간의 프로토콜 호환성, 관측 스택(OpenTelemetry 등)의 HTTP/3 계측 여부를 점검해야 한다. 특히 공식 문서에 따르면 Java 26 HttpClient는 프록시를 통한 HTTP/3 연결 자체를 지원하지 않는다. HttpClient.Builder.proxy()로 프록시가 구성된 클라이언트는 HTTP/3 요청을 보낼 수 없으므로, 사내 프록시 경유가 강제라면 해당 호출은 HTTP/2로 유지해야 한다. 내 경험상 이 지점에서 시간을 가장 많이 잡아먹는다.
Spring Boot RestClient와 Java 26 HTTP/3을 함께 쓰려면?

Spring Boot에서는 RestClient의 요청 팩토리로 Java의 HttpClient를 주입해 Java 26 HTTP/3을 활용할 수 있다. Spring 6.1부터 도입된 JdkClientHttpRequestFactory가 표준 HttpClient 인스턴스를 감싸므로, HTTP/3 버전이 설정된 클라이언트만 넘겨주면 된다. Reactor Netty 기반 WebClient를 쓴다면 Reactor 2024.0의 Netty 1.2 experimental HTTP/3을 별도로 고려해야 한다.
@Configuration
public class HttpClientConfig {
@Bean
public HttpClient http3HttpClient() {
return HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.connectTimeout(Duration.ofSeconds(10))
.build();
}
@Bean
public RestClient http3RestClient(HttpClient http3HttpClient,
RestClient.Builder builder) {
return builder
.requestFactory(new JdkClientHttpRequestFactory(http3HttpClient))
.baseUrl("https://api.example.com")
.build();
}
}
// 실제 호출 코드는 일반 RestClient와 동일
@Service
public class OrderApiClient {
private final RestClient restClient;
public OrderApiClient(RestClient http3RestClient) {
this.restClient = http3RestClient;
}
public Order fetchOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.retrieve()
.body(Order.class);
}
}Java위 구성의 포인트는 JdkClientHttpRequestFactory 생성자에 HTTP/3 버전이 지정된 HttpClient를 주입하는 부분이다. 이렇게 하면 RestClient가 보내는 모든 요청이 Java 26 HTTP/3 엔진을 통과한다. Reactor Netty가 아니라 순수 JDK 구현을 쓰는 방식이므로 별도의 Native 라이브러리 없이 동작한다.
주의할 점은 RestClient가 블로킹이라는 사실이다. HTTP/3의 연결 유지·스트림 다중화 이점은 살아 있지만, 애플리케이션 전체를 리액티브로 전환하려면 WebClient + Reactor Netty 조합을 검토해야 한다. 요청 팩토리별 특성은 Spring Framework 공식 RestClient 문서에 정리되어 있다.
Java 26 HTTP/3 도입 전 무엇을 점검해야 할까?

Java 26 HTTP/3 도입은 단순한 코드 변경이 아니라 네트워크·관측·보안 세 영역에 영향을 준다. 외부 API 대상이라면 CDN이 HTTP/3을 지원하는지 확인하고, 내부 서비스 간 통신이라면 방화벽 UDP 정책과 프록시 지원 여부를 먼저 점검해야 한다. 이 점검 없이 .version(HTTP_3) 한 줄만 바꾸면 운영 시점에 조용히 HTTP/2로 폴백되는 현상이 빈번하게 일어난다.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpOption;
import java.net.http.HttpOption.Http3DiscoveryMode;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// 운영 환경 HTTP/3 활성화 검증 유틸리티
public class Http3Health {
public static void verify(String targetUrl) {
HttpClient strictClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(targetUrl))
.setOption(HttpOption.H3_DISCOVERY,
Http3DiscoveryMode.HTTP_3_URI_ONLY)
.GET()
.build();
try {
HttpResponse<String> resp = strictClient.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("OK: " + targetUrl + " → " + resp.version());
} catch (IOException | InterruptedException e) {
System.err.println("FAIL: " + targetUrl + " → " + e.getMessage());
}
}
public static void main(String[] args) {
verify("https://www.google.com/");
verify("https://cloudflare-quic.com/");
verify("https://internal-api.company.com/health");
}
}Java위 유틸리티는 HTTP/3 전용 모드로 여러 타깃을 일괄 검증하는 템플릿이다. HTTP_3_URI_ONLY를 쓰면 폴백이 일어나지 않아 실제 HTTP/3 연결 가능 여부를 거짓 없이 확인할 수 있다. CI 단계에서 주요 외부 API에 대해 이 검증을 돌려 두면 배포 후 “왜 HTTP/3이 안 찍히지?” 같은 디버깅 시간을 줄여 준다.
관측 관점에서는 응답 객체의 version()을 반드시 로그·메트릭에 남겨야 한다. OpenTelemetry Java 에이전트도 HTTP/3 계측을 지원하므로, 분산 추적 스팬에 프로토콜 버전 속성을 추가하면 운영 중 다운그레이드 현상을 실시간으로 포착할 수 있다.
실행 결과로 검증한 HTTP/3 동작과 흔한 함정
앞의 사용법을 JDK 26.0.2에서 실제로 돌려 보면, 설정만 보고 짐작한 것과 다른 결과가 여럿 나온다. 여기서는 실제 실행 출력으로 세 가지 함정을 확인한다. 결론부터 말하면 .version(HTTP_3) 한 줄로는 첫 요청부터 HTTP/3이 되지 않고, ALT_SVC 옵션은 선호 버전이 HTTP/3일 때만 작동하며, 강제 모드 실패는 SSLHandshakeException으로 표면화된다. 아래 예제는 모두 재현 가능한 실측 값이다.
함정 1: version(HTTP_3)만으로는 첫 요청이 HTTP/3이 아니다
가장 단순하게 클라이언트 레벨에서 HTTP/3을 선호로 지정하고 한 번 요청해 본다.
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Protocol: " + response.version());JavaProtocol : HTTP_2
Status : 200
alt-svc : h3=":443"; ma=86400PlaintextHTTP/3을 선호로 지정했는데도 Protocol이 HTTP_2로 찍힌다. cloudflare-quic.com은 HTTP/3 지원 서버인데도 그렇다. 기본 discovery 모드(ANY)는 처음 접속하는 서버에 대해 HTTP/3과 HTTP/2를 동시에 시도(racing)해 먼저 붙는 쪽을 쓰는데, 콜드 커넥션에서는 TCP+TLS가 먼저 붙는 경우가 많기 때문이다. 대신 응답 헤더의 alt-svc: h3=":443"을 학습해 두고, 같은 클라이언트로 재요청하면 그때부터 HTTP/3으로 전환된다. 즉 첫 요청이 HTTP/2로 찍히는 것은 대체로 정상이다.
함정 2: ALT_SVC 옵션은 선호 버전이 HTTP/3일 때만 작동한다
H3_DISCOVERY에 ALT_SVC를 지정하면 알아서 올라갈 것 같지만, 선호 버전을 HTTP/3으로 두지 않으면 이 옵션은 통째로 무시된다. 잘못된 사용과 올바른 사용을 나란히 돌려 비교한다.
// (A) 흔한 실수: 선호 버전 지정 없이 ALT_SVC만 → 옵션이 무시됨
HttpClient wrong = HttpClient.newBuilder().build(); // 기본 선호 = HTTP/2
// (B) 올바른 사용: 선호 버전 HTTP/3 + ALT_SVC 학습
HttpClient right = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3) // 이 한 줄이 옵션을 살린다
.build();
// 두 클라이언트로 같은 URL에 3번씩 반복 요청하며 version() 출력Java=== (A) 선호 버전 없이 ALT_SVC만 ===
[1] HTTP_2
[2] HTTP_2
[3] HTTP_2
=== (B) 선호 버전 HTTP/3 + ALT_SVC ===
[1] HTTP_2 <- 첫 요청은 HTTP/2, 이때 alt-svc 학습
alt-svc = h3=":443"; ma=86400
[2] HTTP_3 <- 학습 후 HTTP/3 전환
[3] HTTP_3 <- 학습 후 HTTP/3 전환Plaintext(A)는 세 번 모두 HTTP_2에 머문다. ALT_SVC 옵션을 줬지만 선호 버전이 HTTP/2라 옵션 자체가 무효가 됐기 때문이다. 반면 (B)는 첫 요청에서 alt-svc를 학습한 뒤 두 번째 요청부터 HTTP_3으로 전환된다. H3_DISCOVERY는 요청이나 클라이언트의 선호 버전이 HTTP/3일 때만 의미가 있다는 규칙을 실측으로 확인할 수 있는 지점이다.
함정 3: 강제 모드 실패는 SSLHandshakeException으로 드러난다
HTTP_3_URI_ONLY는 폴백 없이 HTTP/3만 시도하므로, CI에서 “정말 HTTP/3이 되는지” 강제 검증에 쓴다. HTTP/3 지원 서버 둘과 미지원 서버 하나를 함께 검증한 출력은 다음과 같다.
OK https://cloudflare-quic.com/ -> HTTP_3 (200)
OK https://www.google.com/ -> HTTP_3 (200)
FAIL https://example.com/ -> SSLHandshakeException: QUIC connection establishment failedPlaintext지원 서버에서는 폴백 없이 HTTP_3이 그대로 찍히고, HTTP/3을 열어 두지 않은 example.com에서는 예외가 던져진다. 이때 예외 타입이 프로토콜 버전 계열이 아니라 SSLHandshakeException(메시지: QUIC connection establishment failed)인 점을 눈여겨봐야 한다. QUIC은 TLS 1.3이 전송 계층에 통합돼 있어, HTTP/3 연결 자체가 안 되면 그 실패가 TLS/QUIC 핸드셰이크 예외로 표면화되기 때문이다. CI에서 이 예외를 잡도록 짜 두면 배포 후에야 HTTP/2로 조용히 내려앉는 사고를 사전에 걸러낼 수 있다.
FAQ
Java 26 HTTP/3은 프로덕션에서 바로 써도 되는 상태인가?
JEP 517은 preview가 아닌 정식 기능으로 Java 26에 포함됐다. Oracle이 2026년 3월 17일 GA로 릴리스했기 때문에 기능 자체는 프로덕션 사용이 가능하다. 단 Java 26은 non-LTS 릴리스이므로, LTS가 필요한 조직은 다음 LTS인 Java 29(예정)를 기다리거나 Java 26을 6개월 주기로 업그레이드해야 한다.
HTTP/3 요청이 예상과 달리 HTTP/2로 찍히면 원인은 무엇인가?
흔한 원인은 방화벽 UDP 443 차단, HttpClient.Builder.proxy()로 프록시가 설정된 클라이언트(프록시는 HTTP/3 미지원), 중간 로드밸런서의 HTTP/3 미지원이다. HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY로 요청해 UnsupportedProtocolVersionException을 재현한 뒤 예외 메시지로 원인을 좁히는 것이 빠른 진단 방법이다. 서버가 alt-svc 헤더를 보내지 않는 경우도 있다.
http:// URL도 HTTP/3으로 요청할 수 있나?
요청할 수 없다. HTTP/3은 QUIC이 요구하는 TLS 1.3이 프로토콜에 내장되어 있어 https:// 전용이다. 일반 http:// URL에 HTTP/3을 지정하면 자동으로 HTTP/1.1 혹은 HTTP/2로 폴백된다.
Spring WebClient에서도 Java 26 HTTP/3을 사용할 수 있나?
WebClient는 기본적으로 Reactor Netty를 쓰기 때문에 JDK의 HttpClient와는 별도 엔진이다. Reactor Netty 1.2의 experimental HTTP/3 지원을 활성화하면 WebClient에서도 HTTP/3을 쓸 수 있지만, JDK 26 HTTP/3 구현과는 별개의 경로다. 사내 표준으로 JDK 구현을 쓰려면 RestClient + JdkClientHttpRequestFactory 조합이 관리 포인트가 적다.
HTTP/3 연결의 상태를 관측하려면 어떻게 해야 하나?
응답 객체의 version() 값을 로그·메트릭에 기록하는 것이 최소한의 관측 방법이다. 추가로 OpenTelemetry Java 에이전트를 통해 HTTP/3 전용 트레이스 속성을 기록하거나, 애플리케이션 레벨에서 Alt-Svc 헤더를 직접 파싱해 모니터링에 반영할 수 있다.
마치며

HTTP/3을 실무에 도입해 보면 체감되는 건 코드 변경량이 아니라 운영 초반의 “어, 왜 HTTP/3이 안 찍히지?” 순간이다. 로컬 테스트에서는 잘 되다가 사내 프록시를 거치는 순간 조용히 HTTP/2로 내려앉는 경험을 한 번 하고 나면, response.version() 로깅을 공용 유틸에 박아두는 습관이 생긴다. Java 26 HTTP/3의 하위 호환성은 분명 고맙지만, 그 편안함 때문에 문제 탐지가 늦어질 수 있다는 점은 도입 초기에 의식적으로 챙기는 편이 낫다. 지금까지 Java 26 HTTP/3 Client API의 사용법과 마이그레이션 전략에 대해서 정리해 보았다.
