연결 단계별 네트워크 오류
DNS 해석 실패, 주소 형식 오류, 미사용 포트의 연결 거부, 네트워크 도달 불가를 서로 다른 운영 원인으로 구분하고 제한 재시도와 진단 정보를 설계합니다.
new Socket(host, port) 한 줄 안에는 호스트 이름 해석, 경로 선택, TCP 연결 수립이 포함됩니다.
실패를 모두 “서버가 죽었다”로 번역하면 구성 오류에 재시도를 반복하거나 일시 장애를 영구 실패로 처리합니다.
어느 단계가 실패했는지 예외 타입과 연결 상태로 구분해야 합니다.
UnknownHostException은 이름을 IP로 해석하지 못했다는 뜻입니다.
오타, DNS 설정, 일시 해석기 장애가 후보입니다.
ConnectException의 연결 거부됨은 대상 IP까지 도달했지만 해당 포트에 리스너가 없거나 방화벽이 명시적으로 거부 응답을 보낸 경우가 많습니다.
응답 자체가 없으면 connect 시간 제한이나 no route 계열 오류로 보일 수 있습니다.
IOException 재시도 분류
호스트 이름 오타는 기다린다고 고쳐지지 않습니다. 간격 없는 재시도는 DNS 해석기와 로그를 압박하고, 연결 거부됨 상태에서도 대상 복구를 방해할 수 있습니다. 요청이 비멱등이면 첫 연결의 실제 처리 여부를 모르는 상태에서 중복 업무를 만들 위험도 있습니다.
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("무한 재시도 반례는 실행하지 않습니다");
}
}재시도는 최대 횟수, 전체 기한, 지수 지연, jitter, 허용 예외를 가져야 합니다. 사용자 요청 안에서 기다릴 수 없다면 빠르게 실패하고 상위 큐나 회로 차단기가 복구를 조정합니다. 연결 성공 뒤 쓰기 실패는 서버가 요청을 받았는지 모호할 수 있으므로 연결 단계 실패와 같은 규칙을 쓰지 않습니다.
예외와 연결 결과 모델
애플리케이션 계층이 운영체제 메시지 문자열을 직접 비교하지 않도록 연결 어댑터가 결과 유형을 만듭니다. 예외 객체는 진단 원인으로 보존하되 사용자 응답에는 내부 주소와 스택을 노출하지 않습니다. 분류할 수 없는 IOException은 UNKNOWN으로 남겨 잘못된 확신을 피합니다.
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")));
}
}일부 플랫폼에서는 시간 제한이 ConnectException 메시지로 나타날 수 있고 방화벽 정책도 다릅니다.
타입만으로 모든 원인을 확정하지 말고 단계, 경과 시간, 대상 주소, OS 수준 지표를 함께 봅니다.
분류표는 테스트된 플랫폼 범위를 문서화하고 UNKNOWN 비율을 관찰해 갱신합니다.
루프백 닫힌 포트의 연결 거부
외부 서버에 의존하지 않고 연결 거부됨을 시험하려면 루프백에서 임시 포트를 열어 번호를 얻은 뒤 리스너를 닫고 연결합니다. 아주 드물게 다른 프로세스가 그 포트를 차지하는 경쟁이 있으므로 엄격한 테스트에서는 열린 ServerSocket이 수락 후 명시적으로 거부하는 대역을 구성합니다.
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 {
int unusedPort;
try (var temporary = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
unusedPort = temporary.getLocalPort();
}
try (var socket = new Socket()) {
socket.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), unusedPort), 500);
System.out.println("unexpected connection");
} catch (ConnectException refused) {
System.out.println("classified=" + ConnectionFailureClassifier.classify(refused));
}
}
}거부는 대개 빠르게 돌아옵니다. 시간 제한은 상대가 RST나 ICMP를 보내지 않아 기다리는 상태라서 시간 특성이 다릅니다. 운영 모니터링에서 빠른 거부됨 급증은 배포 중 리스너 부재나 잘못된 포트를, 긴 시간 제한 증가는 네트워크 경로와 과부하를 의심하게 합니다.
이름 해석과 주소 연결의 분리 관측
InetAddress.getAllByName으로 DNS 시간을 측정하고 반환 주소 목록을 얻은 뒤 각 주소에 제한된 연결을 시도하면 어느 단계가 느린지 알 수 있습니다.
그러나 애플리케이션이 해석기의 주소 선택, IPv6/IPv4 대체 경로, Happy Eyeballs를 서툴게 재구현하면 표준 클라이언트보다 호환성이 떨어질 수 있습니다.
기본 라이브러리의 동작을 우선하고 필요한 계측만 추가합니다.
InetSocketAddress.createUnresolved는 DNS 조회 없이 호스트와 포트를 값으로 보관합니다.
나중 connect 시점에 해석됩니다.
구성 검증에서는 포트 범위와 빈 이름을 먼저 확인하고, 시작 시 반드시 연결할 의존성만 실제 조회합니다.
모든 외부 서비스 DNS를 시작 단계에서 강제하면 일시 DNS 장애가 전체 애플리케이션 부팅을 막을 수 있습니다.
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는 짧은 TTL 때문에 변경 가능하다고 봅니다. IP 허용 목록이 필요하면 DNS rebinding과 여러 주소, 프록시 경로를 포함한 보안 설계를 별도로 합니다.
연결 실패 대응표
| 분류 | 첫 확인 | 일반 대응 |
|---|---|---|
| DNS | 이름과 해석기 상태 | 구성 오류는 즉시 실패 |
| REFUSED | 포트와 리스너 배포 | 짧은 제한 재시도 |
| NO_ROUTE | 라우팅·방화벽 | 운영 경보 |
| TIMEOUT | 경로·대상 부하 | 기한 안 대체 경로 |
| UNKNOWN | 스택과 플랫폼 | 표본 수집 후 분류 갱신 |
연습 문제
연결 실패 종류와 시도 횟수, 남은 시간, 요청 멱등성을 받아 RETRY, FAIL_FAST, ESCALATE를 반환하세요. DNS 구성 오류와 NO_ROUTE는 즉시 운영 확인 대상으로 두고, REFUSED와 TIMEOUT은 멱등 요청에서만 제한 재시도합니다.
정답과 해설
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));
}
}실제 정책은 대상 서비스와 오류 예산에 맞춰 다릅니다. 여기서 중요한 점은 IOException 하나에 동일한 재시도를 적용하지 않고 단계와 멱등성, 전체 시간 예산을 함께 본다는 것입니다.
연결 오류 분류가 유효한 조건
연결 예외는 네트워크라는 한 범주가 아니라 이름 해석, 경로, 리스너, 시간 예산의 서로 다른 실패입니다. 예외 타입을 안정적인 결과로 번역하고 외부 의존 없는 재현 시나리오, 제한 재시도, UNKNOWN 관측을 갖추면 운영자는 어느 계층을 확인해야 할지 빠르게 결정할 수 있습니다.