---
title: "R3 는 씬에 오브젝트를 만들지 않는다"
description: "R3 는 MainThreadDispatcher 같은 오브젝트 없이 PlayerLoop 에 러너를 직접 꽂습니다. 고를 수 있는 타이밍이 아홉으로 늘었고, 매 프레임 경로에서 락이 빠졌고, 시간과 프레임은 각자의 프로바이더로 갈라졌습니다."
author: "유니얼"
pubDate: 2026-09-15
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "Rx", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-r3-time-and-frame/
---
UniRx 로 게임을 켜면 하이어라키에 `MainThreadDispatcher` 가 생기고, 그 안의 코루틴이 프레임 연산을 돌립니다. 씬 정리 코드가 이 오브젝트를 지우거나 프리팹에 하나가 더 딸려 오면 프레임 연산이 흔들립니다.

R3 는 **씬에 아무것도 만들지 않고 PlayerLoop 에 러너를 직접 꽂습니다.** 그 덕에 끼울 자리를 고를 수 있고, 매 프레임 경로에서 락이 빠졌습니다.

## 러너는 PlayerLoop 단계의 맨 앞에 들어간다

`EveryUpdate` 구독자의 스택 트레이스가 차이를 그대로 보여 줍니다.

```
UniRx
UnityEngine.SetupCoroutine:InvokeMoveNext
  MainThreadDispatcher/<RunUpdateMicroCoroutine>d__32:MoveNext()
    InternalUtil.MicroCoroutine:Run()

R3
R3.UnityFrameProvider:Run()
  Internal.CancellableFrameRunnerWorkItemBase:MoveNext(long)
```

R3 쪽에는 코루틴 프레임이 없습니다. `PlayerLoopHelper` 가 유니티의 실행 루프를 배열로 받아 각 단계에 `UnityFrameProvider.Run` 을 끼워 넣습니다.

| 규칙 | 이유 |
| --- | --- |
| 같은 타입이 있으면 빼고 넣습니다 | 재초기화할 때 러너가 중복되지 않습니다 |
| 기본은 **그 단계의 맨 앞** | 유니티 자체 시스템보다 먼저 돌아 내 로직이 엔진 처리에 반영됩니다 |
| `PostFixedUpdate` 만 `FixedUpdate` 단계의 **맨 뒤** | 물리 스텝이 끝난 뒤에 돌아야 뜻이 있습니다 |

`PostFixedUpdate` 는 별도 단계가 아니라 `FixedUpdate` 단계 안의 마지막 자리입니다. 에디터에서 Domain Reload 를 끄면 플레이 모드에 들어갈 때마다 러너를 다시 초기화해서 이전 세션의 워크아이템이 새지 않게 합니다.

## 타이밍은 아홉 자리에서 고른다

UniRx 의 프레임 연산은 디스패처의 코루틴 셋(`Update` · `FixedUpdate` · `EndOfFrame`) 위에서 돕니다. R3 는 `UnityFrameProvider` 의 아홉 자리 중에서 고릅니다.

| 타이밍 | 쓰임새 |
| --- | --- |
| `TimeUpdate` | 프레임 맨 앞. `Initialization` 보다도 앞 |
| `Initialization` | 입력 폴링 전 |
| `EarlyUpdate` | 입력 처리 직후 |
| `FixedUpdate` | 물리 스텝 **전** |
| `PostFixedUpdate` | 물리 스텝 **후**. 물리 결과 보정 |
| `PreUpdate` | `Update` 직전 |
| `Update` | 일반 게임 로직 (**기본값**) |
| `PreLateUpdate` | `LateUpdate` 직전. 카메라 추적 |
| `PostLateUpdate` | 렌더 직전. 최종 보정 |

(`TimeUpdate` 위치는 PlayerLoop 를 덤프해 확인했습니다. R3 러너가 유니티의 `WaitForLastPresentationAndUpdateTime` 보다 앞에 있었습니다.)

```csharp
// 물리 결과가 확정된 뒤에 보정한다
Observable.EveryUpdate(UnityFrameProvider.PostFixedUpdate)
    .Subscribe(_ => CorrectRigidbodyDrift());
```

숫자보다 **표현할 수 있는 요구가 늘어난 것**이 중요합니다.

## 매 프레임 경로에 락이 없다

<x-flow>
<script type="application/json">
{
  "label": "한 프레임에 실행되는 경로",
  "cases": [
    {
      "label": "UniRx",
      "lines": ["yield return null;              // 다음 Update 까지 대기", "updateMicroCoroutine.Run();", "    lock (arrayLock)            // 프레임마다", "        coroutine.MoveNext();   // 컴파일러가 만든 상태 머신"],
      "path": [
        { "line": 0, "why": "코루틴이 다음 Update 까지 잠듭니다." },
        { "line": 1, "why": "깨어나면 이번 프레임 몫을 통째로 돌립니다." },
        { "line": 2, "why": "배열을 돌기 전에 락을 잡습니다. 경합이 없어도 잡습니다." },
        { "line": 3, "why": "구독 하나가 상태 머신 하나입니다. 한 칸씩 밉니다." }
      ],
      "note": "락이 프레임마다 들어갑니다."
    },
    {
      "label": "R3",
      "lines": ["updateDelegate = runner.Run;    // PlayerLoop 에 꽂아 둔 것", "var span = list.AsSpan();      // 락 없음", "    item.MoveNext(frameCount);  // 직접 구현한 워크아이템"],
      "path": [
        { "line": 0, "why": "코루틴을 거치지 않고 PlayerLoop 이 바로 부릅니다." },
        { "line": 1, "why": "목록을 스팬으로 읽습니다. 여기에는 락이 없습니다." },
        { "line": 2, "why": "구독 하나가 직접 구현한 클래스입니다. 프레임 번호를 인자로 받습니다." }
      ],
      "note": "락은 구독하고 해제할 때만 잡습니다."
    }
  ]
}
</script>
</x-flow>

