MagicOnion 의 하트비트는 상대의 메시지 처리 루프를 잰다
Esc

    MagicOnion 의 하트비트는 상대의 메시지 처리 루프를 잰다

    –C#라이브러리네트워크
    builder.Services.AddMagicOnion(options =>
    {
        options.EnableStreamingHubHeartbeat = true;
        options.StreamingHubHeartbeatInterval = TimeSpan.FromSeconds(15);
        options.StreamingHubHeartbeatTimeout = TimeSpan.FromSeconds(10);
    });

    죽은 클라이언트를 정리하려고 하트비트를 켰더니, 로딩 화면을 지나던 멀쩡한 클라이언트가 끊깁니다. 또는 [Heartbeat] 를 붙였는데 아무것도 오가지 않습니다.

    MagicOnion 7 의 하트비트는 HTTP/2 PING 이 아니라 허브 스트림 위의 메시지라서, 상대 기계가 아니라 상대의 메시지 처리 루프가 도는지를 잽니다. 중간 장비가 대신 답할 수 없는 대신, 루프가 바쁘면 살아 있는 상대도 죽었다고 판정합니다. 기준 판본은 7.11.0 입니다.

    기본은 꺼져 있고, 간격이 없으면 켜지지 않는다

    서버 옵션 셋의 기본값은 false, null, null 입니다. 허브 레지스트리는 허브를 등록할 때 옵션과 [Heartbeat] 를 합쳐 한 번 정하고, 켜짐이 아니거나 간격이 null 이면 아무것도 안 하는 관리자를 넣습니다.

    설정 결과
    허브에 [Heartbeat] 만 (옵션에 간격 없음) 꺼짐. 경고 없음
    [Heartbeat(Interval = 200, Timeout = 500)] 200 ms 마다 보내고 500 ms 동안 답이 없으면 끊음
    옵션에 Enable = true 와 Interval 만 보내기만 하고 끊지 않음 (유휴 연결 유지용)
    [Heartbeat(false)] 전역이 켜져 있어도 이 허브만 끔

    [Heartbeat] 만 붙인 허브에 1.5초 연결해 두었더니 서버 하트비트가 한 번도 오지 않았습니다. 클라이언트 하트비트는 StreamingHubClientOptions 로 따로 켜고 역시 기본은 꺼짐입니다.

    제한 시간 시계는 최신 번호의 답만 멈춘다

    서버는 허브 타입마다 PeriodicTimer 하나로 틱마다 순번을 붙인 하트비트를 만들어 연결마다 복사해 보냅니다. 시계는 답을 못 받은 첫 하트비트를 보낼 때 켜지고 뒤이은 틱이 늘려 주지 않으며, 답은 가장 최근 번호일 때만 시계를 멈춥니다.

    둘째 규칙 때문에 왕복 시간이 간격보다 긴 연결은 답이 꼬박꼬박 와도 끊깁니다. 방향마다 150 ms 를 늦추는 중계를 두고 Timeout = 1000 으로 붙었습니다.

    간격 서버가 인정한 답 결과
    200 ms (왕복 300 ms 보다 짧음) 0회 (클라이언트는 6번 답함) 약 1.2초 만에 서버가 끊음
    500 ms (왕복보다 김) 19회 10초 동안 유지

    (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, 같은 프로세스의 TCP 중계.)

    클라이언트 메인 스레드가 멈추면 서버가 끊는다

    클라이언트가 서버 하트비트에 답하는 일은 ConnectAsync 때 잡은 SynchronizationContext 로 Post 된 콜백 안에서 합니다. 유니티에서 메인 스레드에서 연결했다면 답은 메인 스레드가 만듭니다.

    단일 스레드 펌프 안에서 연결한 뒤 펌프를 멈춘 기록입니다(Interval = 200, Timeout = 500).

    시각 일어난 일 멈춘 뒤
    3,592 ms 클라이언트 펌프 멈춤 0
    4,207 ms 서버 하트비트의 TimeoutToken 취소 +615 ms
    5,217 ms 서버 OnDisconnected (실행 중인 메서드를 1초 기다린 뒤) +1,625 ms
    그 뒤 클라이언트 WaitForDisconnectAsync 결과 CompletedNormally, 예외 없음

    서버는 스트림을 정상으로 닫아서 클라이언트에게는 CompletedNormally 로 보입니다. TimedOut 은 클라이언트 쪽 하트비트가 끊었을 때만 나옵니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2.)

    바쁜 서버는 멀쩡한 연결을 스스로 끊는다

    서버가 클라이언트의 답을 읽는 곳은 요청을 요청 큐에 넣는 바로 그 수신 루프이고, 요청 큐는 용량 10 에 차면 기다립니다. 느린 허브 메서드가 도는 동안 같은 연결의 호출이 쌓이면 수신 루프가 멈춰 답을 못 읽습니다.

    경계는 큐 10, 실행 중 1, 넣지 못한 1 을 더한 곳에 정확히 걸렸습니다. 클라이언트도 네트워크도 정상인데 서버가 스스로 끊은 것입니다.

    주의할 점

    1. [Heartbeat] 를 붙였다고 켜졌다고 믿지 않는다

    안 지키면 생기는 문제. 옵션에 간격이 없으면 [Heartbeat] 허브에 아무것도 안 하는 관리자가 들어갑니다. 죽은 클라이언트를 정리해 줄 거라 믿었던 허브가 연결을 끝없이 쥐고 있습니다.

    이렇게 쓴다. 속성에 Interval 과 Timeout 을 함께 적거나 서버 옵션에 StreamingHubHeartbeatInterval 을 넣습니다. 허브 메서드에서 IMagicOnionHeartbeatFeature 를 꺼내 Latency 가 0 이 아닌 값으로 바뀌는지 한 번 확인합니다.

    2. Interval 은 최악의 왕복 시간보다 길게 잡는다

    안 지키면 생기는 문제. 답이 오기 전에 다음 틱이 번호를 올리면 도착한 답은 늘 옛 번호라 무시됩니다. 모바일 망에서 반응을 빨리 보겠다고 간격을 짧게 잡으면 멀쩡한 연결이 제한 시간마다 끊깁니다.

    이렇게 쓴다. 간격은 서비스 지역의 최악 왕복 시간과 상대 처리 지연을 더한 것보다 넉넉히 잡습니다(수 초 단위). 빨리 감지하고 싶으면 간격이 아니라 제한 시간을 줄입니다.

    3. Timeout 은 클라이언트 메인 스레드가 가장 오래 멈추는 시간보다 길게 잡는다

    안 지키면 생기는 문제. 씬 로딩, 큰 에셋 동기 로드, GC 스파이크로 메인 스레드가 첫 틱까지의 시간과 제한 시간을 합친 것보다 오래 멈추면 서버가 끊습니다. 클라이언트는 CompletedNormally 를 보고 끊긴 줄 모를 수 있습니다.

    이렇게 쓴다. 게임에서 메인 스레드가 가장 오래 멈추는 구간(보통 로딩)을 재고 제한 시간을 그보다 길게 잡습니다. 로딩 전에 연결을 닫고 로딩 뒤에 다시 붙는 것도 방법입니다.

    4. 서버 하트비트를 켠 허브에서 한 연결의 동시 호출을 10건 아래로 둔다

    안 지키면 생기는 문제. 느린 메서드가 도는 동안 같은 연결에 12건 이상이 쌓이면 수신 루프가 멈춰 하트비트 답을 못 읽고, 12건 모두 TaskCanceledException 으로 끝났습니다.

    이렇게 쓴다. 오래 걸리는 작업은 허브 메서드에서 await 로 붙잡지 말고 시작만 한 뒤 결과를 리시버로 알립니다. 클라이언트에서도 한 연결에 떠 있는 응답 대기 호출 수를 세어 10건 가까이면 새 호출을 늦춥니다.

    정리

    MagicOnion 의 하트비트는 연결이 아니라 상대의 메시지 처리 루프가 도는지를 잽니다.

    • 기본은 꺼짐이고, 서버는 간격이 있어야 켜지므로 [Heartbeat] 만으로는 아무 일도 없습니다.
    • 제한 시간 시계는 답 없는 첫 하트비트에서 켜져 늘어나지 않고, 최신 번호의 답만 시계를 멈춥니다. 왕복 시간이 간격보다 길면 끊깁니다.
    • 클라이언트 메인 스레드가 멈추거나 서버 요청 큐가 차면 살아 있는 상대도 끊기고, 서버가 끊은 경우 클라이언트는 CompletedNormally 를 봅니다.