본문 바로가기
컴퓨터/Server

TCP의 연결 종료

by 김짱쌤 2015. 3. 27.

TCP의 단절 감지 : 유령 세션



1. 유령 세션이란?

서버에 연결된 클라이언트 세션이 네트워크 단절되었는데도 제대로 연결해제가 되지 않아서 그대로 남아있는 상태를 유령세션이라고 한다. 제대로 접속해제가 되지 않았기 때문에 또는 접속해제에 대한 감지가 이루어지지 않았기 때문에, 할당된 리소스등이 해제되지 않고, 재접속시 문제를 일으킬 수 있다. 리니지 랜선버그가 유령세션 때문에 발생한 버그 사례이다.


2. 유령 세션은 왜 생기는 걸까?


TCP 상태 다이어그램을 참고하면서 일반적인 연결 해제 과정을 살펴보자. 우선 여기서 사용하는 클라이언트와 서버라는 이름은 실제 게임 서버나 클라이언트의 의미가 아니고, TCP에 참여하는 두 컴퓨터를 말한다. 먼저 종료를 시도하는 쪽을 부르기 편하게 클라이언트라 하고, 종료 요청을 받아 연결을 끊는 상대방을 서버라고 한다.


ESTABLISHED인 클라이언트가 closesocket()를 호출하면 서버에 FIN패킷을 전송한다. 그리고 클라는 FIN_WAIT1상태가 된다. ESTABLISHED인 서버가 FIN패킷을 받으면 클라에게 종료 요청 받았다는 ACK를 전송하면서 CLOSE_WAIT 상태가 된다. CLOSE_WAIT상태가 된 서버는 종료에 필요한 처리를 진행하고, closesocket()을 호출하여 클라에 FIN을 전송한다. 서버는 LAST_ACK상태가 된다. FIN_WAIT2인 클라가 FIN패킷을 받으면, 이에대한 ACK를 송신하고 세션종료를 준비하는 TIME_WAIT상태로 전환한다. 이 일반적인 종료과정이 4번의 데이터 송수신을 거쳐 진행되기 때문에 4-ways-handshake라고 불리기도 한다.


클라이언트 세션이 바로 종료하지 않고 TIME_WAIT상태에 돌입하여 잠시 대기하는 이유는 서버가 FIN을 보내기 전에 전송한 패킷이 아직 도착하지 않은 경우에 대비하기 위한 것이다. 네트워크 패킷이 전송순서대로 도착하지 않기 때문에 충분히 일어날 수 있는 경우이다. 이 경우에 바로 종료시켜 버린다면, 이 패킷의 데이터는 바로 유실된다. 유실을 막기 위하여 서버로 부터 FIN수신 이후에 일정시간동안 세션을 남겨서 잉여 패킷을 기다리는 단계가 TIME_WAIT 상태이다. TIME_WAIT는 처음 종료 요청을 한 클라이언트가 종료하기 위해서 반드시 거쳐야하는 단계이다. 그리고 양쪽에서 종료처리를 하고 FIN을 전송해야 TIME_WAIT에 진입할 수 있다.


위의 단계들은 매우 합리적인 것 처럼 보인다. 하지만 문제는 실제 네트워크의 현실은 그렇게 합리적으로 작동하지 않는다는 점이다. ESTABLISH 상태의 클라이언트가 closesocket()이나 shutdown()을 통해 FIN 패킷을 전송하지 못한채로 네트워크 연결이 단절되는 경우를 생각해보자. 위 다이어그램에는 이 비정상 종료를 해결할 수 있는 루트가 존재하지 않는다. 서버와 클라이언트 모두 자신이 ESTABLISH상태에 있다고 인지한다. Recv나 Send 도중이었다면, 상대의 ACK를 기다리고, Time out되어 재전송 또는 재전송 요청을 반복하고 있게된다. 


이 상황에서 다시 두 노드가 네트워크 연결이 되면 재전송 중인 패킷이 상대방에게 도착하게 된다.그러면 상대방은 네트워크 환경 문제로 패킷이 지연되었다고 생각하며 패킷을 다시 처리할 것이다. 만일 이런 상황이 네트워크 게임에서 이루어 졌다고 생각해보자. 클라이언트가 FIN 패킷없이 네트워크 종료가 되고, 다시 연결이 되면, 서버는 그전까지 지 클라이언트가 입력받아 전송한 패킷들을 지연처리하여 결과를 저장할 것이다. 그러면 일반적으로는 허용되지 않는 결과를 초래할 수 있다. 이것의 대표적인 예가 리니지의 랜선버그 사례이다.


