네, 벌써 새로운 한 주가 시작되었군요. 지난주 과제는 잘 마무리하셨으리라 믿습니다. 이번 주에는 상태(State)를 관리하고, 여러 시스템이 상호작용하는, 한 단계 더 발전된 시나리오를 준비했습니다.
안녕하세요! 멘토입니다. 이번 주에는 MMORPG의 경제 시스템에서 핵심적인 역할을 하는 '경매장(Auction House)'을 주제로 다뤄보겠습니다. 실시간으로 수많은 플레이어의 요청을 처리하며 아이템의 상태를 안정적으로 관리하는 것이 이번 챌린지의 핵심입니다.
C#과 .NET 8으로 실시간 MMORPG 경매장 서버를 구현하는 방법을 배우세요. ConcurrentDictionary, PeriodicTimer를 활용한 스레드 안전한 상태 관리와 비동기 처리 실전 예제를 통해 백엔드 개발 역량을 강화할 수 있습니다.
상황 시나리오:
'아르카디아의 그림자'의 경제가 활성화되면서, 플레이어들은 아이템을 거래할 수 있는 경매장 시스템을 강력하게 원하고 있습니다. 당신은 서버 팀의 시니어 개발자로서, 수천 개의 아이템이 동시에 등록되고 입찰이 이루어지는 상황에서도 안정적으로 동작하는 경매장 백엔드 서비스를 구축하는 임무를 맡게 되었습니다.
핵심 요구사항:
이번 솔루션은 상태를 가진 객체를 안전하게 관리하기 위해 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).PeriodicTimer
는 Thread.Sleep
이나 Task.Delay
를 while
루프에서 사용하는 전통적인 방식보다 현대적이고 효율적인 대안입니다. 정해진 주기를 정확하게 지키려고 노력하며, Dispose
패턴과 잘 통합되어 리소스 누수 없이 깔끔하게 백그라운드 작업을 관리할 수 있게 해줍니다.
ConcurrentDictionary
를 사용하여 여러 스레드로부터의 동시 접근(읽기, 쓰기, 삭제)을 안전하게 처리하는 방법을 학습합니다.AuctionItem
객체만 잠가 동시성을 극대화하는 방법을 이해합니다.PeriodicTimer
를 사용하여 효율적이고 취소 가능한 비동기 백그라운드 작업을 구현하는 방법을 익힙니다.event
와 Action
델리게이트를 사용하여 서비스 간의 결합도를 낮추고 유연한 상호작용을 설계하는 방법을 배웁니다.IAsyncDisposable
과 ValueTask
를 사용하여 비동기 리소스를 올바르게 정리하는 방법을 학습합니다.핵심 기능을 완벽하게 구현했다면, 한 단계 더 나아가 실제 경매장에서 볼 수 있는 고급 기능을 추가해 보세요.
과제: '즉시 구매(Buyout)' 기능과 '가상 트랜잭션'을 구현해 보세요.
요구사항:
AuctionItem
을 등록할 때, '즉시 구매가'를 선택적으로 설정할 수 있도록 시스템을 확장합니다.ConcurrentDictionary<ulong, long> _playerWallets
를 만들어 트랜잭션을 시뮬레이션해 보세요.)이번 챌린지는 상태 관리의 복잡성을 다루는 좋은 기회가 될 것입니다.
1.주간 C# 서버 챌린지: 스레드 안전(Thread-Safe) 아이템 루팅 시스템 구현 (0) | 2025.09.06 |
---|