---
title: "R3 는 구독 핸들을 따로 만들지 않는다"
description: "R3 의 Subscribe 는 관찰자 자신을 구독 핸들로 돌려줍니다. 해제가 상류 끝까지 번지고 살아 있는 구독을 모두 추적할 수 있습니다. 할당 이득은 연산자를 걸 때부터 나고, 체인은 구독마다 새로 조립됩니다."
author: "유니얼"
pubDate: 2026-09-14
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "Rx", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-r3-contract/
---
```csharp
var d = source.Where(x => x > 0).Select(x => x * 2).Subscribe(Debug.Log);
d.Dispose();   // 무엇이 어디까지 풀리나
```

이 한 줄이 안에서 무엇을 만드는지 모르면, 같은 스트림을 두 곳에서 구독했을 때 서버 요청이 두 번 가는 이유도, `Dispose` 하나로 무엇이 풀리는지도 읽을 수 없습니다.

R3 의 `Subscribe` 는 연산자마다 **하류 관찰자를 감싼 관찰자**를 하나씩 쌓아 올리고, 맨 아래 **관찰자 자신을 구독 핸들로** 돌려줍니다. 핸들 객체를 따로 만들지 않습니다.

## 구독은 올라가고 값은 내려온다

연산자는 `SubscribeCore` 에서 하류 관찰자를 감싼 새 관찰자를 만들어 상류에 넘기기만 합니다. 그래서 구독은 아래에서 위로 올라가고, 값은 위에서 아래로 내려옵니다.

<x-wiring>
<script type="application/json">
{
  "label": "구독 한 줄이 하는 일",
  "width": 620,
  "height": 440,
  "nodes": [
    { "id": "src", "title": "Source", "sub": "값이 나오는 곳", "x": 235, "y": 8, "w": 150, "h": 50 },
    { "id": "whr", "title": "Where", "sub": "x > 0", "x": 235, "y": 128, "w": 150, "h": 50 },
    { "id": "sel", "title": "Select", "sub": "x * 2", "x": 235, "y": 248, "w": 150, "h": 50 },
    { "id": "usr", "title": "사용자 코드", "sub": "Subscribe(Debug.Log)", "x": 235, "y": 368, "w": 150, "h": 50 }
  ],
  "edges": [
    { "from": "usr", "to": "sel", "label": "Subscribe", "bend": 62 },
    { "from": "sel", "to": "whr", "label": "감싸서 위로", "bend": 62 },
    { "from": "whr", "to": "src", "label": "감싸서 위로", "bend": 62 },
    { "from": "src", "to": "whr", "label": "OnNext(5)", "bend": 62 },
    { "from": "whr", "to": "sel", "label": "통과", "bend": 62 },
    { "from": "sel", "to": "usr", "label": "OnNext(10)", "bend": 62 }
  ],
  "steps": [
    { "edge": 0, "why": "사용자가 준 동작을 담은 관찰자를 만들어 넘깁니다." },
    { "edge": 1, "why": "Select 는 그 관찰자를 감싼 새 관찰자를 만들어 상류로 올립니다." },
    { "edge": 2, "why": "Where 도 똑같이 감싸서 올립니다. 여기서 실제 구독이 일어납니다." },
    { "edge": 3, "why": "이제 값이 흐릅니다. 방향이 반대입니다." },
    { "edge": 4, "why": "조건을 통과한 값만 아래로 내려갑니다." },
    { "edge": 5, "why": "Select 가 바꾼 값이 사용자 코드에 도착합니다." }
  ],
  "note": "구독은 오른쪽으로 올라가고 값은 왼쪽으로 내려옵니다. 관찰자 체인은 구독 시점에 역순으로 쌓입니다."
}
</script>
</x-wiring>

관찰자 체인은 구독 시점에 역순으로 쌓입니다. `Select` 가 `Where` 를, `Where` 가 소스를 구독하는 순서입니다.

## 관찰자가 곧 구독 핸들이다

```csharp
// R3/Observable.cs
public IDisposable Subscribe(Observer<T> observer)
{
    var subscription = SubscribeCore(observer);

    observer.SourceSubscription.Disposable = subscription;   // 상류 구독을 관찰자에 부착
    return observer;                                         // 관찰자 자신을 돌려준다
}
```

