WebFlux와 스레드

정은영·2025년 4월 5일
1

CS

목록 보기
20/24

1. 전통적인 스레드 모델과의 차이

전통적인 Spring MVC 같은 서블릿 기반 프레임워크는 "Thread-pre-Request"모델을 사용합니다. 즉, 클라이언트의 요청이 들어올 때마다 새로운 스레드가 생성되거나 스레드 풀에서 하나를 가져와서 그 요청을 처리합니다.

요청마다 스레드가 할당되므로, I/O 작업(데이터베이스 호출, 외부 API 호출 등) 중 스레드가 block이 되면 해당 스레드는 대기 상태가 됩니다.
동시 요청이 많아지면 스레드 풀이 고갈될 수 있고, 스레드 생성/관리 비용이 커저 성능 저하가 발생할 수 있어요.

보충 설명

  • 스레드 생성 비용이란?
    스레드를 새로 만드는건 컴퓨터 입장에서 꽤 무거운 작업이에요. 왜냐하면
    • 메모리 할당: 각 스레드는 자신만의 스택 메모리(기본적으로 1MB 정도)를 필요로 합니다. 스레드가 많아지면 메모리 사용량이 급격히 늘어나요. 이로 인해 가비지 컬렉션이 자주 발생하거나, 최악의 경우 OOM 에러가 발생할 수 있어요.
    • OS 자원: 운영체제는 스레드를 만들 때마다 커널 수준에서 자원을 할당하고 관리해야 합니다. 이 과정에서 CPU 시간이 소모돼요.
    • 컨텍스트 스위칭: CPU가 여러 스레드 사이를 오가며 실행하려면 현재 스레드의 상태(레지스터 값 등)를 저장하고, 다른 스레드의 상태를 불러오는 과정인 컨텍스트 스위칭이 필요합니다.
  • 스레드 관리 비용이란?
    • 스레드 풀 한계: 보통 Thread Pool을 사용해서 미리 스레드를 만들어 놓고 재사용하는데, 풀 크기가 작으면 요청이 대기열에 쌓이고, 크면 메모리와 CPU 자원을 과도하게 사용합니다.
    • 동기화 오버헤드: 여러 스레드가 공유 자원(데이터 베이스 연결 등)을 동시에 접근하려면 동기화가 필요합니다. lock을 걸거나 해제하는 과정에서 대기 시간이 생기고, 이게 쌓이면 성능이 떨어집니다.
    • 데드락/경쟁 조건: 스레드가 많아질수록 잘못된 관리로 데드락이나 race condition이 발생할 가능성이 커집니다. 이를 해결하기 위해 추가적인 코드와 리소스가 필요합니다.

2. WebFlux의 스레드 모델

반면, Spring WebFlux는 non-blocking event loop 모델을 기반으로 동작합니다. 이 모델에서는 스레드가 요청을 기다리며 block 되지 않고, 작업이 완료될 때까지 다른 요청을 처리할 수 있습니다. WebFlux는 기본적으로 Reactor Netty라는 비동기 서버를 사용하며, 이 서버는 소수의 스레드로 많은 요청을 효율적으로 처리합니다.

WebFlux의 스레드 모델은 리액티브 스트림 구현체와 밀접하게 연관되어 있습니다. 주요 특징은 다음과 같습니다.

이벤트 루프 스레드

WebFlux는 기본적으로 소수의 이벤트 루프 스레드(예: reactor-http-nio-X)를 사용합니다. 이 스레드 수는 보통 CPU 코어 수에 맞춰 설정되며, 요청을 받아 비동기적으로 처리합니다.
이 스레드는 요청을 직접 처리하기보다는, 작업을 스케줄링하고 콜백을 등록하는 역할을 합니다.

논블로킹 I/O

I/O 작업(네트워크 호출, 파일 읽기 등)이 발생하면 스레드가 블록되지 않고, 해당 작업이 완료되면 이벤트 루프가 이를 감지해 후속 처리를 진행합니다. 이 과정에서 스레드는 계속해서 다른 요청을 처리할 수 있어요.

스케줄러

Reactor는 스레드 사용을 제어하기 위해 Scheduler라는 추상화를 제공합니다.
WebFlux에서 자주 사용되는 스케줄러는

  • Schedulers.parallel(): CPU 코어 수에 맞춘 스레드 풀, 주로 CPU 집약적인 작업에 사용.
  • Schedulers.boundedElastic(): I/O 작업처럼 블록될 가능성이 있는 작업을 별도의 스레드 풀에서 처리.
  • Schedulers.single(): 단일 스레드에서 작업 실행.

스레드와 WebFlux의 연관관계

