3.주간 C# 서버 챌린지: '락(Lock)' 없는 플레이어 상태 관리
새로운 주의 시작을 환영합니다. 지난 경매장 챌린지를 통해 동시성 상태 관리에 대한 이해가 깊어지셨을 겁니다. 이번 주에는 여기서 한 걸음 더 나아가, 대규모 MMO 서버의 핵심 난제인 '개별 엔티티(Entity)의 상태를 격리하고 안전하게 관리하는 방법'에 대해 다뤄보겠습니다.
주간 C# 서버 챌린지: 액터 모델로 구현하는 MMO 플레이어 상태 관리 시스템
안녕하세요! 멘토입니다. 이번 주 챌린지의 주제는 **'액터 모델(Actor Model)의 원리를 이용한 플레이어 상태 관리 시스템'**입니다. 수만 명의 플레이어가 각자의 상태를 가지고 동시에 활동할 때, 어떻게 잠금(Lock) 경합 없이 안정성과 고성능을 모두 확보할 수 있는지에 대한 해답을 찾아가는 과정이 될 것입니다.
수만 명 동시 접속 MMO 서버, 잦은 데드락과 락 경합으로 고민이신가요? C# 액터 모델과 System.Threading.Channels를 활용해 잠금 없이 안전하고 확장 가능한 플레이어 상태 관리 시스템을 구축하는 실전 방법을 알아보세요.
문제: 격리된 플레이어 에이전트(Agent) 시스템 구축
상황 시나리오:
'아르카디아의 그림자'의 동시 접속자 수가 급증하면서 새로운 문제가 발생했습니다. 여러 플레이어의 요청(아이템 사용, 스킬 시전, 퀘스트 수락 등)이 동시에 한 플레이어에게 집중될 때, 또는 플레이어가 거의 동시에 여러 행동을 시도할 때(예: 물약 사용과 동시에 귀환 주문서 사용) 데이터가 꼬이는 버그가 빈번하게 발생하고 있습니다. 기존의 락 기반 동기화 방식은 전체 시스템에 병목을 일으키고 있으며, 복잡한 잠금 순서는 데드락(Deadlock)의 위험까지 내포하고 있습니다.
당신의 임무는 각 플레이어를 '에이전트'라는 독립된 단위로 추상화하여, 플레이어별로 모든 요청을 순차적으로 처리하는 새로운 상태 관리 시스템을 설계하는 것입니다. 이 시스템은 특정 플레이어의 부하가 다른 플레이어에게 영향을 주지 않도록 격리되어야 합니다.
샘플 솔루션 (C# 12 / .NET 8)
이번 솔루션은 액터 모델의 핵심인 '메일박스(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();
}
}
}
Lock-free 설계의 장점과 실용적 사용 시나리오
이 PlayerManager
와 PlayerAgent
시스템은 MMO 서버의 핵심 로직을 구성합니다.
- 네트워크 요청 처리: 플레이어 클라이언트로부터 "이동", "아이템 사용" 등의 패킷이 도착하면, 네트워크 스레드는
PlayerManager
를 통해 해당 플레이어의PlayerAgent
를 얻습니다. 그리고는agent.PostActionAsync(new MoveAction(x, y))
와 같이 단순히 액션을 '던져놓기만' 하고 즉시 다른 요청을 처리하러 갑니다. 네트워크 스레드는 플레이어의 복잡한 상태 처리 로직을 기다리느라 블로킹되지 않습니다. - 버그 예방: 만약 플레이어가 0.1초 간격으로 체력 물약을 2개 사용하는 요청을 보냈다고 가정해 봅시다. 기존 락 방식에서는 두 요청이 동시에 체력 값을 읽고 쓰는 과정에서 레이스 컨디션이 발생하여 물약이 1개만 사용된 것처럼 처리될 수 있습니다. 하지만 이 에이전트 모델에서는
UseItemAction
2개가 메일박스에 차례대로 쌓이고, 하나씩 순서대로 처리되므로 항상 정확한 결과가 보장됩니다.
이 패턴은 플레이어뿐만 아니라, 독립적인 상태와 행동을 가진 모든 게임 엔티티(NPC, 몬스터, 움직이는 발판 등)에 동일하게 적용하여 서버 전체의 구조를 단순하고 예측 가능하게 만들 수 있습니다.
학습 목표
- 액터 모델의 핵심 원리 이해: 각 액터(에이전트)가 자신의 상태와 행동, 메일박스를 가지며 서로 격리된다는 개념을 체득합니다.
System.Threading.Channels
활용: 고성능 비동기 생산자-소비자 큐를 구현하는 현대적인 방법을 학습합니다.- 잠금 회피(Lock-free) 설계: 명시적인
lock
사용을 최소화하고, 아키텍처적으로 데이터 경쟁을 회피하는 방법을 익힙니다. - 격리된 동시성: 특정 엔티티의 작업이 다른 엔티티의 성능에 영향을 주지 않는 확장 가능한 시스템을 설계하는 방법을 이해합니다.
- 안전한 비동기 리소스 해제:
IAsyncDisposable
을 구현하여, 백그라운드Task
와Channel
같은 복잡한 비동기 리소스를 누수 없이 안전하게 종료하는 패턴을 배웁니다.
이번 챌린지의 핵심 학습 목표 5가지
- 격리성: 한 플레이어 에이전트의 작업 지연이나 오류가 다른 에이전트의 처리에 영향을 주지 않는가?
- 정확성: 모든 액션이 해당 플레이어에게 순서대로, 빠짐없이 처리되는가? 데이터의 정합성이 보장되는가?
- 성능 및 확장성: 수천, 수만 개의 에이전트가 동시에 활성화된 상태에서도 시스템이 안정적으로 동작하는가?
Channel
과Task
사용이 효율적인가? - 자원 관리: 플레이어가 로그아웃할 때 관련 리소스(
Task
,Channel
,CancellationTokenSource
)가 완벽하게 정리되는가? - 설계의 명확성:
PlayerManager
와PlayerAgent
의 책임이 명확하게 분리되어 있고, 새로운IPlayerAction
을 추가하기 쉬운 구조인가?
보너스 목표
핵심 시스템을 완벽하게 구축했다면, 액터 모델의 또 다른 중요 기능인 '에이전트 간 통신'을 구현해 보세요.
과제: '플레이어 간 거래 요청' 기능을 추가해 보세요.
요구사항:
- 플레이어 A가 플레이어 B에게 거래를 요청하는
TradeRequestAction
을 만듭니다. 이 액션에는 요청자(A)의 ID와 응답을 받을 방법이 포함되어야 합니다. - A의 에이전트는 이
TradeRequestAction
을 B의 에이전트 메일박스로 보내야 합니다. 이를 위해PlayerManager
에 다른 에이전트를 찾는 기능이 필요할 수 있습니다. - B의 에이전트는 자신의 메일박스에서
TradeRequestAction
을 처리하고, "수락" 또는 "거절" 응답을 결정해야 합니다. - B의 응답은 다시 A의 에이전트 메일박스로 전달되어, A가 거래 결과를 알 수 있도록 해야 합니다.
- 힌트: 응답을 전달하기 위해
TaskCompletionSource<bool>
같은 객체를TradeRequestAction
에 포함시켜 전달하고, B가 이를 완료시키는 패턴을 사용하면 효과적일 수 있습니다.
이번 챌린지는 대규모 동시성 프로그래밍의 패러다임을 한 단계 끌어올리는 계기가 될 것입니다. 당신만의 에이전트 시스템을 만들어 보세요!
채널 - .NET
.NET을 사용하는 생산자와 소비자를 위한 System.Threading.Channels의 공식 동기화 데이터 구조에 대해 알아봅니다.
learn.microsoft.com
DisposeAsync 메서드 구현 - .NET
DisposeAsync 및 DisposeAsyncCore 메서드를 구현하여 비동기 리소스 정리를 수행하는 방법을 알아봅니다.
learn.microsoft.com