---
title: "UniRx 의 비용은 무언가를 사려고 낸 값이다"
description: "Subject 에 구독 4,000개를 붙였다 떼면 123.1 MB 가 나옵니다. 배열 복사, 값마다 하는 재예약, 매 프레임 락은 락 없는 발행, 가상 시간, 스레드 자유를 사려고 낸 값입니다."
author: "유니얼"
pubDate: 2026-09-14
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "Rx", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-unirx-limits/
---
`Subject<T>` 에 구독자를 4,000명 붙였다가 떼면 **123.1 MB** 가 할당되고 55.64 ms 가 걸립니다. 60fps 로 3.3프레임입니다. 웨이브마다 적이 전역 이벤트에 붙었다 빠지는 게임이면 그때마다 화면이 멈칫합니다.

UniRx 에서 값을 치르는 자리는 모두 **무언가를 사려고 낸 값**입니다. 무엇을 샀는지 알면 어디서 피해야 하는지가 정해집니다.

## Subject 는 구독 목록을 매번 새 배열로 바꾼다

`Subject` 는 구독자 목록을 `ImmutableList` 로 듭니다. 이름은 `Add` 지만 기존 배열에 넣지 않고 **새 배열을 만들어 통째로 복사**합니다. 해제도 같습니다.

```csharp
// InternalUtil/ImmutableList.cs
public ImmutableList<T> Add(T value)
{
    var newData = new T[data.Length + 1];        // 매번 새 배열
    Array.Copy(data, newData, data.Length);
    newData[data.Length] = value;
    return new ImmutableList<T>(newData);
}
```

크기 1, 2, 3 … N 인 배열이 차례로 생기니 N건을 붙였다 떼면 할당이 약 `8N²` 바이트입니다(참조 하나 8 B).

| N | 이론값 `8N²` | 실측 |
| ---: | ---: | ---: |
| 1,000 | 8 MB | **7.88 MB** |
| 2,000 | 32 MB | 31.0 MB |
| 4,000 | 128 MB | **123.1 MB** |



해제 순서도 영향을 줍니다. `Remove` 는 자기 자리를 처음부터 찾으므로 나중 것부터 풀면 붙인 순서보다 약 2배 느렸습니다(N=4,000, Mono 해제 구간 269.7 ms 대 127.4 ms). 붙인 순서로 풀어도 해제마다 남은 목록을 복사하니 시간은 N제곱으로 늘었습니다.

`Subject` 가 적마다 하나씩 흩어져 있으면 배열이 작아서 드러나지 않습니다. **한 `Subject` 에 수천 건이 몰렸다 빠질 때** 터집니다.

## 그 복사가 락 없는 발행을 산다

발행 쪽을 보면 복사의 이유가 보입니다.

```csharp
// Subjects/Subject.cs
public void OnNext(T value) { outObserver.OnNext(value); }   // 락이 없다
```

목록을 고칠 때 새 배열로 **참조만 갈아끼우니**, 발행은 참조 하나를 읽고 끝납니다. 참조 대입은 원자적이라 락이 필요 없습니다.

<x-wiring>
<script type="application/json">
{
  "label": "해제가 일어나도 순회가 안 깨지는 이유",
  "width": 620,
  "height": 335,
  "nodes": [
    { "id": "f", "title": "outObserver", "sub": "Subject 의 필드", "x": 225, "y": 10, "w": 170, "h": 50 },
    { "id": "old", "title": "[ A , B , C ]", "sub": "해제 전 배열", "x": 60, "y": 150, "w": 200, "h": 54 },
    { "id": "neo", "title": "[ A , C ]", "sub": "새로 만든 배열", "x": 360, "y": 150, "w": 200, "h": 54 },
    { "id": "on", "title": "OnNext 의 지역 참조", "sub": "지금 순회 중", "x": 20, "y": 270, "w": 220, "h": 50 }
  ],
  "edges": [
    { "from": "f", "to": "old", "label": "지금은 여기" },
    { "from": "f", "to": "neo", "label": "여기로 갈아끼운다" },
    { "from": "on", "to": "old", "label": "잡아 둔 것" }
  ],
  "steps": [
    { "edge": 0, "why": "값이 흐를 때 필드가 가리키는 배열을 봅니다." },
    { "edge": 1, "why": "B 가 해제되면 기존 배열을 고치지 않습니다. 새 배열을 만들어 참조만 갈아끼웁니다. 참조 대입은 원자적이라 락이 필요 없습니다." },
    { "edge": 2, "why": "먼저 시작한 OnNext 는 잡아 둔 배열을 끝까지 돕니다. 목록이 바뀌어도 예외가 나지 않습니다." }
  ],
  "note": "구독과 해제만 락을 잡습니다. 발행하는 쪽에는 락이 없습니다."
}
</script>
</x-wiring>

