---
title: "R3 는 UniRx 를 고치지 않고 다시 쓴 라이브러리다"
description: "R3 를 UniRx 의 다음 버전으로 보고 옮기면 중간에 막힙니다. 같은 저자가 범위를 이벤트 처리로 좁혀 계약부터 다시 만들었고, 옛 연산자 이름은 별칭 없이 지워 컴파일 에러로 드러나게 했습니다."
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-why-rewritten/
---
UniRx 프로젝트에서 `using UniRx;` 를 `using R3;` 로 바꾸면 `MessageBroker` 도 `Throttle` 도 없다는 에러가 쏟아집니다. 이름만 고치면 될 것 같지만, 없는 것은 다른 라이브러리로 갔고 이름이 비슷한 연산자는 동작이 다릅니다.

R3 는 UniRx 의 다음 버전이 아니라 **UniRx 를 만든 사람이 계약부터 다시 만든 별개 라이브러리**입니다. 범위를 메모리 안의 이벤트 처리 하나로 좁혔고, 바뀐 것은 조용히 다르게 돌지 않고 컴파일 에러로 드러나게 했습니다.

## 범위를 이벤트 처리 하나로 좁혔다

README 에 설계 원칙 여덟 개가 적혀 있고, 절반이 **무언가를 하지 않겠다는 선언**입니다.

| 원칙 | 달라진 것 |
| --- | --- |
| `OnError` 에서 파이프라인을 멈추는 것은 실수다 | 에러로 스트림이 끊기지 않습니다 |
| `IScheduler` 가 느림의 뿌리다 | `IScheduler` 를 지웠습니다 |
| 게임 엔진에는 프레임 연산이 중요하다 | 프레임축을 시간축과 나란한 추상으로 올렸습니다 |
| 단발 비동기는 async/await 에 맡긴다 | 단발 비동기를 라이브러리 밖으로 뺐습니다 |
| 동기 API 는 만들지 않는다 | `Wait()` 같은 블로킹 API 가 없습니다 |
| 쿼리 문법은 SQL 말고는 나쁜 표기다 | 메서드 체인만 있습니다 |
| 구독 누수를 막으려면 구독 목록이 필요하다 | 살아 있는 구독 전체를 추적합니다 |
| 배압은 `IAsyncEnumerable` 과 `Channels` 에 맡긴다 | 배압 연산자가 없습니다 |

그래서 UniRx 가 떠안던 기능이 R3 에는 없습니다.

| UniRx 의 것 | 어디로 |
| --- | --- |
| `MessageBroker` | MessagePipe |
| `ReactiveCollection` | ObservableCollections |
| `ObservableWWW` · `FromCoroutine` | async/await (`Awaitable` 등) |
| `MainThreadDispatcher` | 대응물이 없습니다. PlayerLoop 에 직접 꽂습니다 |
| 배압 연산자 | `IAsyncEnumerable` 과 `Channels` |
| `Wait()` 같은 블로킹 · 쿼리 문법 | 만들지 않기로 했습니다 |

경계는 **값이 하나냐 여럿이냐**입니다. 씬 로드나 HTTP 요청처럼 `await` 하나로 끝나는 일은 async/await 에 두고, 여러 번 오는 입력과 상태 변화가 R3 몫입니다. `MessageBroker` 로 게임 전체 이벤트를 돌리던 프로젝트라면 R3 로 옮기는 것이 아니라 메시징 라이브러리를 하나 더 들이는 일이 됩니다.

## 옛 이름은 별칭 없이 지웠다

| UniRx | R3 | 왜 |
| --- | --- | --- |
| `Throttle` | `Debounce` | 원래 이름이 업계 통념과 반대였습니다 |
| `Sample` | `ThrottleLast` | `ThrottleFirst` 와 `ThrottleFirstLast` 도 있습니다 |
| `Buffer` | `Chunk` | `ChunkUntil` 도 있습니다 |
| `ObserveEveryValueChanged` | `Observable.EveryValueChanged` | 확장 메서드에서 정적 팩토리로 |
| `OnError` | `OnErrorResume` | **동작 자체가 다릅니다** |
| `OnCompleted()` | `OnCompleted(Result)` | 인자가 생겼습니다 |

구 이름이 R3 에 아예 없으니 전부 컴파일 에러로 드러납니다. 조용히 다르게 도는 것보다 낫습니다. 다만 에러를 보고 이름이 비슷한 쪽으로 고치면 컴파일은 되는데 동작이 바뀝니다. UniRx 의 `Throttle` 은 입력이 멎은 뒤 마지막 값을 내는 연산자라 R3 에서는 `Debounce` 자리입니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 입력, 0.3초 기준",
  "tabs": [
    { "label": "Debounce (UniRx 의 Throttle)", "code": "입력  a(0.0) b(0.1) c(0.2) ........ d(1.0)\n출력                    c(0.5) ........ d(1.3)", "text": "입력이 0.3초 동안 멎어야 마지막 값을 냅니다. 검색창 입력처럼 다 친 뒤에 한 번만 처리할 때 씁니다.", "tone": "good" },
    { "label": "ThrottleFirst", "code": "입력  a(0.0) b(0.1) c(0.2) ........ d(1.0)\n출력  a(0.0) ....................... d(1.0)", "text": "구간의 첫 값을 바로 내고 0.3초 동안 나머지를 버립니다. 버튼 연타 방지가 이쪽입니다.", "tone": "bad" },
    { "label": "ThrottleLast (UniRx 의 Sample)", "code": "입력  a(0.0) b(0.1) c(0.2) ........ d(1.0)\n출력            (구간 끝) c ....... (구간 끝) d", "text": "구간마다 마지막 값을 구간이 끝날 때 냅니다. 멎기를 기다리지 않습니다.", "tone": "bad" }
  ]
}
</script>
</x-compare>

