<Java> Selector가 TCP FIN 패킷을 감지하는 법

배경

특정 캐시 노드를 정상적으로 종료시키고 Java단에서 해당 캐시 노드와 연결된 TCP 소켓 채널 객체의 상태를 확인해봐야하는 작업을 진행중이였다. 이를 확인하기 위해 SocketChannel을 진단하는 isConnected 값을 확인해보니 true 값이 리턴되고 있었다. TCP 연결이 제대로 끊어졌다면 해당 값이 false로 나와야 한다고 생각하였다. 그래서 해당 TCP 연결의 4way 핸드쉐이킹 과정을 확인해보기 위해 lsof 명령을 통해 현재 네트워크 연결 상태를 모니터링 해보았다. 캐시 서버가 띄워진 포트의 경우 down을 시키면 정상적으로 포트가 회수되어진다. 반면 서버와 연결된 자바 프로세스의 TCP 연결은 CLOSE_WAIT 라는 상태를 가진채로 대기하다가 일정시간이 지난 뒤 CLOSE로 변경되어 종료되는 상태를 보여주었다.

CLOSE_WAIT?

우선 CLOSE_WAIT 상태에 대해서 먼저 알아보았다. 4-way HandShake에서 연결 해제를 요청하는 쪽을 Active-close라 하면 Active-close에서 먼저 FIN 패킷을 보낸다. 이후 해당 FIN에 대한 ACK을 보내주고 연달아 FIN을 보내줘야 한다. 여기서 ACK과 FIN을 보내는 사이의 term이 있는데 이 순간에 Passive-Close 쪽의 TCP 연결 상태가 CLOSE_WAIT로 설정된다.

해당 상태가 발생하는 이유를 다음과 같이 추측해보았다. 상대로부터 FIN을 받고 이에 대한 ACK까지 보냈으나 해당 TCP 연결을 종료한다는 시그널을 자신의 프로세스로부터 받지 못한 상태일 경우 CLOSE_WAIT가 지속된다. 즉, 현재 상황에서는 TCP 연결 자체는 종료를 시도하였으나 TCP 연결 여부를 관리하는 자바 프로세스로부터 적절한 close 명령을 받지 못해 CLOSE_WAIT상태를 유지시키는 것이다. 이러한 가설이 맞는지 TCP 패킷 덤프를 통해 확인해보자.

패킷 덤프

11211번 포트가 캐시 서버(Active-Close)이고 49679번 포트가 Java Process와 연결된 포트 번호이다. 보이는것과 같이 FIN을 보내고 이에 대한 응답으로 ACK이 정상적으로 발생한다. 하지만 이후 Passive-Close 쪽에서 보내는 FIN 패킷이 없고 일정 시간이 지난 후에 RST 패킷이 발생하면서 연결이 종료되어 CLOSE 상태로 변경되게 된다.

위와 같은 결과를 보고 내가 생각한 것은 TCP FIN 패킷을 수신하는 Java의 Seletor 구현에서 이를 어떤 신호를 통해 받아들이고 처리해주냐에 달려 있다고 생각하였다.

Selector가 동작하는 원리

기본적으로 Selector는 공식문서에도 기술되어있듯이 멀티플렉서를 구현한 구현체이다. 대략적인 구조를 그림으로 나타내면 아래와 같다.

공식문서의 설명에 따르면 Selector는 총 3가지 Set을 내부적으로 가진다.

  • key set
  • selected-key set
  • cancelled-key set

Key-set은 현재 Selector에 등록된 모든 Channel에 대한 Selection Key를 유지한다. 위 이미지에서 SocketChannel을 Selector에 등록하면 그에 대한 리턴값으로 Selectoin-Key을 Key-Set에 등록한다.

Selected-key set의 경우 최소 한 가지 연산에 대한 준비가 완료된 채널들의 Selection Key를 관리한다. 즉 연산할 준비가 되어 있는 채널에 대한 Key를 관리한다.

마지막 cancelled-key의 경우 Selection Key는 취소되었지만 아직 채널이 등록 해제가 되지 않은 Key를 관리한다. 당연하게 등록 집합과 취소 집합 모두 Key-Set의 부분 집합이다. 참고로 위 그림에서는 취소 집합은 표현되지 않았다.

Selection Key는 특정 채널과 셀렉터 사이에서 발생하는 이벤트에 대한 메타 데이터를 가지고 있는다. 해당 값을 토대로 Selector가 발생한 이벤트를 처리한다.

해당 클래스는 내부적으로 두 가지 operation set을 통해 자신이 수행할 연산 종류와 연산 준비 상태를 나타낸다. 먼저 ReadySet이 있고 이는 현재 Selectoin Key가 자신이 수행할 연산에 대한 준비 상태를 나타내준다. 두번째는 Interest op에 대한 정보를 가지고 있는 InterestedSet이다. Interest op의 경우가 현재 자신이 어떤 종류의 연산을 수행할지에 대한 정보를 가진다. 총 4가지의 타입이 있다. 하나의 Selection Key에는 여러개의 Interest Op가 설정될 수 있다.

  • OP_ACCEPT : 클라이언트가 서버에 접속했을 때 발생하는 이벤트
  • OP_CONNECT : 서버가 클라이언트의 접속을 허락하였을 때 발생하는 이벤트
  • OP_READ: 서버가 클라이언트의 요청을 read할 수 있을 때 발생하는 이벤트
  • OP_WRITE : 서버가 클라이언트의 요청을 write할 수 있을 때 발생하는 이벤트

