R3 의 구독은 끝나지 않는 스트림만 직접 거둔다
Esc

    R3 의 구독은 끝나지 않는 스트림만 직접 거둔다

    2026년 9월 25일 수정–UnityRx라이브러리
    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 에서 비웁니다.