새로운 주의 시작을 환영합니다. 지난 경매장 챌린지를 통해 동시성 상태 관리에 대한 이해가 깊어지셨을 겁니다. 이번 주에는 여기서 한 걸음 더 나아가, 대규모 MMO 서버의 핵심 난제인 '개별 엔티티(Entity)의 상태를 격리하고 안전하게 관리하는 방법'에 대해 다뤄보겠습니다.
안녕하세요! 멘토입니다. 이번 주 챌린지의 주제는 **'액터 모델(Actor Model)의 원리를 이용한 플레이어 상태 관리 시스템'**입니다. 수만 명의 플레이어가 각자의 상태를 가지고 동시에 활동할 때, 어떻게 잠금(Lock) 경합 없이 안정성과 고성능을 모두 확보할 수 있는지에 대한 해답을 찾아가는 과정이 될 것입니다.
수만 명 동시 접속 MMO 서버, 잦은 데드락과 락 경합으로 고민이신가요? C# 액터 모델과 System.Threading.Channels를 활용해 잠금 없이 안전하고 확장 가능한 플레이어 상태 관리 시스템을 구축하는 실전 방법을 알아보세요.
상황 시나리오:
'아르카디아의 그림자'의 동시 접속자 수가 급증하면서 새로운 문제가 발생했습니다. 여러 플레이어의 요청(아이템 사용, 스킬 시전, 퀘스트 수락 등)이 동시에 한 플레이어에게 집중될 때, 또는 플레이어가 거의 동시에 여러 행동을 시도할 때(예: 물약 사용과 동시에 귀환 주문서 사용) 데이터가 꼬이는 버그가 빈번하게 발생하고 있습니다. 기존의 락 기반 동기화 방식은 전체 시스템에 병목을 일으키고 있으며, 복잡한 잠금 순서는 데드락(Deadlock)의 위험까지 내포하고 있습니다.
당신의 임무는 각 플레이어를 '에이전트'라는 독립된 단위로 추상화하여, 플레이어별로 모든 요청을 순차적으로 처리하는 새로운 상태 관리 시스템을 설계하는 것입니다. 이 시스템은 특정 플레이어의 부하가 다른 플레이어에게 영향을 주지 않도록 격리되어야 합니다.
이번 솔루션은 액터 모델의 핵심인 '메일박스(Mailbox)'와 '순차적 메시지 처리' 개념을 System.Threading.Channels
를 사용하여 현대적으로 구현합니다.
using System.Collections.Concurrent;
using System.Threading.Channels;
// 플레이어에게 전달될 액션(요청)을 표현하는 기본 인터페이스입니다.
public interface IPlayerAction { }
// 구체적인 액션 예시 1: 플레이어 위치 이동
public record MoveAction(float X, float Y) : IPlayerAction;
// 구체적인 액션 예시 2: 아이템 사용
public record UseItemAction(int ItemId) : IPlayerAction;
/// <summary>
/// 개별 플레이어의 상태와 행동을 캡슐화하는 에이전트 클래스입니다.
/// 각 PlayerAgent는 자신만의 메일박스(Channel)와 처리 루프(Task)를 가집니다.
/// </summary>
public sealed class PlayerAgent : IAsyncDisposable
{
// 플레이어의 현재 상태 정보입니다. 이 데이터는 오직 전용 처리 루프에서만 접근하므로 lock이 필요 없습니다.
public ulong PlayerId { get; }
public (float X, float Y) Position { get; private set; }
public int Health { get; private set; } = 100;
// 이 에이전트의 메일박스 역할을 하는 Channel입니다. 모든 요청은 이 채널에 기록됩니다.
// UnboundedChannel은 크기 제한이 없어 요청이 쌓여도 막히지 않지만, 메모리 사용량에 주의해야 합니다.
private readonly Channel<IPlayerAction> _mailbox = Channel.CreateUnbounded<IPlayerAction>();
private readonly Task _processingLoop;
private readonly CancellationTokenSource _cts = new();
public PlayerAgent(ulong playerId)
{
PlayerId = playerId;
// 에이전트가 생성될 때, 메일박스를 처리하는 전용 백그라운드 작업을 시작합니다.
_processingLoop = RunProcessingLoopAsync();
}
/// <summary>
/// 이 에이전트에게 처리할 액션을 비동기적으로 전달(Post)합니다.
/// 이 메서드는 여러 스레드에서 동시에 호출될 수 있으며, 스레드로부터 안전합니다.
/// </summary>
public async ValueTask PostActionAsync(IPlayerAction action)
{
// Channel.Writer는 스레드 안전하며, 논블로킹 방식으로 빠르게 액션을 메일박스에 넣습니다.
await _mailbox.Writer.WriteAsync(action, _cts.Token);
}
/// <summary>
/// 메일박스로부터 액션을 하나씩 꺼내 순차적으로 처리하는 메인 루프입니다.
/// </summary>
private async Task RunProcessingLoopAsync()
{
try
{
// _mailbox.Reader.ReadAllAsync는 채널이 닫힐 때까지 새로운 메시지를 비동기적으로 기다립니다.
await foreach (var action in _mailbox.Reader.ReadAllAsync(_cts.Token))
{
// 메일박스에서 꺼낸 액션을 순서대로 처리합니다.
ProcessAction(action);
}
}
catch (OperationCanceledException) { /* 정상 종료 */ }
catch (ChannelClosedException) { /* 정상 종료 */ }
}
/// <summary>
/// 액션의 종류에 따라 실제 로직을 수행합니다.
/// 이 메서드는 단일 스레드(전용 처리 루프)에 의해서만 호출되므로, 상태 변수(Position, Health) 접근 시 lock이 필요 없습니다.
/// </summary>
private void ProcessAction(IPlayerAction action)
{
switch (action)
{
case MoveAction move:
Position = (move.X, move.Y);
Console.WriteLine($"Player {PlayerId}: Moved to ({Position.X}, {Position.Y})");
break;
case UseItemAction useItem:
// 아이템 사용 로직 (예: 체력 회복)
Health = Math.Min(100, Health + 20); // 체력을 20 회복 (최대 100)
Console.WriteLine($"Player {PlayerId}: Used item {useItem.ItemId}. Health is now {Health}.");
break;
}
}
/// <summary>
/// 에이전트를 안전하게 종료하고 리소스를 해제합니다.
/// </summary>
public async ValueTask DisposeAsync()
{
// 1. 새로운 요청을 더 이상 받지 않도록 메일박스를 닫습니다.
_mailbox.Writer.Complete();
// 2. 백그라운드 처리 루프에 취소 신호를 보냅니다.
_cts.Cancel();
// 3. 처리 루프가 메일박스에 남은 모든 메시지를 처리하고 종료될 때까지 기다립니다.
await _processingLoop;
_cts.Dispose();
}
}
/// <summary>
/// 모든 PlayerAgent 인스턴스를 관리하는 중앙 관리자 클래스입니다.
/// </summary>
public sealed class PlayerManager
{
private readonly ConcurrentDictionary<ulong, PlayerAgent> _agents = new();
public PlayerAgent GetOrCreateAgent(ulong playerId)
{
// 플레이어 ID에 해당하는 에이전트가 없으면 새로 생성하고, 있으면 기존 인스턴스를 반환합니다.
// 이 로직은 스레드로부터 안전합니다.
return _agents.GetOrAdd(playerId, id => new PlayerAgent(id));
}
public async Task ShutdownPlayerAsync(ulong playerId)
{
// 플레이어 로그아웃 시 호출되며, 해당 에이전트를 안전하게 종료하고 목록에서 제거합니다.
if (_agents.TryRemove(playerId, out var agent))
{
await agent.DisposeAsync();
}
}
}
이 PlayerManager
와 PlayerAgent
시스템은 MMO 서버의 핵심 로직을 구성합니다.
PlayerManager
를 통해 해당 플레이어의 PlayerAgent
를 얻습니다. 그리고는 agent.PostActionAsync(new MoveAction(x, y))
와 같이 단순히 액션을 '던져놓기만' 하고 즉시 다른 요청을 처리하러 갑니다. 네트워크 스레드는 플레이어의 복잡한 상태 처리 로직을 기다리느라 블로킹되지 않습니다.UseItemAction
2개가 메일박스에 차례대로 쌓이고, 하나씩 순서대로 처리되므로 항상 정확한 결과가 보장됩니다.이 패턴은 플레이어뿐만 아니라, 독립적인 상태와 행동을 가진 모든 게임 엔티티(NPC, 몬스터, 움직이는 발판 등)에 동일하게 적용하여 서버 전체의 구조를 단순하고 예측 가능하게 만들 수 있습니다.
System.Threading.Channels
활용: 고성능 비동기 생산자-소비자 큐를 구현하는 현대적인 방법을 학습합니다.lock
사용을 최소화하고, 아키텍처적으로 데이터 경쟁을 회피하는 방법을 익힙니다.IAsyncDisposable
을 구현하여, 백그라운드 Task
와 Channel
같은 복잡한 비동기 리소스를 누수 없이 안전하게 종료하는 패턴을 배웁니다.Channel
과 Task
사용이 효율적인가?Task
, Channel
, CancellationTokenSource
)가 완벽하게 정리되는가?PlayerManager
와 PlayerAgent
의 책임이 명확하게 분리되어 있고, 새로운 IPlayerAction
을 추가하기 쉬운 구조인가?핵심 시스템을 완벽하게 구축했다면, 액터 모델의 또 다른 중요 기능인 '에이전트 간 통신'을 구현해 보세요.
과제: '플레이어 간 거래 요청' 기능을 추가해 보세요.
요구사항:
TradeRequestAction
을 만듭니다. 이 액션에는 요청자(A)의 ID와 응답을 받을 방법이 포함되어야 합니다.TradeRequestAction
을 B의 에이전트 메일박스로 보내야 합니다. 이를 위해 PlayerManager
에 다른 에이전트를 찾는 기능이 필요할 수 있습니다.TradeRequestAction
을 처리하고, "수락" 또는 "거절" 응답을 결정해야 합니다.TaskCompletionSource<bool>
같은 객체를 TradeRequestAction
에 포함시켜 전달하고, B가 이를 완료시키는 패턴을 사용하면 효과적일 수 있습니다.이번 챌린지는 대규모 동시성 프로그래밍의 패러다임을 한 단계 끌어올리는 계기가 될 것입니다. 당신만의 에이전트 시스템을 만들어 보세요!
채널 - .NET
.NET을 사용하는 생산자와 소비자를 위한 System.Threading.Channels의 공식 동기화 데이터 구조에 대해 알아봅니다.
learn.microsoft.com
DisposeAsync 메서드 구현 - .NET
DisposeAsync 및 DisposeAsyncCore 메서드를 구현하여 비동기 리소스 정리를 수행하는 방법을 알아봅니다.
learn.microsoft.com
2.주간 C# 서버 챌린지: 스레드 안전한 경매장 구현 (0) | 2025.09.08 |
---|---|
1.주간 C# 서버 챌린지: 스레드 안전(Thread-Safe) 아이템 루팅 시스템 구현 (0) | 2025.09.06 |