[C#] UDP 소켓 통신

maldaliza·2023년 2월 19일
0

C# 개발

목록 보기
2/2
post-thumbnail

이번 시간에는 C#으로 UDP 소켓 통신을 구현해보려 한다. 이전 시간에 알아보았던 TCP 방식은 3 Way HandShake라는 방식으로 서버와 클라이언트 간의 접속 상태를 확인하는 부분이 있었고, 접속 상태가 활성화된 상태에만 통신을 할 수 있었다. 그러나 UDP 방식에서는 접속 상태를 확인하지 않고, 일방적으로 패킷을 전달하는 방식이다. TCP 소켓 통신과 마찬가지로 Socket 클래스를 사용하지 않고, UdpClient 클래스 만으로 간단하게 구현해보겠다.

1. UDP란 무엇인가?

UDP 소켓 통신을 구현해보기에 앞서, UDP가 무엇인지부터 이해하고 넘어가도록 하자. 우선 UDP는 User Datagram Protocol의 약자로 "데이터를 데이터그램 단위로 처리하는 프로토콜"이다. 주된 특징은 다음과 같다.

1. TCP와 달리 UDP는 비연결형 프로토콜이다.

한마디로, 연결을 위해 할당되는 논리적인 경로가 없다는 뜻. 그렇기 때문에 각각의 패킷은 다른 경로로 전달되고, 각각의 패킷이 독립적인 관계를 갖게 된다.

2. TCP 보다 속도가 빠르다.

UDP는 비연결형 프로토콜이기 때문에, 연결을 설정하고 해제하는 과정이 필요가 없다. 고로 서로 다른 독립적인 경로로 처리함으로 패킷의 순서를 조립하거나 하는 등의 기능을 처리하지 않으므로 TCP 보다 속도가 빠르며, 네트워크의 부하가 적다.

3. 신뢰성이 낮다.

UDP는 말그대로 데이터를 뿌려주는 것으로 내부 구조가 간단한 것에 비해, 가끔 패킷이 유실되거나 패킷의 순서가 바뀌는 등의 에러가 발생한다.

위의 특징 이외에도 UDP의 특징을 소개하는 자료가 많다. 간략한 소개는 여기서 마치고, 참조할 만한 사이트를 첨부하겠다.

[참조]https://mangkyu.tistory.com/15

2. UDP 소켓 통신 구현

가. 서버 구현

UI는 위처럼 간단하게 IPPort만 입력할 수 있도록 설계하였고, TCP 방식과는 달리, Connect 버튼이 없다. 이는 UDP 방식은 비연결형 프로토콜이라서 접속이 따로 필요없기 때문이다.

메시지 송신 부분

서버 클라이언트 간의 접속이 필요 없기 때문에, 바로 메시지 송신 부분을 알아보자.

private void send_btn_Click(object sender, EventArgs e)
{
	ip = tb_ip.Text;
    port = int.Parse(tb_port.Text);

    string message = tb_message.Text;
    byte[] bytes = Encoding.UTF8.GetBytes(message);
    try
    {
    	using (UdpClient client = new UdpClient())
        {
        	IPEndPoint endPoint = new IPEndPoint(IPAddress.Parse(ip), port);
            client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
            client.Send(bytes, bytes.Length, endPoint);

            WriteLog(message);
            tb_message.Text = string.Empty;

            client.Close();
        }
    }
    catch (Exception ex)
    {
    	Debug.WriteLine(ex.ToString());
    }
}

메시지 Send 버튼을 클릭하면 발생하는 코드이다. 서버 프로그램의 기능이 메시지 Send 밖에 없고, 클라이언트의 Receive 여부에 대해 상관하지 않으므로 따로 쓰레드를 분리할 필요가 없었다.

코드를 살펴보면, 우선 IPPort를 입력받는다. IP의 Default값을 255.255.255.255라고 설정했는데, 이유는 "글로벌 브로드캐스트 주소" 로, 패킷을 모든 네트워크 인터페이스로 보낸다 라는 뜻이다. 그리고 Port를 설정해준다.

그 후, 메시지를 UTF8 방식으로 인코딩하여 바이트 단위 배열로 받는다. UdpClient 클래스를 이용하여 UDP 네트워크에 대한 정보를 client 객체로 받는다. IPEndPoint 클래스를 이용하여 패킷을 전달받을 주소의 종단점을 입력받고, client.Client.SetSocketOption() 메소드를 통해 Socket 옵션을 설정한다.

마지막으로, Send()를 통해 파라미터로 바이트 배열, 배열 크기, 종단점을 넘겨준다.

위의 코드를 통해 원하는 종단점으로 메시지를 송신하는 것을 알아보았다.

서버 전체 코드

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace SocketProgramming.UDP
{
    public partial class Udp_Server : Form
    {
        private static string ip = string.Empty;
        private static int port = 0;

        public Udp_Server()
        {
            InitializeComponent();
        }

        private void send_btn_Click(object sender, EventArgs e)
        {
            ip = tb_ip.Text;
            port = int.Parse(tb_port.Text);

            string message = tb_message.Text;
            byte[] bytes = Encoding.UTF8.GetBytes(message);
            try
            {
                using (UdpClient client = new UdpClient())
                {
                    IPEndPoint endPoint = new IPEndPoint(IPAddress.Parse(ip), port);
                    client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
                    client.Send(bytes, bytes.Length, endPoint);

                    WriteLog(message);
                    tb_message.Text = string.Empty;

                    client.Close();
                }
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.ToString());
            }
        }

        private void WriteLog(string message)
        {
            string date = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");

            Invoke(new MethodInvoker(delegate ()
            {
                tb_log.AppendText("[" + date + "] " + message + "\r\n");
            }));
        }
    }
}

