이번 포스팅에서는 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() 로깅을 습관화하는 편이 좋다.
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 계측을 지원하므로, 분산 추적 스팬에 프로토콜 버전 속성을 추가하면 운영 중 다운그레이드 현상을 실시간으로 포착할 수 있다.
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의 사용법과 마이그레이션 전략에 대해서 정리해 보았다.
