서버 개발 인수인계 · Server Handover

BnaireGameServer 서버 구조 & 동작 가이드

Billionaire Royale Club 모바일 게임의 게임서버. 클라이언트 통신이 어떻게 들어와 어떻게 처리되는지, DB·Redis·암호화·멱등성·에러 처리까지 — 실제 코드와 함께 짚어봅니다.

.NET 9 · ASP.NET Core MessagePack + XXTEA + Zstd MySQL · Dapper Redis 대상: C#/.NET 경험자, 라이브 게임서버 입문
목차 열기
  1. 개요
  2. 프로젝트 구조
  3. 서버 기동
  4. 요청 처리 흐름
  5. 통신 프로토콜
  6. 암호화 & 압축
  7. IService 라우팅
  8. 매니저 클래스
  9. 인증 & 세션
  10. 데이터베이스
  11. Redis
  12. 멱등성
  13. 에러 처리
  14. 로깅
  15. 배포
  16. 리스크
  17. 회고
  18. 부록 · 동시성 제어
00 / OVERVIEW

개요 & 한눈에 보기

이 서버는 Unity 클라이언트의 모든 게임 통신을 단일 엔드포인트 /Gate 로 받는 게이트웨이 구조입니다. 요청은 바이너리(MessagePack)로 직렬화되고, XXTEA로 암호화되며, 프로토콜 ID에 따라 알맞은 서비스 핸들러로 라우팅됩니다.

전체 아키텍처 한 장 요약

Unity 클라이언트
MessagePack + XXTEA
/Gate
Gate.cs (단일 진입)
Service 라우터
ProtocolId → IService
핸들러 + 매니저
Service*.cs / *Manager
Redis
유저캐시 · 응답캐시 · 락
MySQL (Dapper)
Common/Game/Social/...
BnaireLog → Snowflake
분석 로그
외부 연동
Apple/Google/FB/AWS

기술 스택

영역기술 / 라이브러리버전
런타임.NET 9.0 · ASP.NET Core (Minimal API)net9.0
직렬화MessagePack-CSharp3.1.0
암호화XXTEA (자체 구현, BnaireG/Util/XXTea.cs)
압축ZstdSharp.Port (응답 전용)0.8.6
DBMySqlConnector + Dapper2.4.0 / 2.1.66
캐시StackExchange.Redis2.9.32
로깅NLog + BnaireLog(→Snowflake)6.0.5
스케줄Quartz (OnTimeJobScheduler)

📌 이 문서를 읽기 전에 알아둘 3가지

  • DI/미들웨어가 거의 없다. ASP.NET Core의 관례적인 서비스 컨테이너·미들웨어 파이프라인을 쓰지 않고, Gate.cs 하나에서 요청 전 과정을 손수 처리합니다.
  • 공유 코드는 DLL이 아니라 "소스 임포트"다. BnaireCommon·BnaireG·BnaireProtocol·BnaireLog는 .projitems(공유 프로젝트)로 서버에 직접 컴파일됩니다.
  • Redis 우선, MySQL 폴백. 유저 데이터는 항상 Redis를 먼저 보고 없으면 DB에서 읽어 캐시에 채웁니다.

⚠️ CLAUDE.md와 실제 코드가 다른 점 (이 문서가 정정함)

  • 세션은 JWT 기반이 아니다. 실제 세션 토큰은 TblUser.Hash(랜덤 uint32)이며, JWT는 Apple/Google/Facebook 3자 토큰 검증에만 쓰입니다. → 8장
  • Zstd 압축이 존재한다(CLAUDE.md 미기재). 응답에만 적용되며 MessagePack의 LZ4는 미사용. → 5장
01 / STRUCTURE

프로젝트 구조

솔루션 BnaireGameServer.sln의 메인 프로젝트는 BnaireGameServer.csproj 하나지만, 실제 코드의 상당 부분은 4개의 공유 프로젝트에서 옵니다. 이들은 NuGet/DLL 참조가 아니라 Visual Studio 공유 프로젝트(.shproj / .projitems)로, 소스 파일이 서버 어셈블리에 그대로 컴파일됩니다.

BnaireGameServer.csproj (일부)
<Import Project="..\..\..\BnaireProtocol\Protocol\BnaireGameProtocol\BnaireGameProtocol.projitems" Label="Shared" />
<Import Project="..\..\..\BnaireProtocol\Protocol\BnaireProtocol\BnaireProtocol.projitems" Label="Shared" />
<Import Project="..\..\BnaireG\BnaireG.projitems" Label="Shared" />
<Import Project="..\..\BnaireCommon\BnaireCommon.projitems" Label="Shared" />
공유 프로젝트역할물리적 위치
BnaireProtocol클라↔서버 메시지(프로토콜) 정의. ProtocolId·Req/Res 클래스·Result enum~/git/BnaireProtocol
BnaireCommonDB 접근 + 비즈니스 로직. 각 DB 라우터·Tbl* 엔티티·매니저·Redis·외부연동BnaireServer/BnaireCommon
BnaireG저수준 엔진 유틸. DB 커넥션 엔진(G.Database/G.MySql), XXTEA, JWT, AWSBnaireServer/BnaireG
BnaireLog분석/이벤트 로그 정의 및 전송(→ Snowflake)~/git/BnaireLog

💡 왜 DLL이 아니라 소스 임포트인가?

같은 코드를 서버(BnaireGameServer, BnaireEventServer, BnaireBotServer)와 클라이언트(Unity)가 공유하기 때문입니다. 서버는 소스를 직접 컴파일하고, 클라이언트는 같은 Protocol/ 소스로 빌드한 DLL과 생성된 리졸버를 받습니다(→ 4장).

서비스 레이어

게임 기능 하나당 Service/Service*.cs 파일 하나. 약 250개IService 구현이 있습니다(예: ServiceAlive, ServiceAccessGame, ServiceStageClear). 복잡한 다중-DB 작업은 BnaireCommon/GameManager/의 매니저 클래스(GameManager, ShopManager, CardManager 등)로 캡슐화됩니다.

02 / STARTUP

서버 기동 (Program.cs)

Program.Main은 웹서버를 켜기 전에 긴 초기화 시퀀스를 순서대로 실행합니다. 이 순서 자체가 의존성 순서라 이해해 두면 좋습니다.

Program.cs — 부팅 시퀀스 (요약)
// 1) 로깅/설정
NLogEx.SetConfiguration("NLog.config", 5);
GameConfig.Load("GameIndividual.config");

// 2) AWS Secrets → DB 커넥션 문자열 부트스트랩
var key = AccessKey.Load(Const.AwsAccessKey, Const.AwsEncryptionKey);
await SecretsManager.InitializeAsync(key);
TblConnectionString.Initialize(SecretsManager.Get("CommonDB"));

// 3) 캐시/설정 새로고침, 분석로그 초기화
await RefreshManager.InitializeAsync();
Log.Initialize(CfgServer.Shared.SnowflakeLogDatabase, CfgServer.Shared.SnowflakeLogger, CfgServer.Shared.ServerMode);

// 4) 스케줄러 · 외부연동 초기화 (Quartz, Apple, Facebook, StoreKit ...)
await CreateScheduleJob(); OnTimeJobScheduler.Instance.Start();
await AppleId.InitializeAsync(); await FacebookManager.InitializeAsync();

// 5) 서비스 레지스트리 구축 (리플렉션) — 6장 참조
BnaireGameServer.Service.Initialize();

// 6) Redis 헬스체크 후 웹서버 기동
Redis.Ping(false); Redis.Ping(true);
RunWebServer(args, cts);

DI도 미들웨어도 없다

RunWebServerWebApplication.CreateBuilder서비스 등록도, app.UseXxx() 미들웨어도 없이 곧바로 Minimal API 라우트만 매핑합니다. 각 핸들러는 IWebHandler(정적 InvokeAsync)를 구현한 클래스입니다.

Program.cs — 엔드포인트 매핑
app.MapPost("/Gate",    async (context) => { await Gate.InvokeAsync(context); });    // 게임 통신 (유일한 실서비스)
app.MapGet ("/Monitor", async (context) => { await Monitor.InvokeAsync(context); }); // 관리자용 부하 대시보드
// 그 외: /Refresh, /AgeCheck, /AppStoreNotification, /Kick-Bnaire, /ServerTime ... (운영/연동 훅)
app.Run(CfgHaeginGameServer.Shared.ServerUrl);

⚠️ 신입이 헷갈리기 쉬운 지점

