HttpURLConnection을 버려야 하는 이유 — HttpClient(Java 11+)와 현대 네트워킹

// Java 1.1 — 레거시 HttpURLConnection (1997년)
 HttpURLConnection conn = (HttpURLConnection) new URL("https://api.example.com/users").openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
int responseCode = conn.getResponseCode();   // 워낙 구식이라 설정이 복잡
// 타임아웃 설정, 스트리밍, HTTP/2 — 모두 어렵거나 불가능
// Java 25 — HttpClient (Java 11+, JEP-321)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users"))
    .header("Accept", "application/json")
    .timeout(Duration.ofSeconds(10))
    .GET()
    .build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());   // 200
System.out.println(response.body());         // JSON 응답

HttpClient는 Java 11(JEP-321)에서 도입된 현대 HTTP 클라이언트다 — HTTP/2 지원, 비동기 API, WebSocket, 커넥션 풀링을 기본 제공한다. (HttpClient API)

HttpClient의 핵심 특징

특징 HttpURLConnection HttpClient (Java 11+)
HTTP/2 미지원 기본 지원
비동기 불가 sendAsync()CompletableFuture
WebSocket 별도 라이브러리 내장 지원
타임아웃 복잡 .timeout() 한 줄
커넥션 풀 미지원 자동 관리
API 스타일 명령형, 방대한 보일러플레이트 빌더 패턴, 선언적

동기식 요청

// Java 25 — 동기 GET
HttpClient client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)       // HTTP/2 우선 (기본값)
    .connectTimeout(Duration.ofSeconds(5))    // 연결 타임아웃
    .build();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users/1"))
    .header("Authorization", "Bearer mytoken123")
    .timeout(Duration.ofSeconds(10))          // 요청 타임아웃
    .GET()
    .build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Body: " + response.body());

POST / JSON 전송

// Java 25 — POST with JSON body
String json = """
    {"name": "Alice", "age": 30}
    """;

HttpRequest postRequest = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(json))
    .build();

HttpResponse<String> postResponse = client.send(postRequest, HttpResponse.BodyHandlers.ofString());
System.out.println("Created: " + postResponse.statusCode());   // 201

비동기식 요청 — CompletableFuture

// Java 25 — 비동기 GET
CompletableFuture<HttpResponse<String>> future = client.sendAsync(
    request, HttpResponse.BodyHandlers.ofString());

future.thenApply(HttpResponse::body)
      .thenAccept(System.out::println)
      .exceptionally(ex -> {
          System.err.println("요청 실패: " + ex.getMessage());
          return null;
      });

// 여러 요청을 병렬로
CompletableFuture<String> f1 = client.sendAsync(req1, BodyHandlers.ofString())
    .thenApply(HttpResponse::body);
CompletableFuture<String> f2 = client.sendAsync(req2, BodyHandlers.ofString())
    .thenApply(HttpResponse::body);

CompletableFuture.allOf(f1, f2).join();   // 둘 다 완료 대기

sendAsync()는 non-blocking — 호출 즉시 CompletableFuture를 반환하고, 다른 작업을 수행할 수 있다. Virtual Thread 환경에서는 동기식 send()를 써도 충분히 효율적이므로, 코드 단순성을 위해 동기식을 우선할 수 있다.

WebSocket (Java 11+)

// Java 25 — WebSocket 클라이언트
WebSocket ws = HttpClient.newHttpClient()
    .newWebSocketBuilder()
    .buildAsync(URI.create("wss://echo.example.com"),
        new WebSocket.Listener() {
            @Override
            public CompletionStage<?> onText(WebSocket webSocket,
                                              CharSequence data, boolean last) {
                System.out.println("수신: " + data);
                return null;
            }
        })
    .join();

ws.sendText("Hello, WebSocket", true);

소켓 프로그래밍 — 저수준 TCP

HttpClient가 HTTP를 풀어간다면, Socket/ServerSocketTCP 레벨의 저수준 통신을 풀어간다:

// Java 25 — TCP 서버 (간단한 에코 서버)
import java.io.*;
import java.net.*;
import java.util.concurrent.*;

try (var server = new ServerSocket(8080);
     var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    while (true) {
        Socket client = server.accept();   // 클라이언트 연결 대기
        executor.submit(() -> handleClient(client));
    }
}

