Spring MVC vs WebFlux: 동시성 처리

박정민·2026년 1월 29일

블로킹 I/O부터 논블로킹 이벤트 루프까지, 커널 레벨에서 이해하는 Spring 동시성 모델


📌 목차

  1. 개요
  2. 세 가지 동시성 모델 비교
  3. 블로킹 MVC의 한계
  4. @Async의 진실
  5. WebFlux의 논블로킹 메커니즘
  6. 커널 레벨 동작 원리
  7. 스레드 할당과 관리
  8. 실전 가이드

개요

Spring Boot 웹 개발에서 동시성(concurrency)을 다루는 세 가지 방식을 커널 레벨까지 파헤쳐 비교합니다.

시나리오: DB insert(I/O) + 외부 API 호출(I/O) + 약간의 CPU 연산


세 가지 동시성 모델 비교

모델스레드 점유동시성 한계주요 특징
블로킹 MVCI/O 대기 동안 점유요청 스레드 수간단하지만 비효율적
@Async워커 스레드 점유워커 스레드 + DB 풀요청 스레드는 자유롭지만 근본적 해결 아님
WebFluxI/O 대기 시 미점유메모리 + 이벤트 처리량높은 처리량, 복잡한 구조

1. 블로킹 MVC의 한계

코드 예시

@RestController
@RequiredArgsConstructor
class OrderControllerBlocking {
  
  private final JdbcTemplate jdbc;     // 블로킹 DB
  private final RestTemplate rest;     // 블로킹 HTTP
  
  @PostMapping("/orders/blocking")
  public ResponseEntity<String> create() {
    // CPU 약간 연산 (싱글코어든 멀티코어든 여기만 "실행" 구간)
    int score = expensiveButSmallCpuWork();
    
    // [I/O] DB insert -> DB 응답 올 때까지 현재 요청 스레드가 BLOCKED (대기)
    jdbc.update("insert into orders(score) values (?)", score);
    
    // [I/O] 외부 API 호출 -> 네트워크 응답 올 때까지 현재 요청 스레드가 또 BLOCKED
    String result = rest.getForObject("https://example.com/api", String.class);
    
    // 결론: 이 요청 스레드는 대부분 시간을 "기다리면서 점유"
    return ResponseEntity.ok("ok:" + result);
  }
  
  private int expensiveButSmallCpuWork() {
    int x = 0;
    for (int i = 0; i < 100_000; i++) x += i;
    return x;
  }
}

스레드 상태 타임라인

RUNNING  : CPU 연산
BLOCKED  : DB 응답 기다림 (스레드 점유)
BLOCKED  : HTTP 응답 기다림 (스레드 점유)
RUNNING  : 응답 생성

효과

  • ❌ 동시 요청이 늘면: DB/HTTP 대기로 스레드 빠르게 고갈
  • ❌ 큐/대기 증가 → p95/p99 악화
  • ❌ 싱글코어에서도: CPU는 놀 수 있는데 스레드는 묶여있음

핵심 문제

MVC는 "CPU를 안 써도 스레드를 잡고 기다린다"


2. @Async의 진실

코드 예시

@RestController
@RequiredArgsConstructor
class OrderControllerAsync {
  
  private final OrderServiceAsync service;
  
  @PostMapping("/orders/async")
  public CompletableFuture<ResponseEntity<String>> create() {
    // 요청 스레드는 빠르게 반환 가능(서블릿 async)
    return service.createAsync()
        .thenApply(r -> ResponseEntity.ok("ok:" + r));
  }
}

@Service
@RequiredArgsConstructor
class OrderServiceAsync {
  
  private final JdbcTemplate jdbc;   // 여전히 블로킹
  private final RestTemplate rest;   // 여전히 블로킹
  
  @Async // 별도 스레드풀에서 실행
  public CompletableFuture<String> createAsync() {
    int score = expensiveButSmallCpuWork();
    
    // [I/O] 워커 스레드가 DB 응답까지 BLOCKED
    jdbc.update("insert into orders(score) values (?)", score);
    
    // [I/O] 워커 스레드가 HTTP 응답까지 BLOCKED
    String result = rest.getForObject("https://example.com/api", String.class);
    
    return CompletableFuture.completedFuture(result);
  }
  
  private int expensiveButSmallCpuWork() {
    int x = 0;
    for (int i = 0; i < 100_000; i++) x += i;
    return x;
  }
}

요청 흐름

HTTP 요청
 → Tomcat 요청 스레드 (빠르게 반환)
 → @Async 워커 스레드 (여기서 BLOCKED)
 → DB 커넥션 획득 (HikariCP)
 → DB I/O 대기 (워커 스레드 점유)
 → 응답

동시성 한계 공식

@Async 동시성 한계 = min(워커 스레드 수, DB 커넥션 풀 크기)

