UNITY/네트워크

[네트워크] TCP/IP - 비동기 방식의 연결 (1)TCP 비동기 서버

램플릿 2025. 6. 12. 22:30

 

TCP 비동기 서버

 

  TcpServerAsync.cs   

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


public class TcpServerASync : MonoBehaviour
{

    private TcpListener tcpListener;
    private Thread listenerThread;

    // Start is called before the first frame update
    void Start()
    {
        listenerThread = new Thread(ListenForIncomingRequests); //연결 수신처리를 위한 별도의 스레드 생성. 메인 스레드의 멈춤을 방지.
        listenerThread.IsBackground = true; // 메인 스레드가 종료되면 함께 종료
        listenerThread.Start();
    }

    void ListenForIncomingRequests()
    {
        try
        {
            tcpListener = new TcpListener(IPAddress.Parse("127.0.0.1"), 7077);  //지정된 IP와 포트에서 연결수신 대기
            tcpListener.Start();
            Debug.Log("Server is listening!");

            while (true)
            {
                TcpClient connectedTcpClient = tcpListener.AcceptTcpClient();   //새 클라이언트와 연결 수신될 때 까지 Blocking
                
                //각 클라이언트에 대해 별도의 HandleClientComm 메서드를 새 스레드에서 실행.
                Thread clientThread = new Thread(() => HandleClientComm(connectedTcpClient));
                clientThread.IsBackground = true;
                clientThread.Start();
            }

        }
        catch (SocketException e)   //예외 처리
        {
            Debug.Log("SocketException:" + e);
        }
        finally //리소스 정리
        {
            //서버 종료 시 리스너 중지
            if (tcpListener != null)
            {
                tcpListener.Stop();
            }
        }
    }

    void HandleClientComm(TcpClient client)
    {
        //using문을 사용하여 리소스를 관리.
        //스트림이 먼저 닫히고, TCP연결객체(클라이언트)를 그 후에 정리한다.
        
        // using() : using 선언, using declaration (C# 8.0 이상에서 가능)
        // 블록{}을 명시하지 않아도 변수가 속한 범위(scope)가 끝날 때 Dispose()를 자동으로 호출.
        using (client) //즉, client가 포함된 가장 가까운 중괄호 블록(HandleClientComm메서드)이 끝나는 시점에서 Dispose호출.
        using (NetworkStream stream = client.GetStream()) //위 using선언과는 다르게, 명시된 블록{} 끝에서 즉시 Dispose호출. 
        {
            byte[] buffer = new byte[1024];
            int bytesRead;

            try
            {
                while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0) //연결 종료가 아닐 때 루프
                {
                    string dataReceived = Encoding.UTF8.GetString(buffer, 0, bytesRead);
                    Debug.Log("Received:" + dataReceived);

                    string response = "Server received:" + dataReceived;
                    byte[] responseBytes = Encoding.UTF8.GetBytes(response);
                    stream.Write(responseBytes, 0, responseBytes.Length); //응답 메시지 전송

                    //종료조건
                    if (dataReceived.Trim().ToLower() == "exit")    //Trim(): 문자열 앞뒤의 개행문자, 공백, 탭 등 공백문자들을 모두 제거
                    {
                        break;  //"exit" 메시지를 받으면 루프를 종료하고 클라이언트와의 연결을 닫는다.
                    }
                }
            }
            catch (Exception e)
            {
                Debug.Log("Communication error:" + e);
            }
        }
    }

    void OnApplicationQuit()    //Unity 애플리케이션이 종료될 때 호출.
    {
        if (listenerThread != null && listenerThread.IsAlive)
        {
            listenerThread.Abort(); //리스너스레드 강제 종료
        }
    }
}

 

 

  동기 방식과의 차이점   

  • 연결이 수신된 각 클라이언트에 대해 새로운 스레드에서 별도로 처리함으로서 비동기적으로 처리한다.
  • 메인 스레드가 아닌 별도의 스레드에서 병렬적으로 처리하므로, Blocking과 UI Freezing을 방지할 수 있다.

 

  클라이언트마다 새로운 Thread를 생성 (한계有)   

Thread clientThread = new Thread(() => HandleClientComm(connectedTcpClient));
clientThread.IsBackground = true;
clientThread.Start();

이 방식은 간단하고 직관적이지만, 확장성(scalability) 측면에서는 한계가 있다.


✅ 요점부터: 수용 가능한 클라이언트 수는?

현실적으로 50~200개 사이가 한계입니다.
그 이상은 서버 CPU, 메모리, 운영체제 스레드 수 제한 등에서 병목이 발생합니다.


⚠️ 왜 스레드당 1클라이언트 방식은 문제가 되는가?