`List<T>` 였다면 순회 도중 원소가 빠질 때 `InvalidOperationException` 이 납니다. 값은 자주 흐르고 구독은 가끔 일어나니, **자주 불리는 쪽을 싸게 만든 선택**입니다. 같은 라이브러리의 `ReactiveProperty` 는 노드가 곧 구독 핸들인 이중 연결 리스트를 써서 추가와 삭제가 O(1) 이고, 구독자 50명에 값을 10,000번 바꿔도 0 B 였습니다.

## 스케줄러는 값 하나마다 예약을 만든다

`IScheduler.Schedule` 은 **한 번 실행**을 예약하고 취소용 `IDisposable` 을 돌려줍니다. 반복이라는 개념이 없어서 `Range` 같은 연산자는 자기를 다시 예약합니다.

```csharp
// UniRx/Scripts/Operators/Range.cs
return scheduler.Schedule((Action self) =>
{
    if (i < count) { observer.OnNext(start + i); i++; self(); }   // self() 가 다음 스텝을 예약
    else observer.OnCompleted();
});
```

<x-flow>
<script type="application/json">
{
  "label": "Range 가 자기를 다시 예약하는 모습",
  "lines": ["return scheduler.Schedule((Action self) =>", "{", "    if (i < count) { observer.OnNext(start + i); i++; self(); }", "    else observer.OnCompleted();", "});"],
  "cases": [
    {
      "label": "Range(1, 2)",
      "state": "count = 2",
      "path": [
        { "line": 0, "why": "구독하면 첫 스텝을 예약합니다. 실행할 코드와 취소 손잡이가 한 벌 생깁니다.", "out": "예약 1" },
        { "line": 2, "why": "첫 스텝입니다. i = 0 이라 값을 내보냅니다.", "verdict": "true", "out": "OnNext(1)" },
        { "line": 2, "why": "self() 가 다음 스텝을 새로 예약합니다. 손잡이도 방금 나온 것으로 갈아끼웁니다.", "out": "예약 2" },
        { "line": 2, "why": "둘째 스텝입니다. i = 1 이라 값을 내보냅니다.", "verdict": "true", "out": "OnNext(2)" },
        { "line": 2, "why": "또 self() 입니다. 값은 둘뿐인데 예약은 세 번째입니다.", "out": "예약 3" },
        { "line": 2, "why": "셋째 스텝입니다. i = 2 라 조건이 거짓입니다.", "verdict": "false" },
        { "line": 3, "why": "완료를 보냅니다. 값 두 개를 내는 데 예약이 세 번 들었습니다.", "out": "OnCompleted" }
      ],
      "note": "값이 N 개면 예약도 N 번 남짓입니다. 반복 한 바퀴가 곧 예약 하나입니다."
    }
  ]
}
</script>
</x-flow>

재예약 한 번마다 `ScheduledItem` 하나, 클로저 둘, 취소용 `IDisposable` 하나, 그 손잡이를 갈아끼우는 락 두 번이 듭니다. `Range(1, 10000)` 이면 작업 항목 1만 개, 클로저 약 2만 개, 락 약 2만 번입니다. 스케줄러는 싱글턴 하나이고 타이머는 만들지 않습니다. 늘어나는 것은 작업 항목입니다.

| `Range(1, 10000)` 구독 한 번 | Mono | IL2CPP |
| --- | ---: | ---: |
| 기본값 (`CurrentThread`) | 2.51 MB · 25.3 ms | 2.66 MB · 16.3 ms |
| `Scheduler.Immediate` | 256 B · 0.12 ms | 256 B · 0.39 ms |

기본값은 값 하나에 약 250~270 B 이고, `Immediate` 는 그냥 `for` 문이라 값 하나당 0 B 입니다. 빠른 길이 코드에 있는데 인자를 생략하면 느린 길로 갑니다.

