본문 바로가기
TYLER SONGBlog
← Blog index
Frontend12 min read

안티패턴 정리: 중첩 구독과 구독 해제 누락을 연산자와 생명주기로 제거하기

RxJS 안티패턴은 데이터 흐름을 여러 구독(subscribe)과 외부 상태로 분산해 취소, 오류, 종료 시점을 추적하기 어렵게 만든다. 중첩 구독은 고차 매핑 연산자(higher-order mapping operator)로 평탄화하고, 장기 실행 스트림은 명시적인 구독 해제(unsubscribe) 정책을 둬야 한다. 연산자 선택, 오류 처리 위치, 공유

By 송민성

1. 개념

RxJS 안티패턴(anti-pattern)은 당장 동작하지만 스트림의 취소, 오류, 완료, 동시성 규칙을 코드 밖으로 흩어 놓는 구현 방식이다.

대표적인 안티패턴은 다음과 같다.

  • 중첩 구독(nested subscription): 구독 콜백 안에서 다른 옵저버블(Observable)을 다시 구독한다.
  • 구독 해제 누락(missing unsubscription): 종료되지 않는 스트림의 구독을 계속 유지한다.
  • 구독 안의 과도한 로직: 변환, 분기, 재시도, 상태 변경을 subscribe 콜백에서 직접 처리한다.
  • 주체(Subject) 남용: 일반 옵저버블로 표현할 수 있는 흐름까지 수동 next 호출로 연결한다.
  • 오류 처리 위치 혼동: catchError를 잘못 배치해 전체 스트림을 예상보다 일찍 종료한다.
  • 중복 구독: 콜드 옵저버블(cold Observable)을 여러 번 구독해 네트워크 요청이나 파일 읽기를 반복 실행한다.
  • 무제한 동시 실행: mergeMap으로 작업을 제한 없이 시작해 메모리와 외부 시스템을 압박한다.

공통 문제는 데이터 흐름과 생명주기(lifecycle)가 선언형 파이프라인 밖으로 빠져나간다는 점이다.

2. 왜 사용하는가

안티패턴을 제거하면 스트림의 실행 규칙을 pipe 안에서 한 번에 읽을 수 있다.

  • 취소 규칙이 명확해진다. 최신 작업만 유지할지, 순서를 보장할지, 중복 실행을 막을지 연산자로 표현한다.
  • 오류 범위가 드러난다. 개별 작업만 실패 처리할지 전체 스트림을 종료할지 선택할 수 있다.
  • 자원 정리가 예측 가능해진다. 이벤트 리스너, 타이머, 소켓, 파일 감시자의 해제 시점을 구독 생명주기와 맞춘다.
  • 테스트가 쉬워진다. 외부 변수와 중첩 콜백 대신 입력과 출력 스트림을 검증할 수 있다.
  • 경쟁 상태(race condition)를 줄인다. 늦게 끝난 이전 작업이 최신 결과를 덮어쓰는 문제를 방지한다.

목표는 구독 수를 무조건 줄이는 것이 아니다. 구독을 애플리케이션 경계에 두고, 변환과 제어는 연산자 체인으로 이동하는 것이다.

3. 동작 원리

중첩 구독

outer$.subscribe(value => inner$(value).subscribe(...)) 형태에서는 바깥 구독과 안쪽 구독이 별개로 생성된다. 바깥 구독을 해제해도 이미 생성된 안쪽 구독이 자동으로 함께 해제된다고 볼 수 없다. 오류와 완료 신호도 두 구독으로 분리된다.

고차 매핑 연산자(higher-order mapping operator)는 바깥 값으로 내부 옵저버블을 만들고, 내부 구독의 생성과 해제를 관리한다.

  • switchMap: 새 값이 오면 이전 내부 구독을 해제한다. 검색, 자동 완성, 최신 상태 조회에 적합하다.
  • concatMap: 이전 작업이 완료된 뒤 다음 작업을 시작한다. 순차 저장처럼 순서가 중요할 때 쓴다.
  • mergeMap: 여러 내부 작업을 동시에 유지한다. 병렬 실행이 필요할 때 쓴다.
  • exhaustMap: 작업 실행 중 들어온 새 값을 무시한다. 중복 제출 방지에 적합하다.

구독 해제 누락

interval, fromEvent, 웹소켓, 파일 감시처럼 스스로 완료되지 않는 스트림은 구독이 유지되는 동안 자원을 계속 사용한다. 구독 콜백이 큰 객체를 참조하면 해당 객체도 회수되지 않을 수 있다.

