본문 바로가기

TCP 연결 종료: EOF, 반쪽 닫기, RST

TCP 연결이 정상 종료(FIN)로 끝났는지 강제 종료(RST)로 끊겼는지를 자바 코드에서 구분하고, 반쪽 닫기와 채팅 세션 종료 처리에 적용하는 방법을 배웁니다.

채팅 서버에 접속해 있던 사용자 한 명이 사라졌다고 해 봅시다. 그 사용자는 /exit를 입력하고 정상적으로 나갔을 수도 있고, 창을 닫았거나 프로그램이 죽었을 수도 있으며, 중간의 네트워크 장비가 연결을 끊었을 수도 있습니다. 서버 코드가 이 차이를 알아낼 단서는 소켓의 read와 write가 돌려주는 값과 던지는 예외뿐입니다. 어떤 때는 read가 -1을 돌려주고, 어떤 때는 Connection reset이라는 메시지의 SocketException이 나며, 어떤 때는 쓰기에서 Broken pipe가 납니다.

이 글에서는 TCP 연결이 끝나는 방식을 정상 종료, 반쪽 닫기, 강제 종료, 그리고 내 코드가 먼저 닫는 경우로 나누고, 각각이 자바에서 어떤 모습으로 나타나는지 루프백 예제로 직접 확인합니다. 그런 다음 이 신호들을 '요청이 성공했는가'라는 판단으로 옮기는 방법을 정리하고, 채팅 서버의 세션 종료 처리에 적용합니다. 예제는 첫 TCP 연결을 다룬 글의 ServerSocket·Socket 사용법과 try-with-resources로 소켓을 정리하는 방법을 알고 있다고 가정합니다.

TCP 연결은 방향마다 따로 닫힌다

TCP 연결 하나에는 바이트가 흐르는 길이 두 개 있습니다. 클라이언트에서 서버로 가는 방향과 서버에서 클라이언트로 오는 방향입니다. 두 방향은 따로 끝낼 수 있어서, 한쪽이 '나는 더 보낼 것이 없다'고 알린 뒤에도 반대 방향으로는 데이터가 계속 흐를 수 있습니다. 그래서 TCP에서 연결을 닫는다는 것은 한 번의 사건이 아니라 두 방향이 각각 끝나는 두 번의 사건입니다.

한 방향을 정상적으로 끝낼 때 TCP는 FIN이라는 표시를 보냅니다. FIN은 보낸 데이터의 맨 뒤에 붙는 '여기가 끝'이라는 표시라서 앞서 보낸 데이터를 지우지 않습니다. 받는 쪽의 read는 이미 도착한 바이트를 모두 돌려준 다음에야 스트림의 끝, 흔히 EOF(end of file)라고 부르는 상태를 -1로 알립니다. 자바에서 close()를 호출하면 보통 내 송신 방향에 FIN이 나가고 내 쪽 소켓은 읽기와 쓰기를 모두 멈춥니다. 반면 shutdownOutput()은 FIN만 보내고 읽기는 계속할 수 있게 둡니다.

정상적으로 끝낼 수 없는 상황에서는 RST(reset)가 나갑니다. RST는 '이 연결은 지금 버린다'는 신호입니다. 받는 쪽 운영체제는 연결을 즉시 폐기하고, 그 소켓의 read와 write는 SocketException을 던집니다. FIN과 달리 아직 읽지 않은 데이터가 사라질 수도 있습니다. 뒤에서 보겠지만 close()가 언제나 FIN만 보내는 것도 아니어서, 상황에 따라 RST가 나가기도 합니다.

일어난 일상대에게 가는 것상대의 read상대의 write
close()FIN남은 데이터를 모두 읽은 뒤 -1처음에는 성공할 수 있지만 곧 예외
shutdownOutput()FIN남은 데이터를 모두 읽은 뒤 -1계속 쓸 수 있음
강제 종료RSTSocketException (남은 데이터를 먼저 읽을 수도 있음)SocketException

상대와 상관없이 내 코드가 먼저 소켓을 닫는 경우도 있습니다. Socket.close() 문서에 따르면 이때 같은 소켓에서 입출력을 기다리던 스레드는 SocketException을 받습니다. Linux의 JDK 21에서 실행해 보면 그 메시지는 Socket closed입니다. 표의 각 줄은 이 글의 예제로 하나씩 확인하겠습니다. 먼저 가장 기본인 EOF부터 보겠습니다.

read가 -1을 돌려주면 반복을 끝내야 한다

InputStream.read()는 바이트 하나를 0부터 255 사이의 int로 돌려주고, 더 읽을 데이터가 없으면 -1을 돌려줍니다. 반환 타입이 byte가 아니라 int인 이유가 여기에 있습니다. byte로는 데이터 255(0xFF)와 '끝'을 구별할 수 없으니, 데이터가 쓰지 않는 값 -1을 끝 표시로 따로 남겨 둔 것입니다. 따라서 -1은 데이터가 아니라 신호이고, 읽기 반복문은 -1을 받는 즉시 멈춰야 합니다.

-1을 검사하지 않으면 어떻게 되는지 보겠습니다. 소켓에서 얻은 입력 스트림도 같은 InputStream 규칙을 따르므로, 여기서는 실행할 때마다 결과가 같은 ByteArrayInputStream으로 상대가 보낸 두 바이트를 흉내 냅니다. 실제 버그 코드는 while (true)라서 끝나지 않으니, 예제는 네 번만 읽도록 횟수를 정해 두었습니다.

bad/IgnoredSocketEof.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.HexFormat;

public final class IgnoredSocketEof {
    public static void main(String[] args) throws Exception {
        byte[] received = {72, 105}; // 상대가 보낸 "Hi" 두 바이트

        // 잘못된 반복: -1을 검사하지 않는다. 실제 코드처럼 while (true)였다면 끝나지 않는다.
        InputStream wrong = new ByteArrayInputStream(received);
        var copied = new ByteArrayOutputStream();
        for (int i = 0; i < 4; i++) {
            int value = wrong.read();
            System.out.println("byte=" + value);
            copied.write(value); // -1의 하위 8비트인 0xFF가 데이터처럼 저장된다
        }
        System.out.println("copied=" + HexFormat.of().formatHex(copied.toByteArray()));

        // 올바른 반복: -1을 만나면 멈춘다.
        InputStream right = new ByteArrayInputStream(received);
        var correct = new ByteArrayOutputStream();
        int value;
        while ((value = right.read()) != -1) {
            correct.write(value);
        }
        System.out.println("correct=" + HexFormat.of().formatHex(correct.toByteArray()));
    }
}

