Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

Wireshark + C/C++로 확인하는 TCP 연결에서의 shutdown 동작

shutdown 문서에서 설명하는,

shutdown function (winsock.h)
; https://docs.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-shutdown

SD_RECEIVE, SD_SEND, SD_BOTH가 어떤 차이점이 있는지 (wireshark의 도움을 받아) 동작을 비교해 보겠습니다.

우선, 문서에 따라 SD_RECEIVE는 TCP 연결 상에서 다음과 같은 효력을 갖습니다.

If the how parameter is SD_RECEIVE, subsequent calls to the recv function on the socket will be disallowed. This has no effect on the lower protocol layers. For TCP sockets, if there is still data queued on the socket waiting to be received, or data arrives subsequently, the connection is reset, since the data cannot be delivered to the user.


즉, shutdown(SD_RECEIVE)는 단순히 Socket API 수준에서 더 이상의 recv 함수 호출을 하지 못하도록 막는 역할을 합니다. 또한 TCP 연결인 경우 아직도 recv로 받을 수 있는 데이터가 수신 큐에 있거나 이후 수신이 된다면 해당 데이터를 응용 프로그램에 전달하지 못하도록 연결 상태를 아예 RESET 해 버립니다.

과연 이 동작이 어떻게 진행되는지 테스트를 직접 해볼까요? ^^

그나저나, 예전에 쓴 글에서는 내부 소켓 동작을 (예측할 수 없게) 감싼 C#의 TcpClient/NetworkStream을 사용했기 때문에 다소 혼란스러운 감이 있었는데요.

socket - shutdown 호출이 필요한 사례
; https://www.sysnet.pe.kr/2/0/11037

