mergeMap, switchMap, concatMap, exhaustMap: 고차 Observable을 평탄화(flatten)하는 네 가지 전략의 차이
고차 Observable(higher-order Observable)을 다룰 때 내부 Observable을 어떻게 병합할지 결정하는 네 가지 매핑 연산자를 비교한다. mergeMap은 동시 실행, concatMap은 순차 실행, switchMap은 최신 값만 유지, exhaustMap은 진행 중인 작업을 보호하는 전략을 각각 사용한다. 각 연산자의 구독(s
1. 개념
mergeMap, switchMap, concatMap, exhaustMap은 모두 소스 Observable이 방출하는 각 값을 새로운 내부 Observable(inner Observable)로 매핑(map)한 뒤, 그 내부 Observable들을 하나의 스트림으로 합치는(flatten) 연산자다.
이런 구조를 고차 Observable(higher-order Observable)이라고 부른다. Observable이 또 다른 Observable을 값으로 방출하는 형태이기 때문이다. 예를 들어 사용자가 입력할 때마다 HTTP 요청을 보내는 상황을 생각하면, 소스 Observable은 입력 이벤트를 방출하고, 각 입력값은 HTTP 요청이라는 또 다른 Observable로 매핑된다.
네 연산자의 이름 뒤에 붙은 Map은 이 매핑 동작을 의미하고, 앞의 merge, switch, concat, exhaust는 내부 Observable들을 합치는 전략을 의미한다. 결국 차이는 "여러 개의 내부 Observable이 동시에 존재할 때 무엇을 우선하고 무엇을 버리는가"에 있다.
2. 왜 사용하는가
단순히 map을 사용하면 각 값이 Observable로 변환된 결과, 즉 Observable<Observable<T>> 형태가 되어버려서 실제 데이터를 꺼내 쓰기 어렵다. 평탄화(flattening) 연산자는 이 중첩 구조를 한 겹 풀어서 Observable<T>로 만들어준다.
문제는 내부 Observable이 완료되기 전에 소스가 새 값을 또 방출하는 경우다. 이전 요청을 취소할지, 큐에 쌓아둘지, 무시할지, 아니면 그냥 동시에 실행할지에 따라 애플리케이션 동작이 완전히 달라진다. 네 연산자는 이 정책을 미리 정해놓은 조합이다.
3. 동작 원리
mergeMap은 소스가 값을 방출할 때마다 내부 Observable을 즉시 구독(subscribe)하고, 여러 내부 Observable을 동시에 병렬로 실행한다. 완료 순서는 각 내부 Observable의 실제 완료 시점에 따라 달라지며, 소스는 내부 Observable의 완료를 기다리지 않고 계속 새 값을 받아 새 구독을 만든다.
concatMap은 내부 Observable을 순서대로 구독한다. 현재 진행 중인 내부 Observable이 완료(complete)되기 전까지 다음 내부 Observable의 구독을 미루고 큐(queue)에 쌓아둔다. 순서 보장이 필요할 때 사용한다.
switchMap은 소스가 새 값을 방출하면 이전에 구독 중이던 내부 Observable을 즉시 구독 해지(unsubscribe)하고, 새 내부 Observable로 전환(switch)한다. 오직 가장 최근에 매핑된 내부 Observable만 살아남는다.
exhaustMap은 현재 진행 중인 내부 Observable이 있으면 소스가 새 값을 방출해도 완전히 무시한다. 진행 중인 내부 Observable이 완료된 뒤에야 그다음 소스 값을 받아 새로운 내부 Observable을 구독한다.
4. 예제
버튼 클릭 스트림을 clicks$라고 하고, 클릭할 때마다 fakeRequest(id)라는 함수가 1초 뒤 완료되는 Observable을 반환한다고 가정한다.
먼저 mergeMap을 사용하는 경우를 본다. clicks$.pipe(mergeMap(() => fakeRequest(id))).subscribe(result => console.log(result))처럼 작성하면, 사용자가 1초 안에 세 번 클릭하면 세 개의 요청이 동시에 진행되고, 완료되는 순서대로 결과가 출력된다.
다음으로 concatMap을 사용하면 clicks$.pipe(concatMap(() => fakeRequest(id))).subscribe(result => console.log(result))와 같이 작성한다. 이 경우 세 번 클릭해도 요청은 하나씩 순서대로 실행되며, 첫 번째 요청이 끝나야 두 번째 요청이 시작된다.
switchMap을 사용하는 경우는 clicks$.pipe(switchMap(() => fakeRequest(id))).subscribe(result => console.log(result))처럼 작성한다. 사용자가 첫 번째 클릭 후 0.5초 만에 다시 클릭하면, 첫 번째 요청은 구독 해지되어 결과가 출력되지 않고 두 번째 요청의 결과만 출력된다.
마지막으로 exhaustMap을 사용하면 clicks$.pipe(exhaustMap(() => fakeRequest(id))).subscribe(result => console.log(result))와 같이 작성한다. 첫 번째 요청이 진행 중일 때 사용자가 다시 클릭해도 그 클릭은 완전히 무시되고, 첫 번째 요청이 완료된 이후의 클릭만 새 요청으로 이어진다.
5. 실무 사용 사례
검색 자동완성 기능에는 switchMap이 적합하다. 사용자가 타이핑할 때마다 이전 검색 요청 결과는 더 이상 의미가 없으므로, 최신 입력에 대한 요청만 살아남게 하는 것이 자연스럽다.
파일 업로드나 로그 기록처럼 순서가 중요한 작업에는 concatMap을 사용한다. 각 작업이 이전 작업 완료 후 순서대로 실행되어야 데이터 무결성이 깨지지 않는다.
여러 API를 동시에 호출해서 병렬로 처리해야 하는 경우, 예를 들어 여러 이미지를 동시에 업로드하는 상황에는 mergeMap을 사용한다. 다만 동시 실행 개수를 제한해야 할 때는 mergeMap의 두 번째 인자인 concurrency 값을 지정할 수 있다.
폼 제출 버튼처럼 중복 클릭을 방지해야 하는 경우에는 exhaustMap이 유용하다. 이미 제출 요청이 진행 중이면 사용자가 버튼을 다시 눌러도 새로운 요청을 만들지 않고 무시한다.
6. 주의할 점
switchMap을 사용할 때 내부 Observable이 부수효과(side effect)를 일으키는 경우, 즉 취소되어도 서버에서 이미 실행된 작업(예: 결제 요청)이라면 구독 해지가 실제 작업을 되돌리지 못한다는 점을 인지해야 한다. 클라이언트 쪽 스트림만 끊어질 뿐 서버 측 처리는 이미 진행되었을 수 있다.
mergeMap은 동시성 제어 없이 사용하면 짧은 시간에 매우 많은 내부 Observable이 동시에 열려서 리소스 소모나 요청 폭주가 발생할 수 있다. 필요하다면 concurrency 인자로 제한을 걸어야 한다.
concatMap은 순서를 보장하지만, 앞의 내부 Observable이 오래 걸리면 뒤의 작업들이 계속 대기하는 큐가 쌓여서 지연이 누적될 수 있다.
exhaustMap은 진행 중인 작업이 있을 때 들어온 값을 조용히 버리기 때문에, 사용자가 그 사실을 인지하지 못하면 "클릭했는데 반응이 없다"는 혼란을 줄 수 있다. UI 상에서 로딩 상태를 명확히 표시해주는 것이 좋다.
7. 핵심 정리
네 연산자는 모두 값을 내부 Observable로 매핑하고 평탄화한다는 점에서 같지만, 여러 내부 Observable이 겹칠 때의 정책이 다르다. mergeMap은 모두 동시에 실행하고, concatMap은 순서대로 하나씩 실행하며, switchMap은 최신 것만 남기고 이전 것을 취소하고, exhaustMap은 진행 중인 것을 보호하고 새로운 것을 무시한다. 어떤 정책이 필요한지는 결국 "요청 취소가 안전한가", "순서가 중요한가", "중복 실행을 막아야 하는가"라는 질문에 대한 답에서 결정된다.