/Gate만이 실제 게임 통신 엔드포인트입니다. 나머지는 전부 운영·연동용 훅. "컨트롤러가 어디 있지?"를 찾지 마세요 — 라우팅은 ProtocolId 딕셔너리가 대신합니다.

03 / REQUEST FLOW

요청 처리 전체 흐름 ★ 핵심

클라이언트가 POST /Gate로 보낸 바이너리 한 덩어리가 응답으로 돌아가기까지의 전 과정입니다. 이 장만 이해하면 서버의 절반은 이해한 것입니다.

요청 생애주기 (Gate.InvokeAsync)

  1. HTTP 바디 수신Gate.cs

    MemoryPool에서 버퍼를 빌려 ReadExactlyAsync로 읽음(10초 타임아웃). OPTIONS는 CORS 204로 조기 종료.

  2. 5바이트 헤더 파싱Gate.cs

    span[0] = 암호화 모드(0/1/2), span[1..4] = 세션 hash. 페이로드는 offset 5부터.

  3. 복호화 + 역직렬화Gate.cs

    모드에 따라 XXTEA 복호 후 MessagePackSerializer.Deserialize<ProtocolReq>. Union이 ProtocolId에 맞는 구체 Req 타입으로 자동 복원.

  4. 검증 게이트Gate.cs

    프로토콜별 최소 암호화 레벨 확인 → 점검(maintenance) 모드 확인.

  5. 멱등성 캐시 조회Redis

    Redis.GetResponseAsync(Suid, Ticks). 히트 시(=재요청) 저장된 응답을 그대로 반환하고 서비스는 실행하지 않음.

  6. 서비스 라우팅Service.cs

    Service.ProcessAsyncdicHandler[ProtocolId]IService 핸들러 조회.

  7. 유저 로드 + 인증Service.cs → Redis/MySQL

    IsSuidNeededTblUser.GetAsync(Redis→MySQL). IsHashCheckNeededhash == user.Hash 검증.

  8. 핸들러 실행Service*.cs

    handler.ProcessAsync(context, user, req) → 비즈니스 로직 수행 후 ProtocolRes 반환.

  9. 부가 기록 (비동기)Task.Run

    제외 목록이 아니면 요청/응답을 TblUserProtocolHist에 fire-and-forget 저장. StressMonitor 타이밍 기록.

  10. 직렬화 → 압축 → 암호화 → 전송Gate.cs

    MessagePack 직렬화 → (선택)Zstd 압축 → (선택)XXTEA 암호화 → 1바이트 모드 헤더 prepend → 응답 캐시에 저장 후 전송.

Gate.cs — 2~3단계 (헤더 파싱 & 분기)
var span = memory.Span;
encrypted = span[0];                          // 암호화 모드: 0, 1, 2
hash      = BitConverter.ToUInt32(span.Slice(1)); // 세션 해시 (bytes 1~4)

var key = CfgHaeginGameServer.Shared.Key;
if (encrypted == 2) {                          // 정적키 XOR 세션해시
    xxtea = new XXTea(new[]{ hash^key[0], hash^key[1], hash^key[2], hash^key[3] });
    req = DecryptAndDeserialize(xxtea, span.Slice(5), out step);
} else if (encrypted == 1) {                   // 정적키
    xxtea = new XXTea(new[]{ key[0], key[1], key[2], key[3] });
    req = DecryptAndDeserialize(xxtea, span.Slice(5), out step);
} else if (encrypted == 0) {                   // 평문
    req = MessagePackSerializer.Deserialize<ProtocolReq>(memory.Slice(5));
}

🔑 기억할 비대칭

응답직렬화 → 압축 → 암호화지만, 요청복호화 → 역직렬화뿐입니다. 요청에는 압축이 없습니다(서버에 Decompressor 호출 자체가 없음). 압축은 서버→클라 단방향.

04 / PROTOCOL

통신 프로토콜 (BnaireProtocol)

메시지는 MessagePack 특성을 붙인 손으로 작성한 C# 클래스가 원본(source of truth)입니다. 요청/응답은 각각 추상 베이스 Protocol / ProtocolRes를 상속하고, [Union] 다형성으로 하나의 Deserialize 호출이 올바른 구체 타입으로 복원됩니다.

ProtocolReq.cs — 요청 봉투
[MessagePackObject]
public class ProtocolReq
{
    [Key(0)] public long     Suid;      // 유저 식별자
    [Key(1)] public long     Ticks;     // 요청 고유값 = 멱등성 키 (MakeUniqueTicks)
    [Key(2)] public byte     Retry;
    [Key(3)] public Protocol Protocol;  // 실제 페이로드 (다형성)
}
Protocol.cs — 다형성 Union 테이블
[MessagePackObject]
[Union(1,   typeof(MaintenanceCheckReq))]
[Union(102, typeof(AuthReq))]
[Union(201, typeof(AliveReq))]
// ... 약 250개, Union 태그 = ProtocolId 값
public abstract class Protocol
{
    [IgnoreMember] public virtual byte Encrypted { get; set; } // 요구 최소 암호화 레벨(직렬화 안 됨)
    [Key(0)]       public ProtocolId ProtocolId { get; set; }
}

서버와 클라이언트가 같은 ID를 공유하는 법

ProtocolId enum과 [Union] 테이블은 모두 공유 Protocol/ 소스에 한 번만 정의됩니다. 그래서 서버(소스 임포트)와 클라이언트(빌드된 DLL)가 동일한 정수 ID를 갖습니다.

Protocol/

원본 정의

  • 메시지 .cs (~200개)
  • ProtocolId enum
  • Union 테이블
  • 서버·클라 공용

ForClient/

클라 전달물

  • 빌드된 BnaireProtocol.dll
  • 생성된 BnaireResolver.cs
  • Unity용 MessagePack DLL
  • (IL2CPP/AOT 대응)

코드젠 (mpc)

precompile.sh

  • mpcProtocol/ 스캔
  • 포매터 리졸버 생성
  • 메시지는 생성 X, 리졸버만 생성

FOOTGUN Union 테이블은 수동 동기화

새 프로토콜 추가 시 ProtocolId enum과 [Union(n, typeof(...))]사람이 직접 일치시켜야 합니다. 빠뜨리면 역직렬화가 조용히 실패합니다. (유지보수 모드라 신규 추가는 드물지만 알아두세요.)

05 / CRYPTO

암호화 & 압축

와이어 포맷의 맨 앞 5바이트는 평문 헤더이고, 그 뒤가 (선택적으로 암호화된) 페이로드입니다.

요청 와이어 포맷

byte 0
암호화 모드
0 / 1 / 2
bytes 1–4
uint hash
(세션 해시)
bytes 5 …
MessagePack(ProtocolReq) — 모드에 따라 XXTEA 암호화

XXTEA 3가지 모드

모드의미
0암호화 없음 (평문). 예: Handshake2
1key[i]서버 전역 정적 키
2hash ^ key[i]정적 키를 세션 해시로 XOR → 사실상 로그인 세션별 키 + 인증 토큰 역할

키의 출처: DB 설정값 CfgServer.ClientKey(Base62 문자열)를 ConvertEx.ToKey로 16바이트 → uint[4]로 디코드합니다. 소스에 하드코딩된 것이 아니라 DB 설정에서 로드되는 단일 전역 키입니다. 암호화는 필드 단위가 아니라 페이로드 전체에 적용됩니다.

⚠️ "핸드셰이크"는 진짜 키 교환이 아니다

Handshake2(평문)가 버전 체크 통과 시 정적 XXTEA 키를 클라에 평문으로 그대로 내려줍니다(res.Key1..Key4). 즉 per-request 세션키 협상이나 진정한 키 로테이션은 없고, 유일한 세션별 변주는 모드 2의 user.Hash XOR뿐입니다.

압축 — Zstd, 응답 전용

응답은 MessagePack 직렬화 , XXTEA 암호화 ZstdSharp로 압축됩니다. 단, 특정 프로토콜(Alive, Handshake2, MaintenanceCheck, ShopCheckPurchaseNotCompressibleProtocols)은 제외됩니다.

Gate.cs — SerializeAndEncrypt (응답 방향)
MessagePackSerializer.Serialize(ms, response);
ReadOnlyMemory<byte> bytes = ms.ToArray();
if (shouldCompress) bytes = CompressZstd(response.ProtocolId, bytes); // ① 압축
if (xxtea == null) { encrypted = bytes; return null; }
memoryOwner = xxtea.EncryptUsingMemoryPool(bytes.Span, out encrypted);  // ② 암호화

