개발/C# 서버 챌린지

주간 C\# 서버 챌린지: 1주차

Jiung. 2025. 9. 6. 01:05

주간 C# 서버 챌린지: 1주차 

안녕하세요! 매주 여러분의 성장을 돕기 위한 새로운 챌린지를 제공해 드릴 멘토입니다. 오늘은 그 첫 번째 시간으로, 실제 MMO 게임 서버 환경에서 마주할 수 있는 흥미로운 문제를 준비했습니다.


문제: 고성능, 스레드 안전(Thread-Safe) 아이템 루팅 시스템 구현

상황 시나리오:

당신은 인기 MMORPG '아르카디아의 그림자' 서버 팀의 시니어 개발자입니다. 최근 대규모 레이드 업데이트 이후, 여러 플레이어가 동시에 보스 몬스터를 처치하고 아이템을 획득하는 과정에서 서버 지연 및 아이템 분배 오류가 발생하고 있다는 보고가 들어왔습니다. 원인 분석 결과, 기존 루팅 시스템이 다수의 동시 요청을 효율적으로 처리하지 못하고 있으며, 여러 스레드가 공유 자원(아이템 테이블)에 접근할 때 경합 상태(Race Condition)가 발생하는 것으로 확인되었습니다.

당신의 임무는 이 문제를 해결하기 위해, 현대적인 C# 기술을 사용하여 빠르고 안정적이며 확장 가능한 새로운 아이템 루팅 시스템을 설계하고 구현하는 것입니다.


