---
title: "MessagePipe 의 구독 진단은 숫자도 위치도 틀릴 수 있다"
description: "MessagePipe 는 해제 누락을 찾으라고 구독 수와 구독한 곳을 기록합니다. 그런데 비동기 브로커에서는 구독 수가 음수로 내려가고, 구독한 곳은 네임스페이스 이름이나 IL2CPP 설정에 따라 엉뚱하게 찍히거나 사라집니다."
author: "유니얼"
pubDate: 2026-09-21
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징", "프로파일링"]
canonical: https://blog.unialgames.com/posts/unity-messagepipe-diagnostics/
---
씬을 넘겼는데 이전 씬 오브젝트의 핸들러가 계속 불립니다. 어딘가에서 `Subscribe` 가 돌려준 `IDisposable` 을 풀지 않은 것입니다. MessagePipe 는 이것을 찾으라고 진단 창(`Window > MessagePipe Diagnostics`)을 넣어 두었습니다.

진단은 **구독 수와 구독한 곳을 기록하지만, 둘 다 그대로 믿으면 안 됩니다.** 비동기 브로커에서는 구독 수가 실제보다 작아지고, 구독한 곳은 네임스페이스 이름과 IL2CPP 설정에 따라 엉뚱하게 찍히거나 파일과 줄이 빠집니다.

## 구독 수는 늘 세고, 구독한 곳은 켜야 기록한다

구독 수(`SubscribeCount`)는 `Interlocked.Increment` 한 번으로 늘 셉니다. 구독한 곳은 옵션 `EnableCaptureStackTrace` 를 켜야 `new StackTrace(true)` 로 뽑아 전역 락 안에서 딕셔너리에 넣습니다. 창이 컨테이너를 찾을 수 있게 루트 스코프에서 `GlobalMessagePipe.SetProvider` 를 한 번 부릅니다.

```csharp
var options = builder.RegisterMessagePipe(o => o.EnableCaptureStackTrace = true);
builder.RegisterBuildCallback(c => GlobalMessagePipe.SetProvider(c.AsServiceProvider()));
```

| 구독 + 해제 1회 | .NET 10 | Mono | IL2CPP |
| --- | --- | --- | --- |
| `false` (기본) | 152 ns / 56 B | 270 ns / 56 B | 348 ns / 64 B |
| `true` | **21,803 ns / 14,807 B** | **23,207 ns / 1,614 B** | **17,779 ns / 1,400 B** |

구독 한 번이 17~23 µs 가 됩니다. 발행 경로에는 진단 코드가 없어서 발행은 옵션과 상관없이 0 B 입니다(람다 구독, 유니티는 플레이어 빌드).

## 비동기 브로커는 정상 순서로 해제해도 음수가 된다

씬 스코프가 닫히면 컨테이너가 브로커의 Core 를 `Dispose` 하고, 진단은 그 Core 의 구독 수만큼 한 번에 뺍니다. 그다음 오브젝트들이 `OnDestroy` 에서 구독을 풉니다. 흔한 순서입니다.