케이스 A: 워커 스레드 < DB 커넥션

워커 10, DB 풀 30
→ 워커 스레드가 먼저 병목
→ @Async 큐가 쌓임
→ CPU 컨텍스트 스위칭 증가

케이스 B: DB 커넥션 < 워커 스레드 (현실적)

워커 30, DB 풀 10
→ DB 커넥션 풀이 먼저 병목
→ 워커 스레드 20개는 커넥션 대기에서 BLOCKED
→ 메모리/스택만 증가, 실질 처리량 증가 없음

효과

  • ✅ "요청 스레드 점유"는 줄일 수 있음
  • ❌ 블로킹 I/O는 사라지지 않음
  • ❌ 워커 스레드 풀 크기/큐 관리가 곧 한계

핵심 포인트

@Async는 "스레드 옮기기"일 뿐, I/O 블로킹 자체는 그대로다


3. WebFlux의 논블로킹 메커니즘

코드 예시

@RestController
@RequiredArgsConstructor
class OrderControllerWebFlux {
  
  private final DatabaseClient db;   // R2DBC 기반(논블로킹)
  private final WebClient web;       // 논블로킹 HTTP
  
  @PostMapping("/orders/webflux")
  public Mono<String> create() {
    
    // CPU 약간 연산: 이 구간은 실행(코어 1개면 한 번에 하나만)
    int score = expensiveButSmallCpuWork();
    
    Mono<Void> dbInsert =
        db.sql("insert into orders(score) values (:score)")
          .bind("score", score)
          .then(); // [I/O] DB 응답 대기지만 스레드는 반환됨(이벤트로 재개)
    
    Mono<String> apiCall =
        web.get().uri("https://example.com/api")
          .retrieve()
          .bodyToMono(String.class); // [I/O] 네트워크 대기지만 스레드 점유 없음
    
    // 두 I/O를 "논리적으로" 겹치게 진행: 기다리는 동안 스레드는 다른 요청 처리 가능
    return Mono.when(dbInsert, apiCall)
        .then(apiCall.map(r -> "ok:" + r));
  }
  
  private int expensiveButSmallCpuWork() {
    int x = 0;
    for (int i = 0; i < 100_000; i++) x += i;
    return x;
  }
}

스레드 상태 타임라인

[RUN ] event-loop-1 : 파이프라인 구성 + DB I/O 등록
[FREE]              : DB 대기 (스레드 점유 0)

[RUN ] event-loop-2 : DB 응답 처리 + API I/O 등록
[FREE]              : API 대기

[RUN ] event-loop-1 : API 응답 처리 + CPU 소량
[RUN ] event-loop-1 : HTTP 응답 write 등록
[FREE]

[RUN ] event-loop-? : write 완료 이벤트 처리

효과

  • ✅ I/O 대기 동안 스레드 미점유 → 동시 in-flight 요청 수를 크게 늘릴 수 있음
  • ✅ Throughput↑, 큐 적체 줄어 p95/p99 개선 가능
  • ⚠️ CPU 작업이 커지면 별도 워커/큐로 분리 필요

커널 레벨 동작 원리

전체 구조

┌──────────────────────────────┐
│        User Space          │
│                            │
│  ┌──────────┐   ┌─────────┐  │
│  │ WebFlux │ → │  Netty  │  │
│  └──────────┘   └─────────┘  │
│           │                │
│           ▼                │
│      Java NIO (Selector)   │
│                            │
└───────────┬──────────────────┘
            │ system call
════════════▼══════════════════════════════
            │
┌──────────────────────────────────────────┐
│              Kernel Space             │
│                                       │
│  ┌──────────────┐                      │
│  │ epoll/kqueue │ ◀─── 이벤트 감시자    │
│  └──────────────┘                      │
│          │                            │
│          ▼                            │
│   File Descriptor (fd=3,4,5...)       │
│          │                            │
│          ▼                            │
│   Socket Buffer (recv/send buffer)    │
└──────────┬───────────────────────────────┘
           │
           ▼
       Network / DB / API

File Descriptor (fd)

정의: 커널이 관리하는 I/O 객체에 대한 손잡이(핸들)

Process A (JVM)
fd table:
  0 → stdin
  1 → stdout
  2 → stderr
  3 → socket(HTTP client)
  4 → socket(DB)

I/O 이벤트 처리 흐름

1단계: 소켓 생성 및 논블로킹 설정

// Java: socket()
// ────────────────────────▶ Kernel
//
// Kernel:
//   새로운 socket 구조체 생성
//   fd = 12 할당
//   fcntl(fd=12, O_NONBLOCK)

중요: 이 순간부터 read() 했는데 데이터 없으면 BLOCK이 아니라 EAGAIN 리턴

