---
title: "R3 의 Subject 는 스레드 안전하지 않다"
description: "R3 의 Subject 는 구독자 목록을 연결 리스트로 바꿔 구독과 해제를 O(1) 로 만들고 발행 경로의 락을 뺐습니다. 대신 여러 스레드에서 OnNext 를 부르면 한 관찰자가 동시에 불립니다."
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-subject/
---
```csharp
// 네트워크 수신 스레드에서
subject.OnNext(packet);
```

네트워크 수신 스레드와 메인 스레드가 같은 `Subject` 에 값을 밀어 넣는 코드입니다. R3 README 는 `Subject.OnNext` 가 스레드 안전하지 않으니 여러 스레드에서 부르려면 `lock` 을 쓰라고 적어 둡니다.

빠뜨린 것이 아니라 **배열 복사를 없앤 대가로 발행의 원자성을 내준 것**입니다. 구독자 목록이 연결 리스트가 되면서 구독과 해제는 O(1) 이 됐고, 발행은 여러 단계가 됐습니다.

## 구독자 목록은 노드가 곧 핸들인 연결 리스트다

`Subject<T>` 는 구독자를 **이중 연결 리스트**로 들고, 리스트의 노드(`ObserverNode`)가 그대로 구독 핸들입니다.

| | 불변 배열 (UniRx) | 연결 리스트 (R3) |
| --- | --- | --- |
| 구독 1건 추가 | 배열 전체 복사 O(N) | 노드 하나 할당 O(1) |
| 구독 1건 해제 | 배열 전체 복사 O(N) | 포인터 재연결 O(1) |
| N건의 총 할당 | O(N제곱) 슬롯 | O(N) 노드 |
| 구독 핸들 | 별도 객체 | 노드가 겸함 |

해제는 앞뒤 포인터 몇 개만 고치고, 순회 중인 쪽이 다음으로 넘어갈 수 있게 자기 `Next` 는 지우지 않습니다. 배열 복사도 할당도 없습니다.

## 발행 도중에 붙은 구독은 버전 번호로 거른다

불변 배열은 발행이 스냅샷을 돌아서, 발행 도중 새로 붙은 구독자가 이번 값을 받을 일이 없었습니다. 연결 리스트는 그냥 두면 방금 들어온 노드까지 값이 갑니다. R3 는 **버전 번호**로 막습니다.

```csharp
public void OnNext(T value)
{
    if (completeState.IsCompleted) return;

    var currentVersion = GetVersion();     // 이번 발행의 버전을 고정하고 version++
    var node = root;
    while (node != null)
    {
        if (node.Version > currentVersion) break;   // 이번 발행 뒤에 들어온 노드면 멈춘다
        node.Observer.OnNext(value);
        node = node.Next;
    }
}
```

<x-wiring>
<script type="application/json">
{
  "label": "발행 도중에 구독이 하나 늘었을 때",
  "width": 620,
  "height": 440,
  "nodes": [
    { "id": "pub", "title": "OnNext", "sub": "이번 발행의 버전은 5", "x": 235, "y": 8, "w": 150, "h": 50, "aside": true },
    { "id": "a", "title": "NodeA", "sub": "버전 1", "x": 235, "y": 128, "w": 150, "h": 50 },
    { "id": "b", "title": "NodeB", "sub": "버전 3", "x": 235, "y": 248, "w": 150, "h": 50 },
    { "id": "c", "title": "NodeC", "sub": "버전 6", "x": 235, "y": 368, "w": 150, "h": 50 }
  ],
  "edges": [
    { "from": "pub", "to": "a", "label": "1 <= 5", "bend": 62 },
    { "from": "a", "to": "b", "label": "3 <= 5", "bend": 62 },
    { "from": "b", "to": "c", "label": "6 > 5", "bend": 62 }
  ],
  "steps": [
    { "edge": 0, "why": "버전이 이번 발행보다 작거나 같으니 값을 받습니다." },
    { "edge": 1, "why": "그 사이 NodeA 의 핸들러가 새로 구독해서 NodeC 가 생겼습니다. 이번 발행이 버전을 5 에서 6 으로 올려 두었으니 새 노드는 버전 6 입니다." },
    { "edge": 2, "why": "NodeC 의 버전이 이번 발행보다 큽니다. 여기서 순회를 멈춥니다. 방금 들어온 구독자는 이번 값을 받지 않습니다." }
  ],
  "note": "불변 배열이 스냅샷으로 하던 일을 정수 비교 하나로 대신합니다."
}
</script>
</x-wiring>

"구독한 뒤의 값만 받는다" 는 계약을 배열 복사 없이 정수 비교 하나로 지킵니다.

## 발행에 락이 없어서 스레드 안전하지 않다

락은 구독(`lock (parent)`)과 해제(`lock (p)`)에만 있고, 가장 잦은 발행 경로에는 없습니다. 불변 배열 방식은 발행이 참조 하나를 읽고 끝나서 락 없이도 안전했지만, R3 의 발행은 `version++` 과 노드 순회라는 **여러 단계**라 원자적일 수 없습니다.

