HomeEngineeringInfrastructure

Nginx Event Loop와 Blocking I/O — Dropbox는 어떻게 해결했나

2026년 3월 21일19 min readavatarYejun Park
Nginx
Linux
Performance
Infrastructure
Concurrency

Nginx Event Loop 구조와 Blocking I/O 문제를 커널 수준에서 파악한 글입니다. epoll, aio threads, sendfile 각각이 왜 필요한지 Dropbox 실측 데이터와 함께 정리했습니다.

'컴퓨터 밑바닥의 비밀'이라는 책의 'Ch2. 프로그램이 실행되었지만, 뭐가 뭔지 하나도 모르겠다' 챕터를 읽으며 Dropbox에서 관련 개념에 대해 문제를 해결한 아티클이 있어 해당 아티클을 바탕으로 Nginx의 Event Loop 동작 방식과 Blocking I/O 처리과정을 학습해보았다.

Dropbox의 해결 과제

Dropbox는 서비스 특성상 정적 파일을 대량으로 서빙하는 서버를 가진다. 이 서버는 두 가지 성격이 다른 요청을 동시에 처리해야 한다.

  • 짧고 빠른 API 요청 (메타데이터 트랜잭션, 수만 TPS)
  • 길고 무거운 파일 전송 (수십 Gbps)

디스크가 HDD일 때, 캐시 미스가 잦을 때, 대용량 파일을 서빙할 때 병목이 발생하기 쉬운 구조다. 두 종류의 요청이 같은 서버에서 공존하며, 빠른 요청이 느린 요청 때문에 막혀서는 안 되고, 느린 요청이 빠른 요청 때문에 끊겨서도 안 된다. 이 문제를 해결하는 핵심이 Event Loop와 비동기 구조다.

이 원칙이 Dropbox에서 어떻게 실현되는지를 아티클에서 다루고 있다.

Nginx Event Loop 구조

Nginx는 CPU 코어 수만큼의 Worker Process를 만들고, 각 Worker 안에서 Event Loop로 수천 개의 연결을 처리한다. 공유 없는 병렬화가 설계 철학이다. Nginx Event Loop *출처 - How Nginx Handles Thousands of Concurrent Requests

Master-Worker 구조

Master 프로세스의 역할

  • 설정 파일 읽기
  • 포트 바인딩 (1024 이하 포트는 root 권한 필요)
  • Worker Process를 fork()로 생성
  • Worker Process 모니터링 및 재시작

Worker 프로세스의 역할

  • 실제 클라이언트 요청 처리
  • 각자 독립적인 Event Loop 실행
  • 서로 메모리 공유 없음

스레드와 달리 프로세스는 독립된 주소 공간을 가지므로 공유 자원으로 인한 race condition이 원천 차단된다. Nginx가 스레드 대신 프로세스 레벨 격리를 택한 이유다.

Worker 프로세스는 특정 CPU 코어에 고정되어 context switching이 발생하지 않는다. 따라서 CPU L1/L2 캐시가 항상 해당 Worker의 데이터로 유지될 수 있다.

worker_processes auto;        # CPU 코어 수만큼
worker_cpu_affinity auto;     # 각 Worker를 특정 코어에 고정

정상 동작 흐름

Loading diagram...

하나의 스레드가 이벤트를 받을 때마다 handler를 호출하고 즉시 다음 이벤트로 넘어가는 과정을 반복하며 non-blocking I/O로 수만 개의 연결을 동시에 처리할 수 있다.

이벤트 루프: 이벤트 수신 → handler 호출 → 다음 이벤트
                              ↑____________|

epoll

selectpoll의 공통 문제는 등록된 fd 전체를 매 호출마다 순회하는 O(N) 구조다. 연결이 많아질수록 선형적으로 느려진다.

epoll은 이 문제를 다르게 접근했다. 관심 있는 fd만 등록해두고, 이벤트가 준비된 fd가 생기면 커널이 직접 알려주는 방식이다. 준비된 이벤트 수 k에만 비례하는 구조로 동작한다.