나. 클라이언트 구현

클라이언트 프로그램도 서버와 마찬가지로 UdpClient 클래스를 이용하여 설계한다. 메시지를 수신만 하는 기능만 설계할 것이다. 그러나 메시지 수신 기능만 있다고 메인 쓰레드에 설계를 하게 된다면, 서버가 메시지를 송신하지 않는 동안 아무것도 할 수가 없다. 그래서 메시지를 수신할 수 있는 쓰레드를 분리하여 설계해야 한다.

메시지 수신 부분

private void Udp_Client_Load(object sender, EventArgs e)
{
	port = int.Parse(tb_port.Text);

    Thread socketThread = new Thread(new ThreadStart(Start));
    socketThread.IsBackground = true;
    socketThread.Start();
}

클라이언트 프로그램이 로드될 때 실행되는 코드다. TextBox로부터 Port만 넘겨받는다. 이유는 메시지를 송신하는 위치의 IP를 모를 수 있기 때문이다.

Thread 클래스를 이용하여 메인 쓰레드와 분리를 시켜준다.

private void Start()
{
	if (client != null) { Debug.WriteLine("이미 UDP 소켓이 생성되어있음.."); }

    client = new UdpClient();
    endPoint = new IPEndPoint(IPAddress.Any, port);

    client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
    client.ExclusiveAddressUse = false;
    client.Client.Bind(endPoint);

    StartListening();
}

분리된 쓰레드에서 실행한 Start() 메소드이다. 전역에 선언한 UdpClient의 객체인 client가 다시 생성되면 안되므로 조건을 걸어준다.

그 후에 UdpClient 객체와, IPPort로 네트워크 종단점을 선언하는 IPEndPoint 클래스의 객체를 선언한다. 모든 네트워크 인터페이스의 클라이언트 동작을 수신 대기해야 하므로 IPAddress.Any를 파라미터로 IPEndPoint 객체를 생성한다.

SetSocketOption() 메소드는 SocketOptionLevel, SocketOptionName, optionValue 값이 파라미터로 전달된다.

첫번째 필수 파라미터인 SocketOptionLevel은 소켓의 옵션 수준을 정의한다. 열거형을 사용하여 옵션이 적용될 OSI 게층 모델을 지정한다. IP, Socket, Tcp, Udp의 옵션이 존재한다.

두번째 필수 파라미터인 SocketOptionName은 값이 설정될 파라미터의 이름을 정의한다. ReuseAddress는 해당 포트에서 이미 수신 대기 중인 다른 애플리케이션이 있는 경우, 소켓 연결을 실패할지 여부를 제어한다. 즉, 다른 응용 프로그램에서 이미 소켓이 열려 있는 경우에도 이 포트에서 수신하게 된다는 것이다.

