Java 26 HTTP/3 Client API 사용법과 마이그레이션 가이드

이번 포스팅에서는 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_2
Java

위 코드에서 세 번째 예시가 실전에서 자주 쓰인다. 레거시 서버 하나만 예외적으로 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_SVCALT_SVCHTTP/3 지원 여부 불확실한 대상
HTTP/3 전용HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLYHTTP_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의 사용법과 마이그레이션 전략에 대해서 정리해 보았다.