// 클라이언트 처리
void handleClient(Socket socket) {
    try (socket;
         var in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
         var out = new PrintWriter(socket.getOutputStream(), true)) {
        String line;
        while ((line = in.readLine()) != null) {
            out.println("Echo: " + line);   // 에코 응답
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

Java 21+에서는 Virtual Thread와 ServerSocket을 결합하여 수만 개의 동시 TCP 연결을 처리할 수 있다 — 각 연결마다 virtual thread를 할당하고, I/O 대기 시 스레드를 반납한다.

URI와 URL

// Java 25
URI uri = URI.create("https://example.com:8443/path?query=value#fragment");
System.out.println(uri.getScheme());    // https
System.out.println(uri.getHost());      // example.com
System.out.println(uri.getPort());      // 8443
System.out.println(uri.getPath());      // /path
System.out.println(uri.getQuery());     // query=value

URI는 식별자(문자열)이고, URL은 리소스의 위치(네트워크 접근 가능)다. 모든 URL은 URI이지만, 모든 URI가 URL은 아니다 (URN 등). 현대 Java에서는 주로 URI를 사용한다.

InetAddress — IP 주소와 DNS 해석

// Java 25
import java.net.InetAddress;

// 호스트명 → IP 주소
InetAddress addr = InetAddress.getByName("example.com");
System.out.println(addr.getHostAddress());   // 93.184.216.34

// 로컬 주소
InetAddress local = InetAddress.getLocalHost();
System.out.println(local.getHostAddress());  // 192.168.x.x

// 여러 IP 확인 (라운드 로빈 DNS)
InetAddress[] all = InetAddress.getAllByName("google.com");
for (InetAddress a : all) {
    System.out.println(a);
}
// google.com/142.250.x.x
// google.com/2606:4700:x::x (IPv6)

DNS 캐싱 함정

JVM은 기본적으로 DNS 조회 결과를 영구 캐싱한다 (보안 정책). DNS가 변경되면(예: 장애 조치, 로드 밸런서 IP 변경) 오래된 IP로 계속 연결을 시도한다.

# DNS 캐싱 제어
# networkaddress.cache.ttl=60 (60초 후 만료)
# networkaddress.cache.negative.ttl=10 (실패한 조회 10초 캐싱)
java -Dnetworkaddress.cache.ttl=60 MyApp

프로덕션에서는 networkaddress.cache.ttl을 짧게(10 ~ 60초) 설정한다. 기본값은 보안 관리자가 설치된 경우 영구(∞), 설치되지 않은 경우 구현마다 다르다. DNS 장애 조치가 늦어지는 문제의 원인이 자주 된다.

URLConnection — 레거시 진화

// Java 25 — URLConnection (레거시, 가능하면 HttpClient 사용)
import java.net.URL;
import java.net.URLConnection;

URL url = new URL("https://example.com/data");
URLConnection conn = url.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(10000);
try (var in = conn.getInputStream()) {
    String body = new String(in.readAllBytes());
    System.out.println(body.substring(0, Math.min(100, body.length())));
}

URLConnectionHttpURLConnection의 부모 클래스다. HttpClient가 등장하기 전(Java 10 이하)에는 이 API가 유일한 표준 HTTP 클라이언트였다. 새 코드에서는 절대 쓰지 않는다HttpClient로 대체한다.

실습 — HttpClient로 API 호출

// Java 25 — NetworkingDemo.java
import java.net.URI;
import java.net.http.*;
import java.time.Duration;

public class NetworkingDemo {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

        // JSONPlaceholder (무료 테스트 API) 호출
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://jsonplaceholder.typicode.com/users/1"))
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(10))
            .GET()
            .build();

        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Version: " + response.version());   // HTTP_2 or HTTP_1_1
        response.headers().firstValue("content-type")
            .ifPresent(ct -> System.out.println("Content-Type: " + ct));
        System.out.println("Body (first 200 chars): "
            + response.body().substring(0, Math.min(200, response.body().length())));
    }
}
java NetworkingDemo.java
Status: 200
Version: HTTP_2
Content-Type: application/json; charset=utf-8
Body (first 200 chars): {
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  ...

확인할 것: HttpClient로 HTTP/2 API를 호출한다. 빌더 패턴으로 URI, 헤더, 타임아웃을 설정하고, BodyHandlers.ofString()으로 응답 본문을 문자열로 받는다.

요약 — 이 글의 결론

  • HttpClient(Java 11+)가 현대 Java HTTP 클라이언트의 표준이다. HttpURLConnection은 레거시 — 새 코드에서 쓰지 않는다. HTTP/2, 비동기, WebSocket, 타임아웃을 빌더 패턴으로 간결하게 설정한다.
  • 동기(send)와 비동기(sendAsync)를 선택할 수 있다. Virtual Thread 환경에서는 동기식이 단순하면서도 충분히 효율적이다.
  • Socket/ServerSocket은 TCP 레벨 저수준 통신. HTTP가 아닌 커스텀 프로토콜에 사용한다. Virtual Thread와 결합하면 수만 동시 연결을 처리할 수 있다.
  • WebSocket 클라이언트가 내장되어 있다. 별도 라이브러리(Java-WebSocket 등) 없이 표준 API로 WebSocket 통신이 가능하다.
  • URI로 리소스를 식별하고, HttpClient로 접근한다. URL보다 URI를 우선한다.

생각해 볼 문제

  1. HttpClient는 재사용해야 하는가? 매 요청마다 newHttpClient()를 호출하면 어떤 문제가 있는가?
  2. BodyHandlers.ofString() 외에 어떤 핸들러가 있는가? 대용량 파일 다운로드에는 어느 것을 쓸까?
  3. HTTP/2의 멀티플렉싱이 성능에 어떤 영향을 주는가? HTTP/1.1과 비교하라.
  4. ServerSocket + Virtual Thread 모델이 traditional thread pool보다 나은 이유는?
  5. InetAddress.getByName("example.com")가 반환하는 것은 무엇인가? DNS 해석은 언제 일어나는가?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 24. jvm gc  (0) 2026.07.12
Java - 23. modules  (0) 2026.07.12
Java - 21. jdbc  (0) 2026.07.12
Java - 20. virtual threads  (0) 2026.07.12
Java - 19. concurrent  (0) 2026.07.12