스케줄러(Scheduler): Observable 실행의 시간과 컨텍스트를 제어하는 도구
스케줄러는 RxJS에서 구독(subscribe)과 알림(notification) 전달이 언제, 어떤 실행 컨텍스트에서 일어날지 결정하는 객체다. 기본적으로 동기적으로 흐르는 RxJS 실행 흐름에 비동기 경계를 명시적으로 삽입할 수 있게 해주며, subscribeOn과 observeOn 두 연산자를 통해 사용한다. 실무에서 직접 다룰 일은 많지 않지만, 무거
By 송민성
1. 개념
스케줄러(Scheduler)는 "언제, 어떤 실행 컨텍스트에서 작업을 실행할 것인가"를 결정하는 객체다. RxJS 공식 문서에서는 스케줄러를 세 가지 요소로 정의한다. 첫째는 데이터 구조로서, 작업을 우선순위나 시간 기준으로 저장하고 꺼낼 수 있는 큐(queue)의 역할을 한다. 둘째는 실행 컨텍스트로서, 작업이 현재 스택에서 바로 실행될지, 마이크로태스크(microtask)로 밀릴지, setTimeout 같은 매크로태스크(macrotask)로 밀릴지를 나타낸다. 셋째는 가상 시계(virtual clock)로서, now() 메서드를 통해 시간 개념을 제공하고 이를 이용해 테스트에서 시간을 조작할 수 있게 한다.
기본적으로 RxJS의 Observable은 스케줄러를 지정하지 않으면 동기적(synchronous)으로 실행된다. 즉 subscribe를 호출하는 순간 그 자리에서 값 생성과 전달이 모두 끝난다. 스케줄러는 이 흐름 중간에 의도적으로 비동기 경계를 삽입하는 도구다.
2. 왜 사용하는가
동기적으로 실행되는 RxJS 파이프라인은 대부분 문제없이 동작하지만, 두 가지 상황에서 스케줄러가 필요해진다.
첫째, 대량의 동기 연산을 한 번에 처리하면 이벤트 루프(event loop)를 오래 점유해 다른 작업(I/O 처리, 타이머 콜백 등)이 지연될 수 있다. 이럴 때 스케줄러로 작업을 여러 틱(tick)에 나눠 실행하면 이벤트 루프를 블로킹하지 않고 처리할 수 있다.
둘째, 테스트 코드에서 실제로 몇 초, 몇 분을 기다릴 수 없기 때문에 가상 시간(virtual time)을 사용하는 테스트 스케줄러(TestScheduler)가 필요하다. 마블 테스트를 작성할 때 내부적으로 이 개념이 동작한다.
3. 동작 원리
스케줄러를 직접 다루는 방법은 두 가지 연산자, subscribeOn과 observeOn을 통해서다. 이 둘은 역할이 명확히 다르다.
subscribeOn은 Observable의 구독(subscribe) 자체가 어떤 컨텍스트에서 시작될지를 결정한다. 즉 Observable 생성 함수(subscriber 함수) 내부 코드가 실행되는 시점을 스케줄러가 제어한다. 파이프라인 어느 위치에 놓아도 효과는 소스(source) 쪽 구독 시점에 적용되며, 여러 번 호출해도 첫 번째로 지정한 스케줄러만 실제로 적용된다.
observeOn은 반대로 옵저버(observer)에게 값, 완료(complete), 에러(error) 알림이 전달되는 시점을 제어한다. observeOn을 파이프라인 중간에 삽입하면 그 이후의 모든 알림 전달이 지정한 스케줄러 위에서 일어난다. 여러 번 호출하면 각 호출 이후 구간마다 별도로 적용된다.
RxJS가 기본 제공하는 스케줄러는 다음과 같다. queueScheduler는 동기적으로 동작하되 재귀 호출을 트램폴린(trampoline) 방식으로 풀어내 스택 오버플로우를 방지한다. asapScheduler는 마이크로태스크 큐를 이용해 현재 동기 코드가 끝난 직후, Promise의 then 콜백과 비슷한 시점에 실행된다. asyncScheduler는 setTimeout과 유사하게 매크로태스크 큐를 이용해 실행을 다음 이벤트 루프 틱으로 미룬다. animationFrameScheduler는 브라우저의 requestAnimationFrame을 이용하는 스케줄러라 Node.js 환경에서는 실질적인 활용도가 낮다.
4. 예제
먼저 스케줄러 없이 of로 값을 만들고 구독하면 동기적으로 즉시 로그가 찍히는지 확인해본다. of(1, 2, 3).subscribe(value => console.log('sync', value))를 실행하면, 콘솔에는 sync 1, sync 2, sync 3이 그 자리에서 바로 출력되고, 그 다음 줄에 있는 코드는 이 로그들이 다 찍힌 뒤에 실행된다.
이제 asyncScheduler를 이용해 같은 동작을 비동기로 바꿔본다. 먼저 import { of, asyncScheduler } from 'rxjs'로 필요한 것을 가져오고, 그다음 of(1, 2, 3, asyncScheduler).subscribe(value => console.log('async', value))를 호출한다. 이 경우 subscribe 호출 직후 실행되는 동기 코드가 먼저 출력되고, async 1, async 2, async 3은 매크로태스크로 밀려 그 이후에 출력된다.
observeOn을 이용한 예제도 살펴본다. 먼저 import { of, asyncScheduler } from 'rxjs'와 import { observeOn } from 'rxjs/operators'를 가져온다. 그다음 of(1, 2, 3).pipe(observeOn(asyncScheduler)).subscribe(value => console.log('observeOn', value))를 실행하면, 소스 자체는 동기적으로 값을 만들어내지만 옵저버에게 전달되는 시점만 매크로태스크로 지연된다. 결과적으로 이 코드 아래에 있는 동기 로그가 먼저 찍히고, 그다음 observeOn 1, observeOn 2, observeOn 3이 순서대로 출력된다.
subscribeOn 예제는 구독 시작 시점 자체를 지연시킨다. 먼저 import { Observable, asyncScheduler } from 'rxjs'와 import { subscribeOn } from 'rxjs/operators'를 가져온다. 그다음 new Observable(subscriber => { console.log('구독 시작'); subscriber.next(1); subscriber.complete(); }).pipe(subscribeOn(asyncScheduler)).subscribe(value => console.log('subscribeOn', value))를 실행하면, 구독 시작이라는 로그조차 매크로태스크로 밀려서 이 코드 아래에 있는 동기 로그보다 나중에 출력된다.
5. 실무 사용 사례
무거운 배열이나 대량의 동기 연산을 from으로 옵저버블화한 뒤 observeOn(asyncScheduler)를 붙이면, 값 전달을 여러 매크로태스크로 나눠 이벤트 루프가 다른 작업을 처리할 틈을 준다. 서버 사이드에서 대량의 요청 로그를 순차 처리하거나 CPU 집약적인 변환 작업을 스트림으로 처리할 때 이런 패턴이 쓰인다.
테스트 코드에서는 TestScheduler를 이용한 마블 테스트가 대표적인 활용 사례다. TestScheduler는 가상 시간을 이용해 delay, debounceTime, interval 같은 시간 기반 연산자를 실제로 기다리지 않고도 순식간에 검증할 수 있게 해준다. 마블 다이어그램 문법으로 입력과 예상 출력을 문자열로 표현하고, expectObservable로 실제 동작과 비교하는 방식이 널리 쓰인다.
6. 주의할 점
스케줄러는 실행 순서와 타이밍에 직접 관여하기 때문에 남용하면 코드의 동작을 예측하기 어려워진다. 대부분의 RxJS 연산자는 스케줄러를 지정하지 않아도 의도한 대로 동작하도록 설계되어 있으므로, 특별한 이유 없이 스케줄러를 직접 지정하는 것은 권장되지 않는다.
subscribeOn은 파이프라인 어디에 위치시키든 소스의 구독 시점에만 영향을 주고, 여러 번 지정해도 처음 지정한 것만 유효하다는 점을 혼동하기 쉽다. 반면 observeOn은 호출된 위치를 기준으로 그 이후의 모든 알림에 영향을 주므로, 파이프라인 중간 여러 곳에 배치하면 구간별로 다른 스케줄러를 적용하는 효과를 낼 수 있다. 이 둘의 차이를 명확히 구분하지 못하면 원하는 지점에서 비동기 경계가 생기지 않아 디버깅이 어려워진다.
animationFrameScheduler는 브라우저 환경에서만 의미가 있으므로 순수 Node.js 환경에서는 사용을 피해야 한다.
7. 핵심 정리
스케줄러는 RxJS 실행의 "언제, 어떤 컨텍스트에서"를 결정하는 객체이며, 기본값은 동기 실행이다. subscribeOn은 구독 시작 시점을, observeOn은 알림 전달 시점을 제어하며 서로 역할이 다르다. queueScheduler, asapScheduler, asyncScheduler는 각각 동기 트램폴린, 마이크로태스크, 매크로태스크 기반으로 동작한다. 실무에서는 무거운 연산 분산과 마블 테스트의 가상 시간 제어라는 두 가지 상황에서 스케줄러 개념이 실질적으로 활용된다.