R3 는 UniRx 를 고치지 않고 다시 쓴 라이브러리다
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 가 빨랐습니다.