본문 바로가기

소켓 연결·읽기 시간 제한

connect의 시간 제한과 setSoTimeout으로 연결과 응답 대기에 상한을 두고, 여러 단계가 하나의 요청 기한을 나눠 쓰도록 남은 시간을 계산하는 방법을 배웁니다.

서버가 다른 서비스에 TCP로 요청을 보내는데 그 서비스가 멈춰 버렸다고 해 봅시다. 시간 제한이 없으면 요청을 보낸 스레드는 연결 단계에서 운영체제가 포기할 때까지 기다리고, 연결된 뒤 응답을 기다리는 단계에서는 끝없이 기다립니다. 그동안 스레드와 소켓은 붙잡혀 있고, 새 요청이 들어올 때마다 같은 자리에서 멈추는 스레드가 늘어납니다. 결국 멈춘 서비스 하나 때문에 그 서비스를 부르는 서버까지 응답하지 못하게 됩니다.

시간 제한(timeout)은 '끝없이 기다림'을 '정해진 시간 뒤의 실패'로 바꿔서, 다음에 무엇을 할지 프로그램이 정할 수 있게 해 줍니다. 그런데 소켓에는 성격이 다른 대기가 두 가지 있습니다. 하나는 연결이 만들어지기를 기다리는 connect이고, 다른 하나는 만들어진 연결에서 데이터가 오기를 기다리는 read입니다. 이 글에서는 두 대기에 각각 시간 제한을 거는 방법을 먼저 보고, 이어서 여러 단계가 하나의 요청 기한을 나눠 쓰게 하는 방법과 시간이 다 됐을 때 해야 할 정리를 다룹니다.

생성자로 연결하면 시간 제한을 걸 수 없다

지금까지 써 온 new Socket(host, port)는 생성자 안에서 바로 연결을 시도하는데, 연결 시간 제한을 받는 매개변수가 없습니다. 그래서 연결을 얼마나 기다릴지는 운영체제의 기본 동작에 맡겨집니다. 아래 connect()는 이 생성자를 그대로 감싼 메서드이고, 실제로 오래 멈출 수 있으므로 main에서는 부르지 않습니다.

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 대기는 실행하지 않습니다");
    }
}

대상이 패킷을 말없이 버리는 상황에서 이 메서드를 부르면, 호출한 스레드는 운영체제가 스스로 포기할 때까지 멈춰 있습니다. 연결 실패 원인 구분과 재시도에서 본 것처럼 Linux 기본 설정에서는 2분 남짓 걸리고, 이 시간은 운영체제와 커널 설정에 따라 달라서 환경마다 장애 모습도 달라집니다. 사용자가 몇 초 안에 응답을 기대하는 요청이라면 2분은 사실상 무한 대기와 같습니다.

해결 방법은 연결하지 않은 빈 소켓을 new Socket()으로 먼저 만들고, 연결할 주소와 시간 제한을 connect에 함께 넘기는 것입니다.

Socket socket = new Socket();
socket.connect(new InetSocketAddress(host, port), 500); // 연결에 최대 500ms

시간 제한의 단위는 밀리초이고, 0을 넘기면 무제한으로 기다리라는 뜻이 됩니다. JDK 25 문서는 연결에 실패하거나 시간이 다 되면 소켓을 닫는다고 명시하므로, 실패한 Socket 객체로 다시 연결할 수는 없고 다음 시도에는 새 객체를 만들어야 합니다. 앞 글에서 본 것처럼 이 시간 제한은 TCP 연결에만 적용되며, InetSocketAddress를 만들 때 끝난 DNS 조회 시간은 포함하지 않습니다.

연결 시간 초과를 직접 만들어 보기

연결 시간 초과를 보려면 연결 요청(SYN)에 아무 대답도 하지 않는 상대가 필요합니다. TCP 첫 연결과 다중 클라이언트 서버의 연결 대기열 절에서 본 것처럼, 운영체제는 애플리케이션이 accept()를 부르기 전에도 연결 맺기를 끝내 두고 그 연결을 대기열(backlog)에 쌓아 둡니다. 그리고 Linux는 이 대기열이 가득 찬 서버 소켓에 새 SYN이 오면 응답하지 않고 버립니다. 대기열 길이를 1로 주고 accept()를 한 번도 부르지 않는 서버를 만들면, 대답하지 않는 상대를 루프백에서 흉내 낼 수 있습니다.

src/ConnectTimeoutProbe.java
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.List;

public final class ConnectTimeoutProbe {
    public static void main(String[] args) throws Exception {
        InetAddress loopback = InetAddress.getLoopbackAddress();
        try (var server = new ServerSocket(0, 1, loopback)) { // 대기열 1, accept()는 부르지 않는다
            var target = new InetSocketAddress(loopback, server.getLocalPort());
            List<Socket> clients = new ArrayList<>();
            try {
                for (int i = 1; i <= 3; i++) {
                    Socket client = new Socket();
                    clients.add(client);
                    long start = System.nanoTime();
                    try {
                        client.connect(target, 300);
                        System.out.println("client " + i + ": connected");
                    } catch (SocketTimeoutException e) {
                        long millis = (System.nanoTime() - start) / 1_000_000;
                        System.out.println("client " + i + ": " + e + " (" + millis + "ms)");
                    }
                }
            } finally {
                for (Socket client : clients) client.close();
            }
        }
    }
}