UniRx 의 `Subject` 는 여기서 `new Subscription(this, observer)` 로 별도 핸들을 만듭니다. R3 는 관찰자마다 자기 위쪽 구독을 `SourceSubscription` 에 들고 있어서, 맨 아래 관찰자 하나를 `Dispose` 하면 **해제가 상류 끝까지 번집니다.** 구독이 끝나기 전에 해제가 먼저 와도 늦게 도착한 구독을 그 자리에서 해제합니다.

구독 하나와 관찰자 하나가 1:1 로 확정되고 모든 구독이 봉인된 `Subscribe` 한 곳을 지나니, **살아 있는 구독 전체를 목록으로 들 수 있습니다.** 유니티에서 **Window → Observable Tracker** 로 열면 지금 몇 개가 살아 있고 어디서 구독됐는지가 보입니다. 추적을 꺼 두면 분기 하나만 남습니다. 구현체마다 `Subscribe` 를 자기 방식으로 짜는 인터페이스였다면 이 추적 지점을 강제할 수 없습니다.

## 체인은 구독마다 새로 조립된다

`SubscribeCore` 가 구독마다 불리니 소스 쪽 일도 구독마다 따로 시작됩니다. 구독해야 비로소 움직이는 이런 스트림을 **콜드(cold)**, `Subject` 처럼 이미 흐르고 있고 구독은 "지금부터 듣기" 인 쪽을 **핫(hot)** 이라고 부릅니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 스트림을 둘이 구독할 때",
  "tabs": [
    { "label": "그대로", "code": "int n = 0;\nvar stream = Observable.Timer(TimeSpan.FromSeconds(0.1))\n    .Select(_ => ++n);\nstream.Subscribe(x => Debug.Log($\"A:{x}\"));\nstream.Subscribe(x => Debug.Log($\"B:{x}\"));", "text": "구독마다 체인이 따로 조립됩니다. 타이머가 둘 돌고 ++n 도 두 번 실행됩니다. Select 안에서 서버를 부르면 요청이 두 번 갑니다.", "stats": [["타이머", "2개"], ["출력", "A:1, B:2"], ["n", "2"]], "tone": "bad" },
    { "label": "Share()", "code": "int n = 0;\nvar stream = Observable.Timer(TimeSpan.FromSeconds(0.1))\n    .Select(_ => ++n)\n    .Share();\nstream.Subscribe(x => Debug.Log($\"A:{x}\"));\nstream.Subscribe(x => Debug.Log($\"B:{x}\"));", "text": "첫 구독이 소스를 켜고, 둘째 구독은 이미 켜진 흐름에 합류합니다. 체인 하나를 둘이 나눠 듣습니다.", "stats": [["타이머", "1개"], ["출력", "A:1, B:1"], ["n", "1"]], "tone": "good" }
  ]
}
</script>
</x-compare>

`Share()` 는 `Publish().RefCount()` 입니다. 구독자 수가 0에서 1이 될 때 소스를 켜고 1에서 0이 될 때 끕니다. 그래서 "한 번만 실행" 이 아니라, 모두 떠났다가 다시 구독하면 **소스가 처음부터 다시 켜지고**, 늦게 합류한 구독자는 지나간 값을 받지 못합니다.

## 할당 이득은 연산자를 걸 때부터 난다

핸들을 안 만드니 늘 더 쌀 것 같지만, 재보니 연산자 없는 단순 구독에서는 R3 가 더 썼습니다. `Select` 와 `Where` 를 번갈아 건 체인에서 구독과 해제 한 쌍입니다.

| 깊이 | 할당 UniRx | 할당 R3 | 시간 Mono UniRx | Mono R3 | IL2CPP UniRx | IL2CPP R3 |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 0 | **104 B** | 112 B | 1.21 µs | 1.00 µs | 0.82 µs | 0.75 µs |
| 1 | 240 B | **160 B** | 2.64 µs | 1.49 µs | 1.72 µs | 0.97 µs |
| 3 | 376 B | **216 B** | 4.09 µs | 1.99 µs | 2.55 µs | 1.27 µs |

