TCP 에는 2가지 종류의 종료가 있다.
- 정상 종료
- 강제 종료
정상 종료
TCP 에서 A, B 가 서로 통신한다고 가정해보자.
TCP 연결을 종료하려면 서로 FIN 메시지를 보내야한다.
A (FIN) -> B : A 가 B 로 FIN 메시지를 보낸다.
A <- (FIN) B : FIN 메시지를 받은 B 도 A 에게 FIN 메시지를 보낸다.
socket.close() 를 호출하면 TCP 에서 종료의 의미인 FIN 패킷을 상대방에게 전달한다.
FIN 패킷을 받으면 상대방도 socket.close() 를 호출해서 FIN 패킷을 상대방에게 전달한다.
- 클라이언트와 서버가 3-way-handshake 로 연결되어 있다.
- 서버가 연결 종료를 위해 socket.close() 를 호출한다.
- 서버는 클라이언트에 FIN 패킷을 전달한다.
- 클라이언트는 FIN 패킷을 받는다.
- 클라이언트의 OS 에서 자동으로 FIN 에 대한 ACK 패킷을 전달한다.
- 클라이언트도 종료를 위해 socket.close() 를 호출한다.
- 클라이언트는 서버에 FIN 패킷을 전달한다.
- 서버의 OS 는 FIN 패킷에 대한 ACK 패킷을 전달한다.
예제를 통해 소켓의 정상 종료에 대해 알아보자
package network.exception.close.normal;
import java.io.*;
import java.net.Socket;
import static util.MyLogger.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());
}
private static void readByInputStream(InputStream input, Socket socket) throws IOException {
int read = input.read();
log("read = " + read);
if (read == -1) {
input.close();
socket.close();
}
}
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();
}
}
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();
}
}
}
- 중요한 점은 EOF가 발생하면 상대방이 FIN 메시지를 보내면서 소켓 연결을 끊었다는 뜻이다. 이 경우 소켓에 다른 작업을 하면 안되고, FIN 메시지를 받은 클라이언트로 close() 를 호출해서 상대방에 FIN 메시지를 보내고 소켓 연결을 끊어야 한다.
- 이렇게 서로 FIN 메시지를 주고 받으면 TCP 연결이 정상 종료된다.
강제 종료
TCP 연결 중에 문제가 발생하면 RST 라는 패킷이 발생한다.
이 경우 연결을 즉시 종료해야 한다.
package hellojpa;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
public class ResetCloseServer {
public static void main(String[] args) throws IOException, InterruptedException {
ServerSocket serverSocket = new ServerSocket(12345);
Socket socket = serverSocket.accept();
MyLogger.log("소캣 연결: " + socket);
socket.close();
serverSocket.close();
MyLogger.log("소캣 종료");
}
}
package hellojpa;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.net.SocketException;
public class ResetCloseClient {
public static void main(String[] args) throws IOException, InterruptedException {
Socket socket = new Socket("localhost", 12345);
MyLogger.log("소캣 연결: " + socket);
InputStream input = socket.getInputStream();
OutputStream output = socket.getOutputStream();
// client <- server: FIN
Thread.sleep(1000); // 서버가 close() 호출할 때 까지 잠시 대기
// client -> server: PUSH[1] - 서버가 Fin 를 보냈지만 write 를 쓰는 상황
// Fin 를 날리면 ACK 이후에 수신측도 FIN 을 날려야 함
output.write(1);
// client <- server: RST - 이 연결 잘못되었어 라는 RST 가 옴
Thread.sleep(1000); // RST 메시지 전송 대기
try {
int read = input.read();
System.out.println("read = " + read);
} catch (SocketException e) {
e.printStackTrace();
}
try {
// java.net.SocketException: Broken pipe
output.write(1);
} catch (SocketException e) {
e.printStackTrace();
}
}
}
실행 결과
C:\Users\.jdks\temurin-21.0.5\bin\java.exe "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1.1\lib\idea_rt.jar=52729:C:\Program Files\JetBrains\IntelliJ IDEA 2023.1.1\bin" -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -classpath C:\Users\jongwan\Downloads\ex1-hello-jpa-start\ex1-hello-jpa-start\target\classes;C:\Users\jongwan\.m2\repository\org\hibernate\orm\hibernate-core\6.4.2.Final\hibernate-core-6.4.2.Final.jar;C:\Users\jongwan\.m2\repository\jakarta\persistence\jakarta.persistence-api\3.1.0\jakarta.persistence-api-3.1.0.jar;C:\Users\jongwan\.m2\repository\jakarta\transaction\jakarta.transaction-api\2.0.1\jakarta.transaction-api-2.0.1.jar;C:\Users\jongwan\.m2\repository\org\jboss\logging\jboss-logging\3.5.0.Final\jboss-logging-3.5.0.Final.jar;C:\Users\jongwan\.m2\repository\org\hibernate\common\hibernate-commons-annotations\6.0.6.Final\hibernate-commons-annotations-6.0.6.Final.jar;C:\Users\jongwan\.m2\repository\io\smallrye\jandex\3.1.2\jandex-3.1.2.jar;C:\Users\jongwan\.m2\repository\com\fasterxml\classmate\1.5.1\classmate-1.5.1.jar;C:\Users\jongwan\.m2\repository\net\bytebuddy\byte-buddy\1.14.7\byte-buddy-1.14.7.jar;C:\Users\jongwan\.m2\repository\jakarta\xml\bind\jakarta.xml.bind-api\4.0.0\jakarta.xml.bind-api-4.0.0.jar;C:\Users\jongwan\.m2\repository\jakarta\activation\jakarta.activation-api\2.1.0\jakarta.activation-api-2.1.0.jar;C:\Users\jongwan\.m2\repository\org\glassfish\jaxb\jaxb-runtime\4.0.2\jaxb-runtime-4.0.2.jar;C:\Users\jongwan\.m2\repository\org\glassfish\jaxb\jaxb-core\4.0.2\jaxb-core-4.0.2.jar;C:\Users\jongwan\.m2\repository\org\eclipse\angus\angus-activation\2.0.0\angus-activation-2.0.0.jar;C:\Users\jongwan\.m2\repository\org\glassfish\jaxb\txw2\4.0.2\txw2-4.0.2.jar;C:\Users\jongwan\.m2\repository\com\sun\istack\istack-commons-runtime\4.1.1\istack-commons-runtime-4.1.1.jar;C:\Users\jongwan\.m2\repository\jakarta\inject\jakarta.inject-api\2.0.1\jakarta.inject-api-2.0.1.jar;C:\Users\jongwan\.m2\repository\org\antlr\antlr4-runtime\4.13.0\antlr4-runtime-4.13.0.jar;C:\Users\jongwan\.m2\repository\javax\xml\bind\jaxb-api\2.3.1\jaxb-api-2.3.1.jar;C:\Users\jongwan\.m2\repository\javax\activation\javax.activation-api\1.2.0\javax.activation-api-1.2.0.jar;C:\Users\jongwan\.m2\repository\com\h2database\h2\2.2.224\h2-2.2.224.jar hellojpa.ResetCloseClient
17:44:40.407 [ main] 소캣 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=52731]
java.net.SocketException: 현재 연결은 사용자의 호스트 시스템의 소프트웨어의 의해 중단되었습니다
at java.base/sun.nio.ch.SocketDispatcher.read0(Native Method)
at java.base/sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:46)
at java.base/sun.nio.ch.NioSocketImpl.tryRead(NioSocketImpl.java:256)
at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:307)
at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:346)
at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:796)
at java.base/java.net.Socket$SocketInputStream.read(Socket.java:1099)
at java.base/java.net.Socket$SocketInputStream.read(Socket.java:1093)
at hellojpa.ResetCloseClient.main(ResetCloseClient.java:29)
java.net.SocketException: 현재 연결은 사용자의 호스트 시스템의 소프트웨어의 의해 중단되었습니다
at java.base/sun.nio.ch.SocketDispatcher.write0(Native Method)
at java.base/sun.nio.ch.SocketDispatcher.write(SocketDispatcher.java:54)
at java.base/sun.nio.ch.NioSocketImpl.tryWrite(NioSocketImpl.java:394)
at java.base/sun.nio.ch.NioSocketImpl.implWrite(NioSocketImpl.java:410)
at java.base/sun.nio.ch.NioSocketImpl.write(NioSocketImpl.java:440)
at java.base/sun.nio.ch.NioSocketImpl$2.write(NioSocketImpl.java:819)
at java.base/java.net.Socket$SocketOutputStream.write(Socket.java:1195)
at java.base/java.net.Socket$SocketOutputStream.write(Socket.java:1190)
at hellojpa.ResetCloseClient.main(ResetCloseClient.java:37)
종료 코드 0(으)로 완료된 프로세스
TCP/IP 규칙은 FIN 을 날리면 무조건 FIN 이 와야 한다는 규칙이 있다.
- 클라이언트와 서버가 3-way-handshake 로 연결되어 있다.
- 서버가 연결 종료를 위해 socket.close() 를 호출한다.
- 서버는 클라이언트에 FIN 패킷을 전달한다.
- 클라이언트는 FIN 패킷을 받는다.
- 클라이언트의 OS 에서 자동으로 FIN 에 대한 ACK 패킷을 전달한다.
클라이언트도 종료를 위해 socket.close() 를 호출한다.클라이언트는 서버에 FIN 패킷을 전달한다.서버의 OS 는 FIN 패킷에 대한 ACK 패킷을 전달한다.
- 클라이언트는 output.write(1) 를 통해 서버에 메시지를 전달한다.
- 데이터를 전송하는 PUSH 패킷이 서버에 전달된다.
- 서버는 이미 FIN 으로 종료를 요청했는데, PUSH 패킷으로 데이터가 전송되었다.
- 서버가 기대하는 값은 FIN 패킷이다.
- 서버는 TCP 연결에 문제가 있다고 판단하고 즉각 연결을 종료하라는 RST 패킷을 클라이언트에 전송한다.
RST 패킷이 도착했다는 것은 현재 TCP 연결에 심각한 문제가 있으므로 해당 연결을 더는 사용하면 안된다는 의미이다.
RST 패킷이 도착하면 자바는 read() 로 메시지를 읽을 때 다음 예외를 던진다.
MAC: java.net.SocketException: Connection reset
윈도우: java.net.SocketException: 현재 연결은 사용자의 호스트 시스템의 소프트웨어의 의해 중단되었습니다.
RST 패킷이 도착하면 자바는 ` write() ` ` 로 메시지를 전송할 때 다음 예외를 던진다.
MAC: java.net.SocketException: Broken pip
윈도우: java.net.SocketException: 현재 연결은 사용자의 호스트 시스템의 소프트웨어의 의해 중단되었습니다.
참고 - RST(Reset)
TCP 에서 RST 패킷은 연결 상태를 초기화(리셋) 해서 더 이상 현재의 연결을 유지하지 않겠다는 의미를 전달한다.
여기서 "Reset" 은 현재 세션을 강제로 종료하고, 연결을 무효화 하라는 뜻이다.
RST 패킷은 TCP 연결에 문제가 있는 다양한 상황에 발생한다. 예를 들어 다음과 같은 경우들이 있다.
- TCP 스펙에 맞지 않는 순서로 메시지가 전달될 때
- TCP 버퍼에 있는 데이터를 아직 다 읽지 않았는데, 연결을 종료할 때
- 방화벽 같은 곳에서 연결을 강제로 종료할 때도 발생한다.
자기 자신의 소켓을 닫은 이후에 read() , write() 을 호출할 때 발생한다.
- 상대방이 연결을 종료한 경우 데이터를 읽으면 EOF가 발생한다.
- -1, null, EOFException 등이 발생한다.
- 이 경우 연결을 끊어야 한다.
- java.net.SocketException: Connection reset
- RST 패킷을 받은 이후에 read() 호출
- java.net.SocketException: Broken pipe
- RST 패킷을 받은 이후에 write() 호출
- java .net.SocketException: Socket is closed
- 자신이 소켓을 닫은 이후에 read() , write() 호출
네트워크 종료와 예외 정리
네트워크에서 이런 예외를 다 따로따로 이해하고 다루어야 할까? 사실 어떤 문제가 언제 발생할지 자세하게 다 구분해서 처리하기 어렵다.
따라서 기본적으로 정상 , 강제 종요 모두 자원을 정리하고 닫도록 설계하면 된다.
예를 들어서 SocketException , EOFException 은 모두 IOException 의 자식이다. 따라서 IOException 이 발생하면
자원을 정리하면 된다. 만약 더 자세히 분류해야 하는 경우가 발생하면 그때 예외를 구분해서 처리하면 된다.
'Network' 카테고리의 다른 글
버퍼(Buffer)와 I/O 최적화 (0) | 2025.04.21 |
---|---|
HTTP 메서드란? 멱등성 (0) | 2025.04.01 |
HTTP/ HTTPS , SSL 가속기 (0) | 2025.03.19 |
L4 , L7 로드밸런서 (0) | 2025.02.12 |
TCP/IP 네트워크 이해하기 (4) - TCP (0) | 2025.01.27 |