각 클라이언트는 연결에 최대 300ms를 주고, 시간 초과가 나면 실제로 걸린 시간을 밀리초로 함께 찍습니다. Linux에서 실행한 결과입니다.

client 1: connected
client 2: connected
client 3: java.net.SocketTimeoutException: Connect timed out (300ms)

처음 두 클라이언트는 서버가 accept()를 부르지 않았는데도 연결에 성공합니다. 연결 맺기는 운영체제가 끝내므로, connect가 성공했다는 것은 상대 운영체제가 연결을 받아 대기열에 넣었다는 뜻일 뿐 서버 프로그램이 연결을 꺼내 갔다는 뜻은 아닙니다. Linux는 요청한 길이보다 하나 더 많은 연결을 대기열에 받아 주기 때문에 길이가 1인데도 두 개가 들어갔습니다. 세 번째 클라이언트의 SYN은 가득 찬 대기열 때문에 버려지고, 클라이언트 운영체제가 SYN을 다시 보내며 기다리는 사이 300ms가 지나 자바가 SocketTimeoutException을 던집니다.

괄호 안의 시간은 실행할 때마다 1ms 안팎으로 달라질 수 있습니다. 대기열이 찼을 때의 동작과 실제로 받아 주는 연결 수는 운영체제마다 다르므로, 다른 운영체제에서는 세 번째 연결이 거부되거나 성공할 수도 있습니다. 같은 프로그램에서 connect(target, 300) 대신 connect(target)을 쓰면, Linux 기본 설정에서는 세 번째 클라이언트가 2분 남짓 기다린 뒤 ConnectException: Connection timed out으로 끝납니다.

read에 시간 제한 걸기

연결이 된 뒤에도 기다림은 남아 있습니다. 상대가 연결은 받아 놓고 아무것도 보내지 않으면 read는 데이터가 올 때까지 멈춰 있는데, 기본값으로는 그 시간에 끝이 없습니다. socket.setSoTimeout(밀리초)로 SO_TIMEOUT 옵션을 켜면, 소켓의 입력 스트림에서 read를 한 번 부를 때마다 그 시간까지만 기다리고 SocketTimeoutException을 던집니다. 기본값인 0은 무제한을 뜻하며, 옵션은 read에 들어가기 전에 설정해야 효과가 있습니다.

아래 프로그램의 서버는 연결을 받은 뒤 250ms 동안 아무것도 보내지 않다가 1바이트를 보냅니다. 클라이언트는 처음에 50ms만 기다리고, 시간 초과가 난 뒤 같은 소켓에서 1초까지 기다리며 한 번 더 읽습니다.

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 {
        InetAddress loopback = InetAddress.getLoopbackAddress();
        try (var server = new ServerSocket(0, 1, loopback)) {
            Thread delayed = Thread.startVirtualThread(() -> {
                try (Socket accepted = server.accept()) {
                    Thread.sleep(250);                     // 연결은 받았지만 250ms 동안 침묵
                    accepted.getOutputStream().write(1);
                } catch (Exception e) {
                    System.err.println("server: " + e);
                }
            });

            try (var client = new Socket(loopback, server.getLocalPort())) {
                client.setSoTimeout(50);
                try {
                    System.out.println("first read: " + client.getInputStream().read());
                } catch (SocketTimeoutException e) {
                    System.out.println("first read: " + e);
                }
                client.setSoTimeout(1000);
                System.out.println("second read: " + client.getInputStream().read());
            }
            delayed.join();
        }
    }
}

InputStream.read()는 받은 바이트 하나를 0부터 255 사이의 int로 돌려줍니다. 그래서 서버가 write(1)로 보낸 바이트는 출력에 숫자 1로 찍힙니다.

first read: java.net.SocketTimeoutException: Read timed out
second read: 1

첫 번째 read는 50ms 동안 아무것도 받지 못해 Read timed out으로 끝납니다. 두 번째 줄은 시간 초과가 연결을 끊지 않는다는 것을 보여 줍니다. Socket.setSoTimeout 공식 문서도 SO_TIMEOUT이 만료돼도 소켓은 여전히 유효하다고 설명하며, 실제로 같은 소켓에서 다시 읽자 서버가 250ms 시점에 보낸 1이 도착했습니다. 서버의 침묵(250ms)이 클라이언트의 시간 제한(50ms)보다 충분히 길어서 결과는 거의 항상 이렇게 나오지만, 클라이언트 스레드가 어떤 이유로 250ms 넘게 늦게 읽기 시작한다면 첫 read가 곧바로 1을 받을 수도 있습니다.

시간 초과 뒤에 같은 연결을 계속 써도 되는지는 그때까지 무엇을 읽었는지에 달려 있습니다. 이 예제처럼 아직 한 바이트도 받지 않았다면 다시 읽어도 안전합니다. 하지만 길이를 먼저 보내는 프로토콜에서 길이만 읽고 본문을 기다리다 시간이 초과됐다면, 스트림은 메시지 한가운데에 멈춰 있습니다. 이 상태에서 다음 요청을 보내면 남은 본문을 새 응답으로 잘못 읽게 되므로, 요청과 응답을 주고받는 프로토콜에서는 보통 시간 초과가 난 연결을 닫고 새로 연결합니다.

