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() 호출공통

실전 처리 전략

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

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

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

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