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

안동민 개발노트

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

TCP 정상·강제 종료

정상 종료의 FIN과 EOF 표현, 읽지 않은 데이터·강제 종료에서 나타나는 RST와 broken pipe, 반쪽 닫기를 구분하고 결과 미확정 업무와 채팅 세션 종료를 안전하게 처리합니다.

TCP 연결 종료는 한쪽 메서드 호출만으로 양방향 상태가 동시에 사라지는 사건이 아닙니다. 정상 close에서는 송신 방향의 끝을 알리는 FIN이 전달되고 상대 read는 남은 바이트를 모두 소비한 뒤 EOF를 봅니다. 상대도 close하거나 shutdownOutput을 호출하면 반대 방향 FIN이 전달됩니다.

연결 상태가 예상과 어긋나거나 읽지 않은 수신 데이터가 남은 채 강제로 닫히면 RST가 발생할 수 있습니다. readConnection reset, writeBroken pipe 같은 SocketException으로 보일 수 있으며 운영체제별 메시지가 다릅니다. 프로토콜 완료 신호와 예외 발생 단계를 함께 보아야 업무 결과를 판단할 수 있습니다.

EOF -1의 일반 바이트 처리 오류

InputStream.read가 -1을 반환하면 더 읽을 데이터가 없다는 뜻입니다. 반복문이 이를 종료 조건으로 사용하지 않으면 같은 EOF를 계속 출력하거나 -1을 0xFF 데이터처럼 변환할 수 있습니다. DataInputStream.readUTF는 프레임을 완성하지 못하면 EOFException을 던지므로 사용하는 API마다 끝 표현이 다릅니다.

bad/IgnoredSocketEof.java
import java.io.InputStream;

public final class IgnoredSocketEof {
    static void wrongLoop(InputStream input) throws Exception {
        while (true) {
            int value = input.read();
            System.out.println("byte=" + value);
            // value == -1일 때 break하지 않아 끝을 데이터처럼 반복한다.
        }
    }

    public static void main(String[] args) {
        System.out.println("무한 EOF 반례는 실행하지 않습니다");
    }
}

EOF가 정상인지도 프로토콜에 달려 있습니다. 요청 한 건의 응답 길이를 받기 전에 EOF가 오면 잘린 실패이고, 상대가 exit 응답을 모두 보낸 뒤 EOF라면 정상 종료일 수 있습니다. “예외가 없었다” 또는 “EOF가 왔다”만으로 성공을 확정하지 않습니다.

정상 close와 EOF

루프백 서버가 바이트를 하나 쓰고 Socket을 닫으면 클라이언트는 먼저 그 바이트를 받고 다음 read에서 -1을 얻습니다. FIN이 앞서 보낸 데이터를 지우는 것이 아니라 송신 스트림 끝을 표시하기 때문입니다. 출력 flushclose가 실패하지 않았는지도 서버 쪽에서 확인해야 합니다.

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

BufferedReader.readLine은 EOF에서 더 이상 문자가 없으면 null, DataInputStream.readInt는 네 바이트 전에 끝나면 EOFException을 사용합니다. 종료 처리 코드는 구체 판독기 규칙을 읽고 정상 종료 기준과 조기 종료 조건을 분리합니다.

반쪽 닫기로 요청 끝과 응답 수신 분리

클라이언트가 더 보낼 데이터는 없지만 서버 응답은 계속 받아야 할 수 있습니다. shutdownOutput()은 Socket 전체를 닫지 않고 로컬 송신 방향에 FIN을 보냅니다. 서버는 EOF까지 요청 본문을 읽은 뒤 결과를 써서 반대 방향으로 보낼 수 있습니다.

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

메시지 길이 프레임이 있다면 반쪽 닫기를 요청 구분으로 사용할 필요가 없습니다. 연결 재사용도 가능합니다. EOF 기반 단일 요청 프로토콜에서만 적합하며 중간 프록시와 서버가 반쪽 닫기를 어떻게 처리하는지 확인합니다.

RST에 따른 연결 종료 결과

