본문 바로가기
TYLER SONGBlog
블로그 목록
Frontend

고차 Observable 평탄화(flattening): 상황별로 mergeMap, switchMap, concatMap, exhaustMap을 선택하는 기준

고차 Observable을 평탄화(flattening)할 때 쓰는 네 가지 연산자는 내부 구독을 관리하는 방식이 서로 다르기 때문에, 같은 상황에도 완전히 다른 결과를 만들어낸다. 이 글은 각 연산자가 이전 내부 Observable을 취소하는지, 순서를 보장하는지, 새 이벤트를 무시하는지를 기준으로 선택 흐름을 정리한다. 검색, 로그 전송, 파일 업로드,

송민성7분 읽기

1. 개념

고차 Observable(higher-order Observable)은 Observable을 값으로 방출하는 Observable이다. 예를 들어 사용자 입력 이벤트를 받아서 매번 HTTP 요청 Observable을 만들어내면, 바깥쪽 스트림은 "Observable을 담은 Observable" 형태가 된다. 이 구조를 그대로 다루면 구독자는 내부 Observable 객체 자체를 받게 되므로, 내부 Observable을 구독해서 실제 값을 꺼내는 작업이 추가로 필요하다. 이 작업을 평탄화(flattening)라고 부른다.

RxJS는 평탄화를 담당하는 연산자로 mergeMap, switchMap, concatMap, exhaustMap을 제공한다. 네 연산자 모두 "바깥 값 → 내부 Observable 생성 → 내부 Observable 구독 → 값 방출"이라는 동일한 뼈대를 가지지만, 내부 구독을 관리하는 전략이 서로 다르다.

2. 왜 사용하는가

비동기 작업을 연쇄적으로 처리하다 보면 이전 비동기 작업이 끝나지 않은 상태에서 새 이벤트가 발생하는 경우가 흔하다. 검색창에 빠르게 타이핑하거나, 버튼을 여러 번 클릭하거나, 짧은 시간에 여러 요청이 겹치는 상황이다. 이런 상황에서 내부 Observable을 어떻게 처리할지에 대한 정책이 없으면 요청이 중복 실행되거나, 오래된 응답이 화면에 반영되거나, 처리 순서가 뒤섞이는 문제가 생긴다. 네 연산자는 이 정책을 연산자 선택만으로 명시적으로 표현할 수 있게 해준다.

3. 동작 원리

각 연산자는 새로운 바깥 값이 들어왔을 때 기존 내부 구독을 어떻게 처리하는지가 핵심 차이다.

mergeMap은 바깥 값이 들어올 때마다 내부 Observable을 구독하고, 기존 구독을 취소하지 않는다. 따라서 여러 내부 Observable이 동시에 살아있을 수 있고, 값은 완료되는 순서대로 뒤섞여서 방출된다.

concatMap은 내부 Observable을 큐에 순서대로 쌓아두고, 이전 내부 Observable이 완료된 뒤에야 다음 내부 Observable을 구독한다. 순서는 보장되지만 이전 작업이 끝나기 전까지 다음 작업이 시작되지 않으므로 지연이 누적될 수 있다.

switchMap은 새로운 바깥 값이 들어오면 현재 진행 중인 내부 구독을 즉시 취소(unsubscribe)하고 새 내부 Observable을 구독한다. 항상 가장 최신 내부 Observable의 값만 방출된다.

exhaustMap은 내부 Observable이 진행 중일 때 들어오는 새로운 바깥 값을 무시한다. 현재 내부 Observable이 완료된 뒤에야 다음 바깥 값을 받아 새 내부 Observable을 구독한다.

4. 예제

검색 자동완성 기능을 만든다고 하면, 사용자가 입력할 때마다 이전 요청 결과는 필요 없고 최신 입력에 대한 결과만 필요하다. 이때는 fromEvent(input, 'input').pipe(debounceTime(300), switchMap(event => ajax('/search?q=' + event.target.value))) 형태로 작성한다. 타이핑이 계속되면 이전 요청은 취소되고 마지막 입력에 대한 요청만 결과를 반영한다.

