C10K 문제로 살펴보는 서버 아키텍처와 커널 I/O의 진화
개요 C10K 문제란, 단일 서버가 동시에 10,000명(Concurrent 10K connections) 이상의 클라이언트와 연결을 유지하며 통신할 수 있도록 네트워크 소켓 처리 성능을 최적화하는 문제를 말한다.이 용어는 1999
ego2-1.tistory.com
이 글은 멀티프로세싱에서 멀티스레딩, 그리고 멀티플렉싱까지의 진화와 함께, Nginx가 Non-blocking I/O Multiplexing 및 epoll 시스템 콜을 이용해 어떻게 대용량 네트워크 트래픽을 효율적으로 처리하는지 설명한다.
1. 멀티프로세싱 기반

- 클라이언트가 멀티프로세싱 기반 서버에 요청을 보낸다.
- 부모 프로세스가 리스닝 소켓에서 accept()를 호출해 새로운 연결을 수락한다.
- fork()를 통해 자식 프로세스를 생성하고, 새로 생성된 소켓(리스닝 소켓이 아님)의 파일 디스크립터를 자식 프로세스에게 넘긴다.
커넥션마다 프로세스가 할당되어 fork() 및 context switching 비용이 커지는 구조이다.
2. 멀티스레딩 기반

- 프로세스보다 생성 비용은 적지만, 스레드를 만들 때 각각 별도의 stack 공간(일반적으로 1MB)이 필요하다.
- 단순히 1만 명이 접속할 경우 1만 개의 스레드 × 1MB = 10GB 메모리가 필요하다.
- context switching 비용 또한 무시할 수 없다.
3. 멀티플렉싱 기반
멀티플렉싱 시스템 콜을 활용한 다중 소켓 감시
- select, poll, epoll과 같은 system call을 통해 한 개의 스레드가 여러 소켓(File Descriptor)을 동시에 감시할 수 있다.
기존의 Blocking I/O 모델

- 클라이언트 요청마다 read()와 같은 함수 호출이 socket 단위로 blocking된다.
- 커널에 데이터가 없으면 무한정 block 상태가 된다.
- 커널 → user space 복사까지 대기한다.
데이터 이동 경로
- 하드웨어(NIC) → kernel space : 커널이 패킷을 커널 버퍼에 임시 저장한다.
- kernel space → user space : Application 버퍼로 데이터 복사 시 CPU가 개입하며, 이후 스레드를 깨우게 된다.
Multiplexing I/O 모델

- select, poll 등의 system call을 통해 여러 소켓을 동시에 감시한다.
- 읽을 준비가 된 소켓에 대해서만 I/O 작업을 진행한다.
- 커널이 관리하는 소켓의 데이터가 없으면 프로세스는 block 상태이며,
- 데이터가 도착할 때 커널이 이를 감지해서 readable 상태를 알리고,
- 프로세스는 준비된 소켓만 읽게 된다.
I/O Models
| 구분 | Blocking | Non-blocking |
| Synchronous (동기) | (1) Legacy Blocking I/O (Apache, Java IO) | (2) Non-blocking I/O (Busy Wait, 비효율) |
| Asynchronous (비동기) | (3) I/O Multiplexing (Nginx, Node.js - epoll) | (4) True Async I/O (AIO) (Windows IOCP, Linux AIO) |
1) Blocking I/O (구형 Apache)
- read() 호출 후 데이터가 유저 메모리로 모두 복사될 때까지 프로세스는 sleep 상태가 된다.
- CPU가 놀게 되고, 그래서 Apache는 스레드를 수천 개 만들어야 했다.
2) Non-blocking I/O (Busy Wait)
- 소켓을 O_NONBLOCK으로 만들고, read()를 호출했을 때 데이터가 없으면 즉시 EAGAIN을 반환한다.
- 프로세스는 멈추지 않지만, 계속 무한루프로 read를 호출해 polling을 하므로 CPU가 100% 소모되어 비효율적이다. (실무에서는 단독으로 사용하지 않는다.)
3) Synchronous Non-blocking I/O Multiplexing (Nginx의 핵심)
일반적으로 "Nginx는 Asynchronous하다"라고 하지만, 엄밀히 말하자면 I/O Multiplexing(다중화) 모델이다.
Nginx 내부 동작 (커널 레벨 중심)
- Non-blocking 소켓 사용
- Nginx는 모든 연결 소켓을 Non-blocking으로 만든다. (accept4(..., SOCK_NONBLOCK))
- 그래서 Nginx 워커는 I/O 때문에 멈추지 않는다.
- Blocking 지점(epoll_wait)
- Nginx도 Blocking을 한다. 단, read를 하며 기다리는 것이 아니라 "이벤트 발생 대기"를 한다.
- epoll_wait는 Blocking 함수이나, 1만 개 소켓 중 하나라도 신호가 오길 감시한다.
- Sync(동기)적 처리
- epoll이 데이터 준비 알림을 주면, 데이터 읽기(read)는 워커가 직접(Sync) 수행한다.
- 이 순간에는 CPU 자원을 다시 소비한다.
(번외) 대용량 데이터의 경우 추천되는 I/O
Asynchronous I/O (AIO)
- 진정한 비동기(Async)는 알림(Readiness)이 아니라 완료(Completion) 통지 방식이다.
- Sync (epoll): "데이터 준비됐으니 네가 가져가." (Readiness)
- Async (AIO): "메모리에 복사까지 다 해놨으니 너는 읽기만 해." (Completion)
- 네트워크 소켓의 Non-blocking은 쉽지만, 디스크(File) I/O는 리눅스에서 오랫동안 Non-blocking이 불가능했다. 파일 읽기는 반드시 블록될 수밖에 없었다.
- 그래서 "파일 전송"이나 "대용량 파일 쓰기" 등 특수 케이스에서는 리눅스의 AIO(Asynchronous I/O) 기능을 쓴다.
# nginx.conf
http:
server:
listen 80
server_name localhost
root /var/www/videos;
location /video/:
# 핵심: aio 사용
aio on;
# 필수: directio 미설정 시 AIO 무용지물
directio 4m;
# 선택: AIO 버퍼 크기 조정 (기본값은 작을 수 있음)
output_buffers 1 128k;
동작 과정
- Nginx가 커널에 "파일을 읽어줘"라고 요청 후 즉시 리턴한다.
- 다른 요청을 동시에 처리한다.
- 커널이 디스크에서 데이터를 다 읽으면 시그널을 보낸다.
- 그제서야 Nginx가 데이터를 네트워크로 전송한다.
System Calls