setSoLinger(true, 0)close는 많은 플랫폼에서 즉시 RST를 보내는 시험 방법입니다. 상대 readSocketException을 받는지, 아직 버퍼에 있던 데이터를 먼저 읽는지는 실행 시점과 운영체제에 따라 달라질 수 있습니다. 검증할 때는 정확한 메시지 문자열보다 EOF 또는 SocketException이라는 허용 결과와 프로토콜 미완료 여부를 확인합니다.

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("resetType=" + reset.getClass().getSimpleName());
                }
            }
            resetter.join();
        }
    }
}

RST 뒤 같은 Socket을 계속 사용하지 않습니다. readwrite에서 서로 다른 SocketException이 연속으로 날 수 있으므로 첫 연결 실패를 세션 종료 원인으로 기록하고 뒤 close 오류는 부가 정보로 낮춥니다. 서버가 요청을 처리했는지 알 수 없다면 업무 결과를 UNKNOWN으로 둡니다.

정상·강제·로컬 종료를 단계로 분류

자기 코드가 먼저 Socket을 닫아 readSocket closed로 풀린 것은 shutdown 동작일 수 있습니다. 상대 FIN의 EOF, 상대 RST의 초기화, 로컬 시간 제한 뒤 close를 같은 “연결 끊김” 로그로 합치면 오류율이 왜 변했는지 모릅니다. 세션 상태에 종료 주체와 마지막 프로토콜 사건을 기록합니다.

응답 전체를 받은 뒤 FIN은 성공, 응답 중간 EOF는 truncated, RST와 시간 제한은 결과 알 수 없음, 로컬 shutdown close는 취소됨처럼 번역할 수 있습니다. 이 결과 타입이 재시도와 사용자 메시지를 결정합니다. 결제·예약처럼 중복 위험이 있는 업무는 알 수 없음에서 상태 조회를 우선합니다.

채팅 서버에 종료 모델 적용

채팅 프로토콜은 /join|name, /message|text, /change|name, /users, /exit처럼 명시적 명령 프레임을 둘 수 있습니다. 세션이 /exit를 받으면 정상 응답 뒤 close하고, EOF면 사용자 목록에서 제거하며, RST면 비정상 종료 지표를 올리되 다른 세션 broadcast는 계속합니다.

사용자 목록과 이름 중복은 동시성 안전한 레지스트리에서 관리합니다. broadcast 중 느린 클라이언트 하나가 모든 세션을 막지 않도록 세션별 출력 큐나 시간 제한을 둡니다. 입력 길이, 이름 길이, 메시지 속도에 상한을 두고 파싱 오류는 해당 연결만 종료합니다.

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

문자열 한 줄을 TCP read 한 번과 대응시키지 말고 DataStream 길이 프레임이나 UTF-8 줄 판독기를 사용합니다. 파서는 네트워크와 분리되어 명령 오류를 빠르게 확인할 수 있습니다. 세션 상태 머신은 join 전 메시지 금지와 name 변경 원자성을 추가해야 합니다.

연습 문제

예외, 기대 본문 바이트, 실제 본문 바이트, 로컬 종료 여부를 받아 COMPLETE, TRUNCATED, RESET, TIMED_OUT, CANCELLED를 반환하세요. EOF는 예외 null과 실제 길이 부족의 조합으로 표현합니다.

정답과 해설
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));
    }
}

운영 분류에는 마지막 프로토콜 상태와 요청 ID를 더해야 합니다. SocketException 메시지에 의존하지 않아 Windows와 Linux에서도 같은 업무 결과를 만들 수 있습니다.

TCP 종료 결과를 확정하는 기준

TCP 종료를 정확히 읽으려면 FIN, EOF, 반쪽 닫기, RST, 로컬 close를 하나의 사건으로 뭉개지 않습니다. 프로토콜이 기대한 바이트와 완료 신호를 기준으로 성공·잘림·미확정·취소를 구분하고 각 세션 자원을 멱등적으로 제거하면 강제 종료가 다른 연결과 업무 상태까지 오염시키지 않습니다.