해제 방식은 생명주기에 맞춰 선택한다.

  • 단일 값만 필요하면 take(1) 또는 조건 기반 first를 사용한다.
  • 종료 신호가 있으면 takeUntil(stop$)을 사용한다.
  • 여러 구독을 묶어야 하면 부모 Subscription에 add하고 부모만 해제한다.
  • 직접 만든 옵저버블은 생성 함수에서 정리 함수(teardown function)를 반환한다.

HTTP 요청처럼 한 번 방출하고 완료되는 유한 스트림도 실행 도중 취소가 필요하면 구독 해제를 고려해야 한다. 완료된 구독은 추가 해제가 필요하지 않지만, 완료 전까지 연결된 자원은 남아 있다.

오류 처리 위치

catchError는 자신보다 위쪽에서 발생한 오류를 처리한다. 고차 매핑 연산자 바깥에 두면 내부 작업 하나의 실패가 바깥 스트림 전체를 종료할 수 있다. 내부 옵저버블 안에 두면 해당 작업만 대체 값으로 복구하고 다음 바깥 값을 계속 받을 수 있다.

콜드 옵저버블 중복 실행

콜드 옵저버블은 구독마다 생산자(producer)를 새로 실행한다. 같은 요청 스트림을 두 번 구독하면 요청도 두 번 발생할 수 있다. 하나의 실행을 여러 구독자가 공유해야 한다면 share 또는 shareReplay를 검토한다.

shareReplay({ bufferSize: 1, refCount: true })는 최근 값 하나를 재전달하고 구독자 수에 따라 원본 구독을 관리하는 데 쓸 수 있다. 캐시 수명과 재실행 정책이 요구사항과 맞는지 확인해야 한다.

4. 예제 (인라인 코드로만, fenced 코드블록 금지)

중첩 구독 제거

나쁜 형태는 userId$.subscribe(id => loadUser(id).subscribe(user => render(user)))다. 새 식별자가 들어와도 이전 요청이 남을 수 있고, 안쪽 오류를 별도로 처리해야 한다.

최신 사용자만 필요하다면 userId$.pipe(switchMap(id => loadUser(id))).subscribe(user => render(user))로 평탄화한다. 새 식별자가 방출되면 이전 내부 구독이 해제된다.

모든 저장 요청을 순서대로 처리해야 한다면 saveRequest$.pipe(concatMap(data => save(data))).subscribe()를 사용한다. 병렬 처리가 필요하다면 mergeMap을 선택하되 동시성 제한을 함께 지정한다.

종료 신호로 구독 정리

const stop$ = new Subject()를 만들고, 장기 실행 스트림에 events$.pipe(takeUntil(stop$)).subscribe(handleEvent)를 적용한다. 종료 시점에는 먼저 stop$.next()를 호출하고, 그다음 stop$.complete()를 호출한다.

takeUntil의 알림자(notifier)는 값을 방출해야 원본 구독을 종료한다. stop$.complete()만 호출하면 종료 신호가 전달되지 않는다.

직접 만든 옵저버블 정리

이벤트 이미터(event emitter)를 감싸는다면 new Observable(subscriber => { ... })의 생성 함수에서 리스너를 등록하고 정리 함수를 반환한다. 핵심 형태는 return () => emitter.off('data', handler)다. 구독 해제나 완료가 발생하면 RxJS가 이 정리 함수를 실행한다.

표준 이벤트 대상이라면 직접 감싸기보다 fromEvent(target, 'data')를 사용한다. 기존 생성 함수를 재사용하면 등록과 해제 로직을 따로 관리할 필요가 없다.

오류 범위 분리

query$.pipe(switchMap(query => search(query)), catchError(() => of([])))는 검색 요청 하나가 실패했을 때 전체 검색 스트림을 대체 스트림으로 전환한다. 이후 query$가 값을 보내도 기존 바깥 구독은 이어지지 않는다.

입력 스트림을 계속 유지하려면 query$.pipe(switchMap(query => search(query).pipe(catchError(() => of([])))))처럼 내부에 catchError를 둔다. 실패한 요청만 빈 배열로 대체되고 다음 검색어는 계속 처리된다.

구독 안의 로직 이동

source$.subscribe(value => { const normalized = value.trim().toLowerCase(); if (normalized) save(normalized); })는 변환과 필터링을 구독 콜백에 숨긴다.

source$.pipe(map(value => value.trim().toLowerCase()), filter(Boolean), tap(value => save(value))).subscribe()처럼 단계별로 옮길 수 있다. 다만 save가 옵저버블을 반환하는 비동기 작업이라면 tap이 아니라 concatMap(value => save(value)) 같은 평탄화 연산자를 사용해야 한다.

