지난주 액터 모델 챌린지는 잘 수행하셨나요? 개별 엔티티를 격리하는 개념에 익숙해지셨을 겁니다. 하지만 지금까지 우리가 만든 모든 상태는 서버가 종료되면 사라지는, 즉 '휘발성' 상태였습니다. 실제 게임 서버가 되려면 이 데이터를 어딘가에 저장하고 다시 불러올 수 있어야 합니다. 이번 주에는 바로 그 '영속성(Persistence)'을 다뤄보겠습니다.
안녕하세요! 이번 챌린지의 주제는 '데이터베이스를 연동한 플레이어 데이터 영속성 확보'입니다. 지난주에 구현한 PlayerAgent
시스템을 기반으로, 플레이어의 상태를 안전하고 효율적으로 데이터베이스에 저장하고 불러오는 기능을 추가해 보겠습니다.
C# 게임 서버의 데이터 유실 문제, 더는 걱정하지 마세요. 리포지토리 패턴, Dapper, SQLite를 활용해 플레이어 데이터를 안전하게 저장하고 불러오는 비동기 영속성 처리 방법을 단계별 코드로 완벽하게 설명합니다.
상황 시나리오:
'아르카디아의 그림자' 서버가 예기치 않게 재시작될 때마다 모든 플레이어의 위치, 체력, 아이템 정보가 초기화되는 심각한 문제가 발생했습니다. 플레이어들의 원성이 자자한 가운데, 당신은 인-메모리(In-memory) 상태로만 관리되던 플레이어 데이터를 데이터베이스에 저장하여, 서버가 꺼지거나 플레이어가 로그아웃해도 그들의 노력이 사라지지 않도록 만들어야 합니다.
핵심 요구사항:
PlayerAgent
가 처음 생성될 때), 데이터베이스에서 해당 플레이어의 마지막 상태(위치, 체력, 인벤토리 등)를 비동기적으로 불러와야 합니다.PlayerAgent
가 소멸될 때), 현재 상태를 데이터베이스에 비동기적으로 저장해야 합니다.이 솔루션은 가볍고 빠른 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
에 주입해주기만 하면 됩니다. 이는 시스템의 유연성과 유지보수성을 극대화합니다.
async/await
와 Dapper 같은 비동기 지원 라이브러리를 사용하여 논블로킹(Non-blocking) 데이터베이스 접근 코드를 작성하는 방법을 학습합니다.PlayerAgent
가 구체적인 SqlitePlayerRepository
클래스가 아닌 IPlayerRepository
인터페이스에 의존하도록 설계하여 컴포넌트 간의 결합도를 낮추는 방법을 이해합니다.PlayerAgent
의 생성이나 소멸 과정이 DB I/O로 인해 지연되지 않는가?PlayerAgent
가 특정 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
3.주간 C# 서버 챌린지: '락(Lock)' 없는 플레이어 상태 관리 (0) | 2025.09.17 |
---|---|
2.주간 C# 서버 챌린지: 스레드 안전한 경매장 구현 (0) | 2025.09.08 |
1.주간 C# 서버 챌린지: 스레드 안전(Thread-Safe) 아이템 루팅 시스템 구현 (0) | 2025.09.06 |