Schedulers 완전 정복 — 스레딩 핵심 정리

2026-05-03AWS SAA-C03 스터디

Java Reactive Programming 핵심 정리 시리즈 6편. Schedulers 종류(boundedElastic·parallel·single·immediate), subscribeOn과 publishOn의 영향 범위 차이, 멀티 publishOn 패턴, 병렬 처리 설계까지 공장 라인 비유로 친절하게 풀어쓴 스레딩 완전 정복.

📚 Java Reactive Programming 핵심 정리 · 6편 / 14편 — 스레딩 핵심 정리

이 글은 Java Reactive Programming 핵심 정리 시리즈의 여섯 번째 편입니다. 앞의 다섯 편에서 Reactive Streams 구조, Mono/Flux, 연산자, Hot & Cold Publisher까지 쌓아 왔다면, 이제 "어떤 스레드에서 실행되는가" 를 다룰 시간입니다. Schedulers 하나만 제대로 잡으면 WebFlux 애플리케이션의 퍼포먼스 병목이 절반 이상 해결됩니다.

Schedulers 단원이 처음엔 어렵게 느껴지는 이유

이유는 네 가지예요.

첫째, 기본 동작이 직관에 맞습니다 — 그래서 문제가 생겨도 눈에 안 띄어요. Project Reactor는 기본적으로 현재 스레드에서 모든 것을 실행합니다. 겉으로 보기엔 문제없이 돌아가는데, 실제로는 I/O 대기 중 스레드를 꽉 잡고 있어서 성능이 바닥을 기는 일이 많습니다.

둘째, subscribeOn과 publishOn이 너무 비슷하게 들립니다. 둘 다 "스레드를 바꾼다"는 역할인데, 영향 범위가 정반대예요. 이름만 봐서는 차이를 알기 어렵습니다.

셋째, "위치는 상관없다"는 subscribeOn과 "위치가 전부다"라는 publishOn이 같은 파이프라인에 뒤섞이면 어디서 어느 스레드가 실행되는지 추적이 어려워집니다.

넷째, Scheduler 종류를 언제 써야 하는지 기준이 모호합니다. parallel이면 다 빠를 것 같지만, 블로킹 코드를 parallel에 넣으면 오히려 성능이 폭락합니다.

해결법은 한 가지예요. Schedulers를 "공장 라인 작업조" 로 상상하는 것입니다. CPU 집약 계산은 CPU 작업조(parallel), I/O 대기 작업은 I/O 작업조(boundedElastic), 단일 사무실 직원은 single — 이 그림 하나로 선택 기준이 명확해집니다. 이 글은 그 비유를 따라 처음부터 풀어 갑니다.

Schedulers 없이 실행하면 — 기본 스레드 모델

Project Reactor는 기본적으로 모든 리액티브 스트림을 현재 스레드(호출자 스레드)에서 실행합니다. subscribeOn이나 publishOn을 한 번도 쓰지 않으면, range 생성부터 filter, map, subscribe까지 전부 동일한 스레드에서 순서대로 돌아가요.

Flux.range(1, 10)
    .filter(n -> n % 2 == 0)
    .map(n -> n * 100)
    .subscribe(System.out::println);
// 모든 연산이 main 스레드에서 동기적으로 실행됨

여기서 중요한 시험 함정이 하나 있어요. Reactor는 기본적으로 동기(Synchronous)입니다. 비동기 프레임워크라고 들어서 당연히 멀티스레드로 돌겠거니 생각하는 분들이 많은데, Schedulers를 명시하지 않으면 호출자 스레드 하나에서 전부 실행돼요. 비동기성은 "자동으로 켜지는 기본값"이 아니라 개발자가 명시적으로 설정하는 옵션입니다.

그렇다면 "그냥 기본 스레드로 두면 뭐가 문제인가"라고 물으실 수 있어요. 문제는 블로킹 I/O 작업이 끼어들 때 시작됩니다. JDBC로 DB를 조회하거나 외부 HTTP를 RestTemplate으로 호출하면, 그 응답이 올 때까지 스레드 전체가 멈춰 버려요. 적은 스레드로 많은 요청을 처리하겠다는 Reactive의 철학이 단번에 무너지는 순간입니다.

한 줄 정리 — Reactor는 기본 동기 실행, 비동기 스레딩은 Schedulers로 명시.

Schedulers 종류 — 공장 라인 작업조

Schedulers 유틸리티 클래스는 다양한 스레드 풀 타입을 제공합니다. 어느 것을 써야 하는지는 작업의 성격에 따라 결정됩니다.

공장 비유로 정리하면 이렇습니다. CPU 집약 계산 작업은 코어 수에 딱 맞춘 CPU 작업조(parallel)가 효율적이에요. I/O 대기 작업은 기다리는 동안 다른 일을 할 수 있는 I/O 작업조(boundedElastic)가 맞습니다. 순서를 보장해야 하는 단일 사무실 직원이 필요하면 single을 씁니다.

