개발 & 프로그래밍/C# 서버 챌린지

4. 주간 C# 서버 챌린지: 비동기 I/O와 리포지토리 패턴으로 플레이어 데이터 저장하기

Jiung. 2025. 9. 24. 22:59
반응형

지난주 액터 모델 챌린지는 잘 수행하셨나요? 개별 엔티티를 격리하는 개념에 익숙해지셨을 겁니다. 하지만 지금까지 우리가 만든 모든 상태는 서버가 종료되면 사라지는, 즉 '휘발성' 상태였습니다. 실제 게임 서버가 되려면 이 데이터를 어딘가에 저장하고 다시 불러올 수 있어야 합니다. 이번 주에는 바로 그 '영속성(Persistence)'을 다뤄보겠습니다.


주간 C# 서버 챌린지: 리포지토리 패턴으로 게임 서버 데이터 영속성 완벽 정복하기

안녕하세요! 이번 챌린지의 주제는 '데이터베이스를 연동한 플레이어 데이터 영속성 확보'입니다. 지난주에 구현한 PlayerAgent 시스템을 기반으로, 플레이어의 상태를 안전하고 효율적으로 데이터베이스에 저장하고 불러오는 기능을 추가해 보겠습니다.

C# 게임 서버의 데이터 유실 문제, 더는 걱정하지 마세요. 리포지토리 패턴, Dapper, SQLite를 활용해 플레이어 데이터를 안전하게 저장하고 불러오는 비동기 영속성 처리 방법을 단계별 코드로 완벽하게 설명합니다.

 


문제: PlayerAgent 시스템에 데이터베이스 영속성 계층 추가

상황 시나리오:

'아르카디아의 그림자' 서버가 예기치 않게 재시작될 때마다 모든 플레이어의 위치, 체력, 아이템 정보가 초기화되는 심각한 문제가 발생했습니다. 플레이어들의 원성이 자자한 가운데, 당신은 인-메모리(In-memory) 상태로만 관리되던 플레이어 데이터를 데이터베이스에 저장하여, 서버가 꺼지거나 플레이어가 로그아웃해도 그들의 노력이 사라지지 않도록 만들어야 합니다.

 

핵심 요구사항:

  1. 데이터 로딩: 플레이어가 로그인할 때(즉, PlayerAgent가 처음 생성될 때), 데이터베이스에서 해당 플레이어의 마지막 상태(위치, 체력, 인벤토리 등)를 비동기적으로 불러와야 합니다.
  2. 데이터 저장: 플레이어가 로그아웃할 때(즉, PlayerAgent가 소멸될 때), 현재 상태를 데이터베이스에 비동기적으로 저장해야 합니다.
  3. 데이터베이스 추상화: 특정 데이터베이스 기술(예: MSSQL, MySQL, SQLite)에 종속되지 않도록, 리포지토리 패턴(Repository Pattern)을 사용하여 데이터 접근 로직을 분리해야 합니다.
  4. 비동기 I/O: 모든 데이터베이스 작업은 서버의 메인 스레드를 차단(Block)하지 않는 비동기 방식으로 구현되어야 합니다.

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

이 솔루션은 가볍고 빠른 SQLite 데이터베이스와, 간단하면서도 강력한 마이크로 ORM인 Dapper를 사용합니다. 또한, 복잡한 객체(인벤토리)를 단일 컬럼에 저장하기 위해 System.Text.Json을 활용합니다.

 

사전 준비:
아래 NuGet 패키지들을 프로젝트에 추가해야 합니다.
dotnet add package Microsoft.Data.Sqlite
dotnet add package Dapper

using Dapper;
using Microsoft.Data.Sqlite;
using System.Text.Json;
using System.Threading.Channels; // (지난주 코드에서 이어짐)

// --- 데이터베이스와 상호작용할 Player 데이터 모델 ---
public class PlayerData
{
    public ulong PlayerId { get; set; }
    public float PositionX { get; set; }
    public float PositionY { get; set; }
    public int Health { get; set; }
    // 인벤토리는 복잡한 객체 리스트이므로, DB에는 JSON 문자열로 저장합니다.
    public string InventoryJson { get; set; } = "[]"; 
}

// --- 데이터 접근 로직을 추상화하는 인터페이스 ---
public interface IPlayerRepository
{
    Task<PlayerData?> GetPlayerDataAsync(ulong playerId);
    Task SavePlayerDataAsync(PlayerData data);
}

// --- SQLite와 Dapper를 사용한 리포지토리 구현체 ---
public class SqlitePlayerRepository : IPlayerRepository
{
    private readonly string _connectionString;

    public SqlitePlayerRepository(string dbFilePath)
    {
        _connectionString = $"Data Source={dbFilePath}";
        InitializeDatabase();
    }

    private void InitializeDatabase()
    {
        using var connection = new SqliteConnection(_connectionString);
        connection.Execute("""
            CREATE TABLE IF NOT EXISTS Players (
                PlayerId INTEGER PRIMARY KEY,
                PositionX REAL NOT NULL,
                PositionY REAL NOT NULL,
                Health INTEGER NOT NULL,
                InventoryJson TEXT
            )
            """);
    }