로그 전송처럼 순서와 완전성이 중요한 경우는 concatMap을 쓴다. logSubject.pipe(concatMap(log => sendLogToServer(log))) 형태로 작성하면, 로그가 여러 개 쌓여도 서버로 보내는 순서가 발생 순서와 동일하게 유지되고 하나도 누락되지 않는다.

여러 파일을 동시에 업로드하면서 순서는 상관없고 병렬로 처리해도 되는 경우는 mergeMap을 쓴다. fileSelect$.pipe(mergeMap(file => uploadFile(file))) 형태로 작성하면 여러 업로드가 동시에 진행되고, 완료되는 순서대로 결과가 방출된다.

폼의 제출 버튼처럼 이미 요청이 진행 중일 때 중복 클릭을 막아야 하는 경우는 exhaustMap을 쓴다. submitClick$.pipe(exhaustMap(() => submitForm(formData))) 형태로 작성하면, 요청이 진행 중인 동안 추가로 발생하는 클릭 이벤트는 무시되고, 요청이 끝난 뒤에야 다음 클릭이 새 요청을 만든다.

5. 실무 사용 사례

검색 자동완성, 라우팅에 따른 데이터 재조회처럼 "최신 요청만 유효하다"는 상황에는 switchMap이 표준적으로 쓰인다. 사용자가 화면을 빠르게 전환할 때 이전 응답이 늦게 도착해서 화면을 덮어쓰는 문제를 막을 수 있다.

주문 처리, 로그 적재, 순차적으로 실행해야 하는 마이그레이션 스크립트처럼 "순서 보장과 누락 방지"가 중요한 상황에는 concatMap이 쓰인다.

파일 업로드, 여러 독립적인 API 호출을 동시에 처리해야 하는 대시보드 데이터 로딩 같은 상황에는 mergeMap이 쓰인다. 다만 동시 실행 개수를 제한해야 할 때는 mergeMap에 동시성(concurrency)을 제한하는 인자를 넘겨서 동시 구독 수를 제어할 수 있다.

폼 제출, 결제 버튼, 새로고침 버튼처럼 "중복 실행을 막아야 하는" 상황에는 exhaustMap이 쓰인다.

6. 주의할 점

mergeMap을 제한 없이 사용하면 내부 Observable이 계속 쌓여서 동시 요청 수가 통제되지 않을 수 있다. 동시 실행 개수를 제한하는 인자를 명시적으로 고려해야 한다.

switchMap은 내부 Observable을 취소하기 때문에, 취소되면 안 되는 부수 효과(side effect)가 내부에 있으면 문제가 된다. 예를 들어 결제 요청처럼 서버에 이미 요청이 전달된 이후에는 클라이언트에서 구독을 취소해도 서버 쪽 처리가 취소되지 않는 경우가 있으므로, 이런 작업에는 switchMap이 적합하지 않다.

concatMap은 큐에 쌓인 작업이 많아지면 처리 지연이 계속 누적될 수 있다. 바깥 값이 내부 Observable보다 훨씬 빠르게 발생하는 상황에서는 대기열이 무한히 늘어날 수 있다.

exhaustMap은 진행 중인 작업이 있을 때 새 이벤트를 완전히 무시하므로, 사용자에게 "왜 클릭이 반응하지 않는지"에 대한 피드백(예: 로딩 상태 표시)을 함께 제공하는 것이 좋다.

7. 핵심 정리

네 연산자의 선택 기준은 "이전 내부 Observable을 어떻게 처리할 것인가"라는 질문으로 요약된다. 최신 값만 필요하면 switchMap, 순서와 완전성이 필요하면 concatMap, 병렬 처리가 가능하면 mergeMap, 중복 실행을 막아야 하면 exhaustMap을 선택한다. 이 판단은 연산자 이름을 외우는 문제가 아니라, 비즈니스 요구사항이 취소, 순서, 동시성, 중복 방지 중 어떤 것을 요구하는지 먼저 파악하는 문제다.

© 2026 Tyler Song