epoll_create  → 커널에 epoll 인스턴스 생성 (감시 목록 자료구조)
epoll_ctl     → 관심 있는 소켓 fd를 등록/수정/삭제
epoll_wait    → 준비된 이벤트가 생길 때까지 대기, 생기면 즉시 반환

epoll의 두 가지 모드

모드알림 시점특징
Level-Triggered (LT)데이터가 있는 동안 계속 알림구현 쉬움, 알림 중복 가능
Edge-Triggered (ET)상태가 변할 때만 한 번 알림효율적, 데이터를 한 번에 전부 읽어야 함

Nginx는 ET(Edge-Triggered)를 사용한다. ET 모드에서는 알림이 한 번만 오기 때문에 EAGAIN이 반환될 때까지 데이터를 전부 읽어야 한다. 불필요한 syscall이 없어 성능이 좋지만 구현이 복잡하다.

비동기 콜백 구조

호출자는 무엇을 할지 알지만 언제 실행될지 모른다. 피호출자는 언제 실행할지 알지만 무엇을 할지는 콜백에 의존한다. Nginx의 동작이 정확히 이 구조다.

  • Event Loop = 피호출자. 이벤트가 도착하는 시점(언제)을 안다.
  • Handler 함수 = 호출자가 등록한 콜백. 무엇을 처리할지 담겨있다.
  • epoll = 비동기 I/O 완료를 감지하는 커널 인터페이스.

Thread Pool과 결합하면:

Event Loop: "파일 읽기 끝나면 이 콜백 호출해줘"Thread Pool에 위임
Thread Pool: 실제 I/O 수행 (blocking 허용)
Thread Pool: 완료 → Event Loop에 신호
Event Loop: 콜백 실행

Event Loop Stall

Nginx is an event-loop-based web server, which means it can only do one thing at a time. All Nginx does is quickly switch between events, handling one after another.

Inside NGINX: How We Designed for Performance & Scale

단일 스레드 구조에서 Handler 안에 Blocking 호출이 들어오면 스레드 전체가 멈춘다. 그 시간 동안 Event Loop 내 모든 요청이 대기한다.

Loading diagram...

Dropbox가 실제로 측정한 Stall

funclatencyngx_process_events_and_timers 처리 시간을 측정했다. 정상이라면 대부분의 이벤트가 1ms 이내여야 한다. 이중 분포(bimodal distribution) 가 보이면 Stall이 발생하는 것이다.

msecs  : count     분포
01    : 3799      ████████████████████████  ← 정상
215   : 0         (비어있음)
1631  : 409       ████                      ← Stall
3263  : 313       ███
64127 : 128

수백만 번의 이벤트 중 일부가 수십 ms씩 멈추는 것이고, 그 순간 해당 Worker의 모든 요청이 대기하게 된다.

문제 원인: 디스크 I/O

파일 읽기 같은 디스크 I/O는 기본적으로 Blocking이다. Nginx가 파일을 읽을 때 Blocking read()를 호출하면 Event Loop 전체가 멈춘다.

nginx가 access.log 쓰기 → 18ms 지연
nginx가 파일 읽기       → 12ms 지연

18ms 동안 이 Nginx Worker는 아무 요청도 처리하지 못한다.

사례: Cloudflare

Cloudflare는 초당 1,000만 요청 환경에서 read() 이전의 open() syscall 하나가 Event Loop를 막는다는 것을 발견했다.

aio threadsread()를 Thread Pool로 offload했는데 효과가 없었다. Nginx 공식 벤치마크는 4MB 파일 + HDD 환경이라 read() 자체가 오래 걸렸던 것이고, Cloudflare는 60KB 이하 파일 + SSDread() 시간이 짧아 상대적으로 open() 비중이 더 컸다. 같은 설정이라도 파일 크기와 스토리지 특성에 따라 병목 지점이 달라진다.

