초보자용 시각 가이드 · GETTING-STARTED + CHOOSING-AXES

ChServerM, 축을 골라 조립하는 서버

완성된 서버가 아니라 부품(축)을 끼워 맞춰 만드는 프레임워크다. 전송·프레이밍·직렬화·실행 모델·세션을 각각 골라 ServerBuilder 에 꽂으면 서버가 된다. 이 페이지 하나로 첫 서버를 띄우고, 무엇을 언제 고를지 결정할 수 있다.

완성된 서버 축의 조합이 곧 서버다
.NET SDK 10 준비물 메타 패키지 ChServerM 하나로 시작 5분 안에 에코 서버

핵심 개념

서버 하나 = 슬롯마다 부품 하나

아래가 이 프레임워크의 전부다. 각 슬롯에서 부품을 하나씩 고르면 그게 서버가 된다. 부품을 바꾸는 비용은 대개 .UseXxx() 한 줄이다.

new ServerBuilder()  — 조립 시작
전송 (Transport) .UseTransport()
누가 접속해 오는가 — 바이트가 오가는 통로
Transport.Tcp Http/2 WebSocket QUIC InMemory
프레이밍 (Framing) .UseFraming()
바이트 스트림에서 메시지 경계를 어떻게 긋는가
고정 헤더 MaxPayloadLength 만 정하면 됨
직렬화 (Serialization) MapGeneratedHandlers()
페이로드를 무엇으로 읽고 쓰는가
MemoryPack Protobuf FlatBuffers
실행 모델 (Execution) .UseExecutionModel() 선택
같은 커넥션의 메시지 순서를 보장할 것인가
Partitioned · 순서 보장 안 꽂으면 스레드풀 병렬
세션 저장소 (Session) Persistence.* 선택
커넥션 너머로 상태를 어디에 두는가
InMemory Redis Postgres
.Build() → 서버 완성 · 어긋난 조합은 이 시점에 거부된다

가장 먼저 정할 것

커넥션에 “상태”가 있는가?

이 한 가지가 나머지 선택의 절반을 결정한다. 둘 중 어느 쪽인지 먼저 고른다 — 두 프로필 모두 같은 핸들러 코드로 돌아간다.

realtime-stateful

게임·채팅처럼 서버가 먼저 말을 걸고, 연결이 계속 살아 있는 경우

커넥션
상시 연결 · 서버가 먼저 보냄(push)
실행
Partitioned — 커넥션별 순차
순서
커넥션 단위로 보장됨
세션
인메모리 (커넥션 = 세션)
샘플
Samples/EchoServer

stateless-web

요청-응답 API처럼 노드를 자유롭게 늘리고 줄이는 경우

커넥션
요청-응답 · 노드 교체 자유
실행
없음 — 스레드풀 병렬
순서
없음 (시퀀스로 응답 짝지음)
세션
외부화 (Redis/Postgres)
샘플
Samples/StatelessWeb

💡 중간 형태(일부 메시지만 순서 보장)도 같은 부품으로 조립된다. 한쪽만 도는 추상화는 추상화가 아니라는 게 이 프레임워크의 합격 기준이다.

첫 실행 · 5분 경로

샘플 실행 → 직접 조립 → 축 갈아끼우기

순서대로 따라가면 된다. 1번은 저장소 클론이 필요하고, 2번부터는 nuget 패키지만으로 조립한다.

1

동봉 샘플 실행 · 1분

dotnet run --project Samples/ChServerM.Samples.EchoServer -c Release — 인자 없이 실행하면 같은 핸들러를 TCP·인메모리 양쪽에서 돌려 자체 검증하고 결과를 알려준다.

2

최소 서버 직접 조립 · 4분

dotnet new console 로 프로젝트를 만들고 dotnet add package ChServerM 하나면 이 가이드가 쓰는 축이 전부 들어온다. 아래 코드가 그 최소 서버다.

3

축 갈아끼우기

전송을 바꾸고, 순서 보장을 빼고, 타입 있는 메시지로 넘어간다 — 대부분 한 줄 교체다. 맨 아래 갈아끼우기 표 참고.

최소 예제 · 서버 + 클라이언트

받은 걸 그대로 돌려주는 에코 서버

