Microsoft MVP성태의 닷넷 이야기
.NET Framework: 603. socket - shutdown 호출이 필요한 사례 [링크 복사], [링크+제목 복사]
조회: 19741
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 2개 있습니다.)
(시리즈 글이 2개 있습니다.)
.NET Framework: 603. socket - shutdown 호출이 필요한 사례
; https://www.sysnet.pe.kr/2/0/11037

.NET Framework: 1030. C# Socket의 Close/Shutdown 동작 (동기 모드)
; https://www.sysnet.pe.kr/2/0/12574




socket - shutdown 호출이 필요한 사례

우선, shutdown API에 대한 문서를 보면 다음과 같습니다.

shutdown function
; https://learn.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-shutdown

The shutdown function is used on all types of sockets to disable reception, transmission, or both.

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. For UDP sockets, incoming datagrams are accepted and queued. In no case will an ICMP error packet be generated.

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.

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


문서에서 나오듯이 Both는 SD_RECEIVE + SD_SEND의 역할을 모두 한다고 되어 있으며 SD_SEND의 경우 TCP 소켓이면 데이터를 모두 전송 후 FIN을 보내는 걸로 나옵니다.

음... 역시나 코드를 보는 것이 더 낫겠죠? ^^





우선, 소켓 서버를 다음과 같이 구성해 봅니다.

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;

class Program
{
    static void Main(string[] args)
    {
        UseTcpListener();
    }

    private static void UseTcpListener()
    {
        TcpListener server = new TcpListener(IPAddress.Any, 10230);
        server.Start();

        while (true)
        {
            TcpClient client = server.AcceptTcpClient(); // 1. 클라이언트가 접속하면,

            NetworkStream stream = client.GetStream();

            byte[] buffer = new byte[1024];
            stream.Read(buffer, 0, 5);                   // 2. 읽기 1회를 하고,

            Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, 5));

            buffer = Encoding.UTF8.GetBytes("h");
            stream.Write(buffer, 0, buffer.Length);      // 3. 쓰기 2회를 합니다.

            buffer = Encoding.UTF8.GetBytes("ello world!");
            stream.Write(buffer, 0, buffer.Length);

            stream.Close();                           // 곧바로 연결 종료
            client.Close();
        }
    }
}

구성이 간단합니다. 클라이언트로부터 5바이트를 읽은 다음, 2회의 쓰기 후 연결을 종료합니다.

클라이언트도 이에 맞게 구성해 보겠습니다.

using System;
using System.Net.Sockets;
using System.Text;

class Program
{
    static void Main(string[] args)
    {
        TcpClient client = new TcpClient();

        client.Connect("192.168.150.95", 10230);           // 1. 서버에 연결하고,
        NetworkStream stream = client.GetStream();

        byte[] buffer = Encoding.UTF8.GetBytes("12345"); // 2. 5바이트 전송 후,
        stream.Write(buffer, 0, buffer.Length);

        buffer = new byte[1024];

        int readBytes = stream.Read(buffer, 0, 20);      // 3. 읽기 2회를 하고,
        Console.Write(Encoding.UTF8.GetString(buffer, 0, readBytes));

        readBytes = stream.Read(buffer, 0, 20);
        Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, readBytes));

        stream.Close();                                  // 4. 연결 종료
        client.Close();
    }
}

(*** 이 글의 테스트는 반드시 소켓 서버와 클라이언트가 다른 컴퓨터에 있어야 합니다. 그렇지 않으면 재현이 잘 안됩니다.)

빌드하고, 소켓 서버를 다른 컴퓨터에 복사해 실행해 두고 클라이언트를 실행합니다. 당연히 서버 측의 화면에는 "12345"가 찍히고, 클라이언트는 "hello world!"가 출력됩니다. 여기까지는 지극히 정상적인 동작을 하고 있습니다.

그럼, 여기에 약간의 변화를 줘볼까요? ^^

클라이언트 측에서 "12345"가 아닌 "123456"으로 6바이트를 전송하는 것으로 바꿔보겠습니다.

