자바로 HTTP 요청을 보내는 방법이 왜 이렇게 여러 가지인 걸까? Socket, URLConnection, Apache HttpClient, 그리고 Java 11의 HttpClient까지. 각각이 등장한 이유를 알면, "왜 지금은 HttpClient를 쓰는지"가 자연스럽게 이해된다.

Socket과 ServerSocket — 네트워킹의 가장 밑바닥

HTTP를 이야기하기 전에, 네트워크 통신의 기본 단위인 소켓부터 짚고 갈게요.

Socket이란

소켓은 네트워크 상에서 두 프로그램이 데이터를 주고받기 위한 끝점(endpoint)입니다.

자바에서는 java.net.Socket 클래스가 이 역할을 합니다. IP 주소와 포트 번호를 조합해서 상대방과 TCP 연결을 맺고, InputStream/OutputStream으로 데이터를 읽고 써요.

JAVA
// 클라이언트 소켓 — 서버에 연결
try (Socket socket = new Socket("example.com", 80)) {
    OutputStream out = socket.getOutputStream();
    InputStream in = socket.getInputStream();

    // HTTP 요청을 직접 작성 (실무에서는 이렇게 안 한다)
    String request = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
    out.write(request.getBytes());
    out.flush();

    // 응답 읽기
    byte[] buffer = new byte[4096];
    int bytesRead = in.read(buffer);
    System.out.println(new String(buffer, 0, bytesRead));
}

ServerSocket이란

ServerSocket은 특정 포트에서 클라이언트의 연결을 기다리는 서버 측 소켓입니다.

accept() 메서드를 호출하면 클라이언트가 연결할 때까지 블로킹되고, 연결이 들어오면 Socket 객체를 반환해요.

JAVA
// 서버 소켓 — 8080 포트에서 연결 대기
try (ServerSocket serverSocket = new ServerSocket(8080)) {
    System.out.println("서버 시작, 연결 대기 중...");

    while (true) {
        // 클라이언트 연결을 수락 (블로킹)
        Socket clientSocket = serverSocket.accept();

        // 연결된 클라이언트 처리
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(clientSocket.getInputStream()))) {
            String line;
            while ((line = reader.readLine()) != null && !line.isEmpty()) {
                System.out.println(line); // 요청 헤더 출력
            }
        }
    }
}

핵심 정리

  • Socket -- 클라이언트/서버 양쪽 모두에서 사용합니다. 연결이 수립된 후 양방향 통신을 담당해요
  • ServerSocket -- 서버에서만 사용합니다. accept()로 연결 수락 후 Socket을 반환해요
  • 소켓은 TCP 기반이에요. UDP를 쓰려면 DatagramSocket을 사용해야 합니다

HTTP가 결국 TCP 소켓 위에서 돌아간다는 걸 이해하면, 네트워크 문제를 디버깅할 때 "어느 레이어에서 문제가 발생했는지"를 판단할 수 있다.

URLConnection — 레거시지만 알아야 하는 이유

Java 1.1부터 있던 java.net.URLConnection과 그 하위 클래스인 HttpURLConnection은 자바 초창기부터 HTTP 요청을 보내는 표준 방법이었습니다.

JAVA
// URLConnection으로 GET 요청
URL url = new URL("https://api.example.com/users");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setConnectTimeout(5000); // 연결 타임아웃 5초
conn.setReadTimeout(5000);    // 읽기 타임아웃 5초

int statusCode = conn.getResponseCode();
if (statusCode == 200) {
    try (BufferedReader reader = new BufferedReader(
            new InputStreamReader(conn.getInputStream()))) {
        String line;
        StringBuilder response = new StringBuilder();
        while ((line = reader.readLine()) != null) {
            response.append(line);
        }
        System.out.println(response);
    }
}
conn.disconnect();

URLConnection의 한계

URLConnection이 HttpClient로 대체된 이유는 명확합니다.

  • API 설계가 불편해요 -- 스트림을 직접 열고 닫아야 하고, 에러 응답은 getErrorStream()을 따로 써야 합니다
  • HTTP/2 미지원 — HTTP/1.1까지만 지원해요
  • ** 비동기 요청 불가** — 모든 요청이 블로킹입니다
  • ** 불변성 없음** — 요청 객체를 재사용하거나 빌더 패턴으로 구성할 수 없어요
  • ** 쿠키/인증 처리가 번거롭습니다**

