
주제 정의
김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션| 김영한 - 인프런 강의
현재 평점 5.0점 수강생 7,020명인 강의를 만나보세요. I/O, 네트워크, 리플렉션, 애노테이션을 기초부터 실무 레벨까지 깊이있게 학습합니다. 웹 애플리케이션 서버(WAS)를 자바로 직접 만들어봅니
www.inflearn.com
외부 서버와 통신할 때 만나는 대표 네트워크 예외들이 TCP 통신의 어느 단계에서 왜 발생하는지
그리고 백엔드 개발자가 연결 타임아웃과 소켓 타임아웃을 반드시 지정해야 하는 이유를 정리한 글이다.
인프런에서 김영한님의 자바 강의를 듣다가 네트워크 예외 파트에서 멈칫했다.
지금까지 예외 처리를 "IDE가 빨간 줄을 그으니까 try~catch로 감싸는 것" 정도로 생각했기 때문이다. 컴파일을 통과시키려고 IOException을 습관처럼 잡아왔는데, 정작 그 안에서 어떤 예외가 어떤 상황에 발생하는지는 설명하지 못했다. . .
이 글의 출발점은 강의에서 들은 한 문장이다.
외부 서버와 통신을 하는 경우 반드시 연결 타임아웃과 소켓 타임아웃을 지정하자.
원전/공식 스펙 요약
예외 계층 구조
네트워크 예외는 전부 IOException의 자식이다. IDE가 IOException을 잡으라고 안내하는 이유가 여기에 있다.
IOException
├── SocketException
│ └── ConnectException // 연결 단계에서 실패
└── InterruptedIOException
└── SocketTimeoutException // 지정한 타임아웃 초과
ConnectException
공식 문서의 정의는 다음과 같다. 공식 문서 참고
ConnectException (Java SE 17 & JDK 17)
All Implemented Interfaces: Serializable Signals that an error occurred while attempting to connect a socket to a remote address and port. Typically, the connection was refused remotely (e.g., no process is listening on the remote address/port). Since: 1.1
docs.oracle.com
Signals that an error occurred while attempting to connect a socket to a remote address and port. Typically, the connection was refused remotely (e.g., no process is listening on the remote address/port).
소켓을 원격 주소와 포트에 연결하는 도중 오류가 발생했다는 신호다.
대표적으로 원격에서 연결을 거부한 경우, 즉 해당 주소와 포트에 리슨 중인 프로세스가 없는 경우에 발생한다
SocketTimeoutException
Signals that a timeout has occurred on a socket read or accept.
소켓의 read 또는 accept에서 타임아웃이 발생했다는 신호다. 공식 문서 참고
SocketTimeoutException (Java SE 17 & JDK 17)
All Implemented Interfaces: Serializable Signals that a timeout has occurred on a socket read or accept. Since: 1.4 See Also: Field Summary Constructor Summary Constructors Method Summary Methods declared in class java.lang.Throwable addSuppressed, fillIn
docs.oracle.com
타임아웃을 지정하는 두 개의 지점
Socket API 기준으로 타임아웃은 정확히 두 곳에서 지정한다. 공식 문서 참고
Socket (Java SE 17 & JDK 17)
All Implemented Interfaces: Closeable, AutoCloseable Direct Known Subclasses: SSLSocket This class implements client sockets (also called just "sockets"). A socket is an endpoint for communication between two machines. The actual work of the socket is perf
docs.oracle.com
import java.net.InetSocketAddress;
import java.net.Socket;
public class TimeoutExample {
public static void main(String[] args) throws Exception {
Socket socket = new Socket();
socket.connect(new InetSocketAddress("example.com", 443), 3000); // 연결 타임아웃 3초
socket.setSoTimeout(5000); // 소켓(읽기) 타임아웃 5초
socket.close();
}
}
여기서 주의할 점이 있다. 공식 문서는 두 메서드 모두에 대해 이렇게 명시한다.
A timeout of zero is interpreted as an infinite timeout.
기본값이 0이다.
즉 아무것도 지정하지 않으면 읽기는 말 그대로 무한히 대기하고
연결은 OS가 포기할 때까지(리눅스 기본 설정 기준 약 2분) 대기한다.
무한 대기가 자바의 기본 동작이라는 뜻이다
실무 해석 및 비교
에러 메시지별 발생 원인
강의에서 다룬 메시지들을 실제 발생 조건 기준으로 정리하면 다음과 같다.
예외와 메시지 발생 단계 원인
| ConnectException: Connection refused | 연결 | 서버 IP까지는 도달했지만 해당 포트에 리슨 중인 프로세스가 없다. 서버가 RST 패킷으로 즉시 거부한다 |
| ConnectException: Operation timed out (macOS) / Connection timed out (리눅스) | 연결 | SYN 패킷에 아무 응답이 없어 OS가 재전송을 반복하다 포기한다. 방화벽의 패킷 DROP, 서버 전원 다운, 네트워크 단절이 원인이다 |
| SocketTimeoutException: Connect timed out | 연결 | 위와 같은 상황이지만, 개발자가 지정한 연결 타임아웃이 OS 기본값보다 먼저 만료된 경우다 |
| SocketTimeoutException: Read timed out | 읽기 | 연결은 성공했지만 지정한 시간 안에 응답 데이터가 도착하지 않았다. 서버 과부하, 느린 쿼리, 응답 누락이 원인이다 |
여기서 두 가지 구분이 중요하다.
refused는 빠르고, timed out은 느리다.
Connection refused는 서버가 RST로 즉시 거부하므로 몇 ms 만에 실패한다
timed out 계열은 응답 자체가 없어서 기다리다 실패한다.
메시지만 봐도 "서버가 살아서 거부했는지"와 "응답조차 없는지"를 구분할 수 있다
같은 네트워크 상황, 다른 예외.
ConnectException: Connection timed out과 SocketTimeoutException: Connect timed out은 네트워크 상황이 동일하다.
차이는 누가 먼저 포기했는지다
전자는 OS 커널이 자기 기본값만큼 기다리다 포기한 것이고, 후자는 개발자가 지정한 시간에 끊고 나온 것이다
연결 타임아웃을 지정한다는 것은 실패 확정 시점을 OS 기본값이 아니라 내가 정한 시간으로 앞당긴다는 의미다.
참고로 예외 메시지의 세부 표기는 OS와 JDK 버전에 따라 조금씩 다르다.
강의 환경(macOS)에서는 Operation timed out으로 찍히지만
같은 상황이 리눅스 서버에서는 Connection timed out으로 찍힌다.
직접 재현해봤다
메시지를 눈으로 확인하고 싶어서 전부 로컬에서 재현해봤다.
외부 서버 없이 루프백(127.0.0.1)만으로 재현되도록 만들었고, 아래 코드는 JDK 17 이상에서 그대로 실행 가능하다.
실험용이라 자원 정리는 생략했다.
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
/**
* 네트워크 예외 재현 실험.
* 실행: java NetworkExceptionLab.java [refused|connect-timeout|read-timeout|reset|eof|unknown-host]
*/
public class NetworkExceptionLab {
public static void main(String[] args) throws Exception {
String mode = args.length > 0 ? args[0] : "refused";
switch (mode) {
case "refused" -> refused();
case "connect-timeout" -> connectTimeout();
case "read-timeout" -> readTimeout();
case "reset" -> reset();
case "eof" -> eof();
case "unknown-host" -> unknownHost();
default -> System.out.println("unknown mode");
}
}
// 1. 아무도 리슨하지 않는 포트에 연결 -> Connection refused
static void refused() {
try {
Socket socket = new Socket("127.0.0.1", 45678);
} catch (IOException e) {
print(e);
}
}
// 2. accept 큐를 가득 채워 SYN이 무시되게 만든 뒤 연결 타임아웃 지정 -> Connect timed out
static void connectTimeout() throws IOException {
ServerSocket serverSocket = new ServerSocket(0, 1); // backlog 1, accept 하지 않음
int port = serverSocket.getLocalPort();
List<Socket> fillers = new ArrayList<>();
for (int i = 0; i < 20; i++) {
try {
Socket s = new Socket();
s.connect(new InetSocketAddress("127.0.0.1", port), 300);
fillers.add(s);
} catch (IOException e) {
break; // 큐가 가득 찼다
}
}
try {
Socket socket = new Socket();
socket.connect(new InetSocketAddress("127.0.0.1", port), 2000); // 연결 타임아웃 2초
} catch (IOException e) {
print(e);
}
}
// 3. 연결은 성공했지만 서버가 응답을 주지 않음 -> Read timed out
static void readTimeout() throws Exception {
ServerSocket serverSocket = new ServerSocket(0);
Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort());
Socket serverSide = serverSocket.accept(); // 받기만 하고 아무것도 보내지 않는다
client.setSoTimeout(2000); // 소켓(읽기) 타임아웃 2초
try {
client.getInputStream().read();
} catch (IOException e) {
print(e);
}
}
// 4. 서버가 RST로 강제 종료 -> Connection reset
static void reset() throws Exception {
ServerSocket serverSocket = new ServerSocket(0);
Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort());
Socket serverSide = serverSocket.accept();
serverSide.setSoLinger(true, 0); // close 시 FIN 대신 RST 전송
serverSide.close();
Thread.sleep(300);
try {
int data = client.getInputStream().read();
System.out.println("read = " + data);
} catch (IOException e) {
print(e);
}
}
// 5. 서버가 정상 종료(FIN) -> 예외가 아니라 -1(EOF) 반환
static void eof() throws Exception {
ServerSocket serverSocket = new ServerSocket(0);
Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort());
Socket serverSide = serverSocket.accept();
serverSide.close(); // 정상 종료
Thread.sleep(300);
InputStream in = client.getInputStream();
System.out.println("read = " + in.read());
}
// 6. DNS로 찾을 수 없는 호스트 -> UnknownHostException
static void unknownHost() {
try {
Socket socket = new Socket("api.no-such-host.invalid", 80);
} catch (IOException e) {
print(e);
}
}
static void print(Exception e) {
System.out.println(e.getClass().getName() + ": " + e.getMessage());
}
}
실행 결과다(리눅스, JDK 21 기준).
=== refused ===
java.net.ConnectException: Connection refused
=== connect-timeout ===
java.net.SocketTimeoutException: Connect timed out
=== read-timeout ===
java.net.SocketTimeoutException: Read timed out
=== reset ===
java.net.SocketException: Connection reset
=== eof ===
read = -1
=== unknown-host ===
java.net.UnknownHostException: api.no-such-host.invalid
connect-timeout 재현에는 트릭을 하나 썼다.
서버 소켓의 accept 큐(backlog)를 가득 채우면 OS가 이후의 SYN 패킷을 무시하는데
이 상태가 "요청을 보내도 응답이 없는 서버"와 같아서 연결 타임아웃이 발생한다
보너스로 재현한 두 가지도 의미가 있다.
상대가 연결을 정상 종료(FIN)하면 예외가 아니라 read()가 -1을 반환하고
강제 종료(RST)하면 SocketException: Connection reset이 발생한다
같은 "연결 끊김"이어도 정상 종료는 예외가 아니라는 점이 인상적이었다.
왜 반드시 지정해야 하는가
타임아웃 미지정이 위험한 이유는 예외가 발생해서가 아니다. 예외조차 발생하지 않고 기다리기 때문이다.
외부 API 서버가 장애로 응답을 못 주는 상황을 가정한다.
타임아웃을 지정하지 않았다면 호출 스레드는 read()에서 블로킹된 채 풀려나지 못한다.
사용자 요청이 들어올 때마다 스레드가 하나씩 같은 지점에 묶이고, 톰캣의 요청 처리 스레드 풀(기본 200개)이 잠식된다.
결국 외부 API와 아무 상관없는 기능까지 응답 불가 상태가 된다
외부 서버 하나의 장애가 우리 서비스 전체의 장애로 전파되는 것이다
타임아웃은 "느린 응답을 배려하는 시간"이 아니라, 장애를 빨리 확정하고 스레드를 회수하는 방어 장치다
Spring에서는 이렇게 적용한다
실무에서 Socket을 직접 다룰 일은 거의 없고, 대부분 RestTemplate이나 RestClient 같은 HTTP 클라이언트를 쓴다.
여기서 중요한 사실 두 가지가 있다
첫째, Spring의 HTTP 클라이언트도 기본값은 타임아웃 미지정이다.
자바의 기본 동작을 그대로 따르기 때문에 직접 지정해야 한다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestTemplate;
@Configuration
public class HttpClientConfig {
@Bean
public RestClient restClient() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3000); // 연결 타임아웃 3초
factory.setReadTimeout(5000); // 소켓(읽기) 타임아웃 5초
return RestClient.builder()
.requestFactory(factory)
.baseUrl("https://api.example.com")
.build();
}
@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3000);
factory.setReadTimeout(5000);
return new RestTemplate(factory);
}
}
RestClient는 Spring Framework 6.1(Spring Boot 3.2)부터 추가된 동기 클라이언트이고
Spring Framework 7부터 RestTemplate은 Deprecated 상태다.
신규 코드는 RestClient로 작성하는 편이 낫다. 공식 문서 참고
REST Clients :: Spring Framework
You can define an HTTP Service as a Java interface with @HttpExchange methods, and use HttpServiceProxyFactory to create a client proxy from it for remote access over HTTP via RestClient, WebClient, or RestTemplate. On the server side, an @Controller class
docs.spring.io
둘째, Spring은 네트워크 I/O 예외를 ResourceAccessException으로 감싸서 던진다.
ConnectException이나 SocketTimeoutException을 직접 catch하려고 하면 잡히지 않고, 원인 예외는 getCause()로 꺼내야 한다. 공식 문서 참고
ResourceAccessException (Spring Framework 7.0.8 API)
Construct a new ResourceAccessException with the given message.
docs.spring.io
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
@Service
public class OrderApiClient {
private static final Logger log = LoggerFactory.getLogger(OrderApiClient.class);
private final RestClient restClient;
public OrderApiClient(RestClient restClient) {
this.restClient = restClient;
}
public String getOrder(Long orderId) {
try {
return restClient.get()
.uri("/orders/{id}", orderId)
.retrieve()
.body(String.class);
} catch (ResourceAccessException e) {
Throwable cause = e.getCause();
if (cause instanceof ConnectException) {
// 연결 자체가 실패했다. 요청이 서버에 도달하지 않았으므로 재시도가 비교적 안전하다
log.warn("주문 API 연결 실패: {}", cause.getMessage());
} else if (cause instanceof SocketTimeoutException) {
// Connect timed out인지 Read timed out인지에 따라 재시도 전략이 달라진다
log.warn("주문 API 타임아웃: {}", cause.getMessage());
}
throw new OrderApiException("주문 API 호출 실패", e);
}
}
}
public class OrderApiException extends RuntimeException {
public OrderApiException(String message, Throwable cause) {
super(message, cause);
}
}
재시도 관점: 두 타임아웃은 처리 전략이 다르다
같은 타임아웃이라도 재시도 안전성이 완전히 다르다.
Connect timed out은 요청이 서버에 도달하기 전에 실패한 것이다.
서버는 아무것도 처리하지 않았으므로 재시도가 비교적 안전하다.
Read timed out은 요청이 서버에 도달했고, 서버가 처리를 이미 끝냈는데 응답만 늦는 것일 수도 있다.
결제나 주문 생성 같은 API를 그대로 재시도하면 중복 처리가 발생할 수 있다.
멱등성이 보장될 때만 재시도해야 한다.
IOException 하나로 뭉뚱그려 잡으면 이 구분이 불가능하다.
IDE가 시키는 대로 IOException만 잡던 코드가 왜 부족한지에 대한 답이 여기에 있다.
결론 및 실무 팁
- 외부 서버 호출에는 연결 타임아웃과 소켓 타임아웃을 반드시 지정한다. 자바와 Spring의 기본값은 사실상 무한 대기다.
- 타임아웃 값은 "응답을 기다려주는 시간"이 아니라 "장애를 확정하고 스레드를 회수하는 시간"을 기준으로 정한다.
- Connect timed out은 재시도가 비교적 안전하고, Read timed out은 멱등성 확인이 먼저다. 이 구분을 위해서라도 예외를 뭉뚱그려 잡지 않는다.
다음 편에서는 네트워크 예외 외에 실무에서 반드시 잡아야 하는 예외들을 DB, 동시성, 웹 계층, 트랜잭션 카테고리별로 정리한다.
'📍 Lαɴɢυαɢe > Jαvα' 카테고리의 다른 글
| 백엔드 개발자가 반드시 잡는 예외 모음 (feat. IDE가 시키는 try~catch 탈출) (0) | 2026.08.10 |
|---|---|
| 자바 Executor 프레임워크로 스레드 풀 다루기 (feat. ThreadPoolExecutor) (0) | 2026.07.24 |
| Java7의 날짜계산 : Date, Calendar, SimpleDateFormat(2) (0) | 2024.08.16 |
| Java7의 날짜계산 : Date, Calendar, SimpleDateFormat(1) (0) | 2024.08.16 |
| Java의 상수, 매직넘버란 ? (0) | 2024.04.28 |