---
title: "R3 의 값이 오는 스레드와 프레임은 소스가 정한다"
description: "SubscribeOn 은 구독 호출만 옮겨 값의 스레드는 소스가 정하고, ObserveOnMainThread 는 다음 러너 차례에, Timer 는 프레임 델타를 더해 발동합니다. 겹치는 비동기 처리는 AwaitOperation 으로 고릅니다."
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-when-and-where/
---
흔히 이렇게 기대합니다.

- `SubscribeOnThreadPool()` 을 붙이면 구독자도 백그라운드에서 돈다
- `ObserveOnMainThread()` 는 한 프레임 늦게 온다
- `Observable.Timer(1초)` 는 1초 뒤에 온다

재보니 셋 다 달랐습니다. R3 에서 값이 **어느 스레드, 어느 프레임에 오는지는 연산자 이름이 아니라 소스와 PlayerLoop 러너가 정합니다.**

## SubscribeOn 은 구독 호출 한 번만 옮긴다

```csharp
// R3/Operators/SubscribeOn.cs
public void Execute()
{
    disposable.Disposable = source.Subscribe(this);   // 스레드풀로 옮겨진 것은 이 한 줄뿐
}

protected override void OnNextCore(T value)
{
    observer.OnNext(value);                           // 스레드를 바꾸지 않고 통과시킨다
}
```

그래서 같은 연산자를 붙여도 소스에 따라 결과가 정반대입니다.

<x-compare>
<script type="application/json">
{
  "label": "SubscribeOnThreadPool() 을 붙였을 때",
  "tabs": [
    { "label": "EveryUpdate", "code": "Observable.EveryUpdate()\n    .SubscribeOnThreadPool()\n    .Subscribe(_ => Debug.Log(\"...\"));", "text": "소스가 자기 루프를 갖고 있습니다. 값은 PlayerLoop 이 돌 때 나오므로 구독자는 메인 스레드에서 불립니다.", "stats": [["값이 나오는 곳", "PlayerLoop"], ["구독자 스레드", "메인"]] },
    { "label": "Range", "code": "Observable.Range(1, 3)\n    .SubscribeOnThreadPool()\n    .Subscribe(v => Debug.Log(v));", "text": "Range 는 구독하는 도중에 값을 전부 뱉습니다. 그 구독이 스레드풀로 옮겨졌으니 값도 같이 끌려갑니다.", "stats": [["값이 나오는 곳", "SubscribeCore 안"], ["구독자 스레드", "백그라운드"]] }
  ]
}
</script>
</x-compare>

`SubscribeOn` 은 "조립을 누가 하느냐" 를, `ObserveOn` 은 "배달을 누가 하느냐" 를 바꿉니다. 소스가 조립하면서 배달까지 끝내면 둘이 같이 끌려갑니다. 그래도 **동기 블로킹 소스에는 결정적**입니다. `Thread.Sleep(2000)` 을 품은 소스를 `Update()` 에서 구독하면 붙이지 않았을 때 한 프레임이 2.042초로 튀고, 붙이면 그 2초 동안 930프레임이 렌더됐습니다.

## ObserveOnMainThread 는 다음 러너 차례에 배달한다

지연의 단위는 프레임이 아니라 **다음 러너 차례**입니다. R3 러너는 `Update` 단계의 맨 앞에 있습니다.

| 발행 위치 | 발행 프레임 | 수신 프레임 |
| --- | ---: | ---: |
| `Start()` | 1 | **1** (같은 프레임) |
| `Update()` | 60 | **61** (다음 프레임) |

<x-wiring>
<script type="application/json">
{
  "label": "한 프레임 안에서 러너를 언제 만나나",
  "width": 620,
  "height": 440,
  "nodes": [
    { "id": "early", "title": "앞 단계", "sub": "Start() 가 여기서 불린다", "x": 235, "y": 8, "w": 150, "h": 50 },
    { "id": "runner", "title": "R3 러너", "sub": "Update 단계의 맨 앞", "x": 235, "y": 128, "w": 150, "h": 50 },
    { "id": "upd", "title": "Update()", "sub": "러너가 지나간 뒤", "x": 235, "y": 248, "w": 150, "h": 50 },
    { "id": "next", "title": "다음 프레임의 러너", "sub": "여기서야 배달된다", "x": 235, "y": 368, "w": 150, "h": 50 }
  ],
  "edges": [
    { "from": "early", "to": "runner", "label": "같은 프레임", "bend": 62 },
    { "from": "runner", "to": "upd", "label": "프레임 진행", "bend": 62 },
    { "from": "upd", "to": "next", "label": "한 바퀴 대기", "bend": 62 }
  ],
  "steps": [
    { "edge": 0, "why": "Start() 에서 발행하면 러너가 아직 안 지나갔습니다. 같은 프레임에 배달됩니다." },
    { "edge": 1, "why": "러너가 지나가고 나서 Update() 가 불립니다." },
    { "edge": 2, "why": "Update() 에서 발행하면 이번 차례를 놓쳤습니다. 다음 프레임 러너까지 기다립니다." }
  ],
  "note": "지연의 단위는 프레임이 아니라 다음 러너 차례입니다."
}
</script>
</x-wiring>

