[Nginx] Nginx 이벤트 루프와 epoll 기반 I/O 다중화
NGINX 한 대가 초당 수만 건의 요청을 처리하면서도 메모리를 수백 MB 이상 잡아먹지 않는다. 스레드 기반 서버라면 스택만으로 수 GB가 사라지는 상황에서도 NGINX는 그러지 않는다. 이것이 가능한 이유는 하나다. I/O를 기다리지 않기 때문이다.
Apache가 연결마다 스레드를 할당하는 구조적 한계를 드러내던 자리에서, NGINX는 OS 커널의 이벤트 알림 메커니즘을 활용해 단 몇 개의 프로세스로 수만 개의 연결을 동시에 관리한다. 이 글에서는 그 원리를 이벤트 루프(Event Loop)와 I/O 다중화(epoll/kqueue)를 중심으로 OS 레벨까지 파헤쳐본다.
문제의 시작 - C10K
2000년대 초, 웹 트래픽이 폭발적으로 증가하면서 하나의 서버로 동시 접속자 1만 명(10,000 Connections)을 처리하는 것이 업계의 화두로 떠올랐다. 이것이 C10K 문제다.
당시 지배적인 웹 서버인 Apache는 요청당 프로세스 혹은 스레드를 생성하는 구조였기 때문에, 1만 개의 동시 연결은 곧 1만 개의 스레드를 의미했다. 스레드 하나당 기본 스택 크기가 수백 KB에서 수 MB에 달한다는 점을 감안하면, 단순 메모리 계산만으로도 수십 GB가 필요했다. 하드웨어 성능을 올리는 것만으로는 근본적으로 해결할 수 없는 구조적 문제였다.
NGINX는 2004년, 이 C10K 문제를 정면으로 겨냥해 설계되었다. 접근 방식은 단순했다. 스레드 수를 늘리는 대신, 하나의 스레드가 수천 개의 연결을 동시에 다룰 수 있도록 만드는 것이었다. 그러려면 먼저 스레드당 요청 모델이 정확히 어디서, 왜 무너지는지를 이해해야 한다.
전통적인 웹 서버의 한계 - 스레드당 요청 모델
Apache의 초기 버전을 포함한 과거 웹 서버 대부분은 스레드당 요청(Thread-per-request) 모델을 사용했다. 새로운 요청이 들어올 때마다 전담 스레드를 생성하거나 스레드 풀에서 할당하는 방식이다.
- 작동 방식:
- 사용자 A의 요청 → 스레드 A 할당
- 사용자 B의 요청 → 스레드 B 할당
- 사용자 N의 요청 → 스레드 N 할당
- 근본적인 문제:
- 높은 메모리 사용량:
앞서 본 메모리 문제 외에도, 스레드당 요청 모델에는 더 근본적인 문제들이 있다. 연결 수가 늘수록 OS가 각 스레드의 메타데이터를 관리하는 비용도 함께 선형으로 증가한다. - 컨텍스트 스위칭 오버헤드:
CPU가 스레드를 전환할 때마다 레지스터, 스택 포인터, 프로그램 카운터 등 전체 실행 컨텍스트를 저장하고 복원해야 한다. 수천 개의 스레드 환경에서는 이 오버헤드가 실제 작업 시간을 압도하기 시작한다. - 락 경합(Lock Contention):
여러 스레드가 공유 자원(캐시, 설정 등)에 동시에 접근하려 할 때 발생하는 병목이다.
- 높은 메모리 사용량:

