R3 의 구독은 끝나지 않는 스트림만 직접 거둔다
Observable.EveryUpdate().Subscribe(_ => Tick()); // 누수
Observable.EveryUpdate().Take(5).Subscribe(_ => Tick()); // 누수 아님
모든 구독에 해제 코드를 붙이자니 번거롭고, 빼먹으면 씬을 넘겨도 Tick 이 계속 불립니다. 둘의 차이는 Take(5) 하나입니다.
R3 는 끝나는 스트림을 스스로 정리합니다. 직접 거둘 것은 끝이 없는 스트림뿐이고, 그것을 무엇에 담을지는 할당과 개별 제거가 필요한지로 고릅니다.
끝나는 스트림은 스스로 풀린다
완료되면 구독이 자동으로 정리되고, 그 해제가 상류 끝까지 전파됩니다.
| 상황 | 자동 해제 |
|---|---|
OnCompleted 로 끝남 · Take(5) 처럼 개수로 끝남 |
됩니다 |
TakeUntil(token) 이 취소됨 |
됩니다 |
Subject 를 Dispose |
됩니다. 구독자에게 완료가 전파됩니다 |
EveryUpdate() 같은 무한 스트림 |
안 됩니다 |
ReactiveProperty 구독 |
안 됩니다. 프로퍼티가 사는 동안 유지됩니다 |
직접 거둬야 하는 것은 끝이 없는 스트림뿐입니다. 다만 게임에서는 그것이 대부분입니다.
수집기 셋은 할당과 개별 제거로 고른다
구독 여덟 건을 모으는 것을 만 번 반복해 수집기 몫만 뽑은 값입니다.
| 수집기 | 형태 | 수집기 비용 | 개별 제거 · 스레드 안전 | 언제 |
|---|---|---|---|---|
DisposableBuilder |
ref struct |
80 B | 없음 | 한 메서드 안에서 묶고 끝낼 때 |
DisposableBag |
struct |
160 B | 없음 | 필드로 들고 계속 추가할 때 |
CompositeDisposable |
class |
256 B | 둘 다 됨 | 개별 제거나 여러 스레드가 필요할 때 |
DisposableBuilder 는 8개까지 스택 필드에 담아 모으는 동안 힙 할당이 없고, 80 B 는 Build() 가 돌려주는 객체 하나입니다. README 가 무할당이라고 말하는 것도 이 빌더 자체이지 구독이 아닙니다. ref struct 라 필드로 들 수 없고 async 메서드나 람다 안에서 못 쓰니, Start 에서 모아 Build() 결과 하나만 필드에 둡니다.
DisposableBag 은 값 타입이라 ref 로만 다뤄야 합니다. CompositeDisposable 은 개별 제거와 스레드 안전을 주는 대신 Count 게터조차 락을 잡습니다.
AddTo(this) 는 파괴에 묶이고 컴포넌트를 붙이지 않는다
가장 짧은 방법은 구독을 오브젝트 수명에 맡기는 AddTo(this) 입니다. Unity 2022.2 이상에서 활성 MonoBehaviour 에 붙이면 숨은 컴포넌트를 만들지 않고 엔진의 destroyCancellationToken 에 묶습니다.
비활성 오브젝트이거나 GameObject 오버로드를 쓰면 여전히 ObservableDestroyTrigger 가 붙습니다. 활성 경로의 실체는 RegisterTo(destroyCancellationToken) 이라, 토큰을 직접 주면 아무 CancellationToken 에나 수명을 맡길 수 있습니다.
async Awaitable RunBattleAsync(CancellationToken ct)
{
Observable.EveryUpdate()
.Subscribe(_ => UpdateBattle())
.RegisterTo(ct); // 토큰이 취소되면 구독도 풀린다
await BattleLoopAsync(ct);
}
비동기 흐름과 스트림이 섞이는 코드에서 해제 지점을 하나로 모을 수 있습니다.
주의할 점
1. 끝나지 않는 스트림은 구독하는 줄에서 바로 수명에 묶는다
안 지키면 생기는 문제. EveryUpdate 처럼 끝이 없는 스트림은 완료로 정리될 일이 없습니다. 구독한 오브젝트가 파괴돼도 러너는 PlayerLoop 에 있으니 계속 불립니다. 정적 Subject 를 인스턴스가 구독하거나 DontDestroyOnLoad 오브젝트가 씬 오브젝트를 구독할 때도 수명이 긴 쪽이 짧은 쪽을 붙들어 씬을 넘겨도 수거되지 않습니다.
이렇게 쓴다. Subscribe 뒤에 곧바로 AddTo 나 RegisterTo 를 붙입니다. 새는지 의심되면 Window → Observable Tracker 를 열어 씬을 넘긴 뒤에도 남아 있는 구독을 봅니다. 어디서 구독했는지까지 보는 스택 트레이스 수집은 구독마다 콜스택을 걷으므로 찾을 때만 켭니다.
2. DisposableBag 은 ref 로만 넘긴다
안 지키면 생기는 문제. 값 타입이라 ref 없이 넘기면 복사본에 담기고 원본은 비어 있습니다. 컴파일은 되고 OnDestroy 에서 아무것도 안 풀립니다.
void Register(DisposableBag bag) { source.Subscribe(...).AddTo(ref bag); } // 복사본에 담긴다
void Register(ref DisposableBag bag) { source.Subscribe(...).AddTo(ref bag); } // 원본에 담긴다
이렇게 쓴다. DisposableBag 을 받는 메서드는 매개변수를 ref DisposableBag 으로 선언합니다. 필드로 들고 있는 가방에 직접 AddTo(ref bag) 하는 것이 가장 안전합니다.
3. 풀에서 도는 오브젝트는 AddTo(this) 에 맡기지 않는다
안 지키면 생기는 문제. AddTo(this) 는 파괴에 묶입니다. 오브젝트 풀은 파괴하지 않고 비활성으로 돌려놓으니 풀로 돌아간 오브젝트의 EveryUpdate 구독이 계속 돕니다. 다시 꺼낼 때 OnEnable 에서 또 구독하면 한 벌씩 쌓입니다.
이렇게 쓴다. 활성 구간에 묶습니다. OnEnable 에서 구독해 가방에 담고 OnDisable 에서 Clear() 로 비웁니다.
void OnEnable() => Observable.EveryUpdate().Subscribe(_ => Think()).AddTo(ref bag);
void OnDisable() => bag.Clear(); // 풀로 돌아갈 때마다 비운다
void OnDestroy() => bag.Dispose(); // 정말 파괴될 때
정리
R3 에서 직접 거둘 것은 끝나지 않는 스트림뿐입니다.
- 완료,
Take,TakeUntil,Subject해제로 끝나는 구독은 스스로 풀리고 상류까지 전파됩니다. - 수집기는
DisposableBuilder(80 B, 한 메서드),DisposableBag(160 B,ref필수),CompositeDisposable(256 B, 개별 제거와 스레드 안전) 중에서 고릅니다. AddTo(this)는 2022.2 이상 활성 컴포넌트에서destroyCancellationToken을 쓰고, 파괴에만 묶이니 풀링 오브젝트는OnDisable에서 비웁니다.