연결·읽기 시간 제한
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와 SO_TIMEOUT은 서로 다른 대기를 제한하며, write·flush에는 같은 방식의 제한이 없습니다.
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에서 났는지 단계 정보를 결과에 포함해야 합니다.
예시
SO_TIMEOUT=50ms이고 각 개별 read가 40ms마다 다음 바이트를 받는다고 가정하세요. 한 read는 50ms를 넘기지 않아도 여러 번 읽어 완성하는 프레임은 50ms보다 오래 걸릴 수 있습니다. Socket.setSoTimeout의 공식 계약은 소켓 입력의 개별 read 대기를 제한합니다. readUTF 전체나 쓰기·flush까지 한 번에 제한하는 값이 아니므로, 전체 응답 기한은 호출자가 별도로 검사하고 만료 때 연결·작업을 정리해야 합니다. 이 예시는 시간 관계의 가정이며 실제 느린 송신 서버를 실행한 관측값은 아닙니다.
읽기 시간 제한 재현
연결을 수락한 뒤 일정 시간 아무 바이트도 보내지 않는 서버로 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() 기반 기한을 만들고 다음 단계마다 남은 밀리초를 전달합니다.
시스템 시각 변경에 영향을 받지 않도록 경과 시간에는 단조 시계를 사용합니다.
아래 절대값 덧셈 예제에는 long 숫자 경계 문제가 남아 있습니다. 밀리초 변환을 확인한 뒤 이어지는 경과 시간 버전으로 기한 계산을 보완합니다.
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의 변환식과 경계
절대 기한 · 밀리초 변환
remainingMillis의 변환식과 경계
1.9ms도 1로 반환합니다. 원문은 올림이 아니라 내림한 뒤 최소 1ms를 적용합니다.
nanos≤0
- 원문 반환 또는 예외
- SocketTimeoutException
- 해석
- 이미 기한이 지남
0ms 초과·1ms 미만
- 원문 반환 또는 예외
- 1
- 해석
- 0으로 내림되어 무제한이 되는 것을 방지
예: 1.9ms
- 원문 반환 또는 예외
- 1
- 해석
- 원문은 정확한 올림이 아닌 내림 후 최소 1 적용
Integer.MAX_VALUE ms 초과
- 원문 반환 또는 예외
- Integer.MAX_VALUE
- 해석
- API의 int 범위에 맞춰 상한 적용
현재 시각을 가정해 변환식을 비교한 값이며 실측 출력이 아닙니다. 최소 1ms와 호출 사이의 지연 때문에 이 반환값만으로 엄격한 전체 기한을 보장할 수 없습니다.
원문은 기한을 반환값으로 표현하는 부분입니다. 실제 연결·읽기 직전에 다시 계산하고, 전체 기한이 지나면 작업과 Socket을 종료하는 정책을 함께 연결해야 합니다.
변수 이름 roundedUp과 달리 TimeUnit 변환은 밀리초 아래를 버린 뒤 최소 1을 적용합니다. 남은 1.9ms는 2가 아니라 1이며, 0.2ms도 무제한 0을 피하려고 1을 반환합니다. 따라서 정수 API의 최소 단위와 설정 이후 실행 지연까지 포함해 엄격한 전체 기한을 보장한다고 단정하지 않습니다.
nanoTime의 숫자 경계와 경과 시간
System.nanoTime()의 값은 날짜나 0에서 시작한 요청 시간이 아닙니다. 같은 JVM 안의 임의 원점을 기준으로 하므로 음수일 수도 있습니다. System.nanoTime 공식 API는 제한 시간 비교에서 현재값 - 시작값을 사용하도록 안내합니다.
앞의 Math.addExact(시작값, 예산)은 덧셈 넘침을 감지하지만 유효한 요청의 남은 시간을 복구하지는 않습니다. 시작값이 Long.MAX_VALUE - 5라면 10ns라는 작은 예산도 생성자에서 ArithmeticException을 만듭니다. 시작값과 양수 예산을 따로 저장하면 절대값 덧셈 없이 경과 시간을 뺄 수 있습니다.
아래는 같은 JVM에서 측정한 경과 구간이 2^63ns 미만이라는 공식 API의 조건을 사용합니다. Duration.toNanos()로 양수 long에 담을 수 없는 예산은 생성 때 실패합니다. 읽을 때 음수 경과 차분이 나오면 지원하는 구간을 벗어난 것으로 보고 만료시킵니다.
import java.net.SocketTimeoutException;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.LongSupplier;
public final class ElapsedRequestDeadline {
private final long startedAt;
private final long budgetNanos;
private final LongSupplier ticks;
ElapsedRequestDeadline(Duration budget) {
this(budget, System::nanoTime);
}
ElapsedRequestDeadline(Duration budget, LongSupplier ticks) {
if (budget.isNegative() || budget.isZero()) {
throw new IllegalArgumentException("budget");
}
this.budgetNanos = budget.toNanos();
this.ticks = Objects.requireNonNull(ticks);
this.startedAt = ticks.getAsLong();
}
int remainingMillis() throws SocketTimeoutException {
long elapsed = ticks.getAsLong() - startedAt;
if (elapsed < 0 || elapsed >= budgetNanos) {
throw new SocketTimeoutException("request deadline");
}
long remaining = budgetNanos - elapsed;
long millis = Math.max(1, TimeUnit.NANOSECONDS.toMillis(remaining));
return Math.toIntExact(Math.min(millis, Integer.MAX_VALUE));
}
public static void main(String[] args) throws Exception {
long start = Long.MAX_VALUE - 5;
try {
Math.addExact(start, 10);
} catch (ArithmeticException expected) {
System.out.println("absolute addition overflow");
}
AtomicLong clock = new AtomicLong(start);
var deadline = new ElapsedRequestDeadline(Duration.ofNanos(10), clock::get);
clock.set(Long.MIN_VALUE + 1);
System.out.println("elapsedNanos=" + (clock.get() - start));
System.out.println("remainingMillis=" + deadline.remainingMillis());
clock.set(Long.MIN_VALUE + 4);
try {
deadline.remainingMillis();
} catch (SocketTimeoutException expected) {
System.out.println("expired");
}
}
}main의 시계는 숫자 경계를 확인하기 위한 입력 값입니다. 운영체제 시계를 바꾸거나 실제로 경계까지 기다리는 실험이 아닙니다. 일반 생성자는 System::nanoTime을 사용합니다.
시작값 Long.MAX_VALUE - 5에서 Long.MIN_VALUE + 1까지의 차분은 long 연산에서 7ns입니다. 예산 10ns에서 7ns를 쓰면 3ns가 남아 최소 단위 정책에 따라 1ms를 반환합니다. 다음 값 Long.MIN_VALUE + 4에서는 10ns를 써서 만료합니다. 정수 밀리초의 내림·최소 1 정책은 앞의 그림과 같으며, 이 수정도 소켓 설정 이후의 실행 지연이나 전체 작업 종료 시간을 자동으로 제한하지는 않습니다.
예상 출력
absolute addition overflow
elapsedNanos=7
remainingMillis=1
expired확인 문제
예산 15ns, 시작값 Long.MAX_VALUE - 8에서 현재값이 Long.MIN_VALUE + 2일 때 경과 시간·남은 나노초·반환 밀리초를 계산하세요. 이후 현재값이 Long.MIN_VALUE + 6이면 반환하는지도 판단합니다. 별도로 예산 9ns, 시작값 -20, 현재값 -13인 경우와 비교하세요.
차분과 만료 조건 해설
첫 경과 시간은 11ns이므로 4ns가 남고 1ms를 반환합니다. 다음 값에서는 15ns를 사용했으므로 elapsed >= budgetNanos가 참이 되어 만료 예외를 던집니다. 별도 음수 원점에서는 -13 - (-20) = 7ns, 남은 시간은 2ns이며 반환값은 1ms입니다. 반환값 1ms를 남은 나노초가 모두 같다는 뜻으로 읽지 않습니다.
시간 제한 이후 취소·자원 상한
사용자 요청의 대기 시간이 초과됐는데 백그라운드 작업이 Socket을 계속 기다리면 시스템 자원은 회복되지 않습니다.
호출 Future를 취소하고 Socket close로 블로킹 I/O를 깨우며, 작업이 종료됐는지 관찰합니다.
외부 라이브러리가 자체 스레드를 만들면 그 라이브러리 시간 제한도 함께 설정합니다.
시간 제한을 너무 짧게 잡으면 정상적인 꼬리 지연 요청을 실패시키고 재시도 폭풍을 만듭니다.
p95만 보고 상한을 두지 말고 p99, 네트워크 지역, 콜드 스타트, 본문 데이터 크기를 고려합니다.
재시도 한 번의 남은 예산에는 백오프와 실제 응답 처리 시간도 포함합니다.
신용카드 의존성 격리 예시
세 결제사 중 하나가 응답하지 않을 때 그 대상 전용 연결과 동시성 제한이 없다면 주문 서버의 모든 작업자가 대기에 잠길 수 있습니다.
대상별 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 비율을 조정할 수도 있습니다.
확인 문제
새 시도 직전 남은 800ms를 allocate로 나누면 connect·read 예산은 각각 얼마인가요? 실제 connect에 180ms, 요청 쓰기에 70ms가 지났다고 가정하면 read 직전 전체 기한에서 남은 값은 무엇인가요? 한 번 더 50ms 백오프한 뒤 다음 시도를 할 수 있는지와 그때의 분배도 설명하세요.
확인 문제 해설
처음 배분은 connect=200ms, read=600ms입니다. connect와 쓰기의 250ms가 지난 뒤에는 800−180−70=550ms만 남으므로 원래 받은 read 600ms를 그대로 전체 제한으로 쓰지 않습니다. 다음 시도가 최대 세 번 안에 있고 백오프 뒤 남은 500ms라면 100ms 하한을 통과하며, 그때는 connect=125ms·read=375ms로 다시 배분합니다. 실제 대기 직전에 남은 값을 갱신해야 한다는 조건은 바뀌지 않습니다.
요청 시간 예산의 최종 규칙
네트워크 시간 제한은 숫자 하나가 아니라 연결과 응답의 서로 다른 블로킹 경계를 제한하는 규칙입니다.
명시적 connect·read 상한을 하나의 절대 기한에서 계산하고 시간 제한 뒤 Socket과 작업을 실제로 중단하며 업무 멱등성을 확인해야 느린 의존성이 서버 전체를 잠식하지 않습니다.