Java 네트워크 예외 — TCP 종료와 예외 정리

TCP 연결을 종료하는 방법은 두 가지다. 서로 합의하며 마무리하는 정상 종료와 문제가 생겨 즉시 끊어버리는 강제 종료. 각 상황에서 어떤 예외가 발생하고 어떻게 처리해야 하는지 코드와 함께 정리한다

정상 종료 (FIN)

socket.close()를 호출하면 TCP는 종료를 알리는 FIN 패킷을 상대방에게 전송한다. FIN을 받은 쪽도 socket.close()를 호출해 FIN을 돌려보내야 한다. 이 과정을 4-way handshake라 부르며, 양쪽이 FIN을 주고받아야 연결이 완전히 종료된다

서버

public class NormalCloseServer {
    public static void main(String[] args) throws IOException, InterruptedException {
        ServerSocket serverSocket = new ServerSocket(12345);
        Socket socket = serverSocket.accept();
        log("소켓 연결: " + socket);

        Thread.sleep(1000);
        socket.close(); // FIN 전송
        log("소켓 종료");
    }
}

클라이언트

public class NormalCloseClient {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("localhost", 12345);
        log("소켓 연결: " + socket);
        InputStream input = socket.getInputStream();

        readByInputStream(input, socket);
        readByBufferedReader(input, socket);
        readByDataInputStream(input, socket);

        log("연결 종료: " + socket.isClosed());
    }

    // InputStream.read() → EOF 시 -1 반환
    private static void readByInputStream(InputStream input, Socket socket) throws IOException {
        int read = input.read();
        log("read = " + read);
        if (read == -1) {
            input.close();
            socket.close();
        }
    }

    // BufferedReader.readLine() → EOF 시 null 반환
    private static void readByBufferedReader(InputStream input, Socket socket) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(input));
        String readString = br.readLine();
        log("readString = " + readString);
        if (readString == null) {
            br.close();
            socket.close();
        }
    }

    // DataInputStream.readUTF() → EOF 시 EOFException 발생
    private static void readByDataInputStream(InputStream input, Socket socket) throws IOException {
        DataInputStream dis = new DataInputStream(input);
        try {
            dis.readUTF();
        } catch (EOFException e) {
            log(e);
        } finally {
            dis.close();
            socket.close();
        }
    }
}

**실행 결과**

10:28:42.121 [ main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=49188]
10:28:43.128 [ main] read = -1
10:28:43.130 [ main] readString = null
10:28:43.130 [ main] java.io.EOFException
10:28:43.130 [ main] 연결 종료: true

서버가 socket.close()로 FIN을 보내면, 클라이언트는 더 읽을 데이터가 없다는 의미인 EOF를 수신한다. EOF를 표현하는 방식은 스트림 타입마다 다르다

읽기 방식EOF 표현
InputStream.read()-1반환
BufferedReader.readLine()null반환
DataInputStream.readUTF()EOFException발생

EOF를 받으면 반드시 close()를 호출해야 한다. EOF는 상대방이 FIN을 보냈다는 신호다. 클라이언트도 FIN을 돌려보내 4-way handshake를 완료해야 연결이 정상 종료된다

강제 종료 (RST)

TCP 연결 중 프로토콜 규칙을 위반하는 상황이 발생하면 RST(Reset) 패킷이 전송된다. RST는 “이 연결은 즉시 무효화하라”는 신호다

서버

public class ResetCloseServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(12345);
        Socket socket = serverSocket.accept();
        log("소켓 연결: " + socket);

        socket.close();       // FIN 전송
        serverSocket.close();
        log("소켓 종료");
    }
}

클라이언트

public class ResetCloseClient {
    public static void main(String[] args) throws IOException, InterruptedException {
        Socket socket = new Socket("localhost", 12345);
        log("소켓 연결: " + socket);
        InputStream input = socket.getInputStream();
        OutputStream output = socket.getOutputStream();

        // 서버가 FIN을 보낼 때까지 잠시 대기
        Thread.sleep(1000);

        // FIN을 받은 상태에서 서버에 데이터 전송 → PUSH 패킷
        output.write(1);

        // 서버가 RST를 보낼 때까지 잠시 대기
        Thread.sleep(1000);

        try {
            int read = input.read(); // RST 수신 후 read → SocketException
            System.out.println("read = " + read);
        } catch (SocketException e) {
            e.printStackTrace(); // Connection reset
        }

        try {
            output.write(1); // RST 수신 후 write → SocketException
        } catch (SocketException e) {
            e.printStackTrace(); // Broken pipe
        }
    }
}

**실행 결과**

java.net.SocketException: Connection reset
java.net.SocketException: Broken pipe

동작 원리

  • 서버가 socket.close()를 호출해 FIN을 전송한다
  • 클라이언트가 FIN을 수신한다. OS가 ACK를 돌려보낸다
  • 클라이언트가 FIN을 받은 상태에서 output.write(1)로 데이터를 전송한다
  • 서버는 이미 FIN으로 종료를 요청한 상태다. 기대하는 것은 FIN인데 PUSH(데이터) 패킷이 도착했다
  • 서버는 TCP 연결에 문제가 있다고 판단하고 RST 패킷을 전송한다
  • RST를 받은 클라이언트는 이후 read()에서 Connection reset, write()에서 Broken pipe 예외를 받는다

RST가 발생하는 대표적인 상황은 FIN을 받고 아직 종료하지 않은 채 데이터를 전송하는 경우, TCP 버퍼에 읽지 않은 데이터가 남아있는데 소켓을 닫는 경우, 방화벽이 연결을 강제로 차단하는 경우다

소켓 예외 정리

예외발생 조건OS별 메시지
EOFExceptionFIN 수신 후 DataInputStream.readUTF() 호출공통
SocketException: Connection resetRST 수신 후 read() 호출Max/Linux
SocketException: Broken pipeRST 수신 후 write() 호출Mac/Linux
SocketException: Socket is closed자신이 닫은 소켓에 read() 또는 write() 호출공통

실전 처리 전략

모든 소켓 예외를 개별적으로 구분해 처리할 필요는 없다. SocketException과 EOFException 모두 IOException의 자식이기 때문에 다음과 같이 처리하면 충분하다

try {
    // 소켓 통신 로직
} catch (IOException e) {
    log(e); // 예외 기록
} finally {
    closeAll(socket, input, output); // 반드시 자원 정리
}

정상 종료든 강제 종료든, IOException이 발생하면 자원을 정리하고 연결을 끊는다. 예외를 더 세밀하게 구분해야 하는 비즈니스 요구사항이 생길 때 그때 추가 분기 처리를 고려하면 된다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션