이번에는, 순수하게 C++의 socket 관련 함수만을 사용해 테스트를 진행하겠습니다. (C#의 Socket 타입도 TcpClient 만큼은 아니지만 약간의 래핑을 하기 때문에 이 글에서는 배제합니다.)

자... 그럼 서버와 클라이언트의 송/수신을 간략하게 구현하고,

// 서버 측 코드 https://docs.microsoft.com/en-us/windows/win32/winsock/complete-server-code

while (true)
{
    clntSocket = accept(ListenSocket, NULL, NULL);

    send(clntSocket, "hello", 5, 0); // 5바이트 송신 후 대기
        
    printf("Press any key to call closesocket");
    getchar();

    iResult = closesocket(clntSocket);
    printf("closesocket: %d\n", iResult);
}

// 클라이언트 측 코드 https://docs.microsoft.com/en-us/windows/win32/winsock/complete-client-code

int errorCode = 0;

iResult = recv(clntSocket, recvbuf, 4, 0); // 서버는 5바이트를 송신했지만,
                                           // 클라이언트는 4바이트만 수신
print(recvbuf, iResult, errorCode);

printf("Press any key to call shutdown");
getchar();

// 아직 1바이트가 수신 버퍼에 있는 상태에서 shutdown 호출
shutdown(clntSocket, SD_RECEIVE);

printf("Press any key to call closesocket");
getchar();

closesocket(clntSocket);

실행하면, 문서의 내용에 따라 연결을 RESET할 텐데요, 그 의미를 wireshark로 확인하면 이해할 수 있습니다.

// shutdown(SD_RECEIVE)를 호출한 클라이언트 측의 패킷

// ...[3-way handshake 생략]...

// 5바이트 수신
4 0.008397 ..server_ip... ..client_ip... TCP 60  15000 → 1451 [PSH, ACK] Seq=1 Ack=1 Win=131328 Len=5
// ACK
5 0.053200 ..client_ip... ..server_ip... TCP 54  1451 → 15000 [ACK] Seq=1 Ack=6 Win=262656 Len=0

// shutdown(SD_RECEIVE) 호출
6 10.457317 ..client_ip... ..server_ip... TCP 54  1451 → 15000 [RST, ACK] Seq=1 Ack=6 Win=0 Len=0

보는 바와 같이 (수신 버퍼에 있는 1 바이트로 인해) 연결을 (정상적graceful으로 FIN을 이용해 종료하지 않고) RST 패킷을 날려 강제/비정상abortive 연결 해제를 진행합니다.

일면 이해가 됩니다. 더 이상 수신하지 않겠다고 shutdown(SD_RECEIVE)를 했는데 아직도 수신 데이터가 버퍼에 있다면 서로 간에 프로토콜이 맞지 않았던 결과일 수 있으므로 정상적인 정보 교환에 해당하지 않습니다. 만약, shutdown(SD_RECEIVE) 호출 이후 recv를 어떤 식으로든 호출하게 되면 곧바로 -1을 반환하고 WSAGetLastError는 10058(WSAESHUTDOWN: A request to send or receive data was disallowed because the socket had already been shut down in that direction with a previous shutdown call.)을 반환합니다.

그리고 위의 동작 자체는 shutdown(SD_RECEIVE) 호출을 빼고 그냥 closesocket만 호출했을 때와 결과가 같습니다.

그런데, 혹시 송신 큐에 남아 있는 데이터는 어떻게 처리될까요? 재현을 위해 다음과 같이 코드를 작성할 수 있습니다.

// 수신 큐는 비어 있다고 가정
send(clntSocket, recvbuf, DEFAULT_BUFLEN, 0); // DEFAULT_BUFLEN == 10MB
shutdown(clntSocket, SD_RECEIVE);
closesocket(clntSocket);

위의 코드는 10MB를 전송하는데 이때 상대방은 recv를 호출하지 않는다고 가정해 보겠습니다. 즉, 지난 글에서 설명한 TCP Receive Window가 차면서 송신 버퍼에 데이터가 남게 되는 현상이 발생하는데요. shutdown(SD_RECEIVE)는 송신 버퍼에 데이터가 있는 경우 모두 비우고 정상적으로 FIN 절차를 밟게 됩니다.

재미있는 것은, (수신 버퍼가 비어 있는 상황이라면) shutdown(SD_RECEIVE)가 그 외에 하는 일이 없다는 점입니다. 즉, 이름과는 달리 연결을 해제한다는 어떠한 패킷 전송도 하지 않기 때문에, 만약 다르게 함수 이름을 지어 본다면 suppressCallToRecv_and_IssueResetIfRecvBufIsNotEmpty(); 정도가 어울릴 것입니다. ^^




그다음 SD_SEND의 설명을 보면,

If the how parameter is SD_SEND, subsequent calls to the send function are disallowed. For TCP sockets, a FIN will be sent after all data is sent and acknowledged by the receiver.


마찬가지로 이후로는 send 함수를 호출할 수 없도록 막는 역할을 합니다. 그리고 한 가지 특이한 기능이 있다면, 현재 송신 버퍼에 있는 데이터까지는 모두 보낸 후 연결 해제를 위한 FIN 신호를 서버에 전송한다는 점입니다. 이에 대한 테스트 코드는 이미 위에서 했던 것과 동일하게 하면 됩니다.

// 서버 측
int errorCode;

while (true)
{
    errorCode = 0;
    clntSocket = accept(ListenSocket, NULL, NULL);

    // 연결 수락 후, 키가 눌리면 클라이언트로부터 전송한 데이터를 모두 수신
    printf("Press any key to call recv");
    getchar();

    int totalReceive = 0;
    while (true)
    {
        iResult = recv(clntSocket, recvbuf, DEFAULT_BUFLEN, 0);
        if (iResult == -1 || iResult == 0)
        {
            errorCode = WSAGetLastError();
            printf("recv: %d(%d)\n", iResult, errorCode);
            break;
        }

        totalReceive += iResult;
        printf("recvLen: %d (total: %d)", iResult, totalReceive);
    }

    // 수신 완료 후 FIN 전송
    iResult = closesocket(clntSocket);
    errorCode = WSAGetLastError();
    printf("closesocket: %d(%d), totalRecv = %d\n", iResult, errorCode, totalReceive);
}

// 클라이언트 측 코드

int errorCode = 0;

// 10MB를 서버 측으로 전송하고,
iResult = send(clntSocket, recvbuf, DEFAULT_BUFLEN, 0); // DEFAULT_BUFLEN == 1024 * 1024 * 10
errorCode = WSAGetLastError();
printf("send: %d(%d)\n", iResult, errorCode);

printf("Press any key to call shutdown");
getchar();

// 키가 눌리면 shutdown(SD_SEND) 호출
iResult = shutdown(clntSocket, SD_SEND);
errorCode = WSAGetLastError();
printf("shutdown: %d(%d)\n", iResult, errorCode);

printf("Press any key to call closesocket");
getchar();

// 키가 눌리면 closesocket 호출
iResult = closesocket(clntSocket);
errorCode = WSAGetLastError();
printf("closesocket: %d(%d)\n", iResult, errorCode);

이번에도 클라이언트에서 10MB 정도 송신하고 서버에서는 recv를 하지 않습니다. send(10MB)가 첫 번째 호출이므로 송신 버퍼로는 전달이 되지만 수신 측에는 Receive Window 문제로 여전히 송신 버퍼에는 데이터가 머무르게 될 텐데요, 이 상태에서, shutdown(SD_SEND)를 호출한 후 서버 측에서 다시 recv를 했을 때 10MB 데이터가 전부 오는지 테스트하면 됩니다.

실행 후, 서버는 recv를 호출하지 않고 클라이언트 측만 키를 2번 입력하여 closesocket까지 진행합니다. (그래서 응용 프로그램이 종료된 상태여도 됩니다.) 그리고 이후 서버에서 recv를 해도 모든 데이터를 수신하고 양측 모두 4-way handshake까지 TCP Layer에서 잘 처리가 됩니다. 참고로, 클라이언트의 TCP Layer는 마지막 패킷 전송 시에 FIN flag를 함께 켜서 보내고 이후 서버로부터 수신한 FIN 신호까지 처리한 후 TIME_WAIT 상태로 진입합니다.)

