상세 컨텐츠

본문 제목

2.주간 C# 서버 챌린지: 스레드 안전한 경매장 구현

개발/C# 서버 챌린지

by Jiung. 2025. 9. 8. 21:49

본문

 

네, 벌써 새로운 한 주가 시작되었군요. 지난주 과제는 잘 마무리하셨으리라 믿습니다. 이번 주에는 상태(State)를 관리하고, 여러 시스템이 상호작용하는, 한 단계 더 발전된 시나리오를 준비했습니다.


주간 C# 서버 챌린지: 실시간 비동기 경매장 서버

안녕하세요! 멘토입니다. 이번 주에는 MMORPG의 경제 시스템에서 핵심적인 역할을 하는 '경매장(Auction House)'을 주제로 다뤄보겠습니다. 실시간으로 수많은 플레이어의 요청을 처리하며 아이템의 상태를 안정적으로 관리하는 것이 이번 챌린지의 핵심입니다.

C#과 .NET 8으로 실시간 MMORPG 경매장 서버를 구현하는 방법을 배우세요. ConcurrentDictionary, PeriodicTimer를 활용한 스레드 안전한 상태 관리와 비동기 처리 실전 예제를 통해 백엔드 개발 역량을 강화할 수 있습니다.

문제: 실시간 비동기 경매장 서비스 구현

상황 시나리오:

'아르카디아의 그림자'의 경제가 활성화되면서, 플레이어들은 아이템을 거래할 수 있는 경매장 시스템을 강력하게 원하고 있습니다. 당신은 서버 팀의 시니어 개발자로서, 수천 개의 아이템이 동시에 등록되고 입찰이 이루어지는 상황에서도 안정적으로 동작하는 경매장 백엔드 서비스를 구축하는 임무를 맡게 되었습니다.

핵심 요구사항:

  1. 아이템 등록: 플레이어가 아이템을 경매에 등록할 수 있어야 합니다. 등록 시 시작 가격과 경매 기간(예: 24시간)이 설정됩니다.
  2. 입찰: 다른 플레이어가 등록된 아이템에 대해 현재 최고가보다 높은 금액으로 입찰할 수 있어야 합니다.
  3. 만료 처리: 경매 시간이 만료되면, 아이템은 자동으로 처리되어야 합니다.
    • 입찰자가 있었을 경우: 아이템은 최고 입찰자에게 전달되고, 판매 금액은 판매자에게 전달됩니다.
    • 입찰자가 없었을 경우: 아이템은 다시 판매자에게 반환됩니다.
  4. 스레드 안전성 및 확장성: 이 모든 과정은 수많은 플레이어가 동시에 API를 호출하는 다중 스레드 환경에서 데이터 정합성을 깨뜨리지 않고 안전하게 동작해야 합니다.

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

이번 솔루션은 상태를 가진 객체를 안전하게 관리하기 위해 ConcurrentDictionary를, 그리고 효율적인 시간 기반 처리를 위해 PeriodicTimer를 사용합니다.

using System.Collections.Concurrent;

// 경매 아이템의 현재 상태를 나타내는 열거형입니다.
public enum AuctionStatus { Listed, Bidding, Expired, Sold }

// 경매에 등록된 아이템의 상세 정보를 담는 클래스입니다.
// 여러 스레드에서 상태가 변경될 수 있으므로 record 대신 class를 사용하고, 상태 변경은 lock을 통해 제어합니다.
public class AuctionItem
{
    private readonly object _lock = new(); // 개별 아이템의 상태 변경에 대한 동기화를 위한 잠금 객체입니다.

    public ulong AuctionId { get; } // 경매 식별자 (고유해야 함).
    public int ItemId { get; } // 게임 내 아이템의 고유 ID.
    public ulong SellerId { get; } // 판매자 플레이어 ID.
    public ulong CurrentHighestBidderId { get; private set; } // 현재 최고 입찰자.
    public long CurrentPrice { get; private set; } // 현재 최고 입찰가.
    public DateTime ExpiryTime { get; } // 경매 만료 시간.
    public AuctionStatus Status { get; private set; } = AuctionStatus.Listed; // 현재 경매 상태.

