distinctUntilChanged, sample: 연속된 중복값을 걸러내고 특정 시점의 값만 취하는 연산자
distinctUntilChanged는 바로 직전 값과 비교해 연속으로 중복되는 값의 방출을 막는 필터링 연산자이고, sample은 다른 옵저버블(Observable)이 신호를 보낼 때마다 소스의 최신 값을 한 번씩 뽑아내는 타이밍 연산자다. 두 연산자 모두 스트림에서 불필요한 방출을 줄여 성능과 로직의 명확성을 높이는 데 쓰인다. 이 문서에서는 두 연산자
1. 개념
distinctUntilChanged는 소스 옵저버블이 방출하는 값 중에서 바로 이전에 방출된 값과 같으면 걸러내고, 다르면 통과시키는 연산자다. 여기서 "이전 값"은 전체 히스토리가 아니라 "직전에 통과한 값" 단 하나만을 의미한다.
sample은 소스 옵저버블의 값을 계속 흘려보내는 대신, 별도로 주어지는 다른 옵저버블(sampler라고 부른다)이 값을 방출하는 순간에만 소스의 "가장 최근 값"을 꺼내서 내보내는 연산자다. sampler가 신호를 보내지 않으면 소스가 아무리 자주 값을 내도 아무것도 방출되지 않는다.
2. 왜 사용하는가
실제 스트림에서는 같은 값이 연속으로 여러 번 방출되는 경우가 흔하다. 예를 들어 입력창의 값이 변하지 않았는데도 이벤트가 여러 번 발생하거나, 상태 관리 라이브러리에서 동일한 상태 객체가 반복적으로 방출되는 경우다. 이런 중복 방출은 불필요한 렌더링이나 API 호출을 유발하므로 distinctUntilChanged로 걸러낸다.
반면 sample은 소스가 너무 빠르게, 너무 자주 값을 내보낼 때 유용하다. 마우스 이동이나 스크롤 이벤트처럼 초당 수십 번씩 발생하는 스트림을 매번 처리하면 비용이 크므로, 일정한 신호(타이머나 다른 이벤트)가 왔을 때만 그 시점의 최신 값을 골라 처리량을 줄인다.
3. 동작 원리
distinctUntilChanged는 내부적으로 "마지막으로 통과시킨 값"을 하나 저장해 둔다. 새 값이 들어오면 저장된 값과 비교 함수(기본은 === 연산)로 비교하고, 다르면 그 값을 방출하면서 저장값을 갱신하고, 같으면 아무것도 하지 않는다. 두 번째 인자로 비교 함수를 직접 넘기면 객체의 특정 속성만 비교하는 식으로 커스터마이징할 수 있다.
sample은 소스 옵저버블을 구독하면서 매번 최신 값을 내부 변수에 저장만 해두고 즉시 방출하지는 않는다. 동시에 sampler 옵저버블도 구독하는데, sampler가 값을 방출하는 시점에 지금까지 저장해 둔 소스의 최신 값을 꺼내 내보낸다. 만약 sampler가 신호를 보내는 순간 소스가 한 번도 값을 낸 적이 없다면 아무것도 방출되지 않는다. sampleTime이라는 관련 연산자는 sampler 대신 일정 시간 간격을 기준으로 동일하게 동작한다.
4. 예제
distinctUntilChanged 예제는 다음과 같은 흐름으로 구성한다. 먼저 from 연산자로 숫자 배열 [1, 1, 2, 2, 3, 1]을 옵저버블로 만들고, 그다음 distinctUntilChanged()를 파이프에 연결한다. 이렇게 하면 연속된 중복값인 두 번째 1과 두 번째 2는 걸러지고, 1, 2, 3, 1 순서로 방출된다. 마지막의 1은 바로 직전 값이 3이었기 때문에 다시 통과한다는 점이 핵심이다. 즉 완전히 유일한 값만 남기는 것이 아니라 "연속된" 중복만 제거한다는 것을 이 예제로 확인할 수 있다.
객체 비교가 필요한 경우에는 distinctUntilChanged((prev, curr) => prev.id === curr.id)처럼 두 번째 인자에 비교 함수를 넘긴다. 이렇게 하면 객체 참조가 달라도 id 속성이 같으면 중복으로 처리해 걸러낸다.
sample 예제는 다음과 같이 구성한다. 먼저 interval(100)으로 100밀리초마다 증가하는 숫자를 방출하는 소스 옵저버블을 만들고, 그다음 interval(500)으로 500밀리초마다 신호를 보내는 sampler 옵저버블을 준비한다. 이어서 소스에 sample(interval(500))을 파이프로 연결해 구독하면, 100밀리초마다 빠르게 증가하던 값 중에서 500밀리초 시점마다 그 순간의 최신 값만 뽑혀 나온다. 결과적으로 4, 9, 14 같은 식으로 5개 단위씩 건너뛴 값이 방출되는 흐름을 관찰할 수 있다.
5. 실무 사용 사례
distinctUntilChanged는 폼 입력값 변경 감지에서 자주 쓰인다. 사용자가 입력창에 같은 문자열을 다시 입력하거나 포커스 이벤트로 동일한 값이 재방출될 때, 이 연산자를 걸어두면 실제로 값이 바뀐 경우에만 검증 로직이나 API 호출이 실행되도록 만들 수 있다. 상태 관리 스트림에서도 동일한 상태 객체가 반복 방출될 때 리렌더링을 막는 용도로 사용한다.
sample은 실시간 위치 추적이나 센서 데이터 스트림처럼 값이 매우 빈번하게 들어오는 상황에서, 화면 갱신 주기나 로그 기록 주기에 맞춰 값을 솎아낼 때 사용한다. 예를 들어 드래그 이벤트의 좌표를 매번 서버에 전송하는 대신, 일정 시간 간격의 sampler를 붙여서 그 시점의 좌표만 전송하면 네트워크 부하를 줄일 수 있다.
6. 주의할 점
distinctUntilChanged는 객체나 배열처럼 참조 타입을 비교할 때 기본 비교 함수(===)를 쓰면 내용이 같아도 참조가 다르면 중복으로 인식하지 못한다. 이 경우 비교 함수를 직접 넘기거나, 필요한 속성만 뽑아 비교하도록 설계해야 한다. 또한 "연속된" 중복만 제거한다는 점을 오해하면 안 된다. 전체 스트림에서 유일한 값만 남기려면 별도의 로직이나 distinct 연산자를 검토해야 한다.
sample은 sampler가 신호를 보내는 시점에 소스가 아직 한 번도 값을 방출하지 않았다면 아무것도 나오지 않는다는 점을 주의해야 한다. 또한 소스의 값 변화가 sampler의 주기보다 훨씬 느리면 같은 값이 여러 번 반복 방출될 수 있으므로, 필요하다면 distinctUntilChanged와 함께 조합해서 사용하는 것을 고려해야 한다.
7. 핵심 정리
distinctUntilChanged는 바로 직전 값과 비교해 연속된 중복 방출을 걸러내는 필터링 연산자이고, sample은 별도의 sampler 옵저버블이 신호를 줄 때마다 소스의 최신 값을 꺼내는 타이밍 연산자다. 두 연산자는 목적이 다르지만 스트림의 방출 빈도를 줄여 불필요한 처리를 방지한다는 공통점이 있으며, 실무에서는 두 연산자를 함께 조합해 사용하는 경우도 많다.