스케줄러스레드 풀 크기주요 용도블로킹 허용
Schedulers.boundedElastic()동적 확장 (CPU×10, 최대 100K)블로킹 I/O (DB, HTTP)O
Schedulers.parallel()CPU 코어 수 고정CPU 집약 연산X
Schedulers.single()단일 스레드순차 처리X
Schedulers.immediate()현재 스레드기본값, 변경 없음-
Schedulers.fromExecutorService(es)커스텀기존 스레드 풀 재사용-

여기서 시험 함정이 하나 있어요. parallel은 이름 때문에 "병렬이면 다 여기!"라는 착각을 부릅니다. 하지만 parallel Scheduler는 CPU 코어 수만큼 스레드가 고정돼 있고, 블로킹 I/O가 들어오는 순간 그 코어가 멈춰 버려요. 블로킹 DB 쿼리를 parallel에 걸면 코어 4개짜리 서버에서 4개 요청이 들어오는 순간 전부 블로킹돼 새 요청을 처리할 스레드가 사라집니다. 블로킹 작업은 반드시 boundedElastic을 써야 합니다.

boundedElastic — I/O 작업조

boundedElastic은 블로킹 I/O 작업을 위해 설계된 Scheduler입니다. 스레드 수가 동적으로 확장되고(CPU 코어 수 × 10이 기본 상한선), 작업이 없으면 스레드를 반납해요. JDBC 조회, RestTemplate HTTP 호출, 파일 I/O처럼 스레드가 멈춰서 기다려야 하는 모든 작업의 정답입니다.

Mono.fromCallable(() -> {
    // 블로킹 DB 쿼리 — boundedElastic 필수
    return jdbcTemplate.queryForObject("SELECT name FROM users WHERE id = ?",
        String.class, 1L);
})
.subscribeOn(Schedulers.boundedElastic())
.subscribe(result -> System.out.println("DB 결과: " + result));

parallel — CPU 작업조

parallel은 CPU 집약 연산에 맞는 Scheduler입니다. 코어 수에 딱 맞게 스레드를 고정해 스레드 간 전환 비용을 최소화해요. 암호화, 이미지 리사이즈, 복잡한 수치 계산 같은 CPU가 바쁘게 돌아야 하는 작업에 씁니다.

newBoundedElastic / newParallel / newSingle — 독립 인스턴스

Schedulers.boundedElastic()은 JVM 레벨에서 공유되는 글로벌 풀입니다. 특정 모듈이 I/O를 너무 많이 써서 다른 모듈에 영향을 주는 상황을 막으려면 독립 인스턴스를 만들어 씁니다.

Scheduler isolatedScheduler = Schedulers.newBoundedElastic(
    10,          // threadCap: 최대 스레드 수
    100,         // queuedTaskCap: 대기 작업 수
    "my-io"      // 스레드 이름 접두사
);

한 줄 정리 — 블로킹 I/O → boundedElastic, CPU 집약 → parallel, 순차 보장 → single.

subscribeOn — 소스(upstream) 실행 스레드 변경

subscribeOn전체 upstream(소스 포함) 의 실행 스레드를 바꿉니다. 구독 신호가 파이프라인을 역방향으로 올라가다가 subscribeOn을 만나면 그 Scheduler로 실행 컨텍스트가 전환돼요.

Flux.range(1, 5)
    .map(n -> {
        System.out.println("map: " + Thread.currentThread().getName());
        return n * 2;
    })
    .subscribeOn(Schedulers.boundedElastic())
    .subscribe(n ->
        System.out.println("받음: " + n + " @ " + Thread.currentThread().getName()));

Thread.sleep(1000);  // 비동기 실행 대기 필수!

// 출력:
// map: boundedElastic-1
// 받음: 2 @ boundedElastic-1
// ...

여기서 시험 함정이 하나 있어요. subscribeOn은 파이프라인 어디에 놓아도 소스부터 영향을 줍니다. 체인의 끝에 놓아도 맨 앞에 놓아도 결과가 동일해요. 구독 신호가 upstream으로 전파될 때 만나는 첫 번째 subscribeOn이 전체를 결정합니다.

그리고 더 중요한 함정 — subscribeOn이 여러 개 있으면 첫 번째(소스에 가장 가까운 쪽)만 유효합니다. 나머지는 모두 무시됩니다.

// 첫 번째 subscribeOn만 유효
Flux.range(1, 5)
    .subscribeOn(Schedulers.boundedElastic())  // 이것만 유효
    .subscribeOn(Schedulers.parallel())         // 무시됨
    .subscribeOn(Schedulers.single())           // 무시됨
    .subscribe(n ->
        System.out.println(n + " @ " + Thread.currentThread().getName()));

