상세 컨텐츠

본문 제목

[TCP 4-way-handshake] TIME_WAIT & CLOSE_WAIT EXCEPTION 재현

CS/네트워크

by young1403 2023. 8. 30. 13:42

본문

[성공과 실패를 결정하는 1%의 네트워크 원리]

라는 책의 스터디를 진행하며 TCP socket 연결 종료의 TIME_WAIT과 CLOSE_WAIT 상태를 확인해 보기 위해 실습해 보며 정리한 내용을 적은 글입니다.

 

[4-way-handshake]

 

4-way-handshake

TCP CONNECTION이 이루어진 상황에서 서버와 클라이언트간의 연결을 끊어야 할 때, TCP 프로토콜에서는 4-way-handshake 과정을 거치게 됩니다.

 

이 4-way-handshake 과정에 대해 간단하게 설명해보자면

  • 1. 송신 측에서 수신 쪽으로 FIN패킷을 보냅니다.
  • 2. 수신 쪽에서 받았다는 ACK 패킷을 송신 측으로 우선 응답해 줍니다.
  • 3. 수신 쪽에서는 송신 측으로부터 FIN패킷을 받았을 때부터 해당 포트에 연결되어 있는 쪽에  close()를 요청합니다. (이때 ACK패킷을 받은 수신 측은  FIN_WAIT2로 바뀝니다.)
  • 4. close() 요청을 받은 쪽에서 종료 프로세스를 진행하고 FIN패킷을 송신 측에 보내며 LAST_ACK 상태로 바꿉니다.
  • 5. FIN을 받은 송신 측은 ACK를 수신 쪽에 다시 전송하고 TIME_WAIT으로 상태를 바꿉니다. TIME_WAIT에서 일정 시간이 지나면 CLOSED 됩니다. ACK를 받은 서버도 포트를 CLOSED로 닫으며 SOCKET TERMINATE 합니다.

 

현재 서버와 클라이언트 관계를 송신, 수신이라고 표현을 하였는데 Active Close(또는 Initiator)와 Passive Close(또는 Receiver)로 표현하는 것이 더 정확합니다. TCP에서 3-way-handshake로 연결을 수립하거나 4-way-handshake로 연결을 종료하는 과정 모두 클라이언트 측에서 시작할 수도 있고 서버 측에서 시작할 수 있기 때문입니다. 즉 close_wait은 서버뿐만 아니라 클라이언트 측에서도, time_wait도 클라이언트 측뿐만 아니라 서버에서도 가질 수 있는 상태입니다.

 

 

[CLOSE_WAIT과 TIME_WAIT 재현]

CLOSE_WAIT : Passive Close 쪽에서 FIN패킷을 받은 후 FIN패킷을 Active Close 쪽으로 보내는 LAST_ACK까지의 상태를 말합니다.

 

TIME_WAIT : Active Close에서 FIN패킷을 받고 그에 대한 ACK를 넘겨줄 때까지의 상태를 말합니다.

 

저는 위의 색이 칠해져 있는 부분에 임의로 THREAD_SLEEP을 주어서 CLOSE_WAIT과 TIME_WAIT 상태를 직접 확인해보려 했습니다. Wireshark를 사용하여 전송되는 패킷을 확인하고 netstat 명령어를 통해 서버와 클라이언트 TCP 상태를 확인할 수 있었습니다.

 

아래는 CLOSE_WAIT과 TIME_WAIT 상태를 확인하기 위해 JAVA 코드로 재현한 server와 client측 재현 코드입니다.

public class ServerExam {

