---
title: "VitalRouter 의 구독과 해제는 목록을 처음부터 찾는다"
description: "씬을 나갈 때 구독자 4,000명을 하나씩 Dispose 하면 IL2CPP 에서 3 ms 가 넘습니다. VitalRouter 는 빈 칸도 해제할 구독자도 배열 처음부터 찾기 때문입니다. 함께 사라지는 구독은 라우터째 버리면 탐색이 없습니다."
author: "유니얼"
pubDate: 2026-09-22
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징", "자료구조"]
canonical: https://blog.unialgames.com/posts/unity-vitalrouter-subscription/
---
```csharp
void OnDestroy()
{
    foreach (var s in subscriptions) s.Dispose();   // 4,000개
}
```

C# `event` 의 `-=` 는 델리게이트 목록에서 핸들러를 찾아 뺍니다. VitalRouter 의 해제도 비슷하게 목록에서 찾는데, 구독도 해제도 **배열 처음부터** 찾습니다.

그래서 구독이나 해제 한 번의 비용이 라우터에 붙어 있는 구독자 수에 비례하고, N 명을 하나씩 정리하는 총비용은 **N 의 제곱**으로 늘어납니다. 라우터를 `Dispose` 하면 찾지 않고 통째로 비웁니다.

## 빈 칸도, 해제할 구독자도 처음부터 찾는다

구독 목록 `FreeList<T>` 는 해제한 칸을 `null` 로 비워 두는 배열입니다. 빈 칸 번호를 모아 두는 큐가 없어서 구독할 때마다 처음부터 첫 `null` 을 찾고(참조 배열을 정수 배열로 읽어 `IndexOf(0)`), 해제할 때는 칸 번호가 아니라 **구독자 객체**로 처음부터 찾습니다.

<x-flow>
<script type="application/json">
{
  "label": "해제할 구독자를 찾는 루프",
  "lines": ["for (var i = 0; i <= lastIndex; i++)", "    if (values[i] == value)", "        RemoveAt(i); return true;"],
  "cases": [
    {
      "label": "맨 앞 칸 해제",
      "state": "칸 0 = A, 1 = B, 2 = C, 3 = D. A 를 해제",
      "path": [
        { "line": 0, "why": "칸 0 부터 봅니다." },
        { "line": 1, "why": "A 입니다.", "verdict": "true" },
        { "line": 2, "why": "한 번 비교하고 끝납니다.", "out": "비교 1번" }
      ],
      "note": "A 가 빠져도 B 는 앞으로 당겨지지 않고 칸 1 에 그대로 있습니다."
    },
    {
      "label": "맨 뒤 칸 해제",
      "state": "칸 0 = A, 1 = B, 2 = C, 3 = D. D 를 해제",
      "path": [
        { "line": 0, "why": "칸 0 부터 봅니다." },
        { "line": 1, "verdict": "false" },
        { "line": 0, "why": "칸 1." },
        { "line": 1, "verdict": "false" },
        { "line": 0, "why": "칸 2." },
        { "line": 1, "verdict": "false" },
        { "line": 0, "why": "칸 3." },
        { "line": 1, "why": "D 입니다.", "verdict": "true" },
        { "line": 2, "why": "네 번 비교했습니다.", "out": "비교 4번" }
      ],
      "note": "i 번 칸의 구독자는 i + 1 번 비교해야 찾습니다."
    }
  ]
}
</script>
</x-flow>

구독자 N 명이 붙어 있을 때 구독 하나를 붙였다 떼는 한 번의 비용입니다(유니티는 플레이어 빌드).

| 상주 구독자 N | .NET 10 | Mono | IL2CPP |
| --- | --- | --- | --- |
| 500 | 821.6 ns | 769.5 ns | 1,480.1 ns |
| 4,000 | 3,473.0 ns | 3,955.7 ns | 8,956.9 ns |
| 상주 구독자 1명당 | 0.87 ns | 0.99 ns | 2.24 ns |

한 번이 N 에 비례하니 N 명을 차례로 정리하면 1 + 2 + … + N, 곧 N 의 제곱입니다.

## 순서를 바꿔 지워도 줄지 않는다

앞에서부터 지우면 늘 맨 앞에서 바로 찾을 것 같지만, 해제한 칸이 당겨지지 않으니 `i` 번째로 지우는 대상은 여전히 `i` 번 칸에 있습니다.

| 4,000명 해제 | 구독한 순서대로 | 역순으로 |
| --- | --- | --- |
| .NET 10 | 7.3~8.5 ms | 7.4~8.7 ms |
| Mono | 4.3~4.7 ms | 4.3~4.5 ms |
| IL2CPP | 3.0~3.2 ms | 3.3 ms |

세 런타임 모두 차이가 없었습니다.

## 라우터째 버리면 찾지 않는다