- select/poll: 모든 FD에게 일일이 물어봐야 해서(탐색) 규모가 커질수록 느려지며 O(n)이다.
- epoll: 이벤트가 발생한 FD만 커널이 별도 리스트로 제공하므로 O(1)로 동작한다.
epoll man 결과
리눅스 쉘에 epoll man을 하면 system call에 대한 도큐먼트를 볼 수 있다.

1) 자료구조: Interest List vs Ready List
Interest List (관심 목록)

- FD 등록(ADD)/수정(MOD)/삭제(DEL) 시 Red-Black Tree로 관리되어 O(logN) 속도를 보장한다.
Ready List (준비 목록)

- 실제 I/O가 발생해서 읽을 수 있는 FD들의 목록으로, Doubly Linked List로 구현되어 있다.
- epoll_wait는 이 리스트의 FD만 유저에게 넘기므로 활성화된 일부 소켓만 O(1)로 처리할 수 있다.
2) Edge Triggered(ET) vs Level Triggered(LT)
epoll의 Level-Triggered(LT)와 Edge-Triggered(ET)는 언제 이벤트를 알릴지를 결정하며, 고성능 서버에선 핵심이 된다.

예시: 파이프에 데이터를 쓸 때
- 파이프에 2KB 데이터 쓰기
- epoll_wait() → "rfd 준비됨" 신호
- 1KB만 읽고 1KB 남김
- 다시 epoll_wait() 호출
Level Triggered (LT, 기본 모드)

- 데이터가 버퍼에 남아 있는 동안 계속 신호를 준다.
- select/poll과 유사하며, nginx의 기본값이다.
Edge Triggered (ET, EPOLLET)

- 상태 변화 시점에 한 번만 신호 주고, 이후 데이터가 남아도 추가 신호가 없다.
- 한 번의 신호를 놓치면 남은 데이터를 읽지 못해 데드락 위험이 있다.
ET 모드의 올바른 사용법 (nginx 방식)
- Non-blocking 소켓을 써야 한다.
- read()/write()가 EAGAIN을 반환할 때까지 반복해서 읽거나 쓴다.
- EAGAIN이 나오면 다시 epoll_wait으로 돌아간다.
| 알림 방식 | 데이터 있을 때마다 | 상태 변화 시 한 번만 |
| 안전성 | 높음 (놓칠 일 없음) | 낮음 (놓치면 데드락) |
| 성능 | 보통 | 매우 좋음 (thundering herd 방지) |
| 사용법 | 간단 | Non-blocking + 반복루프 필수 |
| nginx 기본 | O (안전 우선) | 필요시 ET+EPOLLONESHOT |
반응형
댓글