    public static void main(String[] args) {
        int port = 8888;

        try {
            /** 서버측 소켓 생성*/
            ServerSocket serverSocket = new ServerSocket(port);
            System.out.println("서버측 소켓을 생성하였습니다 port : " + port);

            /** 서버와 클라이언트 연결 */
            Socket clientSocket = serverSocket.accept();
            System.out.println("클라이언트와 연결되었습니다.");

            BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
            String receivedMessage = in.readLine();
            System.out.println("클라이언트로부터 받은 메시지 : " + receivedMessage);

            /** 4초간 대기 후 CLOSE 실행 */
            Thread.sleep(4000);

            /**
             * 서버에서 클라이언트로 보내는 데이터 메시지
             * 서버 -> 클라 : FIN packet 보냄
             **/
            out.println("서버에서 클라이언트로 보내는 메시지입니다!");
            System.out.println("클라이언트 socket close 시작");
            clientSocket.close();

            /** client로부터 FIN패킷을 받고나서 time-wait 시작 */
            Thread.sleep(11000); // 11초간 sleep , 이후 time-wait 상태 유지 약 1분
            serverSocket.close(); // ACK 패킷은 이미 보내졌지만 명시적으로 SERVER측 SOCKET CLOSE
            System.out.println("서버 측 종료 완료");

        } catch (IOException | InterruptedException e) {
            System.out.println("server에서 에러가 발생하였습니다");
            e.printStackTrace();
        }
    }
}

public class ClientExam {

    public static void main(String[] args) {
        String serverAddress = "localhost";
        int serverPort = 8888;

        try {
            /** 클라이언트측 소켓 생성*/
            Socket clientSocket = new Socket(serverAddress, serverPort);
            System.out.println("클라이언트측 소켓을 생성하였습니다 : " + serverPort);

            /** 서버와 클라이언트 연결 */
            BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
            Scanner scanner = new Scanner(System.in); /** 사용자가 입력한 데이터 */
            String messageToSend = scanner.nextLine();
            out.println("(클라이언트 to 서버)" + messageToSend);

            /**
             * 첫번째 FIN 패킷이 넘어오면서 ACK패킷을 생성해서 보냄
             * 서버로부터 읽어온 데이터 출력
             * */
            String receivedMessage = in.readLine();
            System.out.println("서버로부터 받은 메시지 : " + receivedMessage);

            Thread.sleep(10000); /** 10초간 대기. close-wait 시작 */

            System.out.println("클라에서 서버로 FIN 패킷 전송");
            clientSocket.close(); /** 양방향 종료 _ FIN 패킷 보냄. close-wait 종료 */
            System.out.println("클라이언트 측 종료 완료");

        } catch (IOException | InterruptedException e) {
            System.out.println("client에서 에러가 발생하였습니다");
            e.printStackTrace();
        }
    }
}

 

아래처럼 서버와 클라이언트 간의 connection이 이루어진 후 thread sleep을 걸어준 4초 후에 FIN패킷이 전송됨을 볼 수가 있습니다.  (FIN패킷만이 아닌 [FIN, ACK]으로 전달됨을 볼 수가 있는데요. FIN과 ACK를 함께 보냄으로써 만약 FIN패킷이 유실되더라도 수신 측에서 ACK패킷을 받음으로써 송신 측의 패킷 전송상태를 확인할 수가 있습니다. 따라서 패킷의 손실 때문에 지연이 발생하지 않아 안전하고 빠르게 수행할 수가 있다는 장점 때문에 ACK를 포함해서 보내는 것이라고 합니다.)

서버에서 클라쪽으로 FIN 패킷 전송

그리고 thread sleep 10초간 걸어준 부분에서 클라이언트로부터 FIN패킷을 받음을 볼 수 있습니다.

mac os :
 netstat -p tcp -a -n | grep TIME_WAIT | grep 8888
 netstat -p tcp -a -n | grep CLOSE_WAIT | grep 8888
 netstat -p tcp -a -n | grep 8888

window :
 netstat -a -n | findstr ":8888"

os에 따른 위의 netstat 명령어를 사용하여 소켓상태를 확인할 수 있습니다.

아래는 서버에서 클라이언트로 첫 번째 FIN패킷을 보낸 후 thread sleep (약) 10초 간의 상황입니다. FIN패킷을 받은 수신 측은 CLOSE_WAIT 상태로 바뀜을 확인할 수 있었고 수신 측으로부터 ACK를 받은 송신 측은 FIN_WAIT_2  상태가 됨을 볼 수 있습니다.
(FIN이후 ACK 사이에 DELAY를 주어서 CLOSE_WAIT과 FIN_WAIT_2 상태를 따로 확인하고 싶었으나 아직 방법을 찾지 못하고 포스팅을 하였습니다. 혹시 이 글을 읽고  CLOSE_WAIT과 FIN_WAIT_2 상태를 따로 확인이 가능한 방법을 아시는 분은 댓글 남겨주시면 감사하겠습니다.)

 

