목차13개 항목
출처와 참고
테오(teo)의 「프로그래밍 패러다임과 반응형 프로그래밍 그리고 Rx」(2022.02)와 「Rxjs 한번 배워보실래요?」(2022.10)를 바탕으로 재구성한 노트다. 의존성 추적의 원리는 「0.7KB로 Vue와 같은 반응형 시스템 만들기」를 바탕으로 더했다. 요청·응답을 스트림으로 다루는 예와 flatMap 계열 연산자는 「The introduction to Reactive Programming you've been missing」을 바탕으로 RxJS 7 연산자로 옮겨 보강했다. 배압과 RxJava 스케줄러는 4z7l의 「tech_interview.zip」 RxJava 문서를 바탕으로 보강했다.
패러다임이란
패러다임은 같은 현상을 보는 관점이다. 천동설과 지동설은 둘 다 천체 운동을 설명할 수 있었고, 지동설이 이긴 것은 더 간결해서다. 객체 지향은 프로그램을 상호작용하는 객체의 집합으로, 함수형은 상태 없는 함수의 연속으로 보게 한다. 여러 관점을 알면 그중 더 단순하게 설명하는 쪽을 고를 수 있다. JavaScript는 여러 패러다임이 공존하는 언어라 특히 그렇다.
패러다임이 먼저 있고 라이브러리가 따라 나온 것이 아니다. 문제를 푼 도구들이 먼저 나오고, 그 공통점을 정리한 것이 패러다임이다.
반응형 프로그래밍이란
반응형 프로그래밍(Reactive Programming)은 데이터의 흐름과 변경 사항의 전파에 초점을 둔 선언적 프로그래밍 패러다임이다.
가장 친숙한 예는 스프레드시트다. C1에 =A1+B1을 선언해 두면 A1이 바뀔 때 C1이 알아서 바뀌고, C1을 쓰는 D1까지 따라 바뀐다. 실행 순서를 정의한 적이 없는데 데이터가 흘러간다.
웹에서 이 패러다임이 중요해진 계기는 jQuery에서 웹 프레임워크로의 전환이다. "DOM을 쉽게 조작하게 돕는다"에서 "DOM을 알아서 그려 준다"로 바뀌었다.
변경 감지 + 바인딩 + 템플릿 + 자동 렌더 = Reactive
값이 바뀌면, 템플릿에 선언해 둔 대로, 알아서 다시 그린다이후 Redux 같은 상태 관리도 변경을 감지하고 전파하며 선언적으로 값을 만든다는 점에서 같은 패러다임에 속한다. 뷰에 쓰이던 관점이 비즈니스 로직까지 넓어진 셈이다.
두 가지 전환
| 전환 | 전 | 후 |
|---|---|---|
| 선언적 프로그래밍 | DOM을 어떻게 바꿀지 명령한다 | 무엇을 보여 줄지 선언하고, 언제·어떻게는 내부가 처리한다 |
| Pull → Push | 그릴 때가 되면 필요한 데이터를 끌어온다 | 값이 바뀔 때마다 구독자에게 밀어 준다 (setState를 떠올리면 된다) |
왜 프론트엔드에 필요한가: 비동기
프론트엔드는 비동기 덩어리다. 사용자가 언제 무엇을 할지, 서버가 언제 답할지 모른다. 비동기가 어려운 이유는 이렇다.
- 쓴 순서대로 실행되지 않는다.
- 언제 실행될지, 호출 순서대로 끝날지 보장이 없다.
- 호출할 때의 값이 실행될 때도 그대로라는 보장이 없다.
콜백 → Promise → async/await는 비동기를 동기 코드처럼 보이게 하는 방향으로 발전했다(async/await). 그런데 "타이핑할 때마다 검색하되, 입력이 멈추면 요청하고, 같은 내용이면 건너뛰고, 7초 안에 답이 없으면 3번 재시도하고, 엔터를 누르면 진행 중 요청을 버린다" 같은 요구는 순서를 맞추는 Pull 방식으로는 금세 복잡해진다. 사용자와 서버의 비동기가 뒤섞여 있기 때문이다.
제어의 역전
모듈 Foo가 네트워크 요청을 받으면 Bar의 카운터를 올린다고 하자. Foo가 Bar.incrementCounter()를 부르면 상태를 바꾸는 로직은 Bar에 있는데 호출은 Foo가 쥔다. Bar는 수동적이 되고 둘은 강하게 묶인다.
화살표의 방향은 그대로 두고 소유권을 뒤집는다. Bar가 Foo의 이벤트를 구독하고 자기 상태는 자기가 바꾼다. 이것이 제어의 역전(Inversion of Control)이다.
// Bar.js: 상태 변경은 내 안에서
Foo.onNetworkRequest((event) => incrementCounter(event.value));Foo는 누구에게 어떻게 전달할지 몰라도 되고 값이 생기면 내보내기만 한다. Promise의 resolve, DOM 이벤트, 옵저버 패턴, Pub/Sub이 모두 같은 구조다. 반응형 프로그래밍은 이 방식을 일부가 아니라 모든 코드에 먼저 적용하자(Reactive First) 는 관점이다. 그러면 모듈 하나는 화살표 하나만 신경 쓰면 된다.
이벤트를 배열처럼: 스트림
배열은 같은 타입의 값이 여러 개다. 클릭 이벤트도 같은 타입의 값이 여러 개다. 다만 아직 오지 않았을 뿐이다. 그렇다면 for 대신 map·filter로 배열을 선언적으로 다루듯 이벤트도 선언적으로 다룰 수 있다. 이벤트를 하나의 값으로 보는 것, 이것이 스트림이다.
| 값 하나 | 값 여러 개 | |
|---|---|---|
| 동기 | 값 | Array |
| 비동기 | Promise | Observable |
반응형 프로그래밍 = 변경 전파 + 데이터 흐름 + 선언적
= 이벤트 + 선언적
= 스트림 + 선언적 ("Everything is a stream")각 노드를 스트림으로 정의하고 선언적으로 연결하면, 전체 흐름이 복잡해도 노드 하나하나는 단순하다. 이 데이터 흐름(data-flow)은 함수형 프로그래밍의 파이프라인으로 구현된다.
RxJS
RxJS는 공식 문서 표현대로 "이벤트용 Lodash"다. 비동기 이벤트와 시간을 배열처럼 다루게 해 주는 라이브러리이고, 그 중심이 Observable이다. Observable은 Promise로 만들 수 있는 것을 모두 만들 수 있지만 반대는 안 된다. Promise는 값을 하나만 받고 정리(cleanup)를 못 한다.
const fromEvent = (target, type) => new Observable((observer) => {
const handler = (e) => observer.next(e);
target.addEventListener(type, handler);
return () => target.removeEventListener(type, handler); // 구독 해제 시 정리
});
// 0.25초 안에 세 번 클릭하면 트리플 클릭
fromEvent(window, 'click').pipe(
bufferTime(250),
filter((clicks) => clicks.length === 3),
).subscribe(onTripleClick);이벤트는 next, error, complete 세 종류로 흐른다. try·catch·finally와 비슷하게 보면 된다.
요구사항은 대부분 "~하면 ~한다"다
앱 코드의 대부분은 API 사용법보다 "참여자가 10명을 넘으면 마이크를 끈다" 같은 조건과 시점의 시나리오다. RxJS는 이 "~하면"을 로직의 조합이 아니라 값의 변환(참여자 수 변화 → 이전 값과 비교 → 2초 버퍼 → 토스트)으로 쓰게 한다.
요청과 응답도 스트림이다
"새로고침을 누르면 추천 사용자 3명을 새로 받아 보여 준다" 같은 화면을 스트림으로 짜면, 클릭·요청·응답·화면 상태가 모두 스트림이고 코드는 그 사이의 변환만 남는다.
const refreshClick$ = fromEvent(refreshButton, 'click')
const request$ = refreshClick$.pipe(
startWith(null), // 처음 한 번은 클릭 없이도 요청
map(() => `https://api.github.com/users?since=${Math.floor(Math.random() * 500)}`),
)
const response$ = request$.pipe(
switchMap((url) => fetch(url).then((r) => r.json())), // 스트림의 스트림을 펴서 응답 스트림 하나로
)
const suggestion1$ = merge(
refreshClick$.pipe(map(() => null)), // 누르는 즉시 비운다
response$.pipe(map((users) => users[Math.floor(Math.random() * users.length)])),
).pipe(startWith(null))
suggestion1$.subscribe(renderSuggestion1)- 요청 URL 하나마다 응답이 오는 비동기 스트림이 하나씩 생긴다. 이 스트림의 스트림(메타스트림) 을 하나로 펴는 것이
flatMap계열 연산자다. 펴는 방식이 다르다.
| 연산자 | 새 요청이 오면 | 쓰는 곳 |
|---|---|---|
mergeMap(옛 flatMap) | 이전 요청도 그대로 두고 모두 흘린다 | 순서와 무관한 병렬 작업 |
switchMap | 이전 요청을 취소하고 최신 것만 | 검색어 자동완성, 새로고침: 늦게 도착한 옛 응답이 화면을 덮는 경쟁 상태를 막는다 |
concatMap | 이전 것이 끝날 때까지 줄 세운다 | 순서가 중요한 저장 요청 |
exhaustMap | 진행 중이면 새 것을 무시한다 | 중복 제출 방지 |
- 화면 조각 하나(추천 1번 칸)의 상태는 "클릭하면 비운다"와 "응답이 오면 채운다"를
merge한 하나의 스트림이다. 닫기 버튼처럼 다른 스트림의 최신 값이 함께 필요하면combineLatest로 묶는다.
절차형과의 차이는 "누가 언제 무엇을 바꾸나"를 어디에 적느냐다. 절차형은 클릭 핸들러 안에 요청·로딩 표시·결과 반영을 차례로 적고, 반응형은 각 값이 무엇으로부터 만들어지는지를 선언하고 실행 순서는 스트림에 맡긴다.
실무에서 막히는 지점
| 지점 | 내용 | 대응 |
|---|---|---|
| pipe 문법 | 5.5부터 메서드 체인 대신 pipe(map(), filter()). 메서드는 트리 셰이킹이 안 되기 때문이다 | 함수 합성으로 이해한다 |
| 연산자보다 소스가 문제 | 이벤트는 프레임워크 핸들러가, Ajax는 값 하나라 Promise가 이미 맡고 있다. 연산자를 배워도 쓸 Observable이 없다 | 계속 값이 들어오는 것부터 감싼다. 실시간 DB(Firebase) 구독이 좋은 첫 소스다 |
| Hot과 Cold | Promise는 만들자마자 실행되고 결과를 공유한다(Hot). Observable은 기본이 지연 평가라 구독할 때마다 새로 실행된다(Cold) | API를 Observable로 감싸 여러 곳에서 구독하면 요청이 중복된다. 공유가 필요하면 Subject를 쓴다 |
| Subject 고르기 | 시점이 중요한 이벤트·동작 → Subject, 마지막 결과를 계속 공유할 값 → BehaviorSubject | 상태 관리의 기반이 된다 |
| 배압(backpressure) | 생산 속도를 소비가 못 따라가면 버퍼가 쌓여 메모리가 터진다(OOM). RxJS는 배압 프로토콜이 없다 | RxJS는 throttleTime·debounceTime·sampleTime·bufferTime으로 흘려보낼 양을 줄인다. RxJava는 배압을 모르는 Observable과 따로, 소비자가 요청한 만큼만 받는 Flowable(전략: BUFFER·DROP·LATEST)을 두고, Kotlin Flow는 수집이 끝날 때까지 생산이 멈추는 일시 중단으로 배압을 처리한다 |
| 스레드 고르기(RxJava) | subscribeOn은 소스가 도는 스레드, observeOn은 그 뒤 연산자가 도는 스레드를 정한다 | I/O는 스레드를 재사용하는 Schedulers.io(), 계산은 코어 수만큼인 Schedulers.computation(). 요청마다 스레드를 만드는 newThread()는 스레드가 폭증할 수 있다 |
의존성 추적: 반응형 상태의 원리
Rx가 이벤트 흐름을 다룬다면, Vue의 반응성·MobX·시그널(Solid, Angular Signals, Svelte 5 runes)은 값을 다룬다. 「total은 price × qty다」라고 선언해 두면, price가 바뀔 때 total과 그것을 그리는 화면이 저절로 갱신된다. 이벤트 방식처럼 「price가 바뀌면 total을 다시 계산하라」는 리스너를 손으로 달지 않는다. 누가 무엇에 의존하는지를 실행하면서 기록하기 때문이다.
두 동작이 전부다.
| 동작 | 언제 | 하는 일 |
|---|---|---|
| 의존성 수집(track) | 속성을 읽을 때(getter) | 지금 실행 중인 관찰자(effect)를 그 속성의 구독자 목록에 넣는다 |
| 변경 통지(trigger) | 속성을 쓸 때(setter) | 그 속성의 구독자를 다시 실행한다 |
let current = null // 지금 실행 중인 관찰자
function observable(obj) {
const subs = new Map() // 속성 → 관찰자 Set
return new Proxy(obj, {
get(target, key) {
if (current) {
if (!subs.has(key)) subs.set(key, new Set())
subs.get(key).add(current) // track
}
return target[key]
},
set(target, key, value) {
target[key] = value
subs.get(key)?.forEach((fn) => fn()) // trigger
return true
},
})
}
function observe(fn) {
const run = () => { const prev = current; current = run; try { fn() } finally { current = prev } }
run()
}
const cart = observable({ price: 1000, qty: 2 })
observe(() => { totalEl.textContent = cart.price * cart.qty }) // 처음 실행하며 price·qty를 구독
cart.qty = 3 // 자동으로 다시 그린다Proxy 대신 Object.defineProperty로 속성마다 getter·setter를 거는 방식도 있다. Vue 2가 그랬고, 그래서 나중에 추가한 속성과 배열 인덱스 변경을 감지하지 못했다. Vue 3는 Proxy로 객체 전체를 가로채 이 한계를 없앴다(Proxy와 Reflect).
장난감 구현을 실제로 쓰려면 다음을 더 풀어야 한다.
| 문제 | 해결 |
|---|---|
첫 실행 때 타지 않은 if 분기의 값은 구독되지 않는다 | 관찰자를 다시 실행할 때마다 구독을 지우고 새로 수집한다 |
| 관찰자 안에서 다른 관찰자가 돈다(계산 값이 계산 값을 읽는다) | current를 스택으로 관리한다(위 코드의 prev) |
| 사라진 컴포넌트의 관찰자가 남아 메모리가 샌다 | observe가 구독 해제 함수를 돌려주고 언마운트 때 부른다 |
| 계산 값(computed)을 매번 다시 계산한다 | getter를 관찰자로 감싸 결과를 캐시하고, 입력이 바뀔 때만 무효로 한다 |
| 값 여러 개를 바꾸면 그때마다 다시 그린다 | 통지를 모아 마이크로태스크에서 한 번에 실행한다(batch) |
| 10만 건 배열의 원소마다 Proxy를 씌우면 느리다 | 큰 컬렉션은 반응형으로 만들지 않고 통째로 교체하거나 수동으로 통지한다(Vue의 shallowRef, MobX의 observable.ref) |
React는 이 방식을 쓰지 않는다. 상태를 바꾸면 컴포넌트를 다시 실행해 결과를 비교한다. 두 모델의 차이는 React와 Vue에 있다. 시그널을 언어 표준으로 넣자는 TC39 제안도 진행 중이다. 이벤트 쪽에서는 addEventListener 대신 Observable을 돌려주는 EventTarget.when()(WICG Observable 제안)이 Chrome에 들어갔지만, 다른 브라우저가 지원하지 않아 Baseline이 아니다.
한계
- 학습 곡선이 높아 범용 상태 관리의 주류는 되지 못했다. Angular가 RxJS를 기본 의존성으로 써 왔지만, 동기 상태는 Signals로 옮기고 RxJS는 비동기 이벤트 흐름에 쓰는 쪽으로 역할이 나뉘었다.
- 과거에는 Observable을 TC39(언어 표준)에 넣으려 했으나 오래 진척이 없었고, 이후 WHATWG DOM 표준의 Observable API로 방향을 틀었다. Chrome 135(2025)가
element.when('click')처럼 이벤트를 Observable로 받는 API를 처음 출시했다. 다른 브라우저 지원은 MDN에서 확인한다. 파이프라인 연산자|>는 아직 표준이 아니다. - 반응형 프로그래밍은 Rx가 아니다. Rx는 패러다임의 대표 구현일 뿐이고, React·Redux·TanStack Query·Svelte·시그널도 같은 관점으로 읽을 수 있다.
정리
- 반응형 프로그래밍은 선언해 두면 변경이 알아서 전파되는 관점이다. 스프레드시트와 웹 프레임워크가 그 예다.
- 핵심 전환은 명령 → 선언, Pull → Push, 그리고 제어의 역전이다.
- 프론트엔드의 어려움은 뒤섞인 비동기와 "~하면 ~해 주세요" 시나리오에 있다. 이벤트를 값으로, 스트림으로 보면 단순해진다.
- RxJS의 Observable은 비동기 컬렉션이다. 실무에서는 연산자보다 소스 만들기와 Hot/Cold가 먼저다.
관련 노트: 함수형 프로그래밍, 콜백과 Promise, async/await, 이벤트 루프, 프론트엔드 MV* 아키텍처의 흐름, Proxy와 Reflect
원본 기록6
- 원문 (새 창) velog.io/@teo/reactive-programming
- 원문 (새 창) velog.io/@teo/rxjs
- 원문 (새 창) ui.toast.com/weekly-pick/ko_20190531/
- GitHub 원문 (새 창) gist.github.com/staltz/868e7e9bc2a7b8c1f754
- 원문 (새 창) developer.mozilla.org/en-US/docs/Web/API/EventTarget/when
- GitHub 원문 (새 창) github.com/4z7l/tech_interview.zip/blob/main/직무/RxJava.md