대부분의 스레드는 I/O 완료를 기다리며 블로킹 상태에 머물러 있다. CPU가 쉬는 동안 메모리만 잡아먹는 구조, 이것이 C10K 문제의 핵심이다.
OS 레벨에서 바라본 I/O 다중화 - select 부터 epoll까지
NGINX가 어떻게 단일 스레드로 수천 개의 소켓을 감시할 수 있는지 이해하려면, OS가 제공하는 I/O 다중화(I/O Multiplexing) 메커니즘의 진화를 먼저 살펴봐야 한다.
select / poll의 한계
select는 가장 오래된 I/O 다중화 방식이다. 여러 파일 디스크립터(소켓 포함)를 한 번에 감시할 수 있지만, 근본적인 한계가 있다.
// select 방식의 의사 코드
while (true) {
fd_set read_fds;
FD_ZERO(&read_fds); // select()는 반환 시 fd_set을 결과로 덮어씀
FD_SET(sock1, &read_fds); // → 루프마다 감시 목록을 통째로 재구성해야 함
FD_SET(sock2, &read_fds); // → 재구성된 fd_set 전체를 매 호출마다 커널에 복사
// (비용 발생)
// ...
// 준비된 fd가 생길 때까지 블로킹
select(max_fd + 1, &read_fds, NULL, NULL, NULL);
// 어떤 fd가 준비됐는지 전체를 순회해야 함 → O(N)
for (int i = 0; i <= max_fd; i++) {
if (FD_ISSET(i, &read_fds)) {
handle(i);
}
}
}
- O(N) 순회 비용: 준비된 fd를 찾기 위해 등록된 모든 fd를 매번 순회한다.
- fd 개수 제한: FD_SETSIZE로 인해 일반적으로 최대 1024개의 fd만 감시할 수 있다.
- 커널-유저 공간 복사 비용: 매 호출마다 fd 집합 전체를 커널에 복사해야 한다.
fd(File Descriptor)란?
Unix/Linux에서 소켓, 파일, 파이프 등 모든 I/O 리소스를 식별하는 정수 번호다. accept() 로 클라이언트 연결을 수락하면 OS가 정수 하나(예: 5, 12, 47)를 반환하고, 이후 그 번호로 해당 연결을 참조한다.
fd_set이란?
`select`에 감시할 fd 목록을 전달하는 비트맵(bitmap)이다. fd 번호에 해당하는 비트를 1로 켜서 "이 fd를 감시해"라고 알린다. `select`는 반환 시 이 비트맵을 결과로 덮어쓰므로(준비된 fd만 1로 남기고 나머지는 0으로 지움), 다음 루프에서 재사용하려면 매번 통째로 재구성해야 한다.
poll은 위와 같은 select의 한계를 개선하기 위해 등장했다.
poll은 fd_set 비트맵 대신 pollfd라는구조체 배열을 사용하여 두 가지를 개선했다.
- select의 1024 제한은 fd_set이 컴파일 타임에 FD_SETSIZE(1024) 크기로 고정된 비트맵이기 때문에 생기는데, poll의 pollfd 배열은 호출자가 직접 할당하므로 크기 제한이 없다.
- select는 감시 목록과 결과를 fd_set 하나로 공유하기 때문에 호출 후 덮어써져 매번 재구성해야 했지만, poll은 pollfd 구조체 내에서 감시할 이벤트와 결과를 분리했기 때문에 호출 후에도 감시 목록이 그대로 유지된다.
그러나 준비된 fd를 찾으려면 여전히 배열 전체를 순회해야 하고, 매 호출마다 배열 전체를 커널에 복사하는 비용도 남아 있었다. poll은 단순히 fd 개수 제한이라는 문제 하나를 해결했을 뿐, O(N) 순회와 반복 복사라는 더 근본적인 병목은 그대로였다.
epoll의 등장
Linux 2.6에서 도입된 epoll은 위 문제를 근본적으로 해결했다.
// epoll 방식의 의사 코드
int epfd = epoll_create1(0); // epoll 인스턴스 생성 (커널이 레드-블랙 트리 초기화)
// fd 등록은 루프 밖에서 한 번만 — select와 달리 매 호출마다 재전달 불필요
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // EPOLLIN: 읽기 가능 이벤트 / EPOLLET: 엣지 트리거
ev.data.fd = sock1; // 이벤트 발생 시 어떤 fd인지 식별하기 위한 페이로드
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev); // 커널 레드-블랙 트리에 fd 등록
// sock2, sock3... 동일하게 등록
struct epoll_event events[MAX_EVENTS];
while (true) {
// 준비된 이벤트만 반환 → O(준비된 이벤트 수), 등록된 전체 fd 수와 무관
// 준비된 이벤트 없으면 여기서 대기 (-1: 타임아웃 없이 무한 대기)
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
handle(events[i].data.fd); // events 배열에는 준비된 fd만 들어있음 — 순회 낭비 없음
}
}
epoll의 핵심 API 3개:
`epoll_create1()`로 인스턴스를 생성하고, `epoll_ctl()`로 감시할 fd를 등록·수정·삭제한다. `epoll_wait()`는 준비된 이벤트가 생길 때까지 대기했다가 준비된 fd 목록만 반환한다. select와 달리 등록(`epoll_ctl`)과 대기(`epoll_wait`)가 분리되어 있어 fd를 루프마다 재전달할 필요가 없다.
핵심 차이점은 다음과 같다.
| 항목 | select/poll | epoll |
| fd 등록 | 매 호출마다 전달 | epoll_ctl로 한 번만 등록 |
| 준비된 fd 탐색 | O(N) 전체 순회 | O(준비된 이벤트 수), 등록된 전체 fd 무관 |
| fd 개수 제한 | 1024개 (select) | 수십만 개 |
| 커널-유저 복사 | 매 호출마다 fd 집합 전체 복사 | fd 집합 전체의 반복 복사 없음 (준비된 이벤트만 복사) |
epoll이 준비된 이벤트만 즉시 반환할 수 있는 이유는 커널 내부에서 레드-블랙 트리로 등록된 fd를 관리하고, 이벤트가 발생하면 연결 리스트에 추가해두기 때문이다. epoll_wait는 이 리스트만 읽으면 된다.

