---
title: "MagicOnion 의 하트비트는 상대의 메시지 처리 루프를 잰다"
description: "네트워크는 멀쩡한데 하트비트가 연결을 끊습니다. MagicOnion 의 하트비트는 PING 이 아니라 허브 메시지라서, 답이 상대의 처리 루프를 지나야 나오기 때문입니다. 메인 스레드가 멈추거나 서버 요청 큐가 차면 끊깁니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-heartbeat/
---
```csharp
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` 하나로 틱마다 순번을 붙인 하트비트를 만들어 연결마다 복사해 보냅니다. 시계는 **답을 못 받은 첫 하트비트**를 보낼 때 켜지고 뒤이은 틱이 늘려 주지 않으며, 답은 **가장 최근 번호**일 때만 시계를 멈춥니다.

<x-flow>
<script type="application/json">
{
  "label": "서버 하트비트의 제한 시간 판정 (Interval 200 ms, Timeout 500 ms, 줄인 코드)",
  "lines": [
    "// 틱: 하트비트를 보내며",
    "waitingSequence = sequence;",
    "if (!timeoutTimerIsRunning) { timeoutToken.CancelAfter(timeout); timeoutTimerIsRunning = true; }",
    "// 답이 오면: Ack(seq)",
    "if (waitingSequence != seq) return;",
    "timeoutToken.CancelAfter(Timeout.InfiniteTimeSpan); timeoutTimerIsRunning = false;",
    "// 시계가 다 되면",
    "timeoutToken 취소 → 수신 루프의 MoveNext(ct) 가 빠져나온다"
  ],
  "cases": [
    {
      "label": "답이 제때 온다",
      "state": "t = 0 ms · 시계 꺼짐",
      "path": [
        { "line": 0, "why": "0번 하트비트를 보냅니다." },
        { "line": 1, "state": "t = 0 · 기다리는 번호 0" },
        { "line": 2, "why": "시계가 꺼져 있으니 켭니다.", "state": "t = 0 · 기다리는 번호 0 · 시계: t = 500 에 만료" },
        { "line": 3, "why": "1 ms 뒤 0번의 답이 옵니다." },
        { "line": 4, "verdict": "0 == 0" },
        { "line": 5, "why": "시계를 끕니다.", "state": "t = 1 · 시계 꺼짐", "out": "Latency 약 0.6 ms" },
        { "line": 0, "why": "200 ms 에 1번을 보냅니다. 같은 일이 반복됩니다." },
        { "line": 1, "state": "t = 200 · 기다리는 번호 1" },
        { "line": 2, "state": "t = 200 · 시계: t = 700 에 만료" }
      ]
    },
    {
      "label": "t = 0 에 상대가 멈춘다",
      "state": "t = 0 ms · 상대 처리 루프 멈춤 · 다음 틱 t = 115",
      "path": [
        { "line": 0, "why": "멈춘 뒤 첫 틱입니다. 5번을 보냅니다." },
        { "line": 1, "state": "t = 115 · 기다리는 번호 5" },
        { "line": 2, "why": "시계를 켭니다.", "state": "t = 115 · 시계: t = 615 에 만료" },
        { "line": 0, "why": "6번을 보냅니다." },
        { "line": 1, "state": "t = 315 · 기다리는 번호 6" },
        { "line": 2, "verdict": "이미 도는 중", "why": "시계를 늘려 주지 않습니다.", "state": "t = 315 · 시계: t = 615 에 만료 (그대로)" },
        { "line": 0 },
        { "line": 1, "state": "t = 515 · 기다리는 번호 7" },
        { "line": 6 },
        { "line": 7, "state": "t = 615 · 제한 시간", "out": "멈춘 지 615 ms 에 연결 종료 판정" }
      ],
      "skip": [4, 5]
    }
  ],
  "note": "끊기는 시각은 멈춘 뒤 첫 틱까지(0~Interval) 더하기 Timeout 입니다. 실측 두 경우 모두 +615 ms 였습니다."
}
</script>
</x-flow>

둘째 규칙 때문에 왕복 시간이 간격보다 긴 연결은 답이 꼬박꼬박 와도 끊깁니다. 방향마다 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 된 콜백 안에서 합니다. 유니티에서 메인 스레드에서 연결했다면 답은 메인 스레드가 만듭니다.

