UniRx 의 비용은 무언가를 사려고 낸 값이다
Esc

    UniRx 의 비용은 무언가를 사려고 낸 값이다

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

    Subject<T> 에 구독자를 4,000명 붙였다가 떼면 123.1 MB 가 할당되고 55.64 ms 가 걸립니다. 60fps 로 3.3프레임입니다. 웨이브마다 적이 전역 이벤트에 붙었다 빠지는 게임이면 그때마다 화면이 멈칫합니다.

    UniRx 에서 값을 치르는 자리는 모두 무언가를 사려고 낸 값입니다. 무엇을 샀는지 알면 어디서 피해야 하는지가 정해집니다.

    Subject 는 구독 목록을 매번 새 배열로 바꾼다

    Subject 는 구독자 목록을 ImmutableList 로 듭니다. 이름은 Add 지만 기존 배열에 넣지 않고 새 배열을 만들어 통째로 복사합니다. 해제도 같습니다.

    // InternalUtil/ImmutableList.cs
    public ImmutableList<T> Add(T value)
    {
        var newData = new T[data.Length + 1];        // 매번 새 배열
        Array.Copy(data, newData, data.Length);
        newData[data.Length] = value;
        return new ImmutableList<T>(newData);
    }

    크기 1, 2, 3 … N 인 배열이 차례로 생기니 N건을 붙였다 떼면 할당이 약 8N² 바이트입니다(참조 하나 8 B).

    N 이론값 8N² 실측
    1,000 8 MB 7.88 MB
    2,000 32 MB 31.0 MB
    4,000 128 MB 123.1 MB

    해제 순서도 영향을 줍니다. Remove 는 자기 자리를 처음부터 찾으므로 나중 것부터 풀면 붙인 순서보다 약 2배 느렸습니다(N=4,000, Mono 해제 구간 269.7 ms 대 127.4 ms). 붙인 순서로 풀어도 해제마다 남은 목록을 복사하니 시간은 N제곱으로 늘었습니다.

    Subject 가 적마다 하나씩 흩어져 있으면 배열이 작아서 드러나지 않습니다. 한 Subject 에 수천 건이 몰렸다 빠질 때 터집니다.

    그 복사가 락 없는 발행을 산다

    발행 쪽을 보면 복사의 이유가 보입니다.

    // Subjects/Subject.cs
    public void OnNext(T value) { outObserver.OnNext(value); }   // 락이 없다

    목록을 고칠 때 새 배열로 참조만 갈아끼우니, 발행은 참조 하나를 읽고 끝납니다. 참조 대입은 원자적이라 락이 필요 없습니다.

    List<T> 였다면 순회 도중 원소가 빠질 때 InvalidOperationException 이 납니다. 값은 자주 흐르고 구독은 가끔 일어나니, 자주 불리는 쪽을 싸게 만든 선택입니다. 같은 라이브러리의 ReactiveProperty 는 노드가 곧 구독 핸들인 이중 연결 리스트를 써서 추가와 삭제가 O(1) 이고, 구독자 50명에 값을 10,000번 바꿔도 0 B 였습니다.

    스케줄러는 값 하나마다 예약을 만든다

    IScheduler.Schedule 은 한 번 실행을 예약하고 취소용 IDisposable 을 돌려줍니다. 반복이라는 개념이 없어서 Range 같은 연산자는 자기를 다시 예약합니다.

    // UniRx/Scripts/Operators/Range.cs
    return scheduler.Schedule((Action self) =>
    {
        if (i < count) { observer.OnNext(start + i); i++; self(); }   // self() 가 다음 스텝을 예약
        else observer.OnCompleted();
    });

    재예약 한 번마다 ScheduledItem 하나, 클로저 둘, 취소용 IDisposable 하나, 그 손잡이를 갈아끼우는 락 두 번이 듭니다. Range(1, 10000) 이면 작업 항목 1만 개, 클로저 약 2만 개, 락 약 2만 번입니다. 스케줄러는 싱글턴 하나이고 타이머는 만들지 않습니다. 늘어나는 것은 작업 항목입니다.

    Range(1, 10000) 구독 한 번 Mono IL2CPP
    기본값 (CurrentThread) 2.51 MB · 25.3 ms 2.66 MB · 16.3 ms
    Scheduler.Immediate 256 B · 0.12 ms 256 B · 0.39 ms

    기본값은 값 하나에 약 250~270 B 이고, Immediate 는 그냥 for 문이라 값 하나당 0 B 입니다. 빠른 길이 코드에 있는데 인자를 생략하면 느린 길로 갑니다.

    산 것은 가상 시간 테스트입니다. 테스트에서 하루를 즉시 흘려보내려면 시간이 새지 않아야 하고, 그러려면 모든 실행이 스케줄러를 지나야 합니다. async/await 도 CancellationToken 도 없던 시절의 Rx.NET 설계를 그대로 가져온 값입니다.

    프레임 쪽 비용도 같은 모양이다

    값 산 것
    MicroCoroutine.Run 이 경합이 없어도 매 프레임 lock 을 잡습니다 아무 스레드에서나 구독을 추가하고 해제해도 되는 자유
    EveryUpdate 가 yield 이터레이터라 구독마다 컴파일러 생성 클래스가 하나씩 생깁니다 코루틴마다 네이티브 객체를 만드는 StartCoroutine 을 쓰지 않는 것
    디스패처가 진짜 GameObject 라 중복 방어 코드와 하이어라키의 정체 모를 오브젝트가 따라옵니다 엔진에 손대지 않고 어느 유니티 버전에서나 프레임을 얻는 것

    셋 모두 실수가 아니라 당시에 필요한 것을 산 값입니다. 문제는 그 값이 커지는 모양을 모르고 쓸 때입니다.

    주의할 점

    1. 하나의 Subject 에 구독을 수천 개씩 붙였다 떼지 않는다

    안 지키면 생기는 문제. 구독을 붙이고 뗄 때마다 목록 배열을 통째로 새로 만들어서 N건이면 할당이 약 8N² 입니다. 4,000건이면 123.1 MB, 55.64 ms 입니다. 웨이브마다 적 수천 마리가 전역 이벤트에 붙었다 빠지는 구조가 정확히 이 모양입니다.

    이렇게 쓴다. 구독이 몰리는 쪽을 흩습니다. 적마다 자기 Subject 를 두고 관리자 쪽이 그것을 구독하면 배열은 여럿이어도 각각 작습니다. 한 번 붙이고 씬이 끝날 때까지 두는 구독은 걱정하지 않아도 됩니다.

    2. 몰린 구독을 한꺼번에 풀 때는 붙인 순서대로 푼다

    안 지키면 생기는 문제. 해제는 목록에서 자기 자리를 처음부터 찾습니다. 나중에 붙인 것부터 풀면 매번 끝까지 훑어서 붙인 순서보다 약 2배 느렸고, 4,000건이면 Mono 에서 해제만 270 ms 였습니다.

    이렇게 쓴다. 여러 구독을 한꺼번에 정리해야 하면 붙인 순서대로 해제합니다. 그래도 배열 복사 때문에 시간은 N제곱으로 늘고 줄어드는 것은 절반 정도이니, 먼저 1번처럼 한 Subject 에 몰리지 않게 짭니다.

    3. 동기로 끝나도 되는 반복에는 Scheduler.Immediate 를 준다

    안 지키면 생기는 문제. Observable.Range(start, count) 처럼 스케줄러를 생략하면 CurrentThread 로 가서 값 하나마다 예약과 클로저와 락이 생깁니다. 실측으로 값 하나에 약 250~270 B 를 할당했습니다.

    이렇게 쓴다. 그 자리에서 다 내보내도 되는 반복이면 Scheduler.Immediate 를 넘깁니다. 그러면 for 문으로 돕니다.

    정리

    UniRx 의 비용은 실수가 아니라 산 것의 값이고, 커지는 모양을 알면 피할 수 있습니다.

    • Subject 의 배열 복사는 락 없는 OnNext 를 산 값입니다. 한 Subject 에 구독이 몰리면 8N² 으로 커집니다.
    • 스케줄러의 재예약은 가상 시간 테스트를 산 값입니다. 기본값이 느린 길이라 Scheduler.Immediate 를 직접 줘야 합니다.
    • 매 프레임 락, 구독마다 상태 머신, 씬의 디스패처는 스레드 자유, 네이티브 코루틴 회피, 엔진 무수정을 산 값입니다.