잘못된 반복은 read가 돌려준 값을 검사하지 않고 매번 찍고 저장합니다. 그래서 byte= 줄은 받은 바이트가 두 개뿐이어도 반복 횟수만큼 네 번 나옵니다.

byte=72
byte=105
byte=-1
byte=-1
copied=4869ffff
correct=4869

처음 두 줄은 H와 i의 바이트 값입니다. 그다음부터 read는 기다리지도, 예외를 던지지도 않고 -1을 계속 돌려줍니다. 끝에 도달한 스트림은 몇 번을 다시 읽어도 끝이기 때문입니다. 소켓 스트림도 EOF 뒤에는 -1을 계속 돌려주므로, 이 반복이 while (true)였다면 CPU를 쓰면서 -1을 끝없이 출력했을 것입니다.

다섯째 줄은 더 조용한 피해를 보여 줍니다. ByteArrayOutputStream.write(int)는 OutputStream의 규칙대로 인수의 하위 8비트만 저장하는데, -1의 하위 8비트는 0xFF입니다. 그래서 끝 표시였던 -1이 ff라는 진짜 데이터 바이트로 바뀌어 복사본 뒤에 붙었습니다. 마지막 줄의 올바른 반복은 읽은 값을 변수에 담으면서 바로 -1과 비교하므로, 받은 두 바이트만 정확히 복사합니다.

끝을 알리는 방법은 API마다 다릅니다. 줄 단위로 읽는 BufferedReader.readLine()은 더 읽을 문자가 없으면 null을 돌려주고, DataInputStream.readInt()나 readUTF()는 필요한 바이트를 다 받기 전에 스트림이 끝나면 EOFException을 던집니다. 반대로 readAllBytes()와 transferTo()는 EOF를 만날 때까지 읽은 뒤 정상적으로 돌아옵니다. 그러니 읽기 코드를 쓸 때는 지금 쓰는 메서드가 끝을 값으로 알리는지, 예외로 알리는지, 아니면 끝까지 읽고 돌아오는지를 먼저 확인해야 합니다.

상대가 close하면 남은 데이터를 받은 뒤 EOF가 온다

이번에는 실제 소켓으로 정상 종료를 확인합니다. 서버 쪽 스레드는 연결을 받아 바이트 하나(42)를 쓰고, try-with-resources가 끝나면서 소켓을 닫습니다. 클라이언트는 read를 두 번 호출합니다. 포트 번호로 0을 주면 운영체제가 비어 있는 포트를 골라 주므로 다른 프로그램과 겹치지 않습니다. InetAddress.getLoopbackAddress()는 같은 컴퓨터 안에서만 통하는 루프백 주소(보통 127.0.0.1)이고, 서버 쪽 작업은 Thread.startVirtualThread로 만든 가상 스레드에서 실행합니다.

src/NormalTcpCloseProbe.java
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;

public final class NormalTcpCloseProbe {
    public static void main(String[] args) throws Exception {
        try (var server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
            Thread sender = Thread.startVirtualThread(() -> {
                try (Socket accepted = server.accept()) {
                    accepted.getOutputStream().write(42);
                    accepted.getOutputStream().flush();
                } catch (Exception error) {
                    throw new RuntimeException(error);
                }
            });

            try (var client = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort())) {
                int data = client.getInputStream().read();
                int eof = client.getInputStream().read();
                System.out.printf("data=%d eof=%d%n", data, eof);
            }
            sender.join();
        }
    }
}

data는 첫 read의 반환값이고 eof는 두 번째 read의 반환값입니다. 클라이언트가 서버의 정상 종료를 어떻게 알아차리는지는 두 번째 값에서 드러납니다.

data=42 eof=-1

클라이언트가 첫 read를 호출할 무렵에는 서버가 이미 소켓을 닫았을 수도 있습니다. 그런데도 첫 read는 42를 돌려줍니다. FIN은 데이터 뒤에 붙어 오는 끝 표시일 뿐 앞서 보낸 데이터를 지우지 않기 때문입니다. 두 번째 read는 더 받을 데이터가 없으니 -1을 돌려줍니다. 포트 번호는 실행할 때마다 바뀌지만 출력에는 나오지 않으므로 결과는 언제나 이 한 줄입니다.

서버 쪽의 flush()는 이 예제에서 하는 일이 거의 없습니다. 소켓의 출력 스트림은 자바 쪽에 따로 버퍼를 두지 않고 쓴 바이트를 바로 운영체제에 넘기기 때문입니다. 다만 실무에서는 소켓 스트림을 BufferedOutputStream 같은 버퍼 스트림으로 감싸 쓰는 경우가 많고, 그때는 flush()나 close()를 해야 버퍼에 쌓인 내용이 실제로 나갑니다.

여기서 한 가지를 분명히 해 두겠습니다. 서버의 write와 close가 예외 없이 끝났다는 것은 데이터를 운영체제의 송신 버퍼에 넘겼다는 뜻일 뿐, 상대 프로그램이 그 바이트를 읽었다는 뜻이 아닙니다. 상대가 정말 받아서 처리했는지 알고 싶다면 상대가 응답을 보내도록 프로토콜을 정해야 합니다. 뒤에서 볼 Broken pipe 예제가 이 차이를 직접 보여 줍니다.

EOF는 '더 올 데이터가 없다'는 뜻일 뿐이다

EOF가 정상인지 아닌지는 TCP가 아니라 프로토콜이 정합니다. 서버가 응답 길이를 100바이트라고 알려 놓고 40바이트만 보낸 뒤 EOF가 왔다면 잘린 응답입니다. 반대로 exit에 대한 응답을 끝까지 받은 다음에 온 EOF라면 정상적인 마무리입니다. 같은 -1이라도 프로토콜의 어느 단계에서 받았는지에 따라 의미가 달라집니다.