open()이 하는 일 (이 과정이 모두 Blocking으로 진행됨):

  1. 파일 경로로 inode 찾기
  2. 디렉토리 탐색 — /cache/prefix/dir/EF/BE/CAFEBEEF 경로에서 내부적으로 6번의 inode 조회 발생
  3. 권한 확인

open()도 Thread Pool로 옮기는 패치를 적용해 p99 TTFB가 크게 개선됐다.

해결 방안

1. aio threads — Thread Pool Offload

Dropbox의 실제 구현이 이 방식을 따른다. (Core functionality)

aio threads;
aio_write on;
Loading diagram...

Blocking 작업이 별도의 Worker Thread Pool로 들어갔으므로 Event Loop는 멈추지 않는다. Nginx 공식 벤치마크(4MB 파일 + HDD 기준) 기준 처리량 최대 9배 향상, D-state(디스크 대기) 프로세스 소멸이 보고됐다.

Thread Pool 기본 설정값 (빌드 시 --with-threads 필요)

thread_pool default threads=32 max_queue=65536;

# 디스크별로 분리해 I/O 병렬화
thread_pool disk1 threads=16 max_queue=65536;
thread_pool disk2 threads=16 max_queue=65536;

http {
    location /files/ {
        aio threads=disk1;
        aio_write on;
    }
}
설정기본값
aiooff
sendfileoff
aio threads비활성화 — 빌드 시 --with-threads 플래그 필요
Thread Pool 기본 스펙스레드 32개, 큐 최대 65,536개

2. sendfile on — 파일 전송 시 커널이 직접 처리

sendfile()은 파일 데이터를 user space를 거치지 않고 커널 내에서 page cache → socket buffer로 직접 전달한다. user-space 복사를 제거해 CPU 사용과 메모리 대역폭을 아끼는 것이 핵심이다.

3. aio on + directio — Linux 커널 AIO 인터페이스

파일을 반드시 4KB 정렬된 크기로 읽어야 하고, OS 페이지 캐시를 bypass해 항상 디스크에서 직접 읽는다. 소규모 파일에는 오히려 불리하고 8MB 이상 대용량 파일 전송에 적합하다.

권장 조합

sendfile on;     # 작은 파일 — user-space 복사 제거
aio threads;     # 큰 파일 — Thread Pool로 Blocking I/O offload
directio 8m;     # 8MB 이상 — OS 페이지 캐시 bypass

Socket I/O와 달리 Disk I/O는 epoll을 통해 직접 비동기화할 수 없다.

로그 쓰기도 같은 문제

로그 파일 쓰기도 Blocking이다. Dropbox는 로그를 syslog로 전달해 이 문제를 회피한다.

access_log syslog:server=unix:/dev/log;

syslog는 Unix 도메인 소켓을 통해 커널 버퍼에 전달하는 구조라 디스크 I/O latency 없이 즉시 반환된다. 엄밀히는 blocking syscall이지만 실측 latency가 수 µs 수준으로 Event Loop Stall을 유발하지 않는다.

메모리 버퍼에 모았다가 일정량이 쌓이면 쓰는 방식으로도 Blocking 빈도를 줄일 수 있다.

access_log /var/log/nginx/access.log combined buffer=64k;

정리 — Ch.2 이론과 실제 구현 대응

Ch.2 개념Nginx/Dropbox 실제 구현
Event Loopngx_process_events_and_timers 함수가 반복 실행
Non-blocking I/Oepoll로 I/O 완료 감지, 완료될 때까지 다른 이벤트 처리
Event Handler각 요청/이벤트에 등록된 nginx handler 함수
Blocking 호출 금지 원칙Event Loop Stall로 실제 확인 — 위반 시 해당 Worker 전체 멈춤
Worker Thread로 위임aio threads; — Blocking I/O를 Thread Pool로 offload
비동기 콜백Thread Pool 완료 후 Event Loop에 신호, 콜백 실행