어디서 발행했느냐에 따라 0프레임 또는 1프레임 늦습니다.

## Timer 는 프레임 델타를 더해서 잰다

유니티에서 시간축 타이머는 프레임 워크아이템입니다. R3.Unity 가 어셈블리 로드 직후 기본 시간 프로바이더를 `UnityTimeProvider.Update` 로 바꾸기 때문에, 인자 없는 `Timer` 와 `Interval` 은 스레드풀이 아니라 **메인 스레드에서 돌고 `Time.timeScale` 을 탑니다.** 그리고 절대 시각을 보지 않고 델타를 더합니다.

```csharp
// R3.Unity/Runtime/UnityTimeProvider.cs (FrameTimer)
elapsed += GetDeltaTime();
if (elapsed >= dt) { callback(state); elapsed = 0; }
```

`timeScale` 을 0으로 두고 `TimerFrame(60)` 과 `Timer(1초, UnityTimeProvider.UpdateIgnoreTimeScale)` 을 같이 걸되 시작 프레임만 바꿨습니다.

| 시작 | 그 프레임의 `unscaledDeltaTime` | `TimerFrame(60)` | `Timer(1초, UpdateIgnoreTimeScale)` |
| --- | ---: | ---: | ---: |
| 1프레임 | **약 4.0초** | 60프레임 | **1프레임** |
| 100프레임 | 0.0014초 | 160프레임 | 621프레임 |

같이 건 기본 프로바이더의 `Timer(1초)` 는 `timeScale` 이 0이라 끝까지 발동하지 않았습니다. 긴 프레임 하나가 짧은 타이머를 첫 틱에 터뜨리고, `elapsed` 를 0으로 되돌리니 밀린 주기도 몰아 보내지 않습니다. 정밀도의 하한은 1프레임이고, **결정론이 필요하면 프레임축**입니다.

## 겹치는 비동기 처리는 AwaitOperation 이 정한다

값 하나를 받아 `await` 가 걸린 일을 하려면 `SubscribeAwait` 를 쓰고, 앞 작업이 안 끝났는데 다음 값이 오면 어떻게 할지를 `AwaitOperation` 으로 고릅니다.

```csharp
button.OnClickAsObservable()
    .SubscribeAwait(async (_, ct) =>
    {
        Debug.Log("시작");
        await Awaitable.WaitForSecondsAsync(1f, ct);   // 1초짜리 작업
        Debug.Log("끝");
    }, AwaitOperation.Drop)
    .AddTo(this);
```

<x-compare>
<script type="application/json">
{
  "label": "1초짜리 작업, 0.0 · 0.2 · 0.4초에 세 번 클릭",
  "tabs": [
    { "label": "Sequential", "code": "0.0  시작①\n0.2  (대기열)\n0.4  (대기열)\n1.0  끝①  시작②\n2.0  끝②  시작③\n3.0  끝③", "text": "큐에 쌓고 앞 작업이 끝나야 다음을 시작합니다. 빠짐없이 순서대로 하지만, 100번 누르면 100초가 밀립니다.", "stats": [["시작", "3번 (0 · 1 · 2초)"], ["끝", "3번"]] },
    { "label": "Drop", "code": "0.0  시작①\n0.2  (버림)\n0.4  (버림)\n1.0  끝①", "text": "실행 중에 온 값은 버립니다. 저장 버튼처럼 연타해도 한 번만 해야 할 때 씁니다.", "stats": [["시작", "1번"], ["끝", "1번"]] },
    { "label": "Switch", "code": "0.0  시작①\n0.2  ① 취소  시작②\n0.4  ② 취소  시작③\n1.4  끝③", "text": "새 값이 오면 이전 작업의 토큰을 취소하고 새로 시작합니다. 검색어 입력처럼 이전 결과가 필요 없을 때 씁니다. 취소된 작업의 OperationCanceledException 은 조용히 삼켜집니다.", "stats": [["시작", "3번"], ["끝", "1번"]] },
    { "label": "Parallel", "code": "0.0  시작①\n0.2  시작②\n0.4  시작③\n1.0  끝①\n1.2  끝②\n1.4  끝③", "text": "기다리지 않고 바로 시작합니다. 결과가 끝나는 순서대로 오니 순서는 보장되지 않습니다.", "stats": [["시작", "3번 (즉시)"], ["끝", "3번"]] }
  ]
}
</script>
</x-compare>