`Debounce` 는 마지막 입력 뒤 조용한 시간을 기다리고 `ThrottleFirst` 는 첫 입력에 바로 반응합니다. **이름이 아니라 동작으로 대조해야** 옮긴 코드가 같은 일을 합니다.

## README 의 벤치마크는 UniRx 와 견준 것이 아니다

README 가 근거로 든 벤치마크는 `Range(1, 10000).Subscribe()` 와 `Subject` 에 구독 1만 건 추가 후 해제, 둘입니다. 코드(`sandbox/Benchmark/Program.cs`)를 보면 비교 대상은 **.NET 위의 System.Reactive** 입니다. UniRx 에서 옮길지 정할 때 그 수치를 그대로 가져오면 안 됩니다.

같은 두 벤치마크를 유니티에서 R3 와 UniRx 로 돌렸습니다.

| 벤치마크 | R3 (Mono) | UniRx (Mono) | R3 (IL2CPP) | UniRx (IL2CPP) |
| --- | ---: | ---: | ---: | ---: |
| `Range(1, 10000).Subscribe()` 시간 | 0.11 ms | 25.3 ms | 0.06 ms | 16.3 ms |
| 같은 것 할당 | 80 B | 2.51 MB | 80 B | 2.66 MB |
| `Subject` 구독 1만 + 해제 1만 시간 | 14.5 ms | 1,313 ms | 10.3 ms | 507 ms |
| 같은 것 할당 | 약 1.07 MB | 약 765 MB | 약 1.07 MB | 약 765 MB |

두 벤치마크 모두 R3 가 크게 앞섰습니다. 다만 README 가 고른 것은 UniRx 가 가장 약한 자리입니다. 값을 흘리는 비용은 Mono 에서 UniRx 가 빨랐습니다(구독자 1명에 `OnNext` 1회, UniRx 13.1 ns 대 R3 21.0 ns). README 에 "제로 얼로케이션" 이라는 말도 없습니다. 실제 문구는 불필요한 할당을 피했다는 쪽입니다.

## 주의할 점

### 1. 한 파일에서 UniRx 와 R3 를 같이 쓰지 않는다

**안 지키면 생기는 문제.** `Observable` · `Subject<T>` · `ReactiveProperty<T>` · `CompositeDisposable` · `Unit` 이 양쪽에 다 있습니다. 두 `using` 을 같이 쓰면 이 이름을 쓰는 줄마다 모호하다는 컴파일 에러가 납니다.

**이렇게 쓴다.** asmdef 로 어셈블리를 가르고 한쪽은 UniRx 만, 다른 쪽은 R3 만 참조해서 모듈 하나씩 옮깁니다. `extern alias` 로 한 파일에 섞는 것은 문법으로는 되지만, 에러가 나면 스트림이 끊기는지가 줄마다 달라져서 읽는 사람이 따라갈 수 없습니다.

### 2. 두 인자짜리 Subscribe 는 두 번째 람다의 인자 타입을 적는다

**안 지키면 생기는 문제.** UniRx 의 `Subscribe(onNext, onError)` 에서 두 번째는 에러 핸들러인데, R3 의 `Subscribe(onNext, onCompleted)` 에서 두 번째는 완료 핸들러(`Action<Result>`)입니다. 대개 컴파일 에러가 나지만 람다 인자 타입을 안 적으면 추론이 통과해 **뜻이 바뀐 채 컴파일될 수 있습니다.**

**이렇게 쓴다.** 옮길 때 두 인자짜리 호출을 전부 찾아 `(Exception ex) =>` 나 `(Result r) =>` 처럼 타입을 적습니다. 에러를 받으려면 `onErrorResume` 이 들어 있는 세 인자 오버로드로 바꿉니다.

### 3. Throttle 은 Debounce 로 옮긴다

**안 지키면 생기는 문제.** 이름이 비슷한 `ThrottleFirst` 로 옮기면 "다 치고 나서 한 번" 이 "첫 입력에 바로 한 번" 으로 바뀝니다. 컴파일은 됩니다.

**이렇게 쓴다.** UniRx `Throttle` 은 `Debounce`, `Sample` 은 `ThrottleLast`, `Buffer` 는 `Chunk` 로 옮깁니다. 이름이 아니라 위 타임라인의 동작으로 대조합니다.

## 정리

R3 는 **같은 저자가 범위를 좁혀 계약부터 다시 만든 별개 라이브러리**입니다.

- 메시징, 컬렉션, 단발 비동기, 배압은 R3 밖으로 갔습니다. 값이 여럿 오는 이벤트 처리만 남았습니다.
- 옛 연산자 이름은 별칭 없이 지워 컴파일 에러로 드러납니다. 고칠 때는 이름이 아니라 동작으로 대조합니다.
- README 의 벤치마크는 System.Reactive 와 견준 것입니다. 유니티에서 UniRx 와 견줘도 두 항목은 R3 가 크게 앞섰지만 값 발행은 Mono 에서 UniRx 가 빨랐습니다.