두 시간 제한을 함께 쓰는 클라이언트

연결과 읽기 시간 제한을 모두 갖춘 요청 함수를 만들어 보겠습니다. main은 받은 문자열 앞에 echo: 를 붙여 돌려주는 서버를 가상 스레드로 띄우고, 그 서버에 요청을 한 번 보냅니다.

src/TimedSocketClient.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.time.Duration;

public final class TimedSocketClient {
    static String request(InetSocketAddress endpoint, String message,
                          Duration connectTimeout, Duration readTimeout) throws IOException {
        try (var socket = new Socket()) {
            socket.connect(endpoint, toMillis(connectTimeout));
            socket.setSoTimeout(toMillis(readTimeout));
            var output = new DataOutputStream(socket.getOutputStream());
            var input = new DataInputStream(socket.getInputStream());
            output.writeUTF(message);
            output.flush();
            return input.readUTF();
        }
    }

    // 0은 '무제한'이라는 뜻이므로 1ms보다 짧은 값은 받지 않는다.
    static int toMillis(Duration timeout) {
        long millis = timeout.toMillis();
        if (millis < 1) throw new IllegalArgumentException("timeout must be >= 1ms: " + timeout);
        return Math.toIntExact(millis);
    }

    public static void main(String[] args) throws Exception {
        InetAddress loopback = InetAddress.getLoopbackAddress();
        try (var server = new ServerSocket(0, 10, loopback)) {
            Thread echo = Thread.startVirtualThread(() -> {
                try (Socket accepted = server.accept()) {
                    var input = new DataInputStream(accepted.getInputStream());
                    var output = new DataOutputStream(accepted.getOutputStream());
                    output.writeUTF("echo: " + input.readUTF());
                } catch (IOException e) {
                    System.err.println("server: " + e);
                }
            });

            var endpoint = new InetSocketAddress(loopback, server.getLocalPort());
            System.out.println(request(endpoint, "hello", Duration.ofMillis(500), Duration.ofSeconds(1)));
            echo.join();
        }
        try {
            toMillis(Duration.ofNanos(500_000));
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}

첫 줄은 request의 반환값이고, 둘째 줄은 toMillis가 1ms보다 짧은 시간을 거부하며 던진 예외의 메시지입니다. Duration의 toString()은 ISO 8601 형식을 쓰므로 500마이크로초는 PT0.0005S로 찍힙니다.

echo: hello
timeout must be >= 1ms: PT0.0005S

request는 연결에 최대 500ms, 응답의 각 read에 최대 1초를 주고, 첫 줄은 서버가 돌려준 응답입니다. 소켓을 try-with-resources로 열었으므로 시간 초과든 다른 예외든 메서드를 빠져나갈 때 소켓이 닫히고, 소켓을 닫으면 거기서 얻은 두 스트림도 함께 닫힙니다.

toMillis는 작은 함정을 막습니다. Duration.toMillis()는 밀리초 아래를 버리므로 0.5ms짜리 Duration은 0이 되고, 그 0을 connect나 setSoTimeout에 넘기면 시간 제한이 오히려 무제한으로 바뀝니다. 그래서 1ms보다 짧은 값은 설정 단계에서 거부하고, int 범위를 넘는 값은 Math.toIntExact가 예외로 알려 주게 했습니다. 두 번째 줄의 PT0.0005S는 0.5ms를 ISO 8601 형식으로 쓴 Duration의 문자열 표현입니다.

두 설정이 각각 무엇을 제한하고 무엇을 제한하지 못하는지 그림으로 정리하면 다음과 같습니다.

소켓 시간 제한이 적용되는 연산

소켓 시간 제한이 적용되는 연산

소켓 시간 제한 · 연결과 읽기

소켓 시간 제한이 적용되는 연산

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은 유효하지만, 프레임 일부를 소비했는지에 따라 재사용 판단이 달라집니다.

connect의 시간 제한은 TCP 연결 수립에만 적용되므로, 그 앞의 DNS 조회나 요청 전체에 걸리는 시간은 따로 관리해야 합니다. SO_TIMEOUT은 입력 스트림의 read 한 번에만 적용됩니다. readUTF는 안에서 길이 2바이트와 본문을 읽느라 read를 여러 번 부를 수 있으므로, readUTF 전체가 1초 안에 끝난다는 보장은 없습니다. 이 점은 바로 다음 절에서 실제로 확인합니다.

쓰기에는 이런 시간 제한이 아예 없습니다. 상대가 데이터를 읽지 않아 송신 버퍼가 가득 차면 write와 flush는 버퍼에 자리가 날 때까지 멈추는데, 고전 Socket API에는 이를 제한하는 설정이 없고 SO_TIMEOUT도 쓰기에는 적용되지 않습니다. 멈춘 쓰기를 풀려면 다른 스레드에서 소켓을 닫거나, java.net.http.HttpClient처럼 연결 시간 제한(connectTimeout)과 요청마다 응답을 기다릴 시간(timeout)을 따로 설정할 수 있는 상위 클라이언트를 씁니다.

SO_TIMEOUT은 read 한 번마다 따로 적용된다

SO_TIMEOUT을 100ms로 설정했는데, 서버가 HELLO 다섯 글자를 40ms마다 한 바이트씩 보낸 뒤 연결을 닫는다고 해 봅시다. read 하나하나가 기다리는 시간을 따라가 보면 다음과 같습니다.

시각일어나는 일이번 read가 기다린 시간
40msH 도착, 첫 번째 read 반환40ms
80msE 도착, 두 번째 read 반환40ms
120msL 도착, 세 번째 read 반환40ms
160msL 도착, 네 번째 read 반환40ms
200msO 도착 후 서버가 닫음, 다섯 번째 read 반환40ms
200ms여섯 번째 read가 곧바로 -1 반환거의 0

어느 read도 100ms를 기다리지 않았으므로 시간 초과는 한 번도 나지 않습니다. 그런데 메시지 전체를 받는 데는 200ms, 즉 설정한 시간 제한의 두 배가 걸렸습니다. 바이트를 아주 조금씩 흘려보내는 서버는 일부러든 네트워크 문제 때문이든 SO_TIMEOUT보다 훨씬 오래 연결을 붙잡아 둘 수 있습니다. 결국 SO_TIMEOUT은 '바이트와 바이트 사이에 허용하는 최대 침묵'이지 '응답 전체의 시간 제한'이 아닙니다.

사용자 쪽에서 보면 문제는 더 커집니다. 사용자는 이름 해석, 연결, 요청 쓰기, 응답 읽기를 모두 합친 시간을 기다립니다. 단계마다 1초씩 새로 주면 각 단계가 1초 가까이 걸릴 때 전체는 몇 초가 되고, 여러 번 읽는 단계는 앞의 표처럼 1초보다도 길어질 수 있습니다. 그래서 요청을 시작할 때 전체 기한을 한 번 정하고, 단계마다 그 기한까지 남은 시간만 넘겨 주는 방식이 필요합니다.

요청 전체에 기한 하나 두기

요청 기한을 나타내는 작은 클래스를 만들겠습니다. 생성할 때 시작 시각과 전체 예산을 기억해 두고, remainingMillis()를 부를 때마다 지난 시간을 빼서 남은 시간을 밀리초로 돌려줍니다. 남은 시간이 없으면 다음 단계를 시작하지 않도록 SocketTimeoutException을 던집니다.

시각은 System.nanoTime()으로 잽니다. System.currentTimeMillis()는 벽시계 시각이라서 시스템 시각을 맞추면 앞이나 뒤로 건너뛸 수 있는데, 그러면 남은 시간이 갑자기 늘거나 음수가 됩니다. 반면 nanoTime()은 공식 문서의 표현대로 경과 시간을 재는 용도로만 쓰는 값이고, 벽시계 시각과는 관계가 없습니다.

src/RequestDeadline.java
import java.net.SocketTimeoutException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;

public final class RequestDeadline {
    private final long startNanos;
    private final long budgetNanos;

    RequestDeadline(Duration budget) {
        if (budget.isNegative() || budget.isZero()) {
            throw new IllegalArgumentException("budget must be positive: " + budget);
        }
        budgetNanos = budget.toNanos();
        startNanos = System.nanoTime();
    }

    int remainingMillis() throws SocketTimeoutException {
        long elapsed = System.nanoTime() - startNanos;
        if (elapsed >= budgetNanos) throw new SocketTimeoutException("request deadline exceeded");
        long millis = Math.max(1, TimeUnit.NANOSECONDS.toMillis(budgetNanos - elapsed));
        return (int) Math.min(millis, Integer.MAX_VALUE);
    }

    public static void main(String[] args) throws Exception {
        var deadline = new RequestDeadline(Duration.ofSeconds(1));
        System.out.println("connect limit=" + deadline.remainingMillis() + "ms");
        Thread.sleep(300); // 연결과 요청 쓰기에 300ms가 걸렸다고 하자
        System.out.println("read limit=" + deadline.remainingMillis() + "ms");
        Thread.sleep(800); // 응답을 기다리다 1초를 넘겼다
        try {
            deadline.remainingMillis();
        } catch (SocketTimeoutException e) {
            System.out.println(e);
        }
    }
}

실행 결과의 한 예입니다. 숫자는 실행할 때마다 몇 ms씩 달라집니다.

connect limit=999ms
read limit=678ms
java.net.SocketTimeoutException: request deadline exceeded

첫 줄은 기한을 만든 직후라 1초가 거의 그대로 남았지만, 그 사이에도 시간이 조금 흘렀고 밀리초 아래는 버리므로 999가 나옵니다. 둘째 줄은 300ms를 쉰 뒤인데 700이 아니라 그보다 조금 작은 값이 나왔습니다. 두 호출 사이에 흐른 시간이 300ms보다 조금 길었기 때문인데, 첫 줄을 출력하는 데 걸린 시간도 여기에 들어갑니다. 기한은 네트워크 대기뿐 아니라 내 코드가 쓴 시간까지 포함한다는 점을 잊지 마세요. 마지막 줄은 모두 합쳐 1초를 넘긴 뒤라서 남은 시간을 묻자마자 예외가 납니다.

밀리초로 바꾸는 규칙에는 각각 이유가 있습니다.

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와 호출 사이의 지연 때문에 이 반환값만으로 엄격한 전체 기한을 보장할 수 없습니다.

TimeUnit.NANOSECONDS.toMillis는 밀리초 아래를 버리므로 1.9ms가 남았다면 1을 돌려줍니다. 버림을 쓰면 실제로 남은 시간보다 길게 기다리라고 알려 주는 일이 없습니다. 다만 1ms보다 적게 남았을 때 그대로 버리면 0이 되고, 0은 connect와 setSoTimeout에서 무제한을 뜻하므로 기한이 거의 끝난 순간에 오히려 끝없이 기다리게 됩니다. 그래서 Math.max(1, ...)로 최소 1ms를 보장하고, 두 API가 int를 받으므로 Integer.MAX_VALUE에서 값을 자릅니다.

이 규칙 때문에 기한을 아주 조금 넘길 수는 있습니다. 1ms보다 적게 남았을 때도 1ms를 주고, 남은 시간을 계산한 뒤 실제로 read에 들어가기까지도 시간이 흐르기 때문입니다. 밀리초 단위의 엄격한 보장은 아니지만, 단계마다 새 예산을 주는 방식과 비교하면 전체 대기 시간을 기한 근처로 묶어 둘 수 있습니다.

느린 서버로 확인하기

앞의 표에서 본 느린 서버를 실제로 만들어, SO_TIMEOUT만 쓸 때와 요청 기한을 쓸 때를 비교해 보겠습니다. 서버는 연결을 두 번 차례로 받아 각각 HELLO를 40ms마다 한 바이트씩 보냅니다. 첫 번째 클라이언트는 SO_TIMEOUT을 100ms로 고정하고, 두 번째 클라이언트는 연결 전에 100ms짜리 RequestDeadline을 만든 뒤 connect와 매 read 직전에 남은 시간을 다시 설정합니다.

src/SlowSenderProbe.java
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

public final class SlowSenderProbe {
    public static void main(String[] args) throws Exception {
        InetAddress loopback = InetAddress.getLoopbackAddress();
        try (var server = new ServerSocket(0, 10, loopback)) {
            Thread sender = Thread.startVirtualThread(() -> {
                for (int i = 0; i < 2; i++) {                 // 연결 두 개를 차례로 받는다
                    try (Socket accepted = server.accept()) {
                        for (byte b : "HELLO".getBytes(StandardCharsets.US_ASCII)) {
                            Thread.sleep(40);                 // 40ms마다 한 바이트
                            accepted.getOutputStream().write(b);
                        }
                    } catch (IOException | InterruptedException e) {
                        // 클라이언트가 먼저 끊으면 쓰기가 실패할 수 있다
                    }
                }
            });
            var endpoint = new InetSocketAddress(loopback, server.getLocalPort());

            // 1) read 한 번마다 100ms
            try (var client = new Socket()) {
                client.connect(endpoint, 1000);
                client.setSoTimeout(100);
                long start = System.nanoTime();
                String text = readAll(client.getInputStream());
                System.out.println("SO_TIMEOUT only: " + text + " in " + since(start) + "ms");
            }

            // 2) 연결부터 응답 끝까지 전체 100ms
            var deadline = new RequestDeadline(Duration.ofMillis(100));
            long start = System.nanoTime();
            var text = new StringBuilder();
            try (var client = new Socket()) {
                client.connect(endpoint, deadline.remainingMillis());
                InputStream input = client.getInputStream();
                while (true) {
                    client.setSoTimeout(deadline.remainingMillis()); // read마다 남은 시간으로
                    int b = input.read();
                    if (b == -1) break;
                    text.append((char) b);
                }
                System.out.println("deadline: " + text + " in " + since(start) + "ms");
            } catch (SocketTimeoutException e) {
                System.out.println("deadline: got " + text + ", then " + e.getMessage()
                        + " at " + since(start) + "ms");
            }
            sender.join();
        }
    }

    static String readAll(InputStream input) throws IOException {
        var text = new StringBuilder();
        for (int b; (b = input.read()) != -1; ) text.append((char) b);
        return text.toString();
    }

    static long since(long startNanos) {
        return (System.nanoTime() - startNanos) / 1_000_000;
    }
}

출력은 클라이언트마다 한 줄이고, 받은 글자와 시작부터 그때까지 걸린 시간을 함께 찍습니다. 두 번째 클라이언트는 RequestDeadline을 쓰므로 두 파일을 함께 컴파일합니다.

javac RequestDeadline.java SlowSenderProbe.java
java SlowSenderProbe

걸린 시간은 스레드가 언제 실행되느냐에 따라 실행할 때마다 몇 밀리초씩 달라집니다. 실행 결과의 한 예입니다.

SO_TIMEOUT only: HELLO in 215ms
deadline: got HE, then Read timed out at 99ms

첫 줄은 앞의 표 그대로입니다. 매 read는 40ms 남짓만 기다렸으므로 100ms 시간 제한에 한 번도 걸리지 않았고, HELLO 전체를 받는 데는 200ms가 넘게 걸렸습니다. 둘째 줄에서는 H와 E가 40ms와 80ms 무렵에 도착한 뒤, 세 번째 read 직전에 남은 시간이 20ms 남짓이라 그만큼만 SO_TIMEOUT이 설정됩니다. L은 120ms 무렵에야 오므로 그 전에 시간이 다 되어 Read timed out이 나고, 전체 대기는 100ms 안에서 끝납니다. 99ms로 찍힌 것은 남은 시간을 밀리초로 바꿀 때 버림을 했기 때문입니다.

시각은 실행할 때마다 몇 ms씩 달라집니다. 드물게는 read에 들어가기 전에 이미 기한이 지나 remainingMillis()가 request deadline exceeded를 던질 수도 있는데, 둘 다 SocketTimeoutException이고 뜻도 같습니다.

더 깊이: nanoTime 값이 넘쳐도 기한 계산이 맞는 이유

System.nanoTime()이 돌려주는 값은 날짜도 아니고 0에서 시작하는 시간도 아닙니다. 공식 문서에 따르면 JVM마다 임의로 정한 원점으로부터 흐른 나노초이고, 원점이 미래일 수도 있어서 값이 음수일 수도 있습니다. 값 자체에는 의미가 없고 같은 JVM에서 얻은 두 값의 차이만 의미가 있습니다. 그리고 long이므로 값이 Long.MAX_VALUE를 넘으면 Long.MIN_VALUE 쪽으로 넘어가 음수가 됩니다.

그래서 System.nanoTime 공식 문서는 시간 초과를 검사할 때 System.nanoTime() >= startTime + timeoutNanos처럼 값끼리 비교하지 말고 System.nanoTime() - startTime >= timeoutNanos처럼 차이로 비교하라고 안내합니다. 2의 보수로 하는 long 뺄셈은 중간에 값이 넘쳤더라도 실제 차이가 2⁶³나노초(약 292년)보다 작기만 하면 정확한 차이를 돌려주기 때문입니다. 시작값을 일부러 long의 최댓값 가까이에 두고 확인해 보겠습니다.

src/NanoTimeOverflow.java
public final class NanoTimeOverflow {
    public static void main(String[] args) {
        long start = Long.MAX_VALUE - 5; // nanoTime()이 이 값을 돌려줬다고 하자
        long timeout = 10;               // 제한 시간 10ns
        long early = start + 2;          // 2ns 뒤
        long late = start + 12;          // 12ns 뒤: long 범위를 넘어 음수가 된다

        System.out.println("late = " + late);
        System.out.println("early >= start + timeout : " + (early >= start + timeout));
        System.out.println("early - start >= timeout : " + (early - start >= timeout));
        System.out.println("late - start >= timeout  : " + (late - start >= timeout)
                + " (late - start = " + (late - start) + ")");
        try {
            Math.addExact(start, timeout);
        } catch (ArithmeticException e) {
            System.out.println("addExact: " + e);
        }
    }
}

early는 제한 시간 전이고 late는 제한 시간 뒤입니다. 그러니 맞게 판단하는 비교라면 early 쪽에서는 false, late 쪽에서는 true가 나와야 합니다.

late = -9223372036854775802
early >= start + timeout : true
early - start >= timeout : false
late - start >= timeout  : true (late - start = 12)
addExact: java.lang.ArithmeticException: long overflow

첫 줄처럼 12ns 뒤의 값은 이미 음수로 넘어갔습니다. 둘째 줄의 값 비교는 start + timeout이 넘쳐 음수가 되는 바람에, 2ns밖에 지나지 않았는데도 시간이 다 됐다고 잘못 판단합니다. 셋째와 넷째 줄의 차이 비교는 두 경우 모두 맞는 답을 냅니다. late가 음수인데도 late - start는 정확히 12입니다.

마지막 줄은 넘침을 막겠다고 Math.addExact를 쓰는 방법도 답이 아니라는 것을 보여 줍니다. addExact는 넘침을 예외로 바꿀 뿐이라서, 아무 문제 없는 2ns짜리 대기를 오류로 만들어 버립니다. 앞의 RequestDeadline은 시작값과 예산을 따로 저장하고 뺄셈만 하므로 이런 문제가 없습니다. 실제 nanoTime() 값이 넘침 근처에 있을 일은 드물지만, 원점이 임의이므로 코드가 그런 가정에 기대지 않는 편이 안전합니다.

시간이 다 됐을 때 정리하기

시간 초과 예외를 받았다고 일이 끝나지는 않습니다. 요청과 응답을 주고받는 연결이라면 앞에서 본 것처럼 메시지 중간에서 멈췄을 수 있으므로 연결을 닫고, 다시 시도할지는 연결 실패 원인 구분과 재시도에서 본 멱등성 기준으로 정합니다. 시간 초과는 기다림을 멈췄다는 뜻이지 상대가 요청을 처리하지 않았다는 뜻이 아니기 때문입니다.

호출한 쪽만 포기하고 실제 작업은 계속 남는 경우도 조심해야 합니다. 예를 들어 소켓 작업을 다른 스레드에 맡기고 Future.get(timeout)으로 기다리다 시간이 초과되면, 호출한 쪽은 다음 일로 넘어가지만 작업자 스레드는 여전히 소켓의 read에서 멈춰 스레드와 소켓을 붙잡고 있습니다. 이때 future.cancel(true)는 작업자 스레드를 인터럽트하는데, 플랫폼 스레드에서는 인터럽트로 소켓 read가 깨어나지 않습니다. JDK 문서에 따르면 소켓 입출력이 인터럽트에 반응하는 것은 가상 스레드이거나 SocketChannel에서 얻은 소켓일 때뿐입니다. 어떤 스레드든 확실하게 깨우는 방법은 다른 스레드에서 그 소켓을 닫는 것으로, 소켓을 닫으면 그 소켓에서 입출력 중이던 스레드는 SocketException을 받습니다.

JDK 21에서 read에 멈춘 스레드를 두 방법으로 깨워 보면 다음과 같습니다.

멈춘 스레드interrupt()다른 스레드에서 socket.close()
플랫폼 스레드깨어나지 않고 계속 기다림SocketException: Socket closed
가상 스레드소켓이 닫히며 SocketException: Closed by interruptSocketException: Socket closed

그래서 작업을 취소하는 코드는 작업이 쓰는 소켓의 참조를 갖고 있다가 직접 닫을 수 있어야 하고, 닫은 뒤 작업이 실제로 끝났는지도 확인해야 합니다. 외부 라이브러리가 자기 스레드와 연결을 따로 만든다면 그 라이브러리의 시간 제한도 함께 설정해야 합니다. 그렇지 않으면 내 코드는 기다리기를 멈췄는데 라이브러리 안의 스레드는 계속 기다릴 수 있습니다.

시간 제한 값 정하기와 결제 요청의 시간 초과

시간 제한은 짧을수록 좋은 값이 아닙니다. 너무 짧으면 조금 늦을 뿐 정상인 요청까지 실패로 처리하고, 그 요청들이 한꺼번에 재시도되면서 대상 서버의 부하를 더 키웁니다. 그래서 평균이나 p95가 아니라 p99(전체 요청의 99%가 끝나는 시간) 같은 느린 쪽 응답 시간을 보고, 네트워크 거리, 첫 요청이 유난히 느린 콜드 스타트, 주고받는 데이터 크기를 함께 고려해 정합니다. 재시도까지 한다면 재시도 사이의 백오프 시간과 응답을 처리하는 시간도 전체 기한 안에 들어가야 합니다.

시간 제한이 꼭 필요한 이유는 결제처럼 외부 서비스에 의존하는 기능에서 잘 드러납니다. 주문 서버가 세 결제사에 요청을 보내는데 그중 하나가 응답하지 않는다고 해 봅시다. 결제사별 시간 제한과 동시 요청 수 제한이 없다면 주문 서버의 작업자 스레드가 모두 그 결제사의 응답을 기다리느라 묶이고, 멀쩡한 두 결제사로 가는 주문까지 처리하지 못합니다. 그래서 결제사마다 읽기 시간 제한을 두고, 한 결제사에 동시에 보내는 요청 수를 제한하고, 계속 실패하는 결제사는 한동안 호출을 멈추는 회로 차단기(circuit breaker)를 둡니다.

그런데 결제 요청이 시간 초과됐다는 것은 응답을 제때 받지 못했다는 뜻일 뿐, 결제가 이루어지지 않았다는 뜻이 아닙니다. 결제사는 요청을 받아 이미 승인했는데 응답만 늦었을 수도 있습니다. 이 상태에서 바로 다른 결제사로 다시 요청하면 이중 결제가 생길 수 있습니다. 그래서 결제 요청마다 고유한 멱등성 키(idempotency key)를 붙여 결제사가 같은 요청의 중복을 알아보게 하고, 시간 초과 뒤에는 결제 상태 조회 API로 실제 결과를 확인합니다. 업무 상태도 성공과 실패 두 가지가 아니라 '확인 중'을 따로 두어, 기술적인 시간 초과를 결과를 아직 모르는 상태로 기록합니다.

연습 문제

  1. 전체 기한 1초 안에서 최대 세 번 시도하는 클라이언트가 있습니다. 각 시도는 시작할 때 남은 시간의 25%를 connect에, 나머지를 read에 씁니다. 시도 번호와 남은 시간을 받아 두 값을 나눠 주는 allocate 메서드를 작성하세요. 시도 번호가 1부터 3 사이가 아니면 IllegalArgumentException을, 남은 시간이 100ms 미만이면 재시도하지 않도록 IllegalStateException을 던집니다.
  2. 새 시도 직전에 800ms가 남아 1번의 allocate로 나누었습니다. connect와 read 예산은 각각 얼마인가요? 실제로는 connect에 180ms, 요청 쓰기에 70ms가 걸린 뒤 read 직전에 연결이 끊겨 이번 시도가 실패했다고 합시다. 그 순간 전체 기한에는 얼마가 남았고, 50ms 백오프 뒤 다음 시도를 할 수 있다면 예산은 어떻게 나뉘나요?
  3. SO_TIMEOUT이 100ms인 소켓에서, 서버가 10바이트짜리 메시지를 50ms마다 한 바이트씩 보냅니다. DataInputStream.readFully로 10바이트를 읽으면 시간 초과가 나나요? 메시지 전체를 받는 데 걸리는 시간을 300ms 안으로 묶으려면 어떻게 해야 하나요?
  4. RequestDeadline.remainingMillis()는 0.4ms가 남았을 때 0이 아니라 1을 돌려줍니다. 0을 돌려주어 그 값을 setSoTimeout에 넘기면 어떤 일이 생기는지 설명하세요.
1번 해설

시도 번호를 먼저 검사하고, 남은 시간이 하한보다 작으면 예외로 재시도를 막습니다. connect 예산은 dividedBy(4)로 구하고, read에는 남은 시간에서 그만큼을 뺀 값을 줍니다.

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");
        }
    }
}

