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, 렌더링 등)가 처리될 수 있게 한다.