Guider/Practical/PracticalDevGuide0003
Practical#03· 18분 읽기

PracticalDevGuide0003

RxJS Observable & 비동기 흐름 처리 배경 지식

list목차(28)
PRACTICAL DEV GUIDE · 03

RxJS Observable & 비동기 흐름 처리 배경 지식

Observable · Promise · async/await · Streams · Subject · Operators · Hot/Cold · Signals
비동기 흐름을 어떻게 모델링할지, 각 도구의 차이는 무엇인지 정리

대상: 비동기 흐름을 다듬으려는 프론트·백엔드 개발자 유형: 배경 지식 / 패러다임 비교 난이도: ★★★★☆

"비동기"는 한 단어지만 실제로는 단발 응답(API 호출), 연속 이벤트(스크롤, WebSocket 메시지), 파이프라인 변환(파일 스트림, 데이터 가공) 등 매우 다른 패턴을 모두 포함합니다. Promise·async/await만으로 충분한 상황도 있지만, 시간 축 위에서 흘러가는 값들의 흐름을 합성·필터링·취소해야 한다면 RxJS Observable처럼 스트림 추상화가 효과적입니다. 본 문서는 Observable을 중심으로 비동기 흐름 모델들을 비교·정리합니다.

비동기 흐름 모델 한눈에 보기

모델 값의 개수 시점 대표 사례
동기 함수 (T) 1개 즉시 일반 함수
Promise<T> 1개 미래 1회 fetch, DB 쿼리
Iterable<T> N개 즉시 (pull) 배열, 제너레이터
AsyncIterable<T> N개 미래 (pull) async generator, ReadableStream
Observable<T> N개 미래 (push) RxJS, 이벤트 스트림

Observable은 N개의 값을 push 방식으로 흘려보내는 스트림 추상화입니다.

1. RxJS Observable

시간 축 위의 값 스트림을 다루는 표준 추상화

어떤 기술인가

RxJS의 Observable은 "구독(subscribe)할 수 있는 값의 흐름"입니다. 한 Observable은 0개~다수의 next 값을 흘려보내고, 정상 종료(complete)나 에러(error)로 끝납니다. lazy(구독 시점에야 실행 시작)이며, cancellable(unsubscribe로 즉시 중단)하다는 점이 Promise와 결정적으로 다릅니다.

핵심 특성

  • Lazy — 구독하지 않으면 실행되지 않음 (Promise는 즉시 실행)
  • Multi-value — 0~∞ 개의 값을 시간차로 emit
  • Cancellable — unsubscribe로 진행 중인 작업을 취소 (HTTP, 타이머, 리스너 정리)
  • Composable — 100+ 개의 operator로 변환·합성·시간 제어
  • Push 모델 — 데이터가 준비되는 즉시 구독자에게 흘려보냄

어떨 때 쓰이는가

  • UI 이벤트 디바운스/쓰로틀, 자동완성 입력 처리
  • WebSocket·SSE 메시지 스트림 가공
  • 여러 비동기 소스를 합성(combineLatest, withLatestFrom 등)
  • 요청 취소가 필요한 경쟁 상태 (검색어 변경 시 직전 요청 취소)
  • Angular(공식 채택), NestJS, Redux Observable 미들웨어

장점

  • 이벤트·시간·취소·합성을 한 추상화로
  • 선언적 파이프라인 (`pipe(map, filter, switchMap)`)
  • 경쟁 상태(race condition) 처리가 자연스러움
  • 풍부한 operator 생태계, 학습 자산 재사용성↑

단점

  • 러닝 커브가 가파름 (operator 100+, Hot/Cold 등)
  • 잘못된 사용 시 메모리 누수 위험 (구독 정리 누락)
  • 단발 비동기에는 과한 추상화
  • 스택 트레이스가 길고 디버깅 난이도 ↑

⚠ 사용 시 유의점

  • 구독 정리 필수 — 컴포넌트 unmount 시 unsubscribe 또는 takeUntil/Subject 패턴
  • switchMap vs mergeMap vs concatMap vs exhaustMap 선택 — 의미가 완전히 다르므로 의도에 맞게
  • Hot/Cold 차이를 모르면 멀티 구독 시 중복 호출 발생
  • 단발 fetch는 그냥 Promise/async가 더 단순할 수 있음 → 기준 없이 모든 것을 Observable화하지 말 것
  • RxJS 7+ firstValueFrom / lastValueFrom으로 Promise와 상호 변환

2. Promise

단발 비동기의 표준

어떤 기술인가

미래에 정확히 한 번의 결과(성공/실패)가 도착함을 약속하는 객체. 생성 즉시 실행되며(eager), 한 번 settle된 결과는 변하지 않습니다(immutable). ES2015부터 표준이며 모든 비동기 API의 기본 단위가 되었습니다.