실행 결과:

Attempt[number=1, connect=PT0.25S, read=PT0.75S]
Attempt[number=2, connect=PT0.125S, read=PT0.375S]
retry stopped

1초는 250ms와 750ms로, 500ms는 125ms와 375ms로 나뉩니다. 세 번째 시도는 50ms밖에 남지 않아 100ms 하한에 걸립니다. 이 함수는 받은 순간의 남은 시간을 나누기만 하므로, 실제 구현에서는 앞의 RequestDeadline처럼 시도를 시작하기 직전에 남은 시간을 다시 계산해서 넘겨야 합니다. 대상별로 쌓인 응답 시간 기록을 보고 connect 비율을 조정할 수도 있습니다.

2번 해설

처음 배분은 connect 200ms, read 600ms입니다. 그런데 connect와 쓰기에 250ms가 지났으므로 read 직전에 전체 기한에 남은 시간은 800 − 180 − 70 = 550ms입니다. 처음 받은 read 예산 600ms를 그대로 쓰면 전체 기한을 50ms 넘기게 되므로, 실제 대기 직전에는 언제나 남은 시간을 다시 계산해야 합니다. 50ms 백오프 뒤에는 500ms가 남고, 100ms 하한을 넘으며 시도 횟수도 세 번 안이라면 다음 시도는 connect 125ms, read 375ms로 다시 나뉩니다.