레벨 트리거 vs 엣지 트리거
epoll은 두 가지 감지 방식을 제공한다.
- 레벨 트리거(Level-Triggered, LT): 버퍼에 데이터가 남아 있는 한 계속 이벤트를 발생시킨다. 안전하지만 이벤트가 반복적으로 발생할 수 있다.
- 엣지 트리거(Edge-Triggered, ET): 상태가 변경되는 순간(데이터가 새로 도착하는 순간)에만 이벤트를 발생시킨다. 이벤트가 누락되지 않도록 한 번에 버퍼를 비워야 하므로 구현 복잡도가 높지만, 잘 구현하면 이벤트 발생 횟수를 줄여 성능상 이점을 얻을 수 있다.

NGINX는 내부적으로 엣지 트리거(ET)를 활용한다. 다만 모든 소켓에 일률적으로 적용하는 것은 아니며, 소켓 유형과 상황에 따라 LT와 ET를 혼용한다. ET를 사용하는 경우 이벤트 발생 횟수를 최소화하여 epoll_wait 호출 빈도를 줄이고, 각 이벤트 처리 시 버퍼를 완전히 소진(EAGAIN이 반환될 때까지 반복 읽기)하는 방식으로 동작한다.
macOS/BSD 환경에서는 epoll 대신 동일한 역할을 하는 kqueue를 사용한다.
epoll/kqueue라는 OS 레벨 메커니즘을 파악했다. 이제 NGINX가 이를 프로세스 구조에 어떻게 녹여냈는지 살펴보자.
NGINX 아키텍처 - 마스터와 워커
NGINX의 프로세스 구조는 단순하지만 영리하다.

