모든 네트워크 서버, 데이터베이스 클라이언트, 파일 시스템 작업의 성능은 I/O를 어떻게 처리하느냐에 달려 있다.
초당 수만 건의 요청을 처리하는 현대 서버(Nginx, Node.js, Netty 등)가 가능해진 핵심 배경이 바로 I/O 모델의 진화이다.
I/O(Input/Output)란 프로세스가 외부 장치나 커널과 데이터를 주고받는 모든 작업을 말한다. 하지만 CPU의 연산 속도에 비해 외부 장치(네트워크, 디스크)의 속도는 수천~수만 배 느리다.
I/O Bound vs. CPU Bound: 현대의 많은 서비스(특히 AI, 웹 서버)는 CPU 연산보다 I/O를 기다리는 시간이 훨씬 긴 I/O Bound 특성을 가진다.
성능의 핵심: "기다리는 시간(Wait) 동안 CPU가 놀지 않고 다른 일을 할 수 있는가?"가 고성능 서버의 성패를 결정한다.
| I/O 종류 | 예시 | 특징 |
|---|---|---|
| Network I/O | 소켓 read/write, HTTP 요청/응답 | 지연 시간 불확실 (ms~sec) |
| Disk I/O | 파일 읽기/쓰기, DB 쿼리 | HDD 수 ms, SSD 수십 μs |
| Device I/O | 키보드 입력, 마우스 이벤트 | 사용자 의존적, 완전 비예측 |
| IPC | 파이프, 메시지 큐, 공유 메모리 | 커널 중재 필요 |
I/O 모델을 이해하려면 두 공간의 분리를 반드시 알아야 한다. OS가 메모리 공간을 둘로 나눈 이유는 안전 때문이다.
┌─────────────────────────────────────┐
│ User Space │
│ ┌────────────┐ ┌────────────┐ │
│ │ Process A │ │ Process B │ │
│ └────┬───────┘ └─────┬──────┘ │
│ │ system call │ │
├────────┼────────────────┼───────────┤ ← 권한 경계 (Ring 3 → Ring 0)
│ ▼ ▼ │
│ Kernel Space │
│ ┌────────────────────────────┐ │
│ │ I/O Subsystem │ │
│ │ ┌───────┐ ┌──────────┐ │ │
│ │ │Socket│ │FileSystem│ │ │
│ │ │Buffer│ │ Cache │ │ │
│ │ └───────┘ └──────────┘ │ │
│ └────────────────────────────┘ │
│ ┌───────────────────────────┐ │
│ │ Device Drivers / NIC │ │
│ └───────────────────────────┘ │
└────────────────────────────────────┘
| 구분 | User Space (유저 영역) | Kernel Space (커널 영역) |
|---|---|---|
| 주체 | 우리가 만든 일반 애플리케이션 | OS 핵심 로직, 하드웨어 드라이버 |
| 권한 | 제한적 (Ring 3) | 모든 자원 접근 가능 (Ring 0) |
| I/O 수행 | 직접 수행 불가능 | 커널만이 직접 수행 가능 |
| 안전성 | 프로세스가 죽어도 시스템은 안전함 | 커널이 죽으면 시스템 전체가 멈춤 |
모든 I/O 모델을 구분하는 가장 중요한 기준이다. 시스템 콜이 발생하면 커널은 다음 두 단계를 거친다.
⭐ NIC (Network Interface Card)
컴퓨터가 네트워크(예: 로컬 네트워크 또는 인터넷)와 통신할 수 있도록 하는 핵심 하드웨어 장치
유저 영역에서 커널 영역으로 넘어가는 시스템 콜은 공짜가 아니다.
Blocking I/O의 핵심은 "데이터가 유저 버퍼에 복사될 때까지 프로세스의 제어권이 돌아오지 않는다"는 점이다.
Application Kernel
│ │
│────────── recvfrom() ────────────▶│
│ (system call) │
│ │ 데이터 없음...
│ ⏳ 프로세스 BLOCKED │ NIC에서 패킷 대기 중
│ (Sleep 상태로 전환) │
│ │ ... 시간 경과 ...
│ │
│ │ ✅ 데이터 도착!
│ │ 커널 버퍼에 적재
│ │ 커널 버퍼 → 유저 버퍼 복사
│ │
│◀────────── 데이터 반환 ────────────│
│ │
│ 이제서야 다음 코드 실행 │
▼ ▼
C언어 Blocking I/O 소켓 예제
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen 생략 ...
int client_fd = accept(sockfd, NULL, NULL); // Blocking -> 클라이언트 연결까지 멈춤
char buffer[1024];
ssize_t n = read(client_fd, buffer, sizeof(buffer)); // Blocking -> 데이터 올 때까지 멈춤
// 이 줄은 read()가 반환될 때까지 절대 실행되지 않음
printf("Received: %s\n", buffer);
int sockfd = socket(AF_INET, SOCK_STREAM, 0);SOCK_STREAM은 신뢰성 있는 연결인 TCP를 쓰겠다는 의미이다.int client_fd = accept(sockfd, NULL, NULL);client_fd라는 전용 통로를 돌려준다.ssize_t n = read(client_fd, buffer, sizeof(buffer));printf("Received: %s\n", buffer);read()가 끝나기 전까지는 이 줄은 절대 실행되지 않는다.Python Blocking I/O 예제
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8080))
server.listen(5)
conn, addr = server.accept() # Blocking: 클라이언트 접속 때까지 정지
data = conn.recv(1024) # Blocking: 데이터 수신까지 정지
print(f"Received: {data}")
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8080))
server.listen(5)listen(5)는 대기실(큐)에 최대 5명까지 줄을 세울 수 있다는 설정이다.conn, addr = server.accept()conn)와 손님의 주소(addr)를 들고 깨어난다.data = conn.recv(1024)data 변수에 값을 담고 다음으로 넘어간다.print(f"Received: {data}")recv()가 끝나기 전까지는 이 출력문은 절대 실행되지 않는다.Client A ──▶ Thread 1 (read에서 Block 중... 아무것도 못 함)
Client B ──▶ Thread 2 (read에서 Block 중... 아무것도 못 함)
Client C ──▶ Thread 3 (read에서 Block 중... 아무것도 못 함)
...
Client N ──▶ Thread N (read에서 Block 중... 아무것도 못 함)
| 문제 요인 | 설명 | 실제 수치 | 아키텍처적 영향 |
|---|---|---|---|
| 메모리 낭비(고갈) | 스레드 하나당 고정된 스택 메모리가 필요함 | Linux 기본 thread당 8MB → 1000개 = 8GB | 동시 접속자 수의 물리적 한계 발생 |
| 컨텍스트 스위칭 | 스레드가 많아지면 CPU가 실제 일하는 시간보다 스레드를 갈아끼우는 시간이 더 길어짐 | 수천 스레드 → CPU 시간의 상당 부분을 스위칭에 소모 | 시스템 전체의 처리량(Throughput)급감 |
| CPU 유휴 (리소스 낭비) | I/O 작업은 CPU를 거의 쓰지 않는데, 스레드는 아무것도 못하고 점유만 함 | 대부분의 시간을 Sleep 상태로 허비 | 하드웨어 자원(CPU, RAM)의 낮은 가동률 |
| 확장성 한계 (연쇄 지연) | 하나의 작업이 늦어지면 해당 스레드를 기다리는 다른 작업들도 줄줄이 지연됨 | 전통적 Apache prefork 방식의 한계 | 서비스 전체의 응답성(Responsiveness) 저하 |
Thread Pool로도 한계가 있다: 스레드 기아(Starvation)
Thread Pool을 쓰면 무한정 스레드가 늘어나는 건 막을 수 있지만, 더 큰 문제가 생긴다.
상황: Thread Pool 크기가 200개인데, 200명의 사용자가 아주 느린 네트워크 환경에서 접속했다.
결과: 200개의 스레드가 모두 read()에서 Block 되어버린다.
현상: 201번째 사용자는 아주 가벼운 요청을 보내도, 빈 스레드가 없어서 큐에서 무한 대기하게 된다. 이걸 스레드 기아 상태라고 부른다.
결론: 결국 Blocking 방식은 "기다리는 행위" 자체가 스레드를 점유하기 때문에, 풀을 아무리 잘 관리해도 근본적인 확장성 문제를 해결할 수 없다.
코드 예시
// Java Thread Pool 방식 - 개선되었지만 여전히 Blocking
ExecutorService pool = Executors.newFixedThreadPool(200);
while (true) {
Socket client = serverSocket.accept();
pool.submit(() => {
InputStream in = client.getInputStream();
byte[] buf = new byte[1024];
int n = in.read(buf); // 여전히 Blocking (풀의 스레드를 점유)
// ...
});
}
// 동시 접속이 200개를 넘으면? → 큐에서 대기 → 응답 지연 시작
ExecutorService pool = Executors.newFixedThreadPool(200);Socket client = serverSocket.accept();pool.submit). 그리고 사장님은 바로 다음 손님을 맞으러 간다. 이건 개선된 점이다.int n = in.read(buf);Non-blocking의 핵심은 커널이 데이터가 준비되지 않았더라도 프로세스를 WAIT 큐에 넣지 않고 즉시 리턴시킨다는 점이다.
Application Kernel
│ │
│────── recvfrom() ────────────────▶│
│ │ 데이터 없음
│◀───── EWOULDBLOCK 즉시 반환 ──────│
│ │
│ 다른 작업 수행 가능! ✅ │
│ │
│────── recvfrom() ────────────────▶│
│ │ 아직 데이터 없음
│◀───── EWOULDBLOCK 즉시 반환 ──────│
│ │
│ 다른 작업 수행 ✅ │
│ │
│────── recvfrom() ────────────────▶│
│ │ ✅ 데이터 준비됨!
│ │ 커널 버퍼 → 유저 버퍼 복사
│◀───── 데이터 반환 (n bytes) ──────│
│ │
▼ ▼
C언어 Non-Blocking I/O 설정
#include <fcntl.h>
int sockfd = socket(AF_INET, SOCK_STERAM, 0);
// 소켓을 Non-Blocking 모드로 설정
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
char buffer[1024];
while (1) {
ssize_t n = read(sockfd, buffer, sizeof(buffer));
if (n > 0) {
// 데이터를 성공적으로 읽음
process_data(buffer, n);
} else if (n == -1 && errno == EWOULDBLOCK) {
// 아직 데이터 없음 → 다른 작업 수행
do_other_work();
} else if (n == 0) {
// 연결 종료
break;
}
}
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);fcntl 함수를 써서 O_NONBLOCK 플래그를 꽂아주면, 이제 이 소켓은 데이터가 없어도 절대 멈추지 않는 성격을 갖게 된다.ssize_t n = read(sockfd, buffer, sizeof(buffer));read()가 즉시 리턴한 결과(n)에 따라 세 가지 길로 나뉜다.n > 0)process_data)."n == -1 && errno == EWOULDBLOCK)do_other_work()를 실행하며 댜른 일을 할 수 있게 된다.n == 0)break)."Python Non-Blocking I/O 예제
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setblocking(False) # Non-Blocking 모드 설정
server.bind(('0.0.0.0', 8080))
server.listen(5)
while True:
try:
conn, addr = server.accept()
except BlockingIOError:
# 아직 연결 요청 없음 → 다른 작업 수행 가능
do_other_work()
continue
server.setblocking(False)accept()는 손님이 올 때까지 무한정 기다리지만(Blocking), 이 설정을 하면 "손님이 없으면 기다리지 말고 바로 나한테 알려줘!"라고 명령하는 것이다.try:
conn, addr = server.accept()conn과 addr를 들고 기분 좋게 다음 줄로 넘어간다.BlockingIOError라는 예외를 즉시 발생시킨다. (이게 바로 Non-blocking의 신호이다.)except BlockingIOError:
do_other_work()
continuedo_other_work)."continue를 통해 다시 while 루프의 처음으로 돌아가서 손님이 왔는지 또 확인한다.Non-Blocking 소켓에서 데이터가 아직 준비되지 않았을 때 커널에 반환하는 에러 코드이다.
이 에러 코드들은 진짜 에러가 아니라 "지금은 데이터가 없으니 나중에 다시 시도해줘"라는 커널의 친절한 메시지이다.
| 에러 코드 | 의미 | 비고 |
|---|---|---|
EWOULDBLOCK | "이 작업은 Block이 필요한데, Non-Blocking 모드라서 대신 에러를 반환할게." | POSIX 표준 |
EAGAIN | "지금은 리소스가 없어. 나중에 다시 시도해." | Linux에서 EWOULDBLOCK과 동일 값(11) |
Linux에서는 EAGAIN == EWOULDBLOCK == 11이므로 사실상 같다. 하지만 이식성을 위해 둘 다 체크하는 것이 관례이다.
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 데이터 미준비 → 나중에 재시도
}
데이터가 준비되지 않았는데도 계속 시스템 콜을 호출한다.
이는 CPU 사이클을 낭비하며, 시스템 콜 자체도 User → Kernel 모드 전환 비용이 발생한다.
while (true) {
result = read(fd); // 시스템 콜 호출
if (result == EAGAIN) // 데이터 없으면
continue; // 또 호출, 또 호출, 또 호출...
}
while 루프를 계속 돌리게 된다. 이때 CPU는 "데이터 왔니?"라는 질문을 초당 수백만 번 던지며 100% 가동률을 찍게 된다.read()를 호출할 때마다 User Mode ↔ Kernel Mode 전환(Context Switch)이 일어난다. 데이터도 없는데 이 전환을 반복하는 건 엄청난 자원 낭비이다.| 관점 | Blocking I/O | 순수 Non-Blocking I/O (Polling) |
|---|---|---|
| CPU 활용 | Sleep 상태로 낭비 | Busy loop로 낭비 |
| 응답 속도 | 데이터 오면 즉시 깨어남 | 폴링 주기에 따라 약간의 지연 발생 가능 |
| 시스템 콜 횟수 | 1회 (대기 후 반환) | N회 (반복 호출) |
| 다른 작업 가능? | ❌ 불가 | ⭕ 가능 (루프 사이에) |
| 효율성 | 스레드 낭비가 심함 | CPU 자원 낭비가 심함 |
이 문제를 해결하기 위해 등장한 것이 I/O 멀티플렉싱이다.
Blocking의 스레드 낭비 문제와 Non-Blocking의 Busy Waiting 문제를 동시에 해결하는 방법이다.
epoll_wait()를 호출하면 데이터가 올 때까지 프로세스는 잠들지만(Sleep), 데이터가 도착하는 순간 OS가 깨워주기 때문에 CPU 낭비가 전혀 없다.┌─────────────────────────────────────────────────────┐
│ I/O Multiplexing │
│ │
│ "야, 이 소켓들 중에 읽을 수 있는 거 있으면 알려줘" │
│ │
│ select()/poll()/epoll_wait() │
│ │ │
│ │ ← 준비된 FD가 있을 때까지 Block │
│ │ (CPU를 낭비하지 않음!) │
│ ▼ │
│ "소켓 #3, #7이 읽기 가능해!" │
│ │ │
│ ├── read(fd_3) → 바로 데이터 획득 │
│ └── read(fd_7) → 바로 데이터 획득 │
└─────────────────────────────────────────────────────┘
| 특성 | select | poll | epoll |
|---|---|---|---|
| 최대 FD 수 | 1024 (FD_SETSIZE) | 제한 없음 | 제한 없음 |
| FD 전달 방식 | 매번 전체 FD 집합 복사 | 매번 전체 배열 복사 | 커널에 등록, 변경분만 전달 |
| 이벤트 탐색 | O(n) 순회 | O(n) 순회 | O(1) 콜백 방식 |
| 대규모 연결 성능 | 매우 나쁨 | 나쁨 | 우수 |
| 지원 OS | 거의 모든 OS | 거의 모든 OS | Linux 전용 |
select나 poll은 손님이 1만 명이면 1만 명을 다 훑어야 한다. 하지만 epoll은 다르다.
// epoll 사용 예제 (Linux)
int epfd = epoll_create1(0);
// 감시할 소켓 등록
struct epoll_event ev;
ev.events = EPOLLIN; // 읽기 이벤트 감시
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
// 이벤트 대기 (Block - 하지만 CPU 낭비 없음)
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
// 이 소켓에 읽을 데이터가 있음 → 바로 read
read(events[i].data.fd, buffer, sizeof(buffer));
}
}
epoll_create1)int epfd = epoll_create1(0);epfd라는 파일 디스크립터(번호표)를 돌려받는다. 앞으로 모든 감시 설정은 이 번호를 통해 이루어진다.epoll_ctl)struct epoll_event ev;
ev.events = EPOLLIN; // 읽기 이벤트(데이터 도착)을 감시할게
ev.data.fd = client_fd; // 이 소켓(client_fd)을 감시해줘
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);select와 달리, 한 번만 등록해두면 커널이 계속 기억하고 있다. 이걸 관심 리스트(Interest List)라고 부른다. 매번 전체 목록을 커널에 넘길 필요가 없어서 엄청나게 효율적이다.epoll_wait)struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);events 배열에 쏙 담아준다.nfds): "지금 바로 읽을 수 있는 소켓이 총 몇 개인지" 알려준다.for 루프)for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
// 이 소켓은 100% 데이터가 준비된 상태
read(events[i].data.fd, buffer, sizeof(buffer));
}
}select는 1만 개 중 누가 보냈는지 몰라서 1만 번을 다 확인해야 했다.epoll은 커널이 준비된 녀석들만 따로 모아서(events 배열) 주기 때문에, 딱 nfds만큼만 루프를 돌면 된다. 1만 개 연결 중 2개만 데이터가 왔다면 딱 2번만 돌면 된다.| 구분 | Level-Triggered (LT) | Edge-Triggered (ET) |
|---|---|---|
| 알림 조건 | 버퍼에 데이터가 조금이라도 있으면 계속 알림 | 데이터가 새로 들어오는 순간에만 딱 한 번 알림 |
| 안전성 | 높음 (데이터를 다 안 읽어도 다음에 또 알려줌) | 낮음 (한 번 놓치면 다음 데이터 올 때까지 알림 없음) |
| 성능 | 보통 (반복적인 알림으로 인한 오버헤드) | 매우 높음 (불필요한 시스템 콜 최소화) |
| 구현 방식 | 일반적인 read() 호출 | 반드시 Non-blocking 소켓 + 루프 돌며 EAGAIN까지 읽기 |
LT 모드:
데이터 100byte 도착 → 이벤트!
50byte만 읽음
→ 다음 epoll_wait에서도 이벤트! (50byte 남아있으니까)
ET 모드:
데이터 100byte 도착 → 이벤트!
50byte만 읽음
→ 다음 epoll_wait에서 이벤트 없음! (새로운 데이터가 오기 전까지)
→ 반드시 EAGAIN이 나올 때까지 읽어야 데이터 유실 방지
네트워크 데이터가 도착하면 다음 경로를 따른다.
NIC(네트워크 카드)
│ DMA로 데이터 전송
▼
Kernel Socket Receive Buffer (recvBuffer)
│ ← 여기가 핵심!
│ 커널 메모리 → 유저 메모리 복사 (memcpy)
▼
User Application Buffer
| 작업 | 속도 | 설명 |
|---|---|---|
| 네트워크 → 커널 버퍼 | 느림 (ms) | 물리적 I/O, NIC 대기 |
| 커널 버퍼 → 유저 버퍼 | 매우 빠름 (μs) | RAM 내 메모리 복사(memcpy) |
데이터를 직접 유저 메모리로 쏘면 빠를 것 같은데, 번거롭게 커널 버퍼(recvBuffer)를 거치는 이유는 다음과 같다.
커널 버퍼 → 유저 버퍼 복사마저 줄이려는 최적화 기법이다.
sendfile(), 2번 복사)| 항목 | 전통적 방식 (Read+Write) | Zero-Copy (sendfile) |
|---|---|---|
| 데이터 복사 횟수 | 4회 | 2회 (하드웨어 간 이동만) |
| 컨텍스트 스위칭 | 4회 | 2회 |
| CPU 사용량 | 높음 (복사하느라 바쁨) | 매우 낮음 |
| 주요 사용처 | 데이터 가공이 필요한 경우 | 대용량 파일 전송 (Kafka, Nginx) |
Kafka가 초당 수백만 건의 메시지를 처리하는 비결이 바로 이것이다.
sendfile()을 통해 바로 네트워크로 쏴버린다.덕분에 CPU는 복사 작업에서 해방되어 다른 중요한 연산에 집중할 수 있는 것이다.
Unix 네트워크 프로그래밍(Stevens)에서 정의한 5가지 모델은 다음과 같다.
Wait 단계 Copy 단계
(데이터 준비) (커널→유저 복사)
───────────── ──────────────
1. Blocking I/O ██████████ ████
(Block) (Block)
2. Non-Blocking I/O ░░░░░░░░░░ ████
(Polling/반복체크) (Block)
3. I/O Multiplexing ██████████ ████
(select/epoll) (select에서 Block) (Block)
4. Signal-Driven I/O ────────── ████
(비동기 알림대기) (Block)
5. Async I/O (AIO) ────────── ────
(완전 비동기) (완전 비동기)
██ = Block(프로세스 멈춤)
░░ = Polling(반복 체크, CPU 소모)
── = 프로세스 자유(다른 작업 가능)
read()를 호출하는 순간, 복사가 끝날 때까지는 멈춘다.select나 epoll_wait 호출 시, 감시하는 소켓 중 하나라도 준비될 때까지 멈춘다.read()를 호출하면, 복사하는 동안은 멈춘다.read)가면, 복사하는 동안은 멈춘다.| 모델 | Wait 단계 (준비) | Copy 단계 (복사) | 동기/비동기 구분 |
|---|---|---|---|
| Blocking | Block | Block | Synchronous |
| Non-blocking | Polling | Block | Synchronous |
| I/O Multiplexing | Block | Block | Synchronous |
| Signal-Driven | Async Notification | Block | Synchronous |
| Asynchronous I/O | Async | Async | Asynchronous |
| 기술 | I/O 모델 | 핵심 메커니즘 |
|---|---|---|
| Apache (prefork) | Blocking I/O | 프로세스당 1 연결 |
| Apache (worker) | BLocking I/O | 스레드풀, 스레드당 1 연결 |
| Nginx | I/O Multiplexing | epoll (Linux) + Event Loop, ET 모드 |
| Node.js | I/O Multiplexing + AIO | libuv (epoll/kqueue 래핑) + 이벤트 루프 |
| Netty (Java) | I/O Multiplexing | Java NIO (Selector = epoll 래핑) |
| Go net/http | Blocking 스타일 코드 + 내부 Non-Blocking | goroutine + netpoller (epoll 래핑) |
| Redis | I/O Multiplexing | 싱글 스레드 + epoll, 이벤트 루프 |
| 세대 | 목표 | 해결책 |
|---|---|---|
| C10K (1999) | 동시 1만 연결 | select → epoll 전환, 이벤트 기반 아키텍처 |
| C100K | 동시 10만 연결 | epoll + Non-Blocking + Connection Pooling |
| C1M | 동시 100만 연결 | 커널 바이패스 (DPDK, io_uring), Zero-Copy |
| C10M | 동시 1000만 연결 | 유저 스페이스 네트워크 스택, 하드웨어 오프로딩 |
동시 접속자 수가 늘어날수록 병목 지점이 이동하는 과정이 다르다.
Linux 5.1에서 도입된 진정한 비동기 I/O 인터페이스이다.
기존 epoll:
epoll_wait() → "읽을 수 있어!" → read() 호출 (여전히 시스템 콜)
io_uring:
제출 큐(SQ)에 I/O 요청 등록
→ 커널이 비동기로 처리
→ 완료 큐(CQ)에서 결과 수확
→ 시스템 콜 최소화 (배치 처리)
epoll은 네트워크 전용이었지만, io_uring은 파일 읽기/쓰기까지 완벽하게 비동기로 처리한다.epoll 기반 서버보다 처리량이 20~30% 이상 향상되는 놀라운 결과를 보여준다.이 두 개념은 관점이 다르다.
| 구분 | 관점 | 질문 |
|---|---|---|
| Blocking/Non-Blocking | 제어권 | "내가 기다려야 해, 말아야 해?" |
| Synchronous/Asynchronous | 완료 통지 방식 | "내가 결과를 확인해야 해, 알려줘?" |
2×2 조합 매트릭스
| Blocking | Non-Blocking | |
|---|---|---|
| Sync | [Sync-Blocking] 가장 일반적인 모델. 결과가 올 때까지 멈춰서 기다림. (예: JDBC, 전통적 API 호출) | [Sync-Non-blocking] 제어권은 바로 받지만, 결과가 나왔는지 계속 물어봄(Polling). (예: Java NIO 초기 모델) |
| Async | [Async-Blocking] 안티패턴. 비동기로 호출했는데 내부에서 블로킹 요소가 있어 결국 멈춤. (예: Node.js에서 동기 DB 드라이버 사용) | [Async-Non-blocking] 가장 이상적. 제어권도 바로 받고, 완료 통보도 나중에 콜백으로 받음. (예: Node.js, WebFlux) |
// Java NIO: Non-Blocking + I/O Multiplexing 조합
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBLocking(false); // Non-Blocking 모드
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 이벤트 발생까지 Block (하지만 하나의 스레드로 다수 채널 관리)
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isAcceptable()) { /* 새 연결 */ }
if (key.isReadable()) { /* 데이터 읽기 가능 */ }
}
}
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();epoll을 자바에서 쓸 수 있게 만든 것이다.server.configureBlocking(false); // Non-Blocking 모드server.register(selector, SelectionKey.OP_ACCEPT);select)while (true) {
selector.select(); // 이벤트 발생까지 Block
}select()는 블로킹(Block)되어 멈춘다. 이것은 '효율적인 멈춤'이다.Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {keys)를 넘겨준다.if (key.isAcceptable()) { /* 새 연결 처리 */ }
if (key.isReadable()) { /* 데이터 읽기 처리 */ } selector.select()는 블로킹되어도 괜찮은가?select() 호출 시, 감시하는 채널 중 하나라도 이벤트(연결, 읽기 등)가 발생할 때까지 스레드는 멈춘다.
memcpy는 CPU가 직접 데이터를 옮겨야 하므로 CPU 점유율을 높이고 메모리 대역폭을 잡아먹는다.sendfile, mmap)을 써서 커널 영역에서 유저 영역으로의 복사 단계를 아예 건너뛴다.select는 매번 "누가 왔니?"라고 물어볼 때마다 전체 명단을 커널에 복사해서 넘겨줘야 한다. (O(n))epoll은 한 번만 등록하고 바뀐 것만 통보받는다(O(1)). 최신 io_uring은 여러 요청을 한 바구니에 담아 한 번에 국경을 넘는 '배치 처리'로 시스템 콜 횟수 자체를 획기적으로 줄였다.
| 모델 | 핵심 동작 | 장점 | 단점 | 대표 사용처 |
|---|---|---|---|---|
| Blocking | 호출 시 응답까지 대기 | 구현 단순, 직관적 | 스레드 낭비, 확장성 한계 | 전통적 서버, 간단한 CLI 도구 |
| Non-Blocking | 즉시 반환 + 반복 확인 | 스레드 점유 없음 | Busy Waiting으로 CPU 낭비 | 단독 사용은 드묾 |
| I/O Multiplexing | 다수 FD를 하나의 호출로 감시 | 소수 스레드로 대량 연결 처리 | 이벤트 기반 프로그래밍 복잡 | Nginx, Redis, Node.js |
| Async I/O | 요청 후 완료 시 통지 | 가장 효율적, 진정한 비동기 | 구현 복잡, OS 지원 필요 | io_uring, Windows IOCP |