서버와 클라이언트가 같은 프레이밍·디스패치를 쓴다 — 그게 이 프레임워크의 대칭이다. 조립도의 슬롯이 실제 코드에서 어떻게 채워지는지, 주석(// …)이 왜 그렇게 쓰는지 알려준다.

서버 · Program.cs
// 프레이밍: 최대 페이로드는 기본값에 기대지 말고 워크로드에 맞게 명시한다.
const int MaxPayload = 64 * 1024;
FramingOptions framing = new() { MaxPayloadLength = MaxPayload };
FixedHeaderFrameEncoder encoder = new(framing);

// 실행 모델: 같은 커넥션은 순차, 다른 커넥션끼리는 병렬.
await using PartitionedExecutionModel executionModel = new();

await using ChServerMServer server = new ServerBuilder()
    .UseTransport(new TcpServerTransport(endPoint, tcpOptions))  // 전송
    .UseFraming(new FixedHeaderFrameDecoder(framing), encoder)      // 프레이밍
    .UseExecutionModel(executionModel)                            // 순서 보장
    .ConfigureDispatcher(d => d
        // 메시지 ID 1: 받은 페이로드를 그대로 돌려보낸다. (ID 0 은 예약 센티넬)
        .MapRaw(new MessageId(1), async context =>
        {
            await FrameWriter.WriteFrameAsync(
                context.Connection.Output, encoder,
                context.Envelope.MessageId, context.Payload,
                FrameFlags.None, context.Envelope.Sequence, context.CancellationToken);
            return DispatchStatus.Handled;
        }))
    .Build();

await server.StartAsync();  // 와이어 형식: [고정 헤더][페이로드]
클라이언트 · 왕복 확인
// 클라이언트도 서버와 같은 프레이밍·인코더를 쓴다.
FramingOptions framing = new() { MaxPayloadLength = 64 * 1024 };
FixedHeaderFrameEncoder encoder = new(framing);

// 응답을 받아 둘 곳 — 핸들러가 여기에 결과를 넣는다.
TaskCompletionSource<byte[]> echoed = new(TaskCreationOptions.RunContinuationsAsynchronously);

await using ChServerMClient client = new ClientBuilder()
    .UseTransport(new TcpClientTransport(tcpOptions))         // 서버와 같은 버퍼 임계값
    .UseFraming(new FixedHeaderFrameDecoder(framing), encoder)
    .ConfigureDispatcher(d => d
        .MapRaw(new MessageId(1), context =>
        {
            // Payload 는 핸들러가 반환하면 무효 — await 너머로 들고 가려면 복사한다.
            echoed.TrySetResult(context.Payload.ToArray());
            return new ValueTask<DispatchStatus>(DispatchStatus.Handled);
        }))
    .Build();

// 접속 → 메시지 1개 전송 → 에코 수신.
ClientSession session = await client.ConnectAsync(new IPEndPoint(IPAddress.Loopback, 5000));

await FrameWriter.WriteFrameAsync(
    session.Connection.Output, encoder, new MessageId(1),
    "안녕, ChServerM"u8, FrameFlags.None, sequence: 0, session.Connection.ConnectionClosed);

Console.WriteLine($"에코: {Encoding.UTF8.GetString(await echoed.Task)}");  // → 에코: 안녕, ChServerM

await session.Connection.DisposeAsync();

에코: 안녕, ChServerM 이 찍히면 왕복이 완성된 것이다. 서버·클라이언트가 같은 MessageId(1) · 같은 프레이밍으로 짝을 이룬다. 전체 using 목록과 설명은 docs/GETTING-STARTED.md 에 있다.

무엇을 언제 꽂는가

축별 선택 가이드

처음이라면 기본 표시를 고르면 된다. 바꿀 이유가 생겼을 때 아래 “고르는 경우”를 본다.

01

전송 — 누가 접속해 오는가

전송이 무엇이든 프레이밍·직렬화·핸들러는 그대로다. 바꾸는 비용은 한 줄.

Transport.Tcp 기본

자체 클라이언트를 가진 상시 연결. 지연 바닥이 가장 낮다.

Transport.Http/2

LB·프록시·인그레스 등 웹 인프라를 거쳐야 할 때. 고동시성에선 다중화가 TCP를 5.9배 역전.

Transport.WebSocket

브라우저 클라이언트. 메시지 경계는 프레이밍 축이 긋는다.

Transport.Quic

헤드오브라인 블로킹 회피, 0-RTT 재접속. TLS 필수(리눅스는 msquic).

Transport.InMemory

테스트·프로세스 내 조립 검증. 소켓 없이 전체 파이프라인이 돈다.

02

직렬화 — 페이로드를 무엇으로

MemoryPack 기본

C# ↔ C#. zero-encoding, 소스 생성으로 가장 빠르다.

Protobuf

크로스 언어 상호운용, 스키마 진화(모르는 필드 보존).

FlatBuffers

역직렬화 없이 큰 메시지에서 일부 필드만 랜덤 접근.

03

실행 모델 — 순서가 필요한가

Partitioned 순서 보장

커넥션 ID 해시로 파티션 고정 → 같은 커넥션은 순차, 다른 커넥션은 병렬. 파티션 안엔 락도 없다.

(안 꽂음)

스레드풀 병렬. 응답 순서가 섞이므로 클라이언트가 시퀀스로 짝짓는다.

⚠ 룸 축(RealTime.Rooms)은 파티션 실행 모델을 전제한다 — 무상태 프로필과 룸은 조합하지 않는다.

04

세션 저장소 — 상태를 어디에

InMemory 기본

단일 노드, 커넥션 = 세션 수명. 상태 유지 프로필의 기본.

Redis

다중 노드 무상태 프로필. Redis Cluster 지원.

Postgres

세션이 곧 영속 데이터(재시작 생존)이거나 이미 PostgreSQL 을 운영할 때.

05

횡단 축 — 필요할 때만 꽂는다

코어 로직을 오염시키지 않고 데코레이터·미들웨어로 감싸는 관심사들. 안 꽂으면 없는 셈이다.

Compression.LZ4 ADR-0019

페이로드를 LZ4 블록으로 압축·해제하는 코덱. 압축률보다 속도(GB/s급)를 택하는 알고리즘이라 압축 레벨은 FAST 고정 — HC 레벨은 실시간 경로엔 수십 배 느리다.

핵심 와이어 형식이 [원본 길이 4B][블록] — 길이가 앞에 있어 버퍼를 잡기 전에 해제 상한을 검사한다(압축 폭탄 방어). 무상태라 전 커넥션이 공유하고 힙 할당 0.

언제 페이로드가 크고 반복적일 때

Security.Tls ADR-0017

커넥션 파이프를 감싸는 전송 중립 TLS 데코레이터. TCP·WebSocket 등 어느 전송에나 그대로 끼운다(QUIC은 프로토콜 자체가 TLS). 기본은 TLS 1.3 전용.

핵심 클라이언트 인증서 검증 기본값이 전체 체인 + 호스트명 검증이다(accept-all 아님). 핸드셰이크 실패는 예외가 아니라 상태 — 폭주를 공격 신호로 관측한다.

언제 공인망을 지날 때

Security.AspNetIdentity ADR-0018

인증·인가 축. 비밀번호는 ASP.NET Core의 PBKDF2 v3(반복 60만) 해셔에 위임하고 레거시 해시 형식과 호환된다. 실제 검사는 Hosting 미들웨어 2종이 한다.

핵심 인증·인가 미들웨어는 라우팅보다 앞에 강제된다 — 순서가 뒤집히면 모르는 메시지 ID를 던지는 것만으로 인증을 우회한다. 위반 조립은 Build() 예외.

언제 사용자 신원이 필요할 때

Observability ADR-0020

System.Diagnostics.Metrics(BCL 표준) 기반 메트릭 싱크. dotnet-counters 가 즉시 읽고, OpenTelemetry·Prometheus 는 이 Meter 를 구독하는 배선만 얹으면 된다. Diagnostics.Http/healthz·/readyz 프로브를 연다.

핵심 익스포터를 안 붙이면 거의 무비용(Enabled 검사만). 큐 깊이·드롭 수를 노출한다 — 조용한 유실은 관측되지 않으면 없는 것과 같다.

언제 프로덕션이면 항상

DataTable ADR-0043

밸런스 표·요금표 같은 정적 CSV 를 기동 시 한 번 읽고 이후엔 조회만 하는 강타입 테이블. 스키마와 접근자를 한 선언에서 소스 생성한다(선언 오류는 CHSM2xxx 가 컴파일 타임에 잡음).

핵심 값이 파싱된 채 서수로 조회된다 — 조회마다 int.Parse 없이 컬처에도 안 흔들린다. 데이터 오류는 전부 로딩 시점에 전수 검증돼 조회는 실패하지 않는 게 계약. 파일 변경 시 리로드 가능.

언제 밸런스·룰 표를 코드에서 안전하게

06

실시간 프리미티브 — 선택 축 (전부 빼도 성립)

셋은 서로를 참조하지 않는다 — 룸만, 공간만, 틱만 따로 쓸 수 있다. 전부 빼도 프레임워크는 성립한다.

RealTime

세 가지 시간 프리미티브를 담은 어셈블리:

틱 루프 · TickLoop 전용 스레드 하나가 고정 간격으로 핸들러 호출. 절대 스케줄로 드리프트 보정(n번째 마감 = 원점 + n×간격) — 상대 스케줄이면 오차가 누적돼 한 시간이면 초 단위로 밀린다. 밀린 틱은 상한까지 캐치업 후 건너뛰고 관측한다(죽음의 나선 방지).
타이밍 휠 · TimerWheel 만료 타이머 수만 개를 삽입 O(1)·진행 O(1) 로 관리하는 계층적 휠(Kafka·Netty 계열). 커넥션마다 타이머를 만드는 대신 휠 하나로 통합한다.
시간 동기화 · RemoteClock 드문 서버 시각 표본 사이를 로컬 단조 시계로 외삽. 출력은 절대 뒤로 가지 않는다 — 시계 역행을 게임 로직에 노출하지 않는다.

언제 고정 주기 시뮬레이션 · 대량 타이머 · 클라이언트 시간 정렬

RealTime.Rooms ADR-0064

룸 멤버십 + 1회 인코딩 브로드캐스트. 같은 페이로드를 N 명에게 보낼 때 직렬화·헤더 인코딩을 딱 한 번만 하고 참조 계수 프레임을 공유한다 — 멤버당 비용은 파이프로의 바이트 복사뿐이다.

핵심 멤버당 ~400ns · 0 B(정상 상태 브로드캐스트당 할당 0). 브로드캐스트 프레임 시퀀스는 0이라(헤더 공유) 커넥션별 일련번호가 필요한 메시지는 대상이 아니다. 파티션 실행 모델을 전제한다 — 무상태 프로필과는 조합하지 않는다.

언제 채팅·게임 룸, 관전, 존 방송

RealTime.Spatial ADR-0065

관심 영역(AOI) 균일 그리드. "내 주변 엔티티"를 전수 순회 O(전체) 대신 겹치는 셀만 본다 — 삽입·이동·제거 O(1), 셀 키는 모튼(Z-order) 코드. 여기에 집합 차분(입장/퇴장 판정)과 무할당 SAT 충돌 판정(OBB·AABB)이 더해진다.

핵심 범위 밖 좌표는 가장자리 셀로 클램프하되 진짜 위치는 따로 보관한다(셀은 후보 축소 수단이지 정답이 아님). 존 하나 = 파티션이라 락도 Concurrent* 도 없고, 이동·질의의 정상 상태 할당이 0.

언제 대규모 공간, 시야·근접 판정, 브로드캐스트 대상 필터링

3단계 · 다음

축 갈아끼우기 — 대부분 한 줄

위 최소 서버에서 바꿀 수 있는 것들이 곧 프레임워크의 표면이다.

바꾸고 싶은 것방법참조 샘플
전송 (TCP → HTTP/2·WebSocket·QUIC·인메모리)UseTransport(...) 한 줄StatelessWeb
순서 보장 없이 병렬로 (무상태 웹)UseExecutionModel 을 빼기StatelessWeb
타입 있는 메시지 + 직렬화[MessageHandler] 선언 + MapGeneratedHandlersEchoServer · StatelessWeb
룸 입장·브로드캐스트 (게임·채팅)RealTime.Rooms 선택 축GameRoom
TLS.UseTransportSecurity(...)Security.Tls.Tests

막히면

초보자가 자주 만나는 벽

대부분 런타임 교착 대신 시작 시점에 실패하도록 설계돼 있다 — 에러가 곧 안내다.

Build() 가 조립을 거부하는 예외최대 프레임 > 전송 버퍼 한계. 예외 메시지의 두 수치를 맞춘다 — 이 조합은 런타임이면 조용한 교착이었다.
핸들러 등록이 거부됨MessageId(0) 은 ‘설정되지 않음’ 센티넬. 1 이상을 쓴다 (앱 대역 1~40000).
빌드 오류 CHSM1xxx / CHSM2xxx소스 제너레이터가 선언 오류를 컴파일 타임에 잡은 것. DIAGNOSTICS.md 의 표에서 ID를 찾는다.
페이로드가 깨져 읽힘Payloadawait 너머로 들고 갔다. 핸들러 반환 전에 역직렬화하거나 복사한다 (.ToArray()).
SDK 버전 불일치로 빌드 실패global.json 고정과 로컬 SDK 드리프트. 요구하는 10.0.2xx 를 설치한다.