모드는 여섯입니다. 위 넷에 동시에 시작하되 결과는 들어온 순서대로 받는 `SequentialParallel`, 실행 중 들어온 값 중 첫 값과 마지막 값만 처리하는 `ThrottleFirstLast` 가 있습니다. 체인 중간에서 결과를 넘기려면 같은 모드를 받는 `SelectAwait` 를 씁니다.

## 주의할 점

### 1. SubscribeOn 을 붙였다고 구독자가 백그라운드에서 돈다고 가정하지 않는다

**안 지키면 생기는 문제.** `EveryUpdate` 에 붙이면 아무 효과가 없고, `Range` 처럼 구독 도중에 값을 다 뱉는 소스에 붙이면 값 처리까지 백그라운드로 끌려갑니다. 뒤쪽에서 유니티 API 를 부르면 메인 스레드가 아니라는 예외가 납니다.

**이렇게 쓴다.** 무거운 동기 소스를 메인에서 빼낼 때만 `SubscribeOnThreadPool()` 을 붙이고 그 뒤에 `ObserveOnMainThread()` 로 결과를 되돌립니다. 어느 스레드인지 헷갈리면 `Debug.Log` 스택의 맨 아래 줄을 봅니다(`UnityFrameProvider:Run` 이면 메인, `_ThreadPoolWaitCallback` 이면 백그라운드).

### 2. 일시정지 중에도 돌아야 하는 타이머는 시계를 바꿔서 건다

**안 지키면 생기는 문제.** 인자 없는 `Timer` 와 `Interval` 은 `Time.timeScale` 을 탑니다. 일시정지 메뉴에서 `timeScale` 을 0으로 두면 메뉴의 연출 타이머까지 멈춥니다.

**이렇게 쓴다.** `UnityTimeProvider.UpdateIgnoreTimeScale` 을 인자로 넘깁니다. 게임 시간과 무관해야 하는 서버 하트비트 같은 것은 `UnityTimeProvider.UpdateRealtime` 입니다.

### 3. 로딩 직후에 짧은 시간축 타이머를 걸지 않는다

**안 지키면 생기는 문제.** 씬 로드 직후나 알트탭 복귀 직후처럼 한 프레임이 몇 초씩 걸리면 짧은 타이머가 첫 틱에 바로 터집니다. 10초 자리를 비웠다 돌아와도 `Interval(0.1초)` 는 한 번만 발동합니다.

**이렇게 쓴다.** 로딩이 끝나고 프레임이 안정된 뒤에 구독합니다. 발동 시점이 매번 같아야 하면 `TimerFrame` 같은 프레임축을 씁니다.

### 4. SubscribeAwait 안에서는 받은 토큰을 끝까지 넘긴다

**안 지키면 생기는 문제.** `Switch` 는 토큰을 취소해서 이전 작업을 멈춥니다. 람다 안에서 `ct` 를 넘기지 않으면 이전 작업이 끝까지 돌아 `Parallel` 과 같은 결과가 됩니다. `Sequential` 은 아무것도 버리지 않아 연타가 쌓이면 작업이 계속 밀립니다.

**이렇게 쓴다.** 람다가 받은 `ct` 를 안에서 부르는 모든 비동기 메서드에 넘깁니다. 모드는 "밀려도 되는가, 버려도 되는가" 로 고릅니다.

## 정리

R3 에서 값이 오는 **스레드와 프레임은 소스와 PlayerLoop 러너가 정합니다.**

- `SubscribeOn` 은 구독 호출만 옮깁니다. 값의 스레드는 소스가 정하고, 동기 블로킹 소스에서만 결정적입니다.
- `ObserveOnMainThread` 는 다음 러너 차례라 발행 위치에 따라 0 또는 1프레임 늦고, `Timer` 는 델타를 더해서 긴 프레임에 즉시 터집니다.
- 겹치는 비동기 처리는 `AwaitOperation` 으로 쌓을지, 버릴지, 취소할지, 같이 돌릴지 고릅니다.