연결·읽기 시간 제한
Socket.connect와 SO_TIMEOUT으로 연결·응답 대기를 제한하고 여러 단계가 공유하는 절대 기한과 취소 정책을 구현합니다.
TCP 연결을 시도했는데 응답 패킷이 오지 않으면 운영체제가 자체 재전송과 기본 시간 제한을 적용합니다.
이 값은 플랫폼과 네트워크 설정에 따라 수십 초 이상일 수 있어 사용자 요청의 응답 예산보다 깁니다.
애플리케이션은 Socket.connect(endpoint, timeoutMillis)로 연결 단계의 상한을 명시해야 합니다.
연결 성공 뒤 서버가 응답을 보내지 않는 문제는 다른 시간 제한입니다.
socket.setSoTimeout은 입력 read가 지정 시간 동안 데이터를 받지 못할 때 SocketTimeoutException을 발생시킵니다.
쓰기 시간 제한은 고전 Socket API에서 같은 방식으로 직접 제공되지 않으므로 상위 클라이언트, 비동기 채널, 작업 기한과 연결 종료를 검토합니다.
소켓 생성자 위임에 따른 요청 예산 초과
호스트와 포트를 받는 Socket 생성자는 내부에서 바로 connect를 시도하며 별도의 애플리케이션 시간 제한 인자를 받지 않습니다.
응답이 없는 주소에서 OS 기본값만큼 요청 스레드가 멈출 수 있습니다.
실행 환경마다 시간이 달라 장애 재현도 어렵습니다.
import java.net.Socket;
public final class ImplicitConnectTimeout {
static Socket connect(String host, int port) throws Exception {
return new Socket(host, port);
}
public static void main(String[] args) {
System.out.println("운영체제 기본 connect 대기는 실행하지 않습니다");
}
}해결은 먼저 빈 Socket 객체를 만들고 끝점과 시간 제한을 connect에 전달하는 것입니다.
시간 제한 뒤 Socket을 재사용하지 말고 닫습니다.
구성값이 양수 범위이고 요청 전체 예산 이하인지 확인합니다.
connect·read의 개별 상한
연결은 대상 서버에 도달하는 단계이고 read는 연결된 세션에서 다음 바이트를 기다리는 단계입니다.
같은 3초를 기계적으로 두기보다 DNS, connect, 요청 쓰기, 첫 바이트, 전체 본문이 공유하는 사용자 기한을 설계합니다.
대상별 지연 분포와 오류 예산이 각 단계 배분의 근거입니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.time.Duration;
public final class TimedSocketClient {
static String request(InetSocketAddress endpoint, String message,
Duration connectTimeout, Duration readTimeout) throws Exception {
try (var socket = new Socket()) {
socket.connect(endpoint, Math.toIntExact(connectTimeout.toMillis()));
socket.setSoTimeout(Math.toIntExact(readTimeout.toMillis()));
try (var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
output.writeUTF(message);
output.flush();
return input.readUTF();
}
}
}
public static void main(String[] args) {
InetSocketAddress endpoint = InetSocketAddress.createUnresolved("service.invalid", 443);
System.out.println("configured=" + endpoint + " client uses explicit limits");
}
}소켓 시간 제한이 적용되는 연산
| 설정·연산 | 제한하는 대기 | 이 설정만으로 제한하지 못하는 것 |
|---|---|---|
| connect(endpoint, timeout) | TCP 연결 수립 | 앞서 수행한 DNS 조회와 요청 전체 시간 |
| setSoTimeout(timeout) | 소켓 입력 스트림의 개별 read 대기 | 여러 read를 수행하는 readUTF의 전체 시간 |
| 출력 스트림 write·flush | 같은 방식의 쓰기 timeout 설정 없음 | 송신 대기를 SO_TIMEOUT으로 제한할 수 없음 |
- connect(endpoint, timeout)
- 제한하는 대기: TCP 연결 수립이 설정만으로 제한하지 못하는 것: 앞서 수행한 DNS 조회와 요청 전체 시간
- setSoTimeout(timeout)
- 제한하는 대기: 소켓 입력 스트림의 개별 read 대기이 설정만으로 제한하지 못하는 것: 여러 read를 수행하는 readUTF의 전체 시간
- 출력 스트림 write·flush
- 제한하는 대기: 같은 방식의 쓰기 timeout 설정 없음이 설정만으로 제한하지 못하는 것: 송신 대기를 SO_TIMEOUT으로 제한할 수 없음
connect와 SO_TIMEOUT의 0은 무제한입니다. 읽기 시간 초과 뒤에도 Socket은 유효하지만, 프레임 일부를 소비했는지에 따라 재사용 판단이 달라집니다.
Duration을 밀리초 int로 바꿀 때 0으로 내림되거나 범위를 넘지 않게 구성 접점에서 제한합니다.
예제 main은 미해결 주소 정보를 출력할 뿐 request를 호출하지 않습니다. 일반 직접 연결에서 사용할 주소는 호출 전에 해석되어 있어야 하며, createUnresolved가 DNS를 connect에 맡기는 뜻은 아닙니다.
실제 호출자는 SocketTimeoutException이 connect에서 났는지 read에서 났는지 단계 정보를 결과에 포함해야 합니다.
읽기 시간 제한 재현
연결을 수락한 뒤 일정 시간 아무 바이트도 보내지 않는 서버로 read 시간 제한 경로를 관찰할 수 있습니다.
서버 스레드와 클라이언트를 한 프로세스에 두고 리스너는 임시 포트를 사용합니다.
클라이언트 시간 제한은 서버 지연보다 짧게 두지만, sleep만으로 스레드 실행 순서가 고정되지는 않습니다.
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
public final class ReadTimeoutProbe {
public static void main(String[] args) throws Exception {
try (var server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
Thread delayed = Thread.startVirtualThread(() -> {
try (Socket accepted = server.accept()) {
Thread.sleep(250);
accepted.getOutputStream().write(1);
} catch (Exception ignored) { }
});
try (var client = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort())) {
client.setSoTimeout(50);
try {
client.getInputStream().read();
System.out.println("unexpected byte");
} catch (SocketTimeoutException expected) {
System.out.println("read timed out");
}
}
delayed.join();
}
}
}클라이언트가 늦게 읽기 시작하면 이미 도착한 바이트를 받을 수도 있습니다. 원문의 unexpected byte는 read()의 반환값을 검사하지 않아 EOF 반환도 같은 문구로 출력합니다. 이 출력만으로 수신 값을 확정하지 않습니다.
read 시간 제한 뒤 같은 연결에서 다시 읽을지는 프로토콜과 소비한 프레임 상태에 따라 판단합니다.
요청 단위 시간 제한에서는 보통 연결을 폐기하고 새 요청의 재시도 여부를 멱등성으로 결정합니다.
단계별 시간 제한과 절대 기한
DNS, connect, read에 각각 1초 예산을 새로 주면 각 단계의 대기가 누적됩니다. 단계별 상한만으로 사용자 요청의 1.5초 제한을 지킬 수는 없습니다.
사용자 응답 제한이 1.5초라면 시작 시 System.nanoTime() 기반 기한을 만들고 다음 단계마다 남은 밀리초를 전달합니다.
wall clock 변경에 영향을 받지 않도록 경과 시간에는 단조 시계를 사용합니다.
import java.time.Duration;
import java.util.concurrent.TimeUnit;
public final class RequestDeadline {
private final long endNanos;
RequestDeadline(Duration budget) {
if (budget.isNegative() || budget.isZero()) throw new IllegalArgumentException("budget");
endNanos = Math.addExact(System.nanoTime(), budget.toNanos());
}
int remainingMillis() throws java.net.SocketTimeoutException {
long nanos = endNanos - System.nanoTime();
if (nanos <= 0) throw new java.net.SocketTimeoutException("request deadline");
long roundedUp = Math.max(1, TimeUnit.NANOSECONDS.toMillis(nanos));
return Math.toIntExact(Math.min(roundedUp, Integer.MAX_VALUE));
}
public static void main(String[] args) throws Exception {
RequestDeadline deadline = new RequestDeadline(Duration.ofSeconds(1));
System.out.println("connectLimit=" + deadline.remainingMillis());
System.out.println("readLimit=" + deadline.remainingMillis());
}
}remainingMillis의 변환식과 경계
| 계산 시점의 남은 시간 | 원문 반환 또는 예외 | 해석 |
|---|---|---|
| nanos≤0 | SocketTimeoutException | 이미 기한이 지남 |
| 0ms 초과·1ms 미만 | 1 | 0으로 내림되어 무제한이 되는 것을 방지 |
| 예: 1.9ms | 1 | 원문은 정확한 올림이 아닌 내림 후 최소 1 적용 |
| Integer.MAX_VALUE ms 초과 | Integer.MAX_VALUE | API의 int 범위에 맞춰 상한 적용 |
- nanos≤0
- 원문 반환 또는 예외: SocketTimeoutException해석: 이미 기한이 지남
- 0ms 초과·1ms 미만
- 원문 반환 또는 예외: 1해석: 0으로 내림되어 무제한이 되는 것을 방지
- 예: 1.9ms
- 원문 반환 또는 예외: 1해석: 원문은 정확한 올림이 아닌 내림 후 최소 1 적용
- Integer.MAX_VALUE ms 초과
- 원문 반환 또는 예외: Integer.MAX_VALUE해석: API의 int 범위에 맞춰 상한 적용
현재 시각을 가정해 변환식을 비교한 값이며 실측 출력이 아닙니다. 최소 1ms와 호출 사이의 지연 때문에 이 반환값만으로 엄격한 전체 기한을 보장할 수 없습니다.
원문은 기한을 반환값으로 표현하는 부분입니다. 실제 연결·읽기 직전에 다시 계산하고, 전체 기한이 지나면 작업과 Socket을 종료하는 정책을 함께 연결해야 합니다.
시간 제한 이후 취소·자원 상한
사용자 요청이 시간 제한됐는데 백그라운드 작업이 Socket을 계속 기다리면 시스템 자원은 회복되지 않습니다.
호출 Future를 취소하고 Socket close로 블로킹 I/O를 깨우며, 작업이 종료됐는지 관찰합니다.
외부 라이브러리가 자체 스레드를 만들면 그 라이브러리 시간 제한도 함께 설정합니다.
시간 제한을 너무 짧게 잡으면 정상적인 꼬리 지연 요청을 실패시키고 재시도 폭풍을 만듭니다.
p95만 보고 상한을 두지 말고 p99, 네트워크 지역, 콜드 스타트, payload 크기를 고려합니다.
재시도 한 번의 남은 예산에는 백오프와 실제 응답 처리 시간도 포함합니다.
신용카드 의존성 격리 예시
세 결제사 중 하나가 응답하지 않을 때 그 대상 전용 연결과 동시성 제한이 없다면 주문 서버의 모든 작업자가 대기에 잠길 수 있습니다.
대상별 read 시간 제한, 제한된 동시 요청, 회로 차단, 대체 경로 결제사 정책을 둡니다.
시간 제한은 실패를 빨리 알릴 뿐 결제가 실제로 수행되지 않았다는 증거가 아닙니다.
결제 요청에는 멱등성 키를 넣고 시간 제한 뒤 상태 조회 API로 결과를 확인합니다.
무조건 다른 결제사에 재시도하면 이중 결제가 생길 수 있습니다.
기술 시간 제한을 업무 상태 UNKNOWN으로 번역하고 확정·실패·조사 중을 구분합니다.
연습 문제
1초 전체 기한에서 최대 세 번 시도하되 매 시도의 connect는 남은 시간의 25%, read는 나머지를 사용하세요.
백오프 뒤 남은 시간이 100ms 미만이면 재시도하지 않습니다.
정답과 해설
import java.time.Duration;
public final class RetryBudgetSolution {
record Attempt(int number, Duration connect, Duration read) {}
static Attempt allocate(int number, Duration remaining) {
if (number < 1 || number > 3) throw new IllegalArgumentException("attempt");
if (remaining.compareTo(Duration.ofMillis(100)) < 0) {
throw new IllegalStateException("insufficient budget");
}
Duration connect = remaining.dividedBy(4);
return new Attempt(number, connect, remaining.minus(connect));
}
public static void main(String[] args) {
System.out.println(allocate(1, Duration.ofSeconds(1)));
System.out.println(allocate(2, Duration.ofMillis(500)));
try {
allocate(3, Duration.ofMillis(50));
} catch (IllegalStateException expected) {
System.out.println("retry stopped");
}
}
}실제 구현은 시도 시작 직전에 기한에서 남은 값을 다시 계산해야 합니다.
이 함수는 그 순간 받은 예산을 단계별로 나누는 정책만 표현합니다.
대상별 과거 지연을 이용해 connect 비율을 조정할 수도 있습니다.
요청 시간 예산의 최종 규칙
네트워크 시간 제한은 숫자 하나가 아니라 연결과 응답의 서로 다른 블로킹 구분을 제한하는 규칙입니다.
명시적 connect·read 상한을 하나의 절대 기한에서 계산하고 시간 제한 뒤 Socket과 작업을 실제로 중단하며 업무 멱등성을 확인해야 느린 의존성이 서버 전체를 잠식하지 않습니다.