✅ 처리 순서 요약

응답: Serialize → Zstd압축 → XXTEA암호화 → 1바이트 모드헤더
요청: 5바이트헤더 분리 → XXTEA복호 → Deserialize (압축 해제 단계 없음)

06 / ROUTING

IService 패턴 & 라우팅

모든 게임 기능은 IService를 구현하고 [ProtocolHandler] 특성을 붙입니다. 서버 기동 시 리플렉션으로 이들을 모아 ProtocolId → 핸들러 딕셔너리를 만듭니다.

Service.cs — 인터페이스
public interface IService
{
    ProtocolId ProtocolId { get; }
    bool IsSuidNeeded { get; }        // 인증된 유저 로드 필요?
    bool IsHashCheckNeeded { get; }   // 세션 해시 검증 필요?
    Task<ProtocolRes> ProcessAsync(HttpContext context, TblUser user, Protocol request);
}
Service.cs — 리플렉션 등록 & 라우팅
private static Dictionary<ProtocolId, IService> dicHandler = new();

// Initialize(): 엔트리 어셈블리의 모든 타입 스캔
if (t.GetInterfaces().Contains(typeof(IService)) == false) continue;
if (typeInfo.GetCustomAttribute<ProtocolHandlerAttribute>() != null) {
    var instance = (IService)Activator.CreateInstance(t);
    dicHandler.Add(instance.ProtocolId, instance);   // 싱글톤 등록
}

// ProcessAsync(): 라우팅
if (dicHandler.TryGetValue(req.ProtocolId, out var handler) == false)
    throw new HaeginException(Result.UnknownProtocol, "Unknown Protocol : {0}", req.ProtocolId);

중요 핸들러는 싱글톤 = 반드시 stateless

IService는 기동 시 딱 한 번 생성되어 모든 요청이 공유합니다. 인스턴스 필드에 요청별 상태를 저장하면 동시성 버그가 납니다. 상태는 전부 user/request/로컬 변수로만 다루세요.

ServiceAlive.cs — 대표 핸들러
[ProtocolHandler]
public class ServiceAlive : IService
{
    public ProtocolId ProtocolId => ProtocolId.Alive;
    public bool IsSuidNeeded => true;
    public bool IsHashCheckNeeded => true;

    public async Task<ProtocolRes> ProcessAsync(HttpContext context, TblUser user, Protocol request)
    {
        var req = (AliveReq)request;   // Protocol 베이스에서 다운캐스트
        var res = new AliveRes();
        // ... 로직 ...
        return res;
    }
}
07 / MANAGERS

매니저 클래스

서비스 핸들러(Service*.cs)는 얇은 "오케스트레이터"입니다. 실제 비즈니스 로직은 매니저 클래스에 모여 있고, 핸들러는 요청을 캐스팅해 매니저의 정적 메서드들을 호출(자주 Task.WhenAll로 병렬)한 뒤 결과를 응답에 담습니다.

핸들러 → 매니저 → 데이터 (레이어)

Service*.cs
얇은 오케스트레이션
*Manager (static)
비즈니스 로직
Tbl* DAO
Dapper / MySQL
Redis
캐시 / 락

⚠️ 매니저 ↔ DB 사이에 Repository 레이어가 없다

매니저는 Tbl* DAO(예: TblUserShopProduct.SelectAsync)와 Redis.*직접 호출합니다. 핸들러도 단순 조회는 Tbl*를 직접 부릅니다. 즉 "핸들러 → 매니저 → Tbl*/Redis"의 2~3단이 전부이고 그 사이 추상화 레이어는 없습니다.

대부분 static · stateless

거의 모든 매니저는 class로 선언되지만 멤버가 전부 static이고, TblUser user를 매 호출마다 인자로 받습니다(인스턴스 필드에 유저 상태를 담지 않음). 예외는 아래 싱글톤 핸들러 규칙과 같은 이유로 상태를 가지는 소수뿐입니다:

  • 상태를 가지는 것: EventManager(활성 이벤트 인메모리 캐시), RefreshManager·LeaderBoardManager(스케줄/캐시), ApiManager(유일한 인스턴스 클래스, IDisposable)
  • partial 분할: AuthManager(7개 파일, 프로바이더별), EventManager(2개 파일)

꼭 알아야 할 매니저

매니저담당 도메인대표 메서드 / 특징
GameManager ★ 매우 중요
4,711줄 · 최대
핵심 게임플레이 엔진 — 재화·보상·부스터·주사위·EXP·아바타·수집/협동 포인트. 모든 게임플레이 핸들러가 의존하는 허브.AddUserCurrency, GetRewardItems, GetUserActiveBoosters, AcquireToken
SocialManager ★ 매우 중요
2,689줄 · 2위
소셜 전반 — 맵친구·친구선물/초대·길드·1v1 협동·랜덤/봇 유저 선택·페북 친구. (namespace BnaireGame)RegenerateMapFriendList, SendFriendGiftWithAwait, JoinGuild, PickMultiRandomUser
ScenarioManager ★ 매우 중요
2,534줄 · 3위
보드 이동 시나리오 — 유저의 모노폴리형 보드에서 앞으로 나올 주사위 눈·토큰 배치·찬스 보상·강탈(heist)을 미리 계산해 생성. (스토리/퀘스트 아님) 보드 동기화 핵심 경로.CreateUserScenario, CreateTutorialScenario, ResetBoardScenarioRelatedInfo, PlanTokenRespawn
SyncManager
1,893줄
게임 진행 동기화 & 보상 정산의 핵심 — 클라 시나리오(주사위 이동/타일/부스터)를 동기화하고 보상 집계·재화 반영·주사위 환불·랭킹/힘(Power) 스탯 처리. (인스턴스+정적 하이브리드: sync당 누적 상태 보유)SyncScenario, SetSyncResult, FinishHeistAsync, AddRefundDice
MessengerManager ★ 매우 중요
1,431줄
인게임 메신저/알림 — 카드 공격/피격·친구 성립·이모티콘·초대·공지/선물 등 소셜 이벤트 알림(notification·foreground message)을 생성·등록.RegisterNotification, CreateAttackNotification, SendGiftMessage, SendNoticeToAllUsers
CardManager수집형 카드 — 카드 획득·팩/뽑기·유저간 카드 전송/교환·번들/시즌 완성·인벤토리.AddCardAsync, DeliverOrExchangeCard, CheckBundleRewardableAsync
EventManager
partial
라이브/기간 이벤트 — 랭킹·수집·협동·미니게임 이벤트 라이프사이클. 활성 이벤트 인메모리 캐시 보유(비-stateless).RefreshActiveEvents, GetListActiveEvents, InitUserEventProgress
ShopManager인게임 상점 — 상품 번들·구매 자격·구매횟수/한도·구매→보상 변환. 보상 생성은 GameManager.GetRewardItems에 위임.ProcessProductReward, GetShopList, UpdateUserShopBuyCount
BingoManager
427줄
빙고 게임 핵심 로직 — 보드 동기화·빙고 라인 판정·히든 보상·진행/보상 적용·NPC 픽.SyncBingo, BingoCheck, HandleHiddenRewardsAsync, NpcPick
MiniGameManager
357줄
미니게임 초기화 허브 — 주로 칵테일(Cocktail)·빙고 상태 세팅, 미니게임 게이지 포인트·칵테일 평판/NPC 관리.InitCocktail, InitBingo, GetCocktailInfo, ModifyMiniGameGaugePoint
HeistManager
380줄
금고털이/약탈(steal) 보상 계산기 — steal power·사보타지 보상·금고해제(safecracking) 금액/보너스 레벨을 RNG로 산출.GetSafecrackingRewardMoneyAsync, GetStealPowerMultipleAsync, PickSafecrackingBonusLevel
DjEventManager
381줄
DJ 파티 이벤트 — 카드를 주고받는 파티 이벤트의 봇/시스템 자동화(카드 포커스 선택·시스템 발송·봇 입장/보상).PickDjEventCardFocus, SystemSendCard, UserEnterSetBot, BotSelectReward
CostumeManager
100줄
코스튬(의상) 뽑기/저장 — 코스튬 박스 개봉(seed 기반), 저장, 중복 시 주사위 환불.OpenCostumeBoxBySeed, SaveCostumeAsync
NicknameManager
121줄
닉네임 생성/변경 — 게스트/템플릿 닉네임 생성, PIN 부여, 닉네임 변경.GenerateGuestNicknameAsync, GenerateTemplateNicknameAsync, ChangeNicknameAsync
FormulaManager
156줄
보상 수식 엔진 — 테이블 정의 수식(TblFormula)을 람다로 컴파일해 파라미터(주사위 배수·친구 부스트 등)로 보상 금액 계산. (주로 다른 매니저 내부에서 사용)SetFormulaLambda, FormulaCalculate, MakeFormulaParameterDic
UserManager인증/계정 라이프사이클 — 실제 로그인 흐름, 계정 생성/연동, 게임데이터 리셋. → 8장AuthenticateAsync, CreateUserAccountAsync, UserGameDataResetAsync
AccountManager계정 DB 레코드 계층TblAccount get/create, 격리(quarantine)/복구, 계정타입 충돌, 계정 이전, 연령확인.GetAsync, QuarantineAsync, RecoverFromQuarantineAsync, TransferAsync
AuthManager
partial · 7파일
플랫폼 로그인 디스패처 — 3자 신원 검증. 베이스가 ProcessAsync, 각 partial이 프로바이더별 AuthenticateAsync. → 8장ProcessAsync, AuthenticateAsync(provider별)
StorePurchaseManagerIAP 검증 기록 — 실결제의 store 측 레코드 생성, 영수증→상품id+검증코드 해석. 보상 지급은 ShopManager에 위임.InitializeAsync, GetProductIdAndCodeAsync
SubscribeManager자동갱신 구독 — Apple/Google 구독 상태머신(시작·갱신·환불), 백그라운드 자동갱신 체크 루프. (게임서버 쪽이 authoritative)StartAutoRenewalSubscriptionCheckAsync, AppleSubscriptionAsync
LeaderBoardManager
55줄 · 매우 얇음
글로벌/국가 랭킹 — DB→Redis 머티리얼라이저 + 조회 2개. ServerNumber==1에서만 갱신.RefreshRankers, GetOverallRankers, GetCountryRankers
CouponManager
59줄 · 얇음
쿠폰 — 대량 발급·사용(리딤). 보상은 GameManager 경유.IssueAsync, UseAsync
RefreshManager설정/상수 테이블 핫리로드[Refreshable] 타입을 주기적으로 재로드(재배포 없이 밸런스 데이터 반영). 15초마다 체크 + Redis로 서버간 신호.InitializeAsync, RefreshAsync