<x-compare>
<script type="application/json">
{
  "label": "Core 가 먼저 닫힌 뒤 구독을 Dispose 하면",
  "tabs": [
    { "label": "동기 Subscription.Dispose", "code": "// Runtime/MessageBroker.cs\nlock (core.gate)\n{\n    if (!core.isDisposed)            // Core 가 닫혔으면 건너뛴다\n    {\n        core.handlers.Remove(subscriptionKey, true);\n        core.diagnostics.DecrementSubscribe(core, this);\n    }\n}", "text": "Core 가 닫힐 때 이미 한꺼번에 뺐으니, 뒤늦은 개별 해제는 아무것도 안 합니다.", "stats": [["구독 2개, Core 닫힘, 개별 해제 2번", "0"]], "tone": "good" },
    { "label": "비동기 Subscription.Dispose", "code": "// Runtime/AsyncMessageBroker.cs\nlock (core.gate)\n{\n    core.handlers.Remove(subscriptionKey, true);        // 닫힌 FreeList 는 조용히 돌아온다\n    core.diagnotics.DecrementSubscribe(core, this);     // 그래도 수는 뺀다\n}", "text": "isDisposed 검사가 없습니다. 구독 목록에서 빼는 쪽은 FreeList 가 알아서 무시하지만, 진단 수는 한 번 더 빠집니다.", "stats": [["구독 2개, Core 닫힘, 개별 해제 2번", "−2"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

| 브로커 | 구독 2개 | `Core.Dispose()` 뒤 | 그 뒤 구독을 하나씩 `Dispose` |
| --- | --- | --- | --- |
| 동기 | 2 | 0 | **0** |
| 비동기 | 2 | 0 | **−2** |

구독 목록은 제대로 비었고 틀리는 것은 진단 숫자뿐입니다. 하지만 씬을 몇 번 오가면 음수로 내려가 있고, 그 뒤 새로 새는 구독이 생겨도 0 근처로 보입니다.

## 구독한 곳은 MessagePipe 밖 첫 프레임이다

구독한 곳을 고르는 코드는 스택을 위에서부터 훑다가, 네임스페이스가 `MessagePipe` 로 시작하지 않는 첫 프레임에서 멈춥니다. 라이브러리 안쪽을 건너뛰려는 것인데, `StartsWith` 라서 **그 글자로 시작하기만 하면** 라이브러리로 봅니다.

<x-flow>
<script type="application/json">
{
  "label": "구독한 곳을 고르는 루프",
  "lines": ["for (int i = 0; i < stackTrace.FrameCount; i++)", "    var m = stackTrace.GetFrame(i).GetMethod();", "    if (ns == null || !ns.StartsWith(\"MessagePipe\"))", "        return 타입.메서드 (at 파일:줄);"],
  "cases": [
    {
      "label": "Game 에서 구독",
      "state": "Game.HealthBar.Start 가 Subscribe 를 부른다",
      "path": [
        { "line": 0, "why": "스택 맨 위부터 봅니다." },
        { "line": 1, "why": "맨 위 몇 칸은 MessagePipe 안의 진단 기록과 Subscribe 경로입니다." },
        { "line": 2, "why": "네임스페이스가 MessagePipe 로 시작하니 건너뜁니다.", "verdict": "false" },
        { "line": 1, "why": "라이브러리를 다 지나 Game.HealthBar.Start 프레임에 닿습니다." },
        { "line": 2, "why": "Game 은 MessagePipe 로 시작하지 않습니다.", "verdict": "true" },
        { "line": 3, "why": "이 프레임을 구독한 곳으로 적습니다.", "out": "Game.HealthBar.Start (at HealthBar.cs)" }
      ],
      "note": "구독한 줄이 그대로 찍힙니다."
    },
    {
      "label": "MessagePipeGame 에서 구독",
      "state": "MessagePipeGame.ScoreView.Bind 가 Subscribe 를 부른다. 이 메서드는 Game.UiInstaller.Setup 에서 불렸다",
      "path": [
        { "line": 0, "why": "위와 같이 라이브러리 프레임을 건너뜁니다." },
        { "line": 2, "verdict": "false" },
        { "line": 1, "why": "MessagePipeGame.ScoreView.Bind 프레임에 닿습니다. 여기가 실제로 구독한 곳입니다." },
        { "line": 2, "why": "그런데 MessagePipeGame 은 MessagePipe 로 시작합니다. 라이브러리로 보고 건너뜁니다.", "verdict": "false" },
        { "line": 1, "why": "한 칸 위, 이 메서드를 부른 Game.UiInstaller.Setup 으로 올라갑니다." },
        { "line": 2, "verdict": "true" },
        { "line": 3, "why": "호출한 쪽이 구독한 곳으로 적힙니다.", "out": "Game.UiInstaller.Setup (at UiInstaller.cs)" }
      ],
      "note": "구독한 줄은 목록에 아예 나오지 않고 그 호출자가 대신 나옵니다."
    }
  ]
}
</script>
</x-flow>

## IL2CPP 기본 설정은 파일과 줄을 남기지 않는다

같은 코드를 IL2CPP 플레이어로 돌리면 구독한 곳에 메서드 이름만 찍힙니다. Player Settings 의 **IL2CPP Stacktrace Information** 이 기본값이면 스택 프레임에 파일과 줄이 없기 때문입니다.

| IL2CPP Stacktrace Information | 구독한 곳으로 찍힌 값 |
| --- | --- |
| `Method Name` (기본) | `Probe.Run` 처럼 메서드 이름만 |
| `Method Name, File Name, and Line Number` | `Probe.Run (at Probe.cs:줄 번호)` |

설정을 바꿔도 구독을 감싼 작은 메서드가 인라인되어 빠지면, 그 한 칸 바깥 메서드가 구독한 곳으로 찍혔습니다. 에디터(Mono)에서는 그대로 찍혔습니다.

## 주의할 점

### 1. 스택 트레이스 옵션은 에디터에서 켜고 에디터에서 본다

**안 지키면 생기는 문제.** 켠 채로 빌드하면 오브젝트를 꺼낼 때마다, 씬을 열 때마다 구독 한 번이 유니티에서 51~86배 느려집니다. 그렇게 켜도 IL2CPP 기본 설정에서는 파일과 줄이 빠져 쓸모가 적습니다.

**이렇게 쓴다.** `#if UNITY_EDITOR` 로 감싸 에디터에서만 켜고, 누수가 의심되면 같은 흐름을 에디터에서 재현해 찾습니다. 옵션은 플레이를 시작하기 전에 정합니다.

### 2. SubscribeCount 가 0 이라고 누수가 없다고 판정하지 않는다

**안 지키면 생기는 문제.** 비동기 브로커는 스코프가 먼저 닫히고 구독이 나중에 풀리는 흔한 순서에서 구독 수를 한 번 더 뺍니다. 음수에 묻혀 새로 새는 구독이 0 근처로 보이고, "0 이니 괜찮다" 고 넘어가게 됩니다.

**이렇게 쓴다.** 이 숫자는 씬 전환 전후의 변화 방향을 보는 참고값으로만 쓰고, 의심되면 구독한 곳 목록을 직접 봅니다.

### 3. 프로젝트 네임스페이스를 MessagePipe 로 시작하지 않는다

**안 지키면 생기는 문제.** `MessagePipeGame` 처럼 지은 프로젝트는 진단 창에 구독한 줄 대신 그 호출자가 찍혀, 누수를 찾으러 연 창이 엉뚱한 파일을 가리킵니다.

**이렇게 쓴다.** 프로젝트 네임스페이스는 `MessagePipe` 라는 글자로 시작하지 않게 짓습니다. 이것 말고는 피할 방법이 없습니다.

## 정리

MessagePipe 의 구독 진단은 **해제 누락을 찾게 해 주지만 숫자도 위치도 틀릴 수 있습니다.**

- 구독 수는 늘 공짜로 세고, 구독한 곳은 켜면 구독 한 번이 17~23 µs 가 됩니다.
- 비동기 브로커는 해제에 닫힘 검사가 없어 구독 수가 음수로 내려갑니다.
- 구독한 곳은 `MessagePipe` 로 시작하지 않는 첫 프레임이고, IL2CPP 기본 설정에서는 파일과 줄이 빠집니다.