byte[] buffer = Encoding.UTF8.GetBytes("123456"); // 2. 6바이트 전송으로 변경.
stream.Write(buffer, 0, buffer.Length);

다만, 서버 측에서는 5바이트를 읽는 코드를 그대로 유지하겠습니다. 즉, 소켓 서버는 1바이트를 아직 덜 읽은 상태에서 클라이언트 측으로 "h" + "ello world!" 데이터를 전송하는 것입니다.

(서버가 바뀌지 않은 상태이고) 변경한 클라이언트 프로그램을 다시 빌드해 실행하면 이번에는 클라이언트 측 프로그램에서 다음과 같은 예외가 발생합니다.

Unhandled Exception: System.IO.IOException: Unable to read data from the transport connection: An existing connection was forcibly closed by the remote host. ---> System.Net.Sockets.SocketException: An existing connection was forcibly closed by the remote host
   at System.Net.Sockets.Socket.Receive(Byte[] buffer, Int32 offset, Int32 size, SocketFlags socketFlags)
   at System.Net.Sockets.NetworkStream.Read(Byte[] buffer, Int32 offset, Int32 size)
   --- End of inner exception stack trace ---
   at System.Net.Sockets.NetworkStream.Read(Byte[] buffer, Int32 offset, Int32 size)
   at ConsoleApplication2.Program.Main(String[] args) in C:\ConsoleApplication1\ConsoleApplication2\Program.cs:line 25

저 오류가 발생하는 지점은 클라이언트 측의 두 번의 읽기 시도에서 2번째에 해당하는 코드입니다.

int readBytes = stream.Read(buffer, 0, 20);
Console.Write(Encoding.UTF8.GetString(buffer, 0, readBytes));

readBytes = stream.Read(buffer, 0, 20);  // <======= 이 코드에서 예외 발생
Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, readBytes));

이 오류의 원인은, 서버 측의 NetworkStream.Close가 내부적으로 호출하는 Shutdown(Both) 때문입니다.

위에서 소개한 API 문서에 따르면 Both는 Receive + Send를 포함하는 동작인데, SD_RECEIVE 옵션의 "For TCP sockets, if there is still data queued on the socket waiting to be received, or data arrives subsequently, the connection is reset" 설명에 따라, 받아야 될 데이터가 여전히 큐에 있는 경우 connection reset이 됩니다. (정확히는 RST 패킷이 전송됩니다.)

즉, 클라이언트가 보낸 1바이트 데이터를 못 읽은 상태의 소켓 서버가 클라이언트로 2회의 데이터 전송을 한 후 stream.Close를 하면서 수신 큐에 남아 있는 데이터를 읽지 않아 RST 패킷이 클라이언트로 내려간 것입니다. 이 상태에서 클라이언트는 "h"를 수신 후 다음번 receive 사이에 "elloworld"와 RST 패킷이 수신되어 (수신만 되었지 아직 recv 호출이 이뤄지지 않은 상태) 연결이 Reset 된 것입니다. (참고로, Java 같은 경우라면 "java.net.SocketException: Connection reset"이라는 예외 메시지가 나타납니다.)

이 문제를 깨끗하게 해결하려면 서버와 클라이언트가 정확하게 프로토콜에 맞춰 읽기/쓰기를 해주면 됩니다. 즉, 클라이언트가 6바이트를 보내면 서버도 정확히 6바이트를 읽어주도록 변경하는 것으로 문제가 해결됩니다. (당연히 NetworkStream.Close 시 Shutdown(BOTH)가 되었지만 RST 처리가 될 이유가 없습니다.)