<x-wiring>
<script type="application/json">
{
  "label": "서버 하트비트의 답이 클라이언트 메인 스레드를 거치는 길",
  "width": 760,
  "height": 330,
  "nodes": [
    { "id": "timer", "title": "서버 하트비트 타이머", "sub": "허브 타입당 하나", "x": 0, "y": 8, "w": 170, "h": 54 },
    { "id": "rl", "title": "클라 받는 루프", "sub": "스레드 풀, 멀쩡함", "x": 295, "y": 8, "w": 170, "h": 54 },
    { "id": "main", "title": "메인 스레드", "sub": "연결할 때 잡은 컨텍스트", "x": 590, "y": 8, "w": 170, "h": 54 },
    { "id": "wq", "title": "클라 보내는 줄", "sub": "writerQueue", "x": 590, "y": 150, "w": 170, "h": 54 },
    { "id": "ack", "title": "서버 수신 루프", "sub": "Ack(seq)", "x": 295, "y": 150, "w": 170, "h": 54 },
    { "id": "cut", "title": "제한 시간 토큰", "sub": "Interval 뒤 첫 틱 + Timeout", "x": 0, "y": 270, "w": 170, "h": 54 }
  ],
  "edges": [
    { "from": "timer", "to": "rl", "label": "[7f, seq, 시각]" },
    { "from": "rl", "to": "main", "label": "Post" },
    { "from": "main", "to": "wq", "label": "답 만들기" },
    { "from": "wq", "to": "ack", "label": "[7f, seq, 시각] 답" },
    { "from": "timer", "to": "cut", "label": "답이 없으면" }
  ],
  "steps": [
    { "edge": 0, "why": "서버가 하트비트를 보냅니다. 클라이언트의 받는 루프는 문제없이 읽습니다." },
    { "edge": 1, "why": "받는 루프는 답을 만들지 않고 메인 스레드로 Post 합니다." },
    { "edge": 2, "why": "메인 스레드가 돌고 있으면 콜백이 답을 만들어 보내는 줄에 넣습니다." },
    { "edge": 3, "why": "서버 수신 루프가 최신 번호의 답을 받아 시계를 끕니다." },
    { "node": "main", "why": "이번에는 메인 스레드가 씬 로딩으로 멈췄습니다. Post 된 콜백이 줄만 섭니다." },
    { "edge": 0, "why": "서버는 계속 하트비트를 보내고 받는 루프도 계속 읽습니다. 답만 안 나갑니다." },
    { "edge": 4, "why": "제한 시간이 지나 서버가 연결을 닫습니다. 클라이언트는 이 종료를 정상 종료로 봅니다." }
  ],
  "note": "받는 루프는 끝까지 멀쩡했습니다. 끊긴 원인은 답을 만드는 자리가 메인 스레드라는 데 있습니다."
}
</script>
</x-wiring>

단일 스레드 펌프 안에서 연결한 뒤 펌프를 멈춘 기록입니다(`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 에 차면 기다립니다. 느린 허브 메서드가 도는 동안 같은 연결의 호출이 쌓이면 수신 루프가 멈춰 답을 못 읽습니다.

<x-compare>
<script type="application/json">
{
  "label": "서버 하트비트를 켠 허브에 SlowAsync(1000) 을 동시에 부르기 (Interval 200, Timeout 500)",
  "tabs": [
    { "label": "11건", "text": "하나는 실행 중, 열은 요청 큐에 들어갔다. 수신 루프는 막히지 않고 하트비트 답을 계속 읽었다.", "stats": [["결과", "11/11 성공"], ["걸린 시간", "11,111 ms"], ["연결", "유지"]], "tone": "good" },
    { "label": "12건", "text": "열두 번째를 넣지 못한 수신 루프가 기다리는 동안 하트비트 답이 쌓였다. 클라이언트와 네트워크는 정상이었다.", "stats": [["제한 시간", "+615 ms"], ["OnDisconnected", "+1,012 ms"], ["결과", "12/12 TaskCanceledException"], ["클라가 본 이유", "CompletedNormally"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

경계는 큐 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` 를 봅니다.