서버 개발 인수인계 · Server Handover
BnaireGameServer 서버 구조 & 동작 가이드
Billionaire Royale Club 모바일 게임의 게임서버. 클라이언트 통신이 어떻게 들어와 어떻게 처리되는지, DB·Redis·암호화·멱등성·에러 처리까지 — 실제 코드와 함께 짚어봅니다.
개요 & 한눈에 보기
이 서버는 Unity 클라이언트의 모든 게임 통신을 단일 엔드포인트 /Gate 로 받는 게이트웨이 구조입니다. 요청은 바이너리(MessagePack)로 직렬화되고, XXTEA로 암호화되며, 프로토콜 ID에 따라 알맞은 서비스 핸들러로 라우팅됩니다.
전체 아키텍처 한 장 요약
기술 스택
| 영역 | 기술 / 라이브러리 | 버전 |
|---|---|---|
| 런타임 | .NET 9.0 · ASP.NET Core (Minimal API) | net9.0 |
| 직렬화 | MessagePack-CSharp | 3.1.0 |
| 암호화 | XXTEA (자체 구현, BnaireG/Util/XXTea.cs) | — |
| 압축 | ZstdSharp.Port (응답 전용) | 0.8.6 |
| DB | MySqlConnector + Dapper | 2.4.0 / 2.1.66 |
| 캐시 | StackExchange.Redis | 2.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에서 읽어 캐시에 채웁니다.
프로젝트 구조
솔루션 BnaireGameServer.sln의 메인 프로젝트는 BnaireGameServer.csproj 하나지만, 실제 코드의 상당 부분은 4개의 공유 프로젝트에서 옵니다. 이들은 NuGet/DLL 참조가 아니라 Visual Studio 공유 프로젝트(.shproj / .projitems)로, 소스 파일이 서버 어셈블리에 그대로 컴파일됩니다.
<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 |
BnaireCommon | DB 접근 + 비즈니스 로직. 각 DB 라우터·Tbl* 엔티티·매니저·Redis·외부연동 | BnaireServer/BnaireCommon |
BnaireG | 저수준 엔진 유틸. DB 커넥션 엔진(G.Database/G.MySql), XXTEA, JWT, AWS | BnaireServer/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 등)로 캡슐화됩니다.
서버 기동 (Program.cs)
Program.Main은 웹서버를 켜기 전에 긴 초기화 시퀀스를 순서대로 실행합니다. 이 순서 자체가 의존성 순서라 이해해 두면 좋습니다.
// 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도 미들웨어도 없다
RunWebServer는 WebApplication.CreateBuilder 후 서비스 등록도, app.UseXxx() 미들웨어도 없이 곧바로 Minimal API 라우트만 매핑합니다. 각 핸들러는 IWebHandler(정적 InvokeAsync)를 구현한 클래스입니다.
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 딕셔너리가 대신합니다.
요청 처리 전체 흐름 ★ 핵심
클라이언트가 POST /Gate로 보낸 바이너리 한 덩어리가 응답으로 돌아가기까지의 전 과정입니다. 이 장만 이해하면 서버의 절반은 이해한 것입니다.
요청 생애주기 (Gate.InvokeAsync)
- HTTP 바디 수신Gate.cs
MemoryPool에서 버퍼를 빌려ReadExactlyAsync로 읽음(10초 타임아웃).OPTIONS는 CORS 204로 조기 종료. - 5바이트 헤더 파싱Gate.cs
span[0]= 암호화 모드(0/1/2),span[1..4]= 세션hash. 페이로드는 offset 5부터. - 복호화 + 역직렬화Gate.cs
모드에 따라 XXTEA 복호 후
MessagePackSerializer.Deserialize<ProtocolReq>. Union이ProtocolId에 맞는 구체 Req 타입으로 자동 복원. - 검증 게이트Gate.cs
프로토콜별 최소 암호화 레벨 확인 → 점검(maintenance) 모드 확인.
- 멱등성 캐시 조회Redis
Redis.GetResponseAsync(Suid, Ticks). 히트 시(=재요청) 저장된 응답을 그대로 반환하고 서비스는 실행하지 않음. - 서비스 라우팅Service.cs
Service.ProcessAsync→dicHandler[ProtocolId]로IService핸들러 조회. - 유저 로드 + 인증Service.cs → Redis/MySQL
IsSuidNeeded면TblUser.GetAsync(Redis→MySQL).IsHashCheckNeeded면hash == user.Hash검증. - 핸들러 실행Service*.cs
handler.ProcessAsync(context, user, req)→ 비즈니스 로직 수행 후ProtocolRes반환. - 부가 기록 (비동기)Task.Run
제외 목록이 아니면 요청/응답을
TblUserProtocolHist에 fire-and-forget 저장.StressMonitor타이밍 기록. - 직렬화 → 압축 → 암호화 → 전송Gate.cs
MessagePack 직렬화 → (선택)Zstd 압축 → (선택)XXTEA 암호화 → 1바이트 모드 헤더 prepend → 응답 캐시에 저장 후 전송.
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 호출 자체가 없음). 압축은 서버→클라 단방향.
통신 프로토콜 (BnaireProtocol)
메시지는 MessagePack 특성을 붙인 손으로 작성한 C# 클래스가 원본(source of truth)입니다. 요청/응답은 각각 추상 베이스 Protocol / ProtocolRes를 상속하고, [Union] 다형성으로 하나의 Deserialize 호출이 올바른 구체 타입으로 복원됩니다.
[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; // 실제 페이로드 (다형성)
}[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
mpc가Protocol/스캔- 포매터 리졸버 생성
- 메시지는 생성 X, 리졸버만 생성
FOOTGUN Union 테이블은 수동 동기화
새 프로토콜 추가 시 ProtocolId enum과 [Union(n, typeof(...))]를 사람이 직접 일치시켜야 합니다. 빠뜨리면 역직렬화가 조용히 실패합니다. (유지보수 모드라 신규 추가는 드물지만 알아두세요.)
암호화 & 압축
와이어 포맷의 맨 앞 5바이트는 평문 헤더이고, 그 뒤가 (선택적으로 암호화된) 페이로드입니다.
요청 와이어 포맷
0 / 1 / 2
(세션 해시)
XXTEA 3가지 모드
| 모드 | 키 | 의미 |
|---|---|---|
| 0 | — | 암호화 없음 (평문). 예: Handshake2 |
| 1 | key[i] | 서버 전역 정적 키 |
| 2 | hash ^ 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, ShopCheckPurchase 등 NotCompressibleProtocols)은 제외됩니다.
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 (압축 해제 단계 없음)
IService 패턴 & 라우팅
모든 게임 기능은 IService를 구현하고 [ProtocolHandler] 특성을 붙입니다. 서버 기동 시 리플렉션으로 이들을 모아 ProtocolId → 핸들러 딕셔너리를 만듭니다.
public interface IService
{
ProtocolId ProtocolId { get; }
bool IsSuidNeeded { get; } // 인증된 유저 로드 필요?
bool IsHashCheckNeeded { get; } // 세션 해시 검증 필요?
Task<ProtocolRes> ProcessAsync(HttpContext context, TblUser user, Protocol request);
}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/로컬 변수로만 다루세요.
[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;
}
}매니저 클래스
서비스 핸들러(Service*.cs)는 얇은 "오케스트레이터"입니다. 실제 비즈니스 로직은 매니저 클래스에 모여 있고, 핸들러는 요청을 캐스팅해 매니저의 정적 메서드들을 호출(자주 Task.WhenAll로 병렬)한 뒤 결과를 응답에 담습니다.
핸들러 → 매니저 → 데이터 (레이어)
⚠️ 매니저 ↔ 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 |
SyncManager1,893줄 | 게임 진행 동기화 & 보상 정산의 핵심 — 클라 시나리오(주사위 이동/타일/부스터)를 동기화하고 보상 집계·재화 반영·주사위 환불·랭킹/힘(Power) 스탯 처리. (인스턴스+정적 하이브리드: sync당 누적 상태 보유) | SyncScenario, SetSyncResult, FinishHeistAsync, AddRefundDice |
MessengerManager ★ 매우 중요1,431줄 | 인게임 메신저/알림 — 카드 공격/피격·친구 성립·이모티콘·초대·공지/선물 등 소셜 이벤트 알림(notification·foreground message)을 생성·등록. | RegisterNotification, CreateAttackNotification, SendGiftMessage, SendNoticeToAllUsers |
CardManager | 수집형 카드 — 카드 획득·팩/뽑기·유저간 카드 전송/교환·번들/시즌 완성·인벤토리. | AddCardAsync, DeliverOrExchangeCard, CheckBundleRewardableAsync |
EventManagerpartial | 라이브/기간 이벤트 — 랭킹·수집·협동·미니게임 이벤트 라이프사이클. 활성 이벤트 인메모리 캐시 보유(비-stateless). | RefreshActiveEvents, GetListActiveEvents, InitUserEventProgress |
ShopManager | 인게임 상점 — 상품 번들·구매 자격·구매횟수/한도·구매→보상 변환. 보상 생성은 GameManager.GetRewardItems에 위임. | ProcessProductReward, GetShopList, UpdateUserShopBuyCount |
BingoManager427줄 | 빙고 게임 핵심 로직 — 보드 동기화·빙고 라인 판정·히든 보상·진행/보상 적용·NPC 픽. | SyncBingo, BingoCheck, HandleHiddenRewardsAsync, NpcPick |
MiniGameManager357줄 | 미니게임 초기화 허브 — 주로 칵테일(Cocktail)·빙고 상태 세팅, 미니게임 게이지 포인트·칵테일 평판/NPC 관리. | InitCocktail, InitBingo, GetCocktailInfo, ModifyMiniGameGaugePoint |
HeistManager380줄 | 금고털이/약탈(steal) 보상 계산기 — steal power·사보타지 보상·금고해제(safecracking) 금액/보너스 레벨을 RNG로 산출. | GetSafecrackingRewardMoneyAsync, GetStealPowerMultipleAsync, PickSafecrackingBonusLevel |
DjEventManager381줄 | DJ 파티 이벤트 — 카드를 주고받는 파티 이벤트의 봇/시스템 자동화(카드 포커스 선택·시스템 발송·봇 입장/보상). | PickDjEventCardFocus, SystemSendCard, UserEnterSetBot, BotSelectReward |
CostumeManager100줄 | 코스튬(의상) 뽑기/저장 — 코스튬 박스 개봉(seed 기반), 저장, 중복 시 주사위 환불. | OpenCostumeBoxBySeed, SaveCostumeAsync |
NicknameManager121줄 | 닉네임 생성/변경 — 게스트/템플릿 닉네임 생성, PIN 부여, 닉네임 변경. | GenerateGuestNicknameAsync, GenerateTemplateNicknameAsync, ChangeNicknameAsync |
FormulaManager156줄 | 보상 수식 엔진 — 테이블 정의 수식(TblFormula)을 람다로 컴파일해 파라미터(주사위 배수·친구 부스트 등)로 보상 금액 계산. (주로 다른 매니저 내부에서 사용) | SetFormulaLambda, FormulaCalculate, MakeFormulaParameterDic |
UserManager | 인증/계정 라이프사이클 — 실제 로그인 흐름, 계정 생성/연동, 게임데이터 리셋. → 8장 | AuthenticateAsync, CreateUserAccountAsync, UserGameDataResetAsync |
AccountManager | 계정 DB 레코드 계층 — TblAccount get/create, 격리(quarantine)/복구, 계정타입 충돌, 계정 이전, 연령확인. | GetAsync, QuarantineAsync, RecoverFromQuarantineAsync, TransferAsync |
AuthManagerpartial · 7파일 | 플랫폼 로그인 디스패처 — 3자 신원 검증. 베이스가 ProcessAsync, 각 partial이 프로바이더별 AuthenticateAsync. → 8장 | ProcessAsync, AuthenticateAsync(provider별) |
StorePurchaseManager | IAP 검증 기록 — 실결제의 store 측 레코드 생성, 영수증→상품id+검증코드 해석. 보상 지급은 ShopManager에 위임. | InitializeAsync, GetProductIdAndCodeAsync |
SubscribeManager | 자동갱신 구독 — Apple/Google 구독 상태머신(시작·갱신·환불), 백그라운드 자동갱신 체크 루프. (게임서버 쪽이 authoritative) | StartAutoRenewalSubscriptionCheckAsync, AppleSubscriptionAsync |
LeaderBoardManager55줄 · 매우 얇음 | 글로벌/국가 랭킹 — DB→Redis 머티리얼라이저 + 조회 2개. ServerNumber==1에서만 갱신. | RefreshRankers, GetOverallRankers, GetCountryRankers |
CouponManager59줄 · 얇음 | 쿠폰 — 대량 발급·사용(리딤). 보상은 GameManager 경유. | IssueAsync, UseAsync |
RefreshManager | 설정/상수 테이블 핫리로드 — [Refreshable] 타입을 주기적으로 재로드(재배포 없이 밸런스 데이터 반영). 15초마다 체크 + Redis로 서버간 신호. | InitializeAsync, RefreshAsync |
💡 가장 먼저 파악할 매니저
★ 매우 중요 로 표시한 GameManager · SocialManager · ScenarioManager · MessengerManager 네 개는 게임의 심장부라 반드시 먼저 읽으세요. 여기에 보상 정산의 핵심 SyncManager(1,893줄)까지 더한 다섯이 대부분의 게임플레이 로직을 담고 있습니다.
반대로 이름은 거창하지만 얇은 것들도 있습니다 — LeaderBoardManager(55줄), CouponManager(59줄), CostumeManager(100줄). "이 기능 로직이 어디 있지?"는 대개 이 매니저들 중 하나입니다.
핸들러가 매니저를 쓰는 전형적 모습
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(관리자 브로드캐스트/킥)는 게임플레이 요청 경로가 아니라 운영 도구입니다. 처음엔 몰라도 됩니다.
인증 & 세션
이 장의 핵심은 딱 하나입니다: 서버에는 완전히 다른 두 개의 비밀이 있다. 이 둘을 구분하면 인증 코드 전체가 술술 읽힙니다.
🔑 두 개의 비밀 — 이것만 기억하세요
AccountPass= 저장해두는 "비밀번호". 클라이언트 디스크에 저장되어 앱을 껐다 켜도 유지되는 장기 자격증명. 오직/Auth*호출에서만 사용.Hash= "세션 쿠키/토큰". 랜덤uint32로, 로그인할 때마다 새로 발급되고 모든 게임플레이 요청마다 검사되는 단기 토큰.
흐름 한 줄 요약: 클라가 /Auth*에서 AccountPass를 내고 새 Hash를 받아, 이후 세션 내내 그 Hash를 사용합니다.
정정 세션은 JWT가 아니다
게임 세션 토큰은 TblUser.Hash입니다. JWT는 Apple/Google/Facebook 3자 신원 토큰을 검증할 때만 프로바이더별 AuthenticateAsync 내부에서 쓰이고, 세션 토큰을 발급하지 않습니다.
먼저 정리: 유저를 가리키는 3가지 식별자
이 셋은 계속 헷갈리는데, 서로 다른 축입니다.
| 식별자 | 타입 · 위치 | 의미 (한 줄) |
|---|---|---|
Suid | long · TblUser.Suid | "어느 플레이어" — 영구·표준 유저 PK. 모든 게임 데이터가 이걸로 묶임. DB 자동증가 seq를 가역 암호화(SimpleEncrypter60)해 랜덤처럼 보이게 만든 값. |
Duid | string · TblUser.Duid | "어느 폰" — 기기 고유 id. 신원이 아니라 분석/DAU 집계·어뷰징 제한용. 한 기기에 여러 계정 가능. |
AccountId | string · TblAccount.AccountId | "어느 구글/애플/페북 신원" — 프로바이더별 계정 id. 조회 키는 (accountType, accountId) 쌍. DB에 암호화 저장. |
💡 인증의 본질
인증이 하는 일은 결국 프로바이더 AccountId → 올바른 Suid를 찾아주고, 새 Hash를 발급하는 것뿐입니다. 한 유저(suid)는 tblAccount 행을 여러 개 가질 수 있습니다 — 연동된 프로바이더 하나당 한 행.
AccountPass — 실체
AccountPass는 4-튜플 (AccountType, AccountId, AccountName, Password)을 구분자 #'%:로 이어붙인 뒤 XXTEA로 암호화한 불투명 문자열입니다. 서버 키(CfgServer.AccountPassEncKey)가 없으면 클라가 위조할 수 없습니다.
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 — 역할 비교
AccountPass | Hash | |
|---|---|---|
| 타입 | 암호화된 4-튜플 문자열 | uint32 (tblUser.hash) |
| 수명 | 장기 · 클라 디스크에 저장 | 세션 1회 · 로그인마다 재발급 |
| 목적 | 내가 누구인지 증명해 세션을 받음 | 이 요청이 현재 세션 것인지 증명 |
| 검사 위치 | /Auth* 에서만 | 모든 게임플레이 요청 (Gate) |
💡 왜 둘로 나눴나?
Hash가 유출돼도 다음 로그인에 만료됩니다. 장기 비밀(AccountPass)은 매 요청이 아니라 인증 호출 때만 오가므로 노출이 적습니다. 또 로그인마다 Hash를 갈아끼우면 기존 세션을 끊을 수 있습니다(다른 기기 로그인 → 옛 Hash 불일치).
로그인 흐름 — 게스트 vs 소셜
모든 인증은 AuthManager.ProcessAsync(AccountType, ...) 하나로 모입니다. 프로바이더별 ServiceAuth*는 AccountType만 골라 위임합니다(None=게스트, GooglePlay, Facebook, AppleId, ...).
최초 실행 — 신규 게스트 생성
.CreateUserAccountAsync
Hash·Suid·password 발급
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);
}소셜 로그인은 다릅니다: 프로바이더별 AuthenticateAsync가 3자 토큰(JWT/OAuth)을 검증해 진짜 AccountId를 얻고, UserManager.AuthenticateAsync가 (accountType, accountId)로 기존 계정을 찾습니다. 있으면 그 유저를 로드해 Hash만 재발급, 없으면 신규 생성, 게스트→소셜 연동 상황이면 아래 연동 트리로 갑니다.
재접속 유저 — 전체 시퀀스
클라가 이전에 Suid·AccountPass·Hash를 저장해 둔 상태에서 앱을 다시 켰을 때.
저장된 자격증명 → 새 세션
- 앱 실행 → /Auth*클라
저장한
AccountPass·Duid전송. 이 호출은 해시 검사 안 함(IsHashCheckNeeded=false) — 해시를 발급하는 호출이라 순환이 되니까. - AccountPass 검증AuthManager
복호화 후
tblAccount조회. 게스트는ap.Password == account.Password비교, 실패 시AuthenticationFailed. 소셜은 3자 토큰 재검증. - 새 Hash 발급UserManager
user.Hash = new Randomizer().NextUInt32()→user.SetAsync()로 저장. - 응답서버
res.AccountPass(갱신될 수 있음) ·res.Suid·res.Hash(새 토큰) 반환. - 클라 저장 → 이후 요청클라
Suid+새Hash를 모든 게임플레이 요청에 실어 보냄.
이후 매 요청 인증
클라는 Suid를 ProtocolReq.Suid에, Hash를 요청 헤더 bytes 1–4(→ 5장)에 실어 보냅니다. 서버는 매 요청마다 유저를 로드해 해시를 대조합니다.
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가 새로 발급되므로, 기존 기기의 hash는 user.Hash와 맞지 않아 AuthenticationExpired가 되고 클라는 다시 1단계(재인증)로 돌아갑니다. Hash 재발급 지점은 ① 신규 계정 생성 ② 게스트/소셜 재로그인 ③ 계정 이전(원본 세션 강제 종료)입니다.
계정 연동 · 이전 · 격리
- 연동(게스트 → 소셜):
LinkOptionenum(None/Local/Auth/Link)으로 제어. 소셜 신원엔 계정이 없는데 로컬 게스트가 있고None이면 서버가AuthenticationNeedToLink를 던져, 클라가 사용자에게 물어본 뒤Local(로컬 데이터 유지)/Auth(인증계정 데이터 사용)로 재시도합니다. 두 신원이 서로 다른suid면NeedToSelect/NeedToLogout로 선택을 요구. - 이전(TransferCode): 기기 간 계정 이동 장치는 있으나 Billionaire에서는 사실상 미사용(코드 주석 "안씀").
AccountManager.TransferAsync가 행을 옮기고, 원본Hash를 갈아끼워 옛 기기를 즉시 로그아웃시킵니다 —Hash가 왜 필요한지 보여주는 예. - 격리(Quarantine): 장기 미접속 계정을 핫 테이블에서
QuarantineAccountDB로 보관. 로그인 시AccountManager.GetAsync가AuthQuarantined를 던지면, 클라가NeedToRecoverFromQuarantine=true로 재호출해RecoverFromQuarantineAsync로 복구합니다.
⚠️ 헷갈리기 쉬운 이름들
tblAccount.password= 서버 랜덤 시크릿(사람 비밀번호 아님)tblAccount.accountName(프로바이더 표시명) ≠tblUser.nickname(인게임 닉네임)AccountType이10이상 = deprecated(해제된) 링크. 쿼리에서accountType < 10= "활성 링크만"ServiceAuth.cs에 주석 처리된 옛 코드가 있음 — 과거 설계 잔재이니 무시
데이터베이스 계층
MySQL + Dapper(MySqlConnector 드라이버). 별도 Repository/ORM 매핑 레이어는 없고, 각 Tbl* 엔티티에 정적 DAO 메서드가 붙은 Active-Record 스타일입니다(TblUser.GetAsync, user.UpdateAsync() 등).
연결 문자열은 "데이터 주도"
부트스트랩용 CommonDB 문자열만 AWS Secrets Manager에서 오고, 나머지 모든 DB의 연결 문자열은 DB 테이블 tblConnectionString에 저장되어 런타임에 로드됩니다. 재배포 없이 DB를 재지정할 수 있습니다.
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 샤딩
= user.GameServerId
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 | 대부분의 CRUD | QueryFirstOrDefaultAsync<T>(sql, p) |
| 저장 프로시저(sp*) | 계정·결제 등 원자성 중요 로직 | spWithdrawProcess, spInsertProductPurchase |
| 멀티 스테이트먼트 | upsert / INSERT+SELECT | INSERT ... ON DUPLICATE KEY UPDATE |
💡 커넥션 풀 & 페일오버
풀링은 MySqlConnector의 네이티브 풀(연결 문자열 기준)을 그대로 사용합니다. DB 오류 시 CheckAndClearPool(DbException)이 에러 코드를 보고 MySqlConnection.ClearPool()을 호출해 master/slave 토폴로지 변경 후 페일오버를 처리합니다. 읽기는 slave, 쓰기는 master(isMaster / !isReadOnly 불리언으로 분기).
Redis 활용
중앙 헬퍼는 정적 클래스 BnaireCommon/Redis.cs(~2100줄)이며, 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}— 관리자 메시지 ListkickTarget:{suid}— 강제종료 플래그ALIVE:{시각}— DAU dedup Setrank— 랭킹 HashdjEvent:*— DJ 이벤트 상태
connMisc
범용 + 락
- 범용 KV (
GetFromMiscAsync) CARD:{seq}— 환불 분산 락(SETNX)- 설정 새로고침 신호 List
💡 "Redis 세션"의 실체
authoritative한 Hash는 MySQL tblUser에 있지만, 매 요청 읽기가 Redis user:{suid}(field auth, MessagePack 직렬화, TTL 1h)를 먼저 거칩니다. 즉 "Redis 세션"은 사실 유저 레코드의 1시간 캐시입니다.
멱등성 & 중복 방지
중복 처리 방어는 중앙 1개 + 서비스별 임시방편 여러 개의 층으로 되어 있습니다.
① 중앙: 응답 리플레이 캐시
모든 요청은 고유 Ticks(nonce)를 가집니다. Gate는 서비스 실행 전에 R:{suid}:{ticks}를 조회해, 이미 있으면 저장된 응답 바이트를 그대로 반환하고 서비스를 재실행하지 않습니다. 네트워크 재시도로 인한 중복 제출을 10분간 막는 핵심 안전망입니다.
var data = await LoadResponseDataAsync(req); // Redis.GetResponseAsync(Suid, Ticks)
if (data == null) {
// 최초 요청: 서비스 실행 → SendResponseAsync가 응답을 캐시에 저장
} else {
// 재요청: 캐시된 응답 그대로 반환, 서비스 실행 안 함
StressMonitor.UpdateRetry(...);
}② 서비스별 방어 패턴
| 패턴 | 메커니즘 | 대표 위치 |
|---|---|---|
| IAP 영수증 dedup | DB 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를 받습니다. (KeyExists후StringSet을 따로 하면 둘 다 "없음"을 보고 둘 다 저장하는 경쟁 상태가 생김.)
보통 TTL을 함께 걸어(예: 10분) 처리 중 죽어도 락이 영구히 안 풀리는 것을 막습니다. 이름은 SETNX(SET if Not eXists) 패턴으로도 불립니다.
// 같은 카드 환불 메시지를 한 번만 처리하도록 잠금
var ok = await db.StringSetAsync($"CARD:{messageSeq}", "locked",
TimeSpan.FromMinutes(10), When.NotExists);
return ok; // true = 내가 락 획득(처리 진행), false = 이미 처리 중/처리됨(건너뜀)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초 지난 재요청은 캐시 미스가 나서 핸들러가 다시 실행됩니다. 오래 지연된 재시도가 보상을 두 번 주지 않도록, 시간에 의존하지 않는 방어(위 상태 플래그 등)가 필요합니다.
에러 처리 & 결과 코드
전역 예외 필터/미들웨어는 없습니다. Gate.InvokeAsync 전체를 감싼 단 하나의 try/catch가 사실상의 전역 핸들러입니다. 진행 단계를 Step enum(None→HeadRead→Decrypted→Deserialized→Service→Completed)으로 추적해, 실패 지점을 결과 코드로 매핑합니다.
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 예외뿐입니다.
로깅
서로 다른 3개 채널이 있습니다. NLog는 밑단 전송 계층이고, BnaireLog는 그 위의 강타입 분석 스키마 계층입니다(별개 시스템이 아니라 층위 관계).
| 채널 | 용도 | 목적지 |
|---|---|---|
| NLog | 운영/에러 로그 (log.Error(ex)) | 콘솔 + ConsoleLog.*.log |
| BnaireLog → Snowflake | 분석/감사 이벤트 (신규유저·결제·주사위 등) | *.sflog → Kinesis → S3 → Snowflake |
| ProtocolHist | 요청/응답 감사 추적 (제외목록 외 전부) | DB TblUserProtocolHist (비동기) |
// 기동 시 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.sh의 ClassScanner가 클래스를 리플렉션해 JSON 스키마를 만들어 S3에 올립니다. 이 스키마가 다운스트림 Parquet/Snowflake 테이블 모양을 결정합니다. InsertAsync는 자체 예외를 삼키므로 로그 실패가 요청을 깨뜨리지 않습니다.
배포 & 환경
단일 파일 self-contained 실행 파일(linux-x64)로 publish 후 deploy.sh로 대상 호스트에 배포하고 systemctl로 관리합니다.
./deploy.sh bnaireS # 스테이징
./deploy.sh bnaire-game-1 # 프로덕션 (AWS)| 환경 | 호스트 | 비고 |
|---|---|---|
| 스테이징 | bnaireS / bnaireC | 테스트 |
| 프로덕션 | bnaire-game-1 / -2 | AWS ARM64, 베스천 경유 |
| QA | bnaireQ | — |
💡 설정의 출처 (3단계)
- AWS Secrets Manager — 부트스트랩
CommonDB문자열 - DB 테이블
cfgServer— Redis 주소, 로거명, 클라 키 등 대부분의 런타임 설정([Refreshable]) - 파일 —
appsettings.json(로깅만),GameIndividual.config,NLog.config
점검 모드는 CfgServer.MaintenanceNow로 켜지며, AdminAcl에 없는 요청은 MaintenanceNow로 거부됩니다.
리스크 & 주의사항
인수인계 시 특히 조심해야 할, 코드에서 관찰된 함정과 취약 지점 모음입니다. 유지보수 모드이므로 "당장 고쳐라"가 아니라 "건드릴 때 알고 있어라"의 관점입니다.
보안 SQL 인젝션 가능 지점
일부 쿼리가 파라미터 바인딩 대신 문자열 보간으로 값을 SQL에 직접 넣습니다. 예: TblUser.GetWithDuidAsync의 WHERE `duid` = '{duid}', SearchWithNicknameAsync의 LIKE '%{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장).
유지보수 수동 동기화가 필요한 곳
ProtocolIdenum ↔[Union]테이블 (프로토콜 추가 시)- 미사용 설정:
RedisSession,RedisAdmin필드는 존재하나 어디서도 참조되지 않음(혼동 주의)
관례 ASP.NET 상식이 안 통하는 곳
DI 컨테이너·미들웨어·컨트롤러·모델 바인딩이 없습니다. 모든 것은 Gate.cs 수동 처리와 ProtocolId 딕셔너리 라우팅입니다. 프레임워크 관례를 기대하지 말고 Gate.cs → Service.cs → Service*.cs 순서로 코드를 따라가세요.
회고 — 일반 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 |
| DB | EF Core (LINQ · 마이그레이션) | Dapper + Active-Record 정적 DAO |
| 설정 | appsettings.json · IOptions | DB 테이블 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 미사용 + 정적 매니저 + 거대 파일(
GameManager4,711줄)로 단위 테스트·모킹이 어렵고 인지 부하가 큼. - 사람 실수 여지:
ProtocolId↔[Union]수동 동기화, 죽은 코드·미사용 설정 잔재. - 동시성: per-user 락이 없어 멱등성 TTL(600초) 밖의 동시/재요청은 취약.
🧭 특이한 것 — "왜 이렇게 했지?" 싶은 지점
- ASP.NET을 얇은 바이너리 리스너로만 사용. DI·미들웨어·컨트롤러·모델바인딩을 의도적으로 버리고, 게임서버 관례를 .NET 위에 얹었습니다.
- 세션 토큰이 암호화 키를 겸함: 모드 2에서
hash ^ key— 인증과 전송 암호화가 한 값으로 결합. - Suid = 가역 암호화된 auto-increment. 랜덤처럼 보이지만 복호 가능한 순번.
- 응답만 압축(요청은 비압축) — 방향 비대칭.
- 로그는 코드→스키마 생성(역방향)인데 프로토콜은 코드가 원본. 코드젠 방향이 서로 반대.
/Monitor가 헬스체크가 아니라 관리자용 HTML 대시보드.- 핸들러 싱글톤 + 정적 매니저 → 상태가 거의 없는 절차적/함수형 스타일(전형적 OOP 상태 보관이 드묾).
📝 신입에게 — 한 문단 요약
이 서버의 "이상함" 대부분은 낮은 지연 · 높은 처리량 · 좁은 모바일 대역폭 · 수백 개 프로토콜을 감당하려고 프레임워크의 편의를 포기한 결과입니다. 그러니 ASP.NET 상식을 기대하지 말고 이 코드베이스의 관례(Gate.cs → Service.cs → Service*.cs → 매니저)를 그대로 따르세요. 다만 트랜잭션 부재·SQL 인젝션·테스트 용이성 세 가지는 "도메인 특성"이 아니라 실제 개선 여지가 있는 부분이라고 보면 됩니다.
부록 · 동시성 제어는 보통 어떻게 하나
이 서버엔 범용 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(게이트
SETNXper-user 락)를 국소적으로 추가. - 클라 측 방어는 유지하되 그것만 믿지 않는다. 서버가 최종 방어선.