Observable과의 차이

항목 Promise Observable
실행 시점 생성 즉시(eager) 구독 시(lazy)
값 개수 정확히 1 0~∞
취소 가능 기본 불가 (AbortController 별도) unsubscribe로 즉시
합성 .then 체이닝, Promise.all 등 100+ operator

⚠ 사용 시 유의점

  • Promise는 일단 실행되면 결과를 되돌릴 수 없음 — 취소가 필요하면 AbortController 명시적으로 사용
  • 병렬 실행: Promise.all (모두 성공) / Promise.allSettled (실패 포함) 구분
  • unhandled rejection 누락은 Node 18+에서 프로세스 종료를 유발할 수 있음

3. async / await

Promise를 동기처럼 쓰게 해주는 문법 설탕

어떤 기술인가

async 함수는 항상 Promise를 반환하고, await는 Promise가 settle될 때까지 함수 실행을 일시 중단합니다. 본질은 Promise 위의 syntactic sugar이지만, 코드 가독성·에러 처리(try/catch)·디버깅 측면에서 큰 이점을 제공합니다.

장점

  • 동기 코드처럼 자연스러운 흐름
  • try/catch로 일관된 에러 처리
  • 스택 트레이스 보존

단점·주의

  • 순차 await 남발 시 성능 저하 (병렬 가능한 것은 Promise.all)
  • 스트림성 데이터엔 부적합 (단발 결과 모델)
  • 루프 안에서 await + 외부 자원 사용 시 동시성 통제 어려움

⚠ 사용 시 유의점

  • 병렬 가능한 작업을 순차 await로 풀지 말 것 — Promise.all로 묶어야 합산 지연 감소
  • top-level await는 ESM에서만 가능 — CJS 호환성 확인
  • 대량 동시 작업은 p-limit 같은 동시성 제한 도구 권장

4. Node.js Streams & AsyncIterable

대용량·연속 데이터를 위한 풀 모델 스트림

어떤 기술인가

Node.js의 Readable/Writable/Transform Stream은 대용량 데이터를 청크 단위로 처리하기 위한 표준입니다. ES2018부터는 AsyncIterable(`for await...of`)로 더 간결히 소비할 수 있고, 웹 표준 ReadableStream(WHATWG)도 fetch·서비스 워커 등에서 같은 추상을 공유합니다.

Observable과의 차이

항목 Stream/AsyncIterable Observable
소비 방식 Pull (소비자가 요청) Push (생산자가 흘려보냄)
백프레셔 Stream API에 내장 직접 처리 (buffer/throttle)
합성 pipe / pipeline 100+ operator
대표 사용처 파일 I/O, HTTP 본문, 변환 파이프라인 UI 이벤트, 시간 합성, 취소

⚠ 사용 시 유의점

  • 대용량 파일·네트워크 데이터엔 Stream이 메모리·백프레셔 측면에서 우월
  • Observable과 Stream은 from(asyncIterable) 등으로 상호 변환 가능
  • Node Stream은 에러 전파가 까다로워 stream.pipeline 사용 권장

5. Subject 계열

Observable이자 Observer — 직접 발행 가능한 멀티캐스트 채널

Subject는 스스로 next/error/complete를 호출할 수 있는 특수 Observable입니다. 일반 Observable이 구독자마다 독립 실행(unicast)되는 반면, Subject는 모든 구독자에게 동일 값을 전파(multicast)합니다. 이벤트 버스, 상태 브로드캐스트, 컴포넌트 간 통신에 자주 쓰입니다.

유형 특징 언제 사용
Subject 기본형. 구독 이후 발행되는 값만 받음 단순 이벤트 버스
BehaviorSubject 현재 값을 보관 → 구독 즉시 최신 값 1개 push UI 상태, 로그인 사용자, 토글
ReplaySubject 과거 N개 값을 새 구독자에게 재전달 최근 알림 N건, 채팅 히스토리 캐시
AsyncSubject complete 시점의 마지막 값만 전달 최종 결과만 의미 있는 작업 (HTTP 단발)

⚠ 사용 시 유의점

  • 외부에 Subject 자체를 노출하지 말고 .asObservable()로 읽기 전용으로 제공
  • BehaviorSubject 초기값은 의미 있는 기본값이어야 함 — null·undefined 남발 시 분기 폭증
  • 완료(complete) 후엔 새 값이 무시됨 — 재사용하려면 새 Subject 생성

6. 핵심 Operator 카테고리

Observable을 변환·합성·시간 제어하는 함수들

