---
title: "R3 코어는 유니티를 모른다"
description: "R3 코어는 시간, 프레임, 미처리 예외 핸들러라는 전역값 셋만 들고 유니티를 모릅니다. R3.Unity 가 어셈블리 로드 직후 이것을 채우고, 그 덕에 FakeFrameProvider 로 기다리지 않고 프레임 로직을 테스트합니다."
author: "유니얼"
pubDate: 2026-09-16
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "Rx", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-r3-providers/
---
```csharp
Observable.EveryUpdate().Subscribe(_ => Tick());
// NotSupportedException: ObservableSystem.DefaultFrameProvider is not set.
```

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

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

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

```csharp
// 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 보다 먼저 채운다

```csharp
// 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` 게터는 정적 필드를 바로 돌려주지 않고 등록된 서비스 제공자에 먼저 묻습니다.

<x-flow>
<script type="application/json">
{
  "label": "시간 연산자가 TimeProvider 를 찾는 길",
  "lines": ["var services = serviceProvider;", "if (serviceProviderFactory != null) services = serviceProviderFactory();", "if (services != null)", "{", "    var provider = services.GetService(typeof(TimeProvider));", "    if (provider != null) return (TimeProvider)provider;", "}", "return defaultTimeProvider;"],
  "cases": [
    {
      "label": "컨테이너에 등록됨",
      "state": "DI 컨테이너에 TimeProvider 가 있다",
      "path": [
        { "line": 1, "why": "팩토리를 등록해 뒀으면 지금 스코프를 꺼냅니다." },
        { "line": 2, "why": "컨테이너가 있습니다.", "verdict": "true" },
        { "line": 4, "why": "컨테이너에 물어봅니다." },
        { "line": 5, "why": "찾았으니 컨테이너 것을 씁니다.", "verdict": "true", "out": "컨테이너의 TimeProvider" }
      ],
      "skip": [7]
    },
    {
      "label": "등록 안 함",
      "state": "컨테이너를 안 쓴다",
      "path": [
        { "line": 0, "why": "등록된 컨테이너가 없습니다." },
        { "line": 2, "why": "널이라 안쪽으로 안 들어갑니다.", "verdict": "false" },
        { "line": 7, "why": "정적 기본값을 씁니다. 유니티에서는 초기화 때 갈아끼운 UnityTimeProvider 입니다.", "out": "UnityTimeProvider.Update" }
      ],
      "skip": [4, 5]
    }
  ]
}
</script>
</x-flow>

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

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

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

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

```csharp
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` 로 프레임 로직을 기다리지 않고 테스트합니다.