---
title: "R3 는 에러로 스트림을 끊지 않는다"
description: "UniRx 에서는 연산자 안에서 예외가 한 번 나면 그 버튼이 조용히 죽습니다. R3 는 예외를 OnErrorResume 으로 흘리고 구독을 살려 둡니다. 안 멈춤에서 멈춤으로는 연산자 하나로 갈 수 있지만 반대는 불가능해서입니다."
author: "유니얼"
pubDate: 2026-09-14
updatedDate: 2026-09-28
category: "game-dev/unity/libraries"
tags: ["Unity", "Rx", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-r3-onerror/
---
```csharp
button.OnClickAsObservable()
    .Select(_ => int.Parse(inputField.text))   // "abc" 를 넣으면 FormatException
    .Subscribe(n => ApplyDamage(n));
```

UniRx 에서는 여기서 예외가 한 번 나면 **그 버튼이 게임이 끝날 때까지 죽습니다.** `Select` 가 예외를 잡아 `OnError` 를 보내고, `OnError` 가 오면 구독이 자동으로 해제되는 것이 Rx 의 계약이기 때문입니다. 로그 한 줄 말고는 증상이 없고, 버튼을 눌러도 아무 일도 일어나지 않습니다.

R3 는 이 기본값을 뒤집었습니다. **예외는 `OnErrorResume` 으로 흘러가고 구독은 살아 있습니다.**

<x-flow>
<script type="application/json">
{
  "label": "같은 코드, 두 번의 클릭",
  "lines": [
    "button.OnClickAsObservable()",
    "    .Select(_ => int.Parse(inputField.text))",
    "    .Subscribe(n => ApplyDamage(n));"
  ],
  "cases": [
    {
      "label": "UniRx",
      "state": "첫 클릭 \"abc\", 둘째 클릭 \"10\"",
      "path": [
        {
          "line": 0,
          "why": "구독을 겁니다."
        },
        {
          "line": 1,
          "why": "첫 클릭입니다. \"abc\" 를 파싱하다 예외가 납니다.",
          "out": "FormatException"
        },
        {
          "line": 1,
          "why": "Select 가 예외를 잡아 OnError 로 보내고 구독을 해제합니다.",
          "out": "구독 해제"
        },
        {
          "line": 0,
          "why": "둘째 클릭입니다. 구독이 없으니 아무 데도 가지 않습니다.",
          "out": "반응 없음"
        }
      ],
      "skip": [
        2
      ],
      "note": "ApplyDamage 는 한 번도 밟히지 않습니다."
    },
    {
      "label": "R3",
      "state": "첫 클릭 \"abc\", 둘째 클릭 \"10\"",
      "path": [
        {
          "line": 0,
          "why": "구독을 겁니다."
        },
        {
          "line": 1,
          "why": "첫 클릭입니다. 같은 예외가 납니다.",
          "out": "FormatException"
        },
        {
          "line": 1,
          "why": "예외가 OnErrorResume 으로 흘러갑니다. 핸들러를 안 줬으니 전역 미처리 핸들러가 콘솔에 찍습니다. 구독은 살아 있습니다.",
          "out": "FormatException 로그"
        },
        {
          "line": 1,
          "why": "둘째 클릭입니다. 구독이 그대로라 다시 들어오고, 이번엔 파싱이 됩니다.",
          "out": "10"
        },
        {
          "line": 2,
          "why": "데미지가 들어갑니다.",
          "out": "데미지 10"
        }
      ],
      "note": "같은 코드인데 둘째 클릭에서 갈립니다."
    }
  ]
}
</script>
</x-flow>

UniRx 에서 구독이 끊기는 것은 예외가 **연산자 안**에서 날 때입니다. `Subscribe` 람다 안에서 나면 예외는 값을 보낸 쪽으로 올라가고 버튼 구독은 남습니다. `EveryUpdate` 나 `Interval` 처럼 UniRx 의 코루틴이 값을 만드는 스트림은 그 코루틴이 예외로 죽어 `OnError` 없이 멈춥니다. R3 는 세 경우 모두 `OnErrorResume` 으로 보내고 계속 돕니다.

## 안 멈춤에서 멈춤으로만 갈 수 있다

기본값을 바꾼 근거는 편의가 아니라 **변환의 방향**입니다. README 의 설명은 `OnErrorResume` 을 실패 완료로 바꾸는 것은 쉽고 성능도 안 떨어지지만 반대는 불가능하다는 것입니다.

| 기본값 | 반대 동작으로 갈 수 있나 |
| --- | --- |
| **안 멈춤** (R3) | 갈 수 있습니다. `OnErrorResumeAsFailure()` 를 끼우면 멈춥니다 |
| 멈춤 (UniRx) | **못 갑니다.** 이미 해제된 구독은 되살릴 방법이 없습니다 |

그래서 넓은 쪽을 기본으로 두었습니다. 계약도 같이 바뀌어서 종료는 `OnError` 와 `OnCompleted` 두 갈래가 아니라 `OnCompleted(Result)` 하나로 오고, 실패는 `Result` 안에 담깁니다. 멈춘 스트림을 되살릴 일이 줄어서 R3 1.3.1 에는 `Retry` 연산자도 없습니다.

## 예외는 봉인된 OnNext 의 catch 에서 흐름이 된다

UniRx 는 BCL 의 `IObserver<T>` 인터페이스를 그대로 써서 `OnError` 를 바꿀 수 없었고, 연산자마다 `try` 와 `catch` 와 `Dispose` 를 손으로 넣어 계약을 지킵니다. R3 는 `Observer<T>` 를 **추상 클래스**로 새로 만들고 공개 메서드를 봉인했습니다.

```csharp
// R3/Observable.cs
public void OnNext(T value)
{
    if (IsDisposed || IsCalledCompleted) return;      // 계약 검사

    try
    {
        OnNextCore(value);                            // 연산자가 구현한 부분
    }
    catch (Exception ex)
    {
        OnErrorResume(ex);                            // 예외를 흐름으로 전환
    }
}
```

연산자는 `OnNextCore` 만 구현합니다. 거기서 무슨 예외가 나든 기반 클래스의 `catch` 가 받으니 **어느 연산자도 이 처리를 빠뜨릴 수 없습니다.** `OnErrorResume` 처리 중에 또 예외가 나면 전역 미처리 핸들러로 가고, 유니티에서는 `Debug.LogException` 이 등록돼 있어 최소한 콘솔에는 남습니다.

대가는 둘입니다. 클래스라서 `in` · `out` 을 못 붙여 **공변성이 없고**, BCL 의 `IObservable<T>` 를 받는 API 에 그대로 넘길 수 없습니다. 에러 처리를 바꾼 것과 타입을 새로 만든 것은 하나의 결정입니다.

## 에러를 받는 법과 멈추는 법

값만 받는 `Subscribe` 는 에러 자리에 **전역 미처리 핸들러를 대신 꽂습니다.**

```csharp
// R3/ObservableSubscribeExtensions.cs
new AnonymousObserver<T>(onNext, ObservableSystem.GetUnhandledExceptionHandler(), Stubs.HandleResult)
```

그래서 맨 위 버튼 코드는 R3 에서 콘솔에 예외를 한 줄 남기고 계속 돕니다. 에러를 직접 받거나 스트림을 멈추려면 이렇게 씁니다.

<x-compare>
<script type="application/json">
{
  "label": "파싱이 실패할 때",
  "tabs": [
    { "label": "핸들러 없이", "code": "button.OnClickAsObservable()\n    .Subscribe(_ => ApplyDamage(int.Parse(inputField.text)));", "text": "Subscribe 가 꽂아 둔 전역 미처리 핸들러가 받습니다. 유니티에서는 Debug.LogException 이라 콘솔에 한 줄 찍힙니다. 사용자는 아무것도 못 봅니다.", "stats": [["예외 뒤 구독", "계속"], ["누가 받나", "전역 핸들러 (콘솔)"]] },
    { "label": "onErrorResume 을 줄 때", "code": "button.OnClickAsObservable()\n    .Subscribe(\n        _      => ApplyDamage(int.Parse(inputField.text)),\n        ex     => ShowToast(\"숫자를 입력하세요\"),\n        result => Debug.Log($\"종료: {result}\"));", "text": "값, 에러, 완료를 받는 세 인자 오버로드입니다. 에러는 받되 스트림은 계속됩니다. 다음 클릭도 그대로 들어옵니다.", "stats": [["예외 뒤 구독", "계속"], ["누가 받나", "내 핸들러"]], "tone": "good" },
    { "label": "OnErrorResumeAsFailure", "code": "source\n    .OnErrorResumeAsFailure()\n    .Subscribe(\n        _      => WriteToFile(),\n        result => Debug.Log($\"종료: {result}\"));", "text": "첫 에러를 실패 종료로 바꿉니다. OnCompleted(Result.Failure) 가 오고 구독이 풀립니다. UniRx 가 하던 동작입니다.", "stats": [["예외 뒤 구독", "끝남"], ["완료 인자", "Result.Failure"]] }
  ]
}
</script>
</x-compare>

`OnErrorResumeAsFailure` 가 "안 멈춤에서 멈춤으로 가는 쉬운 변환" 의 실물입니다. 예외 말고 다른 조건으로 끝내려면 `TakeUntil(cancellationToken)` 처럼 종료 조건을 직접 줍니다.

## 주의할 점

### 1. "에러 나면 어차피 끊긴다" 를 전제한 코드를 그대로 옮기지 않는다

**안 지키면 생기는 문제.** UniRx 코드에는 첫 실패에 알아서 멈추는 것을 믿고 짠 자리가 있습니다. 파일 핸들이 닫힌 뒤에 쓰기를 시도하는 스트림이 대표적입니다. R3 로 옮기면 이 스트림은 **실패한 상태로 계속 돌면서** 매번 같은 예외를 냅니다.

**이렇게 쓴다.** 첫 실패에 멈춰야 하는 스트림에는 `OnErrorResumeAsFailure()` 를 넣습니다. 옮길 때 `OnError` 를 쓰던 자리를 전부 찾아 "여기서 멈춰야 했나" 를 하나씩 답합니다.

### 2. 실패가 예상되는 스트림은 에러 핸들러를 받아서 구독한다

**안 지키면 생기는 문제.** 값만 받는 `Subscribe` 는 에러를 전역 핸들러에 넘깁니다. 스트림은 살아 있지만 사용자에게는 아무 반응이 없고, 같은 예외가 클릭할 때마다 콘솔에 쌓입니다.

**이렇게 쓴다.** 사용자 입력이나 네트워크 수신처럼 실패가 정상 흐름의 일부인 곳은 `Subscribe(onNext, onErrorResume, onCompleted)` 로 구독하고 에러 자리에서 사용자에게 알립니다.

### 3. 상속 관계의 스트림은 명시적으로 변환한다

**안 지키면 생기는 문제.** `Observable<T>` 가 클래스라 `Observable<Dog>` 를 `Observable<Animal>` 자리에 넘기면 컴파일 에러가 납니다. BCL 의 `IObservable<T>` 를 받는 API 에도 그대로 넘길 수 없습니다.

**이렇게 쓴다.** `.Select(x => (Animal)x)` 로 타입을 바꿔서 넘깁니다. 여러 타입을 한 스트림으로 흘려야 하면 처음부터 기반 타입으로 발행합니다.

## 정리

R3 는 **예외를 흐름으로 바꾸고 구독을 끊지 않습니다.** 멈추는 것은 사용자가 고릅니다.

- 근거는 방향입니다. 안 멈춤에서 멈춤으로는 `OnErrorResumeAsFailure()` 하나로 가지만 반대는 불가능합니다.
- 전환 자리는 추상 클래스의 봉인된 `OnNext` 안 `catch` 라 연산자가 빠뜨릴 수 없고, 대가로 공변성과 `IObservable<T>` 호환을 잃었습니다.
- 핸들러 없이 구독하면 에러는 콘솔로 가고 구독은 계속됩니다. 받으려면 세 인자 `Subscribe` 를 씁니다.