- 마스터 프로세스:
트래픽을 직접 처리하지 않는다. 설정 관리, 워커 감시, 무중단 재시작(graceful reload) 등 관리 역할만 수행한다.
- 워커 프로세스:
실제 요청을 처리한다. 일반적으로 CPU 코어 수만큼 생성하며, 각 워커는 독립적인 단일 스레드 이벤트 루프를 가진다. 요청 처리 경로에서는 워커 간 공유 자원이 없으므로 락 경합이 발생하지 않는다. 단, 캐신이나 속도 제한 (rate limiting) 같은 기능에서 사용하는 shared memory zone은 워커 간에 공유되며 여기서는 동기화가 필요하다.
- 캐시 매니저 / 캐시 로더 프로세스:
정적 콘텐츠 캐싱을 담당하는 선택적 프로세스다. 캐시 로더는 시작 시 디스크의 캐시 데이터를 메모리에 로드하고, 캐시 매니저는 만료된 캐시를 정리하는 백그라운드 작업을 수행한다. 워커 프로세스의 이벤트 루프를 방해하지 않도록 별도 프로세스로 분리되어 있다.
- 공유 리슨 소켓:
모든 워커가 동일한 소켓을 공유한다. 새 연결이 들어오면 OS가 워커 중 하나에 분배한다.
Thundering Herd와 accept_mutex
Shared Listen Socket 구조에서는 새 연결이 들어올 때 모든 워커가 동시에 깨어나 accept()를 시도하는 Thundering Herd 현상이 발생할 수 있다. 한 워커만 성공하고 나머지는 헛수고가 되는 것이다.
NGINX는 accept_mutex 옵션으로 이를 해결한다. mutext를 획득한 워커만 Listen Socket을 epoll에 등록하고, 나머지 워커는 기존 연결 처리에 집중한다. 단, NGINX 1.11.3부터 accept_mutex의 기본값이 off로 변경되었다. Linux 3.9에서 도입된 SO_REUSEPORT 소켓 옵션을 사용하면 OS 커널이 직접 워커에 연결을 분산시켜 주므로, accept_mutex 없이도 Thundering Herd를 방지할 수 있기 때문이다. 현재는 SO_REUSEPORT가 권장 방식이다.
이벤트 루프의 작동 방식
소켓과 요청의 구분
이벤트 루프를 정확히 이해하려면 소켓(Socket)과 요청(Request)을 구분해야 한다.
소켓은 서버와 클라이언트 사이의 통신 채널이다. TCP 연결이 수립되는 순간 생성되어 종료될 때까지 유지되는 파이프로, NGINX가 epoll로 감시하는 대상이 바로 이것이다. 요청은 이 소켓 위를 흐르는 데이터다. HTTP/1.1 Keep-alive를 사용하면 하나의 소켓(연결) 위에서 여러 요청이 순차적으로 오간다.
NGINX 워커가 동시에 관리하는 것은 수천 개의 소켓이다. 소켓이 생성될 때 epoll에 등록해두고, 그 소켓에 새 요청이 도착하거나 I/O가 완료될 때만 이벤트 루프가 해당 소켓을 처리한다. 대부분의 소켓은 유휴 상태로 대기 중이며, 이 소켓들은 이벤트 루프를 거의 점유하지 않는다.

Listen Socket Backlog Queue
Listen Socket의 Backlog Queue는 워커가 아직 accept()하지 않은 새 연결들이 대기하는 공간이다. 마치 입장을 기다리는 대기실처럼, 문을 두드렸지만 아직 안으로 들어오지 못한 연결들이 여기에 쌓인다. 이 큐가 꽉 차면 새로운 연결은 바로 거절된다.
트래픽 급증(traffic spike) 구간에서 연결이 거절되지 않도록, OS와 NGINX 설정을 함께 튜닝할 수 있다.
# /etc/sysctl.conf - OS 레벨 튜닝
net.core.somaxconn = 65535 # listen() 백로그 큐 최대 크기 (accept 대기 연결)
net.ipv4.tcp_max_syn_backlog = 65535 # 반열린 연결(half-open) 큐 크기 (SYN 수신 후 핸드셰이크 미완료)
# nginx.conf
http {
server {
listen 80 backlog=65535 reuseport;
// backlog=65535 - 워커가 accept()하기 전 대기 가능한 연결 수 (OS 기본값 128~512로는 트래픽 급증 시 연결 거절 발생)
// reuseport; - SO_REUSEPORT: 워커마다 독립 리슨 소켓 부여 → OS가 직접 워커에 연결 분산, Thundering Herd 방지
}
}
I/O 모델 - 블로킹 vs 논블로킹 vs 다중화
이벤트 루프가 어떻게 블로킹 없이 수천 개의 소켓을 처리하는지 이해하려면, I/O 모델의 차이를 먼저 짚어야 한다.
- 블로킹(Blocking) I/O:
read()호출 시 데이터가 도착할 때까지 스레드가 멈춘다. 전통적인 스레드당 요청 모델의 기본 방식이다. - 논블로킹(Non-blocking) I/O:
read()호출 즉시 반환한다. 데이터가 없으면EAGAIN을 반환하므로, 애플리케이션이 직접 반복 확인해야 한다. - I/O 다중화(Multiplexing):
epoll처럼 여러 fd를 감시하다 준비된 것만 처리한다. NGINX가 사용하는 방식이다. - 비동기(Asynchronous) I/O: OS가 백그라운드에서 I/O를 완료하고 완료 신호를 보낸다. Linux의
io_uring이 대표적이다.