💡 가장 먼저 파악할 매니저

★ 매우 중요 로 표시한 GameManager · SocialManager · ScenarioManager · MessengerManager 네 개는 게임의 심장부라 반드시 먼저 읽으세요. 여기에 보상 정산의 핵심 SyncManager(1,893줄)까지 더한 다섯이 대부분의 게임플레이 로직을 담고 있습니다.

반대로 이름은 거창하지만 얇은 것들도 있습니다 — LeaderBoardManager(55줄), CouponManager(59줄), CostumeManager(100줄). "이 기능 로직이 어디 있지?"는 대개 이 매니저들 중 하나입니다.

핸들러가 매니저를 쓰는 전형적 모습

ServiceRefreshEvent.cs — 오케스트레이션 예 (개념)
public async Task<ProtocolRes> ProcessAsync(HttpContext ctx, TblUser user, Protocol request)
{
    var res = new RefreshEventRes();
    // 여러 매니저의 정적 메서드를 병렬 호출 → 응답 조립
    res.Events        = EventManager.GetListActiveEvents();
    res.Currency      = await GameManager.GetUserCurrency(user);
    res.CardInventory = await CardManager.GetUserCardInventory(user, ...);
    res.ShopList      = await ShopManager.GetShopList(user, ...);
    return res;
}

🛠️ 요청 경로가 아닌 "운영/툴" 매니저

TableManager·TableUploadManager·TableValidationManager(스프레드시트 기반 게임데이터 업로드/검증), RoutineManager(스케줄 작업), ApiManager(관리자 브로드캐스트/킥)는 게임플레이 요청 경로가 아니라 운영 도구입니다. 처음엔 몰라도 됩니다.

08 / AUTH & SESSION

인증 & 세션

이 장의 핵심은 딱 하나입니다: 서버에는 완전히 다른 두 개의 비밀이 있다. 이 둘을 구분하면 인증 코드 전체가 술술 읽힙니다.

🔑 두 개의 비밀 — 이것만 기억하세요

  • AccountPass = 저장해두는 "비밀번호". 클라이언트 디스크에 저장되어 앱을 껐다 켜도 유지되는 장기 자격증명. 오직 /Auth* 호출에서만 사용.
  • Hash = "세션 쿠키/토큰". 랜덤 uint32로, 로그인할 때마다 새로 발급되고 모든 게임플레이 요청마다 검사되는 단기 토큰.

흐름 한 줄 요약: 클라가 /Auth*에서 AccountPass를 내고 새 Hash를 받아, 이후 세션 내내 그 Hash를 사용합니다.

정정 세션은 JWT가 아니다

게임 세션 토큰은 TblUser.Hash입니다. JWT는 Apple/Google/Facebook 3자 신원 토큰을 검증할 때만 프로바이더별 AuthenticateAsync 내부에서 쓰이고, 세션 토큰을 발급하지 않습니다.

먼저 정리: 유저를 가리키는 3가지 식별자

이 셋은 계속 헷갈리는데, 서로 다른 축입니다.

식별자타입 · 위치의미 (한 줄)
Suidlong · TblUser.Suid"어느 플레이어" — 영구·표준 유저 PK. 모든 게임 데이터가 이걸로 묶임. DB 자동증가 seq를 가역 암호화(SimpleEncrypter60)해 랜덤처럼 보이게 만든 값.
Duidstring · TblUser.Duid"어느 폰" — 기기 고유 id. 신원이 아니라 분석/DAU 집계·어뷰징 제한용. 한 기기에 여러 계정 가능.
AccountIdstring · TblAccount.AccountId"어느 구글/애플/페북 신원" — 프로바이더별 계정 id. 조회 키는 (accountType, accountId) 쌍. DB에 암호화 저장.

💡 인증의 본질

인증이 하는 일은 결국 프로바이더 AccountId → 올바른 Suid를 찾아주고, 새 Hash를 발급하는 것뿐입니다. 한 유저(suid)는 tblAccount 행을 여러 개 가질 수 있습니다 — 연동된 프로바이더 하나당 한 행.

AccountPass — 실체

AccountPass4-튜플 (AccountType, AccountId, AccountName, Password)을 구분자 #'%:로 이어붙인 뒤 XXTEA로 암호화한 불투명 문자열입니다. 서버 키(CfgServer.AccountPassEncKey)가 없으면 클라가 위조할 수 없습니다.

AccountPass.cs — 불투명 문자열 생성
public static string ToString(AccountType type, string id, string name, string password)
{
    id = Account.ToDecryptedId(id);
    var accountPass = $"{(int)type}#'%:{id}#'%:{name}#'%:{password}";
    var xxtea = new XXTea(CfgServer.Shared.AccountPassEncKey);
    return xxtea.EncryptString(accountPass);   // 클라에 주는 불투명 문자열
}

내부 데이터는 테이블 tblAccount(suid, accountType, accountId, accountName, password, ...)에서 조립됩니다. 여기서 password사람이 쓰는 비밀번호가 아니라 계정 생성 시 서버가 만든 랜덤 토큰(G.Util.Account.New, 8바이트 랜덤)입니다 — 이름만 password인 서버 시크릿이니 오해 주의.

💡 최초 실행 신호값

클라가 아직 계정이 없을 때는 AccountPass 자리에 매직 문자열 CfgServer.AccountPassToCreate("나 계정 없음, 만들어줘")를 보냅니다. 서버는 이걸 보고 신규 생성 분기를 탑니다.

AccountPass vs Hash — 역할 비교

AccountPassHash
타입암호화된 4-튜플 문자열uint32 (tblUser.hash)
수명장기 · 클라 디스크에 저장세션 1회 · 로그인마다 재발급
목적내가 누구인지 증명해 세션을 받음이 요청이 현재 세션 것인지 증명
검사 위치/Auth* 에서만모든 게임플레이 요청 (Gate)

💡 왜 둘로 나눴나?

Hash가 유출돼도 다음 로그인에 만료됩니다. 장기 비밀(AccountPass)은 매 요청이 아니라 인증 호출 때만 오가므로 노출이 적습니다. 또 로그인마다 Hash를 갈아끼우면 기존 세션을 끊을 수 있습니다(다른 기기 로그인 → 옛 Hash 불일치).