10초가 지난 이후 수신 측에서 연결을 CLOSE 하겠다는 FIN패킷을 송신 측으로 전송(회신)을 해주면 아래처럼 서버 측이 TIME_WAIT 상태로 바뀌는 것을 볼 수 있었습니다. 

이렇게 송신 측에서 받은 FIN에 대한 응답을 수신 측으로 전달해 주면 그제야 수신 측의 소켓은 TERMINATE 됩니다.

송신 측은 약 60초간 TIME_WAIT상태가 지속되고 나서 마지막으로 송신 측의 소켓이 TERMINATE 됩니다. 

 

TIME_WAIT 상태가 왜 지속되는지는 아래의 사진으로 예를 들면 CLIENT로부터 FIN패킷을 SERVER로 전달을 했는데 이거에 대한 응답이 마지막 ACK 패킷입니다. 그런데 전달 중에 이 ACK패킷이 소멸된다면 어떤 상황이 생길까요?
CLIENT 측에서 보낸 FIN패킷에 대한 ACK을 받지 못하였기 때문에 FIN패킷(아래 사진에서 '종료 준비 OK' 부분)을 다시 보내게 되는데 이러한 상황이 발생할 수 있기 때문에 필요한 게 TIME_WAIT입니다.

 

이렇게 TIME_WAIT 상황에서 IP와 PORT는 사용 중인 상황이기 때문에 같은 IP와 PORT를 사용하여 서버를 띄우려고 하면 bind exception이 발생합니다. 왜냐하면 TIME_WAIT 상황에선 IP와 PORT를 유지하고 있기 때문입니다. 따라서 TIME_WAIT 상황에서 서버를 띄우고 싶으면 어느 정도 일정시간 지나고 나서 혹은 PORT 번호를 바꾸면 서버를 띄울 수 있습니다.

 

하지만 위의 코드로 구현을 하고 같은 port와 로컬 ip로 접속하는 server2 class에서 접속을 시도하면 'Address already in use: bind' 에러가 간헐적으로 나오는 것을 확인했습니다. 왜 에러를 가끔씩 뱉는지에 대해 헤딩을 좀 오래 하였는데요.

 

아래처럼 time_wait 상태가 서버에서 listening 인 상태여야 제가 예상한 bind exception인 already in use 에러를 뱉는 것이었습니다. listening 상태가 짧았기에 제가 서버를 다시 실행시키려 하면 exception이 나지 않고 소켓을 재사용하는 상황이었습니다. 

if (bind(listenfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0)
error("Error: bind() Not enough privilleges(<1024) or already in use");

사용/연결 중인 서버의 port와 ip에 또 서버를 띄우려 할 때

   

그리고 이 listening이 없는 (살아있는 서버가 없는) 상태에서 client에서 해당 서버로 연결을 시도하면 listen중인 서버가 없기 때문에 연결을 수립할 수 없어 Connection refused: connect 에러를 뱉는 것까지 확인할 수 있었습니다.

사용/연결상태가 아닌 서버에 클라이언트 측에서 연결을 시도하려 할 때

 

 

  사실 처음엔 단순하게 socket 소멸과정을 공부하면서 알게 된 'time_wait 상태에선 같은 ip와 port로 접속 불가' 상태의 exception을 보고 싶은 마음에 실습을 시작했다가 생각보다 알아야 할 지식이 많다는 것을 깨달았습니다. 소켓의 로우레벨까지 확인을 해봐야 더 정확한 정보 전달이 되겠지만 아래 참고자료와 wireshark 등을 사용한 실습을 바탕으로 작성된 글임을 참고해 주시면 감사하겠습니다. 


특히 아래 참고자료 중 하나인 kaon.park님께서 작성하신 'close_wait & time_wait 최종분석' 글이 큰 도움이 되었습니다. 더 자세한 close_wait & time_wait 동작과 상태를 알고 싶으신 분들은 아래 글을 참고하시면 좋을 것 같습니다.

 

감사합니다.

 

참고 자료

성공과 실패를 결정하는 1%의 네트워크 원리
https://tech.kakao.com/2016/04/21/closewait-timewait
https://youtu.be/Secb95GGmKI?si=l4uhqso5kiEA8FR4

 

 

관련글 더보기

댓글 영역