R3 코어는 유니티를 모른다
Esc

    R3 코어는 유니티를 모른다

    2026년 9월 25일 수정–UnityRx라이브러리
    Observable.EveryUpdate().Subscribe(_ => Tick());
    // NotSupportedException: ObservableSystem.DefaultFrameProvider is not set.

    EveryUpdate 는 유니티 프레임에 맞춰 도는데, 유니티 밖이나 초기화가 빠진 빌드에서는 이렇게 던집니다. R3 코어에 유니티 참조가 하나도 없기 때문입니다.

    R3 코어는 전역값 셋만 들고, 유니티 쪽 어셈블리가 로드 직후에 그것을 채웁니다. 떼어 놓은 덕에 프레임을 손으로 밀어 테스트할 수 있습니다.

    코어는 전역값 셋만 들고 프레임 기본값은 던진다

    // R3/ObservableSystem.cs
    static TimeProvider defaultTimeProvider = TimeProvider.System;
    static FrameProvider defaultFrameProvider = new NotSupportedFrameProvider();
    static Action<Exception> unhandledException = DefaultUnhandledExceptionHandler;
    전역값 코어의 초기 상태 유니티에서
    DefaultTimeProvider TimeProvider.System (스레드풀) UnityTimeProvider.Update
    DefaultFrameProvider NotSupportedFrameProvider UnityFrameProvider.Update
    미처리 예외 핸들러 Console.WriteLine Debug.LogException

    프레임은 플랫폼 개념이라 코어에 옳은 기본값이 없습니다. 그래서 NotSupportedFrameProvider 는 조용히 아무것도 안 하는 대신 무엇을 세팅해야 하는지 알려 주며 던집니다.

    R3.Unity 가 모든 Awake 보다 먼저 채운다

    // R3.Unity/Runtime/UnityProviderInitializer.cs
    [assembly: UnityEngine.Scripting.AlwaysLinkAssembly]
    
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
    public static void SetDefaultObservableSystem()
        => SetDefaultObservableSystem(static ex => UnityEngine.Debug.LogException(ex));
    
    public static void SetDefaultObservableSystem(Action<Exception> unhandledExceptionHandler)
    {
        ObservableSystem.RegisterUnhandledExceptionHandler(unhandledExceptionHandler);
        ObservableSystem.DefaultTimeProvider  = UnityTimeProvider.Update;
        ObservableSystem.DefaultFrameProvider = UnityFrameProvider.Update;
    }

    AfterAssembliesLoaded 는 어셈블리 로드 직후, 모든 Awake 보다 앞입니다. 에디트 모드용으로 [InitializeOnLoad] 와 정적 생성자에서도 같은 일을 합니다.

    AlwaysLinkAssembly 가 실무에서 중요합니다. 이 어셈블리는 초기화 메서드가 알아서 불리는 구조라 아무도 참조하지 않습니다. 표시가 없으면 IL2CPP 빌드에서 통째로 잘려 나갈 수 있고, 그러면 에디터에서는 되는데 빌드에서만 초기화가 사라집니다. 지금 패키지는 IL2CPP 빌드(스트리핑 Minimal)에서도 초기화가 살아 있었습니다.

    게터는 DI 컨테이너를 먼저 본다

    DefaultTimeProvider 게터는 정적 필드를 바로 돌려주지 않고 등록된 서비스 제공자에 먼저 묻습니다.

    ObservableSystem.RegisterServiceProvider(myServiceProvider);                // 인스턴스
    ObservableSystem.RegisterServiceProvider(() => currentScope.Container);     // 스코프가 바뀌는 경우

    팩토리로 등록하면 씬마다 갈리는 스코프를 매번 따라갑니다. 게터는 캐시를 안 해서 시간 연산자를 만들 때마다 조회하지만, 잰 값은 한 번에 Mono 약 2881 ns, IL2CPP 약 19175 ns 에 할당 0 B 였습니다. Observable.Timer(10초) 를 만들고 구독하고 해제하는 데 드는 수십 µs 의 1% 안팎입니다.

    프레임을 손으로 밀어 테스트한다

    코어가 프레임을 추상으로만 알기 때문에 가짜 프레임을 꽂으면 유니티 없이, 기다리지 않고 프레임 로직을 검증할 수 있습니다. FakeFrameProvider 는 R3 가 직접 줍니다.

    var frame = new FakeFrameProvider();
    var results = new List<Unit>();
    
    Observable.EveryUpdate(frame).Take(3).Subscribe(x => results.Add(x));
    
    frame.Advance(3);                    // 3프레임을 즉시 진행
    Assert.AreEqual(3, results.Count);

    시간축의 FakeTimeProvider 는 별도 NuGet 패키지(Microsoft.Extensions.TimeProvider.Testing)에 있는데, 대상 프레임워크가 net462, net6.0, net8.0 뿐이라 NuGetForUnity 로 받으면 lib 폴더가 비어 dll 이 들어오지 않았습니다. 유니티에서는 FakeFrameProvider 로 짜는 편이 현실적입니다. UniRx 는 프레임 연산이 씬의 MonoBehaviour 에 묶여 있어서 플레이 모드 없이는 돌지 않습니다.

    주의할 점

    1. 빌드에서만 초기화가 사라지면 스트리핑부터 본다

    안 지키면 생기는 문제. 초기화 어셈블리는 아무도 참조하지 않아서 IL2CPP 빌드가 잘라 낼 수 있습니다. 그러면 에디터에서는 도는 EveryUpdate 가 빌드에서만 NotSupportedException 을 던지고, 타이머는 스레드풀에서 돕니다. R3.Unity 에는 AlwaysLinkAssembly 가 붙어 있지만 스트리핑 설정을 손본 프로젝트에서는 다시 확인할 자리입니다.

    이렇게 쓴다. 빌드에서만 증상이 나면 link.xml 과 스트리핑 수준을 먼저 봅니다. “아무도 부르지 않는 초기화 코드” 를 직접 만든다면 같은 속성을 붙입니다.

    2. 전역 기본값은 가장 나중에 바꾼다

    안 지키면 생기는 문제. ObservableSystem 의 기본값은 static 이라 프로세스에 하나입니다. 테스트에서 DefaultFrameProvider 를 가짜로 바꾸고 그대로 두면 뒤에 도는 다른 테스트와 남의 코드까지 가짜 프레임 위에서 돕니다.

    이렇게 쓴다. 연산자 인자로 넘기기, DI 컨테이너 등록, 전역 대입 순으로 좁은 방법부터 씁니다. 테스트에서 전역을 바꿨다면 끝날 때 원래 값으로 되돌립니다.

    3. 미처리 예외 핸들러는 더하는 것이 아니라 바꾸는 것이다

    안 지키면 생기는 문제. RegisterUnhandledExceptionHandler 는 += 가 아니라 대입입니다. 크래시 리포터를 붙이려고 등록하면 유니티가 걸어 둔 Debug.LogException 이 사라져 콘솔에 아무것도 안 찍힙니다.

    이렇게 쓴다. UnityProviderInitializer.SetDefaultObservableSystem(ex => { Debug.LogException(ex); Report(ex); }) 처럼 로그와 보고를 한 핸들러에 같이 넣습니다. 프로바이더 세팅도 같이 다시 됩니다.

    정리

    R3 코어는 유니티를 모르고 전역값 셋만 들며, R3.Unity 가 로드 직후에 그것을 채웁니다.

    • 프레임 기본값은 옳은 값이 없어 던지고, 유니티에서는 AfterAssembliesLoaded 에 모든 Awake 보다 먼저 채워집니다.
    • 초기화 어셈블리는 아무도 참조하지 않아 AlwaysLinkAssembly 로 스트리핑을 막습니다.
    • 게터는 DI 컨테이너를 먼저 보고, 떼어 놓은 덕에 FakeFrameProvider.Advance 로 프레임 로직을 기다리지 않고 테스트합니다.