로그인 흐름 — 게스트 vs 소셜

모든 인증은 AuthManager.ProcessAsync(AccountType, ...) 하나로 모입니다. 프로바이더별 ServiceAuth*AccountType만 골라 위임합니다(None=게스트, GooglePlay, Facebook, AppleId, ...).

최초 실행 — 신규 게스트 생성

/AuthNone
AccountPass = ...ToCreate
AuthManager
신호값 감지 → 신규 분기
UserManager
.CreateUserAccountAsync
tblUser + tblAccount 생성
Hash·Suid·password 발급
응답
AccountPass · Suid · Hash
AuthManager.cs — 신규 게스트 분기
if (string.IsNullOrEmpty(authResult.AccountId))    // AccountPassToCreate 인 경우
{
    log.Debug($"=== Auth : Guest 신규 유저 생성");
    userAccount = await UserManager.CreateUserAccountAsync(
        req.Duid, req.Adid, req.Telecom, req.Market, req.Os, country, req.Language, currency, accountName: req.Nickname);
}

소셜 로그인은 다릅니다: 프로바이더별 AuthenticateAsync3자 토큰(JWT/OAuth)을 검증해 진짜 AccountId를 얻고, UserManager.AuthenticateAsync(accountType, accountId)로 기존 계정을 찾습니다. 있으면 그 유저를 로드해 Hash만 재발급, 없으면 신규 생성, 게스트→소셜 연동 상황이면 아래 연동 트리로 갑니다.

재접속 유저 — 전체 시퀀스

클라가 이전에 Suid·AccountPass·Hash를 저장해 둔 상태에서 앱을 다시 켰을 때.

저장된 자격증명 → 새 세션

  1. 앱 실행 → /Auth*클라

    저장한 AccountPass·Duid 전송. 이 호출은 해시 검사 안 함(IsHashCheckNeeded=false) — 해시를 발급하는 호출이라 순환이 되니까.

  2. AccountPass 검증AuthManager

    복호화 후 tblAccount 조회. 게스트는 ap.Password == account.Password 비교, 실패 시 AuthenticationFailed. 소셜은 3자 토큰 재검증.

  3. 새 Hash 발급UserManager

    user.Hash = new Randomizer().NextUInt32()user.SetAsync()로 저장.

  4. 응답서버

    res.AccountPass(갱신될 수 있음) · res.Suid · res.Hash(새 토큰) 반환.

  5. 클라 저장 → 이후 요청클라

    Suid+Hash를 모든 게임플레이 요청에 실어 보냄.

이후 매 요청 인증

클라는 SuidProtocolReq.Suid에, Hash요청 헤더 bytes 1–4(→ 5장)에 실어 보냅니다. 서버는 매 요청마다 유저를 로드해 해시를 대조합니다.

Service.cs — 유저 로드 & 세션 검증
if (handler.IsSuidNeeded) {
    user = await TblUser.GetAsync(suid, ...);      // Redis 우선 → MySQL 폴백
    if (user == null)
        throw new HaeginException(Result.UserNotExist, ...);
    if (hash != user.Hash && handler.IsHashCheckNeeded)   // 세션 검증
        throw new HaeginException(Result.AuthenticationExpired, ...);
}
return await handler.ProcessAsync(context, user, req);

💡 다른 기기 로그인 = 자동 무효화

재로그인 시 Hash가 새로 발급되므로, 기존 기기의 hashuser.Hash와 맞지 않아 AuthenticationExpired가 되고 클라는 다시 1단계(재인증)로 돌아갑니다. Hash 재발급 지점은 ① 신규 계정 생성 ② 게스트/소셜 재로그인 ③ 계정 이전(원본 세션 강제 종료)입니다.

계정 연동 · 이전 · 격리

  • 연동(게스트 → 소셜): LinkOption enum(None/Local/Auth/Link)으로 제어. 소셜 신원엔 계정이 없는데 로컬 게스트가 있고 None이면 서버가 AuthenticationNeedToLink를 던져, 클라가 사용자에게 물어본 뒤 Local(로컬 데이터 유지)/Auth(인증계정 데이터 사용)로 재시도합니다. 두 신원이 서로 다른 suidNeedToSelect/NeedToLogout로 선택을 요구.
  • 이전(TransferCode): 기기 간 계정 이동 장치는 있으나 Billionaire에서는 사실상 미사용(코드 주석 "안씀"). AccountManager.TransferAsync가 행을 옮기고, 원본 Hash를 갈아끼워 옛 기기를 즉시 로그아웃시킵니다 — Hash가 왜 필요한지 보여주는 예.
  • 격리(Quarantine): 장기 미접속 계정을 핫 테이블에서 QuarantineAccountDB로 보관. 로그인 시 AccountManager.GetAsyncAuthQuarantined를 던지면, 클라가 NeedToRecoverFromQuarantine=true로 재호출해 RecoverFromQuarantineAsync로 복구합니다.

⚠️ 헷갈리기 쉬운 이름들

  • tblAccount.password = 서버 랜덤 시크릿(사람 비밀번호 아님)
  • tblAccount.accountName(프로바이더 표시명) ≠ tblUser.nickname(인게임 닉네임)
  • AccountType10 이상 = deprecated(해제된) 링크. 쿼리에서 accountType < 10 = "활성 링크만"
  • ServiceAuth.cs에 주석 처리된 옛 코드가 있음 — 과거 설계 잔재이니 무시
09 / DATABASE

데이터베이스 계층

MySQL + Dapper(MySqlConnector 드라이버). 별도 Repository/ORM 매핑 레이어는 없고, Tbl* 엔티티에 정적 DAO 메서드가 붙은 Active-Record 스타일입니다(TblUser.GetAsync, user.UpdateAsync() 등).

연결 문자열은 "데이터 주도"

부트스트랩용 CommonDB 문자열만 AWS Secrets Manager에서 오고, 나머지 모든 DB의 연결 문자열은 DB 테이블 tblConnectionString에 저장되어 런타임에 로드됩니다. 재배포 없이 DB를 재지정할 수 있습니다.

TblConnectionString.cs — 라우팅 엔진
public static DbClient GetDbClient(string name) {
    if (dicClient.TryGetValue(name, out var dbClient) == false) {
        var connectionStrings = Find(name);        // tblConnectionString에서 로드
        dbClient = new MySqlClient(connectionStrings); // [0]=master, 나머지=slave
        dicClient.TryAdd(name, dbClient);
    }
    return dbClient;
}

DB 라우팅 & GameDB 샤딩

CommonDB
"Common"
SocialDB
"Social"
ConstDB
"Const"
StoreDB
"Store"
AccountDB
"Account" · TblUser
GameDB (샤딩)
"Game1" … "Game10"
= user.GameServerId
신규 유저 배정
gameServerRatio 가중 룰렛
GameDB.cs — 유저별 샤드 선택
public static DbConnection CreateConnection(TblUser user, bool isMaster) {
    var dbClient = TblConnectionString.GetDbClient($"Game{user.GameServerId}");
    return dbClient.CreateConnection(isMaster);   // isMaster=쓰기, false=읽기(slave)
}

쿼리 3가지 스타일

스타일용도
인라인 SQL + Dapper대부분의 CRUDQueryFirstOrDefaultAsync<T>(sql, p)
저장 프로시저(sp*)계정·결제 등 원자성 중요 로직spWithdrawProcess, spInsertProductPurchase
멀티 스테이트먼트upsert / INSERT+SELECTINSERT ... ON DUPLICATE KEY UPDATE

💡 커넥션 풀 & 페일오버

풀링은 MySqlConnector의 네이티브 풀(연결 문자열 기준)을 그대로 사용합니다. DB 오류 시 CheckAndClearPool(DbException)이 에러 코드를 보고 MySqlConnection.ClearPool()을 호출해 master/slave 토폴로지 변경 후 페일오버를 처리합니다. 읽기는 slave, 쓰기는 master(isMaster / !isReadOnly 불리언으로 분기).

10 / REDIS

Redis 활용

중앙 헬퍼는 정적 클래스 BnaireCommon/Redis.cs(~2100줄)이며, 3개의 논리적 인스턴스를 각각 다른 용도로 씁니다.