그런데, 이번에도 역시 위의 예제에서는 shutdown(SD_SEND)를 빼고 그냥 closesocket만 호출해도 위와 동일한 기능이 수행됩니다.

하지만, 수신 버퍼가 비어 있지 않은 상태라면 어떨까요? 즉, shutdown(SD_SEND)가 수신 버퍼에는 아무런 영향이 없을까요? 테스트를 해보면 되겠죠? ^^ 간단하게 서버 측에서 send(1 byte)를 해두고 클라이언트 측에서 shutdown(SD_SEND)를 하면, 일단 여기까지는 문제가 없습니다. 수신 버퍼에 상관없이 모든 데이터가 서버로 전송될 여지가 있습니다. 문제는, closesocket을 호출했을 때입니다. 즉 다음과 같은 코드를 만든다면,

// 수신 버퍼에 1 바이트가 있는 상황에서,

send(clntSocket, recvbuf, DEFAULT_BUFLEN, 0); // DEFAULT_BUFLEN == 10MB, 상대방은 recv 호출을 하지 않음
shutdown(clntSocket, SD_SEND);
closesocket(clntSocket);

closesocket에서 무조건 RST 패킷이 전송돼 비정상 종료를 하게 됩니다. 심지어, 상대 측에서 이미 FIN 신호를 보내와 접수한 상태여도 shutdown으로 인한 (FIN 신호가 붙은 마지막 패킷의) 전송 버퍼를 비우기 전에 이미 closesocket이 호출되면서 RST 처리가 됩니다.

반면, 수신 버퍼에 1 바이트가 있지만 송신 버퍼가 비어 있다면,

// 수신 버퍼에 1 바이트, 송신 버퍼는 비어 있음.
// 이미 상대 측은 FIN 신호를 보내와 접수가 된 상태.

shutdown(clntSocket, SD_SEND); 
closesocket(clntSocket);

이때는 shutdown으로 FIN 신호가 곧바로 전송되지만, 뒤 이은 closesocket도 수신 버퍼의 1 바이트로 인해 곧바로 RST 패킷을 전송합니다. 하지만, 서버로부터 FIN에 대한 ACK 신호가 오기 때문에 결과적으로는 4-way handshake까지 진행이 되는 듯합니다. (실제로 RST 패킷을 받은 상대방의 소켓은 active close를 했던 탓에 TIME_WAIT 상태로 넘어갑니다.) 참고로, "C#에서 ShutDown을 호출하는 것으로 문제를 해결한 상황" 글에서 사용한 방법이 바로 위의 코드 동작 방식과 같습니다.