EOF만으로 성공을 판단할 수 없는 이유가 하나 더 있습니다. 프로세스가 죽으면 운영체제가 그 프로세스의 소켓을 대신 닫는데, 이때도 보통은 정상 종료와 같은 FIN이 나갑니다. Linux에서 연결을 받아 둔 서버 JVM을 kill -9와 같은 강제 종료(SIGKILL)로 끝내 보면, 클라이언트의 read는 예외 없이 -1을 돌려줍니다. 즉 EOF는 '상대가 일을 다 마쳤다'가 아니라 '이 방향으로는 더 올 데이터가 없다'는 뜻입니다. 성공 여부는 프로토콜이 정한 완료 신호, 예를 들어 약속한 길이의 응답을 끝까지 받았는지로 판단해야 합니다.

shutdownOutput으로 보내기만 끝내기

요청의 끝을 EOF로 알리는 단순한 프로토콜을 생각해 봅시다. 클라이언트는 요청 본문을 보내고, 서버는 readAllBytes()로 EOF까지 읽은 뒤 결과를 돌려줍니다. 문제는 클라이언트가 EOF를 어떻게 만들어 내느냐입니다. close()를 호출하면 EOF는 가지만 클라이언트도 더 이상 읽을 수 없어서 응답을 받지 못합니다. 그렇다고 아무것도 하지 않으면 서버는 오지 않는 EOF를, 클라이언트는 오지 않는 응답을 서로 기다리며 둘 다 멈춥니다.

이럴 때 쓰는 것이 반쪽 닫기(half-close)입니다. Socket.shutdownOutput()은 소켓 전체를 닫지 않고 내 송신 방향에만 FIN을 보냅니다. Socket 문서의 표현으로는 앞서 쓴 데이터를 모두 보낸 뒤 TCP의 정상 종료 절차를 진행합니다. 입력 방향은 그대로 열려 있으므로 클라이언트는 이어서 응답을 읽을 수 있습니다.

src/HalfCloseRoundTrip.java
import java.io.ByteArrayOutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public final class HalfCloseRoundTrip {
    public static void main(String[] args) throws Exception {
        try (var server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
            Thread worker = Thread.startVirtualThread(() -> {
                try (Socket accepted = server.accept()) {
                    byte[] request = accepted.getInputStream().readAllBytes();
                    String response = "length=" + request.length;
                    accepted.getOutputStream().write(response.getBytes(StandardCharsets.UTF_8));
                } catch (Exception error) {
                    throw new RuntimeException(error);
                }
            });

            try (var client = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort())) {
                client.getOutputStream().write("hello".getBytes(StandardCharsets.UTF_8));
                client.shutdownOutput();
                var response = new ByteArrayOutputStream();
                client.getInputStream().transferTo(response);
                System.out.println(response.toString(StandardCharsets.UTF_8));
            }
            worker.join();
        }
    }
}

서버의 readAllBytes()와 클라이언트의 transferTo()는 둘 다 EOF를 만나야 돌아옵니다. 그래서 이 프로그램이 멈추지 않고 응답을 찍는다면 양쪽 방향의 FIN이 모두 제때 도착했다는 뜻입니다.

length=5

진행 순서를 따라가 보겠습니다. 클라이언트가 UTF-8로 hello 다섯 바이트를 쓰고 shutdownOutput()으로 FIN을 보냅니다. 서버의 readAllBytes()는 그 FIN 덕분에 EOF를 만나 다섯 바이트를 돌려주고, 서버는 length=5를 써서 반대 방향으로 보냅니다. 서버 쪽 try-with-resources가 끝나면 서버의 송신 방향에도 FIN이 나가므로, 클라이언트의 transferTo()는 응답을 모두 복사한 뒤 EOF에서 돌아옵니다. 아래 단계 그림에서 각 방향이 언제 닫히는지 확인할 수 있습니다.

요청 송신을 끝내도 응답은 계속 받는다

hello 전송 뒤 클라이언트 출력만 shutdownOutput되고 서버가 길이 응답을 보내며 입력 방향은 살아 있습니다. 각 방향의 열린 상태와 FIN 순서를 함께 추적할 이점이 있습니다.

요청 송신을 끝내도 응답은 계속 받는다

HalfCloseRoundTrip의 정상 경로에서 클라이언트의 송신 종료와 서버 응답 수신은 서로 다른 방향입니다.

반쪽 닫기 · 두 송신 방향

요청 송신을 끝내도 응답은 계속 받는다

클라이언트가 송신 방향만 끝내면, 서버는 요청 EOF 뒤 응답을 반대 방향으로 보낼 수 있습니다.

송신 종료 뒤의 응답 수신클라이언트가 hello를 보내고 송신 방향만 닫습니다. 서버는 요청 EOF 뒤 length=5를 보내고 닫으며 클라이언트는 응답을 EOF까지 읽습니다.hello · 5바이트송신 종료 · FINlength=5서버 종료 · FIN클라이언트서버
송신 종료 뒤의 응답 수신클라이언트가 hello를 보내고 송신 방향만 닫습니다. 서버는 요청 EOF 뒤 length=5를 보내고 닫으며 클라이언트는 응답을 EOF까지 읽습니다.hello · 5바이트송신 종료 · FINlength=5서버 종료 · FIN클라이언트서버

열린 화살촉은 방향 종료(FIN), 강조선은 응답입니다. client.shutdownOutput()은 입력을 닫지 않습니다. 코드와 Socket 계약의 정상 경로이며 실제 패킷 캡처 결과가 아닙니다.

이 예의 조건

  • 루프백 단일 요청이며 연결·읽기·join에 완료 시간 상한이 없습니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보요청 송신을 끝내도 응답은 계속 받는다 요청 송신을 끝내도 응답은 계속 받는다 HalfCloseRoundTrip의 정상 경로에서 클라이언트의 송신 종료와 서버 응답 수신은 서로 다른 방향입니다. 반쪽 닫기 · 두 송신 방향 요청 송신을 끝내도 응답은 계속 받는다 클라이언트가 송신 방향만 끝내면, 서버는 요청 EOF 뒤 응답을 반대 방향으로 보낼 수 있습니다. 열린 화살촉은 방향 종료(FIN), 강조선은 응답입니다. client.shutdownOutput()은 입력을 닫지 않습니다. 코드와 Socket 계약의 정상 경로이며 실제 패킷 캡처 결과가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
요청 송신

client가 UTF-8 hello 5바이트를 전송합니다.

  • 송신: hello
  • 다음: shutdownOutput
  • 요청 바이트를 쓴 뒤 송신 방향만 닫습니다.
서버 응답

