R3 는 UniRx 를 고치지 않고 다시 쓴 라이브러리다
Esc

    R3 는 UniRx 를 고치지 않고 다시 쓴 라이브러리다

    2026년 9월 25일 수정–UnityRx라이브러리

    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 자리입니다.

    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 가 빨랐습니다.