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

map, filter, tap, scan: 스트림의 값을 변환하고 관찰하고 누적하는 네 가지 기본 연산자

map, filter, tap, scan은 RxJS에서 가장 먼저 익히게 되는 파이프라인용 연산자(operator)다. 각각 값 변환, 값 걸러내기, 부수 효과(side effect) 관찰, 누적 상태 계산이라는 서로 다른 역할을 맡는다. 이 네 가지를 조합하면 복잡한 비동기 로직도 선언적(declarative)으로 표현할 수 있다. 배열 메서드에 익숙한

송민성7분 읽기

1. 개념

map, filter, tap, scan은 모두 파이프 가능 연산자(pipeable operator)로, pipe() 안에서 사용되어 옵저버블(Observable)이 방출하는 값을 가공한다.

  • map: 방출되는 각 값을 변환 함수(projection function)에 통과시켜 새로운 값으로 바꾼다.
  • filter: 조건 함수(predicate function)의 결과가 참인 값만 통과시킨다.
  • tap: 값을 변경하지 않고 그대로 흘려보내면서, 로깅이나 디버깅 같은 부수 효과(side effect)를 실행한다.
  • scan: 이전 누적값(accumulator)과 현재값을 조합해 새로운 누적값을 만들고, 그 결과를 매번 방출한다.

배열의 map, filter, reduce와 개념적으로 동일하지만, 배열은 이미 존재하는 값의 집합을 한 번에 처리하는 반면 이 연산자들은 시간에 따라 하나씩 도착하는 값을 그때그때 처리한다는 점이 다르다.

2. 왜 사용하는가

비동기 스트림을 다루다 보면 "들어온 데이터를 다른 형태로 바꾸고 싶다", "특정 조건의 데이터만 쓰고 싶다", "값 자체는 건드리지 않고 중간에 로그만 찍고 싶다", "지금까지 들어온 값을 합산하거나 누적하고 싶다"는 요구가 반복적으로 발생한다. 이 네 연산자는 콜백(callback)을 중첩하지 않고도 이런 요구를 파이프라인 형태로 선언적으로 표현하게 해준다. 명령형으로 상태 변수를 직접 관리하며 조건문을 쓰는 대신, 연산자를 이어 붙이는 것만으로 데이터 흐름을 읽을 수 있게 만든다.

3. 동작 원리

파이프 가능 연산자는 내부적으로 원본 옵저버블을 구독(subscribe)하고, 값이 도착할 때마다 정해진 로직을 수행한 뒤 새로운 옵저버블로 값을 재방출하는 구조를 가진다.

map(fn)은 원본에서 값 v가 도착하면 fn(v)를 계산해서 그 결과를 다음 단계로 넘긴다. 원본 스트림의 방출 횟수와 타이밍은 그대로 유지되고 값의 내용만 바뀐다.

filter(predicate)는 값 v가 도착하면 predicate(v)를 실행해서 참이면 그대로 흘려보내고 거짓이면 조용히 버린다. 따라서 필터를 거친 스트림은 원본보다 방출 횟수가 같거나 적어질 수 있다.

tap(fn)은 값 v가 도착하면 fn(v)를 실행하지만 그 반환값은 무시하고 원래의 v를 그대로 다음 단계로 전달한다. 즉 스트림의 데이터 흐름에는 전혀 개입하지 않고 관찰만 한다.

scan(accumulatorFn, seed)은 내부적으로 누적값을 기억한다. 초기값(seed)이 있으면 그 값에서 시작하고, 값 v가 도착할 때마다 accumulatorFn(누적값, v)를 실행해 새로운 누적값을 만들고 그것을 즉시 방출한다. reduce와 달리 스트림이 끝날 때까지 기다리지 않고 매 단계의 중간 결과를 내보낸다는 점이 핵심이다.

4. 예제

숫자를 방출하는 옵저버블을 만들고 네 연산자를 순서대로 파이프에 연결하는 상황을 생각해본다.

먼저 of(1, 2, 3, 4, 5)로 1부터 5까지의 값을 순서대로 방출하는 옵저버블을 만든다.