마지막으로 SocketChannel의 경우 TCP 연결을 맺을 수 있게하는 구현체이다. 해당 채널을 Selector에 등록하면서 멀티 플렉서에 의해 연결이 관리된다. 또한 SocketChannel은 OP_CONNECT, OP_READ, OP_WRITE 세 가지 연산들만 채널의 Selection key에 등록 할 수 있다.

셀렉터의 동작을 이해하는데 필요한 3가지 개념을 살펴보았다. 전체적인 동작 원리는 다음과 같다.

  1. 생성한 채널을 셀렉터에 등록시키고 셀렉터 내에 셀렉트 키를 저장한다.
  2. 특정 채널에 셀렉트 키에 설정된 연산이 준비되면은 준비 상태를 셀렉트 키에 저장한다.
  3. 준비상태가 된 셀렉트 키를 셀렉터가 감지하고 이를 통해 이벤트를 처리한다.

TCP FIN 패킷 감지?

TCP 연결을 맺는 객체는 앞서 언급된 Selector를 통해 하나의 객체에서 여러 TCP 커넥션을 관리한다. 그리고 다른 스레드로부터 캐시 요청이 발생하면 OP_WRITE로 해당 TCP 커넥션의 interest op를 변경하고 캐시 서버로부터 응답이 발생하면 OP_READ로 변경하는 방식으로 동작한다. 그리고 만약에 read 또는 write 연산 요청이 없는 경우엔 0으로 interest op를 설정한다. 즉, TCP 연결을 맺은 서버로부터 read 이벤트가 발생하여도 감지하지 못하는 상태이다.

Java에서 TCP FIN 패킷을 어떻게 감지하는지 알아보니 다음과 같은 내용을 발견할 수 있었다. read()를 통해 읽은 값이 -1인 경우 서버로부터 전송된 FIN 패킷을 감지하는 상황이라고 볼 수 있다.

해당 내용은 stackoverflow에서 찾은 내용이라 사실 확신할 수는 없었다. 이를 검증하기 위해 현재 Selector 내에서 interest op를 설정하는 로직을 변경해보았다. 아래 로직이 기존의 구현이다. 각각 분기를 통해 read 또는 write가 있는 경우 해당 연산만을 SelectionKey의 Interest Op으로 설정해주는 것을 확인 할 수 있다.

public final int getSelectionOps() {
    int rv = 0;
    if (getChannel().isConnected()) {
      if (hasReadOp()) {
        rv |= SelectionKey.OP_READ;
      }
      if (toWrite > 0 || hasWriteOp()) {
        rv |= SelectionKey.OP_WRITE;
      }
    } else {
      rv = SelectionKey.OP_CONNECT;
    }
    return rv;
  }

우리에게 필요한 로직은 read 연산이 발생하지 않더라도 서버로부터 발생하는 FIN 패킷에 의해 발생하는 read 이벤트를 감지해야하는 것이다. 간단하게 기본으로 설정되는 Op를 read로 설정해주면 된다.

public final int getSelectionOps() {
    int rv = 0;
    if (getChannel().isConnected()) {
      rv |= SelectionKey.OP_READ;
      if (toWrite > 0 || hasWriteOp()) {
        rv |= SelectionKey.OP_WRITE;
      }
    } else {
      rv = SelectionKey.OP_CONNECT;
    }
    return rv;
  }

그림으로 도식화 시켜보면 아래와 같이 구성된다.

정상적으로 FIN을 수신을 하고 해당 이벤트를 감지 했다면 Java단에서도 FIN 패킷을 서버에게 보내고 4-way HandShaking을 정상적으로 마무리하고 종료되는 과정이 발생하면 된다.

결과

위와 같이 변경된 자바 프로세스를 띄우고 21111번의 캐시 서버와 TCP 연결을 맺도록 구성했다.(자바는 50710번 포트) 해당 상태에서 캐시 서버를 정상 종료 시키고 lsof를 통해 상태를 확인해보니 더 이상 CLOSE_WAIT 상태가 지속되지 않았다. 또한 패킷 덤프를 떠보니 아래와 같이 정상적으로 active-close와 passive-close가 FIN 패킷을 주고 받은 것을 확인할 수 있다.

본 포스팅을 작성하면서 Java의 Selector의 디테일한 내부 동작 그리고 TCP 4-way HandShaking이 발생하는 동안의 상태 값들에 대해 정리해 볼 수 있었다.

참고 링크

https://stackoverflow.com/questions/9868356/how-to-detect-fin-tcp-flag-in-java-application

https://smjeon.dev/etc/tcp-state/