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별 메시지 |
EOFException | FIN 수신 후 DataInputStream.readUTF() 호출 | 공통 |
SocketException: Connection reset | RST 수신 후 read() 호출 | Max/Linux |
SocketException: Broken pipe | RST 수신 후 write() 호출 | Mac/Linux |
SocketException: Socket is closed | 자신이 닫은 소켓에 read() 또는 write() 호출 | 공통 |
실전 처리 전략
모든 소켓 예외를 개별적으로 구분해 처리할 필요는 없다. SocketException과 EOFException 모두 IOException의 자식이기 때문에 다음과 같이 처리하면 충분하다
try {
// 소켓 통신 로직
} catch (IOException e) {
log(e); // 예외 기록
} finally {
closeAll(socket, input, output); // 반드시 자원 정리
}
정상 종료든 강제 종료든, IOException이 발생하면 자원을 정리하고 연결을 끊는다. 예외를 더 세밀하게 구분해야 하는 비즈니스 요구사항이 생길 때 그때 추가 분기 처리를 고려하면 된다