server가 요청 EOF를 읽고 length=5를 보냅니다.

  • client 송신: FIN
  • 응답: length=5
  • 반쪽 닫기는 반대 방향 응답 수신을 허용합니다.
응답 수신 완료

server 종료 EOF까지 client가 응답을 읽습니다.

  • server: 닫기 FIN
  • client: 응답 뒤 worker.join
  • join은 worker 예외를 호출자에게 전달하지 않습니다.

요청 송신을 끝내도 응답은 계속 받는다

hello 전송 뒤 클라이언트 출력만 shutdownOutput되고 서버가 길이 응답을 보내며 입력 방향은 살아 있습니다. 각 방향의 열린 상태와 FIN 순서를 함께 추적할 이점이 있습니다.

요청 송신을 끝내도 응답은 계속 받는다

HalfCloseRoundTrip의 정상 경로에서 클라이언트의 송신 종료와 서버 응답 수신은 서로 다른 방향입니다.

반쪽 닫기 · 두 송신 방향

요청 송신을 끝내도 응답은 계속 받는다

클라이언트가 송신 방향만 끝내면, 서버는 요청 EOF 뒤 응답을 반대 방향으로 보낼 수 있습니다.

송신 종료 뒤의 응답 수신클라이언트가 hello를 보내고 송신 방향만 닫습니다. 서버는 요청 EOF 뒤 length=5를 보내고 닫으며 클라이언트는 응답을 EOF까지 읽습니다.hello · 5바이트송신 종료 · FINlength=5서버 종료 · FIN클라이언트서버
송신 종료 뒤의 응답 수신클라이언트가 hello를 보내고 송신 방향만 닫습니다. 서버는 요청 EOF 뒤 length=5를 보내고 닫으며 클라이언트는 응답을 EOF까지 읽습니다.hello · 5바이트송신 종료 · FINlength=5서버 종료 · FIN클라이언트서버

열린 화살촉은 방향 종료(FIN), 강조선은 응답입니다. client.shutdownOutput()은 입력을 닫지 않습니다. 코드와 Socket 계약의 정상 경로이며 실제 패킷 캡처 결과가 아닙니다.

이 예의 조건

  • 루프백 단일 요청이며 연결·읽기·join에 완료 시간 상한이 없습니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보요청 송신을 끝내도 응답은 계속 받는다 요청 송신을 끝내도 응답은 계속 받는다 HalfCloseRoundTrip의 정상 경로에서 클라이언트의 송신 종료와 서버 응답 수신은 서로 다른 방향입니다. 반쪽 닫기 · 두 송신 방향 요청 송신을 끝내도 응답은 계속 받는다 클라이언트가 송신 방향만 끝내면, 서버는 요청 EOF 뒤 응답을 반대 방향으로 보낼 수 있습니다. 열린 화살촉은 방향 종료(FIN), 강조선은 응답입니다. client.shutdownOutput()은 입력을 닫지 않습니다. 코드와 Socket 계약의 정상 경로이며 실제 패킷 캡처 결과가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
요청 송신

client가 UTF-8 hello 5바이트를 전송합니다.

  • 송신: hello
  • 다음: shutdownOutput
  • 요청 바이트를 쓴 뒤 송신 방향만 닫습니다.
서버 응답

server가 요청 EOF를 읽고 length=5를 보냅니다.

  • client 송신: FIN
  • 응답: length=5
  • 반쪽 닫기는 반대 방향 응답 수신을 허용합니다.
응답 수신 완료

server 종료 EOF까지 client가 응답을 읽습니다.

  • server: 닫기 FIN
  • client: 응답 뒤 worker.join
  • join은 worker 예외를 호출자에게 전달하지 않습니다.

반쪽 닫기는 이 예제처럼 연결 하나에 요청 하나를 보내고 요청의 끝을 EOF로 알리는 프로토콜에 어울립니다. 메시지마다 길이를 앞에 붙이는 프로토콜이라면 사정이 다릅니다. 예를 들어 DataOutputStream.writeUTF()는 문자열 앞에 2바이트 길이를 붙이므로, 받는 쪽은 EOF 없이도 메시지가 어디서 끝나는지 압니다. 이런 프로토콜에서는 반쪽 닫기가 필요 없고, 같은 연결로 요청을 여러 번 주고받을 수도 있습니다. 반쪽 닫기에 기대는 프로토콜을 다른 환경에 가져갈 때는 중간의 프록시나 상대 서버가 반쪽 닫기를 연결 전체의 종료로 처리하지 않는지도 확인해야 합니다.

이 글의 루프백 예제들은 흐름을 짧게 보이려고 연결, 읽기, join()에 시간 제한을 두지 않았습니다. 실제 코드에서는 연결·읽기 시간 제한을 다룬 글처럼 상한을 두어야 합니다. 또 작업자 스레드에서 다시 던진 RuntimeException은 join()을 통해 main으로 전달되지 않습니다. 예외는 기본 처리기가 표준 오류에 출력할 뿐이고 join()은 정상적으로 돌아오므로, 작업자가 성공했는지 알아야 한다면 Future 같은 방법으로 결과를 따로 받아야 합니다.

RST로 강제로 끊긴 연결

RST는 연결을 정상적으로 이어 갈 수 없을 때 나갑니다. 실제로 자주 만나는 경우는 다음과 같습니다.

  • 완전히 닫힌 소켓으로 데이터가 도착하면 받는 쪽 운영체제가 RST로 답합니다. 다음 절의 Broken pipe가 이 경우입니다.
  • 받은 데이터를 다 읽지 않은 채 소켓을 닫으면, 데이터가 버려졌다는 뜻으로 운영체제가 RST를 보냅니다.
  • 방화벽이나 로드 밸런서가 오래 쉬고 있던 연결을 정리하면서 RST를 보내기도 합니다.

반면 앞에서 보았듯이 상대 프로세스가 kill -9로 죽은 경우에도 대개 RST가 아니라 FIN이 나갑니다. 'RST면 비정상, FIN이면 정상'처럼 신호의 종류만으로 상대의 사정을 짐작하면 틀리기 쉽습니다.

테스트에서 RST를 일부러 만들 때는 setSoLinger(true, 0)을 많이 씁니다. SO_LINGER는 close()할 때 아직 보내지 못한 데이터를 어떻게 처리할지 정하는 옵션인데, 대기 시간을 0초로 주면 많은 플랫폼에서 close()가 남은 데이터를 버리고 FIN 대신 RST를 보냅니다. 아래 예제의 서버는 연결을 받자마자 이 설정으로 소켓을 닫고, 클라이언트는 read에서 무엇을 받는지 출력합니다.

