들어가기에 앞서 : 이 글은 IIS 등 웹서버를 운영하시는 서버운영자를 위한 글이 아닙니다. 이 글에서는 Socket 프로그래밍을 통해 서버 프로그램을 개발할 때 안전한 소켓 종료절차에 대해 논해보고자 합니다. TIME_WAIT를 없애는 웹서버 설정을 찾고 계신 분은 다른 글을 검색하시기 바랍니다. ^^
----------------------------------------------------------------------------------------------------------
화장은 하는것보다 지우는 것이 중요하다고 하죠. 마찬가지로 네트워크도 세션을 시작하는 것보다 잘 마무리짓는 것이 중요할 수 있습니다. 이 글에서는 어떻게 세션을 종료해야 데이터 손실 없이 서버에 무리를 주지 않고 잘 마무리 지을 수 있는지를 논하고자 합니다.
1. TCP의 세션 종료 매커니즘과 TIME_WAIT
흔히 알고 있듯이 TCP 프로토콜은 세션을 맺는 3-way Handshake라는 매커니즘을 가지고 있습니다. (아래 그림의 각 상태들은 cmd 창에서 netstat -n 명령을 치면 나타나는 상태들입니다.)
마찬가지로 TCP 세션을 종료할 때도 이와 비슷한 4-way Handshake라는 매커니즘이 적용됩니다.
(그림 출처 : 갱주니 블로그)
종료 절차를 잠시 설명하면 다음과 같이 되겠지요. (위의 그림에서 Client는 흔히 얘기하는 개념이 아니라 종료절차를 먼저 시작한 측을 말합니다. 말하자면 closesocket()을 먼저 호출한 측이 client가 되는 거죠)
① Client 에서는 세션 종료를 시작한다는 의미로 Server에 FIN 패킷을 전송합니다.(C : FIN_WAIT1시작) Server에서 FIN을 수신하면 "니가 보낸 FIN 잘 받았다"라는 의미의 ACK를 Client에 전송하게 됩니다. (S : CLOSE_WAIT, C : FIN_WAIT2 시작)
코드상으로는 closesocket() 혹은 shutdown()함수를 호출하는 작업이 FIN 전송에 해당합니다.
② Client로부터 FIN을 받은 Server는 Client가 세션 종료를 시작했다는 것을 인지합니다. Server는 종료에 필요한 Application 적인 작업을 진행합니다. (CLOSE_WAIT) 종료할 준비가 되면 Server는 closesocket()을 호출하여 Client에 FIN을 전송합니다. 말하자면 "OK, 나도 종료할께"하고 알려주는 거죠 (S : LAST_ACK, C : TIME_WAIT시작)
코드상으로는, 서버에서 recv()를 호출했을 때 0을 return하는 경우가 Client가 먼저 closesocket()을 호출한 경우에 해당합니다.
③ Client에서는 Server에서 전송한 FIN을 수신하고, 이에 대한 ACK를 보내고 나면 세션을 종료할 수 있는 상태가 됩니다.
그런데 만약 "Server에서 FIN을 전송하기 전에 전송한 패킷이 Routing 지연이나 패킷 유실로 인한 재전송 등으로 인해 FIN패킷보다 늦게 도착하는 상황"이 발생한다면 어떻게 될까요? Client에서 세션을 종료시킨 후 뒤늦게 도착하는 패킷이 있다면 이 패킷은 Drop되고 데이터는 유실될 것입니다. (이러한 현상이 발생할 수 있는 것은 패킷이 반드시 전송한 순서대로 도착하는 것이 아니기 때문입니다.) 이러한 현상에 대비하여 Client는 Server로부터 FIN을 수신하더라도 일정시간(디폴트 240초) 동안 세션을 남겨놓고 잉여 패킷을 기다리는 과정을 거치게 되는데 이 과정을 "TIME_WAIT" 라고 합니다. (이런 내용은 Stevens 아저씨의 TCP/IP Illustrated vol 1을 보시면 자세히 나와 있습니다.)
TCP/IP의 상태를 나타내는 State Diagram에는 조금 다르게 그려놓은 그림도 있습니다만, 대략적인 내용은 비슷합니다.
(그림 출처 : 인터넷)
위의 그림을 잘 살펴보면 TIME_WAIT라는 상태에 대해서 몇가지 중요한 사실을 확인할 수 있습니다.
① TIME_WAIT는 반드시 세션 종료를 먼저 시작한 쪽(closesocket()을 먼저 호출한 쪽)에 남게 됨.
② 세션 종료 과정을 먼저 시작한 측에서는 반드시 TIME_WAIT를 거쳐야 CLOSED 상태로 갈 수 있음. 따라서 TIME_WAIT 상태 자체는 비정상적이거나 문제가 되는 상태가 아님.
③ 반드시 양쪽에서 모든 세션을 종료처리 해야 (즉, FIN을 전송해야) TIME_WAIT로 갈 수 있음.
이 TIME_WAIT라는 상태가 중요한 이유는, 만약 종료절차가 잘못 진행되어 서버쪽에 TIME_WAIT가 남게 되면 심각한 문제가 발생할 수도 있기 때문입니다. 일단 TIME_WAIT가 시작되면 2분여 이상 상태가 지속되게 되는데 모든 클라이언트들의 세션 종료시마다 서버 측에 TIME_WAIT가 발생한다면, 서버측에 부하가 될 뿐만 아니라 최악의 경우 서버에서는 더이상 새로운 연결을 받아들일 수 없는 상황이 발생할 수 있습니다. 말하자면.. 장애 상황이 발생하는 거죠. (실제로 실 운영서버에 이런 일이 발생하는 것을 직접 목격한 적이 있습니다. )
2. Linger 옵션에 대하여
Linger옵션이란 closesocket()를 호출했을 때 아직 send되지 않고 SendBuffer에 남아있는 Data를 어떻게 처리해야 할지를 OS에게 알려주는 옵션입니다. 이 Linger 옵션을 사용하면 TIME_WAIT를 남지 않게 소켓을 종료할 수 있습니다.
Linger 옵션의 의미를 알아보기 위해, 먼저 일반적인 send() / recv() 가 호출될 때 어떤 일이 벌어지는지 살펴보겠습니다.
1) send() 함수는 인자로 주어진 Buffer의 데이터를 해당 Socket에 할당된 SendBuffer에 복사하고 Return합니다. 중요한 것은 send()가 return했다고 해서 인자로 넘긴 데이터가 Peer에 전송된 것이 아니라는 것입니다.
2) OS는 SendBuffer에 들어온 Data를 Peer에게 전달합니다. 이때 Data가 전달되기 위해서는 상대편의 RecvBuffer에 공간이 남아있어야 하는데, 만약 Receiver.exe가 바쁘거나 해서 recv()를 제때 호출해주지 못할 경우 RecvBuffer에 공간이 부족하여 SendBuffer의 Data가 곧바로 전송되지 못한 채 지연될 수도 있습니다.
그렇다면 Sender는 Receiver의 RecvBuffer에 공간이 부족하다는 것을 어떻게 확인해서 전송을 하거나 중단하는 것일까요? TCP 프로토콜은 Ack패킷을 이용해 Sender와 Receiver 간에 RecvBuffer에 공간이 있는지를 체크하는 매커니즘을 제공하는데 이것을 "Sliding Window"라고 합니다. (이 부분에 대해서는 역시 스티븐스 아저씨의 TCP/IP Illustrated를 참고하세요. ^^)
3) 만약 위의 그림과 같이 SendBuffer에 Data가 남아있는 상황에서 Sender.exe가 closesocket()을 호출한 경우 어떤 일이 벌어질까요? 일단 Socket을 정리하려면 Socket에 할당된 SendBuffer도 파괴되어야 합니다. SendBuffer에는 아직 Data가 남아있는 상황이구요... 이런 상황에서 어떻게 처리해야 할지를 지정하는 방법이 LINGER 옵션입니다. LINGER 구조체의 값을 어떻게 설정하느냐에 따라 다음과 같은 세가지 처리가 가능합니다.
- 처리방법 1 : LINGER.l_onoff = 1, LINGER.l_linger = 0인 경우, closesocket() 함수는 즉시 return하고, 버퍼에 남아있는 Data는 버려집니다. 말하자면... 비정상종료(Abortive Shutdown)이 됩니다.
- 처리방법 2 : LINGER.l_onoff = 1, LINGER.l_linger = non-zero인 경우, 정상적인 종료과정(Graceful Shutdown)이 진행되며, 정상적 종료가 완료될때까지 closesocket()은 리턴하지 않습니다. 그렇지만, l_linger에 명시된 시간(초) 가 지나도 정상적인 종료가 완료되지 않을 경우 비정상종료(abortive shutdown)가 진행되고, closesocket()이 곧바로 리턴되며, Buffer에 아직 남아있는 데이터는 버려집니다.
- 처리방법 3 : LINGER.l_onoff = 0 인 경우, closesocket() 함수는 즉히 return한 후 정상적인 종료 과정(Graceful Shutdown)을 Background로 진행합니다. 이것이 Default 동작이지만, Application은 Background로 진행되는 종료작업이 언제 완료되었는지를 확인할 방법은 없습니다.
(참고 : http://msdn.microsoft.com/en-us/library/ms738547.aspx)
위에서 설명된 "처리방법 1" 대로 Linger 옵션을 지정했을 경우 TIME_WAIT가 남지 않습니다. 명시적으로 Abortive Shutdown을 지정했기 때문이죠.
다음과 같이 Linger 옵션을 사용하면 TIME_WAIT가 남지 않습니다.
3. Graceful Shutdown에 관하여
위에서 검토한 바와 같이 Buffer에 데이터가 남아있는 상태에서 연결을 강제로 종료할 경우 SendBuffer에 있는 데이터가 유실될 수도 있는데, 이러한 종료방식을 "Abortive Shutdown"이라고 합니다. 반대로 TCP 프로토콜의 4-way Handshake에 따라 데이터 유실 없이 종료하는 것을 "Graceful Shutdown"이라고 합니다.
인터넷의 TIME_WAIT 관련된 글 중 일부는 Linger 옵션을 사용하여 TIME_WAIT를 남기지 않고 세션을 종료하는 것을 "Graceful Shutdown"이라고 표현한 글이 있는데, 이것은 잘못된 표현입니다. 오히려 TIME_WAIT는 Graceful Shutdown이 이루어지는 과정에서 자연스럽게 발생하는 과정입니다. 억지로 TIME_WAIT를 남기지 않기 위해 Linger 옵션을 사용하는 것은 데이터 유실을 초래할 수도 있으므로 조심해야 합니다. (비록 저도 실제로 이런 경우를 보지는 못했지만... 이론적으로는 그렇다고 합니다. ^^)
또한 TIME_WAIT가 FIN 신호를 제대로 교환하지 못했기 때문에 발생한다는 의견도 잘못된 것입니다. 위에서 살펴보았듯이 TIME_WAIT는 Graceful Shutdown 과정에서 필수적으로 거쳐가야 할 과정입니다. (실제로 테스트를 해 본 결과, Linger옵션을 조작하는 경우를 제외하면, 어떠한 방식으로 세션을 종료하더라도 서버 혹은 클라이언트 양쪽에 모두 TIME_WAIT가 생기지 않도록 종료하는 방법을 찾지 못했습니다.)
우리가 지향해야 할 세션 종료는 무조건 TIME_WAIT를 남기지 않는 것이 아니라, TIME_WAIT가 서버 측이 아닌 클라이언트 측에 생기도록 하는 Graceful Shutdown입니다. 이렇게 되기 위해서는 위에서 살펴본 바와 같이 "Client가 먼저 closesocket()을 호출하도록 하는 것"이 가장 중요합니다. 이것을 보장하기 위해서는 Client와 Server 간에 종료 프로토콜을 설계할 때 Client가 먼저 closesocket()을 먼저 호출하도록 반영되어야 합니다. 이것만 확실히 지켜진다면 서버에는 TIME_WAIT가 남지 않습니다.
다음은 종료 프로토콜의 예입니다.
1) 서버에서 클라이언트에 "너 종료해라"는 커맨드를 전송합니다.
2) "너 종료해라"를 수신한 클라이언트는 서버에 "알았다 종료하겠다"를 전송한 후 즉시 closesocket()를 전송합니다. ( 마지막 통신이 클라이언트 -> 서버 방향으로 일어난다는 것이 중요합니다.)
3) "알았다 종료하겠다"를 수신한 서버는 해당 소켓에 대해 closesocket()을 호출합니다. 이때 안전장치로 Linger 옵션을 주어 Abortive Shutdown을 시키는 것이 좋습니다. 어차피 서버에서 클라이언트로는 더이상 보낼(유실될) 데이터가 없다는 것이 확인되었고, 간혹 Client 중에 프로토콜을 따르지 않고 종료하는 녀석들이 있기 때문입니다.
※ IOCP 를 사용한 서버에서는 주기적으로 마지막 통신한 TimeStamp를 체크하여 Idle Session에 대해 Gabage Collection(?)을 수행해주어야 합니다. 이러한 경우에는 서버측에서 먼저 closesocket()을 호출할 수 밖에 없기 때문에, TIME_WAIT를 남기지 않기 위해 반드시 Linger 옵션으로 Abortive Shutdown을 시켜주어야 합니다.
4. 비고
closesocket() 함수는 내부적으로 두가지 역할을 수행합니다.
1) 세션의 종료 절차
2) 소켓 핸들의 Close 등 자원 해제 절차
이 중, 첫번째 종료절차만을 수행하는 shutdown()이라는 함수가 있습니다. 세션 종료 과정을 명시적으로 수행하고 싶을 때 사용하는 함수입니다.
아래의 MSDN 문서를 보면 shutdown 함수를 사용해 완전한 Graceful Shutdown을 수행하는 방법이 안내되어 있습니다. (제 개인적인 생각으로는... 이렇게까지 할 필요는 없을 것 같습니다. ^^)
'C++' 카테고리의 다른 글
delete와 delete[]를 구분해서 사용해야 하는 이유 (0) | 2009.07.30 |
---|---|
new 연산자를 사용한 동적 메모리 할당 실패시 예외 처리 (8) | 2009.05.17 |
멀티스레드 프로그래밍(Multithread Programming)에 관한 고찰 (2) (10) | 2009.04.11 |
멀티스레드 프로그래밍(Multithread Programming)에 관한 고찰 (1) (4) | 2009.03.24 |
DLL Injection (4) | 2009.03.20 |