레거시 코드베이스에서 URLConnection을 마주칠 수 있으니 읽을 줄은 알아야 한다. 하지만 새 코드에서는 쓸 이유가 없다.

Java 11 HttpClient — 현대적인 HTTP 클라이언트

Java 9에서 인큐베이터 모듈로 등장하고, Java 11에서 java.net.http 패키지로 정식 도입 된 HttpClient가 현재 자바 표준입니다.

핵심 클래스 세 개만 기억하면 돼요.

클래스역할
HttpClientHTTP 클라이언트 인스턴스. 설정(프로토콜 버전, 타임아웃, 리다이렉트 등)을 담당
HttpRequest요청 정보(URL, 메서드, 헤더, 바디). 불변 객체
HttpResponse응답 정보(상태 코드, 헤더, 바디)

동기 요청 — send()

JAVA
// HttpClient 생성
HttpClient client = HttpClient.newBuilder()
        .version(HttpClient.Version.HTTP_2)       // HTTP/2 우선
        .connectTimeout(Duration.ofSeconds(10))    // 연결 타임아웃
        .followRedirects(HttpClient.Redirect.NORMAL) // 리다이렉트 자동 추적
        .build();

// GET 요청 생성
HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/users/1"))
        .header("Accept", "application/json")
        .timeout(Duration.ofSeconds(30))           // 요청 타임아웃
        .GET()                                     // 기본값이 GET이라 생략 가능
        .build();

// 동기 전송 — 응답이 올 때까지 블로킹
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println("상태 코드: " + response.statusCode());
System.out.println("응답 바디: " + response.body());
System.out.println("헤더: " + response.headers().map());

비동기 요청 — sendAsync()

JAVA
// 비동기 전송 — CompletableFuture를 반환
CompletableFuture<HttpResponse<String>> future = client.sendAsync(
        request,
        HttpResponse.BodyHandlers.ofString()
);

// 논블로킹으로 후속 처리 체이닝
future.thenApply(HttpResponse::body)
      .thenAccept(body -> System.out.println("응답: " + body))
      .exceptionally(ex -> {
          System.err.println("요청 실패: " + ex.getMessage());
          return null;
      });

// 다른 작업을 하다가 필요할 때 결과 대기
// future.join();

send()는 호출 스레드가 응답을 받을 때까지 블로킹됩니다. 반면 sendAsync()는 내부 스레드 풀에서 처리되므로 호출 스레드는 즉시 반환되고, 응답이 도착하면 콜백이 실행돼요.

POST 요청 보내기

JAVA
// JSON 바디를 담은 POST 요청
String jsonBody = """
        {
            "name": "홍길동",
            "email": "hong@example.com"
        }
        """;

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

HttpResponse<String> postResponse = client.send(
        postRequest,
        HttpResponse.BodyHandlers.ofString()
);

System.out.println("생성 결과: " + postResponse.statusCode()); // 201

BodyHandlers와 BodyPublishers

요청 바디를 보내거나 응답 바디를 처리하는 방법이 여러 가지 있어요.

BodyHandlers (응답 처리)

핸들러설명
ofString()응답을 String으로 변환
ofByteArray()바이트 배열로 변환
ofFile(Path)파일로 직접 저장
ofInputStream()InputStream으로 반환 (대용량 처리)
ofLines()Stream으로 라인 단위 처리
discarding()바디를 무시 (상태 코드만 필요할 때)

BodyPublishers (요청 바디)

퍼블리셔설명
ofString(String)문자열 바디
ofByteArray(byte[])바이트 배열 바디
ofFile(Path)파일 내용을 바디로 전송
noBody()바디 없음 (GET, DELETE)
JAVA
// 파일 다운로드 예시
HttpResponse<Path> fileResponse = client.send(
        HttpRequest.newBuilder()
                .uri(URI.create("https://example.com/report.pdf"))
                .build(),
        HttpResponse.BodyHandlers.ofFile(Path.of("report.pdf"))
);
System.out.println("다운로드 완료: " + fileResponse.body());

타임아웃과 리다이렉트 설정

