블로킹 I/O부터 논블로킹 이벤트 루프까지, 커널 레벨에서 이해하는 Spring 동시성 모델
Spring Boot 웹 개발에서 동시성(concurrency)을 다루는 세 가지 방식을 커널 레벨까지 파헤쳐 비교합니다.
시나리오: DB insert(I/O) + 외부 API 호출(I/O) + 약간의 CPU 연산
| 모델 | 스레드 점유 | 동시성 한계 | 주요 특징 |
|---|---|---|---|
| 블로킹 MVC | I/O 대기 동안 점유 | 요청 스레드 수 | 간단하지만 비효율적 |
| @Async | 워커 스레드 점유 | 워커 스레드 + DB 풀 | 요청 스레드는 자유롭지만 근본적 해결 아님 |
| WebFlux | I/O 대기 시 미점유 | 메모리 + 이벤트 처리량 | 높은 처리량, 복잡한 구조 |
@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 : 응답 생성
MVC는 "CPU를 안 써도 스레드를 잡고 기다린다"
@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 커넥션 풀 크기)
워커 10, DB 풀 30
→ 워커 스레드가 먼저 병목
→ @Async 큐가 쌓임
→ CPU 컨텍스트 스위칭 증가
워커 30, DB 풀 10
→ DB 커넥션 풀이 먼저 병목
→ 워커 스레드 20개는 커넥션 대기에서 BLOCKED
→ 메모리/스택만 증가, 실질 처리량 증가 없음
@Async는 "스레드 옮기기"일 뿐, I/O 블로킹 자체는 그대로다
@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 완료 이벤트 처리
┌──────────────────────────────┐
│ 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
정의: 커널이 관리하는 I/O 객체에 대한 손잡이(핸들)
Process A (JVM)
fd table:
0 → stdin
1 → stdout
2 → stderr
3 → socket(HTTP client)
4 → socket(DB)
// Java: socket()
// ────────────────────────▶ Kernel
//
// Kernel:
// 새로운 socket 구조체 생성
// fd = 12 할당
// fcntl(fd=12, O_NONBLOCK)
중요: 이 순간부터 read() 했는데 데이터 없으면 BLOCK이 아니라 EAGAIN 리턴
epoll_ctl(
epoll_fd,
ADD,
fd=12,
events=READ | WRITE
)
의미: "fd=12가 읽기 가능 / 쓰기 가능 상태가 되면 알려줘"
이 시점:
[Kernel]
recv_buffer: empty
→ 이벤트 없음
→ 어떤 스레드도 BLOCK 안 됨
[Network Card]
↓
[Kernel]
fd=12 recv buffer에 데이터 저장
[Kernel 판단]
recv_buffer: empty → non-empty
→ "fd=12는 이제 read() 하면 안 멈춘다"
epoll:
fd=12 is READABLE
상태 변화 발생:
epoll_wait()
└── returns [fd=12]
event-loop-thread-1 실행
→ fd=12 handler 호출
→ read()
→ Reactor 체인 이어서 실행
"이 fd에서 read()를 하면, BLOCK 안 하고 즉시 읽을 수 있다"
발생 조건:
- 상대방(DB/API)이 데이터를 보냄
- 커널 수신 버퍼에 데이터가 1바이트 이상 들어옴
if (recv_buffer.has_data()) {
return READABLE;
}
"이 fd에 write()를 해도 버퍼가 꽉 차서 멈추지 않는다"
발생 조건:
- 커널 송신 버퍼에 여유 공간이 생김
if (send_buffer.has_space()) {
return WRITABLE;
}
| 상황 | 커널 판단 |
|---|---|
| recv buffer에 데이터 있음 | 읽어도 됨 ✅ |
| recv buffer 비어 있음 | 읽으면 멈춤 ❌ |
| send buffer에 공간 있음 | 써도 됨 ✅ |
| send buffer 꽉 참 | 쓰면 멈춤 ❌ |
규칙: "조건이 참이면, 계속 알려준다"
recv buffer: non-empty
→ epoll_wait(): 즉시 READ 이벤트
→ 처리 안 해도
→ 다음 epoll_wait(): 또 READ 이벤트
규칙: "상태가 '변화'할 때만 알려준다"
empty → non-empty : 이벤트 O
non-empty → non-empty : 이벤트 X
그래서 Netty는 항상:
while(read()) 패턴| 상황 | 이벤트 |
|---|---|
| 데이터 도착 + edge-triggered | 즉시 1회 |
| 데이터 도착 + level-triggered | 즉시 + 반복 |
| 이미 데이터 있음 + epoll_wait | level: 즉시 / edge: ❌ |
WebFlux: "논블로킹으로 처리할게"
↓
Netty: "논블로킹 소켓 + 이벤트 루프"
↓
Java NIO:"이 fd READ/WRITE 감시해줘"
↓
Kernel: "버퍼 상태 바뀌면 이벤트 줄게"
"이건 blocking 없이 처리할 거야"
- Mono / Flux
- WebClient / R2DBC
- 의도만 표현 (fd 모름)
"그럼 non-blocking 소켓 만들고 이벤트 루프에 올릴게"
- 소켓 생성
- non-blocking 설정
- 이 채널을 이벤트로 처리하겠다고 결정
channel.configureBlocking(false);
channel.register(selector, OP_READ);
// 여기서 관심사(OP_READ/WRITE)가 명시됨
"오케이, fd 상태 바뀌면 알려줄게"
- epoll / kqueue
- recv buffer / send buffer 감시
- 판단 기준은 버퍼 상태뿐
요청 도착
↓
Tomcat worker-thread 할당
↓
컨트롤러 실행
↓
DB 대기 (스레드 점유)
↓
API 대기 (스레드 점유)
↓
응답 생성
↓
응답 반환
↓
스레드 반환
핵심: 하나의 스레드가 요청 생애주기를 "소유"
요청 도착 이벤트
↓
event-loop-1 잠깐 실행
↓
I/O 등록
↓
(스레드 반환)
DB 응답 이벤트
↓
event-loop-2 잠깐 실행
↓
다음 단계 실행
↓
(스레드 반환)
API 응답 이벤트
↓
event-loop-1 잠깐 실행
↓
응답 write 등록
↓
(스레드 반환)
핵심: 요청 전체를 담당하는 스레드가 없음, 이벤트마다 스레드 잠깐 사용
| 관점 | Tomcat | WebFlux |
|---|---|---|
| 요청 수신 | worker thread | event loop |
| 컨트롤러 실행 | 같은 스레드 | 이벤트마다 다른 스레드 가능 |
| I/O 대기 | 스레드 점유 | 스레드 없음 |
| 응답 반환 | 같은 스레드 | 이벤트 루프 중 하나 |
| 요청-스레드 관계 | 1:1 | N:1 (논리적) |
이벤트 루프 스레드 수 = CPU 코어 수 × 2
예시:
✅ 대부분의 경우 설정 불필요 - Netty가 자동으로 정해줌
✅ 해야 하는 것:
❌ 하면 안 되는 것:
Mono.fromCallable(() -> heavyCpuWork())
.subscribeOn(Schedulers.parallel());
Mono.fromCallable(() -> jdbcCall())
.subscribeOn(Schedulers.boundedElastic());
System.setProperty(
"reactor.netty.ioWorkerCount", "16"
);
특징: I/O 기다리는 동안 요청 스레드가 묶임
한계: 동시성 한계가 빠르게 옴
적합: 단순한 CRUD, 적은 동시 요청
특징: 묶이는 스레드가 "요청 스레드 → 워커 스레드"로 이동
한계: 근본적인 I/O 블로킹은 그대로
적합: 요청 스레드만 빠르게 반환하고 싶을 때
특징: I/O 대기에 스레드를 점유하지 않음
장점: 동시 in-flight 처리에 강함 (Throughput 중심)
주의: CPU 작업이 커지면 해결 안 됨
✅ WebFlux는 '기다리는 동안 스레드를 낭비하지 않는 기술'
✅ 스레드는 있다. 하지만 오래 안 붙잡는다
✅ 선응답은 설계 전략이지 WebFlux의 기능이 아니다
Tomcat은 "스레드 기반 요청 처리", WebFlux는 "이벤트 기반 상태 머신 위에서의 요청 처리"다.
WebFlux는 "스레드를 붙잡고 기다리는 모델"이 아니라, "이벤트가 발생할 때마다 잠깐 빌려 쓰는 모델"이다.