❗ 스레드 자원 낭비 각 스레드는 1MB 내외의 스택 메모리를 차지하고, 컨텍스트 스위칭 비용이 큼
❗ OS 스레드 수 제한 Windows 기준 수천 개 이상은 비현실적 (예: 5000명 동시에 접속 불가)
❗ 병렬성 오히려 저하 너무 많은 스레드가 CPU 코어를 계속 전환하면서 오히려 느려짐
❗ 안정성 저하 GC, 메모리 사용량 급증으로 서버가 불안정해질 수 있음

🧠 대안: 효율적인 클라이언트 처리 방식

 

✅ ThreadPool (기본적인 개선)

ThreadPool.QueueUserWorkItem(_ => HandleClientComm(connectedTcpClient));
  • 스레드 재사용 가능 → 성능 개선
  • 하지만 여전히 비동기 소켓보다는 느림

✅ async/await + NetworkStream.ReadAsync/WriteAsync (가장 권장)

TcpClient client = await tcpListener.AcceptTcpClientAsync();
_ = HandleClientAsync(client); // fire-and-forget

async Task HandleClientAsync(TcpClient client)
{
    using var stream = client.GetStream();
    byte[] buffer = new byte[1024];

    int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
    // 처리 후 응답
}
  • 완전한 비동기 방식
  • 수천 개의 클라이언트까지 확장 가능 (현대 서버에서 5000~10000도 가능)
  • 스레드 수는 적고, 메모리 사용량도 효율적

💡 결론: 지금 방식의 한계와 추천 수

현재 코드 기준 최대 수용 클라이언트 50~200명 수준 (일반 PC 기준)
실제 안정 운영 클라이언트 수 50명 이하 권장
확장하고 싶다면 ThreadPool 또는 async/await 방식으로 전환 추천

 

 

 

 

✅ 개선된 async TcpListener 서버 코드

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

public class AsyncTcpServer : MonoBehaviour
{
	public int port = 7077;
    private TcpListener tcpListener;
    private bool isRunning = false;

    private async void Start()
    {
        tcpListener = new TcpListener(IPAddress.Parse("127.0.0.1"), port);
        tcpListener.Start();
        isRunning = true;

        Debug.Log("Async TCP Server started...");

        // 비동기 클라이언트 수신 루프 시작
        await AcceptClientsAsync();
    }

    private async Task AcceptClientsAsync()
    {
        while (isRunning)
        {
            try
            {
                TcpClient client = await tcpListener.AcceptTcpClientAsync(); // 비동기 클라이언트 수신
                Debug.Log("Client connected!");
                _ = HandleClientAsync(client); // 클라이언트 처리 비동기 실행
            }
            catch (Exception ex)
            {
                Debug.LogError("Error accepting client: " + ex.Message);
                break;
            }
        }
    }

    private async Task HandleClientAsync(TcpClient client)
    {
        NetworkStream stream = client.GetStream();

        byte[] buffer = new byte[1024];

        try
        {
            while (client.Connected)
            {
                int byteCount = await stream.ReadAsync(buffer, 0, buffer.Length);
                if (byteCount == 0)
                    break; // 클라이언트 종료

                string received = Encoding.UTF8.GetString(buffer, 0, byteCount);
                Debug.Log("Received: " + received);

                // 클라이언트에게 에코 응답
                byte[] response = Encoding.UTF8.GetBytes("Echo: " + received);
                await stream.WriteAsync(response, 0, response.Length);
            }
        }
        catch (Exception ex)
        {
            Debug.LogError("Client handling error: " + ex.Message);
        }
        finally
        {
            Debug.Log("Client disconnected.");
            stream.Close();
            client.Close();
        }
    }

    private void OnApplicationQuit()
    {
        isRunning = false;
        tcpListener.Stop();
        Debug.Log("Server stopped.");
    }
}

🔍 주요 차이점 요약

항목 기존 구조 개선 구조

클라이언트 수락 AcceptTcpClient() + Thread 생성 AcceptTcpClientAsync() + async 메서드
데이터 처리 Thread 내부에서 블로킹 처리 await stream.ReadAsync()
스레드 수 클라이언트 수만큼 생성됨 필요 최소한만 유지 (비동기)
확장성 수십 명 수백~수천 명 가능

 

 

  무한 루프while(true)와 await의 조합   

  • while(true)안에서 await listener.AcceptTcpClientAsync()를 호출하는 것은, 연결을 받고 처리한 후에 다시 새로운 연결을 기다리도록 하는 일반적인 비동기 서버 패턴이다.
  • await가 없으면 루프가 매우 빠르게 반복되어 CPU를 과도하게 사용하고, AcceptTcpClientAsync()가 제대로 동작하지 않을 수도 있다.
  • await는 새로운 클라이언트가 접속할 때까지 대기하고, UI 스레드를 블록하지 않으면서 다른 이벤트(UI, 렌더링 등)가 처리될 수 있게 한다.