데이터베이스 연동까지 성공적으로 마치셨으니, 이제는 서버의 '입구'를 만드는 작업에 도전할 시간입니다. 지금까지의 모든 로직은 결국 외부 클라이언트의 요청을 받아 처리하기 위한 것이었죠.
이번 주에는 C#의 저수준(Low-level) 네트워킹 API를 사용하여 수천 개의 동시 접속을 처리할 수 있는 고성능 TCP 소켓 서버의 뼈대를 직접 만들어 보겠습니다.
안녕하세요! 스튜디오 비를긋다 입니다. 이번 챌린지의 주제는 '고성능 비동기 TCP 소켓 서버 구축' 입니다. .NET이 제공하는 가장 강력하고 효율적인 소켓 프로그래밍 모델인 SocketAsyncEventArgs
(SAEA) 패턴을 사용하여, MMORPG 서버의 기본이 되는 네트워크 리스너를 구현합니다. 이는 서버 개발의 가장 근본적인 기술 중 하나입니다.
C#으로 수천 개의 동시 접속을 처리하는 고성능 비동기 TCP 서버를 구축하는 방법을 배우세요. SocketAsyncEventArgs(SAEA) 패턴, 객체 풀링, 세션 관리까지, MMORPG 서버 개발의 핵심 기술을 예제 코드와 함께 완벽히 마스터할 수 있습니다.
상황 시나리오:
'아르카디아의 그림자'의 아키텍처를 여러 전문 서버(월드 서버, 채팅 서버, 경매장 서버 등)로 분리하는 대규모 프로젝트가 시작되었습니다. 이 분산 환경의 기반이 될 공통 네트워크 엔진이 필요하게 되었습니다. 당신의 임무는 새로운 서버들이 상속하거나 사용할 수 있는, 수천 개의 클라이언트 연결을 동시에 효율적으로 관리하고 최소한의 메모리 할당으로 데이터를 주고받는 재사용 가능한 TCP 서버 모듈을 만드는 것입니다.
핵심 요구사항:
SocketAsyncEventArgs
패턴을 사용하여 성능을 극대화해야 합니다. 이 패턴은 각 I/O 작업에 대한 메모리 할당을 최소화하여 가비지 컬렉션(GC)으로 인한 서버 지연(stutter)을 방지합니다.SocketAsyncEventArgs
객체는 비싸므로, 재사용을 위해 객체 풀을 구현해야 합니다.Session
객체로 캡슐화되어야 하며, 서버는 모든 활성 세션을 추적하고 관리해야 합니다. 클라이언트 연결이 끊어지면 관련 리소스가 즉시 정리되어야 합니다.이 솔루션은 C# 소켓 프로그래밍의 '끝판왕'이라 불리는 SAEA 패턴의 정석적인 구현을 보여줍니다. 복잡하지만 최고의 성능을 보장합니다.
using System.Net;
using System.Net.Sockets;
using System.Collections.Concurrent;
// 각 클라이언트 연결을 나타내는 세션 클래스입니다.
public class ClientSession
{
public Socket Socket { get; }
private readonly Action<ClientSession> _onDisconnected;
public ClientSession(Socket socket, Action<ClientSession> onDisconnected)
{
Socket = socket;
_onDisconnected = onDisconnected;
}
// 데이터 수신을 시작하는 메서드. SAEA 객체를 풀에서 빌려와 비동기 수신을 시작합니다.
public void StartReceive(SocketAsyncEventArgs receiveArgs)
{
receiveArgs.Completed += OnReceiveCompleted; // IO 완료 시 호출될 콜백 등록
if (!Socket.ReceiveAsync(receiveArgs))
{
// 동기적으로 완료된 경우, 즉시 콜백을 호출합니다.
OnReceiveCompleted(this, receiveArgs);
}
}
// 비동기 데이터 수신이 완료되었을 때 호출되는 콜백 메서드입니다.
private void OnReceiveCompleted(object? sender, SocketAsyncEventArgs e)
{
// BytesTransferred: 수신된 데이터의 바이트 수, SocketError: 소켓 오류 상태
if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)
{
// TODO: 수신된 데이터 처리 로직 (e.Buffer에서 e.Offset부터 e.BytesTransferred 만큼)
// 예: Console.WriteLine($"Received {e.BytesTransferred} bytes from {Socket.RemoteEndPoint}");
// 처리가 끝난 후, 다음 데이터 수신을 위해 다시 ReceiveAsync를 호출합니다.
if (!Socket.ReceiveAsync(e))
{
OnReceiveCompleted(this, e);
}
}
else
{
// 수신된 바이트가 0이거나 오류가 발생하면 연결이 끊어진 것으로 간주합니다.
Disconnect();
}
}
public void Disconnect()
{
_onDisconnected(this); // 서버에 연결 끊김을 알립니다.
Socket.Shutdown(SocketShutdown.Both);
Socket.Close();
}
}
// 고성능 TCP 서버의 메인 클래스입니다.
public class GameServer
{
private readonly Socket _listenSocket;
private readonly ConcurrentStack<SocketAsyncEventArgs> _receiveArgsPool;
private readonly ConcurrentDictionary<int, ClientSession> _sessions = new();
private int _sessionIdCounter = 0;
public GameServer(int port, int backlog, int poolSize)
{
_listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_listenSocket.Bind(new IPEndPoint(IPAddress.Any, port));
_listenSocket.Listen(backlog);
// SAEA 객체를 미리 생성하여 풀에 넣어둡니다.
_receiveArgsPool = new ConcurrentStack<SocketAsyncEventArgs>();
for (int i = 0; i < poolSize; i++)
{
var args = new SocketAsyncEventArgs();
// 각 SAEA 객체에 1KB 버퍼를 할당합니다.
args.SetBuffer(new byte[1024], 0, 1024);
_receiveArgsPool.Push(args);
}
}
public void Start()
{
Console.WriteLine("Server started. Waiting for connections...");
// 클라이언트 접속을 받기 위한 별도의 SAEA 객체와 비동기 루프를 시작합니다.
var acceptArgs = new SocketAsyncEventArgs();
acceptArgs.Completed += OnAcceptCompleted;
StartAccept(acceptArgs);
}
private void StartAccept(SocketAsyncEventArgs acceptArgs)
{
acceptArgs.AcceptSocket = null; // 재사용을 위해 이전 소켓을 비웁니다.
if (!_listenSocket.AcceptAsync(acceptArgs))
{
// 동기적으로 완료되면 즉시 콜백을 호출합니다.
OnAcceptCompleted(this, acceptArgs);
}
}
private void OnAcceptCompleted(object? sender, SocketAsyncEventArgs e)
{
if (e.SocketError == SocketError.Success)
{
// 클라이언트 접속 성공
var clientSocket = e.AcceptSocket!;
Console.WriteLine($"Client connected: {clientSocket.RemoteEndPoint}");
// 새로운 세션을 생성하고, 수신 풀에서 SAEA 객체를 하나 빌려옵니다.
var session = new ClientSession(clientSocket, OnSessionDisconnected);
if (_receiveArgsPool.TryPop(out var receiveArgs))
{
// UserToken을 사용하여 SAEA 객체와 세션을 연결합니다.
receiveArgs.UserToken = session;
session.StartReceive(receiveArgs);
}
else
{
// 풀이 비어있으면 연결을 거부합니다 (서버 과부하 상태).
Console.WriteLine("Connection refused: SAEA pool is empty.");
clientSocket.Close();
}
}
else
{
// 접속 오류 처리
Console.WriteLine($"Accept error: {e.SocketError}");
}
// 다음 클라이언트 접속을 받기 위해 다시 AcceptAsync를 호출합니다.
StartAccept(e);
}
// 세션 연결이 끊겼을 때 호출되는 콜백 메서드입니다.
private void OnSessionDisconnected(ClientSession session)
{
Console.WriteLine($"Client disconnected: {session.Socket.RemoteEndPoint}");
// 세션이 사용하던 SAEA 객체를 다시 풀에 반환해야 합니다.
// (이 예제에서는 간단하게 하기 위해 생략. 실제로는 receiveArgs를 찾아 반납해야 함)
}
}
이 GameServer
클래스는 모든 TCP 기반 서버의 출발점입니다.
SocketAsyncEventArgs
패턴의 핵심은 메모리 재사용입니다. new byte[1024]
와 new SocketAsyncEventArgs()
같은 객체 생성을 서버 시작 시점에 '한 번만' 대량으로 해놓고, 이후 모든 네트워크 작업에서 이 객체들을 '빌려 쓰고 반납'하는 방식입니다. 이를 통해 서버 실행 중 GC의 부담을 극적으로 줄여, 게임 서버에서 치명적인 '순간 렉' 현상을 예방할 수 있습니다.
System.Net.Sockets
네임스페이스의 핵심 클래스들을 직접 다루는 방법을 학습합니다.Completed
이벤트 핸들러를 사용하여 비동기 작업의 완료를 처리하는 프로그래밍 모델에 익숙해집니다.Socket
과 SocketAsyncEventArgs
객체가 누수 없이 정확하게 정리되고 풀에 반환되는가?현재 샘플 코드는 단순히 바이트 스트림을 받을 뿐, 의미 있는 단위의 '메시지'나 '패킷'을 처리하지 못합니다. 실제 게임 서버가 되기 위한 다음 단계를 구현해 보세요.
과제: 패킷 기반 메시지 프레이밍(Framing) 계층 구현
요구사항:
[전체 패킷 길이(2바이트 정수)] + [패킷 ID(2바이트 정수)] + [실제 데이터(가변 길이)]
ClientSession
의 데이터 수신 로직을 수정하여, TCP 스트림에서 이 패킷 구조에 따라 완전한 메시지를 조립(Assembly)하는 기능을 추가합니다.
Receive
로 여러 패킷이 도착하는 경우, 패킷 일부만 도착하는 경우 모두 처리할 수 있어야 합니다. 이를 위해 각 세션은 자신만의 수신 버퍼를 가지고 있어야 합니다.이 보너스 과제는 저수준 TCP 통신과 실제 게임 로직을 연결하는 '브릿지' 역할을 하는 매우 중요한 부분입니다.
이번 주는 다소 난이도가 높지만, 이 챌린지를 성공적으로 완수한다면 어떤 고성능 서버도 만들 수 있는 단단한 기초를 갖추게 될 것입니다!
SocketAsyncEventArgs 클래스 (System.Net.Sockets)
비동기 소켓 작업을 나타냅니다.
learn.microsoft.com