Redis.cs — 3개 커넥션
static Redis() {
    connAuth     = ConnectionMultiplexer.Connect(CfgServer.Shared.RedisAuth);     // 유저/메시지/랭킹/이벤트
    connResponse = ConnectionMultiplexer.Connect(CfgServer.Shared.RedisResponse); // 멱등성 응답 캐시
    connMisc     = ConnectionMultiplexer.Connect(CfgServer.Shared.RedisMisc);     // 범용 KV + 분산 락
}

connAuth

유저/소셜/이벤트

  • user:{suid} — 유저+핫 서브객체 Hash (TTL 1h)
  • message:{suid} — 관리자 메시지 List
  • kickTarget:{suid} — 강제종료 플래그
  • ALIVE:{시각} — DAU dedup Set
  • rank — 랭킹 Hash
  • djEvent:* — DJ 이벤트 상태

connResponse

멱등성

  • R:{suid}:{ticks} — 직렬화된 응답 바이트
  • TTL 600초
  • 재요청 시 그대로 replay → 11장

connMisc

범용 + 락

  • 범용 KV (GetFromMiscAsync)
  • CARD:{seq} — 환불 분산 락(SETNX)
  • 설정 새로고침 신호 List

💡 "Redis 세션"의 실체

authoritative한 Hash는 MySQL tblUser에 있지만, 매 요청 읽기가 Redis user:{suid}(field auth, MessagePack 직렬화, TTL 1h)를 먼저 거칩니다. 즉 "Redis 세션"은 사실 유저 레코드의 1시간 캐시입니다.

11 / IDEMPOTENCY

멱등성 & 중복 방지

중복 처리 방어는 중앙 1개 + 서비스별 임시방편 여러 개의 층으로 되어 있습니다.

① 중앙: 응답 리플레이 캐시

모든 요청은 고유 Ticks(nonce)를 가집니다. Gate는 서비스 실행 전에 R:{suid}:{ticks}를 조회해, 이미 있으면 저장된 응답 바이트를 그대로 반환하고 서비스를 재실행하지 않습니다. 네트워크 재시도로 인한 중복 제출을 10분간 막는 핵심 안전망입니다.

Gate.cs — 멱등성 게이트
var data = await LoadResponseDataAsync(req);   // Redis.GetResponseAsync(Suid, Ticks)
if (data == null) {
    // 최초 요청: 서비스 실행 → SendResponseAsync가 응답을 캐시에 저장
} else {
    // 재요청: 캐시된 응답 그대로 반환, 서비스 실행 안 함
    StressMonitor.UpdateRetry(...);
}

② 서비스별 방어 패턴

패턴메커니즘대표 위치
IAP 영수증 dedupDB unique insert의 affected-rows 확인. 0행이면 "이미 처리됨" → 보상 지급 안 함spInsertProductPurchase
구매 State 머신Initialized→Consumed, Duplicated 등. Initialized일 때만 보상 처리StorePurchase.State
"이미 수령" 플래그RewardedAt != null이면 거부 (광고/출석/컬렉션)ServiceAdReward
SETNX 분산 락StringSet(..., When.NotExists)Redis.TryRefundLock

💡 When.NotExists 란? (= Redis SET ... NX)

StackExchange.Redis의 저장 옵션으로 "키가 아직 없을 때만 저장하라"는 뜻입니다. 두 가지가 핵심:

  • 반환값으로 승자 판별 — 저장 성공(true)이면 "내가 방금 만듦 = 락 획득", 실패(false)면 "이미 있음 = 남이 먼저 잡음". 그래서 if (ok) { 나만 실행 }으로 씁니다.
  • 검사+저장이 원자적 — "있는지 보고 없으면 저장"이 쪼갤 수 없는 한 연산이라, 동시에 와도 정확히 한 명만 true를 받습니다. (KeyExistsStringSet을 따로 하면 둘 다 "없음"을 보고 둘 다 저장하는 경쟁 상태가 생김.)

보통 TTL을 함께 걸어(예: 10분) 처리 중 죽어도 락이 영구히 안 풀리는 것을 막습니다. 이름은 SETNX(SET if Not eXists) 패턴으로도 불립니다.

Redis.cs — TryRefundLock (SETNX 락 예시)
// 같은 카드 환불 메시지를 한 번만 처리하도록 잠금
var ok = await db.StringSetAsync($"CARD:{messageSeq}", "locked",
                                 TimeSpan.FromMinutes(10), When.NotExists);
return ok;   // true = 내가 락 획득(처리 진행), false = 이미 처리 중/처리됨(건너뜀)
ServiceConsumeGoogleReceipt.cs — IAP dedup
var isInserted = await CommonDB.InsertProductPurchaseAsync(
    user.SuidValue, gcp.OrderId, priceId, productId, ...);
if (isInserted) {
    // 최초 주문 → 보상 지급 (ShopManager.ProcessProductReward)
} else {
    gcp.ConsumptionState = ConsumptionState.Duplicated;  // 중복 → 지급 안 함
}

보충 — "유저 처리 중 락이 없다"는 게 무슨 뜻?

먼저 용어부터. 락(lock) / 뮤텍스(mutex)는 "같은 자원을 동시에 건드리지 못하게 한 명씩 줄 세우는 잠금장치"입니다. 많은 게임서버는 한 유저의 요청을 한 번에 하나씩만 처리하도록 per-user 락을 겁니다. 왜 필요할까요?

💥 락이 없을 때 생기는 문제 — 경쟁 상태(race condition)

유저가 버튼을 빠르게 두 번 누르거나, 클라가 요청 두 개를 거의 동시에 보내면, 두 요청이 겹쳐서(interleave) 실행될 수 있습니다. 예를 들어 재화 100을 쓰는 요청 A·B가 동시에 오면:

동시 실행 시 (락이 없으면)
A: 잔액 읽기 → 100      B: 잔액 읽기 → 100      // 둘 다 100을 봄
A: 100 >= 100 통과       B: 100 >= 100 통과       // 둘 다 "쓸 수 있다" 판단
A: 잔액 = 0 저장         B: 잔액 = 0 저장         // 100으로 200어치를 씀 → 중복 지급/이중 사용

per-user 락이 있으면 B는 A가 끝날 때까지 기다렸다가 실행되어, B는 "잔액 0"을 보고 정상적으로 거부됩니다.

이 서버에는 그런 범용 per-user 락이 없습니다. 대신 두 가지 부분적 안전장치에만 의존합니다:

  • (a) 응답 리플레이 캐시완전히 똑같은 재요청(같은 Suid+Ticks)만 막아줍니다. 위 예시처럼 서로 다른 두 요청이 동시에 오는 경우는 막지 못합니다. 게다가 유효기간이 600초뿐입니다.
  • (b) 기능별 When.NotExists 가드 — 개발자가 일부러 넣어둔 기능(카드 환불, DJ 이벤트 보상 등)에만 있습니다. 가드를 넣지 않은 나머지 기능은 보호되지 않습니다.

⚠️ 그래서 신입이 조심할 점

  • "요청은 유저별로 한 줄로 처리된다"고 가정하지 마세요. 그런 보장이 없습니다.
  • 유저의 공유 상태(재화·인벤토리·카운트 등)를 바꾸는 핸들러를 새로 만들거나 고칠 때, 중복/동시 실행이 해로우면 스스로 가드를 추가해야 합니다 — DB unique insert, When.NotExists 락, 또는 "이미 처리됨" 상태 플래그(→ 이 장 ② 서비스별 방어 패턴).
  • 600초 지난 재요청은 캐시 미스가 나서 핸들러가 다시 실행됩니다. 오래 지연된 재시도가 보상을 두 번 주지 않도록, 시간에 의존하지 않는 방어(위 상태 플래그 등)가 필요합니다.
12 / ERRORS

에러 처리 & 결과 코드

전역 예외 필터/미들웨어는 없습니다. Gate.InvokeAsync 전체를 감싼 단 하나의 try/catch가 사실상의 전역 핸들러입니다. 진행 단계를 Step enum(None→HeadRead→Decrypted→Deserialized→Service→Completed)으로 추적해, 실패 지점을 결과 코드로 매핑합니다.

Gate.cs — catch 블록
var response = new ErrorRes();
if (ex is HaeginException hex) {
    response.Result = hex.Result;             // 비즈니스 에러 → 그대로 전달
} else {
    log.Error(ex);
    switch (step) {                          // 인프라 에러 → 단계로 코드 결정
        case Step.None:      response.Result = Result.ProtocolFormatError; break;
        case Step.HeadRead:  response.Result = Result.DecryptionFailed;    break;
        case Step.Decrypted: response.Result = Result.DeserializationFailed; break;
        default:            response.Result = Result.UnknownError;        break;
    }
}

