---
title: "R3 의 구독은 끝나지 않는 스트림만 직접 거둔다"
description: "R3 는 완료된 구독을 스스로 정리하니 직접 거둘 것은 EveryUpdate 같은 무한 스트림뿐입니다. 수집기는 할당과 개별 제거로 고르고, AddTo(this) 는 파괴에만 묶이며 2022.2 이상 활성 컴포넌트에는 아무것도 붙이지 않습니다."
author: "유니얼"
pubDate: 2026-09-16
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "Rx", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-r3-disposal/
---
```csharp
Observable.EveryUpdate().Subscribe(_ => Tick());          // 누수
Observable.EveryUpdate().Take(5).Subscribe(_ => Tick());  // 누수 아님
```

모든 구독에 해제 코드를 붙이자니 번거롭고, 빼먹으면 씬을 넘겨도 `Tick` 이 계속 불립니다. 둘의 차이는 `Take(5)` 하나입니다.

R3 는 **끝나는 스트림을 스스로 정리합니다.** 직접 거둘 것은 끝이 없는 스트림뿐이고, 그것을 무엇에 담을지는 할당과 개별 제거가 필요한지로 고릅니다.

## 끝나는 스트림은 스스로 풀린다

완료되면 구독이 자동으로 정리되고, 그 해제가 상류 끝까지 전파됩니다.

| 상황 | 자동 해제 |
| --- | --- |
| `OnCompleted` 로 끝남 · `Take(5)` 처럼 개수로 끝남 | **됩니다** |
| `TakeUntil(token)` 이 취소됨 | **됩니다** |
| `Subject` 를 `Dispose` | **됩니다.** 구독자에게 완료가 전파됩니다 |
| `EveryUpdate()` 같은 무한 스트림 | **안 됩니다** |
| `ReactiveProperty` 구독 | **안 됩니다.** 프로퍼티가 사는 동안 유지됩니다 |

직접 거둬야 하는 것은 끝이 없는 스트림뿐입니다. 다만 게임에서는 그것이 대부분입니다.

## 수집기 셋은 할당과 개별 제거로 고른다

구독 여덟 건을 모으는 것을 만 번 반복해 **수집기 몫만** 뽑은 값입니다.

| 수집기 | 형태 | 수집기 비용 | 개별 제거 · 스레드 안전 | 언제 |
| --- | --- | ---: | --- | --- |
| `DisposableBuilder` | `ref struct` | **80 B** | 없음 | 한 메서드 안에서 묶고 끝낼 때 |
| `DisposableBag` | `struct` | 160 B | 없음 | 필드로 들고 계속 추가할 때 |
| `CompositeDisposable` | `class` | 256 B | **둘 다 됨** | 개별 제거나 여러 스레드가 필요할 때 |

`DisposableBuilder` 는 8개까지 스택 필드에 담아 **모으는 동안 힙 할당이 없고**, 80 B 는 `Build()` 가 돌려주는 객체 하나입니다. README 가 무할당이라고 말하는 것도 이 빌더 자체이지 구독이 아닙니다. `ref struct` 라 필드로 들 수 없고 `async` 메서드나 람다 안에서 못 쓰니, `Start` 에서 모아 `Build()` 결과 하나만 필드에 둡니다.

`DisposableBag` 은 값 타입이라 `ref` 로만 다뤄야 합니다. `CompositeDisposable` 은 개별 제거와 스레드 안전을 주는 대신 `Count` 게터조차 락을 잡습니다.

## AddTo(this) 는 파괴에 묶이고 컴포넌트를 붙이지 않는다

가장 짧은 방법은 구독을 오브젝트 수명에 맡기는 `AddTo(this)` 입니다. Unity 2022.2 이상에서 활성 `MonoBehaviour` 에 붙이면 **숨은 컴포넌트를 만들지 않고** 엔진의 `destroyCancellationToken` 에 묶습니다.

<x-flow>
<script type="application/json">
{
  "label": "AddTo 가 어느 길로 가나",
  "lines": ["public static T AddTo<T>(this T disposable, Component c)", "{", "    if (c == null) { disposable.Dispose(); return disposable; }", "    if (c.gameObject.activeInHierarchy && c is MonoBehaviour mb)", "    {", "        disposable.RegisterTo(mb.destroyCancellationToken);", "        return disposable;", "    }", "    return AddTo(disposable, c.gameObject);", "}"],
  "cases": [
    {
      "label": "활성 MonoBehaviour",
      "state": "Unity 2022.2 이상, 오브젝트 활성",
      "path": [
        { "line": 2, "why": "대상이 살아 있으니 넘어갑니다.", "verdict": "false" },
        { "line": 3, "why": "활성이고 MonoBehaviour 입니다.", "verdict": "true" },
        { "line": 5, "why": "엔진이 주는 destroyCancellationToken 에 묶습니다.", "out": "컴포넌트 추가 없음" },
        { "line": 6, "why": "여기서 끝납니다." }
      ],
      "skip": [8],
      "note": "이 경로에서는 하이어라키에 아무것도 안 생깁니다."
    },
    {
      "label": "비활성 오브젝트",
      "state": "activeInHierarchy 가 거짓",
      "path": [
        { "line": 2, "why": "대상은 있습니다.", "verdict": "false" },
        { "line": 3, "why": "활성이 아니라 조건이 무너집니다. Awake 가 아직 안 돌았을 수 있다는 뜻입니다.", "verdict": "false" },
        { "line": 8, "why": "GameObject 쪽으로 넘어가 ObservableDestroyTrigger 를 붙입니다.", "out": "ObservableDestroyTrigger 추가" }
      ],
      "skip": [5, 6],
      "note": "한 번도 활성화된 적 없는 오브젝트는 파괴돼도 OnDestroy 가 안 불립니다. 그래서 활성화를 따로 감시합니다."
    },
    {
      "label": "이미 파괴된 대상",
      "state": "c == null",
      "path": [
        { "line": 2, "why": "붙일 곳이 없으니 그 자리에서 해제하고 끝냅니다.", "verdict": "true", "out": "즉시 해제" }
      ],
      "skip": [3, 5, 6, 8],
      "note": "파괴된 오브젝트에 붙여도 누수가 생기지 않습니다."
    }
  ]
}
</script>
</x-flow>