    public AuctionItem(ulong auctionId, int itemId, ulong sellerId, long startPrice, TimeSpan duration)
    {
        AuctionId = auctionId;
        ItemId = itemId;
        SellerId = sellerId;
        CurrentPrice = startPrice;
        ExpiryTime = DateTime.UtcNow + duration;
    }

    /// <summary>
    /// 새로운 입찰을 시도하고, 성공 여부를 반환합니다.
    /// 스레드로부터 안전하게 입찰가를 갱신하기 위해 lock을 사용합니다.
    /// </summary>
    /// <returns>입찰 성공 시 true, 실패 시 false를 반환합니다.</returns>
    public bool TryPlaceBid(ulong bidderId, long bidAmount)
    {
        lock (_lock)
        {
            // 입찰은 경매가 진행 중이고, 제시된 가격이 현재가보다 높을 때만 유효합니다.
            if (Status != AuctionStatus.Listed && Status != AuctionStatus.Bidding) return false;
            if (bidAmount <= CurrentPrice) return false;

            CurrentHighestBidderId = bidderId;
            CurrentPrice = bidAmount;
            Status = AuctionStatus.Bidding;
            return true;
        }
    }

    /// <summary>
    /// 경매를 만료 처리합니다. 이 메서드는 외부 타이머에 의해 호출됩니다.
    /// </summary>
    /// <returns>처리 완료된 최종 상태(Sold 또는 Expired)를 반환합니다.</returns>
    public AuctionStatus Expire()
    {
        lock (_lock)
        {
            // 이미 처리된 경매는 건너뜁니다.
            if (Status == AuctionStatus.Expired || Status == AuctionStatus.Sold) return Status;

            // 최고 입찰자가 있는지 여부에 따라 최종 상태를 결정합니다.
            Status = (CurrentHighestBidderId != 0) ? AuctionStatus.Sold : AuctionStatus.Expired;
            return Status;
        }
    }
}

/// <summary>
/// 경매장 시스템의 핵심 로직을 관리하는 서비스 클래스입니다.
/// 싱글턴 또는 의존성 주입을 통해 서버 전역에서 단일 인스턴스로 관리됩니다.
/// </summary>
public sealed class AuctionHouseService : IAsyncDisposable
{
    // 동시성 컬렉션을 사용하여 여러 스레드에서 경매 목록을 안전하게 읽고 쓸 수 있습니다.
    private readonly ConcurrentDictionary<ulong, AuctionItem> _auctions = new();
    // 경매 ID 생성을 위한 원자적 카운터입니다.
    private ulong _nextAuctionId = 0;

    // 경매 만료를 주기적으로 체크하는 백그라운드 작업을 위한 타이머와 CancellationTokenSource입니다.
    private readonly PeriodicTimer _timer = new(TimeSpan.FromSeconds(1));
    private readonly CancellationTokenSource _cts = new();
    private readonly Task _expirationTask;

    // 경매 완료 시 외부 시스템에 알리기 위한 콜백 액션입니다. (예: 메일 발송 서비스)
    public event Action<AuctionItem>? OnAuctionEnded;

    public AuctionHouseService()
    {
        // 서비스가 생성될 때 만료 처리 백그라운드 작업을 시작합니다.
        _expirationTask = CheckForExpirationsAsync();
    }