하지만, 모든 상황이 이와 같이 해결될 수 있는 것은 아닙니다. 가령, 위의 상황을 HTTP 서버와 클라이언트라고 가정해 보겠습니다. HTTP 서버가 클라이언트로부터 HTTP HEADER 영역만 읽고는 처리할 서버 페이지를 찾지 못 해서 HTTP BODY를 읽지 않고 곧바로 에러 페이지로 응답하는 것이 가능합니다. 그런 상황에서 서버가 굳이 HTTP BODY 데이터까지 모두 읽을 필요는 없습니다. 마찬가지로 이럴 때 요청을 보낸 클라이언트 측은 서버로부터 recv를 하기 전 수신 큐에 HTTP 데이터와 함께 RST 패킷이 오면 connection reset 오류를 접하게 되는 것입니다.

그럼, 이 상황을 프로토콜을 맞추는 방법 말고 다른 식으로 해결할 수 있을까요?

이 문제의 근본적인 원인은, 서버가 send API를 호출하긴 했지만 수신 큐에 데이터가 남은 상태에서 Shutdown(BOTH)를 했기 때문입니다. 따라서 이런 경우에는 그냥 shutdown(Send)만을 호출해 주면 해결할 수 있습니다.

buffer = Encoding.UTF8.GetBytes("ello world!");
stream.Write(buffer, 0, buffer.Length);

client.Client.Shutdown(SocketShutdown.Send);
            
stream.Close();
client.Close();

서버를 이렇게 바꿔주면 이후는 다시 정상적으로 클라이언트 실행 시 화면에 "hello world!"를 볼 수 있습니다. 서버는 Shutdown(Send)로 인해 모든 데이터가 전송 + ACK 될 때까지 대기하게 되고 이후에 socket.close가 되기 때문에 클라이언트와의 통신에 아무런 문제가 없습니다. (업데이트 2021-02-12: 이런 경우 Shutdown으로 인한 FIN 전송 후, 만약 읽지 않은 수신 데이터가 있다면 곧바로 Close에 의해 곧바로 RST이 전송됩니다. 자세한 사항은 Wireshark + C/C++로 확인하는 TCP 연결에서의 closesocket 동작 참고)

그렇다면 한가지 의문이 생기는군요. 엄밀히 말해서 (오류만 발생시키는) SD_RECEIVE가 왜 있는 것인지 모르겠습니다. MSDN 문서를 봐도, shutdown을 이용한 "graceful disconnect" 사용 사례로 SD_SEND만 사용하는 2가지 시나리오가 나올 뿐입니다. 굳이 제가 생각해 본다면, 상대측 소켓에서 보낸 모든 데이터를 읽었다는 것을 보장하는 확인 용도로 shutdown(..., SD_RECEIVE)를 호출할 수 있을 것 같습니다. 그런가요? ^^




재미있는 것을 좀 더 살펴볼까요? ^^

Shutdown 호출 말고 다음과 같이 종료시켜도 정상적으로 동작합니다.

buffer = Encoding.UTF8.GetBytes("ello world!");
stream.Write(buffer, 0, buffer.Length);

// stream.Close(); 
client.Client.Close(1);

어차피 NetworkStream.Close 메서드는 내부에서 Socket.Close를 호출하기 때문에 stream.Close 코드는 제거해도 됩니다. 대신 Socket.Close 메서드에 timeout 인자로 1을 줬는데요. 이름이 timeout이기 때문에, 어쩌면 send API로 호출된 데이터가 상대편에 전송이 되도록 여유 시간을 주는 것 같은 의미로 들리는데요. Socket.Close 메서드를 .NET Reflector로 소스 코드를 들여다보면, 다음과 같이 timeout 인자가 있는 경우 shutdown(this.m_Handle, 1); 코드를 호출해 주는 코드가 있습니다.

// === Socket.cs ===

public void Close(int timeout)
{
    // ...[생략]...
    this.m_CloseTimeout = timeout;
    this.Dispose();
}

protected virtual void Dispose(bool disposing)
{
	// ...[생략]...
    try
    {
        int closeTimeout = this.m_CloseTimeout;
        if (closeTimeout == 0)
        {
            this.m_Handle.Dispose();
        }
        else
        {
            error = UnsafeNclNativeMethods.OSSOCK.shutdown(this.m_Handle, 1); // SD_SEND == 1
			// ...[생략]...
			this.m_Handle.Dispose();
        }
    }
    catch (ObjectDisposedException)
    {
    }
    // ...[생략]...
}