Thread.sleep(1000);
// 출력: boundedElastic 스레드에서 실행

비동기로 실행하면 메인 스레드가 먼저 종료돼 아무것도 출력되지 않을 수 있어요. Thread.sleep() 또는 CountDownLatch로 완료를 기다려야 합니다.

한 줄 정리 — subscribeOn은 위치 무관, 첫 번째만 유효, 전체 upstream에 적용.

publishOn — 이후 downstream 실행 스레드 변경

publishOn해당 위치 이후의 연산자들 만 지정된 Scheduler로 전환합니다. 데이터 신호가 downstream으로 흘러가다가 publishOn을 만나는 시점에 스레드가 바뀌어요.

Flux.range(1, 5)
    .map(n -> {
        System.out.println("map1: " + Thread.currentThread().getName());
        return n;
    })
    .publishOn(Schedulers.parallel())  // 여기서부터 parallel 스레드
    .map(n -> {
        System.out.println("map2: " + Thread.currentThread().getName());
        return n * 10;
    })
    .subscribe(n ->
        System.out.println("받음: " + n + " @ " + Thread.currentThread().getName()));

Thread.sleep(1000);

// 출력:
// map1: main (publishOn 이전 → 메인 스레드)
// map2: parallel-1 (publishOn 이후 → parallel 스레드)
// 받음: 10 @ parallel-1

여기서 시험 함정이 하나 있어요. publishOn은 여러 개 사용해도 각각 유효합니다. subscribeOn과 반대예요. 파이프라인에 publishOn을 두 번 박으면 두 번 모두 스레드 전환이 일어납니다.

Flux.range(1, 5)
    .map(n -> {
        System.out.println("단계1: " + Thread.currentThread().getName());
        return n;
    })
    .publishOn(Schedulers.boundedElastic())   // 여기서 boundedElastic으로 전환
    .map(n -> {
        System.out.println("단계2: " + Thread.currentThread().getName());
        return n;
    })
    .publishOn(Schedulers.parallel())         // 여기서 parallel로 전환
    .subscribe(n ->
        System.out.println("단계3: " + Thread.currentThread().getName()));

Thread.sleep(1000);

// 출력:
// 단계1: main
// 단계2: boundedElastic-1
// 단계3: parallel-1

한 줄 정리 — publishOn은 위치가 전부, 여러 개 모두 유효, 해당 위치 이후만 전환.

subscribeOn vs publishOn — 핵심 차이 비교

이 두 연산자를 나란히 놓으면 차이가 명확합니다.

연산자영향 범위방향여러 개 사용 시위치 중요도
subscribeOn(S)전체 upstream(소스 포함)구독 신호 ↑첫 번째만 유효어디든 동일
publishOn(S)이후 downstream만데이터 신호 ↓각각 유효위치마다 다름

실무에서 가장 많이 쓰는 조합 패턴은 이렇습니다. 블로킹 소스를 읽고, CPU 처리를 한 뒤 결과를 저장하는 케이스예요.

// 공장 라인 설계: 소스(I/O 작업조) → CPU 가공(CPU 작업조)
Flux.fromIterable(readLargeFile())            // 블로킹 I/O
    .subscribeOn(Schedulers.boundedElastic())  // 소스는 boundedElastic
    .filter(line -> !line.isEmpty())           // 여전히 boundedElastic
    .publishOn(Schedulers.parallel())          // 여기서부터 parallel
    .map(line -> processLine(line))            // CPU 집약 작업 → parallel
    .subscribe(result -> saveResult(result));

Thread.sleep(5000);

병렬 처리 패턴 — flatMap + subscribeOn

여러 아이템을 진짜로 동시에 처리하고 싶다면 flatMapsubscribeOn을 조합합니다. 아이템마다 별도 스레드에서 독립적으로 처리됩니다.

Flux.range(1, 10)
    .flatMap(n ->
        Mono.fromCallable(() -> processItem(n))
            .subscribeOn(Schedulers.boundedElastic())  // 각 항목을 별도 스레드에서
    )
    .subscribe(result -> System.out.println("결과: " + result));

Thread.sleep(3000);
// 10개 아이템이 병렬로 처리됨

parallel() 연산자로 ParallelFlux를 만들어 처리하는 패턴도 있어요.

Flux.range(1, 100)
    .parallel()                              // ParallelFlux로 변환
    .runOn(Schedulers.parallel())            // 각 rail에 스케줄러 지정
    .map(n -> expensiveCalculation(n))       // 병렬로 실행
    .sequential()                            // 다시 Flux로 수집
    .subscribe(result -> System.out.println(result));