3번 해설

시간 초과는 나지 않습니다. readFully는 안에서 read를 여러 번 부르는데, 각 read는 다음 바이트가 올 때까지 50ms 정도만 기다리므로 100ms 제한에 한 번도 걸리지 않습니다. 대신 메시지 전체를 받는 데는 500ms 정도가 걸립니다. 전체를 300ms로 묶으려면 readFully에 맡기지 말고 바이트를 직접 읽는 반복문을 쓰면서, SlowSenderProbe의 두 번째 클라이언트처럼 매 read 직전에 300ms짜리 RequestDeadline의 남은 시간으로 setSoTimeout을 다시 설정합니다. 메시지를 다 받으려면 500ms가 필요하므로, 그 전에 기한이 끝나 SocketTimeoutException이 납니다.

간격과 시간 제한의 차이가 너무 작으면 결과가 흔들린다는 점도 알아 두세요. 예를 들어 40ms 간격에 50ms 제한이라면, JVM이 막 시작해 첫 바이트가 조금만 늦어져도 시간 초과가 날 수 있습니다.

4번 해설

setSoTimeout(0)은 시간 제한을 끄라는 뜻이라서, 그 read는 데이터가 올 때까지 끝없이 기다립니다. 기한이 거의 끝나 가장 짧게 기다려야 할 순간에 오히려 무제한 대기가 되는 것입니다. 그래서 1ms보다 적게 남았을 때는 1을 돌려주고, 남은 시간이 아예 없을 때는 0을 넘기는 대신 SocketTimeoutException을 던져 다음 단계를 시작하지 않게 합니다.