src/ResetTcpCloseProbe.java
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;

public final class ResetTcpCloseProbe {
    public static void main(String[] args) throws Exception {
        try (var server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
            Thread resetter = Thread.startVirtualThread(() -> {
                try (Socket accepted = server.accept()) {
                    accepted.setSoLinger(true, 0);
                } catch (Exception error) {
                    throw new RuntimeException(error);
                }
            });

            try (var client = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort())) {
                try {
                    System.out.println("read=" + client.getInputStream().read());
                } catch (SocketException reset) {
                    System.out.println("read failed: " + reset);
                }
            }
            resetter.join();
        }
    }
}

정상 종료였다면 read=-1이 찍히고, RST를 받았다면 catch에서 read failed: 줄이 찍힙니다. Linux와 JDK 21에서 실행한 결과는 다음과 같습니다.

read failed: java.net.SocketException: Connection reset

클라이언트의 read는 -1 대신 SocketException을 던졌습니다. 여러 번 실행해도 결과는 같았습니다. 다만 Connection reset이라는 문구는 운영체제가 알려 준 오류 코드를 바탕으로 만든 것이라 운영체제와 JDK에 따라 달라질 수 있습니다. 그래서 코드는 메시지 문자열을 비교하지 말고, 예외 타입과 프로토콜의 어느 단계에서 예외가 났는지로 판단해야 합니다.

RST를 받았을 때 버퍼에 남아 있던 데이터를 먼저 읽을 수 있는지도 정해져 있지 않습니다. Socket.getInputStream() 문서는 연결이 끊긴 것이 감지되면 네트워크 소프트웨어가 버퍼의 바이트를 버릴 수 있다고 설명합니다. 버려지지 않은 바이트는 read로 읽을 수 있고, 남은 바이트가 없으면 이후의 모든 read가 IOException을 던집니다. 이 예제의 서버는 아무 데이터도 보내지 않았으니 이 차이가 드러나지 않습니다.

RST를 받은 소켓은 다시 쓸 수 없으므로 닫고 버려야 합니다. 이때 read의 예외에 이어 write나 close에서도 예외가 연달아 날 수 있는데, 세션이 끝난 원인은 맨 처음 예외로 기록하고 뒤따른 예외는 부가 정보로만 남기는 것이 좋습니다. 그리고 요청을 보낸 뒤 응답을 받기 전에 RST가 왔다면, 서버가 그 요청을 처리했는지는 알 수 없습니다. 이 경우를 실패로 단정하지 않고 '결과를 알 수 없음'으로 다루는 방법은 뒤에서 정리합니다.

이미 닫힌 연결에 쓰면 생기는 Broken pipe

이번에는 상대가 닫은 연결에 계속 쓰는 경우입니다. 서버는 연결을 받자마자 아무것도 읽거나 쓰지 않고 닫습니다. 클라이언트는 먼저 read로 EOF를 받아 서버가 닫혔다는 것을 확인한 다음 두 번 씁니다. 두 쓰기 사이에 200밀리초를 쉬는 이유는 출력을 본 뒤에 설명합니다.

src/BrokenPipeProbe.java
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;

public final class BrokenPipeProbe {
    public static void main(String[] args) throws Exception {
        try (var server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
            Thread closer = Thread.startVirtualThread(() -> {
                try (Socket accepted = server.accept()) {
                    // 아무것도 읽거나 쓰지 않고 바로 닫는다.
                } catch (Exception error) {
                    throw new RuntimeException(error);
                }
            });

            try (var client = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort())) {
                System.out.println("read=" + client.getInputStream().read());

                OutputStream output = client.getOutputStream();
                output.write("first".getBytes(StandardCharsets.UTF_8));
                System.out.println("first write: ok");

                Thread.sleep(200); // 상대 쪽 응답(RST)이 돌아올 시간을 준다
                try {
                    output.write("second".getBytes(StandardCharsets.UTF_8));
                    System.out.println("second write: ok");
                } catch (SocketException error) {
                    System.out.println("second write: " + error);
                }
            }
            closer.join();
        }
    }
}

첫 번째 write는 catch 없이 호출하므로, 여기서 예외가 나면 프로그램이 그 자리에서 끝납니다. 두 번째 write만 SocketException을 잡아 출력합니다. Linux와 JDK 21에서 실행한 결과는 다음과 같습니다.

read=-1
first write: ok
second write: java.net.SocketException: Broken pipe

첫 줄의 -1은 서버의 FIN이 도착했다는 뜻입니다. 그런데 둘째 줄을 보면 이미 닫힌 상대에게 쓴 첫 번째 write가 아무 문제 없이 성공했습니다. write는 바이트를 내 운영체제의 송신 버퍼에 넣으면 바로 돌아오기 때문입니다. 그 바이트는 상대 컴퓨터까지 가지만, 그쪽 소켓은 이미 완전히 닫혀 있어서 받을 프로그램이 없습니다. 그래서 상대 운영체제가 RST로 답하고, 그 RST를 받은 뒤의 두 번째 write가 Broken pipe로 실패합니다.

200밀리초는 이 RST가 돌아올 시간을 넉넉히 주려고 넣은 것입니다. 루프백에서는 RST가 거의 바로 돌아와서 쉬지 않고 써도 두 번째 쓰기가 실패하는 경우가 많습니다. 하지만 왕복 시간이 긴 실제 네트워크에서는 RST가 돌아오기 전에 쓴 바이트가 모두 '성공'으로 끝날 수 있으므로, 몇 번째 쓰기에서 실패하는지는 환경에 따라 달라집니다.

이 예제가 보여 주는 것은 두 가지입니다. 첫째, write가 성공했다고 상대가 데이터를 받았다는 뜻은 아닙니다. 둘째, 쓰기 오류는 문제가 생긴 바로 그 write가 아니라 그다음이나 더 나중의 write에서야 드러날 수 있습니다. 그래서 '요청을 보냈다'와 '상대가 처리했다' 사이의 간격은 상대의 응답으로만 메울 수 있습니다.