분류 대표 operator 용도
변환 map, scan, pluck, bufferTime 값 가공·누적·묶기
필터링 filter, take, takeUntil, distinctUntilChanged 조건 통과·종료 신호
합성 combineLatest, withLatestFrom, merge, zip, forkJoin 여러 스트림 합치기
평탄화 (Flatten) switchMap, mergeMap, concatMap, exhaustMap 중첩 Observable 풀기 — 의미 차이 중요
시간 debounceTime, throttleTime, auditTime, sampleTime 속도 제한·간격 조정
에러·재시도 catchError, retry, retryWhen 실패 복구·백오프

⚠ 평탄화 operator 의미 차이 (실수 빈발)

  • switchMap — 새 값이 오면 이전 inner 구독을 취소. 검색·자동완성에 적합.
  • mergeMap — 모든 inner를 동시에 처리. 순서 보장 X. 병렬 처리에 적합.
  • concatMap — 앞 inner가 끝날 때까지 대기. 순서 중요한 큐잉에 적합.
  • exhaustMap — 진행 중이면 새 값 무시. 중복 클릭 방지·로그인 버튼 등.

7. Hot vs Cold Observable

멀티 구독 시 동작이 갈리는 결정적 개념

유형 동작 예시
Cold 구독마다 데이터 생성을 새로 시작 (unicast). 구독자별 독립 실행 HTTP 요청, of, from, interval(기본)
Hot 하나의 데이터 소스를 모두 공유 (multicast). 늦게 구독하면 과거 값 못 받음 DOM 이벤트, WebSocket, Subject

⚠ 실무에서 자주 보이는 함정

  • HTTP Observable을 여러 곳에서 구독 → 중복 호출 발생. share() / shareReplay()로 multicast화
  • shareReplay는 기본적으로 refCount=false라 마지막 구독 해제 후에도 소스가 살아 있음 → 누수 가능. RxJS 7+ 옵션 { bufferSize: 1, refCount: true } 권장
  • "왜 두 번 호출되지?" 디버깅의 90%는 Cold/Hot 인식 부족이 원인

8. 다른 Reactive 패러다임들

Observable이 유일한 답은 아니다

기술 특징·차이
Signals (Solid, Angular 17+, Vue 3) 세밀한(fine-grained) 반응성. 의존 추적 자동, 구독 관리 불필요. 단발 상태에 가깝고 시간 합성·취소는 약함.
MobX Observable 상태 + autorun. 객체 가변성 기반 반응형. UI 상태 관리에 강하나 스트림 합성은 RxJS 대비 부족.
Redux + Observable (redux-observable) Redux의 사이드 이펙트를 Observable epic으로 처리. RxJS 합성·취소 강점을 그대로 사용.
React Query / TanStack Query 서버 상태(데이터 fetching) 전용 솔루션. 캐싱·재시도·무효화에 특화. RxJS와 보완 관계.
EventEmitter / Pub-Sub 단순 이벤트 채널. 합성·취소·시간 제어가 없어 복잡한 흐름엔 부족. Subject가 상위 호환.

🛠 실습 — Observable로 자동완성 + 상태 채널 만들기

로컬에서 RxJS를 설치해 Observable·Operator·Subject·Hot/Cold를 하나씩 손으로 확인합니다.

사전 준비

  • Node.js 20+ (또는 브라우저 + StackBlitz)
  • TypeScript는 선택 (예제는 JS)
mkdir rxjs-lab && cd rxjs-lab
npm init -y
npm i rxjs
# package.json에 "type": "module" 추가

STEP 1 — Observable 만들고 구독하기

// 01-basic.mjs
import { Observable } from 'rxjs';

const counter$ = new Observable((subscriber) => {
  let i = 0;
  const id = setInterval(() => subscriber.next(++i), 500);
  return () => { clearInterval(id); console.log('cleaned up'); };
});

const sub = counter$.subscribe((v) => console.log('A:', v));
setTimeout(() => sub.unsubscribe(), 2000); // 취소 → cleanup 로그

Observable이 lazy(구독 시 실행)이고 cancellable(unsubscribe로 cleanup)인 점을 직접 확인하세요.

STEP 2 — pipe + map/filter

// 02-pipe.mjs
import { interval } from 'rxjs';
import { map, filter, take } from 'rxjs/operators';

interval(300).pipe(
  filter((n) => n % 2 === 0),
  map((n) => n * n),
  take(5),
).subscribe((v) => console.log(v)); // 0, 4, 16, 36, 64

STEP 3 — 자동완성 (debounceTime + switchMap)

// 03-autocomplete.html — 브라우저
<input id="q" placeholder="검색어..." />
<ul id="out"></ul>
<script type="module">
import { fromEvent } from 'https://esm.sh/rxjs';
import { debounceTime, distinctUntilChanged, map, switchMap, catchError } from 'https://esm.sh/rxjs/operators';
import { of } from 'https://esm.sh/rxjs';

const input = document.getElementById('q');
const out = document.getElementById('out');