타임아웃 설정이 두 곳으로 나뉘어 있다는 점이 중요합니다.

타임아웃은 두 곳에서 설정해요

JAVA
HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))  // 1) 연결 타임아웃: TCP 핸드셰이크 제한
        .build();

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/slow-endpoint"))
        .timeout(Duration.ofSeconds(30))        // 2) 요청 타임아웃: 응답 대기 제한
        .build();
  • connectTimeout — TCP 연결을 맺는 데 걸리는 시간 제한이에요. HttpClient.Builder에서 설정합니다
  • timeout — 요청을 보내고 응답을 받을 때까지의 시간 제한이에요. HttpRequest.Builder에서 설정합니다

타임아웃을 설정하지 않으면 무한 대기할 수 있으므로, 실무에서는 반드시 설정해야 해요.

리다이렉트 정책

JAVA
HttpClient client = HttpClient.newBuilder()
        .followRedirects(HttpClient.Redirect.NORMAL) // 리다이렉트 자동 추적
        .build();
정책동작
NEVER리다이렉트를 따르지 않음 (기본값)
ALWAYS모든 리다이렉트를 따름 (HTTPS → HTTP 포함)
NORMALHTTPS → HTTP 리다이렉트는 무시, 나머지는 따름

보안 관점에서 NORMAL이 가장 안전한 선택이다. ALWAYS를 쓰면 HTTPS에서 HTTP로 다운그레이드되는 리다이렉트도 따라가므로 주의해야 한다.

블로킹 vs 논블로킹 I/O

네트워킹에서 가장 중요한 개념 중 하나가 블로킹과 논블로킹의 차이입니다.

블로킹 I/O

PLAINTEXT
스레드 → 요청 전송 → [대기...대기...대기...] → 응답 수신 → 다음 작업
  • Socket, URLConnection, HttpClient.send() 모두 블로킹 방식이에요
  • 요청을 보내고 응답이 올 때까지 스레드가 아무것도 못 합니다
  • 스레드 하나가 요청 하나를 처리하므로 동시 요청이 많으면 스레드가 부족해져요

논블로킹 I/O

PLAINTEXT
스레드 → 요청 전송 → (바로 반환) → 다른 작업 수행 → 응답 도착 시 콜백 실행
  • HttpClient.sendAsync(), java.nio 채널 방식이에요
  • 스레드가 응답을 기다리지 않고 다른 일을 할 수 있습니다
  • 적은 수의 스레드로 많은 동시 요청을 처리할 수 있어요
JAVA
// 여러 요청을 동시에 보내는 예시
List<URI> urls = List.of(
        URI.create("https://api.example.com/users/1"),
        URI.create("https://api.example.com/users/2"),
        URI.create("https://api.example.com/users/3")
);

// 모든 요청을 비동기로 동시 전송
List<CompletableFuture<String>> futures = urls.stream()
        .map(uri -> HttpRequest.newBuilder().uri(uri).build())
        .map(req -> client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
                .thenApply(HttpResponse::body))
        .toList();

// 모든 응답이 도착할 때까지 대기
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

// 결과 출력
futures.forEach(f -> System.out.println(f.join()));

동시에 여러 API를 호출해야 하는 상황에서 sendAsync() + CompletableFuture.allOf()를 사용하면, 가장 느린 API의 응답 시간이 전체 응답 시간이 된다. 순차 호출 대비 큰 성능 차이가 발생한다.

HttpClient vs RestTemplate vs WebClient

Spring 환경에서는 이 세 가지 HTTP 클라이언트를 비교할 줄 알아야 합니다.

항목HttpClientRestTemplateWebClient
** 소속**Java 표준 (java.net.http)Spring Web (동기)Spring WebFlux (리액티브)
** 도입 시점**Java 11Spring 3.0Spring 5.0
** 동기/비동기**둘 다 지원동기만둘 다 (리액티브 기본)
HTTP/2기본 지원미지원지원 (엔진에 따라)
** 의존성**없음 (JDK 내장)spring-webspring-webflux
** 현재 상태**권장유지보수 모드 (deprecated 예정)권장

