[C#] 44. 네트워크 소켓 통신(Socket)을 하는 방법


Study/C#  2021. 10. 6. 17:14

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


이 글은 C#에서 네트워크 소켓 통신(Socket)을 하는 방법에 대한 글입니다.


프로그램과 프로그램, 그리고 컴퓨터와 컴퓨터끼리 데이터를 주고 받는 것을 통신이라고 합니다. 통신을 좀 더 자세히 설명하면, 전송하는 패킷(데이터)이 컴퓨터의 랜 카드를 거쳐 랜 케이블로 나갑니다. 랜 케이블로 나간 데이터는 DNS와 라우터 등을 거쳐 도달하고자 하는 PC의 랜 카드에 들어가고 목표로 하는 프로그램에서 패킷(데이터)를 읽어 서로 간에 데이터를 주고 받습니다.

이 때, 우리는 각 단말 간에 데이터 변환이나 장비 간의 통신 규약에 대해서 모두 개발하지 않습니다. 이러한 통신 규약 등은 모두 OS 측에서 설정(OSI 7계층)되고, 우리는 그 위에 꽂아서 쓴다라는 개념으로 Socket 통신 규약을 이용해 통신합니다.


링크 - [위키백과] OSI 모형


Socket 통신 규약은 규칙이 정해져 있습니다.

먼저 기다리는 측의 PC를 서버라고 하며 Port를 열고 클라이언트의 접속을 기다립니다. 그리고 접속하는 측을 클라이언트라고 하며 서버의 IP와 Port에 접속하여 통신이 연결이 됩니다.

서버와 클라이언트 간의 통신은 Send, Receive의 형태로 데이터를 주고 받습니다. 그리고 서로 통신이 끝나면 Close로 접속을 끊습니다.

이 규약을 이용해서 C#에서 소켓 통신을 작성해 보겠습니다.

먼저 Server를 만들고 Window의 Telnet 프로그램을 이용해서 접속을 확인하고 그리고 사양에 맞추어서 Client를 작성하겠습니다.

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

namespace Example
{
  class Program
  {
    // 실행 함수
    static void Main(string[] args)
    {
      // Socket EndPoint 설정(서버의 경우는 Any로 설정하고 포트 번호만 설정한다.)
      var ipep = new IPEndPoint(IPAddress.Any, 10000);
      // 소켓 인스턴스 생성
      using (Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
      {
        // 서버 소켓에 EndPoint 설정
        server.Bind(ipep);
        // 클라이언트 소켓 대기 버퍼
        server.Listen(20);
        // 콘솔 출력
        Console.WriteLine($"Server Start... Listen port {ipep.Port}...");
        // 클라이언트로부터 접속 대기
        using (var client = server.Accept())
        {
          // 클라이언트 EndPoint 정보 취득
          var ip = client.RemoteEndPoint as IPEndPoint;
          // 콘솔 출력 - 접속 ip와 접속 시간
          Console.WriteLine($"Client : (From: {ip.Address.ToString()}:{ip.Port}, Connection time: {DateTime.Now})");
          // 클라이언트로 접속 메시지를 byte로 변환하여 송신
          client.Send(Encoding.ASCII.GetBytes("Welcome server!\r\n>"));
          // 메시지 버퍼
          var sb = new StringBuilder();
          // 통신 바이너리 버퍼
          var binary = new Byte[1024];
          // 무한 루프
          while (true)
          {
            // 클라이언트로부터 메시지 대기
            client.Receive(binary);
            // 클라이언트로 받은 메시지를 String으로 변환
            var data = Encoding.ASCII.GetString(binary);
            // 메시지 공백(\0)을 제거
            sb.Append(data.Trim('\0'));
            // 메시지 총 내용이 2글자 이상이고 개행(\r\n)이 발생하면
            if (sb.Length > 2 && sb[sb.Length - 2] == '\r' && sb[sb.Length - 1] == '\n')
            {
              // 메시지 버퍼의 내용을 String으로 변환
              data = sb.ToString().Replace("\n", "").Replace("\r", "");
              // 메시지 내용이 공백이라면 계속 메시지 대기 상태로
              if (String.IsNullOrWhiteSpace(data))
              {
                continue;
              }
              // 메시지 내용이 exit라면 무한 루프 종료(즉, 서버 종료)
              if ("EXIT".Equals(data, StringComparison.OrdinalIgnoreCase))
              {
                break;
              }
              // 메시지 내용을 콘솔에 표시
              Console.WriteLine("Message = " + data);
              // 버퍼 초기화
              sb.Length = 0;
              // 메시지에 ECHO를 붙힘
              var sendMsg = Encoding.ASCII.GetBytes("ECHO : " + data + "\r\n>");
              // 클라이언트로 메시지 송신
              client.Send(sendMsg);
            }
          }
          // 콘솔 출력 - 접속 종료 메시지
          Console.WriteLine($"Disconnected : (From: {ip.Address.ToString()}:{ip.Port}, Connection time: {DateTime.Now})");
        }
      }
      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

위 내용은 윈도우 telnet 프로그램으로 제가 만든 서버 프로그램에 접속하는 예제를 작성했습니다.

먼저 프로그램을 설명하면 Socket 클래스로 서버 Socket 서버 인스턴스를 생성하였습니다. Bind 함수를 사용하여 대기 포트를 설정합니다.

Listen으로 동시 접속 대기 설정을 하고 Accept 함수를 통해 클라이언트의 접속을 대기합니다.

프로그램 상에서는 Accept 함수가 호출이 되면 Client 접속이 발생할 때까지 프로세스가 멈추게 됩니다.


그리고 telnet 프로그램으로 접속을 하게 되면 Accept함수를 통해서 클라이언트 Socket 인스턴스가 나오고 Send와 Receive 함수를 통해서 서버와 클라이언트로부터 서로 메시지를 주고 받을 수 있습니다.


위 예제는 제가 Main 함수에 넣었기 때문에 하나의 클라이언트의 접속만 허용됩니다. 그러니깐 클라이언트 둘 이상이 접속이 되지 않는 상태입니다.

그러면 멀티 접속을 허용하게 하려면 쓰레드 기능을 넣어야 합니다.

using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;

namespace Example
{
  class Program
  {
    // 서버 실행 Task 메소드
    static async Task RunServer(int port)
    {
      // Socket EndPoint 설정(서버의 경우는 Any로 설정하고 포트 번호만 설정한다.)
      var ipep = new IPEndPoint(IPAddress.Any, port);
      // 소켓 인스턴스 생성
      using (Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
      {
        // 서버 소켓에 EndPoint 설정
        server.Bind(ipep);
        // 클라이언트 소켓 대기 버퍼
        server.Listen(20);
        // 콘솔 출력
        Console.WriteLine($"Server Start... Listen port {ipep.Port}...");
        // server Accept를 Task로 병렬 처리(즉, 비동기를 만든다.)
        var task = new Task(() =>
        {
          // 무한 루프
          while (true)
          {
            // 클라이언트로부터 접속 대기
            var client = server.Accept();
            // 접속이 되면 Task로 병렬 처리
            new Task(() =>
            {
              // 클라이언트 EndPoint 정보 취득
              var ip = client.RemoteEndPoint as IPEndPoint;
              // 콘솔 출력 - 접속 ip와 접속 시간
              Console.WriteLine($"Client : (From: {ip.Address.ToString()}:{ip.Port}, Connection time: {DateTime.Now})");
              // 클라이언트로 접속 메시지를 byte로 변환하여 송신
              client.Send(Encoding.ASCII.GetBytes("Welcome server!\r\n>"));
              // 메시지 버퍼
              var sb = new StringBuilder();
              // 종료되면 자동 client 종료
              using (client)
              {
                // 무한 루프
                while (true)
                {
                  // 통신 바이너리 버퍼
                  var binary = new Byte[1024];
                  // 클라이언트로부터 메시지 대기
                  client.Receive(binary);
                  // 클라이언트로 받은 메시지를 String으로 변환
                  var data = Encoding.ASCII.GetString(binary);
                  // 메시지 공백(\0)을 제거
                  sb.Append(data.Trim('\0'));
                  // 메시지 총 내용이 2글자 이상이고 개행(\r\n)이 발생하면
                  if (sb.Length > 2 && sb[sb.Length - 2] == '\r' && sb[sb.Length - 1] == '\n')
                  {
                    // 메시지 버퍼의 내용을 String으로 변환
                    data = sb.ToString().Replace("\n", "").Replace("\r", "");
                    // 메시지 내용이 공백이라면 계속 메시지 대기 상태로
                    if (String.IsNullOrWhiteSpace(data))
                    {
                      continue;
                    }
                    // 메시지 내용이 exit라면 무한 루프 종료(즉, 서버 종료)
                    if ("EXIT".Equals(data, StringComparison.OrdinalIgnoreCase))
                    {
                      break;
                    }
                    // 메시지 내용을 콘솔에 표시
                    Console.WriteLine("Message = " + data);
                    // 버퍼 초기화
                    sb.Length = 0;
                    // 메시지에 ECHO를 붙힘
                    var sendMsg = Encoding.ASCII.GetBytes("ECHO : " + data + "\r\n>");
                    // 클라이언트로 메시지 송신
                    client.Send(sendMsg);
                  }
                }
                // 콘솔 출력 - 접속 종료 메시지
                Console.WriteLine($"Disconnected : (From: {ip.Address.ToString()}:{ip.Port}, Connection time: {DateTime.Now})");
              }
              // Task 실행
            }).Start();
          }
        });
        // Task 실행
        task.Start();
        // 대기
        await task;
      }
    }
    // 실행 함수
    static void Main(string[] args)
    {
      // Task로 Socket 서버를 만듬(서버가 종료될 때까지 대기)
      RunServer(10000).Wait();
      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

위 예제는 첫번째 예제에서 Task 쓰레드를 이용해서 멀티 접속이 가능하게 만들었습니다.

Accept 함수는 클라이언트가 접속하기 전에 쓰레드가 멈추는 형태이기 때문에, 클라이언트로 접속되면 병렬로 다시 Task 쓰레드를 만들고 다시 루프로 Accept로 대기 상태로 들어갑니다.

여기까지 간단한 서버 프로그램은 만들어 졌습니다.


위 프로그램을 기반으로 다시 클라이언트를 만들어 보겠습니다.

using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;

namespace Example
{
  class Program
  {
    // 실행 함수
    static void Main(string[] args)
    {
      // Socket EndPoint 설정
      var ipep = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 10000);
      // 소켓 인스턴스 생성
      using (Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
      {
        // 소켓 접속
        client.Connect(ipep);
        // 접속이 되면 Task로 병렬 처리
        new Task(() =>
        {
          try
          {
            // 종료되면 자동 client 종료
            // 무한 루프
            while (true)
            {
              // 통신 바이너리 버퍼
              var binary = new Byte[1024];
              // 서버로부터 메시지 대기
              client.Receive(binary);
              // 서버로 받은 메시지를 String으로 변환
              var data = Encoding.ASCII.GetString(binary).Trim('\0');
              // 메시지 내용이 공백이라면 계속 메시지 대기 상태로
              if (String.IsNullOrWhiteSpace(data))
              {
                continue;
              }
              // 메시지 내용을 콘솔에 표시
              Console.Write(data);
            }
          }
          catch (SocketException)
          {
            // 접속 끝김이 발생하면 Exception이 발생
          }
          // Task 실행
        }).Start();
        // 유저로부터 메시지 받기 위한 무한 루프
        while (true)
        {
          // 콘솔 입력 받는다.
          var msg = Console.ReadLine();
          // 클라이언트로 받은 메시지를 String으로 변환
          client.Send(Encoding.ASCII.GetBytes(msg + "\r\n"));
          // 메시지 내용이 exit라면 무한 루프 종료(즉, 클라이언트 종료)
          if ("EXIT".Equals(msg, StringComparison.OrdinalIgnoreCase))
          {
            break;
          }
        }
        // 콘솔 출력 - 접속 종료 메시지
        Console.WriteLine($"Disconnected");
      }
      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

서버 프로그램에 맞추어서 클라이언트를 만들었습니다.

서버와 Send, Receive 함수는 서버와 비슷합니다만, Bind, Listen 대신에 Connect 함수를 써서 서버에 Socket 접속을 합니다.

클라이언트는 보통 하나의 서버를 접속하기 때문에 따로 병렬 처리를 만들 필요는 없습니다. 있다면 Receive 함수만 Task 쓰레드로 Send, Receive를 분리했습니다.

사양에 따라 클라이언트도 여러 서버를 동시에 접속할 수 있습니다만, 기본적으로는 하나의 서버의 접속을 합니다.


여기까지 C#에서 네트워크 소켓 통신(Socket)을 하는 방법에 대한 글이었습니다.


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