shutdown이 2번째 인자로 1이 들어오면 그 값이 바로 SocketShutdown.Send인데, 결국 저 코드로 인해 정상 동작하게 된 것입니다.

또 하나 재미있는 것이 있는데요. NetworkStream.Close 메서드도 timeout 인자를 제공합니다.

// === NetworkStream.cs ===

public void Close(int timeout)
{
    // ...[생략]...
    this.m_CloseTimeout = timeout;
    this.Close();
}

protected override void Dispose(bool disposing)
{
    // ...[생략]...
    System.Net.Sockets.Socket streamSocket = this.m_StreamSocket;
    if (streamSocket != null)
    {
        streamSocket.InternalShutdown(SocketShutdown.Both);
        streamSocket.Close(this.m_CloseTimeout);
    }
    // ...[생략]...
}

보시는 바와 같이, NetworkStream.Close에 전달된 timeout 값은 그대로 Socket.Close의 호출로 전달하게 됩니다. 그렇다면 우리가 테스트 한 서버 측 코드를 다음과 같이 고쳐도 되지 않을까요?

buffer = Encoding.UTF8.GetBytes("ello world!");
stream.Write(buffer, 0, buffer.Length);

stream.Close(1);
// client.Close();

아쉽게도 이렇게 하면 다시 예외가 발생합니다. 왜냐하면, NetworkStream.Dispose에서 streamSocket.Close를 호출하기 전 "streamSocket.InternalShutdown(SocketShutdown.Both);" 코드가 호출되기 때문입니다. 만약 저 코드가 "streamSocket.InternalShutdown(SocketShutdown.Send); 였다면 괜찮았을 것입니다.




마지막으로, 닷넷에서 TCP 소켓 통신을 할 때 TcpClient 클래스를 사용하곤 하는데요. 이 클래스는 통신 부분을 NetworkStream에 위임한다는 특징이 있습니다. 그럼, 통신을 종료할 때 NetworkStream.Close와 TcpClient.Close를 모두 호출해야 할까요?

물론, API 호출 관례상 모두 호출해 주는 것이 좋습니다. 하지만, 실질적으로는 NetworkStream.Close만 호출해 줘도 됩니다. 이에 대해서는 역시 소스 코드를 보면 되는데요. 아래는 TcpClient의 Close 메서드 코드입니다.

public NetworkStream GetStream()
{
    // ...[생략]...
    if (this.m_DataStream == null)
    {
        this.m_DataStream = new NetworkStream(this.Client, true);
    }
    // ...[생략]...
    return this.m_DataStream;
}

public void Close()
{
    // ...[생략]...
    this.Dispose();
    // ...[생략]...
}

protected virtual void Dispose(bool disposing)
{
    // ...[생략]...
    IDisposable dataStream = this.m_DataStream;
    if (dataStream != null)
    {
        dataStream.Dispose();
    }
    else
    {
        Socket client = this.Client;
        if (client != null)
        {
            try
            {
                client.InternalShutdown(SocketShutdown.Both);
            }
            finally
            {
                client.Close();
                this.Client = null;
            }
        }
    }
	// ...[생략]...
}

보시는 바와 같이, Close 호출 시 GetStream을 호출한 적이 있다면 Dispose 메서드에서 NetworkStream.Dispose를 호출하는 것으로 자원 정리를 끝내 버립니다. 물론, TcpClient를 사용한다고 해서 꼭 GetStream이 호출된다는 법은 없으므로 안전하게는 NetworkStream.Close와 TcpClient.Close를 해주는 것이 권장됩니다. 또는, TcpClient.Close만 해줘도 됩니다.

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




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

[연관 글]