EOF만으로는 상대가 close()를 했는지 shutdownOutput()만 했는지도 알 수 없습니다. 반쪽 닫기 예제에서는 EOF를 받은 서버가 이어서 응답을 썼고 문제없이 전달됐지만, 이 절에서는 EOF 뒤의 쓰기가 실패했습니다. EOF를 받은 뒤에도 계속 써도 되는지는 프로토콜이 정해 두어야 합니다.

더 깊이: 같은 RST가 다른 메시지로 보이는 이유

Connection reset과 Broken pipe는 운영체제가 돌려준 오류 코드 ECONNRESET과 EPIPE에서 나온 메시지입니다. Linux는 RST를 받았을 때 연결 상태에 따라 다른 오류를 남깁니다. 상대의 FIN을 이미 받은 상태(CLOSE_WAIT)에서 RST가 오면 EPIPE를, 두 방향이 모두 열려 있던 상태에서 RST가 오면 ECONNRESET을 남깁니다. 앞의 두 예제가 정확히 이 차이입니다. SO_LINGER 예제의 클라이언트는 FIN 없이 RST를 받아 Connection reset을 보았고, 이 절의 클라이언트는 FIN을 받은 뒤에 RST를 받아 Broken pipe를 보았습니다.

Broken pipe는 '이 방향으로는 더 쓸 수 없다'는 뜻이라 상대와 무관하게 나기도 합니다. 내 쪽에서 shutdownOutput()을 호출한 뒤 같은 소켓에 쓰면 문서대로 IOException이 나는데, Linux의 JDK 21에서는 그 예외가 SocketException: Broken pipe입니다.

받은 데이터를 다 읽지 않고 닫는 경우도 헷갈리기 쉽습니다. 같은 환경에서 서버가 요청을 읽지 않은 채 응답 OK를 쓰고 닫게 해 보면, 클라이언트는 OK와 EOF를 정상적으로 읽은 뒤 첫 write에서야 Broken pipe를 받았습니다. 클라이언트에 FIN이 먼저, RST가 나중에 도착했기 때문에 읽기에서는 정상 종료처럼 보였고 RST는 다음 쓰기에서야 드러난 것입니다. 이처럼 메시지는 운영체제와 연결 상태에 따라 달라지므로, 운영 코드는 메시지보다 예외가 난 단계와 프로토콜 상태를 기준으로 판단해야 합니다.

끊긴 이유를 요청 결과로 옮기기

지금까지 본 신호를 모두 '연결 끊김'이라는 한 줄 로그로 합쳐 버리면, 오류가 갑자기 늘었을 때 그 이유를 알 수 없습니다. 상대가 정상적으로 나간 것인지, 네트워크 장비가 연결을 자른 것인지, 우리 서버가 종료하면서 스스로 닫은 것인지가 모두 같은 줄에 섞이기 때문입니다. 그래서 세션마다 누가 먼저 끝냈는지와 마지막으로 처리한 프로토콜 단계를 함께 기록해 두는 것이 좋습니다. 기록한 사건은 아래 표처럼 요청 결과로 옮길 수 있습니다.

일어난 일요청 결과이유
약속한 응답을 끝까지 받은 뒤 EOF성공프로토콜의 완료 조건을 채움
응답을 받는 도중 EOF잘린 응답받은 데이터가 모자람
RST 또는 시간 초과알 수 없음서버가 요청을 처리했는지 모름
내 코드가 먼저 close()취소우리 쪽에서 중단함

시간 초과가 '알 수 없음'에 들어가는 이유가 있습니다. 상대 컴퓨터의 전원이 꺼지거나 중간 네트워크가 끊기면 FIN도 RST도 오지 않습니다. 이때 read는 시간 제한이 없으면 끝없이 기다리고, 시간 제한이 있으면 SocketTimeoutException으로 깨어날 뿐 그사이 서버에서 무슨 일이 있었는지는 알려 주지 않습니다.

'알 수 없음'을 실패와 따로 두는 것은 재시도 때문입니다. 같은 요청을 두 번 처리해도 결과가 같은 성질을 멱등성(idempotency)이라고 하는데, 조회처럼 멱등한 요청이라면 다시 보내도 됩니다. 하지만 결제나 예약처럼 두 번 처리되면 안 되는 요청이라면, 다시 보내기 전에 그 요청이 서버에서 어떤 상태인지 먼저 확인해야 합니다. RST나 시간 초과를 단순한 실패로 보고 곧바로 재시도하면 결제가 두 번 될 수 있습니다.

내 코드가 먼저 닫은 경우를 따로 구분하는 데에도 이유가 있습니다. 서버를 종료하면서 세션 소켓을 닫으면, 그 소켓의 read에서 기다리던 스레드는 SocketException: Socket closed를 받습니다. 예외 타입만 보면 상대가 연결을 강제로 끊은 경우와 똑같습니다. 그래서 소켓을 닫기 직전에 '우리가 닫는다'는 표시를 세션 상태에 남겨 두고, 예외를 분류할 때 그 표시를 가장 먼저 확인해야 합니다. 이 글 끝의 연습 문제에서 이 분류를 코드로 만들어 봅니다.

채팅 서버의 세션 종료에 적용하기

다음 장에서 만들 채팅 서버에 이 내용을 적용해 보겠습니다. 채팅 프로토콜은 /join|name, /message|text, /change|name, /users, /exit처럼 무엇을 하려는지 드러내는 명령을 둡니다. 나가는 명령이 따로 있으면 '사용자가 나가겠다고 말했다'와 '연결이 그냥 끊겼다'를 구별할 수 있습니다.

세션 하나는 끝나는 방식에 따라 다르게 처리합니다. /exit를 받았다면 작별 응답을 보낸 뒤 소켓을 닫습니다. /exit 없이 EOF가 왔다면 사용자가 창을 닫았거나 프로그램이 끝난 것이므로, 사용자 목록에서 지우고 다른 사용자에게 퇴장을 알립니다. RST처럼 연결이 강제로 끊겼다면 비정상 종료 횟수를 기록해 두되, 마찬가지로 목록에서 지우고 다른 세션으로의 브로드캐스트는 계속합니다. 어느 경우든 세션 하나가 끝나는 일이 서버 전체나 다른 사용자에게 번지지 않아야 합니다.