| | 스레드 안전 |
| --- | --- |
| `Subscribe` · `Dispose` | **안전.** 락을 잡습니다 |
| `Subject.OnNext` 동시 호출 · 연산자 체인 | **불안전** (README 명시) |
| `ReplaySubject` · `BehaviorSubject` | `OnNext` 까지 **안전** |
| `ReactiveProperty` | **불안전.** `SynchronizedReactiveProperty` 를 씁니다 |

스레드 넷이 동시에 발행하고 미리 붙인 구독자 넷이 받은 값을 셌습니다. 대조군은 `OnNext` 를 `lock` 으로 감싼 것입니다.

| | `Subject.OnNext` 동시 호출 | `lock` 대조군 |
| --- | --- | --- |
| 누락 · 중복 (기대 전달 3,200,000) | 0 · 0 | 0 · 0 |
| **같은 관찰자에 동시 진입** | 약 120만~127만 번 | 0 |

값이 사라지지는 않았지만 **한 관찰자의 `OnNext` 가 여러 스레드에서 동시에 불렸습니다.** 받은 값을 리스트에 넣는 평범한 핸들러가 그대로 경합에 놓입니다. 발행 중에 늦은 구독자를 400번 붙였다 뗀 실험에서는 IL2CPP 에서 한 구독자가 1,036개를 못 받았습니다. `version++` 이 원자적이지 않아 버전이 뒤로 갈 수 있다는 것이 소스에 보이는 후보이고, 한 번 나온 결과라 원인으로 확정하지는 않습니다.

`ReactiveProperty` 에 여러 스레드로 `Value` 를 대입해도 동시 진입이 약 122만~127만 번 나왔고, 0 과 1 을 번갈아 넣었는데 같은 값을 연달아 받은 것이 약 5.7만~10.5만 번이었습니다. 비교한 뒤 대입하는 두 단계 사이에 다른 스레드가 끼기 때문입니다. `SynchronizedReactiveProperty` 는 모두 0 이었습니다.

```csharp
subject.Synchronize().OnNext(x);    // 또는 lock (gate) { subject.OnNext(x); }
```

게임 루프는 단일 스레드라는 전제로, **드문 경우를 위해 모든 발행에 락 비용을 물리지 않은** 설계입니다.

## 주의할 점

### 1. 여러 스레드에서 OnNext 를 부르지 않는다

**안 지키면 생기는 문제.** 발행이 원자적이지 않아서 한 관찰자가 여러 스레드에서 동시에 불리고, IL2CPP 에서는 늦게 붙은 구독자가 값을 놓친 일도 있었습니다. `ReactiveProperty` 는 같은 값을 연달아 알렸습니다.

**이렇게 쓴다.** 백그라운드에서 값을 밀어 넣어야 하면 `subject.Synchronize()` 를 거치거나 직접 `lock` 을 잡습니다. 프로퍼티라면 `SynchronizedReactiveProperty<T>` 를 씁니다. `ReplaySubject` 와 `BehaviorSubject` 는 `OnNext` 까지 안전합니다.

### 2. 발행 도중에 붙인 구독은 이번 값을 못 받는다

**안 지키면 생기는 문제.** 핸들러 안에서 같은 `Subject` 를 새로 구독하면 그 노드의 버전이 이번 발행보다 커서 순회가 거기서 멈춥니다. "값이 오면 구독을 하나 더 건다" 는 코드가 첫 값을 놓칩니다.

**이렇게 쓴다.** 구독하자마자 현재 상태가 필요하면 구독 순간 현재 값을 주는 `BehaviorSubject` 나 `ReactiveProperty` 를 씁니다.

### 3. 완료했거나 해제한 Subject 를 다시 쓰지 않는다

**안 지키면 생기는 문제.** `OnCompleted` 뒤의 `OnNext` 는 첫 줄의 완료 검사에서 아무 말 없이 버려지고, 완료된 `Subject` 를 새로 구독하면 곧바로 완료만 받습니다. 씬을 다시 열 때 이전 판의 `Subject` 를 재사용하면 값이 흐르지 않는데 에러도 없습니다.

**이렇게 쓴다.** 한 번 끝낸 `Subject` 는 버리고 새로 만듭니다.

## 정리

R3 의 `Subject` 는 **연결 리스트로 구독을 싸게 만들고, 발행의 스레드 안전성을 내줬습니다.**

- 노드가 곧 구독 핸들이라 구독과 해제가 O(1) 이고 할당이 노드 하나입니다.
- 발행 도중에 붙은 구독은 버전 번호로 걸러 이번 값을 받지 않습니다.
- 발행에 락이 없어 여러 스레드에서 부르면 한 관찰자가 동시에 불립니다. `Synchronize()` 나 `lock` 을 씁니다.