세번째 파라미터인 optionValue는 소켓 옵션의 현재 동작을 결정한다. (활성화 할 것인지, 말 것인지..)

ExclusiveAddressUse 속성은 Boolean값으로 하나의 포트에 하나의 프로세스만 바인딩할 것을 허용하는지 여부를 지정하는 값이다. Socket이 특정 포트에 하나의 소켓만 바인딩하는 것을 허용하면 true, 그렇지 않으면 false이다.

그 후, 통신의 종단점인 endPoint 객체를 바인딩하고, StartListening()를 실행한다.

private void StartListening()
{
	asyncResult = client.BeginReceive(Receive, new object());
}

StartListening() 메소드는 클래스 전역에 선언된 비동기 작업 상태를 표현하는 IAsyncResult 클래스의 asyncResult 객체를 초기화하는 메소드이다. 데이터그램을 원격 호스트에서 비동기적인 수신을 시작하는 부분으로 첫번째 파라미터의 Receive 메소드가 실행된다.

private void Receive(IAsyncResult ar)
{
	if (client == null) { return; }

    try
    {
    	byte[] bytes = client.EndReceive(ar, ref endPoint);
        string message = Encoding.UTF8.GetString(bytes, 0, bytes.Length);

        if (tb_log.InvokeRequired)
        {
        	tb_log.Invoke(new MethodInvoker(delegate ()
            {
            	WriteLog(message);
            }));
         }
         StartListening();
     }
     catch (Exception ex)
     {
     	Debug.WriteLine(ex.ToString());
     }
}

이제 진짜 메시지를 수신 받는 Receive 메소드다. BeginReceive 메소드에서 호출할 때, IAsyncResult 클래스의 객체를 파라미터로 전달해주기 때문에 그 값으로 endPoint에서 전달하는 메시지를 수신한다. 메시지를 수신 받고 다시 StartListening()을 실행하는 구조이다.

클라이언트 전체 코드

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace SocketProgramming.UDP
{
    public partial class Udp_Client : Form
    {
        private static int port = 0;
        private static UdpClient client;
        private static IPEndPoint endPoint;
        IAsyncResult asyncResult;

        public Udp_Client()
        {
            InitializeComponent();
        }

        private void Udp_Client_Load(object sender, EventArgs e)
        {
            port = int.Parse(tb_port.Text);

            Thread socketThread = new Thread(new ThreadStart(Start));
            socketThread.IsBackground = true;
            socketThread.Start();
        }

        private void Start()
        {
            if (client != null) { Debug.WriteLine("이미 UDP 소켓이 생성되어있음.."); }

            client = new UdpClient();
            endPoint = new IPEndPoint(IPAddress.Any, port);

            client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
            client.ExclusiveAddressUse = false;
            client.Client.Bind(endPoint);

            StartListening();
        }

        private void StartListening()
        {
            asyncResult = client.BeginReceive(Receive, new object());
        }

        private void Receive(IAsyncResult ar)
        {
            if (client == null) { return; }

            try
            {
                byte[] bytes = client.EndReceive(ar, ref endPoint);
                string message = Encoding.UTF8.GetString(bytes, 0, bytes.Length);

                if (tb_log.InvokeRequired)
                {
                    tb_log.Invoke(new MethodInvoker(delegate ()
                    {
                        WriteLog(message);
                    }));
                }
                StartListening();
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.ToString());
            }
        }

        private void WriteLog(string message)
        {
            string date = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");

            Invoke(new MethodInvoker(delegate ()
            {
                tb_log.AppendText("[" + date + "] " + message + "\r\n");
            }));
        }
    }
}

마무리

지금까지 UDP를 이용한 메시지 송수신에 대해 알아보았다. 부족한 점이 많은 포스팅이었지만, 가능한 여유가 있을 때 부족했던 설명을 보완할 예정이다.

[참고]
https://social.msdn.microsoft.com/Forums/en-US/5d755d4a-796c-4906-87a3-d7b40c420c4b/how-to-set-the-reuseaddress-socket-option?forum=netfxnetcom
https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=wkqldpfm&logNo=40155005822

0개의 댓글