그다음 pipe() 안에서 filter(n => n % 2 === 0)를 사용해 짝수만 통과시킨다. 이 시점에서 2와 4만 남는다.

이어서 map(n => n * 10)을 붙여 남은 값을 10배로 바꾼다. 2는 20이 되고 4는 40이 된다.

그 사이에 tap(n => console.log('현재 값:', n))을 끼워 넣으면 값이 변형되지는 않지만 매 단계마다 콘솔에 현재 값이 찍힌다. 예를 들어 map 다음에 tap을 두면 "현재 값: 20", "현재 값: 40"이 순서대로 출력된다.

마지막으로 scan((acc, cur) => acc + cur, 0)을 연결하면 지금까지 흘러온 값을 누적한 합계가 매번 방출된다. 20이 들어오면 누적값 20이 방출되고, 이어서 40이 들어오면 누적값 60이 방출된다.

전체 흐름을 하나로 이으면, of(1,2,3,4,5).pipe(filter(n => n % 2 === 0), map(n => n * 10), tap(n => console.log(n)), scan((acc, cur) => acc + cur, 0)).subscribe(total => console.log('합계:', total)) 같은 형태가 되고, 구독(subscribe) 시점에 실제로 값이 흐르기 시작한다. 이 구독 콜백에서는 최종적으로 "합계: 20", "합계: 60"이 순서대로 출력된다.

5. 실무 사용 사례

map은 서버에서 받은 원시 데이터를 UI에서 쓰기 좋은 형태로 가공할 때 자주 쓰인다. 예를 들어 API 응답 객체에서 필요한 필드만 추출하거나 날짜 문자열을 Date 객체로 바꾸는 작업이 여기에 해당한다.

filter는 사용자 입력 스트림에서 빈 문자열이나 특정 길이 미만의 검색어를 걸러내 불필요한 요청을 막는 데 쓰인다. 예를 들어 검색창의 키 입력 이벤트 스트림에서 두 글자 미만은 무시하도록 만들 수 있다.

tap은 디버깅용 로그를 남기거나, 로딩 스피너를 켜고 끄는 것처럼 스트림의 값 자체와는 무관한 부수 효과(side effect)를 처리할 때 쓴다. 값을 변형하지 않는다는 특성 때문에 파이프라인 중간에 안전하게 끼워 넣을 수 있다.

scan은 웹소켓(WebSocket)으로 들어오는 이벤트를 누적해 실시간 카운터를 만들거나, 사용자의 클릭 스트림을 이용해 상태 관리(state management)를 구현할 때 활용된다. Redux의 리듀서(reducer) 패턴과 개념적으로 유사하다.

6. 주의할 점

mapfilter에 전달하는 함수는 순수 함수(pure function)로 작성하는 편이 예측 가능성을 높인다. 외부 상태를 변경하는 로직은 tap으로 분리하는 것이 역할 구분에 맞다.

tap 안에서 값을 직접 변경하려는 시도는 하지 않아야 한다. tap은 관찰을 위한 연산자이지 변환을 위한 연산자가 아니므로, 값을 바꾸고 싶다면 map을 사용해야 한다.

scan은 초기값(seed)을 생략할 수도 있지만, 생략하면 첫 방출값이 그대로 초기 누적값으로 쓰이기 때문에 누적 로직이 예상과 다르게 동작할 수 있다. 명시적으로 초기값을 지정하는 편이 안전하다.

scan은 구독이 유지되는 동안 누적값을 메모리에 계속 들고 있으므로, 스트림이 무한히 지속되고 누적값이 계속 커지는 구조라면 메모리 사용량을 주의 깊게 살펴야 한다.

7. 핵심 정리

map은 값을 변환하고, filter는 조건에 맞는 값만 통과시키고, tap은 값을 그대로 두면서 부수 효과만 실행하고, scan은 이전 상태와 현재값을 조합해 누적된 결과를 매번 방출한다. 네 연산자 모두 파이프(pipe) 안에서 조합해 사용하며, 배열 메서드인 map, filter, reduce의 개념을 시간 축 위의 스트림으로 확장한 것으로 이해하면 자연스럽게 익힐 수 있다.

© 2026 Tyler Song