2단계: 이벤트 관심 등록 (epoll)

epoll_ctl(
  epoll_fd,
  ADD,
  fd=12,
  events=READ | WRITE
)

의미: "fd=12가 읽기 가능 / 쓰기 가능 상태가 되면 알려줘"

이 시점:

  • ❌ 스레드 대기 없음
  • ❌ 블로킹 없음
  • ✅ 등록만 됨

3단계: 대기 구간 (스레드 점유 0)

[Kernel]
recv_buffer: empty
→ 이벤트 없음
→ 어떤 스레드도 BLOCK 안 됨

4단계: 네트워크 패킷 도착

[Network Card]
  ↓
[Kernel]
  fd=12 recv buffer에 데이터 저장
  
[Kernel 판단]
recv_buffer: empty → non-empty
→ "fd=12는 이제 read() 하면 안 멈춘다"

5단계: 커널 이벤트 발생

epoll:
  fd=12 is READABLE

상태 변화 발생:

  • 읽기 불가능 → 읽기 가능
  • 👉 이 "상태 변화"가 이벤트의 정체

6단계: 유저 공간(Netty)이 알림 받음

epoll_wait()
  └── returns [fd=12]

event-loop-thread-1 실행
→ fd=12 handler 호출
→ read()
→ Reactor 체인 이어서 실행

커널의 이벤트 판단 규칙

READ 이벤트 발생 조건

"이 fd에서 read()를 하면, BLOCK 안 하고 즉시 읽을 수 있다"

발생 조건:
- 상대방(DB/API)이 데이터를 보냄
- 커널 수신 버퍼에 데이터가 1바이트 이상 들어옴

if (recv_buffer.has_data()) {
    return READABLE;
}

WRITE 이벤트 발생 조건

"이 fd에 write()를 해도 버퍼가 꽉 차서 멈추지 않는다"

발생 조건:
- 커널 송신 버퍼에 여유 공간이 생김

if (send_buffer.has_space()) {
    return WRITABLE;
}

판단 기준 정리

상황커널 판단
recv buffer에 데이터 있음읽어도 됨 ✅
recv buffer 비어 있음읽으면 멈춤 ❌
send buffer에 공간 있음써도 됨 ✅
send buffer 꽉 참쓰면 멈춤 ❌

Level-triggered vs Edge-triggered

Level-triggered (기본 개념)

규칙: "조건이 참이면, 계속 알려준다"

recv buffer: non-empty
→ epoll_wait(): 즉시 READ 이벤트
→ 처리 안 해도
→ 다음 epoll_wait(): 또 READ 이벤트

Edge-triggered (Netty 기본)

규칙: "상태가 '변화'할 때만 알려준다"

empty → non-empty : 이벤트 O
non-empty → non-empty : 이벤트 X

그래서 Netty는 항상:

  • read()를 할 수 있을 만큼 반복해서 읽음
  • while(read()) 패턴
상황이벤트
데이터 도착 + edge-triggered즉시 1회
데이터 도착 + level-triggered즉시 + 반복
이미 데이터 있음 + epoll_waitlevel: 즉시 / edge: ❌

위임 체인: WebFlux → Netty → Java NIO → Kernel

전체 흐름

WebFlux:  "논블로킹으로 처리할게"
   ↓
Netty:   "논블로킹 소켓 + 이벤트 루프"
   ↓
Java NIO:"이 fd READ/WRITE 감시해줘"
   ↓
Kernel:  "버퍼 상태 바뀌면 이벤트 줄게"

각 계층의 역할

1️⃣ WebFlux (의사 표현)

"이건 blocking 없이 처리할 거야"
- Mono / Flux
- WebClient / R2DBC
- 의도만 표현 (fd 모름)

2️⃣ Netty (실행 담당)

"그럼 non-blocking 소켓 만들고 이벤트 루프에 올릴게"
- 소켓 생성
- non-blocking 설정
- 이 채널을 이벤트로 처리하겠다고 결정

3️⃣ Java NIO (JVM ↔ 커널 경계)

channel.configureBlocking(false);
channel.register(selector, OP_READ);

// 여기서 관심사(OP_READ/WRITE)가 명시됨

4️⃣ Kernel (최종 판단자)

"오케이, fd 상태 바뀌면 알려줄게"
- epoll / kqueue
- recv buffer / send buffer 감시
- 판단 기준은 버퍼 상태뿐

스레드 할당과 관리

Tomcat vs Netty 요청 처리 모델

Tomcat (MVC)

요청 도착
↓
Tomcat worker-thread 할당
↓
컨트롤러 실행
↓
DB 대기 (스레드 점유)
↓
API 대기 (스레드 점유)
↓
응답 생성
↓
응답 반환
↓
스레드 반환

