Notice
Recent Posts
Recent Comments
Link
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
Tags
more
Archives
Today
Total
관리 메뉴

우당탕탕 개발일지

[C#] 채팅 프로그램 - Echo Server (feat. WinForm) 본문

Server/C#

[C#] 채팅 프로그램 - Echo Server (feat. WinForm)

YUDENG 2025. 2. 19. 21:49

소켓(Socket)이란?

소켓은 프로그램이 네트워크에서 데이터를 주고받을 수 있도록 네트워크 환경에 연결할 수 있게 만들어진 통신 엔드포인트를 의미한다. C#에서는 System.Net.Sockets 네임스페이스를 사용하여 소켓 프로그래밍을 지원할 수 있다.

 

소켓 실행 흐름도

 

 

소켓은 클라이언트 소켓과 서버 소켓으로 구분된다. 서버와 클라이언트에서 공통으로 구현해야 할 부분은 3가지이다.

  1. TCP 소켓을 이용해 데이터를 송수신
  2. IP : 127.0.0.1 / PORT : 8888 를 엔드포인트로 사용 (변경 가능)
  3. 메세지 타입은 String, 인코딩 형식은 UTF-8로 통일

 

서버는 소켓 생성과 바인딩을 마치면 listen( )로 클라이언트 요청을 대기하고, accept( )로 클라이언트와 연결한다. 연결이 완료되면 데이터를 송수신 할 수 있는 상태가 된다.

클라이언트는 소켓 생성과 바인딩을 마치면 accept( )로 클라이언트의 소켓 정보를 반환하며 데이터를 송수신한다.

 

 

소켓 바인딩(Binding)이란?

바인딩은 소켓을 특정 IP 주소와 포트 번호에 연결하는 과정을 의미한다. 

 

서버는 여러 개의 네트워크 주소를 가질 수 있으며, 포트 번호만으로는 서버 소켓을 구분할 수 없다. 운영체제는 (IP + Port) 조합을 보고 어떤 소켓으로 데이터를 전달할지 결정하며, 서버 소켓은 클라이언트 연결 요청을 수락하기 위해 IP와 포트를 미리 지정하는 것이다.

 

 

 

C# 채팅 프로그램

 

dotnet new console -n 프로젝트명

 

위 명령어를 실행하면 C# 프로젝트의 기본 구조가 생성된다.

  • 프로젝트명.csproj : 프로젝트 설정 파일
  • Program.cs : Main 메서드를 포함하는 C# 실행 파일
  • obj & bin 폴더 : 컴파일된 파일과 중간 결과물이 저장되는 폴더

 


채팅 프로그램은 다음 순서로 발전시키면서 서버-클라이언트 구조를 확장해 나갈 예정이다.

 

1️⃣ 에코 서버(Echo Server) → 2️⃣ 브로드캐스트(Broadcast, 전체 메시지 전송) → 3️⃣ 방 대 방(Room-to-Room, 특정 그룹 메시지 전송)

 

 

 

앞으로 진행할 폴더 구조는 다음과 같다.

ZamTok.sln
├── ZamTok.Server/
│   ├── Forms/
│   │   └── ServerForm.cs (서버 UI)
│   ├── Network/
│   │   └── ServerNetwork.cs (서버 네트워크 로직)
│   └── Program.cs (서버 진입점)
│
└── ZamTok.Client/
    ├── Forms/
    │   └── ClientForm.cs (클라이언트 UI)
    ├── Network/
    │   └── ClientNetwork.cs (클라이언트 네트워크 로직)
    └── Program.cs (클라이언트 진입점)

 

테스트는 원폼 GUI로 진행을 하였으며, 당연하게도 C# 프로그램을 컴파일하고 실행하기 위한 .NET SDK가 필요하다.

 

WinForm

윈폼(Winforms)은 윈도우 폼(Windows Forms)의 줄임말이며, 윈도우 기반 사용자 인터페이스(UI) 애플리케이션을 만들기 위한 환경을 의미한다. C# 기반 사용 가능한 다른 UI 프레임워크로는 WDF가 있다. 두 기술의 큰 차이점은 디자인 방식에 있다.

 

윈폼은 툴바에서 원하는 컨트롤을 선택하여 디자이너 화면에 배치하는 방식으로, 쉽게 디자인을 할 수 있다. WPF은 Xaml을 사용하여 컨트롤을 직접 디자인할 수 있다. 개발자와 디자이너간 협업에서 코드 구현부와 디자인 구현부가 분리되어 있기에 개발에 편리하며, 고해상도에서도 깨지지 않는 그래픽을 지원한다.

 

 

예외 발생: 'System.InvalidOperationException'(System.Windows.Forms.dll) 클라이언트 연결 오류: 크로스 스레드 작업이 잘못되었습니다. '' 컨트롤이 자신이 만들어진 스레드가 아닌 스레드에서 액세스되었습니다.

 

윈폼 사용시 주의해야 할 점은 윈폼의 UI 요소는 메인 스레드에서만 접근이 가능하다는 점이다. 메인 스레드가 아닌 다른 스레드에서 UI를 수정하려고 하면 Cross-Thread 오류가 발생하기 때문에, 스레드 안전성을 보장하기 위해 Invoke를 사용한다.

 

WinForms 컨트롤

  • ListBox : 목록 표시
    listBox.Items.Add("새로운 아이템");
    
  • TextBox  : 사용자가 텍스트를 입력할 수있다.
    textBox.AppendText("새로운 텍스트");
    textBox.Text += "새로운 텍스트";
  • Panel : UI 그룹화
  • Button : 클릭 이벤트 연결

 

 

에코 서버(Echo Server) 구현

에코 서버란?

클라이언트가 보낸 메세지를 그대로 다시 돌려주는 단순한 서버이다.

 

 

 

 

1. ClientForm

using System;
using System.Windows.Forms;
using System.Drawing;
using ClientNetwork;

namespace Client.Forms
{
  public partial class ClientForm : Form
  {
    private ClientManager manager;
    private ListBox messages;
    private TextBox messageTxt;
    private TextBox portTxt;
    private TextBox logTxt;
    private Button btnConnect;
    private Button btnSend;
    public ClientForm()
    {
      InitializeComponent();
      this.Text = "채팅 클라이언트";
      this.Size = new Size(600, 600);

      manager = new ClientManager();
      manager.OnConnected += HandleServerConnected;
      manager.OnDisconnected += HandleDisConnected;
      manager.OnMessageReceived += HandleMessageReceived;

      InitializeUI();
    }

    private void InitializeUI()
    {
      // 포트 입력 패널
      var portPanel = new Panel
      {
        Text = "연결하기",
        Dock = DockStyle.Top,
        Height = 50
      };

      portTxt = new TextBox
      {
        Text = "8888",
        Location = new Point(10, 15),
        Width = 140,
      };

      btnConnect = new Button
      {
        Location = new Point(170, 15),
        Width = 100,
        Height = 30,
        Text = "Connect"
      };
      btnConnect.Click += btnConnectServer;

      portPanel.Controls.Add(portTxt);
      portPanel.Controls.Add(btnConnect);

      // 로그 & 메시지 영역
      var mainPanel = new Panel
      {
        Dock = DockStyle.Fill
      };

      messages = new ListBox
      {
        Dock = DockStyle.Fill,
        Font = new Font("맑은 고딕", 10)
      };

      mainPanel.Controls.Add(messages);

      var bottomPanel = new Panel
      {
        Dock = DockStyle.Bottom,
        Height = 50
      };

      btnSend = new Button
      {
        Location = new Point(450, 10),
        Width = 100,
        Height = 30,
        Text = "Send"
      };
      btnSend.Click += btnSendMessage;

      messageTxt = new TextBox
      {
        Location = new Point(10, 10),
        Height = 30,
        Width = 400,
        Multiline = true,
        Font = new Font("맑은 고딕", 10),
      };
      bottomPanel.Controls.Add(messageTxt);
      bottomPanel.Controls.Add(btnSend);

      Controls.Add(bottomPanel);
      Controls.Add(mainPanel);
      Controls.Add(portPanel);
    }

    private void btnConnectServer(object sender, EventArgs e)
    {
      if (!int.TryParse(portTxt.Text, out int port))
      {
        LogMessage("올바른 포트 번호를 입력하세요.");
        return;
      }

      try{
        btnConnect.Enabled = false;
        portTxt.Enabled = false;
      
        LogMessage($"서버 {port}에 연결 시도...");
        manager.Connect(port);
      }
      catch(Exception ex)
      {
        LogMessage($"연결 오류: {ex.Message}");
      }
    }

    private async void btnSendMessage(object sender, EventArgs e)
    {
      string message = messageTxt.Text.Trim();
      if(!string.IsNullOrEmpty(message))
      {
        await manager.SendMessage(message);
        LogMessage($"전송: {message}");
        messageTxt.Clear();
      }
    }

    private void HandleServerConnected(string serverInfo)
    {
      LogMessage($"서버에 연결되었습니다.");
    }

    private void HandleDisConnected(String type)
    {
      if (this.InvokeRequired)
      {
        this.Invoke(new Action<string>(HandleDisConnected), type);
        return;
      }

      try
      {
        btnConnect.Enabled = true;
        portTxt.Enabled = true;

        if(type == "error")
        {
          LogMessage("서버 연결 실패");
        }
        else if(type == "exit")
        {
          LogMessage("서버 종료");
        }
      }
      catch(Exception ex)
      {
        LogMessage($"오류: {ex.Message}");
      }
    }

    private void HandleMessageReceived(string message)
    {
      if (this.InvokeRequired)
      {
        this.Invoke(new Action<string>(HandleMessageReceived), message);
        return;
      }

      try
      {
        LogMessage($"수신: {message}");
      }
      catch(Exception ex)
      {
        LogMessage($"오류: {ex.Message}");
      }
    }


    private void LogMessage(string message)
    {
      string date = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
      Console.WriteLine($"LogMessage: {message}");

      if (this.InvokeRequired)
      {
        this.Invoke(new Action<string>(LogMessage), message);
        return;
      }

      try
      {
        messages.Items.Add("[" + date + "] " + message);
      }
      catch(Exception ex)
      {
        LogMessage($"오류: {ex.Message}");
      }
    }
  }
}

 

 

2. ClientNetwork

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

namespace ClientNetwork
{
  public class ClientManager
  {
    public event Action<string> OnConnected;
    public event Action<string> OnDisconnected;
    public event Action<string> OnMessageReceived;
    
    protected Socket? socket;
    protected EndPoint? endPoint;

    public void Connect(int port)
    {
      try
      {
        // TCP 소켓 생성
        socket = new(
          addressFamily: AddressFamily.InterNetwork,
          socketType: SocketType.Stream,
          protocolType: ProtocolType.Tcp
        );

        // 엔드포인트 설정
        endPoint = new IPEndPoint(ipAddr, port);
        
        if (socket == null || endPoint == null)
        {
          throw new InvalidOperationException("소켓 생성 실패");
        }

        socket.Connect(endPoint);

		// 소켓 연결 완료시
        if (socket.Connected)
        {
            Console.WriteLine("서버에 연결되었습니다.");
            OnConnected?.Invoke(socket.RemoteEndPoint?.ToString() ?? "알 수 없는 서버");
            Task.Run(() => ReceiveMessages(socket));
        }
        else
        {
            throw new SocketException();
        }
      }
      catch (Exception ex)
      {
        Console.WriteLine($"서버 연결 오류: {ex.Message}");
        OnDisconnected?.Invoke("error");
      }
    }

	public void Disconnect()
    {
      socket?.Shutdown(SocketShutdown.Both);
      socket?.Close();
      socket = null;

      OnDisconnected?.Invoke("exit");
    }
    
    public async Task SendMessage(string message)
    {
      try
      {
        byte[] byteData = Encoding.UTF8.GetBytes(message);
        await socket!.SendAsync(new ArraySegment<byte>(byteData), SocketFlags.None);
      }
      catch (Exception ex)
      {
        Console.WriteLine($"메시지 전송 오류: {ex.Message}");
      }
    }

    private async Task ReceiveMessages(Socket serverSocket)
    {
      try
      {
        while(true)
        {
          byte[] buffer = new byte[1024];
          int received = await socket!.ReceiveAsync(new ArraySegment<byte>(buffer), SocketFlags.None);

          if (received == 0) 
          {
            OnDisconnected?.Invoke("error");
            break;
          }

          string message = Encoding.UTF8.GetString(buffer, 0, received);
          OnMessageReceived?.Invoke(message);
        }
        
        // 연결 해제 시
        if (serverSocket.Connected == false)
        {
          OnDisconnected?.Invoke("exit");
          Disconnect();
        }
      }
      catch (Exception ex)
      {
        Console.WriteLine($"메시지 수신 오류: {ex.Message}");
      }
    }
  }
}

 

 

3. ServerForm

using System.Windows.Forms;
using ServerNetwork;

namespace Server.Forms
{
    public partial class ServerForm : Form
    {
        private ServerManager manager;
        private ListBox connectedClients;
        private TextBox txtLog;
        private Button btnStart;

        public ServerForm()
        {
            InitializeComponent();
            this.Text = "채팅 서버";  // 폼 제목 설정
            this.Size = new Size(650, 600);  // 폼 크기 설정
            
            manager = new ServerManager();
            manager.OnConnected += HandleClientConnected;
            manager.OnMessageReceived += HandleMessageReceived;

            InitializeUI();
        }

        private void InitializeUI()
        {
            // 포트 입력 패널
            var portPanel = new Panel
            {
                Dock = DockStyle.Top,
                Height = 50,
                Padding = new Padding(10)
            };

            btnStart = new Button
            {
                Location = new Point(10, 15),
                Width = 100,
                Height = 30,
                Text = "서버 시작"
            };
            btnStart.Click += btnStartServer;

            portPanel.Controls.Add(btnStart);

            // 로그 & 클라이언트 목록 영역
            var mainPanel = new Panel
            {
                Dock = DockStyle.Fill
            };

            connectedClients = new ListBox
            {
                Dock = DockStyle.Left,
                Width = 200,
                Font = new Font("맑은 고딕", 10)
            };

            txtLog = new TextBox
            {
                Dock = DockStyle.Fill,
                Multiline = true,
                ScrollBars = ScrollBars.Vertical,
                ReadOnly = true,
                Font = new Font("맑은 고딕", 10),
                BackColor = Color.White
            };

            var lblClients = new Label
            {
                Text = "연결된 클라이언트",
                Dock = DockStyle.Top,
                Height = 30,
                Font = new Font("맑은 고딕", 10, FontStyle.Bold),
                TextAlign = ContentAlignment.MiddleCenter
            };

            var leftPanel = new Panel
            {
                Dock = DockStyle.Left,
                Width = 200
            };
            leftPanel.Controls.Add(connectedClients);
            leftPanel.Controls.Add(lblClients);

            mainPanel.Controls.Add(txtLog);
            mainPanel.Controls.Add(leftPanel);

            Controls.Add(mainPanel);
            Controls.Add(portPanel);

            this.Load += (s, e) => LogMessage("서버 시작 버튼을 눌러주세요.");
        }

        private void btnStartServer(object sender, EventArgs e)
        {
            btnStart.Enabled = false;

            manager.StartServer(8888);
            LogMessage($"서버가 포트 {8888}에서 시작되었습니다.");
        }

        private void HandleClientConnected(string clientInfo)
        {
            connectedClients.Items.Add(clientInfo);
            LogMessage($"클라이언트 연결됨: {clientInfo}");
        }

        private void HandleMessageReceived(string message)
        {
            Invoke((MethodInvoker)delegate {
                LogMessage(message);
            });
        }

        private void LogMessage(string message)
        {
            string date = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
            Console.WriteLine($"LogMessage: {message}");
            
            if (this.InvokeRequired)
            {
                this.Invoke(new Action<string>(LogMessage), message);
                return;
            }

            try
            {
                txtLog.AppendText("[" + date + "] " + message + "\r\n");
            }
            catch(Exception ex)
            {
                LogMessage($"오류: {ex.Message}");
            }
        }
    }
}

 

 

4. ServerNetwork

using System.Net;
using System.Net.Sockets;
using System.Text;
using Common.Network;

namespace ServerNetwork
{
  public class ServerManager : SocketBase
  {
    public event Action<string> OnConnected; // 연결 알림
    public event Action<string> OnMessageReceived; // 메시지 수신 알림
    
    protected Socket? socket;
    protected EndPoint? endPoint;
    
    public void StartServer(int port) {
      int port = 8888;
      string host = Dns.GetHostName();
      IPHostEntry ipHost = Dns.GetHostEntry(host);
      IPAddress ipAddr = IPAddress.Parse("127.0.0.1");

      try
      {
        // TCP 소켓 생성
        socket = new(
          addressFamily: AddressFamily.InterNetwork,
          socketType: SocketType.Stream,
          protocolType: ProtocolType.Tcp
        );

        // 엔드포인트 설정
        endPoint = new IPEndPoint(ipAddr, port);
       
       	if (socket == null || endPoint == null)
        {
          Console.WriteLine("소켓 생성 실패");
          return;
        }

        socket.Bind(endPoint!);
        socket.Listen(10);      
        Console.WriteLine($"서버가 {port} 포트에서 시작되었습니다.");

        Task.Run(() => ConnectClients(socket));
      }
      catch (Exception ex)
      {
        Console.WriteLine($"서버 시작 오류: {ex.Message}");
      }
    }

    private async Task ConnectClients(Socket serverSocket)
    {
      while(true)
      {
        try 
        {
          Socket clientSocket = await serverSocket.AcceptAsync();
          if (clientSocket.Connected)
          {
            var clientInfo = clientSocket.RemoteEndPoint?.ToString() ?? "알 수 없는 클라이언트";
            
            if (Application.OpenForms.Count > 0)
            {
                Application.OpenForms[0].BeginInvoke(new Action(() => {
                    OnConnected?.Invoke(clientInfo);
                }));
            }
            
            _ = Task.Run(() => ReceiveMessages(clientSocket));
          }
        }
        catch (Exception ex)
        {
          Console.WriteLine($"클라이언트 연결 오류: {ex.Message}");
        }
      }
    }

    private async Task ReceiveMessages(Socket clientSocket)
    {
      while(true)
      {
        byte[] buffer = new byte[1024];
        int received = await Task.Run(() => clientSocket.Receive(buffer)); // await Task.Run -> 비동기 처리

        if (received == 0) break; // 연결 종료

        string message = Encoding.UTF8.GetString(buffer, 0, received).TrimEnd('\0'); // 공백 제거
        string date = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); // 현재 시간
        Console.WriteLine($"수신: {message}");

        OnMessageReceived?.Invoke(message);

        // 에코
        try
        {
          await clientSocket!.SendAsync(new ArraySegment<byte>(buffer, 0, received), SocketFlags.None); // 클라이언트에게 비동기 메시지 전송
        }
        catch (Exception ex)
        {
          Console.WriteLine($"메시지 전송 오류: {ex.Message}");
        }
      }
    }
  }
}
728x90