Skip to content

OracleZJ/CollectionRPGServer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CollectionRPG Orleans Server — 포트폴리오 Created by 이종욱

프로젝트 개요

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;
}

Orleans 분산 액터 모델 (Grain)

설계 의도

  • 사용자(UserId)를 키로 하는 IAccountGrain 하나가 해당 사용자의 모든 게임 로직을 처리합니다.
  • Orleans의 Virtual Actor 모델 덕분에 클라이언트는 Grain의 물리적 위치를 알 필요 없이 GetGrain<IAccountGrain>(userId) 호출만 합니다.
  • 같은 UserId에 대한 요청은 항상 동일한 Grain 인스턴스에서 직렬 처리되어, 별도의 락 없이 동시성 문제를 방지합니다.

IAccountGrain 인터페이스

패킷 매핑 어트리뷰트를 메서드에 선언합니다. 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);
}

패킷 자동 디스패처 (Expression Tree 컴파일)

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은 수정 불필요합니다.


AccountGrain 의존성

public class AccountGrain(
    IAccountRepository  accountRepository,
    IGachaRepository    gachaRepository,
    DataManager         dataManager,
    ILogger<AccountGrain> logger
) : Grain, IAccountGrain

Orleans의 DI 컨테이너를 통해 Repository, DataManager, Logger를 주입받습니다.


데이터 접근 계층

NpgsqlDataSource 싱글톤

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>();

PostgreSQL FUNCTION 호출 패턴

Npgsql 6+에서 CommandType.StoredProcedureCALL(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)

PostgreSQL Functions

함수 반환 역할
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 가챠 마스터 데이터 전체 조회

Orleans 대시보드

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 ────────│

구현 중 해결한 주요 기술 과제

1. Orleans Grain DI 직렬화 충돌

문제: 패킷 클래스를 Grain 메서드 인자로 사용할 때 Orleans 직렬화 오류 발생.

원인: MessagePack 어트리뷰트만 있고 Orleans 직렬화 어트리뷰트가 누락됨.

해결: 패킷 클래스에 [GenerateSerializer] + [Id(n)] 추가. TCP 직렬화([MessagePackObject])와 Orleans 직렬화([GenerateSerializer])를 동시 적용.


2. 10회 가챠 UPSERT 충돌 오류

문제: 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

3. 패킷 핸들러 switch 문 → Expression Tree 자동 디스패처

문제: 패킷 종류가 늘어날수록 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 패킷 전송을 자동 결정합니다.


4. Npgsql CommandType.StoredProcedure 오류

문제: CommandType.StoredProcedure로 PostgreSQL FUNCTION 호출 시 오류 발생.

ERROR: function fn_xxx does not exist (procedure not found)

원인: Npgsql 6+에서 StoredProcedureCALL 구문 전용으로 변경됨. 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

About

CollectionRPG Orleans Server — 포트폴리오

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors