실습: 두 개 이상의 스트림을 조합해 결과 만들기 - combineLatest, merge, zip으로 여러 Observable을 하나로 합치는 방법
여러 개의 스트림에서 나오는 값을 동시에 다루어야 할 때 RxJS는 combineLatest, merge, zip, withLatestFrom 같은 결합(combination) 연산자를 제공한다. 각 연산자는 스트림을 합치는 시점과 방식이 서로 다르기 때문에, 목적에 맞는 연산자를 고르지 못하면 원하지 않는 타이밍에 값이 나오거나 값이 누락되는 문제가 생긴
1. 개념
스트림 결합(stream combination)이란 두 개 이상의 Observable에서 발행되는 값을 조합해 하나의 새로운 Observable로 만드는 것을 말한다. 각 스트림이 독립적으로 값을 방출하더라도, 결합 연산자를 사용하면 이 값들을 하나의 흐름으로 묶어서 처리할 수 있다.
RxJS는 결합 방식에 따라 여러 연산자를 제공한다. combineLatest는 각 스트림의 최신 값을 조합하고, merge는 여러 스트림의 값을 발행 순서대로 하나로 합치며, zip은 각 스트림에서 같은 순번의 값끼리 짝을 지어 조합한다. withLatestFrom은 기준이 되는 스트림이 값을 방출할 때 다른 스트림의 최신 값을 함께 가져온다.
2. 왜 사용하는가
웹 애플리케이션에서는 여러 데이터 소스가 동시에 존재하는 경우가 많다. 예를 들어 검색어 입력 스트림과 필터 옵션 스트림을 동시에 감시해야 하거나, 사용자 클릭 이벤트와 서버 응답 이벤트를 함께 다뤄야 하는 상황이 있다. 이런 경우 각 스트림을 따로 구독(subscribe)해서 콜백 안에서 수동으로 상태를 동기화하면 코드가 복잡해지고 타이밍 버그가 생기기 쉽다.
결합 연산자를 쓰면 여러 스트림의 관계를 선언적으로 표현할 수 있다. "이 값과 저 값이 모두 준비되면 계산해라" 또는 "둘 중 아무거나 값이 오면 처리해라" 같은 로직을 연산자 하나로 표현할 수 있어서 코드의 의도가 명확해지고 유지보수가 쉬워진다.
3. 동작 원리
combineLatest는 인자로 받은 모든 소스 Observable이 각각 최소 한 번씩 값을 방출한 이후부터 동작을 시작한다. 그 이후로는 어느 소스든 새 값을 방출할 때마다, 나머지 소스들의 가장 최근 값과 조합해서 새로운 값을 내보낸다. 즉 모든 스트림이 준비될 때까지 기다렸다가, 그다음부터는 누구든 값이 바뀔 때마다 즉시 반응한다.
merge는 값을 조합하지 않는다. 여러 소스에서 나오는 값을 타입 변환 없이 그대로, 도착한 순서대로 하나의 스트림으로 흘려보낸다. 소스 간의 연관성은 신경 쓰지 않고 단순히 여러 이벤트를 하나의 채널로 모으고 싶을 때 사용한다.
zip은 각 소스에서 같은 인덱스(순번)에 해당하는 값끼리 짝을 지어 방출한다. 첫 번째 소스의 첫 번째 값과 두 번째 소스의 첫 번째 값이 짝을 이루고, 두 번째 값끼리 다시 짝을 이루는 식이다. 만약 한 소스의 방출 속도가 느리면 다른 소스는 그 소스가 다음 값을 낼 때까지 대기 상태로 남는다.
withLatestFrom은 방향성이 있는 결합이다. 기준 스트림이 값을 방출하는 시점에만 새로운 값이 나가며, 이때 다른 보조 스트림들의 가장 최근 값을 함께 담아서 내보낸다. 보조 스트림이 값을 방출하는 시점 자체는 결과에 영향을 주지 않는다.
4. 예제
먼저 combineLatest로 두 스트림의 최신 값을 조합하는 예제를 살펴본다. rxjs에서 combineLatest와 interval을 가져오고, map 연산자도 함께 가져온다. 그다음 const source1 = interval(1000)으로 1초마다 값을 내는 스트림을 만들고, const source2 = interval(1500)으로 1.5초마다 값을 내는 스트림을 만든다. 이후 combineLatest([source1, source2]).pipe(map(([a, b]) => a + b)).subscribe(console.log)를 실행하면, 두 스트림이 각각 처음 값을 낸 이후부터는 어느 한쪽이 값을 낼 때마다 두 값의 합이 출력된다.
다음으로 merge를 사용하는 예제를 본다. const clicks = fromEvent(document, 'click')으로 클릭 이벤트 스트림을 만들고, const keys = fromEvent(document, 'keydown')으로 키보드 이벤트 스트림을 만든다. 그다음 merge(clicks, keys).subscribe(event => console.log(event.type))을 실행하면, 클릭과 키 입력 중 어느 것이든 발생하는 순서대로 이벤트 타입이 출력된다.
이번엔 zip을 사용해 두 배열 기반 스트림을 짝짓는 예제다. const names = of('철수', '영희', '민수')와 const ages = of(20, 25, 30)을 만든 다음, zip(names, ages).pipe(map(([name, age]) => ${name}은 ${age}살)).subscribe(console.log)를 실행하면, 같은 순번끼리 짝지어져 "철수은 20살", "영희은 25살", "민수은 30살"이 순서대로 출력된다.
마지막으로 withLatestFrom을 사용하는 예제다. const button = fromEvent(document, 'click')과 const input = fromEvent(inputEl, 'input').pipe(map(e => e.target.value))를 만든 다음, button.pipe(withLatestFrom(input)).subscribe(([click, value]) => console.log(value))를 실행하면, 버튼을 클릭하는 시점마다 그 순간 입력창에 있던 최신 텍스트 값이 출력된다.
5. 실무 사용 사례
검색 폼에서 검색어 입력과 정렬 옵션, 필터 옵션을 각각 별도의 스트림으로 관리하면서 combineLatest로 묶어, 셋 중 어느 하나라도 바뀌면 자동으로 검색 API를 다시 호출하는 패턴이 흔하다. 대시보드에서는 여러 위젯의 새로고침 버튼 클릭 이벤트나 자동 갱신 타이머 이벤트를 merge로 하나의 채널로 모아서 공통 로딩 로직을 처리하기도 한다.
폼 제출 버튼 클릭 시점에 여러 입력 필드의 최신 값을 한꺼번에 모아 전송해야 할 때는 withLatestFrom이 적합하다. 페이지네이션이 있는 목록 화면에서 사용자의 페이지 이동 요청과 서버에서 내려오는 응답을 순서대로 정확히 짝지어야 하는 특수한 경우에는 zip을 활용할 수 있다.
6. 주의할 점
combineLatest는 모든 소스가 최소 한 번 값을 방출하기 전까지는 아무 값도 내보내지 않는다. 소스 중 하나가 값을 늦게 방출하거나 아예 방출하지 않으면 전체 결과가 나오지 않으므로, 각 소스에 초기값이 있는지 확인해야 한다.
zip은 순번 기반으로 동작하기 때문에 소스들의 방출 빈도가 크게 다르면 느린 소스 쪽에 맞춰 대기가 발생하고, 이 과정에서 빠른 소스의 값들이 메모리에 쌓일 수 있다. 무한히 값을 내는 스트림 두 개를 zip으로 묶을 때는 방출 속도 차이를 반드시 고려해야 한다.
merge는 값을 그대로 합치기 때문에 서로 다른 타입의 스트림을 묶으면 구독자 쪽에서 타입을 구분하는 로직이 추가로 필요하다. withLatestFrom은 보조 스트림이 아직 한 번도 값을 방출하지 않은 상태라면 기준 스트림이 값을 내도 결과가 나가지 않는다는 점을 기억해야 한다.
7. 핵심 정리
여러 스트림을 하나로 묶을 때는 목적에 맞는 결합 연산자를 선택하는 것이 핵심이다. 모든 소스의 최신 값이 함께 필요하면 combineLatest, 여러 이벤트를 순서대로 하나의 채널로 모으고 싶으면 merge, 같은 순번끼리 정확히 짝지어야 하면 zip, 하나의 기준 이벤트 시점에 다른 스트림의 최신 값을 참조하고 싶으면 withLatestFrom을 사용한다. 각 연산자의 방출 타이밍과 조건을 정확히 이해하고 사용해야 의도한 대로 데이터 흐름을 제어할 수 있다.