---
title: "UniRx 는 씬의 오브젝트 하나로 프레임을 돌린다"
description: "버튼 연타 방지 같은 규칙을 UniRx 는 값의 흐름으로 다룹니다. 그 프레임 연산은 런타임에 스스로 만드는 MainThreadDispatcher 오브젝트의 코루틴 위에서 돌고, 시간은 스케줄러가 맡습니다."
author: "유니얼"
pubDate: 2026-09-10
updatedDate: 2026-09-28
category: "game-dev/unity/libraries"
tags: ["Unity", "Rx", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-unirx-what-it-is/
---
```csharp
public void OnBuyClicked()
{
    if (Time.time - lastClick < 0.3f) return;   // 버튼마다 이 세 줄이 복사된다
    lastClick = Time.time;
    Buy();
}
```

버튼이 스무 개면 이 세 줄이 스무 벌 생기고, "0.3초 안에 두 번이면 무시" 라는 규칙은 필드와 `if` 문에 흩어집니다.

UniRx 는 클릭을 **값의 흐름**으로 바꿔 규칙을 연산자 하나로 적게 합니다. 그리고 그 흐름의 프레임 연산은 **런타임에 스스로 만드는 `MainThreadDispatcher` 오브젝트 하나** 위에서 돕니다.

```csharp
button.OnClickAsObservable()
      .ThrottleFirst(TimeSpan.FromSeconds(0.3))
      .Subscribe(_ => Buy())
      .AddTo(this);
```

## 클릭도 체력 변화도 값의 흐름이다

Rx 는 시간에 걸쳐 여러 번 오는 값을 `List` 처럼 `Where` 로 거르고 `Select` 로 바꾸는 방식입니다. UniRx 는 그것을 유니티에 맞춰 다시 만든 것이고, 손에 잡히는 것은 넷입니다.

| 무엇 | 하는 일 |
| --- | --- |
| `IObservable<T>` | 값이 여러 번 오는 것. 여기에 연산자를 겁니다 |
| `Subject<T>` | 내가 직접 값을 밀어 넣는 통로 |
| `ReactiveProperty<T>` | 값과 "바뀌었다" 알림을 한 덩어리로 |
| `AddTo` · `CompositeDisposable` | 구독을 오브젝트 수명에 묶기 |

여기에 유니티 쪽이 둘 붙습니다. `OnCollisionEnterAsObservable` 같은 **트리거 컴포넌트 39개**와, Rx 원본에 없던 **프레임 기반 연산자**입니다.

```csharp
Observable.EveryUpdate()
          .Where(_ => Input.GetKey(KeyCode.Space))
          .Subscribe(_ => Charge())
          .AddTo(this);
```

`Update` 를 도는 일 자체가 스트림이 됩니다.

## 프레임 연산은 디스패처 오브젝트의 코루틴 위에서 돈다

프레임 연산을 처음 쓰는 순간 UniRx 는 씬에 GameObject 를 하나 만듭니다.

```csharp
// UnityEngineBridge/MainThreadDispatcher.cs
new GameObject("MainThreadDispatcher").AddComponent<MainThreadDispatcher>();
```

이 오브젝트는 `DontDestroyOnLoad` 로 씬을 넘어 살아남고, 안에서 `Update` · `FixedUpdate` · `EndOfFrame` 용 코루틴 셋을 돌립니다. 코루틴은 매 프레임 깨어나 `MicroCoroutine` 이라는 배열을 처음부터 끝까지 밉니다. 구독 하나가 배열 한 칸입니다.

<x-wiring>
<script type="application/json">
{
  "label": "한 프레임 동안 일어나는 일",
  "width": 620,
  "height": 350,
  "nodes": [
    { "id": "loop", "title": "유니티 플레이어 루프", "x": 235, "y": 8, "w": 150, "h": 40, "aside": true },
    { "id": "co", "title": "코루틴 · Update", "sub": "yield return null 로 대기", "x": 235, "y": 118, "w": 150, "h": 54 },
    { "id": "mc", "title": "MicroCoroutine", "sub": "IEnumerator 배열", "x": 60, "y": 280, "w": 150, "h": 54 },
    { "id": "sub", "title": "구독 하나", "sub": "배열의 한 칸", "x": 410, "y": 280, "w": 150, "h": 54 }
  ],
  "edges": [
    { "from": "loop", "to": "co", "label": "이번 프레임", "bend": 0 },
    { "from": "co", "to": "mc", "label": "Run()", "bend": 0 },
    { "from": "mc", "to": "sub", "label": "MoveNext()", "bend": 0 },
    { "from": "sub", "to": "co", "label": "다음 프레임까지", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "유니티가 이번 프레임의 Update 를 돌립니다." },
    { "edge": 1, "why": "대기하던 코루틴이 깨어나 이번 프레임 몫을 통째로 돌립니다." },
    { "edge": 2, "why": "배열을 처음부터 끝까지 한 칸씩 밉니다. 구독 하나가 한 칸입니다." },
    { "edge": 3, "why": "여기까지가 한 프레임입니다. 다음 프레임에 같은 일이 그대로 반복됩니다." }
  ],
  "note": "FixedUpdate 판과 EndOfFrame 판이 같은 모양으로 하나씩 더 있습니다."
}
</script>
</x-wiring>

`MicroCoroutine` 은 `StartCoroutine` 처럼 코루틴마다 네이티브 객체를 만들지 않고 배열에 `IEnumerator` 만 담습니다. `EveryUpdate`, `TimerFrame`, `DelayFrame`, `ObserveEveryValueChanged` 같은 프레임 연산자가 이 위에 올라탑니다.

몇몇은 길이 다릅니다. `EveryLateUpdate` 와 `ObserveOnMainThread` 는 디스패처 컴포넌트의 `Update` 와 `LateUpdate` 에서, `FromCoroutine` 과 `RepeatUntilDestroy` 는 디스패처 위의 일반 유니티 코루틴으로 돕니다. 실행 기반이 따로 없는 것은 `Time.frameCount` 를 읽기만 하는 `FrameInterval` 류뿐입니다.

UniRx 의 프레임은 결국 **씬에 떠 있는 디스패처 오브젝트 하나**가 돌립니다.

## 시간은 스케줄러가 맡는다

`Delay(1초)` 나 `Interval(0.5초)` 처럼 시각으로 표현되는 것은 디스패처가 아니라 `IScheduler` 가 맡습니다.

```csharp
public interface IScheduler
{
    DateTimeOffset Now { get; }
    IDisposable Schedule<TState>(TState state, TimeSpan dueTime, Func<IScheduler, TState, IDisposable> action);
    // 즉시 실행 오버로드 생략
}
```

`Now` 는 **시계**이고, 어느 구현체를 고르느냐는 **어느 스레드에서 실행할지**입니다. `ImmediateScheduler` 는 부른 자리에서, `ThreadPoolScheduler` 는 스레드풀에서, `MainThread` 계열은 디스패처를 거쳐 메인 스레드에서 실행합니다. 인터페이스 하나가 "언제" 와 "어디서" 를 함께 들고 있습니다.

| 이 하나가 맡는 것 | 어떻게 |
| --- | --- |
| 실행 위치 교체 | 연산자에 스케줄러를 넘겨 스레드풀에서 계산하고 메인에서 받습니다 |
| 가상 시간 테스트 | 시계가 스케줄러 안에 있어서 갈아끼우면 하루치 타이머를 즉시 흘립니다 |
| 작업 단위 취소 | `Schedule` 이 돌려주는 `IDisposable` 로 예약을 거둡니다 |
| 무한 스트림 | `Interval` 과 `Repeat` 을 "다음 것을 다시 예약" 으로 표현합니다 |

시간 연산자는 `DateTime.Now` 를 직접 읽지 않고 모두 이 인터페이스를 지나갑니다. 그래서 시간이 걸리는 로직을 기다리지 않고 검증할 수 있습니다.

## 주의할 점

### 1. 끝나지 않는 스트림은 반드시 수명에 묶는다

**안 지키면 생기는 문제.** `EveryUpdate` 는 끝나지 않고, 그것을 돌리는 디스패처는 `DontDestroyOnLoad` 라 씬을 넘겨도 살아 있습니다. 구독한 오브젝트가 파괴되고 씬이 바뀌어도 구독은 배열 한 칸을 차지한 채 매 프레임 불립니다. 람다가 파괴된 컴포넌트를 건드리면 예외가 납니다.

**이렇게 쓴다.** 끝나지 않는 스트림에는 `.AddTo(this)` 를 붙입니다. `Take` 나 `TakeUntil` 로 끝이 정해진 스트림만 예외입니다.

### 2. 디스패처를 씬에 따로 두지 않는다

**안 지키면 생기는 문제.** 디스패처는 처음 쓰일 때 스스로 생깁니다. 프리팹이나 씬에 `MainThreadDispatcher` 가 이미 들어 있으면 둘이 되고, 소스의 중복 방어 코드가 경고를 남기고 하나를 지웁니다. 어느 쪽이 지워지는지는 `cullingMode` 설정에 따라 달라집니다.

**이렇게 쓴다.** 디스패처는 라이브러리가 만들게 둡니다. 하이어라키에 떠 있는 `MainThreadDispatcher` 를 씬 정리 코드가 지우지 않게 합니다.

### 3. 예외가 난 자리에 따라 멈추는 것이 다르다

**안 지키면 생기는 문제.** `Select` 나 `Where` 같은 연산자 안에서 난 예외는 `OnError` 로 가고 구독이 풀립니다. 버튼이라면 그 뒤로 눌러도 반응이 없습니다. `EveryUpdate`, `Interval`, `Timer` 는 `Subscribe` 람다에서 던져도 값을 만드는 코루틴이 죽어 `OnError` 없이 멈춥니다. 반대로 버튼, `Subject`, `ReactiveProperty`, 트리거에 건 `Subscribe` 람다의 예외는 값을 보낸 쪽으로 올라가고 구독은 남습니다. 이때는 에러 핸들러도 불리지 않습니다.

| 예외가 난 자리 | 구독 | 에러 핸들러 |
| --- | --- | --- |
| 연산자 람다 (`Select`, `Where`) | 끊김 | 불림 |
| `EveryUpdate` · `Interval` · `Timer` 의 `Subscribe` 람다 | 값이 더 안 옴 | 안 불림 |
| 버튼 · `Subject` · `ReactiveProperty` · 트리거의 `Subscribe` 람다 | 남음 | 안 불림 |



**이렇게 쓴다.** 입력 파싱처럼 실패가 예상되는 일은 그 코드가 있는 람다 안에서 `try` 로 감싸 예외가 밖으로 나가지 않게 합니다. 에러 핸들러로 받을 수 있는 것은 연산자 안의 예외뿐이라고 보고 짭니다.

## 정리

UniRx 는 입력과 상태 변화를 **값의 흐름**으로 다루고, 그 프레임 연산을 **씬의 디스패처 오브젝트 하나**로 돌립니다.

- 손에 잡히는 것은 `IObservable<T>` · `Subject<T>` · `ReactiveProperty<T>` · `AddTo` 넷에, 트리거 컴포넌트와 프레임 연산자가 붙습니다.
- 프레임 연산은 `DontDestroyOnLoad` 디스패처가 돌리는 코루틴 셋과 `MicroCoroutine` 배열 위에서 돕니다.
- 시간은 `IScheduler` 가 맡고, 이 인터페이스 하나가 시계와 실행 위치를 함께 표현합니다.