또한, 위와 같은 상황에서 shutdown과 closesocket 사이에 약간의 delay가 있다면,

// 수신 버퍼에 1 바이트, 송신 버퍼는 비어 있음.
// 이미 상대 측은 FIN 신호를 보내와 접수가 된 상태.

shutdown(clntSocket, SD_SEND); 
Sleep(500);
closesocket(clntSocket);

이번에는 closesocket에서 RST 패킷이 발생하지 않습니다. shutdown으로 인해 서버로 전송한 FIN 신호에 대한 ACK가 Sleep(500ms) 시간 내에 수신이 되기 때문에 이미 정리한 소켓에 대해 RST 패킷이 발생할 여지가 없는 것입니다.




마지막으로, SD_BOTH는 SD_BOTH + SD_RECEIVE 의미를 갖는다고 합니다.

Setting how to SD_BOTH disables both sends and receives as described above.

그렇다면, 수신 버퍼 및 송신 버퍼에 데이터가 있을 때 shutdown(SD_BOTH)를 하면 어떻게 될까요? 과연 이런 상황에서 둘 다의 의미라면 어떻게 동작한다는 것인지 직관적으로 이해가 안 됩니다.

실제로 해보면, 송신 버퍼를 다 비우기도 전에 수신 버퍼에 이미 내용이 있다는 것만으로도 클라이언트 측의 shutdown(SD_BOTH)는 연결을 RESET 해 버립니다. 따라서, 서버 측은 이미 수신하고 있던 일부 데이터가 있더라도 중간에 연결이 RESET 된 탓에 전체 데이터를 수신하지 못합니다.

그리고 이번에도 역시, shutdown(SD_BOTH) 호출 없이 closesocket을 해도 위와 동작 방식이 정확히 일치합니다. 테스트 결과만으로 보면, closesocket에서 암시적으로 수행하는 shutdown 절차는 shutdown(SD_BOTH)를 호출한 것과 같은 효과라고 볼 수 있습니다.




shutdown은 이름과는 달리 실제로 연결을 해제하는 패킷(FIN 또는 RST)은 다음의 2가지 상황에서만 나옵니다.

  • shutdown(SD_RECEIVE): 수신 버퍼에 데이터가 있으면 그 즉시 RST 패킷 전송
  • shutdown(SD_SEND): 송신 버퍼에 데이터가 있으면 마지막 패킷에 FIN flag를 켜고, 데이터가 없으면 FIN 패킷 전송

이와는 별개로, closesocket은 수신 버퍼에 데이터가 있고 아직 4-way handshake가 완료되지 않았으면 RST 패킷을 전송합니다.

이 정도 배경 지식이면 이제 정상graceful 종료와 비정상abortive 종료를 다음과 같이 구분할 수 있습니다.

  • 정상graceful 종료: 4-way handshake가 발생하는 종료
  • 비정상abortive 종료: 어느 한 쪽이 연결을 reset(RST 패킷)해 데이터 송/수신 어느 하나에 문제가 발생할 수 있는 종료

(비정상 종료라고 해서 데이터 송/수신에 꼭 문제가 있었다고 볼 수는 없습니다. 왜냐하면 이미 모든 데이터 송/수신을 완료한 상태에서의 RST 패킷이 발생할 수도 있기 때문입니다.)

지금까지 살펴보았듯이, 서버와 클라이언트가 송/수신에 대한 프로토콜을 맞춘다면 shutdown 호출은 굳이 필요가 없습니다. 즉, closesocket만 해도 안전하게 정상 종료를 이끌어낼 수 있습니다.

하지만, 송/수신을 정확하게 맞출 수 없는 상황이라면 shutdown의 도움을 받아 정상 종료를 할 수 있는 절차가 있습니다. shutdown 문서에 설명한 내용이 바로 그것입니다.

