[C#] 비동기 소켓 통신(IOCP) - EAP 패턴


Development note/C#  2020. 1. 31. 20:03

안녕하세요. 명월입니다.


이 글은 C#에서 비동기 소켓 통신(IOCP) - EAP 패턴에 대한 글입니다.


약 8년전에 이 글에 대해 작성한 적이 있었는데, 그 때는 IOCP의 대한 정확한 개념없이 단순히 리소스를 아낄 수 있는 방법에 대해 작성했습니다.

먼저 IOCP에 대해 간략하게 이야기하겠습니다.


이 전에 동기 소켓 서버에 대해 글을 쓴 적이 있습니다.(이 글도 오래전에 작성한 거라 언젠간 다시 작성해야 겠네요..)

링크 - [C# 강좌 - 32] 소켓 통신 - 1


여기서 동기, 비동기에 대한 의미입니다만, 저도 이 용어때문에 참 헤갈렸던 걸로 기억합니다.

먼저 동기, 비동기는 우리가 흔히 이야기하는 클라이언트와 서버간에 동기면 계속 연결이 되어있는 상태이고 비동기면 한번에 전송 후에 접속을 끊는 형태를 이야기합니다.

근데, 여기서 이야기하는 동기, 비동기 개념은 비슷한데 약간 틀립니다.

동기 소켓이나 비동기 소켓이던 클라이언트에서 접속을 하면 연결을 끊는 것이 아니고 시스템 내부에서 하나의 쓰레드로 리소스를 유지하느냐 안하느냐의 차이입니다.


즉, 동기 소켓이면 Socket 클래스로 서버를 Listen한 다음 Client가 접속을 하면 또 하나의 Socket와 Thread를 생성하여 객체를 유지하는 것입니다. 여기서 보통 Vector나 List로 Client가 접속할 때마다 Socket과 Thread를 보관하게 됩니다.

이 동기 소켓의 문제가 무엇이냐면 다중 접속일 때 문제가 발생합니다. 우리 시스템은 자원이 한정적이기 때문에 무한히 Thread와 Socket객체를 만들어 낼 수 없습니다.

게임 서버라고 가정한다면 동시 접속 200~300만 되어도 서버 프로그램에 쓰레드는 200~300개나 늘어나게 되는 것입니다. 최근에 하드웨어가 어떻게 어떻게 버틸 수는 있겠지만 C#의 장점이자 단점인 GC(가비지 컬렉션)가 우리를 기다리고 있습니다.

C#이라는 언어는 클래스를 생성을 할 수는 있지만 유저가 메모리 해제를 하지 못합니다. GC이 움직이게 됩니다. 그럴 때, 리소스를 마구잡이로 사용하고 어느 순간 GC가 움직이게 되면 서버는 프리징 상태에 빠져도 이상하지 않을 것입니다.


이걸 해결하기 위해서 있는 것이 비동기 소켓입니다. 비동기 소켓은 Client가 접속을 하면 접속할 때의 처리를 위한 이벤트가 발생하고 연결 리소스를 큐 구조의 IOCP에 쌓습니다.

그럼 Client로 부터 메시지가 올 때는 이 IOCP에서 연결 리소스를 꺼내서 프로그램으로 알려주는 것입니다. 결국은 비동기 서버는 접속시마다 Thread를 쌓을 필요가 없기 때문에 리소스를 많이 아낄 수가 있습니다.

그리고 Thread를 관리하는 ThreadPool를 만들 필요도 없고 역시, Thread를 관리할 필요도 없기 때문에 소스도 매우 간단해 집니다.


이게 동기 서버와 비동기 서버의 차이입니다.

그렇다면 비동기 서버가 메모리 효율도 좋고 소스도 훨씬 간단하면 동기 서버는 필요가 없을 듯합니다만, 꼭 그렇지는 않습니다.

비동기도 단점이 분명히 있는데, 사양의 차이입니다.

만약 Client의 액션으로 Server가 반응하는 형태, webserver나 게임 서버같은 경우는 비동기가 유리합니다.

하지만 반대로 Client의 액션이 없이도 Server가 Client로 보내는 데이터가 많을 경우, 원 프레임 단말 형식이던가 주식 차트처럼 추이 프로그램의 데이터를 계속적으로 받아야하는 형태는 동기가 훨씬 낫습니다.

이 차이는 Server에서 Client를 얼마나 잘 찾을 수 있는 것인데, 물론 비동기에서도 접속할 때마다 List에 Socket을 보관하게 되면 가능합니다만, 이럴 때는 확실히 동기식 소켓이 여러가지 편한 점이 많습니다.


C#에서는 이 비동기가 TAP(Task-based Asynchronous Pattern), EAP(Event-based Asynchronous Pattern), APM(Asynchronous Programming Model)이 존재합니다.

여기서는 EAP패턴에 대해 소개하겠습니다.

using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
// 프로그램 개시 main은 아래에 있습니다.
namespace AsyncSocketServer
{
  // Client Event로 SocketAsyncEventArgs를 상속받았다.
  class Client : SocketAsyncEventArgs
  {
    // 메시지는 개행으로 구분한다.
    private static char CR = (char)0x0D;
    private static char LF = (char)0x0A;

    private Socket socket;
    // 메시지를 모으기 위한 버퍼
    private StringBuilder sb = new StringBuilder();
    private IPEndPoint remoteAddr;

    public Client(Socket socket)
    {
      this.socket = socket;
      // 메모리 버퍼를 초기화 한다. 크기는 1024이다
      base.SetBuffer(new byte[1024], 0, 1024);
      base.UserToken = socket;
      // 메시지가 오면 이벤트를 발생시킨다. (IOCP로 꺼내는 것)
      base.Completed += Client_Completed; ;
      // 메시지가 오면 이벤트를 발생시킨다. (IOCP로 넣는 것)
      this.socket.ReceiveAsync(this);

      // 접속 환영 메시지
      remoteAddr = (IPEndPoint)socket.RemoteEndPoint;
      Console.WriteLine($"Client : (From: {remoteAddr.Address.ToString()}:{remoteAddr.Port}, Connection time: {DateTime.Now})");
      this.Send("Welcome server!\r\n>");
    }
    // 메시지가 오면 발생하는 이벤트
    private void Client_Completed(object sender, SocketAsyncEventArgs e)
    {
      // 접속이 연결되어 있으면...
      if (socket.Connected && base.BytesTransferred > 0)
      {
        // 수신 데이터는 e.Buffer에 있다.
        byte[] data = e.Buffer;
        // 데이터를 string으로 변환한다.
        string msg = Encoding.ASCII.GetString(data);
        // 메모리 버퍼를 초기화 한다. 크기는 1024이다
        base.SetBuffer(new byte[1024], 0, 1024);
        // 버퍼의 공백은 없앤다.
        sb.Append(msg.Trim('\0'));
        // 메시지의 끝이 이스케이프 \r\n의 형태이면 서버에 표시한다.
        if (sb.Length >= 2 && sb[sb.Length - 2] == CR && sb[sb.Length - 1] == LF)
        {
          // 개행은 없애고..
          sb.Length = sb.Length - 2;
          // string으로 변환한다.
          msg = sb.ToString();
          // 콘솔에 출력한다.
          Console.WriteLine(msg);
          // Client로 Echo를 보낸다.
          Send($"Echo - {msg}\r\n>");
          // 만약 메시지가 exit이면 접속을 끊는다.
          if ("exit".Equals(msg, StringComparison.OrdinalIgnoreCase))
          {
            // 접속 종료 메시지
            Console.WriteLine($"Disconnected : (From: {remoteAddr.Address.ToString()}:{remoteAddr.Port}, Connection time: {DateTime.Now})");
            // 접속을 중단한다.
            socket.DisconnectAsync(this);
            return;
          }
          // 버퍼를 비운다.
          sb.Clear();
        }
        // 메시지가 오면 이벤트를 발생시킨다. (IOCP로 넣는 것)
        this.socket.ReceiveAsync(this);
      }
      else
      {
        // 접속이 끊겼다..
        Console.WriteLine($"Disconnected :  (From: {remoteAddr.Address.ToString()}:{remoteAddr.Port}, Connection time: {DateTime.Now})");
      }
    }
    // Send도 비동기 식으로 만들 수 있는데.. 굳이 send는 그럴 필요가 없습니다.
    //private SocketAsyncEventArgs sendArgs = new SocketAsyncEventArgs();
    private void Send(String msg)
    {
      byte[] sendData = Encoding.ASCII.GetBytes(msg);
      //sendArgs.SetBuffer(sendData, 0, sendData.Length);
      //socket.SendAsync(sendArgs);
      // Client로 메시지 전송
      socket.Send(sendData, sendData.Length, SocketFlags.None);
    }
  }
  // 서버 Event로 SocketAsyncEventArgs를 상속받았다.
  class Server : SocketAsyncEventArgs
  {
    private Socket socket;
    public Server(Socket socket)
    {
      this.socket = socket;
      base.UserToken = socket;
      // Client로부터 Accept이 되면 이벤트를 발생시킨다. (IOCP로 꺼내는 것)
      base.Completed += Server_Completed; ;
    }

    // Client가 접속하면 이벤트를 발생한다.
    private void Server_Completed(object sender, SocketAsyncEventArgs e)
    {
      // 접속이 완료되면, Client Event를 생성하여 Receive이벤트를 생성한다.
      var client = new Client(e.AcceptSocket);
      // 서버 Event에 cilent를 제거한다.
      e.AcceptSocket = null;
      // Client로부터 Accept이 되면 이벤트를 발생시킨다. (IOCP로 넣는 것)
      this.socket.AcceptAsync(e);
    }
  }
  // 메인 Program은 Socket을 상속받고 서버 Socket으로 사용한다.
  class Program : Socket
  {
    public Program() : base(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
    {
      // 포트는 10000을 Listen한다.
      base.Bind(new IPEndPoint(IPAddress.Any, 10000));
      base.Listen(20);
      // 비동기 소켓으로 Server 클래스를 선언한다. (IOCP로 집어넣는것)
      base.AcceptAsync(new Server(this));
    }
    // 프로그램 시작 함수
    static void Main(string[] args)
    {
      // Program 클래스를 실행한다.
      new Program();
      // q키를 누르면 서버는 종료한다.
      Console.WriteLine("Press the q key to exit.");
      while (true)
      {
        string k = Console.ReadLine();
        if ("q".Equals(k, StringComparison.OrdinalIgnoreCase))
        {
          break;
        }
      }
    }
  }
}

위 소스는 Window의 telnet에서 메시지를 주고 받을 수 있게 맞추어서 작성했습니다.

그럼 telnet으로 접속해 보겠습니다.

ip는 로컬(127.0.0.1)로 설정하고 포트는 10000번입니다.

텔넷에서 메시지를 보내니 서버 측에서 메시지를 제대로 받습니다. echo도 제대로 수신됩니다.

종료까지 깔끔하게 통신이 이루어 집니다.

서버는 만들었고 이제는 Client를 만들겠습니다. client라고 해봐야 서버 소스와 거의 비슷합니다.

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

namespace AsyncSocketClient
{
  // Client Event로 SocketAsyncEventArgs를 상속받았다.
  class Client : SocketAsyncEventArgs
  {
    // 메시지는 개행으로 구분한다.
    private static char CR = (char)0x0D;
    private static char LF = (char)0x0A;

    private Socket socket;
    // 메시지를 모으기 위한 버퍼
    private StringBuilder sb = new StringBuilder();

    public Client(EndPoint pep)
    {
      RemoteEndPoint = pep;
      // 접속시 발생하는 이벤트를 등록한다.
      base.Completed += Connected_Completed;
      // 2020/03/19일 수정, connect이벤트가 발생할때, 버퍼를 설정하면 멈춰버리는 문제가 발생합니다.
      //base.SetBuffer(new byte[1024], 0, 1024);
      //base.Completed += Client_Completed; ;
      //this.socket.ReceiveAsync(this);
    }
    private void Connected_Completed(object sender, SocketAsyncEventArgs e)
    {
      // 접속 이벤트는 해제한다.
      base.Completed -= Connected_Completed;
      // 접속 소켓 설정
      this.socket = e.ConnectSocket;
      base.UserToken = this.socket;
      // 버퍼 설정
      base.SetBuffer(new byte[1024], 0, 1024);
      // 수신 이벤트를 등록한다.
      base.Completed += Client_Completed;
      // 메시지가 오면 이벤트를 발생시킨다. (IOCP로 넣는 것)
      this.socket.ReceiveAsync(this);
    }
    // 메시지가 오면 발생하는 이벤트
    private void Client_Completed(object sender, SocketAsyncEventArgs e)
    {
      // 접속이 연결되어 있으면...
      if (socket.Connected && base.BytesTransferred > 0)
      {
        // 수신 데이터는 e.Buffer에 있다.
        byte[] data = e.Buffer;
        // 데이터를 string으로 변환한다.
        string msg = Encoding.ASCII.GetString(data);
        // 메모리 버퍼를 초기화 한다. 크기는 1024이다
        base.SetBuffer(new byte[1024], 0, 1024);
        sb.Append(msg.Trim('\0'));
        // 메시지의 끝이 이스케이프 \r\n와 >의 형태이면 클라이언트에 표시한다.
        if (sb.Length >= 3 && sb[sb.Length - 3] == CR && sb[sb.Length - 2] == LF && sb[sb.Length - 1] == '>')
        {
          msg = sb.ToString();
          // 콘솔에 출력한다.
          Console.Write(msg);
          // 버퍼 초기화
          sb.Clear();
        }
        // 메시지가 오면 이벤트를 발생시킨다. (IOCP로 넣는 것)
        this.socket.ReceiveAsync(this);
      }
      else
      {
        // 접속이 끊겼다..
        var remoteAddr = (IPEndPoint)socket.RemoteEndPoint;
        Console.WriteLine($"Disconnected :  (From: {remoteAddr.Address.ToString()}:{remoteAddr.Port}, Connection time: {DateTime.Now})");
      }
    }
    // Send도 비동기 식으로 만들 수 있는데.. 굳이 send는 그럴 필요가 없습니다.
    // private SocketAsyncEventArgs sendArgs = new SocketAsyncEventArgs();
    public void Send(String msg)
    {
      byte[] sendData = Encoding.ASCII.GetBytes(msg);
      //sendArgs.SetBuffer(sendData, 0, sendData.Length);
      //socket.SendAsync(sendArgs);
      socket.Send(sendData, sendData.Length, SocketFlags.None);
    }
  }
  // 메인 Program은 Socket을 상속받고 클라이언트 Socket으로 사용한다.
  class Program : Socket
  {
    public Program() : base(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
    {
      // 접속 Event를 생성한다.
      var client = new Client(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 10000));
      // 접속한다.
      base.ConnectAsync(client);
      while (true)
      {
        // 콘솔로 부터 메시지를 받으면 서버로 보낸다.
        string k = Console.ReadLine();
        client.Send(k + "\r\n");
        // exit면 종료한다.
        if ("exit".Equals(k, StringComparison.OrdinalIgnoreCase))
        {
          break;
        }
      }
    }

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

비동기 소켓으로 만든 클라이언트에서 서버로 접속이 잘됩니다.

---- 추가 2020년 3월 19일 ----

Client가 안된다는 질문이 있어서 확인해 보니, 위 Client 소스가 잘못 되어 있었네요. 문제는 이전에 ConnectAsync와 ReceiveAsync의 이벤트의 델리게이트를 모두 Client_Completed의 함수에서 받게 설정했습니다.

그러나 ConnectAsync의 이벤트가 발생할 때, buffer를 설정해 버리면 client가 멈춰버리는 문제가 발생하네요. 이게 예전 .Net framework 버전 별로 멈출때가 있고 정상 실행될 때가 있고 그러네요..

어쨋든 그래서 이벤트의 델리게이트를 분리 시켰습니다. 분리시키니 문제없이 실행되네요.

소스를 수정해 두었습니다.

지적 감사합니다.

--------------------------


여기까지 C#에서 비동기 소켓 통신(IOCP) - EAP 패턴에 대한 설명이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.