정리

  • 소켓에는 연결을 기다리는 connect와 데이터를 기다리는 read라는 두 가지 대기가 있고, 둘 다 기본값으로는 시간 제한이 없거나 운영체제 설정에 맡겨집니다.
  • new Socket(host, port) 대신 new Socket()과 connect(endpoint, 밀리초)를 써야 연결 시간 제한을 걸 수 있습니다. 실패한 소켓은 닫히므로 다음 시도에는 새로 만듭니다.
  • setSoTimeout은 read 한 번의 대기를 제한하고, 시간이 지나도 소켓은 유효합니다. 하지만 메시지 중간에서 멈췄을 수 있으므로 보통 연결을 닫습니다. 쓰기에는 이런 설정이 없습니다.
  • connect와 setSoTimeout에서 0은 무제한입니다. Duration을 밀리초로 바꿀 때 0이 되지 않게 주의합니다.
  • 응답 전체나 요청 전체를 제한하려면 시작할 때 nanoTime()으로 기한을 정하고, 매 단계 직전에 남은 시간을 다시 계산해 넘깁니다. 시간 비교는 언제나 값끼리가 아니라 차이로 합니다.
  • 시간 초과는 결과를 모른다는 뜻입니다. 기다리던 작업과 소켓을 실제로 정리하고, 결제처럼 중복되면 안 되는 요청은 멱등성 키와 상태 조회로 결과를 확인합니다.

다음 글

TCP 연결 종료: EOF, 반쪽 닫기, RST에서는 연결이 끝나는 여러 방식을 다룹니다. 상대가 정상적으로 닫았을 때의 EOF, 강제로 끊었을 때의 연결 재설정, 한쪽 방향만 닫는 반쪽 닫기를 구분하고, 각 경우에 요청의 결과를 어떻게 판단할지 살펴봅니다.