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

여러 소스를 하나로 합치는 패턴: combineLatest·merge·zip·forkJoin으로 스트림을 결합하는 방법

RxJS에는 여러 개의 옵저버블(Observable)을 하나로 묶는 다양한 결합 연산자가 있다. 각 연산자는 값을 합치는 시점과 방식이 서로 다르기 때문에, 요구사항에 맞는 연산자를 고르지 않으면 타이밍 버그나 메모리 누수로 이어질 수 있다. 이 글에서는 combineLatest, merge, zip, forkJoin, concat의 차이를 원리 중심으로

송민성7분 읽기

1. 개념

스트림 결합(combination)은 두 개 이상의 옵저버블에서 나오는 값을 하나의 옵저버블로 합쳐서 처리하는 패턴이다. 웹 개발에서는 API 요청 여러 개를 동시에 보내고 결과를 한 번에 받거나, 여러 사용자 입력(검색어, 필터, 정렬 옵션)을 조합해서 하나의 화면 상태를 만드는 상황에서 이 패턴이 필요하다.

RxJS는 결합 방식에 따라 서로 다른 연산자를 제공한다. 대표적으로 combineLatest, merge, zip, forkJoin, concat이 있으며, 이들은 "언제 값을 방출하는가", "소스가 몇 개 있어야 값을 만드는가", "소스가 완료(complete)되는 시점을 어떻게 다루는가"에서 차이가 난다.

2. 왜 사용하는가

실무에서 화면 하나를 그리기 위해 데이터 소스가 하나뿐인 경우는 드물다. 예를 들어 상품 상세 페이지는 상품 정보 API, 재고 API, 리뷰 API를 동시에 호출해야 하고, 검색 화면은 검색어 입력 스트림과 필터 선택 스트림을 조합해야 검색 결과를 만들 수 있다.

이런 상황을 콜백이나 Promise.all 같은 명령형 방식으로 처리하면 상태 동기화 로직이 여기저기 흩어지기 쉽다. 스트림 결합 연산자를 쓰면 "여러 소스가 어떻게 합쳐져야 하는가"라는 규칙을 선언적으로 표현할 수 있고, 소스 중 하나가 값을 새로 방출할 때마다 자동으로 최신 상태를 다시 계산해준다.

3. 동작 원리

각 연산자는 내부적으로 소스 옵저버블들을 구독(subscribe)하고, 정해진 규칙에 따라 값을 모아서 새로운 값을 만들어 하위 구독자에게 전달한다.

combineLatest는 구독 중인 모든 소스가 최소 한 번씩 값을 방출한 이후부터 동작한다. 그 이후로는 어느 소스든 새 값을 방출할 때마다, 나머지 소스들의 "가장 최근 값"과 합쳐서 새 배열(또는 객체)을 만들어 방출한다. 소스 중 하나라도 아직 값을 방출하지 않았다면 결과는 나오지 않는다.

merge는 값을 합치지 않고 그대로 흘려보낸다. 여러 소스 중 어느 것이든 값을 방출하면 그 값을 즉시 하위로 전달한다. 소스 간의 순서나 짝을 맞추지 않고, 먼저 도착하는 값을 그대로 통과시키는 방식이다.

zip은 각 소스에서 같은 순번의 값을 짝지어 방출한다. 첫 번째 소스의 첫 번째 값과 두 번째 소스의 첫 번째 값이 모두 도착해야 첫 결과가 나오고, 이후 두 번째 값끼리 짝지어 두 번째 결과가 나오는 식이다. 소스 중 하나라도 값이 늦게 오면 나머지는 대기 상태가 된다.

forkJoin은 모든 소스가 완료(complete)될 때까지 기다렸다가, 각 소스의 마지막 값만 모아서 한 번 방출하고 완료된다. HTTP 요청처럼 "한 번 값을 받고 끝나는" 옵저버블을 여러 개 병렬로 실행할 때 Promise.all과 비슷한 역할을 한다.

concat은 소스를 동시에 구독하지 않고 순서대로 처리한다. 첫 번째 소스가 완료되어야 두 번째 소스를 구독하기 시작하며, 순서를 보장해야 하는 상황에 쓰인다.

4. 예제