샘플 솔루션 (C# 12 / .NET 8)

다음은 이 문제를 해결하기 위한 샘플 코드입니다. 최신 C# 기능과 비동기 처리, 그리고 스레드 안전성을 중점적으로 다루었습니다.

// System.Collections.Immutable를 사용하기 위해 NuGet 패키지 참조가 필요할 수 있습니다.
// dotnet add package System.Collections.Immutable
using System.Collections.Immutable;
using System.Security.Cryptography;

/// <summary>
/// 개별 아이템의 데이터를 정의하는 레코드 구조체입니다.
/// 불변성(Immutability)을 보장하여 스레드 간에 안전하게 공유할 수 있습니다.
/// </summary>
public record struct Item(int Id, string Name, double DropChance);

/// <summary>
/// 몬스터가 드랍할 수 있는 아이템 목록과 규칙을 관리하는 클래스입니다.
/// 이 클래스의 인스턴스는 불변(Immutable)으로 설계되어 초기화 후에는 변경되지 않습니다.
/// </summary>
public sealed class LootTable
{
    // 아이템 목록을 ImmutableArray로 선언하여, 한 번 생성되면 절대로 변경되지 않음을 보장합니다.
    // 이는 다중 스레드 환경에서 '읽기' 작업 시 락(lock) 없이도 안전하게 접근할 수 있게 해줍니다.
    public ImmutableArray<Item> Items { get; }

    // LootTable 생성자입니다. 아이템 목록을 받아 내부의 불변 컬렉션에 저장합니다.
    public LootTable(IEnumerable<Item> items)
    {
        // 제공된 아이템 목록으로부터 불변 배열(ImmutableArray)을 생성합니다.
        // ToImmutableArray()는 방어적 복사(defensive copy)를 수행하므로, 원본 컬렉션이 변경되어도 이 인스턴스에는 영향을 주지 않습니다.
        Items = items.ToImmutableArray();
    }
}

/// <summary>
/// 아이템 드랍 로직을 처리하는 핵심 서비스 클래스입니다.
/// 스레드로부터 안전하게 설계되어 여러 요청을 동시에 처리할 수 있습니다.
/// </summary>
public sealed class LootGenerator
{
    // 특정 몬스터 ID에 해당하는 LootTable을 매핑하는 불변 사전(ImmutableDictionary)입니다.
    // 서버 시작 시 한 번 초기화되며, 런타임 중에는 읽기 전용으로만 사용되어 스레드 안전성을 확보합니다.
    private readonly ImmutableDictionary<int, LootTable> _lootTables;

    // LootGenerator 생성자입니다. 몬스터 ID와 LootTable의 매핑 정보를 받습니다.
    public LootGenerator(IReadOnlyDictionary<int, LootTable> lootTables)
    {
        // 제공된 사전으로부터 불변 사전(ImmutableDictionary)을 생성합니다.
        _lootTables = lootTables.ToImmutableDictionary();
    }

    /// <summary>
    /// 지정된 몬스터에 대한 아이템 드랍을 비동기적으로 계산합니다.
    /// 스레드 풀의 작업 스레드에서 실행되도록 설계되어, 메인 스레드의 부하를 줄여줍니다.
    /// </summary>
    /// <param name="monsterId">아이템을 드랍할 몬스터의 ID입니다.</param>
    /// <param name="cancellationToken">비동기 작업 취소를 위한 토큰입니다.</param>
    /// <returns>드랍된 아이템 목록을 포함하는 Task<List<Item>>을 반환합니다.</returns>
    public Task<List<Item>> GenerateLootAsync(int monsterId, CancellationToken cancellationToken = default)
    {
        // Task.Run을 사용하여 CPU 집약적인 루팅 계산 로직을 백그라운드 스레드로 오프로드합니다.
        // 이는 게임 서버의 메인 루프가 블로킹되는 것을 방지하여 서버 전체의 반응성을 유지합니다.
        return Task.Run(() =>
        {
            // CancellationToken을 확인하여 작업 취소 요청이 있었는지 주기적으로 체크합니다.
            cancellationToken.ThrowIfCancellationRequested();

            // 몬스터 ID로 LootTable을 찾습니다. 없으면 예외를 발생시키는 대신 빈 리스트를 반환하는 것이 더 안정적일 수 있습니다.
            if (!_lootTables.TryGetValue(monsterId, out var lootTable))
            {
                // 해당 몬스터에 대한 LootTable이 없으면 빈 리스트를 즉시 반환합니다.
                return new List<Item>();
            }

            var droppedItems = new List<Item>();
            foreach (var item in lootTable.Items)
            {
                // CancellationToken을 루프 내에서도 확인하여 더 빠른 응답성을 제공합니다.
                cancellationToken.ThrowIfCancellationRequested();

                // 암호학적으로 안전한 난수 생성기를 사용하여 각 아이템의 드랍 여부를 결정합니다.
                // RandomNumberGenerator.GetDouble()은 0.0에서 1.0 사이의 균일 분포된 난수를 생성합니다.
                if (RandomNumberGenerator.GetDouble() < item.DropChance)
                {
                    droppedItems.Add(item);
                }
            }

            return droppedItems;
        }, cancellationToken);
    }
}

실용적인 사용 시나리오

LootGenerator는 게임 서버의 다양한 지점에서 활용될 수 있습니다:

  1. 몬스터 사망 처리 로직: 몬스터의 OnDeath 이벤트 핸들러 내에서 LootGenerator.GenerateLootAsync()를 호출합니다. 생성된 아이템 목록은 보스 몬스터를 공격한 플레이어들에게 공정하게 분배되거나, 가장 많은 피해를 준 파티/플레이어에게 귀속됩니다.
  2. 보물 상자 열기: 플레이어가 월드에 배치된 보물 상자와 상호작용할 때, 이 시스템을 호출하여 상자 안의 아이템을 동적으로 생성할 수 있습니다.
  3. 퀘스트 보상 생성: 퀘스트 완료 시, 정해진 보상 외에 추가적인 랜덤 보상을 지급할 때 LootGenerator를 사용하여 결과의 예측 불가능성을 더할 수 있습니다.

Immutable 컬렉션을 사용한 이유는 명확합니다. 게임 서버 데이터(아이템 정보, 몬스터 스펙 등)는 보통 서버 시작 시 로드되어 런타임 중에는 거의 변경되지 않습니다. 이런 '한 번 쓰고, 여러 번 읽는(Write-Once, Read-Many)' 패턴의 데이터를 Immutable 컬렉션으로 관리하면, lock과 같은 동기화 메커니즘 없이도 여러 스레드가 안전하게 데이터에 접근할 수 있어 경합을 원천적으로 차단하고 성능을 크게 향상시킬 수 있습니다.


학습 목표

  • 불변성(Immutability)의 이해: record structSystem.Collections.Immutable을 사용하여 스레드 안전한 데이터 구조를 설계하는 방법을 학습합니다.
  • 비동기 프로그래밍: Task.Run을 사용하여 CPU 집약적 작업을 백그라운드 스레드로 오프로드하고, 서버의 응답성을 유지하는 방법을 익힙니다.
  • 스레드 안전성: lock을 사용하지 않고도 다중 스레드 환경에서 안전하게 동작하는 코드를 작성하는 원리를 이해합니다.
  • 암호학적 난수 생성: System.Random 대신 System.Security.Cryptography.RandomNumberGenerator를 사용하여 보안 및 예측 불가능성이 중요한 시스템에서 더 높은 품질의 난수를 생성하는 방법을 배웁니다.

솔루션 품질 평가 기준

  • 성능: 대량의 루팅 요청을 동시에 처리할 때의 지연 시간과 처리량. lock 경합이 없는가?
  • 스레드 안전성: 여러 스레드에서 동시에 GenerateLootAsync를 호출해도 데이터가 깨지거나 예외가 발생하지 않는가?
  • 유지보수성: 코드의 가독성이 높고, 새로운 아이템이나 몬스터를 추가하는 것이 용이한가? 로직의 각 부분이 명확하게 분리되어 있는가?
  • 확장성: 향후 더 복잡한 루팅 규칙(예: 특정 직업 전용 아이템, 특정 조건 만족 시 드랍률 증가)을 추가하기 용이한 구조인가?

보너스 목표

핵심 요구사항을 완료하셨다면, 다음 목표에 도전해 보세요.

과제: '드랍 가중치 그룹' 시스템을 추가하여 LootGenerator를 확장해 보세요.

요구사항:

  1. 하나의 LootTable이 여러 개의 '드랍 그룹'을 가질 수 있도록 수정합니다.
  2. 각 그룹에는 여러 아이템이 포함될 수 있으며, 그룹별로 "이 그룹에서 단 하나의 아이템만 드랍" 또는 "이 그룹에서 최대 N개의 아이템 드랍"과 같은 규칙을 설정할 수 있어야 합니다.
  3. 예를 들어, '전설 무기 그룹'에서는 단 하나의 무기만 1% 확률로 드랍되고, '소모품 그룹'에서는 최대 3개의 포션이 각각의 드랍률에 따라 드랍될 수 있습니다.
  4. 이 새로운 규칙을 반영하여 GenerateLootAsync 로직을 수정하고, 기존의 스레드 안전성과 성능을 유지해야 합니다.

이제 당신의 차례입니다. 위의 요구사항에 맞춰 자신만의 해결책을 작성하여 업로드해 주세요. 당신의 코드를 면밀히 검토하고, 심층적인 피드백과 함께 개선 방향을 제시해 드리겠습니다. 행운을 빕니다!