Microsoft Orleans 기반의 수집형 RPG 게임 서버 프로토타입. C# async/await 숙련도와 분산 액터 모델 설계 역량을 중심으로 구성한 포트폴리오입니다.
| 항목 | 내용 |
|---|---|
| 런타임 | .NET 10.0 |
| 언어 | C# (LangVersion: latest) |
| 핵심 프레임워크 | Microsoft Orleans 9.1.2 |
| 직렬화 | MessagePack 3.1.3 (TCP 패킷) + Orleans 직렬화 (Grain 내부) |
| 데이터베이스 | PostgreSQL 18, Npgsql 9.0.3 |
| 통신 | Raw TCP (커스텀 바이너리 프로토콜) |
CollectionRPG.sln
├── src/CollectionRPG.Shared (net8.0) 공유 패킷 / 직렬화 엔진
├── src/CollectionRPG.Server (net10.0) Orleans Silo + TCP 서버
├── src/CollectionRPG.Client (net10.0) 단일 클라이언트 테스트
└── src/CollectionRPG.LoadTest (net10.0) 동시접속 부하 테스트
클라이언트 TCP
↓
TcpGameServer (포트 7000)
↓ 연결 수락 → ClientSession 생성
ClientSession
↓ 패킷 수신 → PacketSerializer.Deserialize
↓ PacketId 라우팅
IGrainFactory.GetGrain<IAccountGrain>(userId)
↓ Orleans Virtual Actor
AccountGrain
├─ AccountRepository → PostgreSQL (masterdb)
├─ GachaRepository → PostgreSQL (masterdb)
└─ DataManager → 메모리 캐시 (gacha 마스터 데이터)
↓ 결과 반환
ClientSession.SendPacketAsync → 클라이언트 응답
| 포트 | 용도 |
|---|---|
| 7000 | 게임 클라이언트 TCP 연결 |
| 8080 | Orleans 대시보드 (웹 UI) |
| 11111 | Orleans Silo 내부 통신 |
| 30000 | Orleans Gateway |
TCP 스트림 위에 직접 설계한 바이너리 프로토콜을 사용합니다.
┌─────────────────┬───────────────┬─────────────────────────┐
│ 4B (길이 prefix) │ 2B (PacketId) │ NB (MessagePack 페이로드) │
└─────────────────┴───────────────┴─────────────────────────┘
Big-Endian Big-Endian
- 길이 필드: PacketId + Payload 바이트 수 (Big-Endian 4바이트)
- PacketId: 패킷 종류 식별자 (Big-Endian 2바이트, ushort enum)
- 페이로드: MessagePack 직렬화된 요청/응답 객체
| PacketId | 이름 | 방향 |
|---|---|---|
| 1 | Login | Client → Server |
| 2 | GachaSingleRequest | Client → Server |
| 3 | GachaSingleResponse | Server → Client |
| 4 | CreateAccountRequest | Client → Server |
| 5 | CreateAccountResponse | Server → Client |
| 6 | GachaMultiRequest | Client → Server |
| 7 | GachaMultiResponse | Server → Client |
| 8 | GachaNakResponse | Server → Client (실패 공통) |
패킷 클래스는 TCP 네트워크와 Orleans Grain 호출 두 경로에서 모두 사용되므로 어트리뷰트를 이중으로 적용합니다.
[MessagePackObject] // TCP 직렬화
[GenerateSerializer] // Orleans Grain 직렬화
public class GachaSingleRequest
{
[Key(0), Id(0)] public string UserId { get; set; } = string.Empty;
}- 사용자(UserId)를 키로 하는
IAccountGrain하나가 해당 사용자의 모든 게임 로직을 처리합니다. - Orleans의 Virtual Actor 모델 덕분에 클라이언트는 Grain의 물리적 위치를 알 필요 없이
GetGrain<IAccountGrain>(userId)호출만 합니다. - 같은 UserId에 대한 요청은 항상 동일한 Grain 인스턴스에서 직렬 처리되어, 별도의 락 없이 동시성 문제를 방지합니다.
패킷 매핑 어트리뷰트를 메서드에 선언합니다. ClientSession은 이 인터페이스를 스캔해 핸들러를 자동 구성합니다.
public interface IAccountGrain : IGrainWithStringKey
{
[PacketMapping(PacketId.Login, ResponseId = PacketId.Login)]
Task<LoginResponse> Login(LoginRequest request);
[PacketMapping(PacketId.CreateAccountRequest, ResponseId = PacketId.CreateAccountResponse)]
Task<CreateAccountResponse> CreateAccount(CreateAccountRequest request);
[PacketMapping(PacketId.GachaSingleRequest,
ResponseId = PacketId.GachaSingleResponse,
NakId = PacketId.GachaNakResponse)]
Task<GachaSingleResponse> PullGachaSingle(GachaSingleRequest request);
[PacketMapping(PacketId.GachaMultiRequest,
ResponseId = PacketId.GachaMultiResponse,
NakId = PacketId.GachaNakResponse)]
Task<GachaMultiResponse> PullGachaMulti(GachaMultiRequest request);
}ClientSession static 생성자에서 IAccountGrain 인터페이스를 한 번 스캔하고,
각 메서드에 대한 역직렬화 · UserId 추출 · Grain 호출 델리게이트를 Expression Tree로 컴파일합니다.
이후 패킷 수신 시에는 Dictionary 조회와 델리게이트 호출만 수행하여 Reflection 비용이 없습니다.
static ClientSession()
{
foreach (var method in typeof(IAccountGrain).GetMethods())
{
var attr = method.GetCustomAttribute<PacketMappingAttribute>();
if (attr is null) continue;
var requestType = method.GetParameters()[0].ParameterType;
// 1. PacketSerializer.Deserialize<TRequest>(payload) 컴파일
var deserializeFn = Expression.Lambda<Func<byte[], object>>(...).Compile();
// 2. request.UserId getter 컴파일 — GetProperty는 여기서 1회만 호출
var getUserIdFn = Expression.Lambda<Func<object, string>>(...).Compile();
// 3. grain.Method(request) → Task<object> 컴파일
var invokeFn = Expression.Lambda<Func<IAccountGrain, object, Task<object>>>(...).Compile();
_handlers[attr.RequestId] = (deserializeFn, getUserIdFn, invokeFn, attr.ResponseId, attr.NakId);
}
}HandlePacketAsync는 switch 문 없이 Dictionary 조회만 합니다.
NAK 분기는 INakResponse 인터페이스 패턴 매칭으로 자동 처리합니다.
private async Task HandlePacketAsync(PacketId packetId, byte[] payload, CancellationToken ct)
{
if (!_handlers.TryGetValue(packetId, out var entry)) { ... return; }
var (deserialize, getUserId, invoke, responseId, nakId) = entry;
var request = deserialize(payload);
var userId = getUserId(request);
var grain = _grainFactory.GetGrain<IAccountGrain>(userId);
var response = await invoke(grain, request);
if (nakId != default && response is INakResponse nak && !nak.Success)
await SendPacketAsync(nakId, new GachaNakResponse { Message = nak.Message }, ct);
else
await SendPacketAsync(responseId, response, ct);
}새 패킷 추가 시 IAccountGrain에 [PacketMapping] 메서드 1개만 추가하면 되고, ClientSession은 수정 불필요합니다.
public class AccountGrain(
IAccountRepository accountRepository,
IGachaRepository gachaRepository,
DataManager dataManager,
ILogger<AccountGrain> logger
) : Grain, IAccountGrainOrleans의 DI 컨테이너를 통해 Repository, DataManager, Logger를 주입받습니다.
NpgsqlDataSource는 커넥션 풀을 내장하므로 앱 생명주기 동안 단일 인스턴스로 관리합니다.
MasterDb / GameDb 두 래퍼 클래스를 통해 데이터베이스를 역할별로 분리합니다.
public sealed class MasterDb(NpgsqlDataSource source)
{
public ValueTask<NpgsqlConnection> OpenConnectionAsync(CancellationToken ct = default)
=> source.OpenConnectionAsync(ct);
}DI 등록:
builder.Services.AddSingleton(new MasterDb(masterSource));
builder.Services.AddSingleton(new GameDb(gameSource));
builder.Services.AddSingleton<IAccountRepository, AccountRepository>();
builder.Services.AddSingleton<IGachaRepository, GachaRepository>();
builder.Services.AddSingleton<DataManager>();Npgsql 6+에서 CommandType.StoredProcedure는 CALL(PROCEDURE 전용)이므로,
반환값이 있는 FUNCTION은 CommandType.Text + SELECT 방식으로 호출합니다.
cmd.CommandText = "SELECT fn_save_gacha_result($1, $2)";
// CommandType.StoredProcedure 사용 불가 (PROCEDURE 전용)masterdb
├── account — 계정 (user_id PK, password_hash SHA-256, midnight_term)
├── account_gacha — 보유 캐릭터 (account_id + character_id PK, amount)
└── account_gacha_log — 가챠 이력 (BIGSERIAL PK, 분석용)
gamedb
└── gacha — 가챠 마스터 데이터 (unique_id, name, grade, appearance_rate)
| 함수 | 반환 | 역할 |
|---|---|---|
fn_search_account(user_id) |
BOOLEAN | 계정 존재 여부 조회 |
fn_create_account(user_id, password, midnight_term) |
TABLE(success, message) | 계정 생성 + SHA-256 해싱 |
fn_login_account(user_id, password) |
TABLE(success, message) | 비밀번호 검증 + last_login_at 갱신 |
fn_save_gacha_result(account_id, character_ids[]) |
BOOLEAN | 가챠 결과 UPSERT |
fn_log_gacha(account_id, character_ids[]) |
VOID | 가챠 이력 기록 |
fn_get_all_gacha() |
TABLE | 가챠 마스터 데이터 전체 조회 |
OrleansDashboard 8.2.0 (Orleans 9 호환 커뮤니티 패키지)를 연동해 서버 상태를 실시간으로 모니터링합니다.
- Grain 활성화 수 및 종류별 분포
- 메서드별 호출 횟수 / 평균 레이턴시
- Silo CPU · 메모리 사용량
- 활성화된 Grain 목록 (UserId 기반)
기존 Generic Host(Host.CreateApplicationBuilder) 구조에서 ASP.NET Core(WebApplication.CreateBuilder)로 전환하여 대시보드 웹 서버를 통합했습니다.
// Program.cs
builder.Services.AddControllers();
builder.UseOrleans(siloBuilder =>
{
siloBuilder
.UseLocalhostClustering(...)
.AddMemoryGrainStorage("Default")
.UseDashboard(o => o.Port = 8080); // 대시보드 포트
});
app.UseOrleansDashboard();
app.MapControllers();서버 실행 후 http://localhost:8080 에서 접속합니다.
Client Server (ClientSession → AccountGrain)
│ │
│──── LoginRequest ─────────────▶│
│ │ SearchAsync() → 계정 없음
│◀─── LoginResponse(IsNewUser) ──│
│
│──── CreateAccountRequest ─────▶│
│ │ CreateAsync() → INSERT account
│◀─── CreateAccountResponse ─────│
│
│──── LoginRequest (재로그인) ───▶│
│ │ LoginAsync() → 비밀번호 검증
│◀─── LoginResponse(Success) ────│
│
│──── GachaSingleRequest ────────▶│
│ │ RollOne() → 메모리 확률 계산
│ │ SaveResultAsync() → DB UPSERT
│ │ LogAsync() → fire-and-forget
│◀─── GachaSingleResponse ────────│
문제: 패킷 클래스를 Grain 메서드 인자로 사용할 때 Orleans 직렬화 오류 발생.
원인: MessagePack 어트리뷰트만 있고 Orleans 직렬화 어트리뷰트가 누락됨.
해결: 패킷 클래스에 [GenerateSerializer] + [Id(n)] 추가.
TCP 직렬화([MessagePackObject])와 Orleans 직렬화([GenerateSerializer])를 동시 적용.
문제: 10회 뽑기에서 같은 캐릭터가 중복으로 뽑히면 DB 오류 발생.
ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time
원인: unnest(array) 결과에 동일 character_id가 두 번 나타나면
같은 행을 두 번 업데이트하려 해서 PostgreSQL이 거부함.
해결: GROUP BY + COUNT(*) 로 배열을 먼저 집계한 뒤 UPSERT.
SELECT p_account_id, char_id, COUNT(*)::INT
FROM unnest(p_character_ids) AS char_id
GROUP BY char_id문제: 패킷 종류가 늘어날수록 ClientSession.HandlePacketAsync의 switch 문이 비례해서 커짐.
새 패킷 추가 시 ClientSession을 반드시 수정해야 하는 구조.
해결: IAccountGrain 메서드에 [PacketMapping] 어트리뷰트를 선언하고,
ClientSession static 생성자에서 인터페이스를 스캔해 핸들러 Dictionary를 자동 구성.
효과:
- switch 문 완전 제거 — HandlePacketAsync는 Dictionary 조회 + 컴파일된 델리게이트 호출만 수행
- 이후 패킷 추가:
IAccountGrain에[PacketMapping]메서드 1개 추가,ClientSession수정 불필요 - Reflection(GetProperty, GetMethod) 비용은 static 생성자에서 프로세스당 1회로 한정
NAK 자동 처리: INakResponse 인터페이스로 실패 응답 타입을 구분하고,
디스패처가 response is INakResponse nak && !nak.Success 패턴으로 NAK 패킷 전송을 자동 결정합니다.
문제: CommandType.StoredProcedure로 PostgreSQL FUNCTION 호출 시 오류 발생.
ERROR: function fn_xxx does not exist (procedure not found)
원인: Npgsql 6+에서 StoredProcedure는 CALL 구문 전용으로 변경됨.
PostgreSQL FUNCTION은 SELECT로 호출해야 함.
해결: CommandType.Text + "SELECT fn_xxx($1, $2)" 방식으로 전환.
- Orleans Silo + TCP 서버 기동 및 통신
- 로그인 / 계정 생성 / 가챠 1회 · 10회 전체 흐름
- PostgreSQL 연동 (계정, 가챠 결과, 가챠 로그)
- 20개 클라이언트 동시 부하 테스트
- Orleans 대시보드 연동 (Grain 활성화 수 / 메서드 호출 통계 / Silo 상태 실시간 모니터링)
- Expression Tree 기반 패킷 자동 디스패처 (switch 문 제거, IAccountGrain 어트리뷰트 스캔)
| 분류 | 항목 |
|---|---|
| 인증 | 세션 토큰 발급 및 검증 |
| 게임 로직 | 재화 시스템 (소지/차감 원자적 처리) |
| 아키텍처 | PlayerGrain 분리 (캐릭터/인벤토리) |
| 운영 | 환경 변수 기반 DB 연결 문자열 |
| 품질 | TestCluster 기반 Grain 단위 테스트 |
# 빌드
dotnet build CollectionRPG.sln
# 서버 실행
dotnet run --project src/CollectionRPG.Server/CollectionRPG.Server.csproj
# → 대시보드: http://localhost:8080
# 단일 클라이언트 테스트 (별도 터미널)
dotnet run --project src/CollectionRPG.Client/CollectionRPG.Client.csproj
# 부하 테스트 (서버 실행 중)
dotnet run --project src/CollectionRPG.LoadTest/CollectionRPG.LoadTest.csproj