tap은 관찰과 부수 효과(side effect)를 위한 연산자다. 내부에서 시작한 비동기 작업의 완료, 취소, 오류를 바깥 스트림이 관리해 주지 않는다.

5. 실무 사용 사례

검색과 자동 완성

입력값에 debounceTime, distinctUntilChanged, switchMap을 조합한다. 짧은 시간 동안 반복된 입력을 줄이고, 같은 검색어를 건너뛰며, 이전 요청을 취소한다.

작업 큐와 순차 저장

사용자 편집 내용을 서버에 순서대로 저장해야 한다면 concatMap을 사용한다. switchMap은 이전 저장을 취소할 수 있고, mergeMap은 완료 순서를 보장하지 않으므로 요구사항과 맞지 않을 수 있다.

중복 제출 방지

결제나 폼 제출 중 추가 클릭을 무시해야 한다면 exhaustMap을 사용한다. 첫 작업이 완료되기 전 들어온 값은 처리하지 않는다. 버튼 비활성화만으로 막는 방식보다 스트림 수준에서 실행 규칙을 보장할 수 있다.

병렬 처리 제한

파일 업로드나 외부 API 호출을 병렬 처리할 때 mergeMap(task => run(task), 4)처럼 동시성(concurrency)을 제한한다. 무제한 병렬 실행으로 인한 메모리 증가, 연결 고갈, 요청 제한 초과를 줄인다.

Node.js 종료 처리

프로세스 종료 신호를 fromEvent(process, 'SIGTERM') 같은 스트림으로 만들고 takeUntil의 종료 신호로 사용한다. 웹소켓, 메시지 소비자, 주기 작업을 같은 종료 흐름에 연결하면 정리 순서를 한곳에서 관리할 수 있다.

6. 주의할 점

subscribe 안에서 다시 subscribe하지 않는다

중첩 구독이 항상 문법 오류인 것은 아니다. 하지만 내부 작업이 바깥 값에 의존한다면 고차 매핑 연산자로 표현하는 편이 취소, 오류, 완료 전파를 관리하기 쉽다.

모든 구독을 기계적으로 해제하지 않는다

of, 완료되는 HTTP 응답, take(1)이 적용된 스트림처럼 스스로 종료되는 구독은 완료 시 정리된다. 문제는 종료되지 않거나 완료 여부가 불명확한 스트림이다. 스트림 특성을 확인한 뒤 정책을 정한다.

Subject를 기본 연결 수단으로 쓰지 않는다

Subject는 외부 값을 스트림으로 밀어 넣어야 할 때 유용하다. 단순 변환이나 조합까지 Subject로 연결하면 데이터 출처와 실행 순서가 숨겨진다. 먼저 map, filter, combineLatest, withLatestFrom, 고차 매핑 연산자로 표현할 수 있는지 확인한다.

tap에서 구독하거나 흐름을 변경하지 않는다

tap 안의 subscribe는 중첩 구독과 같은 문제를 만든다. tap 안에서 값을 변경하면 뒤 연산자의 결과도 외부 상태에 따라 달라질 수 있다. 로깅, 계측, 동기적인 경계 작업처럼 제한된 부수 효과에 사용한다.

공유 연산자를 무조건 캐시로 사용하지 않는다

shareReplay는 재전달 버퍼를 유지한다. 큰 값이나 긴 수명의 스트림에 적용하면 메모리 사용량이 커질 수 있다. bufferSize, refCount, 원본 스트림의 완료 여부, 캐시 갱신 요구사항을 함께 검토한다.

오류를 빈 값으로 숨기지 않는다

모든 오류를 catchError(() => EMPTY)로 처리하면 구독자는 실패 원인을 알 수 없다. 복구 가능한 오류만 대체하고, 기록이 필요하면 로깅한 뒤 throwError(() => error)로 다시 전달한다.

7. 핵심 정리

  • 구독은 애플리케이션 경계에 두고 변환, 분기, 재시도, 비동기 연결은 pipe 안에서 처리한다.
  • 중첩 구독은 switchMap, concatMap, mergeMap, exhaustMap 중 요구사항에 맞는 연산자로 평탄화한다.
  • 종료되지 않는 스트림은 take, takeUntil, Subscription.add, 정리 함수 중 하나로 생명주기를 명시한다.
  • catchError 위치로 개별 작업 실패와 전체 스트림 실패의 범위를 구분한다.
  • Subject, tap, shareReplay는 편의 기능이 아니라 상태, 부수 효과, 공유 정책을 드러내는 도구로 제한해 사용한다.