3. 어떻게 처리해야 되는 것인가?

응답에 대한 Time Out을 사용하는 간단한 아이디어를 제시할 수 있다. 하지만 아무 요청도 전송도 일어나지 않은 상황에서 접속 종료가 된다면, 어떤 응답도 요청되지 않은 상태에서 연결이 비정상 종료되므로 이 방법으로는 완전히 해결하기 어렵다.


그렇다면 지속적으로 연결을 확인하는 패킷을 전송하는 방법을 생각할 수 있다. 일반적으로 Heart Beat라고 부르는 방법으로 클라이언트와 서버가 주기적으로 패킷을 주고 받는 형태이다. socket에서도 SO_KEEPALIVE 옵션을 통해서 쉽게 사용할 수 있는 기능이다.


BOOL optval = TRUE; setsockopt( m_Socket, SOL_SOCKET, SO_KEEPALIVE, (const char*)&optval, sizeof(BOOL) );


디폴트 간격은 2시간으로 게임에서 사용하려면 옵션 조절이 필요하다. 윈도우 2000 이하 버전 서버에서는 레지스트리의 옵션값을 직접 바꿔서 기능하였고, 2000이상 버전부터는 코드 레벨에서 커스터마이징 할 수 있는 상위 호환 옵션인 SIO_KEEPALIVE_VALS를 지원한다. 이전 버전에서는 레지스트리를 조작하여 모든 SO_KEEPALIVE 옵션의 주기를 결정했다면, 이번 버전에서는 소켓별로 Heartbeat주기를 조절할 수 있다. tcp_keepalive라는 구조체를 통해서 옵션값을 설정한다.


struct tcp_keepalive
{
    u_long onoff;
    u_long keepalivetime;
    u_long keeoaliveinterval;
}


두 옵션 모두 상대방/자신이 비연결 상태로 전환되었을 떄 keepalive 메시지를 송,수신하여 상태를 체크한다. keepaliveinterval값은 응답을 대기시간이며, keepalivetime은 메시지를 전송하는 주기이다. 실제 사용 예는 아래와 같다. 


DWORD dwError = 0L ;
tcp_keepalive sKA_Settings = {0}, sReturned = {0} ;
sKA_Settings.onoff = 1 ;
sKA_Settings.keepalivetime = 5500 ;        // Keep Alive in 5.5 sec.
sKA_Settings.keepaliveinterval = 3000 ;        // Resend if No-Reply

DWORD dwBytes;
if (WSAIoctl(hSocket, SIO_KEEPALIVE_VALS, &sKA_Settings, 
    sizeof(sKA_Settings), &sReturned, sizeof(sReturned), &dwBytes, NULL, NULL) != 0 )
{
    dwError = WSAGetLastError() ;
    TRACE( _T("SIO_KEEPALIVE_VALS result : %dn"), WSAGetLastError() );
} 


TCP LINGER 옵션



1. LINGER 옵션과 그 필요성

closesocket() 호출 시, 아직 send되지 않고 sendBuffer에 남이있는 Data를 어떻게 처리할 지 OS에게 선언하는 옵션이다. 이 옵션의 필요성을 설명하기 위해서 TCP의 send()와 recv()시의 버퍼상황과 전송과정을 설명할 필요가 있다. Sender.exe와 Receiver.exe 두 프로그램이 서로 연결된 상황을 생각해보자.


http://kuaaan.tistory.com/118


send() 함수는 인자로 주어진 Buffer의 데이터를 해당 Socket에 할당된 SendBuffer에 복사하고 return한다. SendBuffer는 커널이 가지고 있는 보낼 데이터의 버퍼일뿐, 함수가 반환되었다고 해서 모든 데이터가 상대방에게 전송된 것은 아니다.


운영체제는 SendBuffer에 들어온 데이터를 Peer에게 전송하려고 한다. 데이터가 전송되기 위해서는 Peer의 RecvBuffer에 여유공간이 있어야한다. Sender와 Receiver는 슬라이딩 윈도우 기법을 통해서 상대방의 여유공간을 확인한다. Reciver.exe가 recv()를 제때 호출하여 RecvBuffer를 비워주지 않으면 여유공간이 부족하게 되고, Sender는 전송을 지연시킨다.