combineLatest로 검색어와 필터를 조합하는 경우를 보면, 먼저 searchTerm$이라는 옵저버블과 filter$라는 옵저버블을 각각 정의하고, 그다음 combineLatest([searchTerm$, filter$])로 두 스트림을 묶는다. 이렇게 구독하면 searchTerm$이 "js"라는 값을 방출하고 filter$가 "active"라는 값을 방출한 이후부터, 둘 중 하나라도 값이 바뀔 때마다 [검색어, 필터] 형태의 배열이 새로 방출된다. 예를 들어 combineLatest([searchTerm$, filter$]).subscribe(([term, filter]) => console.log(term, filter)) 같은 코드로 최신 조합을 계속 받아볼 수 있다.

forkJoin으로 API 세 개를 병렬 호출하는 경우를 보면, 먼저 productInfo$, stock$, reviews$라는 세 개의 HTTP 요청 옵저버블을 준비하고, 그다음 forkJoin({ product: productInfo$, stock: stock$, reviews: reviews$ }) 형태로 묶는다. 세 요청이 모두 완료되면 { product, stock, reviews } 형태의 객체 하나가 방출되고, 이 값을 받아서 화면을 한 번에 그리면 된다. 만약 세 요청 중 하나라도 에러가 나면 forkJoin 전체가 에러로 종료되므로 에러 처리를 별도로 고려해야 한다.

merge로 여러 버튼 클릭을 하나의 스트림으로 합치는 경우를 보면, 먼저 saveClick$, autoSaveClick$라는 두 개의 클릭 이벤트 스트림을 만들고, 그다음 merge(saveClick$, autoSaveClick$)로 합쳐서 구독하면 어느 버튼을 누르든 동일한 저장 로직을 한 곳에서 실행할 수 있다.

5. 실무 사용 사례

대시보드 화면에서 여러 위젯이 각각 다른 API에서 데이터를 받아오지만 로딩 상태를 하나로 통합해서 보여줘야 할 때 combineLatestforkJoin을 조합해서 쓴다. 페이지 진입 시 필요한 초기 데이터를 병렬로 가져오는 경우에는 forkJoin이 적합하고, 사용자 입력에 따라 계속 갱신되는 화면 상태를 만들 때는 combineLatest가 적합하다.

여러 종류의 사용자 액션(마우스 클릭, 키보드 입력, 터치 이벤트)을 동일한 핸들러로 처리해야 하는 UI 컴포넌트에서는 merge를 사용해 이벤트 스트림을 하나로 합친다. 파일 업로드처럼 순서를 지켜서 여러 요청을 차례로 실행해야 하는 경우에는 concat을 사용한다.

6. 주의할 점

combineLatest는 소스 중 하나라도 값을 아직 방출하지 않으면 아무 값도 나오지 않는다는 점을 놓치기 쉽다. 특히 초기값이 없는 옵저버블을 조합하면 화면이 계속 비어 있는 버그로 이어질 수 있으므로, 필요하다면 startWith 같은 연산자로 초기값을 명시해주는 것이 안전하다.

forkJoin은 소스 중 하나라도 완료되지 않으면 영원히 값을 방출하지 않는다. 예를 들어 무한히 값을 방출하는 옵저버블(사용자 입력 스트림 등)을 forkJoin에 넣으면 결과가 절대 나오지 않으므로, forkJoin은 반드시 완료되는 소스에만 사용해야 한다.

zip은 소스 간 방출 속도 차이가 크면 값이 도착하지 않은 소스 쪽에서 메모리에 값이 계속 쌓일 수 있다. 속도 차이가 큰 스트림을 짝짓는 용도로는 적합하지 않다.

merge는 값을 그대로 흘려보내기 때문에 소스 개수가 많아질수록 하위 구독자가 어떤 소스에서 온 값인지 구분하기 어려워질 수 있다. 필요하다면 map 연산자로 소스를 식별할 수 있는 정보를 값에 포함시키는 것이 좋다.

7. 핵심 정리

여러 소스를 합칠 때는 "값을 언제, 어떤 조건으로 방출할 것인가"를 먼저 정의하고 그에 맞는 연산자를 골라야 한다. 최신 값들의 조합이 계속 필요하면 combineLatest, 순서 그대로 짝을 지어야 하면 zip, 완료된 결과만 한 번 모으면 되면 forkJoin, 값을 구분 없이 합치면 되면 merge, 순서를 지켜 차례로 실행해야 하면 concat을 사용한다. 연산자 선택을 잘못하면 화면이 비거나 값이 누락되는 문제로 이어지므로, 각 연산자가 소스의 완료와 방출 타이밍을 어떻게 다루는지 정확히 이해하고 사용해야 한다.

© 2026 Tyler Song