R3 는 에러로 스트림을 끊지 않는다
button.OnClickAsObservable()
.Select(_ => int.Parse(inputField.text)) // "abc" 를 넣으면 FormatException
.Subscribe(n => ApplyDamage(n));
UniRx 에서는 여기서 예외가 한 번 나면 그 버튼이 게임이 끝날 때까지 죽습니다. Select 가 예외를 잡아 OnError 를 보내고, OnError 가 오면 구독이 자동으로 해제되는 것이 Rx 의 계약이기 때문입니다. 로그 한 줄 말고는 증상이 없고, 버튼을 눌러도 아무 일도 일어나지 않습니다.
R3 는 이 기본값을 뒤집었습니다. 예외는 OnErrorResume 으로 흘러가고 구독은 살아 있습니다.
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> 를 추상 클래스로 새로 만들고 공개 메서드를 봉인했습니다.
// 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 는 에러 자리에 전역 미처리 핸들러를 대신 꽂습니다.
// R3/ObservableSubscribeExtensions.cs
new AnonymousObserver<T>(onNext, ObservableSystem.GetUnhandledExceptionHandler(), Stubs.HandleResult)
그래서 맨 위 버튼 코드는 R3 에서 콘솔에 예외를 한 줄 남기고 계속 돕니다. 에러를 직접 받거나 스트림을 멈추려면 이렇게 씁니다.
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를 씁니다.