3가지 신호 방식

  • 성공 — 구체 XxxRes를 반환, Result는 기본값 OK(0).
  • 비즈니스 에러 — 보통 서비스 내부에서 HaeginException을 catch해 res.Result에 대입(정상 응답 객체로 반환).
  • 치명적/예상외HaeginException이 아닌 예외를 Gate까지 전파 → ErrorRes.

Result enum은 OK=0, 나머지는 음수이며 도메인별로 묶여 있습니다(인프라 -11~-16, 리워드 -33~-35, IAP -2000번대, 쿠폰 -3000번대 등). AuthenticationExpired·MaintenanceNow는 고빈도라 에러 로그를 남기지 않습니다.

💡 서비스별 실제 관례

대부분의 서비스는 자신의 HaeginException을 내부에서 잡아 res.Result로 변환합니다. Gate까지 올라오는 건 대개 잡히지 않은 비-Haegin 예외뿐입니다.

13 / LOGGING

로깅

서로 다른 3개 채널이 있습니다. NLog는 밑단 전송 계층이고, BnaireLog는 그 위의 강타입 분석 스키마 계층입니다(별개 시스템이 아니라 층위 관계).

채널용도목적지
NLog운영/에러 로그 (log.Error(ex))콘솔 + ConsoleLog.*.log
BnaireLog → Snowflake분석/감사 이벤트 (신규유저·결제·주사위 등)*.sflog → Kinesis → S3 → Snowflake
ProtocolHist요청/응답 감사 추적 (제외목록 외 전부)DB TblUserProtocolHist (비동기)
BnaireLog 사용 패턴
// 기동 시 1회 초기화 (Program.cs)
Log.Initialize(CfgServer.Shared.SnowflakeLogDatabase,
               CfgServer.Shared.SnowflakeLogger, CfgServer.Shared.ServerMode);

// 어디서든 fire-and-forget 로 이벤트 기록
_ = new LogDiceRoll { Suid = user.SuidValue, DiceNumber = n }.InsertAsync();

💡 로그 스키마는 코드에서 생성된다

프로토콜과 반대로, 로그는 C# 클래스가 원본이고 genLogSchema.shClassScanner가 클래스를 리플렉션해 JSON 스키마를 만들어 S3에 올립니다. 이 스키마가 다운스트림 Parquet/Snowflake 테이블 모양을 결정합니다. InsertAsync는 자체 예외를 삼키므로 로그 실패가 요청을 깨뜨리지 않습니다.

14 / DEPLOY

배포 & 환경

단일 파일 self-contained 실행 파일(linux-x64)로 publish 후 deploy.sh로 대상 호스트에 배포하고 systemctl로 관리합니다.

배포 명령
./deploy.sh bnaireS        # 스테이징
./deploy.sh bnaire-game-1  # 프로덕션 (AWS)
환경호스트비고
스테이징bnaireS / bnaireC테스트
프로덕션bnaire-game-1 / -2AWS ARM64, 베스천 경유
QAbnaireQ

💡 설정의 출처 (3단계)

  • AWS Secrets Manager — 부트스트랩 CommonDB 문자열
  • DB 테이블 cfgServer — Redis 주소, 로거명, 클라 키 등 대부분의 런타임 설정([Refreshable])
  • 파일appsettings.json(로깅만), GameIndividual.config, NLog.config

점검 모드는 CfgServer.MaintenanceNow로 켜지며, AdminAcl에 없는 요청은 MaintenanceNow로 거부됩니다.

15 / RISKS

리스크 & 주의사항

인수인계 시 특히 조심해야 할, 코드에서 관찰된 함정과 취약 지점 모음입니다. 유지보수 모드이므로 "당장 고쳐라"가 아니라 "건드릴 때 알고 있어라"의 관점입니다.

보안 SQL 인젝션 가능 지점

일부 쿼리가 파라미터 바인딩 대신 문자열 보간으로 값을 SQL에 직접 넣습니다. 예: TblUser.GetWithDuidAsyncWHERE `duid` = '{duid}', SearchWithNicknameAsyncLIKE '%{nickname}%'. 해당 입력 경로를 만질 때 반드시 DynamicParameters로 바꾸세요.

정합성 리워드/결제에 스패닝 트랜잭션 부재

DbTransaction 래퍼는 있으나 리워드/결제 로직에서 거의 안 쓰이고, 다단계 쓰기가 각각 별도 커넥션/SP로 실행됩니다. 예를 들어 InsertProductPurchaseAsync 성공 후 ProcessProductReward 도중 크래시가 나면 원자적으로 롤백되지 않습니다. 정합성은 응답 캐시·영수증 unique insert·구매 State에 의존합니다.

동시성 핸들러 싱글톤 & per-user 락 없음

① 핸들러는 싱글톤이다.IService는 서버 기동 시 딱 하나만 생성되어(→ 6장), 이후 모든 유저의 모든 요청이 그 하나의 인스턴스를 공유합니다. 즉 서로 다른 유저의 요청들이 동시에(여러 스레드에서) 같은 객체의 ProcessAsync를 호출합니다.

그래서 인스턴스 필드에 요청/유저 상태를 저장하면 안 됩니다. 그 필드는 사실상 전역 공유 변수라, 동시 요청끼리 서로 값을 덮어씁니다:

❌ 이렇게 하면 안 됨
[ProtocolHandler]
public class ServiceX : IService {
    private TblUser _user;   // ❌ 싱글톤의 필드 = 모든 요청이 공유
    public async Task<ProtocolRes> ProcessAsync(HttpContext ctx, TblUser user, Protocol req) {
        _user = user;              // 유저 A가 세팅한 직후...
        await SomethingAsync();     // ...await 중 유저 B가 _user를 덮어씀
        return Build(_user);      // A인데 B 데이터로 응답 → 데이터 오염
    }
}
✅ 올바른 방법 — 상태는 지역 변수/파라미터로만
public async Task<ProtocolRes> ProcessAsync(HttpContext ctx, TblUser user, Protocol req) {
    var myReq = (XReq)req;       // 지역 변수는 호출마다 독립 → 안전
    await SomethingAsync();
    return Build(user);          // 넘겨받은 파라미터만 사용
}

② 범용 유저 락이 없다. 한 유저의 요청을 한 번에 하나씩 줄 세우는 잠금장치가 없어서, 동시/지연 재요청이 겹쳐 실행되면 이중 지급 같은 경쟁 상태가 날 수 있습니다(멱등성 캐시 TTL 600초 밖은 특히). 무엇이 문제이고 어떻게 방어하는지는 → 11장 "유저 처리 중 락이 없다"에서 예시와 함께 설명합니다.

암호화 정적 키 + 평문 전달

단일 전역 XXTEA 키를 핸드셰이크가 평문으로 클라에 내려줍니다. 진정한 세션키 교환/로테이션이 아니며, 세션별 변주는 user.Hash XOR(모드 2)뿐입니다(→ 5장).

유지보수 수동 동기화가 필요한 곳

  • ProtocolId enum ↔ [Union] 테이블 (프로토콜 추가 시)
  • 미사용 설정: RedisSession, RedisAdmin 필드는 존재하나 어디서도 참조되지 않음(혼동 주의)

관례 ASP.NET 상식이 안 통하는 곳

DI 컨테이너·미들웨어·컨트롤러·모델 바인딩이 없습니다. 모든 것은 Gate.cs 수동 처리와 ProtocolId 딕셔너리 라우팅입니다. 프레임워크 관례를 기대하지 말고 Gate.csService.csService*.cs 순서로 코드를 따라가세요.

16 / RETROSPECTIVE

회고 — 일반 ASP.NET과 무엇이 다른가

이 서버를 "웹 API"로 생각하고 들어오면 거의 모든 관례가 뒤집혀 있어 당황합니다. 하지만 대부분은 실수가 아니라 라이브 모바일 게임서버라는 도메인의 트레이드오프입니다. 무엇을 얻으려고 무엇을 포기했는지 정리합니다.

표준 ASP.NET Core vs 이 서버