비활성 오브젝트이거나 `GameObject` 오버로드를 쓰면 여전히 `ObservableDestroyTrigger` 가 붙습니다. 활성 경로의 실체는 `RegisterTo(destroyCancellationToken)` 이라, 토큰을 직접 주면 아무 `CancellationToken` 에나 수명을 맡길 수 있습니다.

```csharp
async Awaitable RunBattleAsync(CancellationToken ct)
{
    Observable.EveryUpdate()
        .Subscribe(_ => UpdateBattle())
        .RegisterTo(ct);           // 토큰이 취소되면 구독도 풀린다

    await BattleLoopAsync(ct);
}
```

비동기 흐름과 스트림이 섞이는 코드에서 해제 지점을 하나로 모을 수 있습니다.

## 주의할 점

### 1. 끝나지 않는 스트림은 구독하는 줄에서 바로 수명에 묶는다

**안 지키면 생기는 문제.** `EveryUpdate` 처럼 끝이 없는 스트림은 완료로 정리될 일이 없습니다. 구독한 오브젝트가 파괴돼도 러너는 PlayerLoop 에 있으니 계속 불립니다. 정적 `Subject` 를 인스턴스가 구독하거나 `DontDestroyOnLoad` 오브젝트가 씬 오브젝트를 구독할 때도 수명이 긴 쪽이 짧은 쪽을 붙들어 씬을 넘겨도 수거되지 않습니다.

**이렇게 쓴다.** `Subscribe` 뒤에 곧바로 `AddTo` 나 `RegisterTo` 를 붙입니다. 새는지 의심되면 **Window → Observable Tracker** 를 열어 씬을 넘긴 뒤에도 남아 있는 구독을 봅니다. 어디서 구독했는지까지 보는 스택 트레이스 수집은 구독마다 콜스택을 걷으므로 찾을 때만 켭니다.

### 2. DisposableBag 은 ref 로만 넘긴다

**안 지키면 생기는 문제.** 값 타입이라 `ref` 없이 넘기면 복사본에 담기고 원본은 비어 있습니다. 컴파일은 되고 `OnDestroy` 에서 아무것도 안 풀립니다.

```csharp
void Register(DisposableBag bag)     { source.Subscribe(...).AddTo(ref bag); }  // 복사본에 담긴다
void Register(ref DisposableBag bag) { source.Subscribe(...).AddTo(ref bag); }  // 원본에 담긴다
```

**이렇게 쓴다.** `DisposableBag` 을 받는 메서드는 매개변수를 `ref DisposableBag` 으로 선언합니다. 필드로 들고 있는 가방에 직접 `AddTo(ref bag)` 하는 것이 가장 안전합니다.

### 3. 풀에서 도는 오브젝트는 AddTo(this) 에 맡기지 않는다

**안 지키면 생기는 문제.** `AddTo(this)` 는 **파괴**에 묶입니다. 오브젝트 풀은 파괴하지 않고 비활성으로 돌려놓으니 풀로 돌아간 오브젝트의 `EveryUpdate` 구독이 계속 돕니다. 다시 꺼낼 때 `OnEnable` 에서 또 구독하면 한 벌씩 쌓입니다.

**이렇게 쓴다.** 활성 구간에 묶습니다. `OnEnable` 에서 구독해 가방에 담고 `OnDisable` 에서 `Clear()` 로 비웁니다.

```csharp
void OnEnable()  => Observable.EveryUpdate().Subscribe(_ => Think()).AddTo(ref bag);
void OnDisable() => bag.Clear();     // 풀로 돌아갈 때마다 비운다
void OnDestroy() => bag.Dispose();   // 정말 파괴될 때
```

## 정리

R3 에서 직접 거둘 것은 **끝나지 않는 스트림**뿐입니다.

- 완료, `Take`, `TakeUntil`, `Subject` 해제로 끝나는 구독은 스스로 풀리고 상류까지 전파됩니다.
- 수집기는 `DisposableBuilder`(80 B, 한 메서드), `DisposableBag`(160 B, `ref` 필수), `CompositeDisposable`(256 B, 개별 제거와 스레드 안전) 중에서 고릅니다.
- `AddTo(this)` 는 2022.2 이상 활성 컴포넌트에서 `destroyCancellationToken` 을 쓰고, 파괴에만 묶이니 풀링 오브젝트는 `OnDisable` 에서 비웁니다.