HttpClient와 네트워킹 — 자바에서 HTTP 요청 보내기
자바로 HTTP 요청을 보내는 방법이 왜 이렇게 여러 가지인 걸까? Socket, URLConnection, Apache HttpClient, 그리고 Java 11의 HttpClient까지. 각각이 등장한 이유를 알면, "왜 지금은 HttpClient를 쓰는지"가 자연스럽게 이해된다.
Socket과 ServerSocket — 네트워킹의 가장 밑바닥
HTTP를 이야기하기 전에, 네트워크 통신의 기본 단위인 소켓부터 짚고 갈게요.
Socket이란
소켓은 네트워크 상에서 두 프로그램이 데이터를 주고받기 위한 끝점(endpoint)입니다.
자바에서는 java.net.Socket 클래스가 이 역할을 합니다. IP 주소와 포트 번호를 조합해서 상대방과 TCP 연결을 맺고, InputStream/OutputStream으로 데이터를 읽고 써요.
// 클라이언트 소켓 — 서버에 연결
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 객체를 반환해요.
// 서버 소켓 — 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 요청을 보내는 표준 방법이었습니다.
// 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가 현재 자바 표준입니다.
핵심 클래스 세 개만 기억하면 돼요.
| 클래스 | 역할 |
|---|---|
HttpClient | HTTP 클라이언트 인스턴스. 설정(프로토콜 버전, 타임아웃, 리다이렉트 등)을 담당 |
HttpRequest | 요청 정보(URL, 메서드, 헤더, 바디). 불변 객체 |
HttpResponse | 응답 정보(상태 코드, 헤더, 바디) |
동기 요청 — send()
// 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()
// 비동기 전송 — 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 요청 보내기
// 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) |
// 파일 다운로드 예시
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());
타임아웃과 리다이렉트 설정
타임아웃 설정이 두 곳으로 나뉘어 있다는 점이 중요합니다.
타임아웃은 두 곳에서 설정해요
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에서 설정합니다
타임아웃을 설정하지 않으면 무한 대기할 수 있으므로, 실무에서는 반드시 설정해야 해요.
리다이렉트 정책
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL) // 리다이렉트 자동 추적
.build();
| 정책 | 동작 |
|---|---|
NEVER | 리다이렉트를 따르지 않음 (기본값) |
ALWAYS | 모든 리다이렉트를 따름 (HTTPS → HTTP 포함) |
NORMAL | HTTPS → HTTP 리다이렉트는 무시, 나머지는 따름 |
보안 관점에서
NORMAL이 가장 안전한 선택이다.ALWAYS를 쓰면 HTTPS에서 HTTP로 다운그레이드되는 리다이렉트도 따라가므로 주의해야 한다.
블로킹 vs 논블로킹 I/O
네트워킹에서 가장 중요한 개념 중 하나가 블로킹과 논블로킹의 차이입니다.
블로킹 I/O
스레드 → 요청 전송 → [대기...대기...대기...] → 응답 수신 → 다음 작업
Socket,URLConnection,HttpClient.send()모두 블로킹 방식이에요- 요청을 보내고 응답이 올 때까지 스레드가 아무것도 못 합니다
- 스레드 하나가 요청 하나를 처리하므로 동시 요청이 많으면 스레드가 부족해져요
논블로킹 I/O
스레드 → 요청 전송 → (바로 반환) → 다른 작업 수행 → 응답 도착 시 콜백 실행
HttpClient.sendAsync(),java.nio채널 방식이에요- 스레드가 응답을 기다리지 않고 다른 일을 할 수 있습니다
- 적은 수의 스레드로 많은 동시 요청을 처리할 수 있어요
// 여러 요청을 동시에 보내는 예시
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 클라이언트를 비교할 줄 알아야 합니다.
| 항목 | HttpClient | RestTemplate | WebClient |
|---|---|---|---|
| ** 소속** | Java 표준 (java.net.http) | Spring Web (동기) | Spring WebFlux (리액티브) |
| ** 도입 시점** | Java 11 | Spring 3.0 | Spring 5.0 |
| ** 동기/비동기** | 둘 다 지원 | 동기만 | 둘 다 (리액티브 기본) |
| HTTP/2 | 기본 지원 | 미지원 | 지원 (엔진에 따라) |
| ** 의존성** | 없음 (JDK 내장) | spring-web | spring-webflux |
| ** 현재 상태** | 권장 | 유지보수 모드 (deprecated 예정) | 권장 |
선택 기준
- Spring 없이 순수 Java →
HttpClient - Spring MVC + 단순 동기 호출 →
RestTemplate(레거시) 또는RestClient(Spring 6.1+) - Spring WebFlux / 리액티브 →
WebClient - ** 고성능 비동기가 필요하지만 Spring 의존성을 원하지 않는 경우** →
HttpClient.sendAsync()
// 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 싱글톤
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() {
// 인스턴스 생성 방지
}
}
응답 상태 코드 처리
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());
}
예외 처리
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("요청이 중단됨");
}
주의할 점
타임아웃을 설정하지 않으면 무한 대기합니다
connectTimeout과 timeout을 모두 설정하지 않으면, 상대 서버가 응답하지 않을 때 스레드가 무한정 블로킹돼요. 프로덕션에서는 반드시 양쪽 모두 설정해야 합니다.
HttpClient 인스턴스는 재사용해야 해요
HttpClient.newHttpClient()를 호출할 때마다 내부에 스레드 풀과 커넥션 풀이 새로 생성됩니다. 요청마다 새 인스턴스를 만들면 ** 리소스 낭비 **예요. HttpClient는 스레드 안전하므로 싱글톤으로 재사용하는 것이 올바릅니다.
NORMAL 리다이렉트 정책을 기본으로 사용하세요
HttpClient.Redirect.ALWAYS를 쓰면 HTTPS에서 HTTP로의 다운그레이드 리다이렉트도 따라갑니다. 이 경우 암호화되지 않은 연결로 데이터가 전송될 수 있어요. NORMAL 정책 이 가장 안전한 기본값입니다.
정리
| 구분 | 설명 |
|---|---|
| Socket / ServerSocket | TCP 소켓 통신의 기본. HTTP는 이 위에서 동작한다 |
| URLConnection | Java 1.1부터 있던 레거시. 새 코드에서는 비권장 |
| HttpClient (Java 11+) | 현재 자바 표준. HTTP/2, 비동기, 빌더 패턴 지원 |
| send() vs sendAsync() | 블로킹 vs 논블로킹. 동시 요청이 많으면 sendAsync() |
| 타임아웃 | connectTimeout(HttpClient) + timeout(HttpRequest)을 반드시 설정 |
| RestTemplate vs WebClient | Spring 환경에서의 선택지. RestTemplate은 유지보수 모드 |