영역일반 ASP.NET Core이 서버
의존성DI 컨테이너 주입정적 클래스 + Program.cs 수동 초기화
요청 처리미들웨어 파이프라인Gate.cs 단일 try/catch
라우팅컨트롤러 · 어트리뷰트 라우팅ProtocolId → IService 딕셔너리(리플렉션)
통신REST + JSON단일 /Gate + MessagePack 바이너리 + XXTEA/Zstd
DBEF Core (LINQ · 마이그레이션)Dapper + Active-Record 정적 DAO
설정appsettings.json · IOptionsDB 테이블 cfgServer + AWS Secrets
로깅ILogger 추상화NLog + 자체 BnaireLog(→Snowflake)
테스트DI로 목킹 용이정적 의존성으로 목킹 어려움

✅ 잘한 것 — 규모와 운영을 위한 설계

  • 단일 게이트웨이 + ProtocolId 라우팅: 약 250개 프로토콜이 파일 단위로 격리돼 추가/삭제가 서로를 건드리지 않음. 대규모에 강한 구조.
  • 멱등성을 게이트에 중앙화(R:{suid}:{ticks}): 개별 서비스가 신경 쓰지 않아도 모든 프로토콜이 네트워크 재시도에 자동으로 안전.
  • Redis-우선 캐싱: 유저 핫데이터를 캐시해 DB 부하를 크게 줄임 — 모바일 트래픽 패턴에 적합.
  • 데이터 주도 인프라: tblConnectionString 동적 라우팅 + GameDB 샤딩 + [Refreshable] 핫리로드 → 재배포 없이 스케일·밸런스 조정·페일오버.
  • 프로토콜 단일 소스(공유 프로젝트): 클라-서버 ID 불일치를 원천 차단.
  • 결제 dedup + 관측성: DB unique insert 기반 영수증 중복 방지와 StressMonitor 프로토콜별 성능 가시성은 실전에서 검증된 견고한 선택.

⚠️ 아쉬운 것 — 정합성과 유지보수성

  • 리워드/결제 다단계에 스패닝 트랜잭션 부재: 부분 실패 시 원자적 롤백이 없음. 가장 큰 데이터 정합성 리스크(→ 15장).
  • 일부 쿼리의 문자열 보간: SQL 인젝션 가능 지점.
  • 테스트 용이성: DI 미사용 + 정적 매니저 + 거대 파일(GameManager 4,711줄)로 단위 테스트·모킹이 어렵고 인지 부하가 큼.
  • 사람 실수 여지: ProtocolId[Union] 수동 동기화, 죽은 코드·미사용 설정 잔재.
  • 동시성: per-user 락이 없어 멱등성 TTL(600초) 밖의 동시/재요청은 취약.

🧭 특이한 것 — "왜 이렇게 했지?" 싶은 지점

  • ASP.NET을 얇은 바이너리 리스너로만 사용. DI·미들웨어·컨트롤러·모델바인딩을 의도적으로 버리고, 게임서버 관례를 .NET 위에 얹었습니다.
  • 세션 토큰이 암호화 키를 겸함: 모드 2에서 hash ^ key — 인증과 전송 암호화가 한 값으로 결합.
  • Suid = 가역 암호화된 auto-increment. 랜덤처럼 보이지만 복호 가능한 순번.
  • 응답만 압축(요청은 비압축) — 방향 비대칭.
  • 로그는 코드→스키마 생성(역방향)인데 프로토콜은 코드가 원본. 코드젠 방향이 서로 반대.
  • /Monitor가 헬스체크가 아니라 관리자용 HTML 대시보드.
  • 핸들러 싱글톤 + 정적 매니저 → 상태가 거의 없는 절차적/함수형 스타일(전형적 OOP 상태 보관이 드묾).

📝 신입에게 — 한 문단 요약

이 서버의 "이상함" 대부분은 낮은 지연 · 높은 처리량 · 좁은 모바일 대역폭 · 수백 개 프로토콜을 감당하려고 프레임워크의 편의를 포기한 결과입니다. 그러니 ASP.NET 상식을 기대하지 말고 이 코드베이스의 관례(Gate.csService.csService*.cs → 매니저)를 그대로 따르세요. 다만 트랜잭션 부재·SQL 인젝션·테스트 용이성 세 가지는 "도메인 특성"이 아니라 실제 개선 여지가 있는 부분이라고 보면 됩니다.

APPENDIX A / CONCURRENCY

부록 · 동시성 제어는 보통 어떻게 하나

이 서버엔 범용 per-user 락이 없습니다(→ 11장). 그럼 일반적으로 서버에서 유저 동시성을 어떻게 다루는지, 그리고 이 프로젝트(Orleans 미사용 · 유지보수 모드)에서 현실적인 선택은 무엇인지 정리합니다. 참고로 클라이언트 측 방어(버튼 잠금·요청 1개 제한)는 흔한 실수는 줄여주지만, 변조 클라·네트워크 재전송·멀티 디바이스는 못 막으므로 정합성 보장선이 아니라 UX 최적화로 봐야 합니다.

🧩 먼저: "동시성 문제"는 사실 세 가지다 (도구가 각각 다름)

  • ① 갱신 유실(lost update) — 두 요청이 같은 값을 읽고 각자 덮어써서 하나가 사라짐.
  • ② 불변식 위반(invariant violation) — 잔액이 음수가 되거나 구매 한도를 넘김.
  • ③ 중복 적용(duplicate application) — 개별적으론 유효한 같은 동작이 두 번 반영됨(보상 이중 지급 등).

은탄환은 없습니다. 아래 방법들은 각각 다른 문제를 겨냥합니다.

대표 접근법과 이 프로젝트 적용성

방식핵심 / 겨냥하는 문제이 프로젝트 적용성
A. per-user 분산 락
Redis SETNX / Redlock
게이트에서 lock:user:{suid}를 잡고 처리 후 해제 → 유저 요청을 직렬화. ①②③ 모두 커버.✅ 아키텍처 안 바꾸고 게이트/핸들러 한 곳에만 추가 가능 — 가장 덜 침습적. 단 유저당 처리량 병목·락 해제 누락 주의.
B. 액터/그레인 모델
Orleans · Akka.NET
유저 1명 = 액터 1개, 요청을 한 큐로 순차 처리 → 경쟁이 구조적으로 불가능. 게임서버의 "정석".Orleans 미사용 + 전면 아키텍처 개편 필요 → 유지보수 모드에선 비현실적. (참고용)
C. 낙관적 동시성(CAS)
version 컬럼
UPDATE ... WHERE version=@v, 충돌 시 재시도. 갱신 유실 방지.△ 특정 테이블에 version 추가 시 가능. 재시도 로직 필요.
D. DB 원자적 조건부 갱신 / 트랜잭션UPDATE bal = bal - @c WHERE bal >= @c(0행이면 실패), 트랜잭션으로 다단계 묶기. 불변식 위반 방지.✅ 재화/결제에 권장. 단, ③ 중복 적용은 못 막음(아래 참고).
E. 멱등성 키 / 상태 플래그요청 고유 id·unique 제약·"이미 처리됨" 플래그로 재실행 무효화. 중복 적용 방지.✅ 이미 부분 사용 중 — R:{suid}:{ticks} 응답 캐시, IAP unique insert.

⚠️ D(원자적 조건부 갱신)만으로 부족한 이유

UPDATE bal = bal - 100 WHERE bal >= 100잔액이 음수가 되는 건 막지만, 잔액이 충분한 상태에서 같은 구매 요청이 두 번 들어오면 둘 다 조건(bal >= 100)을 만족해 둘 다 성공합니다 → 이중 구매/이중 차감. 즉 D는 "잘못된 상태"는 막아도 "중복 적용"은 못 막습니다.

그래서 실무에선 D(불변식) + E(멱등성)를 함께 씁니다 — 예: unique 제약이 걸린 "구매/보상 이력 행"을 먼저 INSERT하고(중복이면 실패=③ 방어), 성공했을 때만 원자적 UPDATE로 재화를 차감(② 방어). 이 서버의 IAP dedup이 바로 이 조합입니다(→ 11장 ②).

✅ 이 프로젝트에 권장하는 현실적 답 (유지보수 모드)

  • 전면 개편(B)은 하지 않는다. Orleans도 없고 비용 대비 효과가 안 맞습니다.
  • 돈·보상·구매처럼 "틀리면 손해"인 경로에만 골라서 D+E 조합을 적용: ① unique 제약/상태 플래그로 중복 차단 → ② 원자적 조건부 UPDATE로 불변식 보호.
  • 범용 직렬화가 꼭 필요한 특정 핸들러엔 A(게이트 SETNX per-user 락)를 국소적으로 추가.
  • 클라 측 방어는 유지하되 그것만 믿지 않는다. 서버가 최종 방어선.