선택 기준

  • Spring 없이 순수 JavaHttpClient
  • Spring MVC + 단순 동기 호출RestTemplate (레거시) 또는 RestClient (Spring 6.1+)
  • Spring WebFlux / 리액티브WebClient
  • ** 고성능 비동기가 필요하지만 Spring 의존성을 원하지 않는 경우** → HttpClient.sendAsync()
JAVA
// Spring RestTemplate (레거시 — 참고용)
RestTemplate restTemplate = new RestTemplate();
String result = restTemplate.getForObject(
        "https://api.example.com/users/1", String.class);

// Spring WebClient (리액티브)
WebClient webClient = WebClient.create("https://api.example.com");
Mono<String> mono = webClient.get()
        .uri("/users/1")
        .retrieve()
        .bodyToMono(String.class);

Spring 6.1부터는 동기 HTTP 클라이언트로 RestClient가 새로 나왔다. RestTemplate의 후속으로, HttpClient처럼 빌더 패턴과 플루언트 API를 지원한다.

실무에서 자주 쓰는 패턴

재사용 가능한 HttpClient 싱글톤

JAVA
public class HttpClients {

    // HttpClient는 스레드 안전하므로 싱글톤으로 재사용
    private static final HttpClient INSTANCE = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)
            .connectTimeout(Duration.ofSeconds(10))
            .followRedirects(HttpClient.Redirect.NORMAL)
            .build();

    public static HttpClient getInstance() {
        return INSTANCE;
    }

    private HttpClients() {
        // 인스턴스 생성 방지
    }
}

응답 상태 코드 처리

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

// 상태 코드별 분기 처리
switch (response.statusCode() / 100) {
    case 2 -> System.out.println("성공: " + response.body());
    case 3 -> System.out.println("리다이렉트: " + response.headers().firstValue("Location"));
    case 4 -> System.out.println("클라이언트 오류: " + response.statusCode());
    case 5 -> System.out.println("서버 오류: " + response.statusCode());
    default -> System.out.println("알 수 없는 상태: " + response.statusCode());
}

예외 처리

JAVA
try {
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (HttpTimeoutException e) {
    // 타임아웃 발생
    System.err.println("요청 시간 초과: " + e.getMessage());
} catch (HttpConnectTimeoutException e) {
    // 연결 타임아웃
    System.err.println("연결 시간 초과: " + e.getMessage());
} catch (IOException e) {
    // 네트워크 오류
    System.err.println("네트워크 오류: " + e.getMessage());
} catch (InterruptedException e) {
    // 스레드 인터럽트
    Thread.currentThread().interrupt();
    System.err.println("요청이 중단됨");
}

주의할 점

타임아웃을 설정하지 않으면 무한 대기합니다

connectTimeouttimeout을 모두 설정하지 않으면, 상대 서버가 응답하지 않을 때 스레드가 무한정 블로킹돼요. 프로덕션에서는 반드시 양쪽 모두 설정해야 합니다.

HttpClient 인스턴스는 재사용해야 해요

HttpClient.newHttpClient()를 호출할 때마다 내부에 스레드 풀과 커넥션 풀이 새로 생성됩니다. 요청마다 새 인스턴스를 만들면 ** 리소스 낭비 **예요. HttpClient는 스레드 안전하므로 싱글톤으로 재사용하는 것이 올바릅니다.

NORMAL 리다이렉트 정책을 기본으로 사용하세요

HttpClient.Redirect.ALWAYS를 쓰면 HTTPS에서 HTTP로의 다운그레이드 리다이렉트도 따라갑니다. 이 경우 암호화되지 않은 연결로 데이터가 전송될 수 있어요. NORMAL 정책 이 가장 안전한 기본값입니다.

정리

구분설명
Socket / ServerSocketTCP 소켓 통신의 기본. HTTP는 이 위에서 동작한다
URLConnectionJava 1.1부터 있던 레거시. 새 코드에서는 비권장
HttpClient (Java 11+)현재 자바 표준. HTTP/2, 비동기, 빌더 패턴 지원
send() vs sendAsync()블로킹 vs 논블로킹. 동시 요청이 많으면 sendAsync()
타임아웃connectTimeout(HttpClient) + timeout(HttpRequest)을 반드시 설정
RestTemplate vs WebClientSpring 환경에서의 선택지. RestTemplate은 유지보수 모드
댓글 로딩 중...