그러려면 몇 가지가 더 필요합니다. 여러 세션 스레드가 동시에 사용자 목록을 읽고 고치므로, 목록과 이름 중복 검사는 동시에 접근해도 안전한 등록부에서 관리해야 합니다. 느린 클라이언트 하나가 브로드캐스트 전체를 붙잡지 않도록, 세션마다 출력 큐와 전용 송신 스레드를 두고 큐가 너무 많이 쌓인 세션은 끊는 방법을 쓸 수 있습니다. 또 입력 한 줄의 길이, 이름 길이, 메시지를 보내는 속도에 상한을 두고, 명령을 해석하다 오류가 나면 그 연결만 닫습니다. 등록부와 브로드캐스트는 다음 장의 세션 관리 글에서 자세히 다룹니다.

명령 해석은 네트워크와 떼어 놓으면 소켓 없이 문자열만으로 시험해 볼 수 있습니다. 아래 파서는 한 줄을 받아 명령 객체로 바꿉니다. 명령의 종류는 sealed 인터페이스와 레코드로 표현해서, 허용한 다섯 가지 말고는 명령 타입이 생기지 않게 했습니다.

src/ChatCommandParser.java
public final class ChatCommandParser {
    sealed interface Command permits Join, Message, Change, Users, Exit {}
    record Join(String name) implements Command {}
    record Message(String text) implements Command {}
    record Change(String name) implements Command {}
    record Users() implements Command {}
    record Exit() implements Command {}

    static Command parse(String line) {
        if (line.equals("/users")) return new Users();
        if (line.equals("/exit")) return new Exit();
        int separator = line.indexOf('|');
        if (separator < 0) throw new IllegalArgumentException("missing separator");
        String verb = line.substring(0, separator);
        String value = line.substring(separator + 1).strip();
        if (value.isEmpty() || value.length() > 500) throw new IllegalArgumentException("value");
        return switch (verb) {
            case "/join" -> new Join(value);
            case "/message" -> new Message(value);
            case "/change" -> new Change(value);
            default -> throw new IllegalArgumentException("unknown command");
        };
    }

    public static void main(String[] args) {
        System.out.println(parse("/join|Mina"));
        System.out.println(parse("/message|hello everyone"));
        System.out.println(parse("/users"));
        System.out.println(parse("/exit"));
    }
}

main은 올바른 형식의 줄만 넘기므로 예외 없이 명령 객체 네 개가 차례로 찍힙니다. 잘못된 줄이 어떻게 거부되는지는 코드의 IllegalArgumentException 세 곳에서 확인할 수 있습니다.

Join[name=Mina]
Message[text=hello everyone]
Users[]
Exit[]

parse는 인수가 없는 /users와 /exit를 먼저 정확히 비교합니다. 나머지 명령은 첫 번째 |의 위치를 indexOf로 찾아 앞쪽을 명령 이름, 뒤쪽을 값으로 나누고, 값의 앞뒤 공백을 strip()으로 지웁니다. 값이 비었거나 length()가 500을 넘으면 거부하고, 명령 이름은 switch로 고릅니다. 출력의 각 줄은 레코드가 자동으로 만들어 주는 toString()의 형식 그대로이며, 필드가 없는 레코드는 Users[]처럼 빈 대괄호로 출력됩니다.

이 파서를 쓸 때 주의할 점이 두 가지 있습니다. 첫째, 문자열 한 줄이 TCP read 한 번에 맞춰 도착한다고 가정하면 안 됩니다. TCP는 메시지 단위가 아니라 바이트 흐름이라서, 한 번의 read에 명령 반 줄이 올 수도 있고 두 줄이 붙어서 올 수도 있습니다. 그래서 파서 앞에는 DataInputStream.readUTF()처럼 길이를 붙인 메시지 단위로 읽는 방법이나, UTF-8 텍스트를 줄 단위로 읽는 BufferedReader 같은 판독기를 두어야 합니다.

둘째, 명령을 파싱했다고 그 명령을 실행해도 되는 것은 아닙니다. 입장(/join) 전에는 메시지를 보낼 수 없게 하는 규칙이나, 이름 변경이 다른 세션과 겹치지 않게 한 번에 처리하는 규칙은 세션의 상태를 아는 쪽에서 따로 검사해야 합니다. 이 부분은 채팅 명령을 세션 상태에 맞게 검사하는 글에서 이어집니다.

정리

  • TCP 연결의 두 방향은 따로 끝납니다. FIN은 데이터 뒤에 붙는 끝 표시라서, 받는 쪽은 남은 데이터를 다 읽은 뒤 EOF를 받습니다.
  • InputStream.read()의 -1은 데이터가 아니라 끝을 알리는 신호입니다. readLine()은 null, readInt()와 readUTF()는 EOFException처럼 API마다 끝을 알리는 방법이 다릅니다.
  • shutdownOutput()은 송신 방향만 닫는 반쪽 닫기입니다. 요청의 끝을 EOF로 알리면서도 응답을 계속 받을 수 있습니다.
  • RST는 연결을 즉시 버리는 신호로, read와 write에서 SocketException으로 나타납니다. 메시지 문구는 운영체제와 연결 상태에 따라 다르므로 문자열로 판단하지 않습니다.
  • write의 성공은 상대가 받았다는 뜻이 아니고, EOF는 상대가 일을 마쳤다는 뜻이 아닙니다. 성공은 프로토콜의 완료 신호로 판단하고, RST와 시간 초과는 '결과를 알 수 없음'으로 다룹니다.

연습 문제

  1. HalfCloseRoundTrip에서 client.shutdownOutput(); 줄을 지우면 프로그램은 어떻게 되나요? 어느 쪽이 어떤 메서드에서 무엇을 기다리는지 설명하세요.
  2. ChatCommandParser.parse에 /message| hi|there (첫 | 뒤와 맨 끝에 공백이 하나씩 있음)를 주면 Message에 남는 문자열은 무엇인가요? 값을 😀 250개와 251개로 바꾸면 각각 길이 검사를 통과하나요? 명령을 파싱했다는 사실만으로 입장 전에 메시지를 보낼 권한이 생기는지도 설명하세요.
  3. 예외(없으면 null), 기대한 본문 바이트 수, 실제로 받은 바이트 수, 내 코드가 먼저 닫았는지를 받아 COMPLETE, TRUNCATED, RESET, TIMED_OUT, CANCELLED 가운데 하나를 돌려주는 classify 메서드를 작성하세요. EOF는 예외 없이 실제 길이가 기대보다 짧은 경우로 나타냅니다.