산 것은 **가상 시간 테스트**입니다. 테스트에서 하루를 즉시 흘려보내려면 시간이 새지 않아야 하고, 그러려면 모든 실행이 스케줄러를 지나야 합니다. `async/await` 도 `CancellationToken` 도 없던 시절의 Rx.NET 설계를 그대로 가져온 값입니다.

## 프레임 쪽 비용도 같은 모양이다

| 값 | 산 것 |
| --- | --- |
| `MicroCoroutine.Run` 이 경합이 없어도 매 프레임 `lock` 을 잡습니다 | 아무 스레드에서나 구독을 추가하고 해제해도 되는 자유 |
| `EveryUpdate` 가 `yield` 이터레이터라 구독마다 컴파일러 생성 클래스가 하나씩 생깁니다 | 코루틴마다 네이티브 객체를 만드는 `StartCoroutine` 을 쓰지 않는 것 |
| 디스패처가 진짜 GameObject 라 중복 방어 코드와 하이어라키의 정체 모를 오브젝트가 따라옵니다 | 엔진에 손대지 않고 어느 유니티 버전에서나 프레임을 얻는 것 |

셋 모두 실수가 아니라 당시에 필요한 것을 산 값입니다. 문제는 그 값이 커지는 모양을 모르고 쓸 때입니다.

## 주의할 점

### 1. 하나의 Subject 에 구독을 수천 개씩 붙였다 떼지 않는다

**안 지키면 생기는 문제.** 구독을 붙이고 뗄 때마다 목록 배열을 통째로 새로 만들어서 N건이면 할당이 약 `8N²` 입니다. 4,000건이면 123.1 MB, 55.64 ms 입니다. 웨이브마다 적 수천 마리가 전역 이벤트에 붙었다 빠지는 구조가 정확히 이 모양입니다.

**이렇게 쓴다.** 구독이 몰리는 쪽을 흩습니다. 적마다 자기 `Subject` 를 두고 관리자 쪽이 그것을 구독하면 배열은 여럿이어도 각각 작습니다. 한 번 붙이고 씬이 끝날 때까지 두는 구독은 걱정하지 않아도 됩니다.

### 2. 몰린 구독을 한꺼번에 풀 때는 붙인 순서대로 푼다

**안 지키면 생기는 문제.** 해제는 목록에서 자기 자리를 처음부터 찾습니다. 나중에 붙인 것부터 풀면 매번 끝까지 훑어서 붙인 순서보다 약 2배 느렸고, 4,000건이면 Mono 에서 해제만 270 ms 였습니다.

**이렇게 쓴다.** 여러 구독을 한꺼번에 정리해야 하면 붙인 순서대로 해제합니다. 그래도 배열 복사 때문에 시간은 N제곱으로 늘고 줄어드는 것은 절반 정도이니, 먼저 1번처럼 한 `Subject` 에 몰리지 않게 짭니다.

### 3. 동기로 끝나도 되는 반복에는 Scheduler.Immediate 를 준다

**안 지키면 생기는 문제.** `Observable.Range(start, count)` 처럼 스케줄러를 생략하면 `CurrentThread` 로 가서 값 하나마다 예약과 클로저와 락이 생깁니다. 실측으로 값 하나에 약 250~270 B 를 할당했습니다.

**이렇게 쓴다.** 그 자리에서 다 내보내도 되는 반복이면 `Scheduler.Immediate` 를 넘깁니다. 그러면 `for` 문으로 돕니다.

## 정리

UniRx 의 비용은 **실수가 아니라 산 것의 값**이고, 커지는 모양을 알면 피할 수 있습니다.

- `Subject` 의 배열 복사는 락 없는 `OnNext` 를 산 값입니다. 한 `Subject` 에 구독이 몰리면 `8N²` 으로 커집니다.
- 스케줄러의 재예약은 가상 시간 테스트를 산 값입니다. 기본값이 느린 길이라 `Scheduler.Immediate` 를 직접 줘야 합니다.
- 매 프레임 락, 구독마다 상태 머신, 씬의 디스패처는 스레드 자유, 네이티브 코루틴 회피, 엔진 무수정을 산 값입니다.