MagicOnion 의 하트비트는 상대의 메시지 처리 루프를 잰다
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를 봅니다.