WebFlux에서 스레드는 전통적인 방식처럼 요청마다 1:1로 매핑되지 않습니다.

  • 작업 분리: 요청이 들어오면 이벤트 루프 스레드가 이를 받아 비동기 작업(MonoFlux)으로 변환합니다. 이 작업은 필요에 따라 다른 스케줄러로 오프로드(offload)될 수 있어요.
    • 데이터베이스 호출이 블록킹 작업이라면 Schedulers.boundedElastic()으로 옮겨 실행되며, 이벤트 루프 스레드는 계속 다른 요청을 처리합니다.
  • 스레드 재사용: 동일한 이벤트 루프 스레드가 여러 요청의 일부를 처리할 수 있습니다. 예를 들어, 요청 A의 시작과 요청 B의 완료 처리가 같은 reactor-http-nio-1 스레드에서 실행될 수 있습니다.
  • 컨텍스트 전환: WebFlux는 스레드 간 컨텍스트 전환 비용을 줄이기 위해 리액티브 파이프라인을 사용합니다. subscribeOn()이나 publishOn() 같은 연산자를 통해 특정 작업을 다른 스레드 풀에서 실행하도록 제어할 수 있습니다.
    • subscribeOn(): 전체 파이프라인을 특정 스케줄러에서 실행.
    • publishOn(): 이후 연산을 다른 스케줄러로 전환.

보충 설명: subscribeOn()과 publishOn()

1) subscribeOn(): 전체 파이프라인을 특정 스케줄러에서 실행

  • subscribeOn()은 리액티브 스트림(MonoFlux)이 구독(subscribe)되는 시점부터 전체 파이프라인의 실행을 지정한 스케줄러에서 처리하도록 합니다.
  • 데이터 소스 자체가 블로킹 작업(예: 파일 읽기, DB 쿼리)을 포함하거나, 특정 스레드 풀에서 시작부터 끝까지 실행해야 할 때 사용합니다.
Mono<String> blockingOperation() {
    return Mono.fromCallable(() -> {
        Thread.sleep(1000); // 블록킹 작업 시뮬레이션
        return "Result";
    })
    .subscribeOn(Schedulers.boundedElastic()) // 블록킹 작업을 별도 스레드 풀에서 실행
    .map(result -> result + " Processed");
}

@GetMapping("/test")
public Mono<String> test() {
    return blockingOperation();
}
  • Thread.sleep()은 블록킹 작업이라 이벤트 루프 스레드(reactor-http-nio-X)에서 실행하면 안됩니다. 이벤트 루프가 멈추면 다른 요청을 처리 못 하기 때문이에요.
    subscribeOn(Schedulers.boundedElastic())을 사용하면 이 블록킹 작업이 boundedElastic-X 스레드에서 실행돼요. 전체 파이프라인(fromCallablemap)이 boundedElastic 스레드 풀에서 실행됩니다. 이벤트 루프 스레드는 자유롭게 남아서 다른 요청을 처리할 수 있습니다.

2) publishOn(): 이후 연산을 다른 스케줄러로 전환

  • publishOn()은 이 연산자가 호출된 지점 이후의 연산을 지정한 스케줄러에서 실행하도록 합니다. 그 전 단계는 영향을 받지 않아요. 즉, 파이프라인 중간에 스레드를 "전환"하는 역할을 합니다.
  • 파이프라인에서 특정 연산(예: CPU 집약적인 작업)을 다른 스레드 풀에서 처리하고 싶을 때 사용합니다. 작업을 분리해서 효율적으로 스레드를 활용하려는 경우에 유용해요.
Mono<String> asyncOperation() {
    return Mono.just("Start")
        .map(s -> {
            System.out.println("Step 1: " + Thread.currentThread().getName());
            return s + " Step1";
        })
        .publishOn(Schedulers.parallel()) // 여기서부터 스레드 전환
        .map(s -> {
            System.out.println("Step 2: " + Thread.currentThread().getName());
            return s + " Step2";
        });
}

@GetMapping("/test2")
public Mono<String> test2() {
    return asyncOperation();
}
  • 이 코드는 두 단계의 map 연산을 포함합니다. 첫 번째 map은 기본 스레드(예: reactor-http-nio-1)에서 실행되고, publishOn(Schedulers.parallel()) 이후 두 번째 mapparallel-1 스레드에서 실행됩니다.
  • 두 번째 map이 CPU 집약적인 작업이라면, 이벤트 루프 스레드를 막지 않고 Schedulers.parallel()(CPU 작업에 최적화된 스레드 풀)로 옮겨서 처리할 수 있습니다.

예시

@RestController
public class ExampleController {
    @GetMapping("/test")
    public Mono<String> getData() {
        return Mono.just("Hello")
                .map(s -> {
                    System.out.println("Mapping on: " + Thread.currentThread().getName());
                    return s + " World";
                })
                .subscribeOn(Schedulers.boundedElastic());
    }
}

1) 요청이 들어오면 이벤트 루프 스레드(예: reactor-http-nio-1)가 이를 받아 Mono를 생성합니다.
2) subscribeOn(Schedulers.boundedElastic()) 때문에 실제 작업은 boundedElastic-X 스레드에서 실행됩니다.

  • 출력 예시: Mapping on: boundedElastic-1

3) 이벤트 루프 스레드는 블록되지 않고 다른 요청을 계속 처리합니다.

장점과 주의점

장점

  • 소수의 스레드로 많은 동시 요청 처리 가능
  • I/O 작업이 많은 환경(예: 마이크로서비스 간 호출)에서 효율적

주의점

  • 블로킹 작업(예: Thread.sleep())을 이벤트 루프 스레드에서 실행하면 성능이 급격히 저하됩니다. 이런 경우 별도의 스케줄러로 분리해야 해요.

0개의 댓글