NGINX는 주로 I/O 다중화(epoll/kqueue)를 핵심으로 사용하며, 파일 I/O에서는 스레드 풀을 병행 활용한다.
이벤트 루프 흐름
각 워커 프로세스 내부에서 이벤트 루프는 다음과 같이 동작한다.

핵심은 epoll_wait가 블로킹처럼 보이지만, 실제로는 준비된 이벤트가 생기는 즉시 반환된다는 것이다. 네트워크 I/O에 한해서, 이벤트 루프는 I/O 완료를 기다리며 멈추지 않는다.
Keep-alive 연결과 이벤트 루프의 효율
HTTP/1.1의 Keep-alive 연결은 이벤트 루프 아키텍처와 궁합이 특히 좋다. 스레드 기반 서버에서는 연결이 유지되는 동안 스레드가 묶여 있어야 하지만, NGINX는 해당 연결의 소켓을 epoll에 등록해두고 다른 이벤트를 처리한다. 새 요청이 들어오는 순간에만 해당 연결을 처리한다.
10,000개의 Keep-alive 연결이 있어도, 실제로 동시에 데이터를 주고받는 연결은 그 중 일부에 불과하다. NGINX는 이 특성을 이용해 수만 개의 유휴 연결을 거의 0에 가까운 비용으로 유지한다.
파일 I/O의 특수성 - 스레드 풀 병행
네트워크 소켓과 달리 디스크 파일 I/O는 epoll로 감시할 수 없다. read()가 블로킹되면 이벤트 루프 전체가 멈춘다는 뜻이다. NGINX는 이를 해결하기 위해 별도의 스레드 풀(thread pool)을 운영한다. 파일 읽기 요청은 스레드 풀로 오프로드하고, 완료되면 이벤트 루프에 알림을 보낸다.
# nginx.conf - 스레드 풀 설정
thread_pool default threads=32 max_queue=65536;
location /static/ {
aio threads=default; # 파일 I/O를 스레드 풀로 위임
sendfile on;
}