    /// <summary>
    /// 새로운 아이템을 경매에 등록합니다.
    /// </summary>
    public ulong ListAuction(int itemId, ulong sellerId, long startPrice, TimeSpan duration)
    {
        // Interlocked를 사용하여 여러 스레드가 동시에 ID를 요청해도 안전하게 고유 ID를 생성합니다.
        var auctionId = Interlocked.Increment(ref _nextAuctionId);
        var newItem = new AuctionItem(auctionId, itemId, sellerId, startPrice, duration);

        // ConcurrentDictionary는 TryAdd와 같은 메서드를 통해 스레드 안전한 추가를 보장합니다.
        _auctions.TryAdd(auctionId, newItem);
        return auctionId;
    }

    /// <summary>
    /// 특정 경매에 입찰을 시도합니다.
    /// </summary>
    public bool PlaceBid(ulong auctionId, ulong bidderId, long bidAmount)
    {
        // 경매 아이템을 찾고, 아이템이 존재하는 경우에만 입찰 메서드를 호출합니다.
        if (_auctions.TryGetValue(auctionId, out var item))
        {
            return item.TryPlaceBid(bidderId, bidAmount);
        }
        return false;
    }

    /// <summary>
    /// 주기적으로 만료된 경매를 확인하고 처리하는 백그라운드 작업입니다.
    /// </summary>
    private async Task CheckForExpirationsAsync()
    {
        try
        {
            // CancellationToken이 요청될 때까지 1초마다 반복합니다.
            while (await _timer.WaitForNextTickAsync(_cts.Token))
            {
                var now = DateTime.UtcNow;
                // 만료 시간이 지난 경매들을 찾습니다. ToList()로 스냅샷을 만들어 순회 중 컬렉션 변경 문제를 방지합니다.
                var expiredCandidates = _auctions.Values.Where(a => a.ExpiryTime <= now).ToList();

                foreach (var item in expiredCandidates)
                {
                    // 개별 아이템의 만료 처리를 호출합니다.
                    var finalStatus = item.Expire();

                    // 최종 상태가 결정되었다면(Sold 또는 Expired),
                    if (finalStatus == AuctionStatus.Sold || finalStatus == AuctionStatus.Expired)
                    {
                        // 경매 목록에서 아이템을 제거하고,
                        if (_auctions.TryRemove(item.AuctionId, out var endedItem))
                        {
                            // OnAuctionEnded 이벤트를 통해 외부 구독자(메일 시스템 등)에게 알립니다.
                            OnAuctionEnded?.Invoke(endedItem);
                        }
                    }
                }
            }
        }
        catch (OperationCanceledException)
        {
            // 서비스 종료 시 정상적으로 발생하는 예외이므로, 로깅 후 조용히 종료합니다.
            Console.WriteLine("Auction expiration checker is shutting down.");
        }
    }

    /// <summary>
    /// 서비스가 소멸될 때 백그라운드 작업을 안전하게 종료합니다.
    /// </summary>
    public async ValueTask DisposeAsync()
    {
        _cts.Cancel(); // 백그라운드 작업에 취소 신호를 보냅니다.
        _timer.Dispose(); // 타이머 리소스를 해제합니다.
        await (_expirationTask ?? Task.CompletedTask); // 작업이 완전히 끝날 때까지 기다립니다.
        _cts.Dispose(); // CancellationTokenSource를 해제합니다.
    }
}

실용적인 사용 시나리오

AuctionHouseService는 게임 서버 아키텍처의 중앙 서비스로 동작합니다.

  • 플레이어 요청 처리: 플레이어가 클라이언트에서 '아이템 등록'이나 '입찰' 버튼을 누르면, 서버의 네트워크 계층은 해당 요청을 파싱하여 AuctionHouseService.ListAuction()이나 AuctionHouseService.PlaceBid()를 호출합니다.
  • 시스템 간 연동: OnAuctionEnded 이벤트는 다른 서비스(예: MailService, InventoryService)가 구독할 수 있습니다. 경매가 종료되면 AuctionHouseService는 이벤트를 발생시키고, MailService는 이를 받아 판매자에게 대금을, 구매자에게 아이템을 메일로 보내는 로직을 수행합니다. 이렇게 함으로써 각 서비스는 자신의 책임에만 집중할 수 있습니다(관심사의 분리, Separation of Concerns).

