Microsoft MVP성태의 닷넷 이야기
.NET Framework: 603. socket - shutdown 호출이 필요한 사례 [링크 복사], [링크+제목 복사]
조회: 10767
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

socket - shutdown 호출이 필요한 사례

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

shutdown function
; https://docs.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만 해줘도 됩니다.

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




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

[연관 글]


donaricano-btn



[최초 등록일: ]
[최종 수정일: 7/12/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)
12747정성태8/1/20215오류 유형: 748. 오류 기록 - MICROSOFT GRAPH – HOW TO IMPLEMENT IAUTHENTICATIONPROVIDER파일 다운로드1
12746정성태7/31/202153개발 환경 구성: 588. 네트워크 장비 환경을 시뮬레이션하는 Packet Tracer 프로그램 소개
12745정성태7/31/202117개발 환경 구성: 587. Azure Active Directory - tenant의 관리자 계정 로그인 방법
12744정성태7/30/202120개발 환경 구성: 586. Azure Active Directory에 연결된 App 목록을 확인하는 방법?
12743정성태7/30/202138.NET Framework: 1083. Azure Active Directory - 외부 Token Cache 저장소를 사용하는 방법파일 다운로드1
12742정성태7/30/202119개발 환경 구성: 585. Azure AD 인증을 위한 사용자 인증 유형
12741정성태7/29/202176.NET Framework: 1082. Azure Active Directory - Microsoft Graph API 호출 방법파일 다운로드1
12740정성태7/29/202131오류 유형: 747. SharePoint - InvalidOperationException 0x80131509
12739정성태7/28/202129오류 유형: 746. Azure Active Directory - IDW10106: The 'ClientId' option must be provided.
12738정성태7/28/202131오류 유형: 745. Azure Active Directory - Client credential flows must have a scope value with /.default suffixed to the resource identifier (application ID URI).
12737정성태7/28/202129오류 유형: 744. Azure Active Directory - The resource principal named api://...[client_id]... was not found in the tenant
12736정성태7/28/202136오류 유형: 743. Active Azure Directory에서 "API permissions"의 권한 설정이 "Not granted for ..."로 나오는 문제
12735정성태7/27/202175.NET Framework: 1081. C# - Azure AD 인증을 지원하는 데스크톱 애플리케이션 예제(Windows Forms)파일 다운로드1
12734정성태7/26/202182스크립트: 20. 특정 단어로 시작하거나/끝나는 문자열을 포함/제외하는 정규 표현식 - Look-around
12733정성태7/23/202197.NET Framework: 1081. Self-Contained/SingleFile 유형의 .NET Core/5+ 실행 파일을 임베딩한다면?파일 다운로드2
12732정성태7/23/202148오류 유형: 742. SharePoint - The super user account utilized by the cache is not configured.
12731정성태7/23/202159개발 환경 구성: 584. Add Internal URLs 화면에서 "Save" 버튼이 비활성화 된 경우
12730정성태7/23/202166개발 환경 구성: 583. Visual Studio Code - Go 코드에서 입력을 받는 경우
12729정성태7/22/202169.NET Framework: 1080. xUnit 단위 테스트에 메서드/클래스 수준의 문맥 제공 - Fixture
12728정성태7/22/202172.NET Framework: 1079. MSTestv2 단위 테스트에 메서드/클래스/어셈블리 수준의 문맥 제공
12727정성태7/21/2021140.NET Framework: 1078. C# 단위 테스트 - MSTestv2/NUnit의 Assert.Inconclusive 사용법(?)
12726정성태7/21/2021119VS.NET IDE: 169. 비주얼 스튜디오 - 단위 테스트 선택 시 MSTestv2 외의 xUnit, NUnit 사용법
12725정성태7/21/202161오류 유형: 741. Failed to find the "go" binary in either GOROOT() or PATH
12724정성태7/21/2021151개발 환경 구성: 582. 윈도우 환경에서 Visual Studio Code + Go (Zip) 개발 환경 [1]
12723정성태7/21/202162오류 유형: 740. SharePoint - Alternate access mappings have not been configured 경고
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...