초보자용 시각 가이드 · 클라이언트 편

ChServerM 클라이언트, 서버와 같은 부품으로

클라이언트는 별도 세계가 아니다. 서버와 똑같은 프레이밍·직렬화·디스패치·핸들러ClientBuilder 에 꽂아 만든다. 그래서 서버 핸들러를 클라이언트에 그대로 재사용할 수 있고, 서버-투-서버 통신도 특별한 경로가 되지 않는다.

ServerBuilder ClientBuilder — 대칭
같은 축 프레이밍·직렬화·디스패치 다른 것 실행 모델 없음 · 재접속 없음 진입점 ConnectAsync

핵심 개념

클라이언트도 슬롯마다 부품 하나

서버 조립도와 같은 그림이다 — 단, ‘실행 모델’ 슬롯이 없다. 순서 보장은 서버의 몫이고, 클라이언트는 받은 프레임을 디스패치로 처리할 뿐이다.

new ClientBuilder()  — 조립 시작
전송 (Transport) .UseTransport() 필수
어디로 접속하는가 — 서버 전송과 짝을 이룬다
TcpClientTransport Http/2 WebSocket QUIC InMemory
프레이밍 (Framing) .UseFraming() 필수
서버와 완전히 같은 디코더·인코더·MaxPayloadLength 를 준다
고정 헤더 (서버와 동일)
디스패치 (Dispatch) .ConfigureDispatcher() 필수
서버가 보낸 프레임(에코·푸시·응답)을 어느 핸들러로 보낼지
MapRaw MapGeneratedHandlers · 타입 메시지
횡단 축 (Cross-cutting) .UseTransportSecurity() 등 선택
서버와 대칭으로 끼운다 — 안 꽂으면 없는 셈
TLS 압축(LZ4) 버전 협상 콘텐츠 핑거프린트
실행 모델 클라이언트엔 없음
서버에만 있는 슬롯이다. 순서 보장은 서버가 파티션으로 하고, 무상태 응답은 클라이언트가 시퀀스 번호로 짝짓는다.
.Build()ChServerMClient · 아직 접속 전이다
await client.ConnectAsync(endPoint)ClientSession  — 연결 + 핸드셰이크

서버와의 관계

무엇이 같고, 무엇이 다른가

클라이언트를 이해하는 가장 빠른 길은 서버와의 차이만 아는 것이다. 대부분은 같다.

= 서버와 같다

  • 프레이밍·직렬화 — 같은 FixedHeaderFrameDecoder/Encoder, 같은 MaxPayloadLength
  • 디스패치·핸들러MapRaw / MapGeneratedHandlers. 서버 핸들러를 그대로 꽂을 수 있다
  • 횡단 축 — TLS·압축·버전 협상·콘텐츠 핑거프린트를 대칭으로 조립
  • Payload 수명 — 핸들러가 반환하면 무효. await 너머로 들고 가려면 .ToArray()

클라이언트만 다르다

  • 실행 모델 슬롯이 없다 — 순서 보장은 서버 몫. 응답은 시퀀스로 짝짓는다
  • 재접속 정책이 없다(의도적) — 감추면 상위가 세션 재수립을 건너뛴다
  • 진입점이 ConnectAsyncClientSession 을 돌려주고, 여기서 버전 협상·보안 핸드셰이크가 일어난다
  • 세션 재개 토큰 — 재접속 시 “내가 그 세션 주인”을 32바이트 난수로 증명

클라이언트 생명주기

조립 → 접속 → 송신 → 수신 → 종료

서버가 “대기”라면 클라이언트는 “왕복”이다. 순서대로 흐른다.

1

조립 Build() → ChServerMClient

축을 골라 클라이언트를 만든다. 아직 접속 전 — 소켓은 열리지 않았다.

2

접속 ConnectAsync(endPoint) → ClientSession

연결을 열고, 보안 축이 있으면 그 핸드셰이크까지 마친다. 버전 협상·콘텐츠 핑거프린트가 여기서 검사되고, 실패하면 예외로 나온다.

3

송신 FrameWriter.WriteFrameAsync(session.Connection.Output, …)

세션의 출력 파이프에 프레임을 쓴다. 서버와 같은 MessageId · 같은 프레이밍이다.

4

수신 ConfigureDispatcher 의 핸들러

서버가 보낸 프레임(에코·응답·푸시)이 등록한 핸들러로 들어온다. 무상태라면 응답을 시퀀스로 짝지어 요청과 맞춘다.

5

종료 session.Connection.DisposeAsync()

정상 종료. 서버가 끊거나 네트워크가 죽으면 Connection.ConnectionClosed 취소 토큰이 신호한다.

6

재접속 상위에서 직접

프레임워크가 하지 않는다. 끊김을 감지해 백오프로 다시 ConnectAsync 하고, 상태 유지 세션이면 재개 토큰으로 세션을 복원한다 — 아래 참고.

최소 클라이언트 · 왕복

보내고, 에코를 받는다

조립도의 슬롯이 코드에서 어떻게 채워지는지 그대로 보여준다. 서버와 같은 framing·MessageId(1) 로 짝을 이룬다.

클라이언트 · Program.cs
// 클라이언트도 서버와 같은 프레이밍·인코더를 쓴다.
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 이 찍히면 왕복이 완성된 것이다. 전체 using 목록은 docs/GETTING-STARTED.md 2.3 절에 있다.

클라이언트의 핵심 결정

재접속과 세션 재개

클라이언트에서 가장 자주 헷갈리는 지점이다. 둘 다 일부러 자동화하지 않았다.

재접속은 프레임워크가 안 한다

백오프·재시도를 ClientBuilder 안에 감추면, 상위 계층이 “연결이 살아 있다”고 오해해 세션 재수립(인증·상태 복원)을 건너뛴다.

그래서 재접속은 이 위에서 조립한다 — ConnectionClosed 를 감지 → 백오프 → 다시 ConnectAsync → (필요하면) 세션 재개.

세션 재개 토큰 32B

재접속할 때 “내가 그 세션의 주인”임을 증명하는 32바이트 난수다. 로그·메트릭에 노출되고 추측·열거될 수 있는 SessionId 대신 이 토큰으로 증명한다.

서버는 재개가 성공하면 새 토큰을 발급(회전)한다 — 클라이언트가 이걸 보관해야 다음 재접속이 된다. 도난돼도 다음 재개에서 무효가 되는 1회용이다.

무상태 프로필이라면

재접속이 곧 새 요청이다. 복원할 세션이 없으니 재개 토큰도 필요 없다.

대신 응답은 순서 보장이 없으므로 시퀀스 번호로 요청과 짝짓는다.

막히면

클라이언트에서 자주 만나는 벽

ConnectAsyncVersionNegotiationException서버와 프로토콜 버전 구간이 겹치지 않는다. 양쪽 버전 협상 설정을 맞춘다.
ConnectAsyncContentFingerprintMismatchException서버·클라이언트의 메시지 계약(핑거프린트)이 다르다. 같은 스키마·빌드로 맞춘다.
응답이 순서 없이 뒤섞여 온다무상태엔 순서 보장이 없다(정상). 보낼 때 시퀀스를 실어 응답과 짝지어라.
에코 페이로드가 깨져 읽힘Payload 를 핸들러 반환 뒤에 읽었다. 반환 전에 역직렬화하거나 .ToArray() 로 복사한다.
끊겼는데 자동으로 다시 안 붙는다재접속은 프레임워크가 하지 않는다(의도). ConnectionClosed 를 감지해 상위에서 다시 ConnectAsync 한다.