catchError, retry, retryWhen: 에러 발생 시 스트림을 복구하거나 재시도하는 연산자
catchError는 에러를 잡아 대체 옵저버블(Observable)로 전환하는 연산자이고, retry는 에러 발생 시 소스를 처음부터 재구독(resubscribe)하는 연산자이다. retryWhen은 재시도 시점과 조건을 직접 제어할 수 있는 고급 버전으로, 최근에는 retry의 설정 객체 방식으로 상당 부분 대체되고 있다. 세 연산자 모두 에러를 스트림
1. 개념
RxJS 스트림에서 에러(error)는 next, complete와 함께 옵저버블(Observable)이 발행하는 세 가지 알림 중 하나다. 문제는 에러가 발생하면 해당 옵저버블은 즉시 종료되고 더 이상 어떤 값도 흘려보내지 않는다는 점이다. catchError, retry, retryWhen은 이 종료를 막거나, 종료 전에 대체 동작을 끼워 넣기 위한 연산자다.
catchError는 소스에서 에러가 발생했을 때 콜백 함수를 실행하고, 그 함수가 반환하는 새로운 옵저버블로 스트림을 이어간다. 사실상 에러를 "잡아서" 다른 스트림으로 바꿔치기하는 것이다.
retry는 소스에서 에러가 발생하면 원본 옵저버블을 처음부터 다시 구독(subscribe)한다. 인자로 숫자를 주면 그 횟수만큼 재시도하고, 설정 객체를 주면 지연 시간(delay)이나 조건까지 세밀하게 제어할 수 있다.
retryWhen은 에러가 발생할 때마다 에러들의 스트림(옵저버블)을 받아, 그 스트림을 기반으로 언제 재구독할지 개발자가 직접 결정하게 해주는 연산자다. RxJS 최신 버전에서는 deprecated 되었고 retry의 설정 객체 옵션(delay 함수 등)으로 대체하는 것이 권장되지만, 개념적으로 재시도 로직을 완전히 커스터마이징하는 패턴을 이해하는 데 여전히 유용하다.
2. 왜 사용하는가
네트워크 요청, 웹소켓 연결, 파일 읽기 같은 작업은 언제든 실패할 수 있다. 에러가 발생했다고 스트림 전체가 죽어버리면, 구독자는 이후의 정상적인 값도 받을 수 없게 된다. 예를 들어 실시간 시세 데이터를 구독하는 스트림이 일시적인 네트워크 오류로 에러를 던지면, catchError나 retry 없이는 스트림이 완전히 끊기고 사용자는 화면이 멈춘 것처럼 느끼게 된다.
catchError는 에러 상황에서도 사용자에게 기본값이나 안내 메시지를 보여줄 수 있게 해주고, retry는 일시적 오류를 자동으로 복구해 안정성을 높인다. retryWhen은 단순 재시도로는 부족한 상황, 예를 들어 지수 백오프(exponential backoff)나 특정 에러 코드에서만 재시도하는 등의 정교한 정책이 필요할 때 쓰인다.
3. 동작 원리
catchError는 파이프라인 내부에서 소스 옵저버블을 구독하고 있다가 에러 알림을 받으면, 등록된 콜백 함수에 에러 객체와 원본 옵저버블을 인자로 전달한다. 콜백이 반환하는 값은 반드시 옵저버블이어야 하며, 이 반환된 옵저버블이 기존 구독을 대체한다. 콜백 내부에서 원본 옵저버블을 다시 반환하면 사실상 재시도와 비슷한 효과를 낼 수도 있지만, 무한 루프에 빠지지 않도록 주의해야 한다.
retry는 내부적으로 소스 옵저버블에 대한 구독이 에러로 종료될 때마다 카운터를 증가시키고, 설정된 횟수에 도달하지 않았다면 소스를 새로 구독한다. 설정 객체 방식을 사용하면 count 속성으로 횟수를, delay 속성으로 밀리초 값이나 지연을 계산하는 함수를 지정할 수 있다. 횟수를 모두 소진하면 마지막 에러를 그대로 구독자에게 전달한다.
retryWhen은 에러가 발생할 때마다 그 에러를 하나의 스트림(옵저버블)으로 흘려보내는데, 개발자가 제공한 notifier 함수가 이 에러 스트림을 받아 새로운 옵저버블을 반환한다. 이 notifier 옵저버블이 값을 next로 흘려보내면 그 시점에 원본 소스가 재구독되고, notifier가 에러를 던지면 전체가 에러로 종료되며, notifier가 complete되면 전체가 complete로 종료된다. 즉 재시도 여부와 타이밍을 notifier 옵저버블의 동작으로 완전히 위임하는 구조다.
4. 예제
catchError의 기본 사용은 다음과 같다. 먼저 of(1, 2, 3)에 map 연산자를 적용해 값이 2일 때 강제로 에러를 던지도록 만들고, 그다음 catchError 연산자를 파이프에 연결해 에러가 발생하면 of('fallback')을 반환하도록 설정한다. 이렇게 하면 구독자는 1을 받은 뒤 에러 대신 'fallback' 문자열을 받고 스트림이 정상적으로 complete 된다.
retry의 기본 사용은, 예를 들어 ajax('/api/data') 같은 요청 옵저버블 뒤에 retry(3)을 붙이면 요청이 실패할 때마다 최대 세 번까지 원본 요청을 다시 시도한다. 설정 객체를 쓰는 경우에는 retry({ count: 3, delay: 1000 })처럼 작성해서, 실패할 때마다 1초를 기다린 후 재시도하도록 만들 수 있다.
retry와 catchError를 함께 쓰는 패턴도 흔하다. 먼저 retry(2)로 두 번까지 자동 재시도를 시도하고, 그다음 catchError를 연결해서 재시도가 모두 실패했을 때 사용자에게 보여줄 기본 데이터나 에러 메시지 옵저버블을 반환하게 한다.
retryWhen을 사용한 지수 백오프 예시는 다음과 같이 설명할 수 있다. 먼저 소스 옵저버블 뒤에 retryWhen(errors => ...) 형태로 notifier 함수를 작성하고, 그 안에서 errors 옵저버블에 scan 연산자를 적용해 재시도 횟수를 누적한다. 그다음 delayWhen이나 mergeMap을 이용해 누적된 횟수에 따라 점점 길어지는 지연 시간, 예를 들어 1초, 2초, 4초 순으로 대기한 뒤 값을 흘려보내도록 구성한다. 만약 재시도 횟수가 5회를 넘으면 errors 스트림 내부에서 throwError를 호출해 최종적으로 에러를 전파하도록 만든다.
5. 실무 사용 사례
HTTP API 호출에서 일시적인 5xx 오류나 타임아웃이 발생했을 때 retry로 자동 재시도를 걸어두는 것은 흔한 패턴이다. 이때 4xx 클라이언트 오류처럼 재시도해도 의미 없는 에러는 retry 이전에 catchError나 조건 검사로 걸러내고 즉시 실패 처리하는 것이 바람직하다.
웹소켓 연결이 끊겼을 때 retryWhen이나 retry의 delay 옵션을 이용해 재연결 로직을 구현하는 경우도 많다. 이때 무작정 즉시 재연결을 반복하면 서버에 부하를 줄 수 있으므로 지수 백오프 방식을 함께 적용하는 것이 일반적이다.
파일 업로드나 결제 요청처럼 부작용(side effect)이 있는 작업에 대해서는 재시도가 중복 처리로 이어질 수 있으므로, 재시도 전에 서버 측 멱등성(idempotency) 보장 여부를 확인하고 나서 retry를 적용해야 한다.
catchError는 여러 개의 API 요청을 forkJoin이나 combineLatest로 묶었을 때, 하나의 요청이 실패해도 전체 스트림이 죽지 않도록 개별 요청 파이프라인 안에 넣어 기본값으로 대체하는 용도로도 자주 쓰인다.
6. 주의할 점
catchError 콜백 안에서 원본 옵저버블을 그대로 다시 구독해서 반환하면 에러가 반복될 때마다 스택이 계속 쌓이거나 무한 루프에 빠질 수 있으므로, 반드시 종료 조건이 있는 재시도 로직인지 확인해야 한다.
retry는 소스 옵저버블 전체를 처음부터 다시 구독한다는 점을 기억해야 한다. 만약 소스가 부작용을 일으키는 작업이라면, 재시도할 때마다 그 부작용이 다시 실행된다. 예를 들어 소스 안에 로깅이나 카운터 증가 로직이 있다면 재시도 횟수만큼 반복 실행된다.
retryWhen은 RxJS 최신 버전에서 deprecated 처리되었으므로 새 코드에서는 retry의 설정 객체 옵션(delay 함수, resetOnSuccess 등)을 우선 검토하고, retryWhen은 기존 코드 유지보수나 개념 학습 목적으로만 접근하는 것이 안전하다.
무한 재시도(retry() 인자 없이 호출)는 소스가 계속 실패하는 상황에서 스트림이 영원히 종료되지 않고 리소스를 소모할 수 있으므로, 반드시 최대 횟수나 타임아웃 조건을 함께 두는 것이 안전하다.
catchError와 retry의 순서는 결과에 큰 영향을 준다. catchError를 retry보다 먼저 배치하면 첫 에러에서 곧바로 대체 스트림으로 넘어가 재시도 자체가 발생하지 않으므로, 재시도 후 최종 실패를 처리하고 싶다면 retry를 먼저, catchError를 그 뒤에 배치해야 한다.
7. 핵심 정리
catchError는 에러를 잡아 대체 옵저버블로 전환해 스트림을 계속 이어가는 연산자이고, retry는 에러 발생 시 소스를 처음부터 재구독해 자동 복구를 시도하는 연산자다. retryWhen은 재시도 시점과 조건을 notifier 옵저버블로 완전히 커스터마이징할 수 있는 고급 연산자지만 현재는 deprecated 상태이며, 대부분의 경우 retry의 설정 객체 옵션으로 대체할 수 있다. 실무에서는 재시도 가능한 에러와 즉시 실패해야 하는 에러를 구분하고, 부작용이 있는 작업의 재시도는 멱등성을 고려해서 신중하게 적용해야 한다.