동기/비동기 & 블로킹/논블로킹 완벽 가이드: I/O 모델부터 Spring WebFlux까지
동기/비동기 & 블로킹/논블로킹 완벽 가이드: I/O 모델부터 Spring WebFlux까지
면접에서 “Blocking과 Non-Blocking의 차이를 설명하세요”는 거의 100% 출제된다. 그런데 대부분의 지원자가 동기=블로킹, 비동기=논블로킹으로 동일시한다. 이 두 개념은 서로 다른 축이며, 조합에 따라 4가지 I/O 모델이 만들어진다.
이 글에서는 동기/비동기와 블로킹/논블로킹의 정확한 정의를 구분하고, 운영체제의 I/O 멀티플렉싱(select/poll/epoll), Event Loop와 Reactor Pattern, 그리고 Spring MVC와 WebFlux의 아키텍처 차이를 깊이 있게 정리한다. NIO 기초와 CompletableFuture 기본 문법은 이전 글([TCP/IP 글], [멀티스레딩 글])에서 다뤘으므로, 여기서는 개념의 본질과 아키텍처 수준의 차이에 집중한다.
1. 동기/비동기 vs 블로킹/논블로킹 — 두 축의 분리
1.1 정의
이 두 개념은 관심사가 다르다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌───────────────────────────────────────────────────────────┐
│ 동기(Synchronous) vs 비동기(Asynchronous) │
│ │
│ 관심사: "작업 완료를 누가 확인하는가?" │
│ │
│ 동기 → 호출자(Caller)가 직접 완료를 확인한다 │
│ 호출 후 결과가 돌아올 때까지 호출자가 신경 쓴다 │
│ │
│ 비동기 → 피호출자(Callee)가 완료를 알려준다 │
│ 호출 후 호출자는 다른 일을 할 수 있고, │
│ 완료되면 콜백/이벤트/시그널로 통보받는다 │
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
│ 블로킹(Blocking) vs 논블로킹(Non-Blocking) │
│ │
│ 관심사: "호출 시 제어권을 즉시 돌려주는가?" │
│ │
│ 블로킹 → 호출된 함수가 완료될 때까지 제어권을 잡고 있음 │
│ 호출자의 스레드가 멈춤 (대기 상태) │
│ │
│ 논블로킹 → 호출된 함수가 즉시 제어권을 돌려줌 │
│ 호출자의 스레드가 멈추지 않음 │
│ (데이터가 없으면 없다고 바로 응답) │
└───────────────────────────────────────────────────────────┘
1.2 비유로 이해하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
카페에서 커피를 주문하는 상황:
┌─────────────────────────────────────────────────────────────┐
│ 동기 + 블로킹 (Synchronous Blocking) │
│ │
│ 카운터에서 커피 주문 후, │
│ 카운터 앞에 서서(블로킹) 내가 직접(동기) 커피가 나오는지 지켜본다 │
│ → 커피 나올 때까지 아무것도 못 함 │
│ │
│ 예: 일반 JDBC, java.io의 InputStream.read() │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 동기 + 논블로킹 (Synchronous Non-Blocking) │
│ │
│ 카운터에서 커피 주문 후, │
│ 자리에 돌아가서(논블로킹) 다른 일을 하다가 │
│ 내가 직접(동기) 주기적으로 카운터에 가서 "됐나요?" 확인한다 │
│ → Polling 방식, 확인하러 가는 비용 발생 │
│ │
│ 예: NIO의 non-blocking 채널 + 직접 while 루프로 확인 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 비동기 + 논블로킹 (Asynchronous Non-Blocking) │
│ │
│ 카운터에서 커피 주문 후, │
│ 자리에 돌아가서(논블로킹) 다른 일을 하다가 │
│ 진동벨이 울리면(비동기, 콜백) 가서 커피를 받는다 │
│ → 가장 효율적 │
│ │
│ 예: NIO + Selector, AIO, WebFlux, Node.js │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 비동기 + 블로킹 (Asynchronous Blocking) — 드문 케이스 │
│ │
│ 카운터에서 커피 주문하면서 진동벨도 받았는데(비동기), │
│ 그런데도 카운터 앞에 서서(블로킹) 기다린다 │
│ → 비효율적, 실무에서 안티패턴 │
│ │
│ 예: Node.js에서 async 함수 안에서 blocking I/O 호출 │
│ select() 시스템 콜 (논란 있음) │
└─────────────────────────────────────────────────────────────┘
1.3 2×2 매트릭스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
│ 동기 (Synchronous) │ 비동기 (Asynchronous)
────────────────────┼─────────────────────────┼──────────────────────────
블로킹 (Blocking) │ 호출자가 멈추고 │ 호출자가 멈추지만
│ 직접 결과를 기다림 │ 완료 통보를 받음
│ │ (안티패턴)
│ 예: JDBC, read() │ 예: select() (논란)
────────────────────┼─────────────────────────┼──────────────────────────
논블로킹 │ 호출자가 멈추지 않고 │ 호출자가 멈추지 않고
(Non-Blocking) │ 주기적으로 결과 확인 │ 완료 시 콜백으로 통보
│ (Polling) │ (가장 효율적)
│ 예: NIO + 직접 polling │ 예: AIO, WebFlux, Netty
────────────────────┴─────────────────────────┴──────────────────────────
핵심 구분:
동기/비동기 → "완료 확인의 주체" (내가 vs 상대가)
블로킹/논블로킹 → "대기 방식" (멈춤 vs 안 멈춤)
2. 운영체제의 I/O 모델
2.1 I/O 작업의 두 단계
유저 프로세스가 I/O를 요청하면 두 단계를 거친다. 이 구분을 이해해야 I/O 모델의 차이가 보인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
I/O 작업의 두 단계:
┌────────────────────────────────────────────────────┐
│ 단계 1: 데이터 준비 (Wait for Data) │
│ 커널이 I/O 장치에서 데이터를 읽어 커널 버퍼에 저장 │
│ (네트워크 패킷 도착 대기, 디스크 읽기 대기 등) │
│ → 이 단계에서 블로킹/논블로킹이 결정됨 │
└─────────────────────┬──────────────────────────────┘
▼
┌────────────────────────────────────────────────────┐
│ 단계 2: 데이터 복사 (Copy Data) │
│ 커널 버퍼 → 유저 공간 버퍼로 데이터 복사 │
│ → 이 단계에서 동기/비동기가 결정됨 │
│ 동기: 유저 프로세스가 복사 완료를 직접 기다림 │
│ 비동기: 커널이 복사 완료 후 시그널/콜백으로 통보 │
└────────────────────────────────────────────────────┘
2.2 다섯 가지 I/O 모델 (UNIX Network Programming 분류)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
① Blocking I/O:
┌──────────┐ ┌──────────┐
│ 유저 프로세스│ │ 커널 │
└─────┬────┘ └─────┬────┘
│ read() │
│──────────────────→ │
│ │ 데이터 준비 중...
│ (블로킹: 대기) │ ...
│ │ 데이터 준비 완료
│ │ 커널→유저 버퍼 복사
│ 데이터 반환 │
│←──────────────────│
▼ ▼
→ 두 단계 모두 블로킹. 가장 단순하지만 스레드가 묶임.
② Non-Blocking I/O:
┌──────────┐ ┌──────────┐
│ 유저 프로세스│ │ 커널 │
└─────┬────┘ └─────┬────┘
│ read() │
│──────────────────→ │ 데이터 없음
│ EAGAIN 반환 │
│←──────────────────│
│ read() (다시) │
│──────────────────→ │ 데이터 없음
│ EAGAIN 반환 │
│←──────────────────│
│ read() (다시) │
│──────────────────→ │ 데이터 준비 완료!
│ │ 커널→유저 버퍼 복사 (블로킹)
│ 데이터 반환 │
│←──────────────────│
▼ ▼
→ 1단계 논블로킹(즉시 반환), 2단계 블로킹.
→ Polling으로 CPU 낭비 발생!
③ I/O Multiplexing (select/poll/epoll):
┌──────────┐ ┌──────────┐
│ 유저 프로세스│ │ 커널 │
└─────┬────┘ └─────┬────┘
│ select() │
│──────────────────→ │ 여러 FD 감시 중...
│ (블로킹: 대기) │ ...
│ 준비된 FD 반환 │ FD 3번 ready!
│←──────────────────│
│ read(fd=3) │
│──────────────────→ │ 커널→유저 버퍼 복사
│ 데이터 반환 │
│←──────────────────│
▼ ▼
→ 하나의 스레드로 여러 I/O를 동시 감시!
→ Reactor Pattern의 기반.
④ Signal-Driven I/O:
┌──────────┐ ┌──────────┐
│ 유저 프로세스│ │ 커널 │
└─────┬────┘ └─────┬────┘
│ sigaction() │
│──────────────────→ │
│ 즉시 반환 │
│←──────────────────│
│ │ 데이터 준비 중...
│ (다른 작업 수행) │ ...
│ │ 데이터 준비 완료
│ SIGIO 시그널 │
│←──────────────────│
│ read() │
│──────────────────→ │ 커널→유저 버퍼 복사
│ 데이터 반환 │
│←──────────────────│
▼ ▼
→ 1단계 비동기(시그널 통보), 2단계 동기(직접 read).
⑤ Asynchronous I/O (진정한 비동기):
┌──────────┐ ┌──────────┐
│ 유저 프로세스│ │ 커널 │
└─────┬────┘ └─────┬────┘
│ aio_read() │
│──────────────────→ │
│ 즉시 반환 │
│←──────────────────│
│ │ 데이터 준비 중...
│ (다른 작업 수행) │ ...
│ │ 데이터 준비 완료
│ │ 커널→유저 버퍼 복사 (커널이 처리)
│ 완료 시그널/콜백 │
│←──────────────────│
▼ ▼
→ 두 단계 모두 비동기. 가장 효율적.
→ Java의 AsynchronousFileChannel, Linux의 io_uring.
2.3 I/O 모델 비교 정리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌──────────────────┬───────────┬───────────┬────────────────────┐
│ I/O 모델 │ 1단계 │ 2단계 │ 대표 예시 │
│ │ 데이터 준비 │ 데이터 복사 │ │
├──────────────────┼───────────┼───────────┼────────────────────┤
│ Blocking I/O │ 블로킹 │ 블로킹 │ java.io, JDBC │
├──────────────────┼───────────┼───────────┼────────────────────┤
│ Non-Blocking I/O │ 논블로킹 │ 블로킹 │ NIO (polling) │
├──────────────────┼───────────┼───────────┼────────────────────┤
│ I/O Multiplexing │ 블로킹 │ 블로킹 │ select/poll/epoll │
│ │ (select에서)│ │ NIO Selector │
├──────────────────┼───────────┼───────────┼────────────────────┤
│ Signal-Driven │ 논블로킹 │ 블로킹 │ SIGIO │
├──────────────────┼───────────┼───────────┼────────────────────┤
│ Async I/O (AIO) │ 논블로킹 │ 논블로킹 │ aio_read, io_uring │
│ │ │ │ AsynchronousChannel│
└──────────────────┴───────────┴───────────┴────────────────────┘
핵심: ①~④는 모두 2단계(커널→유저 복사)에서 블로킹이므로
엄밀히 말하면 동기 I/O이다. ⑤만이 진정한 비동기 I/O.
3. I/O 멀티플렉싱: select → poll → epoll
3.1 왜 I/O 멀티플렉싱이 필요한가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
문제: 1만 개의 동시 접속을 처리해야 하는 서버
해결 1: 접속당 1스레드 (Thread-per-Connection)
┌─────────────────────────────────────────────┐
│ Client 1 ─→ Thread 1 (blocking read) │
│ Client 2 ─→ Thread 2 (blocking read) │
│ ... │
│ Client 10000 ─→ Thread 10000 (blocking read)│
│ │
│ 문제: │
│ - 10,000 OS 스레드 → 메모리 ~10GB (1MB/스레드) │
│ - Context Switch 폭발 → CPU가 스케줄링에 소모 │
│ - 대부분의 스레드가 read() 대기 → 자원 낭비 │
└─────────────────────────────────────────────┘
해결 2: I/O 멀티플렉싱 (1스레드로 다수 연결 처리)
┌─────────────────────────────────────────────┐
│ Client 1 ─┐ │
│ Client 2 ─┤ │
│ ... ├→ 1 Thread + epoll │
│ Client N ─┘ "준비된 연결만 알려줘" │
│ │
│ 장점: │
│ - 스레드 1개로 수만 연결 처리 가능 │
│ - Context Switch 최소화 │
│ - 메모리 효율적 │
└─────────────────────────────────────────────┘
3.2 select()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
select() — 가장 오래된 I/O 멀티플렉싱 (1983, BSD)
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
동작:
1. 감시할 FD(파일 디스크립터)들을 fd_set 비트맵에 등록
2. select() 호출 → 블로킹
3. 하나 이상의 FD가 준비되면 반환
4. fd_set을 순회하며 어떤 FD가 준비됐는지 확인
┌─────────────────────────────────────────────────┐
│ fd_set (비트맵): │
│ ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐ │
│ │ 0 │ 1 │ 0 │ 1 │ 0 │ 0 │ 1 │ 0 │ 0 │ 0 │ ... │
│ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘ │
│ fd0 fd1 fd2 fd3 fd4 fd5 fd6 │
│ ↑ ↑ ↑ │
│ 감시 중 감시 중 감시 중 │
│ │
│ select() 반환 후: 어떤 FD가 ready인지 전체 순회 필요 │
└─────────────────────────────────────────────────┘
한계:
┌─────────────────────────────────────────────┐
│ ① FD 개수 제한: 최대 1024개 (FD_SETSIZE) │
│ ② 매번 fd_set 복사: 커널 ↔ 유저 공간 복사 │
│ ③ O(n) 탐색: 모든 FD를 순회해야 준비된 것 파악 │
│ ④ 매 호출마다 재등록: fd_set을 다시 설정해야 함 │
└─────────────────────────────────────────────┘
3.3 poll()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
poll() — select의 FD 제한 해결 (1986, System V)
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 파일 디스크립터
short events; // 감시할 이벤트 (POLLIN, POLLOUT 등)
short revents; // 발생한 이벤트 (커널이 설정)
};
select와의 차이:
┌─────────────────────────────────────────────┐
│ ✓ FD 개수 제한 없음 (배열 기반) │
│ ✓ 이벤트와 결과를 분리 (events / revents) │
│ ✗ 여전히 O(n) 탐색 → 성능 문제 동일 │
│ ✗ 매 호출마다 전체 pollfd 배열을 커널에 전달 │
└─────────────────────────────────────────────┘
3.4 epoll() — 현대 Linux의 핵심
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
epoll() — Linux 고성능 I/O 멀티플렉싱 (2002, Linux 2.5.44)
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
동작:
┌─────────────────────────────────────────────────────┐
│ 1. epoll_create(): epoll 인스턴스 생성 │
│ │
│ 2. epoll_ctl(ADD): FD를 epoll에 등록 (1회) │
│ → 커널이 내부적으로 Red-Black Tree에 관리 │
│ → 매번 재등록 불필요! │
│ │
│ 3. epoll_wait(): 준비된 FD만 반환 │
│ → Ready List에서 바로 반환 (O(1) per ready FD) │
│ → 전체 순회 불필요! │
└─────────────────────────────────────────────────────┘
커널 내부 구조:
┌──────────────────────────────────────────────────────┐
│ │
│ Red-Black Tree (등록된 FD 관리) Ready List │
│ ┌────┐ ┌────┬────┬────┐ │
│ │fd:7│ │fd:3│fd:7│fd:15│ │
│ ╱ ╲ └────┴────┴────┘ │
│ ┌────┐ ┌────┐ ↑ │
│ │fd:3│ │fd:15│ 이벤트 발생한 FD만! │
│ └────┘ └────┘ │
│ │
│ epoll_ctl(ADD)로 등록 epoll_wait()가 이것만 반환 │
└──────────────────────────────────────────────────────┘
select vs poll vs epoll 성능 비교:
┌──────────┬────────────┬────────────┬────────────────┐
│ │ select │ poll │ epoll │
├──────────┼────────────┼────────────┼────────────────┤
│ FD 제한 │ 1024 │ 없음 │ 없음 │
│ 탐색 방식 │ O(n) 전체 │ O(n) 전체 │ O(준비된 FD 수) │
│ FD 전달 │ 매번 복사 │ 매번 복사 │ 최초 등록만 │
│ 구현 │ 비트맵 │ 배열 │ RB Tree+List │
│ 10K FD │ ~1ms │ ~1ms │ ~0.01ms │
│ 플랫폼 │ 모든 UNIX │ 모든 UNIX │ Linux만 │
└──────────┴────────────┴────────────┴────────────────┘
macOS/BSD: kqueue (epoll과 유사)
Windows: IOCP (완전한 비동기 I/O, AIO에 가까움)
3.5 epoll의 두 가지 트리거 모드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Level-Triggered (LT) — 기본 모드:
┌─────────────────────────────────────────────┐
│ 데이터가 남아 있는 한 계속 이벤트를 발생시킴 │
│ → 안전하지만 epoll_wait가 자주 반환될 수 있음 │
│ │
│ 비유: "물이 있는 한 계속 알람이 울림" │
└─────────────────────────────────────────────┘
Edge-Triggered (ET) — 고성능 모드:
┌─────────────────────────────────────────────┐
│ 상태가 변할 때만 딱 한 번 이벤트를 발생시킴 │
│ → 효율적이지만 모든 데이터를 즉시 읽어야 함 │
│ → 안 읽으면 다음 이벤트까지 데이터 소실 위험 │
│ → 반드시 Non-Blocking 소켓과 함께 사용 │
│ │
│ 비유: "물이 새로 들어올 때만 딱 한 번 알람" │
│ │
│ Netty는 ET 모드를 사용하여 최고 성능을 달성 │
└─────────────────────────────────────────────┘
4. Event Loop와 Reactor Pattern
4.1 Event Loop
Event Loop는 I/O 멀티플렉싱을 활용하여 단일 스레드로 다수의 이벤트를 처리하는 루프이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Event Loop의 동작:
┌──────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────┐ │
│ │ Event Loop 시작 │ │
│ └──────────┬──────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ ┌────→│ epoll_wait() 호출 │ │
│ │ │ (준비된 이벤트 대기) │ │
│ │ └──────────┬──────────┘ │
│ │ ▼ │
│ │ ┌─────────────────────┐ │
│ │ │ 이벤트 발생! │ │
│ │ │ ready FD 목록 반환 │ │
│ │ └──────────┬──────────┘ │
│ │ ▼ │
│ │ ┌─────────────────────┐ │
│ │ │ 각 이벤트에 대해: │ │
│ │ │ → 등록된 핸들러 실행 │ ← 블로킹 작업 금지! │
│ │ │ → 데이터 읽기/쓰기 │ │
│ │ │ → 비즈니스 로직 처리 │ │
│ │ └──────────┬──────────┘ │
│ │ │ │
│ └────────────────┘ (무한 반복) │
│ │
└──────────────────────────────────────────────────────┘
핵심 규칙:
┌──────────────────────────────────────────────────────┐
│ Event Loop 안에서 블로킹 작업을 하면 안 된다! │
│ │
│ 이유: Event Loop가 멈추면 모든 연결이 멈춤 │
│ │
│ ✗ Thread.sleep(), JDBC 쿼리, synchronized 대기 │
│ ✗ 파일 I/O (blocking), CPU-intensive 작업 │
│ ✓ 논블로킹 I/O만 사용해야 함 │
└──────────────────────────────────────────────────────┘
4.2 Reactor Pattern
Reactor Pattern은 Event Loop를 구조적으로 설계한 디자인 패턴이다. 네트워크 서버의 표준 아키텍처이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Reactor Pattern 구성 요소:
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Client │────→│ Reactor │───→│ Handler │ │
│ │ 요청들 │ │ (Dispatcher) │ │ (이벤트 처리) │ │
│ └──────────┘ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ Demultiplexer│ │
│ │ (epoll 등) │ │
│ └──────────────┘ │
│ │
│ Reactor: 이벤트를 감지하고 적절한 Handler에게 전달 │
│ Demultiplexer: OS의 I/O 멀티플렉싱 (select/epoll) │
│ Handler: 실제 비즈니스 로직을 수행 │
└──────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Single Reactor Single Thread (기본형):
┌──────────────────────────────────────────────────┐
│ 1 Thread: │
│ ┌────────────────────────────────────┐ │
│ │ Reactor (Event Loop) │ │
│ │ │ │
│ │ epoll_wait() → 이벤트 감지 │ │
│ │ → Accept Handler (새 연결) │ │
│ │ → Read Handler (데이터 읽기) │ │
│ │ → Write Handler (데이터 쓰기) │ │
│ └────────────────────────────────────┘ │
│ │
│ 장점: 단순, 동기화 불필요 │
│ 단점: CPU 코어 하나만 사용, Handler가 느리면 전체 지연│
└──────────────────────────────────────────────────┘
Multi Reactor Multi Thread (Netty 모델):
┌──────────────────────────────────────────────────────┐
│ │
│ Main Reactor (Boss Group) — 1 Thread │
│ ┌──────────────────────┐ │
│ │ Accept만 담당 │ │
│ │ 새 연결 → Sub Reactor에│ │
│ │ 분배 │ │
│ └──────────┬───────────┘ │
│ │ 새 연결 분배 │
│ ┌────────┼────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Sub │ │Sub │ │Sub │ Sub Reactor (Worker │
│ │React │ │React │ │React │ Group) — N Threads │
│ │or 1 │ │or 2 │ │or 3 │ (N = CPU 코어 수) │
│ │ │ │ │ │ │ │
│ │Read/ │ │Read/ │ │Read/ │ 각각 독립적인 Event Loop │
│ │Write │ │Write │ │Write │ 자신에게 할당된 연결만 처리 │
│ └──────┘ └──────┘ └──────┘ │
│ │
│ 장점: 멀티코어 활용, Accept와 I/O 분리 │
│ Netty, Nginx, Redis 등이 이 모델 사용 │
└──────────────────────────────────────────────────────┘
4.3 Proactor Pattern — 진정한 비동기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Reactor vs Proactor:
Reactor Pattern:
┌──────────────────────────────────────────────────┐
│ "I/O 준비됐으니 네가 직접 읽어" │
│ → 이벤트 감지 후 Handler가 직접 read/write 수행 │
│ → I/O 멀티플렉싱 기반 (epoll 등) │
│ → 2단계(데이터 복사)에서 여전히 블로킹 │
└──────────────────────────────────────────────────┘
Proactor Pattern:
┌──────────────────────────────────────────────────┐
│ "I/O 다 끝났으니 결과 받아" │
│ → OS가 I/O를 완전히 처리한 후 완료 통보 │
│ → AIO 기반 (io_uring, IOCP) │
│ → 두 단계 모두 비동기 │
│ → Handler는 이미 완료된 데이터만 받아 처리 │
└──────────────────────────────────────────────────┘
현실:
- Linux에서 AIO 지원이 불완전했음 → Reactor가 주류
- Linux 5.1의 io_uring으로 Proactor가 가능해짐
- Windows의 IOCP는 처음부터 Proactor 방식
- Java의 AIO(AsynchronousChannel)는 내부적으로 epoll 사용 → 진정한 AIO 아님
5. Java의 I/O 모델 진화
5.1 BIO → NIO → NIO.2 비교
이전 글에서 NIO의 Channel/Buffer/Selector 기본 API를 다뤘으므로, 여기서는 아키텍처 수준의 차이에 집중한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
Java I/O 모델 진화:
┌──────────────────────────────────────────────────────────┐
│ BIO (java.io, JDK 1.0~) │
│ │
│ InputStream / OutputStream │
│ │
│ 특성: │
│ - Stream 기반 (바이트 단위 순차 처리) │
│ - Blocking I/O: read() 호출 시 데이터 올 때까지 블로킹 │
│ - 연결당 스레드 1개 필요 │
│ - 단순하고 직관적 │
│ │
│ 아키텍처: │
│ Client 1 ─→ Thread 1 ─→ InputStream.read() [블로킹] │
│ Client 2 ─→ Thread 2 ─→ InputStream.read() [블로킹] │
│ Client N ─→ Thread N ─→ InputStream.read() [블로킹] │
│ │
│ 한계: C10K 문제 (1만 동시접속 시 1만 스레드 필요) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ NIO (java.nio, JDK 1.4~) │
│ │
│ Channel / Buffer / Selector │
│ │
│ 특성: │
│ - Buffer 기반 (블록 단위 처리) │
│ - Non-Blocking 가능: configureBlocking(false) │
│ - Selector로 I/O 멀티플렉싱 (내부적으로 epoll 사용) │
│ - 1 스레드로 다수 채널 관리 가능 │
│ │
│ 아키텍처: │
│ Client 1 ─┐ │
│ Client 2 ─┤→ Selector (epoll) → 1 Thread │
│ Client N ─┘ "준비된 채널만 처리" │
│ │
│ 한계: API가 복잡, 직접 사용하기 어려움 → Netty 탄생 │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ NIO.2 / AIO (java.nio.channels, JDK 7~) │
│ │
│ AsynchronousSocketChannel / AsynchronousFileChannel │
│ │
│ 특성: │
│ - 비동기 I/O: 콜백(CompletionHandler) 기반 │
│ - OS에게 I/O를 위임하고 완료 시 통보받음 │
│ │
│ 한계: │
│ - Linux에서는 내부적으로 epoll + 스레드풀로 구현 (진짜 AIO X) │
│ - Windows IOCP에서만 진정한 비동기 │
│ - 실무에서 거의 사용하지 않음 → Netty의 NIO가 주류 │
└──────────────────────────────────────────────────────────┘
5.2 Netty의 아키텍처
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Netty — Java 비동기 네트워크 프레임워크의 사실상 표준
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────────┐ │
│ │ BossGroup │ 접속 수락 담당 │
│ │ (1 EventLoop) │ = Main Reactor │
│ │ │ │
│ │ ServerSocketChannel.accept() │
│ └────────┬─────────┘ │
│ │ 새 연결을 WorkerGroup에 등록 │
│ ▼ │
│ ┌──────────────────┐ │
│ │ WorkerGroup │ I/O 처리 담당 │
│ │ (N EventLoops) │ = Sub Reactors │
│ │ │ N = CPU 코어 수 × 2 (기본) │
│ │ 각 EventLoop: │ │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ 1. Selector로 이벤트 감지 (epoll) │ │
│ │ │ 2. Channel Pipeline으로 이벤트 전달 │ │
│ │ │ 3. 등록된 Handler들이 순차 처리 │ │
│ │ └──────────────────────────────────────┘ │
│ └──────────────────┘ │
│ │
│ Channel Pipeline (체인 패턴): │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Decoder │→ │Business│→ │Encoder │→ │ Write │ │
│ │(역직렬화)│ │Logic │ │(직렬화) │ │Handler │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ 핵심 규칙: │
│ - 1 Channel = 항상 같은 EventLoop에서 처리 (스레드 안전) │
│ - EventLoop 안에서 블로킹 금지! │
│ - 블로킹 작업은 별도 스레드풀에 위임 │
└──────────────────────────────────────────────────────────┘
Netty를 사용하는 프로젝트:
- Spring WebFlux (Reactor Netty)
- gRPC
- Apache Kafka (브로커 간 통신)
- Elasticsearch
- Cassandra
- Zuul 2 (Netflix)
6. Spring MVC vs Spring WebFlux — 아키텍처 비교
6.1 Spring MVC의 요청 처리 모델
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Spring MVC — Thread-per-Request 모델:
┌──────────────────────────────────────────────────────────┐
│ │
│ Tomcat Thread Pool (기본 200 스레드) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Thread-1 ← Client A의 요청 │ │
│ │ │ │ │
│ │ ├→ DispatcherServlet │ │
│ │ ├→ Controller │ │
│ │ ├→ Service │ │
│ │ ├→ Repository │ │
│ │ ├→ JDBC (DB 쿼리) ──────── 블로킹 대기! ─────│───┐ │
│ │ ├→ Service (후처리) │ │ │
│ │ ├→ Controller (응답 생성) │ │ │
│ │ └→ 응답 반환 │ │ │
│ │ │ │ │
│ │ Thread-2 ← Client B의 요청 │ │ │
│ │ └→ ... (동일한 패턴) │ │ │
│ │ │ │ │
│ │ ... │ │ │
│ │ Thread-200 ← Client 200의 요청 │ │ │
│ └──────────────────────────────────────────────┘ │ │
│ │ │
│ 문제: DB 쿼리가 100ms 걸리면 │ │
│ 200개 요청만으로 모든 스레드가 소진됨! │ │
│ 201번째 요청부터 대기 큐에서 대기 │ │
└──────────────────────────────────────────────────────┘
6.2 Spring WebFlux의 요청 처리 모델
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Spring WebFlux — Event Loop 모델 (Reactor Netty):
┌──────────────────────────────────────────────────────────┐
│ │
│ Event Loop Group (기본 CPU 코어 수 스레드) │
│ ┌──────────────────────────────────────────────┐ │
│ │ EventLoop-1: │ │
│ │ ← Client A의 요청 (read) │ │
│ │ ← Client D의 응답 (write) │ │
│ │ ← Client G의 요청 (read) │ │
│ │ │ │
│ │ EventLoop-2: │ │
│ │ ← Client B의 요청 (read) │ │
│ │ ← Client E의 DB 결과 도착 (callback) │ │
│ │ │ │
│ │ EventLoop-3: │ │
│ │ ← Client C의 요청 (read) │ │
│ │ ← Client F의 외부 API 응답 (callback) │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ 핵심: 스레드가 블로킹되지 않음! │
│ I/O 대기 시 다른 요청을 처리 │
│ 소수의 스레드로 수만 동시 요청 처리 가능 │
└──────────────────────────────────────────────────────────┘
6.3 성능 비교 시나리오
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
시나리오: 동시 10,000 요청, 각 요청이 DB 쿼리 100ms 소요
Spring MVC (Tomcat, 200 스레드):
┌────────────────────────────────────────────────────────┐
│ 처리 방식: │
│ - 200개 요청 동시 처리 (스레드 풀 크기) │
│ - 나머지 9,800개는 큐에서 대기 │
│ - 200개 완료(100ms) → 다음 200개 처리 → 반복 │
│ │
│ 소요 시간: ~5초 (10,000 / 200 × 100ms = 5,000ms) │
│ 메모리: 200 스레드 × 1MB ≈ 200MB │
│ │
│ 만약 1,000 스레드로 늘리면? │
│ → ~1초로 단축되지만 Context Switch 비용 급증! │
│ → 메모리 1GB 사용 │
│ → 한계 존재 │
└────────────────────────────────────────────────────────┘
Spring WebFlux (Netty, 8 스레드 = 코어 수):
┌────────────────────────────────────────────────────────┐
│ 처리 방식 (R2DBC 사용, 논블로킹 DB 드라이버): │
│ - 10,000 요청을 8 스레드가 번갈아 처리 │
│ - DB 쿼리 보내고 → 스레드 반납 → 다른 요청 처리 │
│ - DB 응답 오면 → 콜백으로 후속 처리 │
│ │
│ 소요 시간: ~100ms (모든 요청이 거의 동시에 DB 쿼리 발생) │
│ (DB 커넥션 풀 크기가 병목이 됨) │
│ 메모리: 8 스레드 × 1MB ≈ 8MB │
│ │
│ 단, CPU-bound 작업이 많으면 MVC와 차이 없거나 오히려 불리! │
└────────────────────────────────────────────────────────┘
6.4 Mono와 Flux — Reactive Streams
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Reactive Streams 인터페이스:
┌──────────────────────────────────────────────────┐
│ Publisher<T>: 데이터를 발행하는 주체 │
│ └→ subscribe(Subscriber) │
│ │
│ Subscriber<T>: 데이터를 소비하는 주체 │
│ └→ onSubscribe(Subscription) │
│ └→ onNext(T) │
│ └→ onError(Throwable) │
│ └→ onComplete() │
│ │
│ Subscription: Publisher와 Subscriber 사이의 연결 │
│ └→ request(long n) ← Backpressure! │
│ └→ cancel() │
│ │
│ Processor<T,R>: Publisher + Subscriber │
└──────────────────────────────────────────────────┘
Mono<T>: 0 또는 1개의 데이터
┌──────────────────────────┐
│ ──────[data]──|→ │
│ 0~1개 완료 │
└──────────────────────────┘
Flux<T>: 0 ~ N개의 데이터
┌──────────────────────────────────┐
│ ──[d1]──[d2]──[d3]──...──[dN]──|→│
│ 0~N개 완료 │
└──────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* Spring WebFlux 컨트롤러 예시
*/
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository; // R2DBC
private final WebClient webClient; // 논블로킹 HTTP 클라이언트
/**
* Mono: 단일 결과 반환
* 스레드가 블로킹되지 않음!
*/
@GetMapping("/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userRepository.findById(id) // R2DBC: 논블로킹 DB 쿼리
.switchIfEmpty(Mono.error(
new UserNotFoundException(id)));
}
/**
* Flux: 여러 결과를 스트리밍
* 데이터가 준비되는 대로 하나씩 전송 (SSE 등)
*/
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<User> streamUsers() {
return userRepository.findAll()
.delayElements(Duration.ofMillis(100)); // 100ms 간격 스트리밍
}
/**
* 여러 비동기 작업을 조합
* 모든 작업이 논블로킹으로 실행됨
*/
@GetMapping("/{id}/profile")
public Mono<UserProfile> getUserProfile(@PathVariable Long id) {
Mono<User> userMono = userRepository.findById(id);
Mono<List<Order>> ordersMono = webClient
.get()
.uri("/api/orders?userId={id}", id)
.retrieve()
.bodyToFlux(Order.class)
.collectList();
// zip: 두 Mono가 모두 완료되면 결합
// 두 요청이 병렬로 실행됨!
return Mono.zip(userMono, ordersMono)
.map(tuple -> new UserProfile(
tuple.getT1(), tuple.getT2()));
}
}
6.5 Backpressure (배압)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Backpressure — Publisher가 Subscriber보다 빠를 때의 대응 전략
문제:
┌───────────────────────────────────────────────────┐
│ Publisher: 초당 10,000개 데이터 발행 │
│ Subscriber: 초당 100개만 처리 가능 │
│ │
│ Backpressure 없으면: │
│ → 메모리에 데이터 쌓임 → OutOfMemoryError! │
│ → 또는 데이터 유실 │
└───────────────────────────────────────────────────┘
해결 — Reactive Streams의 Backpressure:
┌───────────────────────────────────────────────────┐
│ │
│ Publisher Subscriber │
│ │ │ │
│ │ ←── subscription.request(3) │ "3개만 줘" │
│ │ │ │
│ │ ──→ onNext(data1) │ │
│ │ ──→ onNext(data2) │ │
│ │ ──→ onNext(data3) │ │
│ │ │ │
│ │ (Publisher가 멈추고 대기) │ │
│ │ │ 처리 완료 │
│ │ ←── subscription.request(5) │ "5개 더 줘" │
│ │ │ │
│ │ ──→ onNext(data4) │ │
│ │ ──→ ... │ │
│ │
│ Subscriber가 처리 속도를 제어! │
│ → Publisher가 Subscriber를 압도하지 못함 │
│ → 메모리 안전, 데이터 유실 없음 │
└───────────────────────────────────────────────────┘
Backpressure 전략 (Reactor):
┌─────────────────────────────────────────────────────────┐
│ onBackpressureBuffer() : 버퍼에 저장 (메모리 한도 설정) │
│ onBackpressureDrop() : 소비 못 하면 버림 │
│ onBackpressureLatest() : 최신 값만 유지 │
│ onBackpressureError() : 에러 발생시킴 │
└─────────────────────────────────────────────────────────┘
6.6 MVC vs WebFlux 선택 기준
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌──────────────────┬──────────────────────┬──────────────────────┐
│ 기준 │ Spring MVC │ Spring WebFlux │
├──────────────────┼──────────────────────┼──────────────────────┤
│ I/O 모델 │ 블로킹 (Servlet) │ 논블로킹 (Netty) │
│ 스레드 모델 │ Thread-per-Request │ Event Loop │
│ 동시 연결 │ ~수백 (스레드 풀) │ ~수만 (이벤트 루프) │
│ 처리량 (I/O-heavy)│ 보통 │ 높음 │
│ 처리량 (CPU-heavy)│ 비슷하거나 나음 │ 비슷하거나 나쁨 │
│ 지연 시간 │ 보통 │ 낮음 (I/O 대기 시) │
│ 코드 복잡도 │ 낮음 (명령형) │ 높음 (선언적/반응형) │
│ 디버깅 │ 쉬움 (스택 트레이스) │ 어려움 (비동기 체인) │
│ 생태계 │ JDBC, JPA, MyBatis │ R2DBC, WebClient │
│ 학습 곡선 │ 낮음 │ 높음 │
├──────────────────┼──────────────────────┼──────────────────────┤
│ 적합한 경우 │ CRUD 중심 서비스 │ 스트리밍, 채팅, 알림 │
│ │ 전통적 웹 애플리케이션 │ 게이트웨이, 프록시 │
│ │ RDBMS 중심 │ 마이크로서비스 통신 │
│ │ 팀이 MVC에 익숙 │ 높은 동시성 요구 │
└──────────────────┴──────────────────────┴──────────────────────┘
핵심 판단:
"DB 드라이버가 블로킹(JDBC)이면 WebFlux를 써도 이점이 제한적"
→ R2DBC 같은 논블로킹 드라이버가 있어야 WebFlux의 진가 발휘
→ 전체 체인에서 하나라도 블로킹이 있으면 Event Loop가 막힘!
7. Java 21 가상 스레드 — 제3의 선택지
7.1 가상 스레드의 위치
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
패러다임 비교:
┌──────────────────────────────────────────────────────────┐
│ 1. Thread-per-Request (Spring MVC + 플랫폼 스레드) │
│ │
│ 장점: 코드 단순 (동기식), 디버깅 쉬움 │
│ 단점: 동시성 한계 (스레드 수 = 동시 요청 수) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 2. Reactive (Spring WebFlux + Event Loop) │
│ │
│ 장점: 높은 동시성, 적은 리소스 │
│ 단점: 코드 복잡, 디버깅 어려움, 러닝 커브 높음 │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 3. Virtual Thread (Spring MVC + 가상 스레드, Java 21+) │
│ │
│ 장점: 코드 단순 (동기식) + 높은 동시성 = 두 마리 토끼! │
│ 단점: CPU-bound에는 이점 없음, 아직 생태계 성숙 중 │
└──────────────────────────────────────────────────────────┘
7.2 가상 스레드의 동작 원리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
가상 스레드 스케줄링:
┌──────────────────────────────────────────────────────────┐
│ │
│ 가상 스레드 (수만~수백만 개) │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │VT 1│ │VT 2│ │VT 3│ │VT 4│ │VT 5│ │... │ │
│ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ │
│ │ │ │ │ │ │ │
│ └──────┴──────┴──┬───┴──────┴──────┘ │
│ │ │
│ ▼ JVM 스케줄러 (ForkJoinPool) │
│ │
│ 캐리어 스레드 (= OS 스레드, CPU 코어 수만큼) │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Carrier │ │Carrier │ │Carrier │ │Carrier │ │
│ │Thread 1│ │Thread 2│ │Thread 3│ │Thread 4│ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ ↓ ↓ ↓ ↓ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │CPU │ │CPU │ │CPU │ │CPU │ │
│ │Core 1 │ │Core 2 │ │Core 3 │ │Core 4 │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ VT가 I/O 블로킹 시: │
│ ┌─────────────────────────────────────────────┐ │
│ │ VT 1이 JDBC read()에서 블로킹 │ │
│ │ → JVM이 VT 1을 Carrier Thread에서 분리(unmount)│ │
│ │ → Carrier Thread는 VT 5를 마운트하여 실행 │ │
│ │ → I/O 완료 시 VT 1은 다시 스케줄링됨 │ │
│ │ │ │
│ │ 결과: Carrier Thread가 절대 블로킹되지 않음! │ │
│ │ 동기식 코드인데 논블로킹 효과! │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* Spring Boot 3.2+ 가상 스레드 설정
*/
// application.yml
// spring:
// threads:
// virtual:
// enabled: true ← 이 한 줄이면 Tomcat이 가상 스레드 사용!
/**
* 기존 Spring MVC 코드를 전혀 수정하지 않아도 됨.
* 동기식 JDBC/JPA 코드가 그대로 높은 동시성을 달성.
*/
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
// JDBC 블로킹 호출이지만, 가상 스레드가 자동으로
// Carrier Thread에서 unmount → 다른 가상 스레드가 실행
return userService.findById(id);
}
}
/**
* 가상 스레드 주의사항
*/
public class VirtualThreadCaveats {
// ✗ synchronized 블록에서는 Carrier Thread가 pinning됨!
// → 가상 스레드의 unmount가 불가능 → 블로킹됨
private final Object lock = new Object();
public void badExample() {
synchronized (lock) { // Carrier Thread pinning!
jdbcTemplate.query(...); // 여기서 블로킹되면 Carrier Thread도 블로킹
}
}
// ✓ ReentrantLock 사용으로 해결
private final ReentrantLock reentrantLock = new ReentrantLock();
public void goodExample() {
reentrantLock.lock();
try {
jdbcTemplate.query(...); // 여기서 블로킹되면 VT만 unmount
} finally {
reentrantLock.unlock();
}
}
// ✗ ThreadLocal 과도한 사용 주의
// → 가상 스레드가 수백만 개이면 ThreadLocal 메모리도 수백만 배
// → ScopedValue (Java 21 Preview) 사용 권장
}
7.3 세 가지 접근법 비교
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌────────────────┬───────────────┬───────────────┬───────────────┐
│ │ MVC + │ WebFlux + │ MVC + │
│ │ 플랫폼 스레드 │ Event Loop │ 가상 스레드 │
├────────────────┼───────────────┼───────────────┼───────────────┤
│ 코드 스타일 │ 동기 (명령형) │ 비동기 (선언형) │ 동기 (명령형) │
│ 동시 연결 │ ~수백 │ ~수만 │ ~수만 │
│ 스레드 수 │ 수백 │ 코어 수 │ 코어 수 (OS) │
│ DB 드라이버 │ JDBC/JPA │ R2DBC │ JDBC/JPA │
│ Context Switch │ OS 레벨 │ 없음 │ JVM 레벨 │
│ 디버깅 │ 쉬움 │ 어려움 │ 쉬움 │
│ 코드 변경 │ 기존 │ 전면 재작성 │ 설정 1줄 │
│ 성숙도 │ 가장 높음 │ 높음 │ 성장 중 │
├────────────────┼───────────────┼───────────────┼───────────────┤
│ 추천 상황 │ 일반 CRUD │ 스트리밍/채팅 │ 높은 동시성 │
│ │ 적은 동시 요청 │ 풀 리액티브 │ 기존 MVC 유지 │
└────────────────┴───────────────┴───────────────┴───────────────┘
8. 실무에서의 동기/비동기 패턴
8.1 블로킹 작업의 격리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* WebFlux에서 불가피한 블로킹 작업 처리
*/
@Service
public class HybridService {
// 블로킹 작업을 별도 스케줄러에서 실행
private static final Scheduler BLOCKING_SCHEDULER =
Schedulers.boundedElastic(); // 블로킹 작업 전용 스레드 풀
public Mono<Report> generateReport(Long userId) {
Mono<User> userMono = userRepository.findById(userId); // 논블로킹
// 레거시 블로킹 라이브러리 호출을 격리
Mono<byte[]> pdfMono = Mono.fromCallable(() ->
legacyPdfLibrary.generate(userId) // 블로킹!
).subscribeOn(BLOCKING_SCHEDULER); // 별도 스레드에서 실행
return Mono.zip(userMono, pdfMono)
.map(tuple -> new Report(tuple.getT1(), tuple.getT2()));
}
}
8.2 비동기 통신 패턴 비교
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
동기 통신 (Request-Response):
┌────────┐ 요청 ┌────────┐ 요청 ┌────────┐
│Service │ ──────→ │Service │ ──────→ │Service │
│ A │ ←────── │ B │ ←────── │ C │
└────────┘ 응답 └────────┘ 응답 └────────┘
총 지연 = A→B 지연 + B→C 지연 (순차적)
비동기 통신 (Message Queue):
┌────────┐ publish ┌───────────┐ consume ┌────────┐
│Service │ ────────→ │ Message │ ────────→ │Service │
│ A │ │ Queue │ │ B │
└────────┘ │ (Kafka) │ └────────┘
└───────────┘
A는 메시지를 보내고 바로 반환 → 비동기, 논블로킹
비동기 통신의 장점:
┌─────────────────────────────────────────────┐
│ ① 결합도 감소: A는 B의 존재를 몰라도 됨 │
│ ② 내구성: B가 다운돼도 메시지가 큐에 보관 │
│ ③ 부하 분산: B가 자신의 속도로 소비 │
│ ④ 확장성: Consumer를 수평 확장 가능 │
└─────────────────────────────────────────────┘
9. 면접 빈출 질문 정리
Q1. 동기/비동기와 블로킹/논블로킹의 차이를 설명하세요.
이 두 개념은 서로 다른 축이다.
동기/비동기는 “작업 완료를 누가 확인하느냐”의 문제이다. 동기는 호출자가 직접 결과를 확인(또는 대기)하고, 비동기는 피호출자가 완료 시 콜백이나 이벤트로 통보한다.
블로킹/논블로킹은 “호출 시 제어권을 즉시 반환하느냐”의 문제이다. 블로킹은 호출된 함수가 완료될 때까지 호출자의 스레드를 멈추고, 논블로킹은 즉시 제어권을 돌려주어 호출자가 다른 작업을 할 수 있다.
이 두 축을 조합하면 4가지가 된다: 동기+블로킹(일반 JDBC), 동기+논블로킹(NIO polling), 비동기+논블로킹(WebFlux, AIO), 비동기+블로킹(안티패턴).
Q2. I/O 멀티플렉싱이란 무엇이며, select와 epoll의 차이는?
I/O 멀티플렉싱은 하나의 스레드로 여러 I/O 채널을 동시에 감시하는 기법이다. 연결당 스레드를 할당하는 대신, 하나의 스레드가 수만 개의 소켓을 감시하고 데이터가 준비된 소켓만 처리한다.
select: 감시할 FD를 비트맵(fd_set)으로 등록하고, 매번 전체를 커널에 복사한다. 반환 후 어떤 FD가 준비됐는지 O(n) 순회가 필요하다. 최대 1024개 FD 제한이 있다.
epoll: FD를 커널의 Red-Black Tree에 한 번만 등록(epoll_ctl)하면, 이후에는 준비된 FD만 Ready List에 넣어 반환(epoll_wait)한다. O(준비된 FD 수)로 효율적이고, FD 수 제한이 없다. 매 호출마다 FD를 재등록할 필요도 없다. Netty, Nginx, Redis 등 고성능 서버가 epoll을 사용한다.
Q3. Event Loop와 Reactor Pattern을 설명하세요.
Event Loop는 I/O 멀티플렉싱(epoll 등)을 활용하여 단일 스레드로 다수의 이벤트를 반복적으로 처리하는 루프이다. epoll_wait()로 준비된 이벤트를 감지하고, 등록된 핸들러를 실행하고, 다시 대기하는 것을 무한 반복한다. 핵심 규칙은 Event Loop 안에서 블로킹 작업을 하면 안 된다는 것이다. 블로킹하면 모든 연결이 멈춘다.
Reactor Pattern은 Event Loop를 구조화한 디자인 패턴이다. Reactor(이벤트 감지와 분배), Demultiplexer(OS의 epoll 등), Handler(실제 비즈니스 로직)로 구성된다. Netty는 Multi Reactor 모델을 사용하여, BossGroup(1 스레드)이 연결 수락을 담당하고, WorkerGroup(N 스레드)이 각각 독립적인 Event Loop로 I/O를 처리한다.
Q4. Spring MVC와 WebFlux의 차이를 설명하세요.
Spring MVC는 Thread-per-Request 모델이다. Tomcat 스레드 풀(기본 200개)에서 각 요청에 스레드를 할당한다. JDBC/JPA로 DB 쿼리 시 해당 스레드가 블로킹된다. 동시 요청 수가 스레드 풀 크기에 제한된다.
Spring WebFlux는 Event Loop 모델이다. Netty의 소수 스레드(CPU 코어 수)로 수만 동시 연결을 처리한다. 모든 I/O가 논블로킹이어야 하며, Mono/Flux 기반 리액티브 프로그래밍을 사용한다. R2DBC 같은 논블로킹 DB 드라이버가 필요하다.
핵심 차이: MVC는 스레드가 I/O 대기 중 블로킹되어 자원을 점유하고, WebFlux는 I/O 대기 시 스레드를 반납하여 다른 요청을 처리한다. 단, CPU-bound 작업이 많으면 WebFlux의 이점이 없으며, 전체 체인에서 하나라도 블로킹이 있으면 Event Loop가 막힌다.
Q5. Backpressure란 무엇이며 왜 필요한가요?
Backpressure는 데이터 생산 속도가 소비 속도를 초과할 때, 소비자가 생산자의 속도를 제어하는 메커니즘이다.
Reactive Streams에서 Subscriber는 subscription.request(n)으로 “n개만 보내달라”고 Publisher에게 요청한다. Publisher는 요청받은 만큼만 데이터를 발행한다. 이를 통해 Subscriber가 처리할 수 있는 만큼만 데이터가 흐르므로 메모리 폭발이나 데이터 유실을 방지한다.
Backpressure가 없으면 빠른 Producer가 느린 Consumer를 압도하여 버퍼가 무한정 커지고 결국 OutOfMemoryError가 발생한다. Reactor는 onBackpressureBuffer(버퍼링), onBackpressureDrop(초과분 버림), onBackpressureLatest(최신만 유지) 등의 전략을 제공한다.
Q6. Java의 I/O 모델 진화(BIO → NIO → AIO)를 설명하세요.
BIO (java.io): Stream 기반 블로킹 I/O. InputStream.read()가 데이터를 받을 때까지 스레드를 블로킹한다. 연결당 스레드 1개가 필요하여 C10K 문제에 부딪힌다.
NIO (java.nio): Buffer/Channel 기반 논블로킹 I/O. Channel을 non-blocking으로 설정하고 Selector로 여러 채널을 멀티플렉싱한다. 내부적으로 Linux에서 epoll을 사용한다. 1 스레드로 수만 연결 처리가 가능하지만 API가 복잡하여 Netty 같은 프레임워크가 탄생했다.
NIO.2/AIO (JDK 7): AsynchronousChannel 기반으로 콜백(CompletionHandler)으로 I/O 완료를 통보받는다. 그러나 Linux에서는 내부적으로 epoll + 스레드풀로 시뮬레이션하여 진정한 비동기가 아니다. Windows IOCP에서만 진정한 AIO이다. 실무에서는 Netty의 NIO가 사실상 표준이다.
Q7. Netty의 아키텍처를 설명하세요.
Netty는 Multi Reactor 패턴을 구현한 Java 비동기 네트워크 프레임워크이다.
BossGroup(Main Reactor): 1개의 EventLoop로 ServerSocketChannel의 accept를 담당한다. 새 연결을 WorkerGroup에 분배한다.
WorkerGroup(Sub Reactors): N개의 EventLoop(기본 CPU 코어 수 × 2)로 실제 I/O를 처리한다. 각 EventLoop는 자신만의 Selector를 가지고, 할당된 Channel들의 읽기/쓰기 이벤트를 처리한다.
Channel Pipeline: 각 Channel에 연결된 Handler 체인이다. 인바운드 이벤트(읽기)는 Decoder → Business Logic 순으로, 아웃바운드 이벤트(쓰기)는 Encoder → Write Handler 순으로 처리된다.
핵심 규칙: 하나의 Channel은 항상 같은 EventLoop에서 처리되므로 스레드 안전하다. EventLoop 안에서 블로킹 작업은 금지이며, 불가피하면 별도 스레드풀에 위임한다.
Q8. 가상 스레드(Virtual Thread)란 무엇이며, WebFlux와 비교하면?
가상 스레드는 Java 21에서 정식 도입된 경량 스레드이다. JVM이 관리하며, OS 스레드(Carrier Thread)에 다대일로 매핑된다. 가상 스레드가 블로킹 I/O를 만나면 JVM이 자동으로 Carrier Thread에서 분리(unmount)하고, 다른 가상 스레드를 마운트하여 실행한다. 결과적으로 동기식 코드를 그대로 작성하면서도 논블로킹의 효과를 얻는다.
WebFlux와의 비교: WebFlux는 코드 전체를 Mono/Flux 기반 리액티브로 재작성해야 하고 러닝 커브가 높다. 가상 스레드는 기존 Spring MVC + JDBC/JPA 코드를 설정 한 줄(spring.threads.virtual.enabled=true)로 변경 없이 높은 동시성을 달성한다.
주의사항: synchronized 블록에서는 Carrier Thread가 pinning되어 언마운트가 불가능하므로, ReentrantLock을 사용해야 한다. 또한 ThreadLocal은 수백만 가상 스레드 환경에서 메모리 문제가 될 수 있어 ScopedValue를 권장한다.
Q9. 왜 Event Loop에서 블로킹 작업을 하면 안 되나요?
Event Loop는 단일 스레드(또는 소수 스레드)로 수만 개의 연결을 처리한다. epoll_wait()로 준비된 이벤트를 감지하고, 각 이벤트에 대한 Handler를 실행한 후, 다시 epoll_wait()로 돌아가는 루프이다.
이 루프 안에서 JDBC 쿼리, Thread.sleep(), 파일 I/O 같은 블로킹 작업을 실행하면, 그 스레드가 멈추는 동안 해당 EventLoop에 할당된 모든 연결이 응답을 받지 못한다. 1000개의 연결이 EventLoop 하나에 할당되어 있다면, 하나의 블로킹 쿼리 100ms가 1000개 연결 전체를 100ms 동안 멈추게 한다.
해결: 블로킹이 불가피하면 Schedulers.boundedElastic() 같은 별도 스레드풀에 위임하여 Event Loop 스레드를 보호한다.
Q10. 동기/비동기 관점에서 JDBC, R2DBC, 가상 스레드를 비교하세요.
JDBC: 동기 + 블로킹. 쿼리 실행 시 스레드가 결과를 받을 때까지 블로킹된다. Spring MVC + 플랫폼 스레드 환경에서 동시성이 스레드 풀 크기에 제한된다.
R2DBC: 비동기 + 논블로킹. Reactive Streams 기반으로, 쿼리 결과를 Mono/Flux로 반환한다. 스레드가 쿼리 대기 중 블로킹되지 않는다. Spring WebFlux와 함께 사용하여 높은 동시성을 달성한다. 단점은 JPA처럼 성숙한 ORM이 없고, 리액티브 코드의 복잡도가 높다.
JDBC + 가상 스레드: 동기 + 블로킹 코드이지만, 가상 스레드가 블로킹 시 자동으로 Carrier Thread에서 분리된다. 결과적으로 동기 코드의 단순함과 논블로킹의 효율을 동시에 얻는다. 기존 JPA/MyBatis 코드를 그대로 사용할 수 있어 마이그레이션 비용이 가장 낮다. 현재(2026년) 가장 실용적인 선택지로 주목받고 있다.
마무리
동기/비동기와 블로킹/논블로킹은 서로 다른 축이며, 이 조합이 시스템 아키텍처를 결정짓는다.
- 운영체제: select → poll → epoll로 진화하며 I/O 멀티플렉싱의 성능을 높였다.
- 패턴: Event Loop와 Reactor Pattern이 고성능 서버의 표준이 되었다.
- Java: BIO → NIO → Netty로 발전했고, 가상 스레드라는 새로운 패러다임이 등장했다.
- Spring: MVC(Thread-per-Request) vs WebFlux(Event Loop) vs MVC+가상 스레드, 세 가지 선택지를 이해하고 상황에 맞게 선택해야 한다.
면접에서 “Blocking과 Non-Blocking의 차이”를 물으면, 단순히 정의를 말하는 것을 넘어 I/O 모델, epoll, Reactor Pattern, WebFlux 아키텍처까지 연결하여 설명할 수 있어야 한다.