To assure that all data is sent and received on a connected socket before it is closed, an application should use shutdown to close connection before calling closesocket. One method to wait for notification that the remote end has sent all its data and initiated a graceful disconnect uses the WSAEventSelect function as follows :
  1. Call WSAEventSelect to register for FD_CLOSE notification.
  2. Call shutdown with how=SD_SEND.
  3. When FD_CLOSE received, call the recv or WSARecv until the function completes with success and indicates that zero bytes were received. If SOCKET_ERROR is returned, then the graceful disconnect is not possible.
  4. Call closesocket.
Another method to wait for notification that the remote end has sent all its data and initiated a graceful disconnect uses overlapped receive calls follows :
  1. Call shutdown with how=SD_SEND.
  2. Call recv or WSARecv until the function completes with success and indicates zero bytes were received. If SOCKET_ERROR is returned, then the graceful disconnect is not possible.
  3. Call closesocket.


위의 첫 번째 블록에 있는 방법을 정리하면!

우선 1) WSAEventSelect를 호출해 FD_CLOSE 알림(즉, 상대 쪽에서 보내는 FIN 신호)을 받을 수 있게 만듭니다. 그 상태에서 2) shutdown(SD_SEND)를 호출해 FIN 신호를 처리하면서 적어도 이후로는 절대 send로 인한 전송이 없도록 만듭니다. 이제 남은 작업은, 서버가 연결을 끊기를 기다리기만 하면 됩니다. 그래서 3) FD_CLOSE 알림이 오면, recv를 호출해 상대가 전송한 모든 데이터를 수신 후 마지막으로 4) closesocket으로 소켓 자원을 해제합니다. 즉, 이렇게 처리하면 정상graceful shtudown을 하는 것입니다. 물론, 이 과정에서 상대가 RST를 보내면 그걸로 통신이 끊기는 것이므로 정상 종료는 당연히 물 건너갑니다.

그리고 두 번째 블록에 있는 방법은 WSAEventSelect보다 더 간결합니다.

그냥 1) shutdown(SD_SEND)를 호출하고, 2) 수신 중인 데이터를 모두 읽어들일 수 있도록 recv를 호출한 다음 3) closesocket을 해도 됩니다. 양쪽 모두 shutdown으로 인해 데이터 전송 및 FIN 신호를 보낼 것이고, recv가 블록킹이 되겠지만 shutdown(SD_SEND)가 보내는 FIN 신호로 인해 결국 반환이 되면서 closesocket으로 자원 해제까지 마무리가 됩니다.




부연 설명을 좀 더 해보면, TCP가 종단 간의 신뢰성 있는 통신을 가능하게 하지만 send한 데이터가 서버에 정상적으로 도착 못할 가능성을 배제하진 못합니다. send는 단지 송신 버퍼에 전달하는 것뿐이고 TCP Layer는 최소한 그 데이터가 통신상의 문제가 없는 한 서버로 전송할 거라는 정도의 보장만 합니다. 따라서, 가령 송신 버퍼에 담겨 있는 사이 컴퓨터 전원이 꺼진다거나, 네트워크가 끊긴다거나, 건너편 소켓에서 연결을 Reset 한다거나 했을 때는 데이터 유실이 발생할 수밖에 없습니다.

따라서, 중요하지 않은 로그를 전송하거나 하는 식이라면 그냥 send하고 closesocket을 해도 무방합니다. 반면, 해당 로그가 반드시 대상 응용 프로그램에서 recv까지 했는지를 보장받으려면 위에서 설명한 정상graceful 종료하는 시나리오를 밟아야 합니다.

마지막으로, shutdown은 블록킹 호출이 아닙니다. 즉, shutdown(SD_SEND)의 경우 송신 버퍼의 내용을 모두 보낸다고 해서 그것이 비워질 때까지 대기하지 않습니다. 또한, closesocket이 SO_LINGER 설정에 의해 블록킹이 가능한 반면 shutdown은 SO_LINGER 설정에 아무런 영향도 받지 않습니다.

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]


donaricano-btn



[최초 등록일: ]
[최종 수정일: 2/9/2021 ]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 쓴 사람
 




