연결 실패 원인 구분과 재시도
new Socket 한 줄이 실패하는 여러 원인을 예외 타입으로 구분하고, 원인에 따라 다시 시도할지 바로 실패할지 정하는 방법을 배웁니다.
TCP 첫 연결과 다중 클라이언트 서버에서 서버 없이 클라이언트만 실행하면 ConnectException: Connection refused로 끝나는 것을 보았습니다. 내 컴퓨터 안에서 연습할 때는 이 오류 하나만 만나기 쉽지만, 실제 서비스에서는 같은 new Socket(host, port) 한 줄이 전혀 다른 이유로 실패합니다. 호스트 이름에 오타가 있을 수도 있고, 서버가 재시작하는 중이라 잠깐 포트가 닫혀 있을 수도 있고, 중간의 방화벽이 패킷을 말없이 버리고 있을 수도 있습니다.
이 원인들을 모두 "서버가 죽었다"로 뭉뚱그리면 대응이 어긋납니다. 기다려도 고쳐지지 않는 설정 오류를 끝없이 재시도하거나, 몇 초 뒤면 풀릴 일시적인 장애를 영구 실패로 처리하게 됩니다. 이 글에서는 연결이 어느 단계에서 실패했는지를 예외 타입으로 구분하고, 그 결과에 따라 다시 시도할지, 바로 실패할지, 운영자에게 알릴지 정하는 방법을 다룹니다.
new Socket 한 줄 안에서 일어나는 일
new Socket("api.example.com", 443)은 한 줄이지만 안에서는 세 단계가 차례로 진행됩니다. 첫 단계는 호스트 이름을 IP 주소로 바꾸는 이름 해석(name resolution)입니다. 대개 DNS(Domain Name System) 서버에 물어서 답을 얻으므로 DNS 조회라고도 부릅니다.
다음으로 운영체제가 그 IP로 가는 경로를 골라 연결 요청 패킷(SYN)을 보냅니다. 상대가 수락 응답(SYN-ACK)을 돌려주고 클라이언트가 확인(ACK)을 보내면 TCP 연결이 만들어지는데, 이렇게 세 번 주고받는 과정을 3-way handshake라고 합니다. 어느 단계에서 멈췄는지에 따라 자바가 던지는 예외가 달라지며, Linux에서 흔히 보는 메시지와 함께 정리하면 다음과 같습니다. 메시지 문구는 운영체제마다 다를 수 있습니다.
| 단계 | 무슨 일이 있었나 | 예외 | Linux 메시지 예 |
|---|---|---|---|
| 이름 해석 | 이름에 해당하는 IP를 찾지 못했다 | UnknownHostException | 호스트 이름 그대로 |
| 경로 | 대상까지 갈 길이 없다는 응답이 왔다 | NoRouteToHostException | No route to host |
| 연결 수립 | 대상이 연결 요청을 거절했다 | ConnectException | Connection refused |
| 연결 수립 | 응답이 없어 직접 정한 시간이 다 됐다 | SocketTimeoutException | Connect timed out |
| 연결 수립 | 응답이 없어 운영체제가 포기했다 | ConnectException | Connection timed out |
UnknownHostException은 IP 주소를 알아내지 못했다는 뜻입니다. 이름의 철자가 틀렸거나, DNS 설정이 잘못됐거나, DNS 서버가 잠시 응답하지 못하는 경우가 여기에 해당합니다. 아직 패킷을 한 번도 보내지 않은 상태에서 실패했으므로, 상대 서버는 이런 시도가 있었다는 것조차 모릅니다.
연결 거부(connection refused)는 패킷이 대상 IP까지는 도착했지만 그 포트에서 연결을 기다리는 프로그램이 없을 때 일어납니다. 대상 운영체제가 연결을 받을 수 없다는 뜻의 RST(reset) 패킷을 돌려보내고, 자바는 이를 ConnectException으로 알려 줍니다. 대상 앞의 방화벽이 대신 RST를 보내도 똑같이 보입니다. 답이 곧바로 오기 때문에 거부는 대개 아주 빨리 끝납니다.
응답이 아예 오지 않는 경우는 조금 복잡합니다. 방화벽이 패킷을 말없이 버리거나 대상 호스트가 꺼져 있으면, 클라이언트 운영체제는 SYN을 몇 번 더 보내며 기다립니다. connect에 시간 제한을 주었다면 그 시간이 지나는 순간 자바가 포기하고 SocketTimeoutException을 던집니다. 시간 제한을 주지 않았다면 운영체제가 스스로 포기할 때까지 기다리는데, Linux 기본 설정(SYN 재전송 6회)에서는 2분 남짓 걸리고 예외도 SocketTimeoutException이 아니라 메시지가 Connection timed out인 ConnectException입니다. 같은 "응답 없음"이 시간 제한을 걸었는지에 따라 다른 예외로 나타나는 셈입니다.
NoRouteToHostException은 대상까지 가는 길이 없다는 응답을 받았을 때 납니다. 중간 라우터가 그 호스트에 도달할 수 없다고 알려 오거나, 같은 네트워크 안의 주소인데 그 주소를 가진 장비가 없을 때가 대표적입니다. 라우팅 설정이나 방화벽 정책 문제인 경우가 많아서, 애플리케이션이 다시 시도한다고 해결되기는 어렵습니다.
이 표에 없는 실패도 있습니다. 예를 들어 IPv6를 쓸 수 없게 설정된 Linux에서 IPv6 주소로 연결하면 SocketException: IPv6 protocol family unavailable처럼 일반 SocketException이 납니다. 다음 절의 분류기는 이런 예외를 억지로 끼워 맞추지 않고 '모름'으로 남겨 둡니다.
예외 타입으로 실패 원인 나누기
운영체제 메시지는 운영체제와 언어 설정마다 문구가 다르므로, 애플리케이션 코드가 메시지 문자열을 비교해 원인을 판단하면 환경이 바뀔 때 쉽게 깨집니다. 그래서 연결을 맡는 코드 한곳에서 예외 타입을 몇 가지 분류 값으로 바꿔 두고, 나머지 코드는 그 값만 보게 하는 편이 안전합니다. 원래 예외 객체는 로그에 남길 진단 정보로 따로 보관하되, 사용자에게 보여 주는 응답에는 내부 주소나 스택 트레이스를 넣지 않습니다.
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
public final class ConnectionFailureClassifier {
enum Kind { DNS, REFUSED, NO_ROUTE, TIMEOUT, UNKNOWN }
static Kind classify(Throwable error) {
if (error instanceof UnknownHostException) return Kind.DNS;
if (error instanceof SocketTimeoutException) return Kind.TIMEOUT;
if (error instanceof NoRouteToHostException) return Kind.NO_ROUTE;
if (error instanceof ConnectException) return Kind.REFUSED;
return Kind.UNKNOWN;
}
public static void main(String[] args) {
System.out.println(classify(new UnknownHostException("name")));
System.out.println(classify(new ConnectException("refused")));
System.out.println(classify(new SocketTimeoutException("connect")));
}
}classify는 위에서부터 차례로 타입을 검사하고, 어느 것에도 해당하지 않으면 UNKNOWN을 돌려줍니다. 모르는 예외를 그럴듯한 분류에 끼워 넣으면 엉뚱한 대응으로 이어지므로, 모르는 것은 모른다고 남겨 두는 편이 낫습니다. 실행하면 main이 직접 만든 예외 객체 세 개가 차례로 분류됩니다.
DNS
REFUSED
TIMEOUT이 main은 예외 객체를 new로 만들어 넣었을 뿐, 실제로 DNS 장애나 연결 거부를 일으킨 것은 아닙니다. 실제 거부는 다음 절에서 재현합니다.
검사 순서도 짚어 둘 만합니다. ConnectException과 NoRouteToHostException은 둘 다 SocketException의 자식이고, SocketTimeoutException은 SocketException이 아니라 InterruptedIOException의 자식이며, UnknownHostException은 IOException을 바로 상속합니다. 넷 사이에 부모와 자식 관계가 없으므로 지금의 네 줄은 순서를 바꿔도 결과가 같습니다. 하지만 나중에 SocketException 검사를 추가한다면 반드시 두 자식보다 뒤에 두어야 합니다. 앞에 두면 연결 거부와 경로 없음이 모두 그 줄에 먼저 걸립니다.
타입만으로 확정할 수 없는 경우
앞 절에서 본 것처럼 운영체제가 포기한 연결 시간 초과는 ConnectException으로 나타나므로, 이 분류기는 그것을 REFUSED로 판단합니다. 실제 원인은 응답 없음인데 분류는 거부가 되는 것입니다. 이런 오분류를 줄이는 방법은 두 가지입니다. 하나는 connect에 언제나 시간 제한을 주어 응답 없음이 SocketTimeoutException으로 나타나게 하는 것이고, 다른 하나는 분류 값과 함께 실패한 단계, 걸린 시간, 대상 주소를 기록하는 것입니다.
걸린 시간은 특히 좋은 단서입니다. 거부는 보통 수 밀리초 안에 돌아오고 운영체제의 시간 초과는 수십 초 이상 걸리므로, 시간을 같이 보면 둘을 쉽게 구분할 수 있습니다. 분류표에는 실제로 확인한 운영체제와 환경을 함께 적어 두고, 운영 중에 UNKNOWN이 얼마나 나오는지 지켜보며 규칙을 보완해 나갑니다.
닫힌 포트로 연결 거부 재현하기
외부 서버 없이도 연결 거부를 직접 일으켜 볼 수 있습니다. ServerSocket을 포트 0으로 만들면 운영체제가 비어 있는 포트를 하나 골라 주는데, 그 번호만 기억해 두고 서버 소켓을 바로 닫으면 됩니다. 방금까지 쓰던 그 포트에는 이제 아무도 연결을 기다리지 않으므로, 그 포트로 연결하면 거부가 납니다.
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
public final class LoopbackRefusalProbe {
public static void main(String[] args) throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
int unusedPort;
try (var temporary = new ServerSocket(0, 1, loopback)) {
unusedPort = temporary.getLocalPort(); // 비어 있는 포트 번호만 얻고 바로 닫는다
}
try (var socket = new Socket()) {
socket.connect(new InetSocketAddress(loopback, unusedPort), 500);
System.out.println("unexpected connection");
} catch (ConnectException refused) {
System.out.println(refused);
System.out.println("classified=" + ConnectionFailureClassifier.classify(refused));
}
}
}포트에서 기다리는 서버가 없으므로 connect()는 ConnectException을 던지고, catch가 예외 객체와 분류 결과를 한 줄씩 찍습니다. 이 프로그램은 앞의 ConnectionFailureClassifier를 사용하므로, 두 파일을 같은 폴더에 두고 함께 컴파일한 뒤 실행합니다.
javac ConnectionFailureClassifier.java LoopbackRefusalProbe.java
java LoopbackRefusalProbeprintln(refused)는 예외의 toString()을 찍으므로 스택 트레이스는 나오지 않습니다. 실행 결과는 다음과 같습니다.
java.net.ConnectException: Connection refused
classified=REFUSED첫 줄은 예외 객체를 그대로 출력한 것으로, 예외 타입 뒤에 운영체제가 알려 준 메시지가 붙어 있습니다. 메시지 문구는 운영체제에 따라 다를 수 있지만 타입은 같으므로 두 번째 줄의 분류 결과는 달라지지 않습니다. 자기 자신을 가리키는 루프백(loopback) 주소에서는 RST가 즉시 돌아오기 때문에, 이 거부는 대개 1밀리초도 걸리지 않고 끝납니다.
드물지만 결과가 달라지는 경우도 있습니다. 임시 서버 소켓을 닫은 뒤 연결하기까지의 짧은 사이에 다른 프로그램이 같은 포트를 차지하면, 거부 대신 그 프로그램에 연결될 수 있습니다. 코드가 연결 성공을 가정하지 않고 unexpected connection을 출력하는 분기를 따로 둔 것은 그 때문입니다.
운영 환경에서는 이 속도 차이가 단서가 됩니다. 빠른 거부가 갑자기 늘면 배포 중이라 서버 프로세스가 내려가 있거나 포트 설정이 틀렸을 가능성이 크고, 느린 시간 초과가 늘면 네트워크 경로나 서버 과부하를 먼저 의심합니다. 다만 시간만으로 원인을 단정하지 말고 앞의 분류 값과 함께 봅니다.
이름 해석은 연결보다 먼저 따로 일어난다
new Socket(host, port)는 이름 해석과 연결을 한꺼번에 하지만, new Socket()으로 빈 소켓을 만들고 connect(address, timeout)을 따로 부르면 두 단계가 나뉩니다. 이때 주소를 나타내는 InetSocketAddress를 어떻게 만드느냐에 따라 이름 해석이 일어나는 시점이 달라집니다.
주소 객체 생성과 직접 소켓 연결의 구별
주소 생성 · DNS와 직접 연결
주소 객체 생성과 직접 소켓 연결의 구별
미해결 주소는 이름과 포트를 보관할 뿐이며, 일반 직접 connect가 DNS를 대신 수행하지 않습니다.
createUnresolved(host, port)
- 이름 해석
- 조회하지 않고 이름·포트 보관
- 연결 여부
- 연결하지 않음
new InetSocketAddress(host, port)
- 이름 해석
- 조회 시도, 실패하면 미해결 주소
- 연결 여부
- 연결하지 않음
직접 Socket.connect에 미해결 주소 전달
- 이름 해석
- 일반 직접 연결은 UnknownHostException
- 연결 여부
- connect timeout이 DNS 해석 예산이 되지 않음
UnresolvedEndpoint의 main은 주소 정보만 출력합니다. SOCKS5처럼 프록시가 이름을 처리하는 별도 경로를 일반 직접 연결의 보장으로 해석하지 않습니다.
new InetSocketAddress(host, port) 생성자는 객체를 만드는 순간 이름 해석을 시도합니다. 해석에 실패해도 예외를 던지지 않고, 해석되지 않았다는 표시가 붙은 주소 객체가 만들어집니다. 이 표시는 isUnresolved()로 확인합니다. 반면 InetSocketAddress.createUnresolved(host, port)는 해석을 아예 시도하지 않고 이름과 포트만 담아 둡니다.
어느 쪽이든 해석되지 않은 주소를 Socket.connect에 넘기면, connect는 DNS를 대신 조회하지 않고 곧바로 UnknownHostException을 던집니다. JDK 문서도 connect가 해석되지 않은 InetSocketAddress를 받으면 이 예외를 던진다고 명시합니다. 그림 아래쪽 설명처럼 SOCKS5 프록시를 거치는 연결은 프록시가 이름을 대신 해석하기도 하지만, 그것은 일반적인 직접 연결과는 다른 경로입니다.
여기서 중요한 결론이 나옵니다. socket.connect(new InetSocketAddress(host, port), 500)의 500밀리초는 TCP 연결에만 적용되고 DNS 조회 시간은 포함하지 않습니다. 이름 해석은 connect가 불리기도 전에 InetSocketAddress 생성자 안에서 이미 끝났기 때문입니다. 게다가 InetAddress의 조회 메서드에는 시간 제한을 받는 매개변수가 없어서, 조회가 얼마나 오래 걸릴지는 운영체제의 해석기 설정을 따릅니다. 그래서 DNS가 느린 환경에서는 연결 시간 제한을 짧게 잡아도 전체 요청이 그보다 훨씬 오래 걸릴 수 있습니다.
해석을 미루는 createUnresolved는 설정을 검사할 때 쓸모가 있습니다. 애플리케이션이 시작할 때 설정에 적힌 모든 외부 서비스의 이름을 해석하게 만들면, DNS가 잠깐 불안정한 것만으로 애플리케이션 전체가 뜨지 못합니다. 또 DNS 응답에는 유효 기간(TTL)이 있어 같은 이름이 가리키는 IP가 나중에 바뀔 수 있으므로, 시작할 때 얻은 IP를 계속 쓰는 것도 위험합니다. 그래서 시작할 때는 이름이 비어 있지 않은지, 포트가 범위 안에 있는지 같은 형식만 검사하고 실제 해석은 연결할 때 합니다.
import java.net.InetSocketAddress;
public final class UnresolvedEndpoint {
record Endpoint(String host, int port) {
Endpoint {
if (host == null || host.isBlank()) throw new IllegalArgumentException("host");
if (port < 1 || port > 65_535) throw new IllegalArgumentException("port");
}
InetSocketAddress unresolved() {
return InetSocketAddress.createUnresolved(host, port);
}
}
public static void main(String[] args) {
InetSocketAddress address = new Endpoint("service.internal", 443).unresolved();
System.out.println("host=" + address.getHostString());
System.out.println("unresolved=" + address.isUnresolved());
}
}첫 줄은 주소에 담긴 이름이고, 둘째 줄은 이 주소가 아직 IP로 바뀌지 않은 상태인지를 알려 줍니다. createUnresolved는 이름을 조회하지 않으므로, service.internal이 실제로는 해석되지 않는 이름이어도 결과는 같습니다.
host=service.internal
unresolved=trueEndpoint 레코드의 컴팩트 생성자가 빈 이름과 범위를 벗어난 포트를 거부하므로, 잘못된 설정은 애플리케이션이 시작할 때 바로 드러납니다. unresolved()는 createUnresolved로 주소를 만들기 때문에 DNS를 조회하지 않고, 그래서 isUnresolved()가 true입니다. getHostString() 역시 IP로 이름을 찾는 역방향 조회 없이 담아 둔 이름을 그대로 돌려줍니다. 이 main은 주소 정보만 출력할 뿐 연결은 시도하지 않습니다. 실제로 연결할 때는 new InetSocketAddress(host, port)로 다시 만들어 해석하고, isUnresolved()가 true이면 DNS 실패로 처리하면 됩니다.
더 깊이: 이름 하나에 주소가 여러 개일 때
규모가 큰 서비스의 이름은 IP 주소를 여러 개 돌려주는 경우가 많고, IPv4와 IPv6 주소가 섞여 있기도 합니다. 그런데 new Socket(host, port)와 new InetSocketAddress(host, port)는 내부에서 InetAddress.getByName을 쓰므로 그중 첫 번째 주소 하나에만 연결을 시도합니다. 첫 주소의 서버가 내려가 있으면 나머지 주소가 멀쩡해도 연결은 실패합니다.
InetAddress.getAllByName으로 주소 목록을 받아 각 주소에 짧은 시간 제한을 두고 차례로 시도하면 이 문제를 피할 수 있습니다. DNS 조회와 연결을 따로 하므로 둘 중 어느 쪽이 느린지 따로 잴 수 있다는 장점도 있습니다. 다만 IPv6와 IPv4 연결을 시차를 두고 동시에 시도하는 Happy Eyeballs(RFC 8305) 같은 정교한 주소 선택까지 직접 구현하기는 생각보다 까다로우므로, 필요한 만큼만 단순하게 만드는 편이 안전합니다.
원인마다 다시 시도할지 정하기
먼저 흔히 보는 잘못된 재시도 코드부터 보겠습니다. connectForever()는 IOException이 나면 원인을 보지 않고 곧바로 다시 연결합니다. 끝나지 않는 반복이므로 main은 이 메서드를 호출하지 않고 안내 문장만 출력합니다.
import java.io.IOException;
import java.net.Socket;
public final class RetryEveryIOException {
static Socket connectForever(String host, int port) {
while (true) {
try {
return new Socket(host, port);
} catch (IOException ignored) {
// 실패 원인을 보지 않고, 기다리지도 않고, 끝없이 다시 시도한다.
}
}
}
public static void main(String[] args) {
System.out.println("무한 재시도 반례는 실행하지 않습니다");
}
}이 코드의 첫 번째 문제는 모든 IOException을 똑같이 다룬다는 점입니다. 호스트 이름에 오타가 있으면 몇 번을 다시 해도 성공할 수 없으니 반복문은 영원히 끝나지 않습니다. 두 번째 문제는 시도 사이에 전혀 기다리지 않는다는 점입니다. CPU를 쉬지 않고 쓰면서 실패 로그를 쏟아 내고, 재시작 중인 서버라면 막 살아나는 순간 여러 클라이언트의 연결 요청이 한꺼번에 몰려 회복을 방해합니다.
세 번째 문제는 시도 횟수에도 전체 시간에도 상한이 없다는 점입니다. 이 메서드를 부른 쪽은 성공할 때까지 다른 일을 할 수 없습니다. 특히 DNS 실패를 곧바로 다시 시도하는 것은 자바에서 의미가 없습니다. InetAddress는 실패한 조회 결과도 기본 10초 동안 캐시에 보관하므로(networkaddress.cache.negative.ttl), 그 사이의 재시도는 DNS에 묻지도 않고 같은 실패를 곧바로 돌려받습니다.
잘 만든 재시도는 다음 네 가지를 갖춥니다.
- 다시 시도할 원인만 고릅니다. 연결 거부와 시간 초과처럼 잠시 뒤 나아질 수 있는 실패만 재시도하고, DNS 실패나 경로 없음은 바로 실패시키거나 운영자에게 알립니다.
- 시도 횟수와 전체 시간에 상한을 둡니다. 상한에 닿으면 마지막 예외를 호출한 쪽에 넘깁니다.
- 시도 사이 간격을 두 배씩 늘립니다. 100ms, 200ms, 400ms처럼 늘려 가는 방식을 지수 백오프(exponential backoff)라고 하며, 장애가 길어질수록 대상에 주는 부담이 줄어듭니다.
- 간격에 무작위성을 섞습니다. 이를 지터(jitter)라고 합니다. 같은 장애로 동시에 실패한 클라이언트들이 똑같은 간격으로 기다리면 다시 같은 순간에 몰려들기 때문입니다.
이 네 가지를 앞의 분류기와 엮으면 다음과 같은 연결 함수가 됩니다.
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ThreadLocalRandom;
public final class ConnectWithBackoff {
static Socket connect(InetSocketAddress target, int maxAttempts)
throws IOException, InterruptedException {
for (int attempt = 1; ; attempt++) {
Socket socket = new Socket();
try {
socket.connect(target, 500);
return socket;
} catch (IOException error) {
socket.close();
var kind = ConnectionFailureClassifier.classify(error);
boolean retryable = kind == ConnectionFailureClassifier.Kind.REFUSED
|| kind == ConnectionFailureClassifier.Kind.TIMEOUT;
if (!retryable || attempt == maxAttempts) {
System.out.println("attempt " + attempt + ": " + kind + ", give up");
throw error;
}
long maxDelay = 100L << (attempt - 1); // 100, 200, 400, ...ms
System.out.println("attempt " + attempt + ": " + kind
+ ", wait up to " + maxDelay + "ms");
Thread.sleep(ThreadLocalRandom.current().nextLong(maxDelay + 1));
}
}
}
public static void main(String[] args) throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
int closedPort;
try (var temporary = new ServerSocket(0, 1, loopback)) {
closedPort = temporary.getLocalPort();
}
try (Socket socket = connect(new InetSocketAddress(loopback, closedPort), 4)) {
System.out.println("connected");
} catch (IOException error) {
System.out.println("final: " + error);
}
}
}connect는 시도할 때마다 새 Socket을 만듭니다. 연결에 실패한 소켓은 다시 쓸 수 없기 때문인데, JDK 25 문서는 연결에 실패하면 소켓을 닫는다고 명시하고 있고 코드에서 한 번 더 닫아도 문제는 없습니다. 실패하면 분류기로 원인을 확인하고, 재시도할 원인이 아니거나 마지막 시도였다면 원래 예외를 그대로 던져 호출한 쪽이 진짜 원인을 볼 수 있게 합니다. 기다릴 최대 시간은 100L << (attempt - 1)이라서 시도마다 두 배가 되고, 실제로는 0부터 그 값 사이에서 무작위로 고른 시간만큼만 잠듭니다. 이처럼 상한 안에서 완전히 무작위로 고르는 방식을 full jitter라고 부릅니다.
앞의 예제처럼 분류기와 함께 컴파일해서 실행합니다. main은 닫힌 포트로 최대 네 번 연결을 시도합니다.
javac ConnectionFailureClassifier.java ConnectWithBackoff.java
java ConnectWithBackoff다시 시도하는 차례는 wait up to 줄을, 포기하는 차례는 give up 줄을 남깁니다. 마지막 final: 줄은 connect가 다시 던진 예외를 main의 catch가 찍은 것입니다.
attempt 1: REFUSED, wait up to 100ms
attempt 2: REFUSED, wait up to 200ms
attempt 3: REFUSED, wait up to 400ms
attempt 4: REFUSED, give up
final: java.net.ConnectException: Connection refused포트가 계속 닫혀 있으므로 네 번 모두 거부되고, 네 번째 시도에서 포기한 뒤 마지막 예외가 main까지 올라옵니다. 실제로 잠드는 시간은 실행할 때마다 달라지지만 출력에는 최대 시간만 찍히므로 결과 줄은 매번 같습니다. 실제 서비스에서는 간격이 끝없이 커지지 않도록 최대 간격(예를 들어 2초)을 정해 두고, 잠들기 전에 요청 전체에 남은 시간이 충분한지도 확인합니다. 남은 시간을 계산하는 방법은 다음 글에서 다룹니다.
연결 전 실패와 연결 뒤 실패의 차이
지금까지 다룬 실패는 모두 연결이 만들어지기 전에 일어났습니다. 연결이 없었으니 요청은 한 바이트도 서버에 닿지 않았고, 그래서 다시 시도해도 같은 작업이 두 번 처리될 걱정은 없습니다. 연결된 뒤의 실패는 사정이 다릅니다. 요청을 보낸 뒤 응답을 기다리다 시간이 초과되거나 연결이 끊기면, 서버가 요청을 처리했는지 클라이언트로서는 알 수 없습니다. 이때 결제나 주문처럼 두 번 실행되면 안 되는 요청을 다시 보내면 중복 처리가 생길 수 있습니다.
그래서 연결 뒤의 실패를 재시도할 때는 요청이 멱등(idempotent)한지를 함께 봅니다. 멱등한 요청은 여러 번 실행해도 한 번 실행한 것과 결과가 같은 요청으로, 값을 조회하거나 어떤 상태를 특정 값으로 설정하는 요청이 여기에 해당합니다. 반대로 잔액에 1만 원을 더하는 요청처럼 실행할 때마다 결과가 쌓이는 요청은 멱등하지 않습니다. 응답을 기다리다 생기는 시간 초과는 소켓 연결·읽기 시간 제한에서, 연결이 끊기는 여러 방식은 TCP 연결 종료: EOF, 반쪽 닫기, RST에서 이어서 다룹니다.
원인별 첫 대응
원인별로 먼저 확인할 것과 기본 대응을 표로 모으면 다음과 같습니다. 실제 정책은 대상 서비스가 얼마나 중요한지, 실패를 어디까지 허용할 수 있는지에 맞춰 조정합니다.
| 분류 | 먼저 확인할 것 | 기본 대응 |
|---|---|---|
| DNS | 호스트 이름 철자, DNS 설정과 해석기 상태 | 설정 오류로 보고 바로 실패 |
| REFUSED | 포트 번호, 서버 프로세스 상태, 배포 진행 여부 | 짧은 간격으로 몇 번만 재시도 |
| NO_ROUTE | 라우팅 설정, 방화벽 정책 | 재시도보다 운영자 경보 |
| TIMEOUT | 네트워크 경로, 대상 서버 부하 | 남은 시간 안에서 재시도하거나 다른 서버 사용 |
| UNKNOWN | 예외 전체와 스택 트레이스, 운영체제 | 사례를 모아 분류 규칙 보완 |
연습 문제
- 연결 실패 분류(
Kind), 지금까지 시도한 횟수, 남은 시간(Duration), 요청의 멱등 여부를 받아RETRY,FAIL_FAST,ESCALATE가운데 하나를 돌려주는decide메서드를 작성하세요.DNS는 다른 값과 상관없이FAIL_FAST,NO_ROUTE와UNKNOWN은 운영자 확인이 필요하므로ESCALATE입니다.REFUSED와TIMEOUT은 멱등 요청이고, 시도 횟수가 3 미만이고, 남은 시간이 200ms 이상일 때만RETRY이고 그렇지 않으면FAIL_FAST입니다. - 1번 답에서
REFUSED, 시도 횟수 2, 멱등 요청인 상태로 남은 시간만 200ms와 199ms로 바꾸면 결과가 각각 무엇인지 구하세요. 같은 조건에서 시도 횟수만 3으로 올린 경우와,NO_ROUTE이면서 멱등하지 않은 요청인 경우도 어느 분기에서 결정되는지 설명하세요. ConnectWithBackoff.connect에new InetSocketAddress("service.invalid", 443)을 넘기면 몇 번 시도하고 어떤 예외로 끝나는지 예상하세요..invalid는 실제로 존재할 수 없도록 예약된 이름입니다.
1번 해설
분류를 먼저 검사해 DNS, NO_ROUTE, UNKNOWN을 걸러 내고, 남은 REFUSED와 TIMEOUT에 대해서만 나머지 세 조건을 봅니다. 세 조건 가운데 하나라도 어긋나면 바로 FAIL_FAST를 돌려주므로, 마지막 줄까지 내려왔다면 세 조건을 모두 만족한 것입니다.
import java.time.Duration;
public final class ConnectionRetryDecisionSolution {
enum Kind { DNS, REFUSED, NO_ROUTE, TIMEOUT, UNKNOWN }
enum Decision { RETRY, FAIL_FAST, ESCALATE }
static Decision decide(Kind kind, int attempts, Duration remaining, boolean idempotent) {
if (kind == Kind.DNS) return Decision.FAIL_FAST;
if (kind == Kind.NO_ROUTE || kind == Kind.UNKNOWN) return Decision.ESCALATE;
if (!idempotent || attempts >= 3 || remaining.compareTo(Duration.ofMillis(200)) < 0) {
return Decision.FAIL_FAST;
}
return Decision.RETRY;
}
public static void main(String[] args) {
System.out.println(decide(Kind.REFUSED, 1, Duration.ofSeconds(1), true));
System.out.println(decide(Kind.DNS, 0, Duration.ofSeconds(1), true));
System.out.println(decide(Kind.TIMEOUT, 1, Duration.ofSeconds(1), false));
}
}실행 결과:
RETRY
FAIL_FAST
FAIL_FAST첫 줄은 세 조건을 모두 만족한 REFUSED이고, 둘째 줄은 다른 값과 상관없이 분류만으로 결정된 DNS입니다. 셋째 줄은 TIMEOUT이지만 멱등하지 않은 요청이라서 FAIL_FAST가 됩니다.
연습 문제의 재시도 분기와 경계값
재시도 정책 · 분기와 200ms 경계
연습 문제의 재시도 분기와 경계값
DNS와 운영 확인 분기를 먼저 고르고, REFUSED·TIMEOUT은 세 조건을 모두 만족할 때만 RETRY입니다.
DNS
- 추가 조건
- 다른 인자보다 먼저 분류
- 반환 결과
- FAIL_FAST
NO_ROUTE 또는 UNKNOWN
- 추가 조건
- 다른 인자보다 먼저 분류
- 반환 결과
- ESCALATE
REFUSED 또는 TIMEOUT
- 추가 조건
- 비멱등, attempts≥3, remaining<200ms 중 하나
- 반환 결과
- FAIL_FAST
REFUSED 또는 TIMEOUT
- 추가 조건
- 멱등, attempts<3, remaining≥200ms 모두 충족
- 반환 결과
- RETRY
decide가 값을 선택하는 규칙입니다. 이 함수는 실제 재연결·백오프를 실행하지 않으며 입력 횟수의 음수 여부도 검증하지 않습니다.
이 정책은 일부러 보수적으로 만들었습니다. 연결 거부는 요청이 서버에 닿기 전에 일어나므로 엄밀히 따지면 멱등하지 않은 요청도 다시 보내도 됩니다. 하지만 TIMEOUT은 응답을 기다리던 중에 났을 수도 있어서, 단계 정보 없이 둘을 같은 규칙으로 다루려면 멱등성을 요구하는 쪽이 안전합니다. 실패한 단계를 함께 기록한다면 연결 단계의 거부에 한해 이 조건을 풀 수 있습니다. 또 decide는 결정 값을 고를 뿐 실제로 다시 연결하거나 잠들지 않으며, 시도 횟수가 음수인지 같은 입력 검사도 하지 않습니다.
2번 해설
남은 시간이 200ms면 remaining.compareTo(200ms) < 0이 거짓이므로 RETRY이고, 199ms면 참이 되어 FAIL_FAST입니다. 기준이 '200ms 미만이면 중단'이므로 정확히 200ms는 통과합니다. 시도 횟수를 3으로 올리면 attempts >= 3에 걸려 남은 시간과 상관없이 FAIL_FAST입니다. NO_ROUTE는 멱등성을 검사하는 줄보다 앞선 두 번째 줄에서 이미 ESCALATE로 결정되므로, 멱등하지 않다는 사실은 결과에 영향을 주지 않습니다.
3번 해설
한 번만 시도하고 끝납니다. new InetSocketAddress("service.invalid", 443)은 생성자에서 이름 해석에 실패해 해석되지 않은 주소가 되고, 이 주소를 받은 socket.connect는 곧바로 UnknownHostException을 던집니다. 분류 결과가 DNS이므로 retryable이 거짓이 되어 첫 시도에서 attempt 1: DNS, give up을 출력하고, 원래 예외인 java.net.UnknownHostException: service.invalid를 그대로 던집니다. 기다려도 나아지지 않는 오류에 시간을 쓰지 않는다는 점이 처음의 잘못된 코드와 다릅니다.
정리
new Socket(host, port)는 이름 해석, 경로 선택, TCP 연결 수립을 차례로 하며, 실패한 단계에 따라UnknownHostException,NoRouteToHostException,ConnectException,SocketTimeoutException이 납니다.- 연결 거부는 대상까지 도착했지만 그 포트에 기다리는 프로그램이 없다는 뜻이고 대개 즉시 돌아옵니다. 응답이 없을 때는
connect에 시간 제한을 주었을 때만SocketTimeoutException이 되고, 주지 않았다면 Linux에서는 2분 남짓 뒤에ConnectException이 됩니다. - 메시지 문자열 대신 예외 타입을 작은 분류 값으로 바꿔 쓰고, 분류할 수 없는 것은
UNKNOWN으로 남깁니다. 실패한 단계와 걸린 시간을 함께 기록하면 오분류를 줄일 수 있습니다. - 이름 해석은
connect보다 먼저 일어나므로 연결 시간 제한에 포함되지 않습니다.createUnresolved로 해석을 연결 시점까지 미룰 수 있습니다. - 재시도는 일시적인 원인에만, 횟수와 시간에 상한을 두고, 지수 백오프와 지터를 섞어서 합니다. 연결된 뒤의 실패는 서버가 요청을 처리했을 수도 있으므로 멱등한 요청만 다시 보냅니다.
다음 글
소켓 연결·읽기 시간 제한에서는 connect와 read에 시간 제한을 거는 방법과, 이름 해석부터 응답까지 여러 단계가 하나의 시간 예산을 나눠 쓰도록 남은 시간을 계산하는 방법을 다룹니다.