본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
25장 : TCP 네트워크

연결·읽기 시간 제한

운영체제 기본 연결 대기 대신 Socket.connect 제한을 지정하고, 연결 후 응답 지연에는 SO_TIMEOUT을 적용하며 여러 단계가 공유하는 절대 기한과 취소 정책을 구현합니다.

TCP 연결을 시도했는데 응답 패킷이 오지 않으면 운영체제가 자체 재전송과 기본 시간 제한을 적용합니다. 이 값은 플랫폼과 네트워크 설정에 따라 수십 초 이상일 수 있어 사용자 요청의 응답 예산보다 깁니다. 애플리케이션은 Socket.connect(endpoint, timeoutMillis)로 연결 단계의 상한을 명시해야 합니다.

연결 성공 뒤 서버가 응답을 보내지 않는 문제는 다른 시간 제한입니다. socket.setSoTimeout은 입력 read가 지정 시간 동안 데이터를 받지 못할 때 SocketTimeoutException을 발생시킵니다. 쓰기 시간 제한은 고전 Socket API에서 같은 방식으로 직접 제공되지 않으므로 상위 클라이언트, 비동기 채널, 작업 기한과 연결 종료를 검토합니다.

소켓 생성자 위임에 따른 요청 예산 초과

호스트와 포트를 받는 Socket 생성자는 내부에서 바로 connect를 시도하며 별도의 애플리케이션 시간 제한 인자를 받지 않습니다. 응답이 없는 주소에서 OS 기본값만큼 요청 스레드가 멈출 수 있습니다. 실행 환경마다 시간이 달라 장애 재현도 어렵습니다.

bad/ImplicitConnectTimeout.java
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을 재사용하지 말고 닫습니다. 0은 무제한을 뜻할 수 있으므로 구성값 검증에서 양수 범위와 요청 전체 예산 이하인지 확인합니다.

connect·read의 개별 상한

연결은 대상 서버에 도달하는 단계이고 read는 연결된 세션에서 다음 바이트를 기다리는 단계입니다. 같은 3초를 기계적으로 두기보다 DNS, connect, 요청 쓰기, 첫 바이트, 전체 본문이 공유하는 사용자 기한을 설계합니다. 대상별 지연 분포와 오류 예산이 각 단계 배분의 근거입니다.

src/TimedSocketClient.java
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");
    }
}

Duration을 밀리초 int로 바꿀 때 0으로 내림되거나 범위를 넘지 않게 구성 접점에서 제한합니다. 예제 main은 외부 연결을 만들지 않습니다. 실제 호출자는 SocketTimeoutException이 connect에서 났는지 read에서 났는지 단계 정보를 결과에 포함해야 합니다.

읽기 시간 제한 재현

연결은 즉시 수락하지만 일정 시간 아무 바이트도 보내지 않는 서버를 만들면 read 시간 제한을 안정적으로 시험할 수 있습니다. 서버 스레드와 클라이언트를 한 프로세스에 두고 리스너는 임시 포트를 사용합니다. 클라이언트 시간 제한은 서버 지연보다 짧게 둡니다.

src/ReadTimeoutProbe.java
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();
        }
    }
}

read 시간 제한은 Socket을 자동으로 닫지 않습니다. 프로토콜과 스트림 상태에 따라 같은 연결에서 다시 읽을 수 있지만 프레임 일부를 이미 소비했는지 모르면 안전한 복구가 어렵습니다. 요청 단위 시간 제한에서는 보통 연결을 폐기하고 새 요청의 재시도 여부를 멱등성으로 결정합니다.

단계별 시간 제한과 절대 기한

DNS 1초, connect 1초, read 1초를 순서대로 각각 시작하면 전체는 3초를 넘습니다. 사용자 응답 제한이 1.5초라면 시작 시 System.nanoTime() 기반 기한을 만들고 다음 단계마다 남은 밀리초를 전달합니다. wall clock 변경에 영향을 받지 않도록 경과 시간에는 단조 시계를 사용합니다.

src/RequestDeadline.java
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());
    }
}

나노초를 밀리초로 내릴 때 양수지만 1ms 미만인 값이 0이 되지 않도록 최소 1을 사용합니다. 정확한 올림이 필요하면 나머지를 확인해 더하지만 int 시간 제한의 실효 정밀도는 운영체제 스케줄링보다 작을 수 있습니다. 전체 기한을 넘기지 않는 것이 핵심입니다.

시간 제한 이후 취소·자원 상한

사용자 요청이 시간 제한됐는데 백그라운드 작업이 Socket을 계속 기다리면 시스템 자원은 회복되지 않습니다. 호출 Future를 취소하고 Socket close로 블로킹 I/O를 깨우며, 작업이 종료됐는지 관찰합니다. 외부 라이브러리가 자체 스레드를 만들면 그 라이브러리 시간 제한도 함께 설정합니다.

시간 제한을 너무 짧게 잡으면 정상적인 꼬리 지연 요청을 실패시키고 재시도 폭풍을 만듭니다. p95만 보고 상한을 두지 말고 p99, 네트워크 지역, 콜드 스타트, payload 크기를 고려합니다. 재시도 한 번의 남은 예산에는 백오프와 실제 응답 처리 시간도 포함합니다.

신용카드 의존성 격리 예시

세 결제사 중 하나가 응답하지 않을 때 그 대상 전용 연결과 동시성 제한이 없다면 주문 서버의 모든 작업자가 대기에 잠길 수 있습니다. 대상별 read 시간 제한, 제한된 동시 요청, 회로 차단, 대체 경로 결제사 정책을 둡니다. 시간 제한은 실패를 빨리 알릴 뿐 결제가 실제로 수행되지 않았다는 증거가 아닙니다.

결제 요청에는 멱등성 키를 넣고 시간 제한 뒤 상태 조회 API로 결과를 확인합니다. 무조건 다른 결제사에 재시도하면 이중 결제가 생길 수 있습니다. 기술 시간 제한을 업무 상태 UNKNOWN으로 번역하고 확정·실패·조사 중을 구분합니다.

연습 문제

1초 전체 기한에서 최대 세 번 시도하되 매 시도의 connect는 남은 시간의 25%, read는 나머지를 사용하세요. 백오프 뒤 남은 시간이 100ms 미만이면 재시도하지 않습니다.

정답과 해설
exercise/RetryBudgetSolution.java
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과 작업을 실제로 중단하며 업무 멱등성을 확인해야 느린 의존성이 서버 전체를 잠식하지 않습니다.