withLatestFrom, startWith: 소스 스트림에 최신 값을 결합하거나 초기값을 주입하는 연산자
withLatestFrom은 소스 옵저버블(observable)이 방출할 때 다른 옵저버블의 최신 값을 가져와 결합하는 연산자다. startWith는 옵저버블 구독(subscribe) 즉시 지정한 초기값을 먼저 방출하도록 만드는 연산자다. 두 연산자는 자주 함께 쓰이는데, withLatestFrom으로 결합할 스트림이 아직 값을 방출하지 않은 상태를 대비해
1. 개념
withLatestFrom은 소스 옵저버블이 값을 방출하는 시점에, 함께 지정한 다른 옵저버블(들)의 가장 최근 값을 꺼내와 배열이나 프로젝션 함수 결과로 묶어서 내보내는 연산자다. 방출 타이밍의 기준은 항상 소스 옵저버블이며, 결합 대상 옵저버블은 값을 보관만 하고 있다가 소스가 방출할 때 스냅샷처럼 참조된다.
startWith는 옵저버블 앞단에 지정한 값(또는 값들)을 미리 붙여서, 실제 소스가 방출을 시작하기 전에 구독자가 즉시 해당 값을 받도록 만드는 연산자다. 원본 스트림의 방출 내용은 전혀 바뀌지 않고, 그 앞에 초기값이 하나 추가되는 것뿐이다.
2. 왜 사용하는가
폼(form) 입력이나 버튼 클릭처럼 이벤트성 스트림을 처리할 때, "이 이벤트가 발생한 시점에 다른 상태값이 무엇이었는가"를 알아야 하는 경우가 많다. 예를 들어 저장 버튼 클릭 스트림과 입력창 값 변경 스트림이 있을 때, 클릭할 때마다 매번 최신 입력값을 함께 보내고 싶다면 withLatestFrom이 정확히 이 용도에 맞다.
문제는 withLatestFrom이 결합 대상 옵저버블이 최소 한 번이라도 값을 방출한 이후에만 동작한다는 점이다. 결합 대상이 BehaviorSubject가 아니라 아직 아무 값도 내보내지 않은 Subject나 옵저버블이라면, 소스가 아무리 방출해도 결합 결과가 나오지 않는다. 이때 startWith로 결합 대상 옵저버블에 초기값을 미리 채워두면 이 문제를 해결할 수 있다.
3. 동작 원리
withLatestFrom은 내부적으로 결합 대상 옵저버블들의 최신 값을 각각 변수에 저장해두는 방식으로 동작한다고 이해하면 된다. 결합 대상 옵저버블이 새 값을 방출할 때마다 저장된 값이 갱신되고, 정작 결과를 내보내는 트리거는 오직 소스 옵저버블의 방출뿐이다. 즉 결합 대상 옵저버블 자체의 방출은 결과 스트림에 직접 나타나지 않고, 소스 방출 시점에 "참고 자료"로만 쓰인다.
startWith는 스트림의 시작 지점에 값을 하나 주입하는 방식으로 동작한다. 구독이 이루어지자마자 지정한 초기값이 즉시 동기적으로 방출되고, 그 이후에는 원본 옵저버블이 방출하는 값들이 순서대로 이어진다. 여러 개의 인자를 넘기면 그 순서대로 여러 값을 먼저 방출한 뒤 원본 스트림으로 이어진다.
두 연산자를 함께 쓸 때의 원리는 이렇다. 결합 대상 옵저버블에 startWith를 적용해두면, 구독 시점에 즉시 초기값이 채워지므로 withLatestFrom이 참조할 "최신 값"이 처음부터 존재하게 된다. 그 결과 소스 옵저버블의 첫 방출부터 바로 결합된 값을 받을 수 있다.
4. 예제
먼저 클릭 이벤트를 흉내 낸 Subject를 하나 만들고 clicks$라고 이름 붙인다. 그다음 입력값 변경을 흉내 낸 Subject를 하나 더 만들고 inputValue$라고 이름 붙인다.
이제 inputValue$.pipe(startWith(''))처럼 작성해서, 아직 아무 입력도 없을 때는 빈 문자열을 기본값으로 갖도록 만든다. 이 결과를 latestInput$라는 새 옵저버블로 저장한다.
그다음 clicks$.pipe(withLatestFrom(latestInput$)).subscribe(([click, input]) => 결과 출력) 형태로 구독을 건다. 이렇게 하면 clicks$가 값을 방출할 때마다 그 시점의 latestInput$ 최신 값이 함께 배열로 묶여 전달된다.
만약 startWith('')를 빼버리면 어떻게 될까. 사용자가 아직 입력값을 한 번도 바꾸지 않은 상태에서 클릭이 먼저 발생하면, inputValue$가 방출한 값이 하나도 없으므로 withLatestFrom은 아무것도 내보내지 않는다. 즉 클릭 이벤트 자체가 유실된 것처럼 보이는 현상이 생긴다. startWith('')를 넣으면 이 문제가 사라지고, 클릭이 아무리 빨리 발생해도 항상 빈 문자열이라도 결합된 결과를 받을 수 있다.
또 다른 예로, 프로젝션 함수를 활용하는 방식도 있다. clicks$.pipe(withLatestFrom(latestInput$, (click, input) => input))처럼 두 번째 인자로 함수를 넘기면, 배열이 아니라 함수가 반환하는 값(여기서는 input 값 자체)만 결과로 받을 수 있어서 이후 처리 코드가 더 간결해진다.
5. 실무 사용 사례
폼 자동 저장 기능에서, 저장 버튼 클릭 스트림에 폼 전체 상태 스트림을 withLatestFrom으로 결합해서 클릭 시점의 스냅샷을 서버에 전송하는 패턴이 흔하다. 이때 폼 상태 스트림에는 초기 렌더링 시점의 기본값을 startWith로 미리 넣어두어, 사용자가 아무것도 건드리지 않고 바로 저장 버튼을 눌러도 정상적으로 기본 상태가 전송되도록 만든다.
페이지네이션(pagination) UI에서도 비슷하게, "다음 페이지" 버튼 클릭 스트림에 현재 검색어나 필터 상태 스트림을 withLatestFrom으로 묶는 방식을 쓴다. 검색어 스트림에는 초기 검색어(빈 문자열이나 URL 쿼리 파라미터 값)를 startWith로 넣어두는 것이 일반적이다.
WebSocket 연결 상태와 사용자 액션을 결합할 때도 유용하다. 연결 상태 스트림에 startWith(false)처럼 초기 연결 여부를 넣어두면, 사용자가 앱을 켜자마자 어떤 액션을 취하더라도 결합 결과에서 연결 상태를 항상 확인할 수 있다.
6. 주의할 점
withLatestFrom은 결합 대상 옵저버블의 방출 자체를 트리거로 쓰지 않는다는 점을 혼동하기 쉽다. 두 스트림을 "동시에" 다루고 싶다면 combineLatest가 더 적합할 수 있으며, withLatestFrom은 어디까지나 소스가 주도권을 쥐는 상황에 맞는 연산자다.
startWith로 넣는 초기값의 타입이 이후 스트림에서 방출되는 값의 타입과 일치하지 않으면, 구독자 쪽 코드에서 타입 분기 처리를 깜빡하기 쉽다. 예를 들어 실제 데이터는 객체인데 초기값만 null을 넣으면, 구독 콜백에서 null 체크를 반드시 넣어야 한다.
withLatestFrom에 넘기는 결합 대상 옵저버블이 완료(complete)되어 버리면 그 시점 이후로는 최신 값이 더 이상 갱신되지 않는다는 점도 유의해야 한다. Subject 기반 스트림을 결합할 때는 완료 처리 여부를 함께 점검하는 것이 좋다.
또한 withLatestFrom은 소스 옵저버블이 방출할 때만 동작하므로, 결합 대상 옵저버블만 계속 값을 바꿔도 결과 스트림에는 아무 변화가 없다. 이 동작이 의도한 것인지 항상 확인해야 한다.
7. 핵심 정리
withLatestFrom은 소스 옵저버블의 방출 시점에 다른 옵저버블의 최신 값을 결합하는 연산자이며, 방출 타이밍의 주도권은 항상 소스에 있다. startWith는 구독 시작 시점에 초기값을 먼저 방출하도록 만드는 연산자로, 결합 대상 스트림이 아직 값을 낸 적 없는 상황을 방지하는 용도로 자주 함께 쓰인다. 두 연산자를 조합하면 이벤트 발생 시점에 항상 유효한 상태값을 안전하게 참조하는 패턴을 구현할 수 있다.