핵심: 하나의 스레드가 요청 생애주기를 "소유"

WebFlux (Netty)

요청 도착 이벤트
↓
event-loop-1 잠깐 실행
↓
I/O 등록
↓
(스레드 반환)

DB 응답 이벤트
↓
event-loop-2 잠깐 실행
↓
다음 단계 실행
↓
(스레드 반환)

API 응답 이벤트
↓
event-loop-1 잠깐 실행
↓
응답 write 등록
↓
(스레드 반환)

핵심: 요청 전체를 담당하는 스레드가 없음, 이벤트마다 스레드 잠깐 사용

요청-스레드 관계 비교

관점TomcatWebFlux
요청 수신worker threadevent loop
컨트롤러 실행같은 스레드이벤트마다 다른 스레드 가능
I/O 대기스레드 점유스레드 없음
응답 반환같은 스레드이벤트 루프 중 하나
요청-스레드 관계1:1N:1 (논리적)

Netty 이벤트 루프 스레드 설정

기본 설정 (자동)

이벤트 루프 스레드 수 = CPU 코어 수 × 2

예시:

  • 4코어 → 8개
  • 8코어 → 16개

대부분의 경우 설정 불필요 - Netty가 자동으로 정해줌

이벤트 스레드가 하는 일

해야 하는 것:

  • HTTP 요청 수신
  • 커널 I/O 이벤트 처리 (read/write 가능)
  • Reactor 체인 이어서 실행 (짧은 CPU 작업)
  • 응답 write 등록

하면 안 되는 것:

  • 오래 걸리는 CPU 연산
  • 블로킹 DB(JDBC)
  • 파일 read/write

직접 설정이 필요한 경우

1️⃣ CPU 작업을 섞는 경우

Mono.fromCallable(() -> heavyCpuWork())
    .subscribeOn(Schedulers.parallel());

2️⃣ 블로킹 작업이 있는 경우

Mono.fromCallable(() -> jdbcCall())
    .subscribeOn(Schedulers.boundedElastic());

3️⃣ 고급 튜닝

System.setProperty(
  "reactor.netty.ioWorkerCount", "16"
);

실전 가이드

핵심 요약

블로킹 MVC

특징: I/O 기다리는 동안 요청 스레드가 묶임
한계: 동시성 한계가 빠르게 옴
적합: 단순한 CRUD, 적은 동시 요청

@Async

특징: 묶이는 스레드가 "요청 스레드 → 워커 스레드"로 이동
한계: 근본적인 I/O 블로킹은 그대로
적합: 요청 스레드만 빠르게 반환하고 싶을 때

WebFlux

특징: I/O 대기에 스레드를 점유하지 않음
장점: 동시 in-flight 처리에 강함 (Throughput 중심)
주의: CPU 작업이 커지면 해결 안 됨

선택 가이드

WebFlux가 유리한 경우

  • ✅ I/O 바운드 작업이 대부분
  • ✅ 높은 동시 요청 처리 필요
  • ✅ Throughput이 중요
  • ✅ 외부 API 호출이 많음

WebFlux를 피해야 하는 경우

  • ❌ CPU 바운드 작업이 주된 부하
  • ❌ 블로킹 DB만 사용 가능 (JDBC)
  • ❌ 팀의 리액티브 프로그래밍 경험 부족
  • ❌ 복잡한 트랜잭션 처리

중요한 오해 제거

❌ "WebFlux는 빨리 응답하는 기술"

WebFlux는 '기다리는 동안 스레드를 낭비하지 않는 기술'

❌ "WebFlux는 스레드가 거의 없다"

스레드는 있다. 하지만 오래 안 붙잡는다

❌ "WebFlux면 자동으로 선응답(202 Accepted)"

선응답은 설계 전략이지 WebFlux의 기능이 아니다


최종 정리

한 문장 요약

Tomcat은 "스레드 기반 요청 처리", WebFlux는 "이벤트 기반 상태 머신 위에서의 요청 처리"다.

핵심 개념

  1. MVC는 스레드 중심, WebFlux는 이벤트 중심
  2. 블로킹 = 스레드가 잠든 채로 붙잡혀 있음
  3. 논블로킹 = 이벤트 발생 시에만 스레드를 잠깐 사용
  4. File Descriptor는 커널의 규칙, WebFlux는 그 규칙을 활용
  5. "읽어도 돼" 판단은 커널이 버퍼 상태를 보고 결정

기억할 핵심 문장

WebFlux는 "스레드를 붙잡고 기다리는 모델"이 아니라, "이벤트가 발생할 때마다 잠깐 빌려 쓰는 모델"이다.


참고 자료

  • Spring WebFlux Documentation
  • Netty Project
  • Linux epoll man page
  • Reactor Core Documentation

profile
Backend Developer

0개의 댓글