러너가 목록을 읽는 `FreeListCore.AsSpan()` 은 `Volatile.Read` 로 배열과 마지막 인덱스를 읽을 뿐이고, 락은 `Add` 와 `Remove` 에만 있습니다. 워크아이템 하나가 예외를 던지면 그 항목만 목록에서 빼고 전역 미처리 핸들러에 넘긴 뒤 루프를 계속합니다.

`EveryUpdate` 구독 N개를 유지한 채 `PlayerLoopType.Update` 노드만 잰 값입니다.

| N | UniRx | R3 |
| ---: | ---: | ---: |
| 1,000 | 0.0207 ms | **0.0096 ms** |
| 5,000 | 0.1329 ms | **0.0418 ms** |
| 10,000 | 0.1961 ms | **0.1300 ms** |

(IL2CPP 플레이어, 프레임당 할당은 양쪽 0 B. 세 번 재서 N=10,000 에서 1.51배 · 2.74배 · 2.95배로 같은 방향이었고, N=1,000 은 한 번의 값입니다.) 차이가 락, 이터레이터, 순회 방식 중 어디서 났는지는 이 측정으로 가를 수 없습니다. 구독이 수천 개 붙는 프레임 루프에서 R3 가 유리하게 나왔다는 것까지입니다.

## 시간과 프레임을 따로 추상화했다

UniRx 의 `IScheduler` 는 "언제" 를 `TimeSpan` 으로만 표현해서 "60프레임 뒤" 를 담을 자리가 없었습니다. R3 소스에는 `IScheduler` 라는 문자열이 아예 없습니다. 대신 둘로 갈랐습니다.

```
IScheduler  →  TimeProvider     시계   (.NET 8 BCL 표준)
               FrameProvider    프레임 (R3 자체)
```

`TimeProvider` 는 타이머 객체를 하나 만들어 `ITimer.Change()` 로 다음 발동 시각만 바꿉니다. 한 번 실행마다 예약 객체를 만들던 모양과 반대입니다. 동기 실행은 추상화하지 않아서 `Range` 에는 스케줄러 오버로드가 없습니다. 그리고 시간축 연산자마다 **프레임판**이 생겼습니다.

| 시간축 | 프레임축 |
| --- | --- |
| `Interval(TimeSpan)` | `IntervalFrame(int)` |
| `Timer(TimeSpan)` | `TimerFrame(int)` |
| `Debounce(TimeSpan)` | `DebounceFrame(int)` |
| `Chunk(TimeSpan)` | `ChunkFrame(int)` |
| `ThrottleLast(TimeSpan)` | `ThrottleLastFrame(int)` |

UniRx 에도 `TimerFrame` 같은 프레임 연산자는 있었지만 `FrameCountType` 열거형으로 부분 지원한 것이었습니다. 게임에서는 "0.2초 뒤" 보다 "12프레임 뒤" 가 정확한 요구일 때가 많고, 프레임 수 기준은 프레임 드랍이 있어도 결정론적입니다.

## 주의할 점

### 1. PostFixedUpdate 를 별도 단계로 여기지 않는다

**안 지키면 생기는 문제.** 이름만 보면 `FixedUpdate` 다음의 새 단계 같지만 실제로는 `FixedUpdate` 단계 안의 맨 뒤 자리입니다. 별도 단계로 알고 짜면 다른 단계와의 순서를 잘못 예상합니다.

**이렇게 쓴다.** "이번 고정 스텝의 물리 처리가 끝난 뒤" 에 해야 하는 보정에만 `UnityFrameProvider.PostFixedUpdate` 를 씁니다. 렌더 전에 한 번 하면 되는 일은 `PreLateUpdate` 나 `PostLateUpdate` 에 겁니다.

### 2. Domain Reload 를 끄면 내 static 도 직접 비운다

**안 지키면 생기는 문제.** Domain Reload 를 끄면 static 필드가 플레이 세션을 넘어 남습니다. R3 러너는 이 경우를 알아서 재초기화하지만, 내 코드의 static `Subject` 나 static 구독은 이전 세션의 구독자를 그대로 들고 있습니다.

**이렇게 쓴다.** static 으로 들고 있는 스트림과 구독은 플레이 모드에 들어갈 때 비우는 초기화 코드를 둡니다.

### 3. 프레임 구독이 조용히 멈췄으면 콘솔의 예외부터 본다

**안 지키면 생기는 문제.** 러너는 예외를 던진 워크아이템만 빼고 계속 돕니다. 나머지는 멀쩡하니 한 구독만 소리 없이 멈춘 것처럼 보이고, 빠진 항목은 다시 붙지 않습니다. 흔적은 콘솔 한 줄뿐입니다.

**이렇게 쓴다.** 매 프레임 도는 구독이 어느 순간부터 안 불리면 그 프레임의 예외 로그를 먼저 찾습니다. 계속 돌아야 하는 일은 구독 안에서 예외를 직접 처리합니다.

## 정리

R3 는 **PlayerLoop 에 러너를 직접 꽂아** 프레임을 돌리고, 씬에는 아무것도 만들지 않습니다.

- 러너는 각 단계의 맨 앞에 들어가고, `PostFixedUpdate` 만 `FixedUpdate` 단계의 맨 뒤입니다. 타이밍은 아홉 자리에서 고릅니다.
- 매 프레임 읽는 경로에 락이 없고, IL2CPP 에서 구독 수천 개 기준 1.5~3.2배 빨랐습니다.
- `IScheduler` 대신 `TimeProvider` 와 `FrameProvider` 로 갈라, 시간축 연산자마다 프레임판이 있습니다.