RxJS와 리액티브 프로그래밍RxJS를 활용한 복잡한 비동기 로직 개선기작성일 2025.09.11페이지가 생성된 시간 2026.02.03 00:47:07
회사 서비스는 돔기반 그래픽 에디터이고 페이지들을 보여주기 위한 하단패널이 존재한다.
이 작은 페이지들을 전부 dom기반으로 그리면 전체페이지 돔 개수도 늘어나고 웹페이지가 무거워진다.
그래서 우리는 동시편집자들이 보고있는 페이지들만 돔으로 그리고 나머지는 썸네일 이미지로 보여주기로 했다. 이 과정에서 썸네일을 생성하는 타이밍과 과도한 썸네일 생성을 막기위한 처리들이 복잡하게 들어가기 시작했고 이 부분을 개선하게 되면서 배우고 느낀점들을 조금 담았다.
프론트엔드 개발을 하다 보면 단순한 데이터 fetching을 넘어 복잡한 비동기 로직을 처리해야 할 때가 있다. "사용자가 마우스를 뗐을 때", "3초 동안 입력이 없을 때", "이전 요청을 취소하고 싶을 때" 같은 요구사항들 말이다.
비동기 처리를 우아하게 해결해 주는 도구 RxJS와 반응형 프로그래밍(Reactive Programming) 의 핵심을 정리해 보려 한다. 왜 우리가 RxJS를 써야 하는지, 그리고 언제 써야 빛을 발하는지 깊이 있게 들여다보자.
반응형 프로그래밍, 도대체 뭔가?
반응형 프로그래밍은 데이터 스트림과 변경 사항의 전파를 다루는 선언적 프로그래밍 패러다임입니다 . 이 패러다임을 사용하면 정적(예: 배열) 또는 동적(예: 이벤트 이미터) 데이터 스트림 을 쉽게 표현할 수 있으며, 관련 실행 모델 내에 추론된 종속성이 존재함을 전달하여 변경된 데이터 흐름의 자동 전파를 용이하게 합니다
반응형 프로그래밍의 정의를 보니 데이터 스트림을 관리하고 자동 전파를 용이하게 하는 프로그래밍 패러다임으로 요약할 수 있다.
우리가 엑셀(Excel)을 쓸 때를 상상해 보자. A1 셀의 값이 바뀌면, 이를 참조하던 B1, C1 셀의 값이 자동으로 업데이트된다. 이것이 바로 반응형이다.
RxJS는 이러한 반응형 프로그래밍을 JavaScript에서 구현할 수 있게 해주는 라이브러리다. 핵심은 관찰 가능한 시퀀스(Observable) 를 사용하여 비동기 및 이벤트 기반 프로그램을 작성하는 것이다.
스트림(Stream)을 배열처럼 다루기
RxJS의 가장 큰 매력은 시간의 축을 가진 비동기 이벤트를 마치 배열(Collection)처럼 다룰 수 있다는 점이다.
- 배열: 공간에 나열된 데이터
- 스트림: 시간에 따라 들어오는 데이터
RxJS는 map, filter, reduce 같이 우리에게 익숙한 Array 메서드에서 영감을 받은 연산자(Operator)들을 제공한다. 덕분에 복잡한 시간 축의 로직을 직관적인 코드로 풀어낼 수 있다.
import { fromEvent, debounceTime } from 'rxjs'
const move$ = fromEvent(window, 'pointermove');
const debouncedMove$ = move$.pipe(debounceTime(50));
const subscription = debouncedMove$.subscribe(console.log);
subscription.unsubscribe();
Push vs Pull: RxJS는 무엇이 다른가?
최근 프런트엔드 생태계에는 SolidJS Signal, Jotai 같은 다양한 상태 관리 도구들이 존재한다. RxJS는 이들과 어떻게 다를까? 바로 데이터의 흐름 방식에 차이가 있다.
RxJS (Push 방식)
다른 언어들의 ReactiveX 구현은 Pull-based Backpressure 방식도 지원하지만 js는 싱글스레드이기 때문에 백프레셔가 없다
- 생산자(Producer)가 데이터를 밀어 넣으면(next), 소비자(Consumer)가 수동적으로 반응한다.
- "데이터가 언제, 어떻게 올지 모르니 흐름을 제어하자."
- 스트림의 흐름을 제어하는 데 중점을 둔다.
Jotai / SolidJS (Push-Pull 방식)
- 필요한 시점에 의존성 그래프를 따라 데이터를 가져온다(get).
- "그래서 지금 어떤 값이 되었는데?"
- 상태를 정의하고 상태 간의 의존성을 선언하는 데 중점을 둔다.
즉, RxJS는 데이터를 밀어넣으면 연결된 연산자가 실행되는 Push 방식이고, Atom 계열은 변경 감지(Push)를 받아 의존성 그래프를 타고 값을 Pull 하는 방식이다.
썸네일 생성 예제로 비교하기
이론만으로는 와닿지 않을 수 있다. 복잡한 요구사항을 코드로 구현하며 비교해 보자.
요구사항
- 페이지 내용이 변경되면 썸네일을 생성해야 한다.
- 단, 페이지 동시 편집자가 0명일 때만 생성한다.
- 이미 생성 중이라면 이전 요청은 취소해야 한다.
Case 1: React Hooks (Atom)로 구현할 때
// Atom과 useEffect를 사용한 구현 예시
export const usePageThumbnailGenerator = (pageId) => {
const lastEditTime = useAtomValue(lastPageEditTimeAtom);
const userCount = useAtomValue(watchingUsersCountAtom);
useEffect(() => {
// 조건 검사
if (generatedTime >= lastEditTime) return;
if (userCount > 0) return;
const controller = new AbortController(); // 취소 로직 직접 구현
const generate = async () => {
try {
const url = await createThumbnailApi(pageId, lastEditTime, controller.signal);
if (!controller.signal.aborted) {
// 상태 업데이트
setThumbnail(url);
}
} catch (error) { /* 에러 처리 */ }
};
generate();
return () => {
controller.abort(); // 클린업
};
}, [pageId, lastEditTime, userCount /* ...의존성 배열 관리... */]);
};
코드가 꽤 복잡하다. useEffect 안에서 조건문을 분기하고, AbortController로 취소 로직을 직접 짜야 하며, 의존성 배열 관리도 신경 써야 한다.
Case 2: RxJS로 구현할 때
// RxJS를 사용한 구현 예시
const thumbnailStream$ = generatePageDiff$(pageId).pipe(
switchMap(() =>
generatePageWatchingUsers$(pageId).pipe(
filter(({ count }) => count === 0), // 편집자가 0명일 때만
first(), // 첫 번째 이벤트만
switchMap(() => createThumbnail$(pageId)) // 썸네일 생성 (이전 요청 자동 취소)
)
)
);
놀랍도록 간결해졌다.
- 세밀한 구독 시점 제어: filter, first 등으로 원하는 시점을 정확히 타겟팅한다.
- 자동 취소: switchMap은 새로운 이벤트가 발생하면 이전 내부 Observable을 자동으로 구독 해제(취소)한다.
- 확장성: 만약 여기에 "0.5초 디바운스"를 추가하고 싶다면? debounceTime 연산자 한 줄만 추가하면 된다.
물론 신뢰할 수 있는 유틸이나 훅들이 정의되어 있다면 아톰예시도 더 간결해질 수 있다. 하지만 아톰의 경우 파생아톰으로 의존성을 관리할지 컴포넌트 상태로 가져와서 계산된 값으로 관리할지가 개발자의 몫이기 때문에 RxJS 연산자를 연결하는 것보다 사이드이펙트가 발생할 가능성이 크다고 생각한다.
RxJS 잘 쓰는 법 (주의사항)
RxJS는 강력하지만 러닝 커브가 있고, 잘못 쓰면 메모리 누수의 원인이 된다.
"시간축"에 주목하고 Marble Diagram 활용하기
비동기 로직을 테스트하는 것은 까다롭다. RxJS는 마블 다이어그램(Marble Diagrams) 을 통해 흐름을 시각화하고 테스트할 수 있다.
"--a--b--|" (2프레임 a, 5프레임 b, 8프레임 완료)
import { TestScheduler } from 'rxjs/testing';
import { throttleTime } from 'rxjs';
const testScheduler = new TestScheduler((actual, expected) => {
expect(actual).deep.equal(expected);
});
it('generates the stream correctly', () => {
testScheduler.run((helpers) => {
const { cold, time, expectObservable, expectSubscriptions } = helpers;
const e1 = cold(' -a--b--c---|');
const e1subs = ' ^----------!';
const t = time(' ---| '); // t = 3
const expected = '-a-----c---|';
expectObservable(e1.pipe(throttleTime(t))).toBe(expected);
expectSubscriptions(e1.subscriptions).toBe(e1subs);
});
});
이런 식으로 문자열로 흐름을 정의하여 복잡한 비동기 시나리오를 단위 테스트할 수 있다.
Cold vs Hot Observable 구분하기
가장 흔한 실수가 Cold Observable을 여러 번 구독해서 무거운 작업이 중복 실행되는 것이다.
- Cold: 구독할 때마다 로직이 처음부터 실행됨 (API 호출 등).
- Hot: 데이터 스트림이 공유됨. 무거운 연산이 포함된 스트림을 여러 곳에서 구독해야 한다면 share() 연산자를 사용하여 Multicast(Hot) 하게 만들어 불필요한 중복 실행을 막아야 한다.
컴포넌트 밖에서 정의하기
RxJS 스트림 정의는 최대한 컴포넌트 밖(External Scope) 으로 빼는 것이 좋다.
- 일관성 유지: 컴포넌트 렌더링 사이클과 무관하게 스트림이 유지된다.
- 테스트 용이: 뷰 로직과 비즈니스 로직이 분리되어 단위 테스트가 쉬워진다. 단, 외부 스코프의 값을 참조할 때는 클로저 문제 등을 주의해야 한다.
구독 해제(Unsubscribe)는 필수
subscribe를 했다면 반드시 unsubscribe를 해야 한다. 메모리 누수를 막기 위해서다. useEffect의 클린업 함수에서 subscription.unsubscribe()를 호출하거나, takeUntil 연산자를 사용해 특정 시그널에 맞춰 자동으로 종료되게 하는 패턴을 추천한다.
마치며: 언제 RxJS를 써야 할까?
모든 비동기 처리에 RxJS가 필요한 것은 아니다. 하지만 다음과 같은 상황이라면 RxJS 도입을 진지하게 고민해 볼 만하다.
- 데이터들의 흐름 제어가 복잡하게 얽혀 있을 때
- 복잡한 비동기 데이터 처리 및 테스트가 필요할 때
- 이벤트 간의 Race Condition 처리가 까다로울 때 RxJS는 동적 기반 데이터를 쉽게 다루자 는 목표를 가지고 있다. 코드가 간결해지고, 유지 보수하기 좋으며, 신뢰할 수 있는 비동기 로직을 작성하고 싶다면, RxJS 사용을 고려해보자