PeriodicTimerThread.Sleep이나 Task.Delaywhile 루프에서 사용하는 전통적인 방식보다 현대적이고 효율적인 대안입니다. 정해진 주기를 정확하게 지키려고 노력하며, Dispose 패턴과 잘 통합되어 리소스 누수 없이 깔끔하게 백그라운드 작업을 관리할 수 있게 해줍니다.


학습 목표

  • 동시성 상태 관리: ConcurrentDictionary를 사용하여 여러 스레드로부터의 동시 접근(읽기, 쓰기, 삭제)을 안전하게 처리하는 방법을 학습합니다.
  • 세분화된 잠금(Fine-grained Locking): 서비스 전체를 잠그는 대신, 상태 변경이 필요한 특정 AuctionItem 객체만 잠가 동시성을 극대화하는 방법을 이해합니다.
  • 비동기 타이머: PeriodicTimer를 사용하여 효율적이고 취소 가능한 비동기 백그라운드 작업을 구현하는 방법을 익힙니다.
  • 이벤트 기반 아키텍처: eventAction 델리게이트를 사용하여 서비스 간의 결합도를 낮추고 유연한 상호작용을 설계하는 방법을 배웁니다.
  • 비동기 리소스 관리: IAsyncDisposableValueTask를 사용하여 비동기 리소스를 올바르게 정리하는 방법을 학습합니다.

솔루션 품질 평가 기준

  • 정확성: 입찰 경쟁, 경매 만료, 아이템/대금 지급 로직이 경합 상태(Race Condition) 없이 정확하게 동작하는가?
  • 성능: 수만 개의 경매 아이템이 등록된 상태에서도 만료 확인 작업이 서버에 큰 부하를 주지 않는가? 입찰과 등록 요청이 빠르게 처리되는가?
  • 견고성: 잘못된 요청(존재하지 않는 경매에 입찰, 현재가보다 낮은 금액으로 입찰 등)을 적절히 처리하는가? 서비스 종료 시 모든 리소스가 정상적으로 해제되는가?
  • 아키텍처: 경매장 로직이 서비스 클래스 내에 잘 캡슐화되어 있는가? 다른 시스템과의 연동 지점이 명확하고 유연한가?

보너스 목표

핵심 기능을 완벽하게 구현했다면, 한 단계 더 나아가 실제 경매장에서 볼 수 있는 고급 기능을 추가해 보세요.

과제: '즉시 구매(Buyout)' 기능과 '가상 트랜잭션'을 구현해 보세요.

요구사항:

  1. AuctionItem을 등록할 때, '즉시 구매가'를 선택적으로 설정할 수 있도록 시스템을 확장합니다.
  2. 플레이어는 입찰하는 대신 즉시 구매가를 지불하여 경매를 즉시 종료하고 아이템을 획득할 수 있어야 합니다.
  3. 가상 트랜잭션: 아이템 소유권 이전과 대금 지급이 **원자적(atomic)**으로 일어나도록 보장해야 합니다. 예를 들어, 즉시 구매 시 다음 두 작업이 모두 성공하거나 모두 실패해야 합니다.
    • 구매자 플레이어의 지갑에서 돈이 차감됩니다.
    • 판매자 플레이어의 지갑에 돈이 입금됩니다.
    • (실제 DB 대신, 플레이어 ID와 잔액을 가진 간단한 ConcurrentDictionary<ulong, long> _playerWallets를 만들어 트랜잭션을 시뮬레이션해 보세요.)
  4. 이 트랜잭션 과정은 스레드로부터 안전해야 합니다. (예: 한 플레이어가 동시에 여러 아이템을 즉시 구매하려 할 때 잔액이 음수가 되는 문제 방지)

이번 챌린지는 상태 관리의 복잡성을 다루는 좋은 기회가 될 것입니다.

관련글 더보기