    public async Task<PlayerData?> GetPlayerDataAsync(ulong playerId)
    {
        using var connection = new SqliteConnection(_connectionString);
        // Dapper를 사용해 비동기 쿼리를 실행하고 결과를 PlayerData 객체로 매핑합니다.
        return await connection.QuerySingleOrDefaultAsync<PlayerData>(
            "SELECT * FROM Players WHERE PlayerId = @PlayerId", new { PlayerId = playerId });
    }

    public async Task SavePlayerDataAsync(PlayerData data)
    {
        using var connection = new SqliteConnection(_connectionString);
        // UPSERT (Update or Insert) 쿼리를 사용하여, 데이터가 있으면 업데이트하고 없으면 새로 삽입합니다.
        // 이는 스레드 안전성을 높이고 로직을 단순화합니다.
        await connection.ExecuteAsync("""
            INSERT INTO Players (PlayerId, PositionX, PositionY, Health, InventoryJson)
            VALUES (@PlayerId, @PositionX, @PositionY, @Health, @InventoryJson)
            ON CONFLICT(PlayerId) DO UPDATE SET
                PositionX = excluded.PositionX,
                PositionY = excluded.PositionY,
                Health = excluded.Health,
                InventoryJson = excluded.InventoryJson
            """, data);
    }
}

// --- PlayerAgent 수정: IPlayerRepository 의존성 주입 및 Load/Save 로직 추가 ---
// (지난주 PlayerAgent 코드에서 아래 내용들을 추가/수정합니다)
public sealed class PlayerAgent : IAsyncDisposable
{
    private readonly IPlayerRepository _repository; // 데이터베이스 리포지토리 의존성
    public List<int> Inventory { get; private set; } = new(); // 인벤토리 상태 추가

    // 생성자에서 IPlayerRepository를 주입받습니다.
    public PlayerAgent(ulong playerId, IPlayerRepository repository)
    {
        PlayerId = playerId;
        _repository = repository;
        // ... 기존 코드 ...
        _processingLoop = RunProcessingLoopAsync();

        // 에이전트 시작 시, 비동기적으로 데이터 로딩을 '요청'합니다.
        // fire-and-forget 방식으로 메일박스에 작업을 던져넣어, 생성자 자체는 블로킹되지 않도록 합니다.
        _ = PostActionAsync(new LoadStateAction());
    }

    // 데이터 로딩/저장을 위한 내부 액션 정의
    private record LoadStateAction : IPlayerAction;
    private record SaveStateAction : IPlayerAction;

    // ProcessAction 메서드 수정
    private async Task ProcessAction(IPlayerAction action)
    {
        switch (action)
        {
            case LoadStateAction:
                await LoadStateAsync();
                break;
            case SaveStateAction:
                await SaveStateAsync();
                break;
            // ... 기존 MoveAction, UseItemAction 처리 ...
            case UseItemAction useItem:
                Inventory.Remove(useItem.ItemId); // 아이템 사용 시 인벤토리에서 제거
                Health = Math.Min(100, Health + 20);
                Console.WriteLine($"Player {PlayerId}: Used item {useItem.ItemId}. Health is now {Health}.");
                break;
        }
    }

    private async Task LoadStateAsync()
    {
        var data = await _repository.GetPlayerDataAsync(PlayerId);
        if (data != null)
        {
            Position = (data.PositionX, data.PositionY);
            Health = data.Health;
            // JSON 문자열을 List<int> 객체로 역직렬화합니다.
            Inventory = JsonSerializer.Deserialize<List<int>>(data.InventoryJson) ?? new();
            Console.WriteLine($"Player {PlayerId}: State loaded successfully.");
        }
        else
        {
            Console.WriteLine($"Player {PlayerId}: No saved data found. Starting with default state.");
        }
    }

    private async Task SaveStateAsync()
    {
        var data = new PlayerData
        {
            PlayerId = this.PlayerId,
            PositionX = this.Position.X,
            PositionY = this.Position.Y,
            Health = this.Health,
            // List<int> 객체를 저장하기 위해 JSON 문자열로 직렬화합니다.
            InventoryJson = JsonSerializer.Serialize(this.Inventory)
        };
        await _repository.SavePlayerDataAsync(data);
        Console.WriteLine($"Player {PlayerId}: State saved successfully.");
    }

    // DisposeAsync 수정
    public async ValueTask DisposeAsync()
    {
        // 종료 직전, 메일박스에 저장 요청을 마지막으로 보냅니다.
        // 이렇게 하면 진행 중이던 모든 액션이 처리된 후, 최종 상태가 저장되는 것을 보장할 수 있습니다.
        await PostActionAsync(new SaveStateAction());

        _mailbox.Writer.Complete();
        _cts.Cancel();
        await _processingLoop;
        _cts.Dispose();
    }
}

실용적인 사용 시나리오