실제 설정 예시
앞서 설명한 내용이 실제 설정 파일에 어떻게 반영되는지 살펴보자.
# [필수] worker 프로세스를 CPU 코어 수만큼 자동 생성
# 기본값 1로 두면 멀티코어를 전혀 활용하지 못함
worker_processes auto;
# [주의: 환경 의존적] 각 워커를 특정 CPU 코어에 고정
# 베어메탈에서는 CPU 캐시 효율을 높이지만,
# Kubernetes/Docker 같은 컨테이너 환경에서는 CPU 할당이 동적이므로 역효과가 날 수 있음
worker_cpu_affinity auto;
# [필수, OS 설정과 세트] 워커 프로세스당 열 수 있는 최대 파일 디스크립터 수
# OS 기본값은 보통 1024로 연결 수가 이를 초과하면 즉시 차단됨
# /etc/security/limits.conf 또는 systemd LimitNOFILE도 함께 올려야 실제 적용됨
worker_rlimit_nofile 65535;
events {
# 워커 하나당 동시에 처리할 수 있는 최대 연결 수
# HTTP 연결뿐 아니라 upstream 연결, keep-alive 대기 등 모두 포함
worker_connections 10240;
# Linux 2.6+에서 epoll이 이미 기본값 — 명시적 문서화 목적
use epoll;
# [트레이드오프 있음] 이벤트 발생 시 가능한 한 많은 연결을 한 번에 수락
# 고트래픽에서 처리량이 높아지지만, 트래픽이 불균등할 때
# 특정 워커에 연결이 몰려 부하 분산이 깨질 수 있음
multi_accept on;
}
http {
# NGINX 기본값과 동일 (65초) — 변경이 필요한 경우에만 명시
keepalive_timeout 65;
# NGINX 1.11.3부터 기본값이 off로 변경됨
# SO_REUSEPORT 사용 시 OS가 직접 분배하므로 별도 설정 불필요
# accept_mutex off;
server {
# SO_REUSEPORT: 워커마다 독립 리슨 소켓 → OS가 직접 연결 분산, 권장 방식
listen 80 reuseport;
...
}
}
worker_processes auto는 단순해 보이지만 강력하다. 4코어 서버라면 워커 4개가 생성되고, 각각이 독립적인 epoll 인스턴스를 가지며 이론상 4 × 10240 = 40,960개의 동시 연결을 처리할 수 있다.
단, 이 수치는 worker_rlimit_nofile과 OS 레벨 fd 제한이 함께 올라가 있어야 실제로 의미를 가진다.
단일 스레드 이벤트 루프가 강력한 이유
| 항목 | 스레드 기반 | 이벤트 루프 기반 |
| 메모리 (10K 연결) | 수 GB (스택 × 스레드 수) | 수십 MB (연결 컨텍스트만) |
| 컨텍스트 스위칭 | 잦음, 비용 큼 | 거의 없음 |
| 락 경합 | 발생 | 없음 (단일 스레드) |
| CPU 캐시 효율 | 낮음 (캐시 무효화 빈번) | 높음 (같은 스레드가 계속 실행) |
| 확장성 | 연결 수에 비례해 자원 소모 | 연결 수 증가에도 자원 소모 안정적 |
락 경합이 없다는 점은 특히 중요하다. 워커 프로세스 내부는 완전한 단일 스레드이므로 공유 자원 접근에 뮤텍스가 필요 없다. 락 획득과 해제, 대기 과정 자체가 존재하지 않는다.
"단일 스레드"의 정확한 의미
"NGINX는 단일 스레드"라는 말은 실행 모델이 단일 스레드라는 뜻이지, 시스템 전체가 정확히 하나의 스레드로만 동작한다는 뜻이 아니다.
실제로는 여러 워커 프로세스가 각자의 이벤트 루프를 가지고 있고, 파일 I/O를 위한 스레드 풀도 존재한다. "단일 스레드"의 핵심은 요청 처리 경로(critical path)에 락과 컨텍스트 스위칭이 없다는 것이다. 또한 여러 워커 프로세스를 통해 단일 장애점(SPOF) 문제도 해소된다.
NodeJS와의 비교: 유사점과 차이점
NodeJS 역시 이벤트 루프와 비동기 I/O를 사용한다는 점에서 NGINX와 유사하지만, 구조와 목적이 다르다.

| 항목 | NGINX | NodeJS |
| 이벤트 루프 | 워커 프로세스당 1개 | 프로세스당 1개 |
| 파일 I/O | 별도 스레드 풀 오프로드 | libuv 스레드 풀 (기본 4개, UV_THREADPOOL_SIZE로 조정) |
| CPU 집약 작업 | 워커 프로세스 자체가 멈춤 | 이벤트 루프 블로킹 → worker_threads로 해결 |
| 멀티코어 활용 | worker_processes = auto로 자동 | 단일 코어 기본 → cluster 모듈 필요 |
| 주요 강점 | 정적 파일, 리버스 프록시, 로드 밸런싱 | 실시간 API, WebSocket, 서버사이드 렌더링 |
NodeJS에서 fs.readFile이 논블로킹인 이유는 libuv가 내부 스레드 풀에서 실제 파일 읽기를 처리하기 때문이다. 완료되면 이벤트 루프의 콜백 큐에 결과를 넣는다. 하지만 JSON.parse(largeData)처럼 CPU 연산은 이벤트 루프를 직접 점유하므로, 이 시간 동안 다른 요청 처리가 멈춘다.
같은 철학을 채택한 다른 시스템들
이벤트 루프 기반 단일 스레드 실행 모델은 NGINX만의 선택이 아니다. 고성능이 요구되는 여러 시스템이 같은 이유로 같은 철학을 채택했다.
- Redis: 핵심 명령어 처리(SET, GET 등)를 단일 이벤트 루프 스레드에서 수행한다. 모든 연산이 메모리에서 이루어지고 락이 없으므로, 단일 인스턴스가 초당 수백만 건의 명령어를 처리할 수 있다. 디스크 퍼시스턴스(AOF, RDB), 만료 키 삭제 등 무거운 작업은 백그라운드 스레드로 분리한다.
- Netflix Zuul (API Gateway): Netty 프레임워크 기반으로 구축되었으며, Netty의 이벤트 루프를 활용해 수천 개의 API 요청을 소수의 스레드로 처리한다.
- Apache Kafka: 네트워크 레이어에서 이벤트 기반 I/O를 사용한다. 하나의 스레드가 수백 개의 프로듀서·컨슈머 연결을 처리하며, 이것이 Kafka가 극단적인 처리량을 달성하는 핵심 요인 중 하나다.
이 시스템들의 공통점은 명확하다. I/O 대기를 없애고, CPU가 실제 작업에만 집중하게 만든다. 단일 스레드 이벤트 루프는 특정 기술의 기이한 선택이 아니라, I/O 집약적 시스템 설계의 검증된 패턴이다.
NGINX의 한계
이벤트 루프 기반 아키텍처는 I/O 바운드 작업에 최적화되어 있다. 반대로 CPU 바운드 작업에는 치명적이다.
예를 들어 NGINX에서 Lua 스크립트나 복잡한 정규식 매칭이 과도하게 실행되면, 해당 워커의 이벤트 루프 전체가 블로킹된다. 워커가 4개라면 그 워커에 할당된 수천 개의 연결이 전부 멈춘다.