깊이 0 에서 R3 가 8 B 더 쓰는 것은 UniRx `Subject` 가 구독자 0명일 때 목록 객체를 아예 만들지 않는 특례가 있고, R3 의 `ObserverNode` 가 앞뒤 노드와 버전까지 들어 크기 때문입니다. 연산자를 하나만 걸어도 뒤집히고, 깊이 3 에서 R3 는 할당이 약 58%, 시간이 두 런타임 모두 약 절반입니다.

값을 흘리는 비용은 런타임에 따라 갈렸습니다. 깊이 3 체인을 값 하나가 통과하는 데 Mono 는 UniRx 31.4 ns, R3 48.0 ns, IL2CPP 는 UniRx 73.6 ns, R3 23.2 ns 였습니다. 구독을 자주 만들고 없애는 코드라면 R3 쪽 이득이 분명하고, 오래 붙여 둔 체인에 값을 많이 흘리는 코드라면 대상 런타임에 따라 다릅니다.

## 주의할 점

### 1. 같은 스트림을 여러 곳에서 구독할 때는 부수효과를 확인한다

**안 지키면 생기는 문제.** 구독마다 체인이 따로 돌아서 `Select` 나 `Do` 안의 부수효과가 구독 수만큼 반복됩니다. 서버 요청이 두 번 가고, 람다가 바깥 변수를 캡처했다면 구독을 하나 늘리는 것만으로 기존 구독자가 받는 값이 바뀝니다.

**이렇게 쓴다.** 연산자 안에는 부수효과를 넣지 않습니다. 소스 쪽 일을 한 번만 해야 하면 `Share()` 로 나누고, 지난 값까지 줘야 하면 현재 값을 들고 있는 `ReactiveProperty` 나 `BehaviorSubject` 에 담아 나눠 줍니다.

### 2. Share 는 구독자가 0명이 되면 꺼진다는 것을 전제로 쓴다

**안 지키면 생기는 문제.** 구독자가 모두 해제되면 소스가 꺼지고 다음 구독에서 처음부터 다시 켜집니다. 창을 닫았다 열 때마다 초기 요청이 다시 나가는 식입니다.

**이렇게 쓴다.** 소스를 계속 켜 두어야 하면 그 스트림을 구독하는 쪽 하나를 수명이 긴 곳에 둡니다. 합류 시점과 상관없이 최신 값이 필요하면 값을 들고 있는 타입에 담습니다.

### 3. 구독 시점의 예외는 Subscribe 호출부에서 잡는다

**안 지키면 생기는 문제.** 값이 흐를 때 난 예외는 `OnErrorResume` 으로 가지만, `SubscribeCore` 도중에 난 예외는 반쯤 연결된 관찰자를 정리한 뒤 그대로 다시 던져집니다. 에러 핸들러를 줘도 거기로 가지 않습니다.

```csharp
catch
{
    observer.Dispose();   // 구독에 실패했으니 하류 관찰자를 떼어낸다
    throw;                // 그리고 그대로 던진다
}
```

**이렇게 쓴다.** `Observable.Range(1, -1)` 같은 인자 오류처럼 구독하는 순간 실패할 수 있는 스트림은 `Subscribe` 호출을 `try` 로 감쌉니다.

### 4. 연산자 없는 단순 구독으로 할당 이득을 기대하지 않는다

**안 지키면 생기는 문제.** `Subject` 에 연산자 없이 붙는 구독은 R3 가 UniRx 보다 8 B 더 씁니다. 할당을 줄이려고 옮겼는데 이런 구독만 많으면 얻는 것이 없습니다.

**이렇게 쓴다.** 옮기기 전에 체인 깊이부터 봅니다. 값을 흘리는 속도는 별개라 Mono 대상이면 따로 확인합니다.

## 정리

R3 는 **관찰자 자신을 구독 핸들로** 돌려주고, 그 위에 해제 전파와 구독 추적을 얹습니다.

- 구독은 아래에서 위로, 값은 위에서 아래로 흐르고, 해제는 `SourceSubscription` 을 타고 상류 끝까지 번집니다.
- 체인은 구독마다 새로 조립됩니다. 나눠 들으려면 `Share()` 를 쓰고, 0명이 되면 꺼진다는 것까지 알고 씁니다.
- 할당은 연산자를 하나라도 걸 때부터 R3 가 유리하고, 값 통과는 IL2CPP 에서 R3, Mono 에서 UniRx 가 빨랐습니다.