개발/C# 서버 챌린지

3.주간 C# 서버 챌린지: '락(Lock)' 없는 플레이어 상태 관리

Jiung. 2025. 9. 17. 00:16

새로운 주의 시작을 환영합니다. 지난 경매장 챌린지를 통해 동시성 상태 관리에 대한 이해가 깊어지셨을 겁니다. 이번 주에는 여기서 한 걸음 더 나아가, 대규모 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 설계의 장점과 실용적 사용 시나리오

PlayerManagerPlayerAgent 시스템은 MMO 서버의 핵심 로직을 구성합니다.

  • 네트워크 요청 처리: 플레이어 클라이언트로부터 "이동", "아이템 사용" 등의 패킷이 도착하면, 네트워크 스레드는 PlayerManager를 통해 해당 플레이어의 PlayerAgent를 얻습니다. 그리고는 agent.PostActionAsync(new MoveAction(x, y)) 와 같이 단순히 액션을 '던져놓기만' 하고 즉시 다른 요청을 처리하러 갑니다. 네트워크 스레드는 플레이어의 복잡한 상태 처리 로직을 기다리느라 블로킹되지 않습니다.
  • 버그 예방: 만약 플레이어가 0.1초 간격으로 체력 물약을 2개 사용하는 요청을 보냈다고 가정해 봅시다. 기존 락 방식에서는 두 요청이 동시에 체력 값을 읽고 쓰는 과정에서 레이스 컨디션이 발생하여 물약이 1개만 사용된 것처럼 처리될 수 있습니다. 하지만 이 에이전트 모델에서는 UseItemAction 2개가 메일박스에 차례대로 쌓이고, 하나씩 순서대로 처리되므로 항상 정확한 결과가 보장됩니다.

이 패턴은 플레이어뿐만 아니라, 독립적인 상태와 행동을 가진 모든 게임 엔티티(NPC, 몬스터, 움직이는 발판 등)에 동일하게 적용하여 서버 전체의 구조를 단순하고 예측 가능하게 만들 수 있습니다.


학습 목표

  • 액터 모델의 핵심 원리 이해: 각 액터(에이전트)가 자신의 상태와 행동, 메일박스를 가지며 서로 격리된다는 개념을 체득합니다.
  • System.Threading.Channels 활용: 고성능 비동기 생산자-소비자 큐를 구현하는 현대적인 방법을 학습합니다.
  • 잠금 회피(Lock-free) 설계: 명시적인 lock 사용을 최소화하고, 아키텍처적으로 데이터 경쟁을 회피하는 방법을 익힙니다.
  • 격리된 동시성: 특정 엔티티의 작업이 다른 엔티티의 성능에 영향을 주지 않는 확장 가능한 시스템을 설계하는 방법을 이해합니다.
  • 안전한 비동기 리소스 해제: IAsyncDisposable을 구현하여, 백그라운드 TaskChannel 같은 복잡한 비동기 리소스를 누수 없이 안전하게 종료하는 패턴을 배웁니다.

이번 챌린지의 핵심 학습 목표 5가지

  • 격리성: 한 플레이어 에이전트의 작업 지연이나 오류가 다른 에이전트의 처리에 영향을 주지 않는가?
  • 정확성: 모든 액션이 해당 플레이어에게 순서대로, 빠짐없이 처리되는가? 데이터의 정합성이 보장되는가?
  • 성능 및 확장성: 수천, 수만 개의 에이전트가 동시에 활성화된 상태에서도 시스템이 안정적으로 동작하는가? ChannelTask 사용이 효율적인가?
  • 자원 관리: 플레이어가 로그아웃할 때 관련 리소스(Task, Channel, CancellationTokenSource)가 완벽하게 정리되는가?
  • 설계의 명확성: PlayerManagerPlayerAgent의 책임이 명확하게 분리되어 있고, 새로운 IPlayerAction을 추가하기 쉬운 구조인가?

보너스 목표

핵심 시스템을 완벽하게 구축했다면, 액터 모델의 또 다른 중요 기능인 '에이전트 간 통신'을 구현해 보세요.

과제: '플레이어 간 거래 요청' 기능을 추가해 보세요.

요구사항:

  1. 플레이어 A가 플레이어 B에게 거래를 요청하는 TradeRequestAction을 만듭니다. 이 액션에는 요청자(A)의 ID와 응답을 받을 방법이 포함되어야 합니다.
  2. A의 에이전트는 이 TradeRequestAction을 B의 에이전트 메일박스로 보내야 합니다. 이를 위해 PlayerManager에 다른 에이전트를 찾는 기능이 필요할 수 있습니다.
  3. B의 에이전트는 자신의 메일박스에서 TradeRequestAction을 처리하고, "수락" 또는 "거절" 응답을 결정해야 합니다.
  4. B의 응답은 다시 A의 에이전트 메일박스로 전달되어, A가 거래 결과를 알 수 있도록 해야 합니다.
  5. 힌트: 응답을 전달하기 위해 TaskCompletionSource<bool> 같은 객체를 TradeRequestAction에 포함시켜 전달하고, B가 이를 완료시키는 패턴을 사용하면 효과적일 수 있습니다.

이번 챌린지는 대규모 동시성 프로그래밍의 패러다임을 한 단계 끌어올리는 계기가 될 것입니다. 당신만의 에이전트 시스템을 만들어 보세요!

 

 

채널 - .NET

.NET을 사용하는 생산자와 소비자를 위한 System.Threading.Channels의 공식 동기화 데이터 구조에 대해 알아봅니다.

learn.microsoft.com

 

DisposeAsync 메서드 구현 - .NET

DisposeAsync 및 DisposeAsyncCore 메서드를 구현하여 비동기 리소스 정리를 수행하는 방법을 알아봅니다.

learn.microsoft.com