http://kuaaan.tistory.com/118


SendBuffer에 데이터가 남아있는 상황에서 Sender.exe가 closesocket()을 호출하는 상황이 발생했다고 가정해보자. 소켓을 정리하면서 소켓이 할당받은 커널의 SendBuffer도 해제가 될 것이다. 그런데 SendBuffer에 데이터가 남이있는 상황에서 남은 데이터를 어떻게 처리해야 되는지는 민감한 문제가 될 수 있다. 사용자에 따라서 남은 데이터를 그냥 삭제해도 무관한 경우가 있고, 절대 삭제하면 안되는 경우도 있을 수 있기 때문이다. 이때 힘을 발휘하는 것이 TCP의 LINGER 옵션이다. LINGER 옵션은 위에서 말한것 처럼 SendBuffer에 남은 데이터를 어떻게 처리할지를 결정한다.  


2. LINGER 옵션의 사용방법


LINGER  ling = {0,};  
ling.l_onoff = 1;   // LINGER 옵션 사용 여부  
ling.l_linger = 0;  // LINGER Timeout 설정  

// LINGER 옵션을 Socket에 적용  
setsockopt(Sock, SOL_SOCKET, SO_LINGER, (CHAR*)&ling, sizeof(ling));  

// LINGER 옵션이 적용된 Socket을 closesocket()한다.  
closesocket(Sock);  


이 옵션을 사용하는 3가지 방법

1) ling.l_onoff = 1, ling.l_linger = 0

closesocket() 함수는 즉시 반환, 버퍼에 남은 데이터는 버림. 

강제 종료(abortive shutdown)처리. TIME_WAIT가 남지 않음.

2) ling.l_onoff = 1, ling.l_linger != 0

blocking 방식의 정상 종료(graceful shutdown)처리

3) ling.l_onoff = 0 

non-blocking 방식의 정상적 종료처리. 종료처리가 완결된 시점을 알수는 없다.


3. TIME_WAIT를 남기지 않는 것의 장점

위에서 설명한 것 처럼 TIME_WAIT는 종료시 잔여 패킷이 제대로 전달 되기위해서 필요한 단계이다. 이 단계는 어떻게 처리하느냐에 따라 큰 이슈를 발생시킬 수 있는데, 종료시킨 소켓에서 TIME_WAIT 상태가 2분 이상 지속되기 때문이다. 만약 서버쪽에서 각 클라이언트세션의 소켓에게 closesocket()을 호출한다고 생각해보자. 그러면 클라이언트세션 소켓 하나마다 2분의 TIME_WAIT 단계를 거쳐야하는 문제가 발생한다. 많은 클라이언트가 오고가는 서버의 입장에서 2분간 지속되는 TIME_WAIT 소켓들은 커다란 리소스 낭비이며, 장애 발생의 원인이 된다. 따라서 클라이언트 세션을 종료시키는 서버의 경우 TIME_WAIT를 남기지 않는 abortive shutdown으로 LINGER를 설정하는 것이 좋다.


Graceful Shutdown



1. 일반적인 종료의 순서를 따르는 것이 우아한 종료이다.

유령세션에서 언급한 바 있지만 이른바 4-way-handshake라 불리는 정상적인 종료의 순서를 따른다. TIME_WAIT단계를 부정적으로 이야기하는 사람들도 있지만, 이 단계또한 데이터의 손실 없이 올바르게 종료할 수 있도록 필요한 단계이다. 


http://kuaaan.tistory.com/118

4-ways-handshake

2. 클라이언트가 먼저 closesocket()을 호출해야한다

LINGER 옵션에서 언급한 바, 서버에서 클라이언트세션의 소켓을 먼저 closesocket() 호출하면, TIME_WAIT로 남아있는 세션들 때문에 리소스를 낭비하게 된다. 이것을 해결하기 위해서 LINGER 옵션을 변경하여 강제종료 시킬 수 있지만, 이것을 우아한 종료라고 말하기 힘들다. 따라서 클라이언트 쪽에서 closesocket()하여 문제없이 정상종료하는 것이 우아한 종료가 된다.