[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
12594정성태4/11/202147.NET Framework: 1035. C# - kubectl 명령어 또는 REST API 대신 Kubernets 클라이언트 라이브러리를 통해 프로그래밍으로 접근파일 다운로드1
12593정성태4/10/202124개발 환경 구성: 567. Docker Desktop for Windows - kubectl proxy 없이 k8s 대시보드 접근 방법
12592정성태4/10/202144개발 환경 구성: 566. Docker Desktop for Windows - k8s dashboard의 Kubeconfig 로그인 및 Skip 방법
12591정성태4/9/202183.NET Framework: 1034. C# - byte 배열을 Hex(16진수) 문자열로 고속 변환하는 방법파일 다운로드1
12590정성태4/9/202148.NET Framework: 1033. C# - .NET 4.0 이하에서 Console.IsInputRedirected 구현
12589정성태4/8/202164.NET Framework: 1032. C# - Environment.OSVersion의 문제점 및 윈도우 운영체제의 버전을 구하는 다양한 방법
12588정성태4/7/202155개발 환경 구성: 565. PowerShell - New-SelfSignedCertificate를 사용해 CA 인증서 생성 및 인증서 서명 방법
12587정성태4/7/202183개발 환경 구성: 564. Windows 10 - ClickOnce 배포처럼 사용할 수 있는 MSIX 설치 파일
12586정성태4/5/202151오류 유형: 710 . Windows - Restart-Computer / shutdown 명령어 수행 시 Access is denied(E_ACCESSDENIED)
12585정성태4/5/202149개발 환경 구성: 563. 기본 생성된 kubeconfig 파일의 내용을 새롭게 생성한 인증서로 구성하는 방법
12584정성태4/5/202174개발 환경 구성: 562. kubeconfig 파일 없이 kubectl 옵션만으로 실행하는 방법
12583정성태3/29/2021129개발 환경 구성: 561. kubectl 수행 시 다른 k8s 클러스터로 접속하는 방법
12582정성태3/29/202156오류 유형: 709. Visual C++ - 컴파일 에러 error C2059: syntax error: '__stdcall'
12581정성태3/28/2021239.NET Framework: 1031. WinForm/WPF에서 Console 창을 띄워 출력하는 방법 (2) - Output 디버깅 출력을 AllocConsole로 우회 [2]
12580정성태3/28/202152오류 유형: 708. SQL Server Management Studio - Execution Timeout Expired.
12579정성태3/28/202163오류 유형: 707. 중첩 가상화(Nested Virtualization) - The virtual machine could not be started because this platform does not support nested virtualization.
12578정성태3/27/2021136개발 환경 구성: 560. Docker Desktop for Windows 기반의 Kubernetes 구성 (2) - WSL 2 인스턴스에 kind가 구성한 k8s 서비스 위치
12577정성태3/29/2021169개발 환경 구성: 559. Docker Desktop for Windows 기반의 Kubernetes 구성 - WSL 2 인스턴스에 kind 도구로 k8s 클러스터 구성
12576정성태3/25/2021164개발 환경 구성: 558. Docker Desktop for Windows에서 DockerDesktopVM 기반의 Kubernetes 구성 (2) - k8s 서비스 위치
12575정성태3/24/2021143개발 환경 구성: 557. Docker Desktop for Windows에서 DockerDesktopVM 기반의 Kubernetes 구성
12574정성태3/28/2021199.NET Framework: 1030. C# Socket의 Close/Shutdown 동작 (동기 모드)
12573정성태4/1/202187개발 환경 구성: 556. WSL 인스턴스 초기 설정 명령어
12572정성태3/28/2021164.NET Framework: 1029. C# - GC 호출로 인한 메모리 압축(Compaction)을 확인하는 방법파일 다운로드1
12571정성태4/1/2021176오류 유형: 706. WSL 2 기반으로 "Enable Kubernetes" 활성화 시 초기화 실패 [1]
12570정성태4/7/2021112개발 환경 구성: 555. openssl - CA로부터 인증받은 새로운 인증서를 생성하는 방법
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...