4. 주간 C# 서버 챌린지: 비동기 I/O와 리포지토리 패턴으로 플레이어 데이터 저장하기
지난주 액터 모델 챌린지는 잘 수행하셨나요? 개별 엔티티를 격리하는 개념에 익숙해지셨을 겁니다. 하지만 지금까지 우리가 만든 모든 상태는 서버가 종료되면 사라지는, 즉 '휘발성' 상태였습니다. 실제 게임 서버가 되려면 이 데이터를 어딘가에 저장하고 다시 불러올 수 있어야 합니다. 이번 주에는 바로 그 '영속성(Persistence)'을 다뤄보겠습니다.
주간 C# 서버 챌린지: 리포지토리 패턴으로 게임 서버 데이터 영속성 완벽 정복하기
안녕하세요! 이번 챌린지의 주제는 '데이터베이스를 연동한 플레이어 데이터 영속성 확보'입니다. 지난주에 구현한 PlayerAgent
시스템을 기반으로, 플레이어의 상태를 안전하고 효율적으로 데이터베이스에 저장하고 불러오는 기능을 추가해 보겠습니다.
C# 게임 서버의 데이터 유실 문제, 더는 걱정하지 마세요. 리포지토리 패턴, Dapper, SQLite를 활용해 플레이어 데이터를 안전하게 저장하고 불러오는 비동기 영속성 처리 방법을 단계별 코드로 완벽하게 설명합니다.
문제: PlayerAgent 시스템에 데이터베이스 영속성 계층 추가
상황 시나리오:
'아르카디아의 그림자' 서버가 예기치 않게 재시작될 때마다 모든 플레이어의 위치, 체력, 아이템 정보가 초기화되는 심각한 문제가 발생했습니다. 플레이어들의 원성이 자자한 가운데, 당신은 인-메모리(In-memory) 상태로만 관리되던 플레이어 데이터를 데이터베이스에 저장하여, 서버가 꺼지거나 플레이어가 로그아웃해도 그들의 노력이 사라지지 않도록 만들어야 합니다.
핵심 요구사항:
- 데이터 로딩: 플레이어가 로그인할 때(즉,
PlayerAgent
가 처음 생성될 때), 데이터베이스에서 해당 플레이어의 마지막 상태(위치, 체력, 인벤토리 등)를 비동기적으로 불러와야 합니다. - 데이터 저장: 플레이어가 로그아웃할 때(즉,
PlayerAgent
가 소멸될 때), 현재 상태를 데이터베이스에 비동기적으로 저장해야 합니다. - 데이터베이스 추상화: 특정 데이터베이스 기술(예: MSSQL, MySQL, SQLite)에 종속되지 않도록, 리포지토리 패턴(Repository Pattern)을 사용하여 데이터 접근 로직을 분리해야 합니다.
- 비동기 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/await
와 Dapper 같은 비동기 지원 라이브러리를 사용하여 논블로킹(Non-blocking) 데이터베이스 접근 코드를 작성하는 방법을 학습합니다. - 리포지토리 패턴: 애플리케이션의 핵심 로직과 데이터 영속성 로직을 분리하여, 테스트 용이성과 유연성이 높은 아키텍처를 설계하는 방법을 익힙니다.
- 데이터 직렬화: 복잡한 인게임 객체(컬렉션, 커스텀 클래스 등)를 JSON 같은 표준 포맷으로 직렬화하여 데이터베이스에 효율적으로 저장하는 방법을 배웁니다.
- 의존성 주입(Dependency Injection):
PlayerAgent
가 구체적인SqlitePlayerRepository
클래스가 아닌IPlayerRepository
인터페이스에 의존하도록 설계하여 컴포넌트 간의 결합도를 낮추는 방법을 이해합니다.
솔루션 품질 평가 기준
- 정확성: 플레이어의 상태가 손실이나 왜곡 없이 정확하게 저장되고 복원되는가?
- 비동기 처리: 모든 DB 접근이 비동기적으로 이루어져 서버의 다른 작업을 방해하지 않는가?
PlayerAgent
의 생성이나 소멸 과정이 DB I/O로 인해 지연되지 않는가? - 추상화: 리포지토리 패턴이 올바르게 적용되어
PlayerAgent
가 특정 DB 기술로부터 완벽히 분리되었는가? - 견고성: DB 연결 실패나 쿼리 오류 같은 예외 상황을 어떻게 처리하는가? (샘플 코드에서는 생략되었지만, 실제 코드에서는 중요)
보너스 목표
로그아웃 시 저장하는 것만으로는 서버가 갑자기 다운될 경우 데이터가 유실될 수 있습니다. 이를 방지하기 위한 더 발전된 저장 전략을 구현해 보세요.
과제: '더티 플래그(Dirty Flag)'를 이용한 주기적 자동 저장 시스템 구현
요구사항:
PlayerAgent
에private bool _isDirty = false;
와 같은 상태 변경 여부를 추적하는 플래그를 추가합니다.- 플레이어의 상태를 변경하는 모든 로직(예:
MoveAction
,UseItemAction
처리 후)에서_isDirty
플래그를true
로 설정합니다. PlayerManager
또는 별도의AutoSaveService
에서, N초(예: 60초)마다 모든 활성PlayerAgent
를 순회하는 백그라운드 타이머를 구현합니다.- 타이머는
_isDirty
플래그가true
인 에이전트들을 찾아, 그들의 메일박스에SaveStateAction
을 보냅니다. 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