"비동기"는 한 단어지만 실제로는 단발 응답(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의 본질은 "값의 흐름"을 일급 객체로 다루는 것이다.
단발 결과로 충분한 곳에 굳이 스트림을 끌어들이지 말고,
흐름의 합성·취소가 정말 필요한 곳에서 그
강력함을 활용하자.