`Router.Dispose()` 는 목록을 하나씩 지우지 않고 쓰인 범위를 한 번에 0 으로 채웁니다.

| 하나씩 해제 대 `router.Dispose()` | 4,000명 |
| --- | --- |
| .NET 10 | 7.13 ms 대 0.0008 ms (8,915배) |
| Mono | 4.46 ms 대 0.0017 ms (2,626배) |
| IL2CPP | 3.13 ms 대 0.0056 ms (559배) |

다만 `Dispose` 는 목록을 비우기만 하고 라우터를 닫지는 않습니다. `Dispose` 한 라우터에 다시 구독하고 발행하니 예외 없이 구독자가 불렸습니다.

## 구독 핸들은 구조체다

`Subscribe`, `SubscribeAwait`, `MapTo` 는 모두 `Subscription` 구조체를 돌려줍니다. 핸들 자체는 할당이 없습니다. 대신 `Dispose` 가 "이미 풀었다" 는 표시로 필드를 `null` 로 바꾸는 것이 **복사본에는 보이지 않습니다.** 그리고 해제 대상은 객체로 찾습니다.

| 두 번 구독한 것 | 핸들 복사본으로 두 번 `Dispose` 한 뒤 발행 |
| --- | --- |
| 같은 `ICommandSubscriber` 객체 | 0번 불림 (두 칸 모두 지워짐) |
| 같은 람다 | 1번 불림 (람다는 구독마다 새 래퍼라 한 칸만 지워짐) |

세 런타임이 같았습니다. `[Routes]` 의 `MapTo` 는 먼저 `UnmapRoutes()` 를 불러 같은 객체가 두 번 구독되지 않게 합니다.

구조체라서 `IDisposable` 을 받는 곳으로 넘기면 박싱됩니다. README 가 권하는 `AddTo(destroyCancellationToken)` 이 그런 확장 메서드라, 구독 한 번이 `Subscribe` + `Dispose` 만일 때 32 B 에서 토큰 등록까지 붙어 **120 B** 가 됩니다(Mono, IL2CPP 같음).

## 주의할 점

### 1. 함께 사라지는 구독은 라우터째 버린다

**안 지키면 생기는 문제.** 씬을 나갈 때 구독자 수천 명을 하나씩 `Dispose` 하면 비용이 N 의 제곱으로 늘어, 4,000명에 IL2CPP 3.13 ms 를 정리에만 씁니다. 순서를 바꿔도 줄지 않습니다.

**이렇게 쓴다.** 화면이나 기능처럼 함께 사라지는 단위로 라우터를 따로 두고 그 라우터를 `Dispose` 합니다. `Dispose` 한 라우터는 다시 써도 알려 주지 않으니, 담았던 필드를 `null` 로 바꾸고 필요하면 새로 만듭니다.

### 2. 구독 핸들을 복사해 두지 않는다

**안 지키면 생기는 문제.** 한쪽에서 `Dispose` 해도 다른 복사본은 아직 해제 전이라고 압니다. 복사본으로 다시 `Dispose` 하면, 같은 객체를 두 번 구독해 둔 경우 살려 두려던 두 번째 구독까지 지워집니다. 람다 구독에서는 안 생기는 일이라 구독 방식에 따라 같은 코드가 다르게 동작합니다.

**이렇게 쓴다.** 핸들은 필드 하나에만 두고 그 필드로만 `Dispose` 합니다. 같은 객체를 두 번 구독하지 않습니다.

### 3. 풀에서 꺼낼 때마다 구독하면 AddTo 를 쓰지 않는다

**안 지키면 생기는 문제.** 꺼낼 때마다 `AddTo(destroyCancellationToken)` 으로 묶으면 매번 120 B 가 쌓입니다. 게다가 그 토큰은 오브젝트가 파괴될 때만 취소되니, 풀에 넣을 때는 해제되지 않고 구독이 계속 쌓입니다.

**이렇게 쓴다.** `AddTo` 는 `Start` 에서 한 번 구독하는 곳에만 씁니다. 풀에서 쓰는 쪽은 핸들을 필드에 두고 직접 풉니다.

```csharp
Subscription subscription;

void OnSpawn()   => subscription = router.Subscribe<Tick>(OnTick);
void OnDespawn() => subscription.Dispose();
```

## 정리

VitalRouter 의 구독과 해제는 **목록을 처음부터 찾아서** 한 번이 상주 구독자 수에 비례합니다.

- N 명을 하나씩 정리하면 N 의 제곱이고, 지우는 순서를 바꿔도 같습니다.
- 라우터를 `Dispose` 하면 찾지 않고 비워서 4,000명 기준 IL2CPP 559배 빠릅니다. 버린 라우터는 다시 쓰지 않습니다.
- `Subscription` 은 구조체라 복사본에는 해제 표시가 안 보이고, `IDisposable` 로 넘기면 박싱됩니다.