1번 해설

서버는 readAllBytes()에서 EOF를 기다리지만, 클라이언트가 FIN을 보내지 않았으므로 EOF는 오지 않습니다. 클라이언트는 transferTo()에서 응답을 기다리지만, 서버는 요청을 끝까지 읽지 못했으니 응답을 쓰지 못합니다. 서로 상대를 기다리는 상태라서 시간 제한이 없는 이 예제는 끝나지 않습니다.

서버에 setSoTimeout으로 읽기 시간 제한을 걸어 두었다면 서버가 먼저 SocketTimeoutException으로 포기하고 연결을 닫습니다. 그러면 클라이언트는 빈 응답과 EOF를 받습니다. 어느 쪽이든 요청은 처리되지 않습니다.

2번 해설

첫 번째 |만 구분자로 쓰므로 값은 hi|there 앞뒤에 공백이 하나씩 붙은 문자열이고, strip()이 그 공백을 지워 hi|there가 남습니다. 두 번째 |는 구분자가 아니라 본문의 일부로 남습니다. 실행하면 Message[text=hi|there]가 출력됩니다.

😀는 U+1F600이라서 String 안에서는 UTF-16 코드 단위 두 개(서로게이트 쌍)를 차지합니다. 그래서 250개는 length()가 500이라 통과하고, 251개는 502라서 IllegalArgumentException으로 거부됩니다. 즉 이 상한은 글자 수(코드 포인트 수)도 아니고 바이트 수도 아닙니다. 😀 250개를 UTF-8로 보내면 1000바이트이므로, 네트워크로 받는 크기를 제한하려면 바이트 단위의 상한을 따로 두어야 합니다.

마지막으로 parse는 명령 객체를 만들 뿐입니다. 입장했는지, 그 명령을 보낼 권한이 있는지, 너무 빠르게 보내고 있지 않은지는 세션 상태를 아는 쪽에서 따로 검사해야 합니다.

3번 해설
exercise/CloseOutcomeSolution.java
import java.net.SocketException;
import java.net.SocketTimeoutException;

public final class CloseOutcomeSolution {
    enum Outcome { COMPLETE, TRUNCATED, RESET, TIMED_OUT, CANCELLED }

    static Outcome classify(Throwable error, int expected, int actual, boolean localClose) {
        if (localClose) return Outcome.CANCELLED;
        if (error instanceof SocketTimeoutException) return Outcome.TIMED_OUT;
        if (error instanceof SocketException) return Outcome.RESET;
        if (error != null || actual < expected) return Outcome.TRUNCATED;
        return Outcome.COMPLETE;
    }

    public static void main(String[] args) {
        System.out.println(classify(null, 10, 10, false));
        System.out.println(classify(null, 10, 4, false));
        System.out.println(classify(new SocketException("platform message"), 10, 0, false));
        System.out.println(classify(new SocketTimeoutException("read"), 10, 3, false));
    }
}

실행 결과:

COMPLETE
TRUNCATED
RESET
TIMED_OUT

main의 네 호출은 차례대로 정상 완료, 길이가 모자란 EOF, 소켓 예외, 시간 초과를 흉내 낸 입력입니다. 실제 네트워크 장애를 일으키는 것이 아니라 분류 함수에 값을 넣어 보는 것뿐입니다.

종료 분류 함수가 조건을 검사하는 순서

CloseOutcomeSolution은 먼저 일치한 조건의 결과를 반환하며 SocketException의 RESET은 이 함수의 정책 이름입니다.

종료 분류 · 먼저 일치한 조건

종료 분류 함수가 조건을 검사하는 순서

로컬 종료를 먼저 확인합니다. RESET은 모든 SocketException에 붙이는 이 예제의 정책 이름입니다.

  1. 로컬 종료 확인

    조건
    localClose가 true
    반환 값
    CANCELLED
  2. 시간 초과 확인

    조건
    SocketTimeoutException
    반환 값
    TIMED_OUT
  3. 소켓 오류 확인

    조건
    SocketException
    반환 값
    RESET
  4. 오류 또는 길이 부족

    조건
    다른 예외 또는 actual < expected
    반환 값
    TRUNCATED
  5. 완료 분기

    조건
    예외 없음 · actual >= expected
    반환 값
    COMPLETE

RESET은 실제 RST 수신의 증거가 아닙니다. 길이 초과·음수 입력 검사는 없으며, main은 네 분류 입력만 만들고 네트워크 장애를 재현하지 않습니다.

핵심은 조건을 검사하는 순서입니다. 내 코드가 먼저 닫으면 read가 SocketException: Socket closed를 던지므로, localClose를 가장 먼저 확인하지 않으면 취소가 RESET으로 잘못 분류됩니다. 그래서 localClose=true와 SocketTimeoutException을 함께 주어도 첫 분기가 CANCELLED를 고릅니다.

SocketTimeoutException은 이름과 달리 SocketException의 하위 클래스가 아니라 java.io.InterruptedIOException의 하위 클래스입니다. 따라서 시간 초과 검사와 소켓 예외 검사는 서로 겹치지 않지만, 둘 다 error != null을 보는 넷째 분기보다는 앞에 있어야 합니다. readUTF() 같은 메서드가 던지는 EOFException은 이 넷째 분기에서 TRUNCATED가 됩니다.

이 분류기가 단순한 정책이라는 점도 알아 두세요. RESET은 모든 SocketException에 붙이는 이름일 뿐입니다. 연결 거부를 뜻하는 ConnectException도 SocketException의 하위 클래스라서 여기에 들어가므로, RESET이 나왔다고 실제로 RST를 받았다는 뜻은 아닙니다. 또 예외가 없고 actual >= expected이면 COMPLETE를 돌려주기 때문에, expected=10, actual=11처럼 약속보다 많이 받은 경우나 음수 길이도 그대로 통과합니다. 실제 서비스에서는 정확한 프레임 길이를 따로 검사하고, 마지막 프로토콜 단계와 요청 ID를 함께 기록해야 합니다.

다음 글

다음 장에서는 콘솔 입력과 서버 메시지 수신을 동시에 처리하는 채팅 클라이언트를 만듭니다. 사용자가 /exit를 입력했을 때와 서버가 먼저 연결을 닫았을 때, 이 글에서 본 종료 신호를 클라이언트 쪽에서 어떻게 다루는지도 함께 살펴봅니다.