[최초 등록일: ]
[최종 수정일: 2/19/2024]

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)
13347정성태5/10/20233936.NET Framework: 2118. C# - Semantic Kernel의 Prompt chaining 예제파일 다운로드1
13346정성태5/10/20233781오류 유형: 858. RDP 원격 환경과 로컬 PC 간의 Ctrl+C, Ctrl+V 복사가 안 되는 문제
13345정성태5/9/20235083.NET Framework: 2117. C# - (OpenAI 기반의) Microsoft Semantic Kernel을 이용한 자연어 처리 [1]파일 다운로드1
13344정성태5/9/20236334.NET Framework: 2116. C# - OpenAI API 사용 - 지원 모델 목록 [1]파일 다운로드1
13343정성태5/9/20234202디버깅 기술: 192. Windbg - Hyper-V VM으로 이더넷 원격 디버깅 연결하는 방법
13342정성태5/8/20234140.NET Framework: 2115. System.Text.Json의 역직렬화 시 필드/속성 주의
13341정성태5/8/20233914닷넷: 2114. C# 12 - 모든 형식의 별칭(Using aliases for any type)
13340정성태5/8/20233951오류 유형: 857. Microsoft.Data.SqlClient.SqlException - 0x80131904
13339정성태5/6/20234636닷넷: 2113. C# 12 - 기본 생성자(Primary Constructors)
13338정성태5/6/20234125닷넷: 2112. C# 12 - 기본 람다 매개 변수파일 다운로드1
13337정성태5/5/20234632Linux: 59. dockerfile - docker exec로 container에 접속 시 자동으로 실행되는 코드 적용
13336정성태5/4/20234397.NET Framework: 2111. C# - 바이너리 출력 디렉터리와 연관된 csproj 설정
13335정성태4/30/20234516.NET Framework: 2110. C# - FFmpeg.AutoGen 라이브러리를 이용한 기본 프로젝트 구성 - Windows Forms파일 다운로드1
13334정성태4/29/20234170Windows: 250. Win32 C/C++ - Modal 메시지 루프 내에서 SetWindowsHookEx를 이용한 Thread 메시지 처리 방법
13333정성태4/28/20233626Windows: 249. Win32 C/C++ - 대화창 템플릿을 런타임에 코딩해서 사용파일 다운로드1
13332정성태4/27/20233721Windows: 248. Win32 C/C++ - 대화창을 위한 메시지 루프 사용자 정의파일 다운로드1
13331정성태4/27/20233744오류 유형: 856. dockerfile - 구 버전의 .NET Core 이미지 사용 시 apt update 오류
13330정성태4/26/20233414Windows: 247. Win32 C/C++ - CS_GLOBALCLASS 설명
13329정성태4/24/20233622Windows: 246. Win32 C/C++ - 직접 띄운 대화창 템플릿을 위한 Modal 메시지 루프 생성파일 다운로드1
13328정성태4/19/20233261VS.NET IDE: 184. Visual Studio - Fine Code Coverage에서 동작하지 않는 Fake/Shim 테스트
13327정성태4/19/20233686VS.NET IDE: 183. C# - .NET Core/5+ 환경에서 Fakes를 이용한 단위 테스트 방법
13326정성태4/18/20235059.NET Framework: 2109. C# - 닷넷 응용 프로그램에서 SQLite 사용 (System.Data.SQLite) [1]파일 다운로드1
13325정성태4/18/20234407스크립트: 48. 파이썬 - PostgreSQL의 with 문을 사용한 경우 연결 개체 누수
13324정성태4/17/20234239.NET Framework: 2108. C# - Octave의 "save -binary ..."로 생성한 바이너리 파일 분석파일 다운로드1
13323정성태4/16/20234140개발 환경 구성: 677. Octave에서 Excel read/write를 위한 io 패키지 설치
13322정성태4/15/20234943VS.NET IDE: 182. Visual Studio - 32비트로만 빌드된 ActiveX와 작업해야 한다면?
1  2  3  4  5  6  7  8  9  10  [11]  12  13  14  15  ...