여기서 시험 함정이 하나 있어요. parallel() 연산자(ParallelFlux)와 Schedulers.parallel()은 다른 개념입니다. parallel() 연산자는 스트림을 여러 "레일(rail)"로 분기하는 것이고, Schedulers.parallel()은 CPU 코어 기반 스레드 풀입니다. 두 개를 같이 써야 진짜 병렬이 됩니다.

한 줄 정리 — 아이템별 병렬 처리는 flatMap + subscribeOn 조합이 가장 유연.

흔한 실수 4가지

실수 1: subscribeOn 후 Thread.sleep() 없이 바로 종료

// 잘못된 코드: 메인 스레드가 먼저 종료돼 아무것도 출력 안 됨
Flux.range(1, 5)
    .subscribeOn(Schedulers.boundedElastic())
    .subscribe(System.out::println);
// 아무것도 출력 안 될 수 있음!

// 올바른 코드
CountDownLatch latch = new CountDownLatch(1);
Flux.range(1, 5)
    .subscribeOn(Schedulers.boundedElastic())
    .subscribe(
        System.out::println,
        e -> latch.countDown(),
        () -> latch.countDown()
    );
latch.await();

실수 2: parallel Scheduler에서 블로킹 코드 실행

// 잘못된 코드: CPU 스레드가 I/O 대기로 낭비됨
Flux.range(1, 10)
    .publishOn(Schedulers.parallel())
    .map(n -> jdbcTemplate.queryForObject("SELECT ...", Long.class, n))  // 블로킹!
    .subscribe(System.out::println);

// 올바른 코드: 블로킹은 boundedElastic에서
Flux.range(1, 10)
    .flatMap(n ->
        Mono.fromCallable(() ->
            jdbcTemplate.queryForObject("SELECT ...", Long.class, n))
            .subscribeOn(Schedulers.boundedElastic())
    )
    .subscribe(System.out::println);

실수 3: 리액티브 체인 내부에서 block() 호출

// 절대 금지: 데드락 가능성
Flux.range(1, 5)
    .flatMap(n ->
        Mono.fromCallable(() -> anotherMono.block())  // 절대 금지!!!
    )
    .subscribe(System.out::println);

// 올바른 코드: flatMap으로 연결
Flux.range(1, 5)
    .flatMap(n -> anotherMono)
    .subscribe(System.out::println);

실수 4: publishOn 위치를 놓치고 "소스부터 다 바뀔 것"으로 착각

// 의도: 모든 작업을 parallel에서 실행
Flux.range(1, 5)
    .map(n -> {
        System.out.println("map1: " + Thread.currentThread().getName());
        return n;
    })
    // publishOn이 없으므로 map1은 main에서 실행됨
    .publishOn(Schedulers.parallel())  // 여기서부터만 전환
    .map(n -> n * 2)
    .subscribe(System.out::println);
// map1 → main, map2 → parallel
// 소스부터 모두 parallel로 실행하려면 subscribeOn 사용

Schedulers 완전 정복 — 압축 노트

여기까지가 Schedulers와 스레딩의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • Reactor 기본 실행 = 현재 스레드(동기) — Schedulers 명시 없으면 호출자 스레드 하나
  • Schedulers = "공장 라인 작업조" — 작업 성격에 맞는 작업조 배정이 핵심
  • blokcing I/O → boundedElastic() — 스레드 동적 확장, 블로킹 허용
  • CPU 집약 연산 → parallel() — CPU 코어 수 고정, 블로킹 금지
  • 순차 처리 보장 → single() — 단일 스레드, 순서 보장
  • subscribeOn = 전체 upstream — 소스부터 영향, 위치 무관
  • subscribeOn 여러 개 → 첫 번째만 유효
  • publishOn = 이후 downstream만 — 적용 위치에서부터 전환, 위치가 전부
  • publishOn 여러 개 → 각각 유효, 체인에서 두 번 바꿀 수 있음
  • subscribeOn + publishOn 조합 — 소스는 boundedElastic, 처리는 parallel
  • parallel() 연산자 ≠ Schedulers.parallel() — 전자는 스트림 분기, 후자는 스레드 풀
  • 병렬 처리 = flatMap + subscribeOn(boundedElastic) — 아이템마다 별도 스레드
  • 비동기 실행 후 Thread.sleep() 또는 CountDownLatch 필수 — 메인 스레드 먼저 종료 방지
  • 리액티브 체인 내부 block() 호출 = 절대 금지 — 데드락 위험
  • parallel Scheduler에 블로킹 코드 = 절대 금지 — CPU 스레드 I/O 대기 낭비
  • newBoundedElastic/newParallel — 독립 인스턴스, 모듈 격리 필요 시 사용
  • immediate() — 현재 스레드 유지, 테스트·디버깅용
  • Scheduler 선택 3원칙 — ①블로킹 I/O → boundedElastic ②CPU 집약 → parallel ③순차 보장 → single

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!