R3 는 구독 핸들을 따로 만들지 않는다
var d = source.Where(x => x > 0).Select(x => x * 2).Subscribe(Debug.Log);
d.Dispose(); // 무엇이 어디까지 풀리나
이 한 줄이 안에서 무엇을 만드는지 모르면, 같은 스트림을 두 곳에서 구독했을 때 서버 요청이 두 번 가는 이유도, Dispose 하나로 무엇이 풀리는지도 읽을 수 없습니다.
R3 의 Subscribe 는 연산자마다 하류 관찰자를 감싼 관찰자를 하나씩 쌓아 올리고, 맨 아래 관찰자 자신을 구독 핸들로 돌려줍니다. 핸들 객체를 따로 만들지 않습니다.
구독은 올라가고 값은 내려온다
연산자는 SubscribeCore 에서 하류 관찰자를 감싼 새 관찰자를 만들어 상류에 넘기기만 합니다. 그래서 구독은 아래에서 위로 올라가고, 값은 위에서 아래로 내려옵니다.
관찰자 체인은 구독 시점에 역순으로 쌓입니다. Select 가 Where 를, Where 가 소스를 구독하는 순서입니다.
관찰자가 곧 구독 핸들이다
// R3/Observable.cs
public IDisposable Subscribe(Observer<T> observer)
{
var subscription = SubscribeCore(observer);
observer.SourceSubscription.Disposable = subscription; // 상류 구독을 관찰자에 부착
return observer; // 관찰자 자신을 돌려준다
}
UniRx 의 Subject 는 여기서 new Subscription(this, observer) 로 별도 핸들을 만듭니다. R3 는 관찰자마다 자기 위쪽 구독을 SourceSubscription 에 들고 있어서, 맨 아래 관찰자 하나를 Dispose 하면 해제가 상류 끝까지 번집니다. 구독이 끝나기 전에 해제가 먼저 와도 늦게 도착한 구독을 그 자리에서 해제합니다.
구독 하나와 관찰자 하나가 1:1 로 확정되고 모든 구독이 봉인된 Subscribe 한 곳을 지나니, 살아 있는 구독 전체를 목록으로 들 수 있습니다. 유니티에서 Window → Observable Tracker 로 열면 지금 몇 개가 살아 있고 어디서 구독됐는지가 보입니다. 추적을 꺼 두면 분기 하나만 남습니다. 구현체마다 Subscribe 를 자기 방식으로 짜는 인터페이스였다면 이 추적 지점을 강제할 수 없습니다.
체인은 구독마다 새로 조립된다
SubscribeCore 가 구독마다 불리니 소스 쪽 일도 구독마다 따로 시작됩니다. 구독해야 비로소 움직이는 이런 스트림을 콜드(cold), Subject 처럼 이미 흐르고 있고 구독은 “지금부터 듣기” 인 쪽을 핫(hot) 이라고 부릅니다.
Share() 는 Publish().RefCount() 입니다. 구독자 수가 0에서 1이 될 때 소스를 켜고 1에서 0이 될 때 끕니다. 그래서 “한 번만 실행” 이 아니라, 모두 떠났다가 다시 구독하면 소스가 처음부터 다시 켜지고, 늦게 합류한 구독자는 지나간 값을 받지 못합니다.
할당 이득은 연산자를 걸 때부터 난다
핸들을 안 만드니 늘 더 쌀 것 같지만, 재보니 연산자 없는 단순 구독에서는 R3 가 더 썼습니다. Select 와 Where 를 번갈아 건 체인에서 구독과 해제 한 쌍입니다.
| 깊이 | 할당 UniRx | 할당 R3 | 시간 Mono UniRx | Mono R3 | IL2CPP UniRx | IL2CPP R3 |
|---|---|---|---|---|---|---|
| 0 | 104 B | 112 B | 1.21 µs | 1.00 µs | 0.82 µs | 0.75 µs |
| 1 | 240 B | 160 B | 2.64 µs | 1.49 µs | 1.72 µs | 0.97 µs |
| 3 | 376 B | 216 B | 4.09 µs | 1.99 µs | 2.55 µs | 1.27 µs |
깊이 0 에서 R3 가 8 B 더 쓰는 것은 UniRx Subject 가 구독자 0명일 때 목록 객체를 아예 만들지 않는 특례가 있고, R3 의 ObserverNode 가 앞뒤 노드와 버전까지 들어 크기 때문입니다. 연산자를 하나만 걸어도 뒤집히고, 깊이 3 에서 R3 는 할당이 약 58%, 시간이 두 런타임 모두 약 절반입니다.
값을 흘리는 비용은 런타임에 따라 갈렸습니다. 깊이 3 체인을 값 하나가 통과하는 데 Mono 는 UniRx 31.4 ns, R3 48.0 ns, IL2CPP 는 UniRx 73.6 ns, R3 23.2 ns 였습니다. 구독을 자주 만들고 없애는 코드라면 R3 쪽 이득이 분명하고, 오래 붙여 둔 체인에 값을 많이 흘리는 코드라면 대상 런타임에 따라 다릅니다.
주의할 점
1. 같은 스트림을 여러 곳에서 구독할 때는 부수효과를 확인한다
안 지키면 생기는 문제. 구독마다 체인이 따로 돌아서 Select 나 Do 안의 부수효과가 구독 수만큼 반복됩니다. 서버 요청이 두 번 가고, 람다가 바깥 변수를 캡처했다면 구독을 하나 늘리는 것만으로 기존 구독자가 받는 값이 바뀝니다.
이렇게 쓴다. 연산자 안에는 부수효과를 넣지 않습니다. 소스 쪽 일을 한 번만 해야 하면 Share() 로 나누고, 지난 값까지 줘야 하면 현재 값을 들고 있는 ReactiveProperty 나 BehaviorSubject 에 담아 나눠 줍니다.
2. Share 는 구독자가 0명이 되면 꺼진다는 것을 전제로 쓴다
안 지키면 생기는 문제. 구독자가 모두 해제되면 소스가 꺼지고 다음 구독에서 처음부터 다시 켜집니다. 창을 닫았다 열 때마다 초기 요청이 다시 나가는 식입니다.
이렇게 쓴다. 소스를 계속 켜 두어야 하면 그 스트림을 구독하는 쪽 하나를 수명이 긴 곳에 둡니다. 합류 시점과 상관없이 최신 값이 필요하면 값을 들고 있는 타입에 담습니다.
3. 구독 시점의 예외는 Subscribe 호출부에서 잡는다
안 지키면 생기는 문제. 값이 흐를 때 난 예외는 OnErrorResume 으로 가지만, SubscribeCore 도중에 난 예외는 반쯤 연결된 관찰자를 정리한 뒤 그대로 다시 던져집니다. 에러 핸들러를 줘도 거기로 가지 않습니다.
catch
{
observer.Dispose(); // 구독에 실패했으니 하류 관찰자를 떼어낸다
throw; // 그리고 그대로 던진다
}
이렇게 쓴다. Observable.Range(1, -1) 같은 인자 오류처럼 구독하는 순간 실패할 수 있는 스트림은 Subscribe 호출을 try 로 감쌉니다.
4. 연산자 없는 단순 구독으로 할당 이득을 기대하지 않는다
안 지키면 생기는 문제. Subject 에 연산자 없이 붙는 구독은 R3 가 UniRx 보다 8 B 더 씁니다. 할당을 줄이려고 옮겼는데 이런 구독만 많으면 얻는 것이 없습니다.
이렇게 쓴다. 옮기기 전에 체인 깊이부터 봅니다. 값을 흘리는 속도는 별개라 Mono 대상이면 따로 확인합니다.
정리
R3 는 관찰자 자신을 구독 핸들로 돌려주고, 그 위에 해제 전파와 구독 추적을 얹습니다.
- 구독은 아래에서 위로, 값은 위에서 아래로 흐르고, 해제는
SourceSubscription을 타고 상류 끝까지 번집니다. - 체인은 구독마다 새로 조립됩니다. 나눠 들으려면
Share()를 쓰고, 0명이 되면 꺼진다는 것까지 알고 씁니다. - 할당은 연산자를 하나라도 걸 때부터 R3 가 유리하고, 값 통과는 IL2CPP 에서 R3, Mono 에서 UniRx 가 빨랐습니다.