이 영속성 시스템은 MMO 서버의 생명줄과 같습니다.

  • 플레이어 로그인: 유저가 인증을 통과하면, 서버는 PlayerManager.GetOrCreateAgent(playerId)를 호출합니다. 내부적으로 PlayerAgent가 생성되고, 생성자는 비동기적으로 LoadStateAction을 자신의 메일박스에 넣어 데이터 로딩을 시작합니다.
  • 서버 재시작: 예기치 않은 서버 다운 후 재부팅 시에도, 플레이어들은 마지막으로 저장된 지점에서 게임을 이어갈 수 있습니다.
  • 데이터 백업 및 분석: 데이터베이스에 저장된 플레이어 데이터는 게임 운영에 필수적인 자산입니다. 주기적으로 백업하거나, 데이터를 분석하여 게임 밸런싱이나 유저 행동 패턴 파악에 활용할 수 있습니다.

리포지토리 패턴을 사용한 이유는 매우 중요합니다. 만약 나중에 데이터베이스를 SQLite에서 고성능 PostgreSQL로 교체하기로 결정했다면? PlayerAgent 코드는 단 한 줄도 바꿀 필요 없이, IPlayerRepository의 새로운 구현체인 PostgresPlayerRepository를 만들어 PlayerManager에 주입해주기만 하면 됩니다. 이는 시스템의 유연성과 유지보수성을 극대화합니다.


학습 목표

  • 비동기 데이터베이스 I/O: async/awaitDapper 같은 비동기 지원 라이브러리를 사용하여 논블로킹(Non-blocking) 데이터베이스 접근 코드를 작성하는 방법을 학습합니다.
  • 리포지토리 패턴: 애플리케이션의 핵심 로직과 데이터 영속성 로직을 분리하여, 테스트 용이성과 유연성이 높은 아키텍처를 설계하는 방법을 익힙니다.
  • 데이터 직렬화: 복잡한 인게임 객체(컬렉션, 커스텀 클래스 등)를 JSON 같은 표준 포맷으로 직렬화하여 데이터베이스에 효율적으로 저장하는 방법을 배웁니다.
  • 의존성 주입(Dependency Injection): PlayerAgent가 구체적인 SqlitePlayerRepository 클래스가 아닌 IPlayerRepository 인터페이스에 의존하도록 설계하여 컴포넌트 간의 결합도를 낮추는 방법을 이해합니다.

솔루션 품질 평가 기준

  • 정확성: 플레이어의 상태가 손실이나 왜곡 없이 정확하게 저장되고 복원되는가?
  • 비동기 처리: 모든 DB 접근이 비동기적으로 이루어져 서버의 다른 작업을 방해하지 않는가? PlayerAgent의 생성이나 소멸 과정이 DB I/O로 인해 지연되지 않는가?
  • 추상화: 리포지토리 패턴이 올바르게 적용되어 PlayerAgent가 특정 DB 기술로부터 완벽히 분리되었는가?
  • 견고성: DB 연결 실패나 쿼리 오류 같은 예외 상황을 어떻게 처리하는가? (샘플 코드에서는 생략되었지만, 실제 코드에서는 중요)

보너스 목표

로그아웃 시 저장하는 것만으로는 서버가 갑자기 다운될 경우 데이터가 유실될 수 있습니다. 이를 방지하기 위한 더 발전된 저장 전략을 구현해 보세요.

과제: '더티 플래그(Dirty Flag)'를 이용한 주기적 자동 저장 시스템 구현

요구사항:

  1. PlayerAgentprivate bool _isDirty = false; 와 같은 상태 변경 여부를 추적하는 플래그를 추가합니다.
  2. 플레이어의 상태를 변경하는 모든 로직(예: MoveAction, UseItemAction 처리 후)에서 _isDirty 플래그를 true로 설정합니다.
  3. PlayerManager 또는 별도의 AutoSaveService에서, N초(예: 60초)마다 모든 활성 PlayerAgent를 순회하는 백그라운드 타이머를 구현합니다.
  4. 타이머는 _isDirty 플래그가 true인 에이전트들을 찾아, 그들의 메일박스에 SaveStateAction을 보냅니다.
  5. SaveStateAsync 메서드는 저장이 완료된 후 _isDirty 플래그를 다시 false로 설정해야 합니다.

이 방식은 아무런 변경이 없었던 플레이어에 대해서는 불필요한 DB 쓰기 작업을 수행하지 않아, 서버와 데이터베이스의 부하를 크게 줄일 수 있는 매우 효율적인 최적화 기법입니다.


이번 챌린지를 통해 여러분의 서버는 비로소 '상태를 기억하는' 진짜 게임 서버로 거듭나게 될 것입니다. 성공적으로 과제를 마치기를 기대하겠습니다!

 

 

GitHub - DapperLib/Dapper: Dapper - a simple object mapper for .Net

Dapper - a simple object mapper for .Net. Contribute to DapperLib/Dapper development by creating an account on GitHub.

github.com

 

개요 - Microsoft.Data.Sqlite

Microsoft.Data.Sqlite 개요

learn.microsoft.com

 

반응형