- CPU 바운드 연산이 많은 경우: 애플리케이션 서버(Java, Go, Python 등)로 처리를 위임하는 것이 올바른 설계다. NGINX는 프록시 역할만 담당한다.
- 대용량 요청 본문 처리: 파일 업로드나 스트리밍을 NGINX 단에서 직접 처리할 때 메모리 버퍼 설정에 주의해야 한다.
- TLS 핸드셰이크: CPU 집약적인 연산이므로, 고트래픽 환경에서는 TLS 오프로드를 별도 하드웨어나 전용 서비스로 분리하는 것을 고려할 수 있다.
결국 NGINX가 잘하는 것은 명확하다. 빠르게 받아서, 빠르게 넘기거나 빠르게 반환하는 것이다. 무거운 연산은 백엔드로 위임하는 구조가 NGINX의 성능을 온전히 활용하는 방법이다.
결론: NGINX 아키텍처의 시사점
NGINX의 성공은 단순히 빠른 기술의 도입을 넘어, 문제를 바라보는 시각의 전환에 있다. "어떻게 더 많은 스레드를 효율적으로 관리할까"가 아니라 "어떻게 스레드를 쓰지 않고 동시성을 달성할까"라는 질문에서 출발했다.
이는 개발자들에게 다음과 같은 시사점을 제공한다.
- 워크로드를 먼저 파악하라: I/O 바운드인가, CPU 바운드인가. 이 판단이 아키텍처 선택의 출발점이다.
- OS가 제공하는 메커니즘을 이해하라:
epoll,kqueue,io_uring은 단순한 API가 아니라 커널 수준의 최적화다. 이를 이해할 때 비로소 올바른 도구를 선택할 수 있다. - 단일 스레드가 곧 느린 것은 아니다: 올바른 아키텍처와 결합될 때 단일 스레드는 수천 개의 스레드를 능가하는 처리량을 낼 수 있다. 중요한 것은 스레드 수가 아니라 CPU가 실제 작업에 집중하는 시간의 비율이다.
- 한계를 인정하는 설계가 강하다: NGINX는 잘할 수 있는 것과 못하는 것이 명확하다. 이 경계를 인식하고 시스템을 설계할 때 NGINX의 강점이 극대화된다.
NGINX의 아키텍처는 현대 고성능 시스템 설계의 교과서다. "스레드를 늘리는 대신 I/O 대기 시간을 없앤다"는 비동기 I/O 기반 동시성 사상은 NGINX를 넘어 Rust의 async/await, Java의 Virtual Thread(Project Loom), Python의 asyncio 등 현대 언어와 런타임 전반에 걸쳐 핵심 패러다임으로 자리잡았다.