fromEvent(input, 'input').pipe(
  map((e) => e.target.value.trim()),
  debounceTime(300),
  distinctUntilChanged(),
  switchMap((q) => q
    ? fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json())
    : of([])),
  catchError((err) => { console.error(err); return of([]); }),
).subscribe((items) => {
  out.innerHTML = items.map((i) => `<li>${i.title}</li>`).join('');
});
</script>

핵심은 switchMap: 새 입력이 오면 직전 fetch가 자동 취소돼 경쟁 상태가 사라집니다. mergeMap으로 바꾸면 모든 요청이 살아남고 응답 순서가 뒤섞입니다 — 직접 비교해보세요.

STEP 4 — BehaviorSubject로 상태 채널

// 04-store.mjs
import { BehaviorSubject } from 'rxjs';
import { map, distinctUntilChanged } from 'rxjs/operators';

const user$ = new BehaviorSubject({ id: 0, name: 'guest' });

// 외부엔 읽기 전용으로
export const user = user$.asObservable();
export const setUser = (u) => user$.next(u);

// 파생 스트림
user.pipe(
  map((u) => u.name),
  distinctUntilChanged(),
).subscribe((name) => console.log('name changed:', name));

setUser({ id: 1, name: 'alice' });
setUser({ id: 1, name: 'alice' }); // 중복 → 무시
setUser({ id: 2, name: 'bob' });

STEP 5 — Hot/Cold + shareReplay

// 05-share.mjs
import { defer } from 'rxjs';
import { shareReplay, tap } from 'rxjs/operators';

const cold$ = defer(() => {
  console.log('🔥 work started');
  return Promise.resolve(Math.random());
});

// 구독마다 새로 실행 → "work started"가 두 번 출력
cold$.subscribe((v) => console.log('A', v));
cold$.subscribe((v) => console.log('B', v));

// shareReplay로 multicast → "work started"1회, 두 구독자는 같은 값
const shared$ = cold$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
shared$.subscribe((v) => console.log('A2', v));
shared$.subscribe((v) => console.log('B2', v));

refCount: true가 핵심 — 마지막 구독 해제 시 소스도 정리됩니다. 빠뜨리면 장기 누수의 원인이 됩니다.

STEP 6 — Promise ↔ Observable 상호 변환

import { from, firstValueFrom } from 'rxjs';

// Promise → Observable
const obs$ = from(fetch('/api/me').then((r) => r.json()));

// Observable → Promise (첫 값)
const value = await firstValueFrom(obs$);

관찰 포인트

  • switchMap / mergeMap / concatMap을 같은 스트림에 끼워넣어 의미 차이 직접 비교
  • 구독 정리(unsubscribe·takeUntil) 누락 시 장기 실행에서 메모리 사용량 증가 확인
  • shareReplay에 refCount 옵션 유무에 따라 소스 정리 시점이 어떻게 달라지는지
  • 단발 fetch 한 번이면 그냥 async/await가 더 단순함 — 굳이 Observable로 감싸지 말 것

의사결정 가이드 — 무엇을 언제 쓸까

상황 권장 이유
단발 API 호출 / 직선적 비동기 흐름 Promise + async/await 가장 단순, 도구도 풍부
검색 자동완성, 디바운스/취소 필요 RxJS Observable + switchMap 시간 제어·취소가 자연스러움
WebSocket·SSE 메시지 가공·합성 RxJS Observable 멀티 스트림 합성에 강함
대용량 파일·HTTP 본문 처리 Streams / AsyncIterable 백프레셔, 메모리 효율
서버 데이터 fetching·캐싱 React/TanStack Query 서버 상태 전용 솔루션이 더 효율
UI 상태 (선택, 입력, 토글) Signals / 로컬 state 스트림보다 단발 상태가 적합
컴포넌트 간 이벤트 브로드캐스트 Subject / BehaviorSubject 멀티캐스트 + 현재값 보관

🎯 핵심 요약

  • Observable — Lazy + Multi-value + Cancellable + Composable인 push 스트림
  • Promise / async-await — 단발 비동기의 표준. 직선적 흐름엔 가장 단순
  • Streams / AsyncIterable — 대용량·연속 데이터의 풀 모델, 백프레셔 내장
  • Subject 계열 — 직접 발행 가능한 멀티캐스트 채널 (BehaviorSubject가 가장 실용적)
  • Operator — 시간·합성·취소·에러 복구를 선언적으로
  • Hot/Cold를 모르면 디버깅 지옥. share/shareReplay로 멀티캐스트 명시
  • Signals·Query — 모든 비동기를 Observable로 만들 필요는 없다. 도메인에 맞는 도구 선택

Observable의 본질은 "값의 흐름"을 일급 객체로 다루는 것이다.
단발 결과로 충분한 곳에 굳이 스트림을 끌어들이지 말고,
흐름의 합성·취소가 정말 필요한 곳에서 그 강력함을 활용하자.