한 줄 정의
단일 서버에서 동시 접속 10,000개를 처리할 수 없었던 문제. thread-per-connection 모델의 메모리·스케줄링 한계에서 비롯되었으며, 이벤트 기반 I/O(epoll, kqueue)와 경량 스레드 모델의 등장을 이끌었다.
쉽게 말하면
전화 한 통당 상담원 한 명을 배정하는 콜센터에서, 동시 통화가 10,000건이 되면 상담원 10,000명이 필요합니다. 대부분의 상담원은 고객이 서류를 찾는 동안(I/O 대기) 아무것도 안 하고 앉아있는데, 그 자리(메모리)와 월급(스케줄링 비용)은 계속 나갑니다. C10K Problem은 “상담원 수를 늘리는 것 말고 다른 방법이 없는가?”라는 질문에서 시작합니다.
왜 이걸 알아야 하는가?
- 서버 아키텍처 진화의 출발점: Nginx, Node.js, Netty, Go goroutine, Java Virtual Thread, Kotlin Coroutine — 현대 서버 기술의 대부분이 C10K 문제를 해결하기 위해 만들어졌습니다. 이 배경을 모르면 왜 이렇게 많은 동시성 모델이 존재하는지 맥락을 잡을 수 없습니다.
- “왜 Tomcat 대신 Netty를 쓰는가”의 근본 답: thread-per-request 모델의 한계를 설명할 수 있어야 event-loop 모델의 존재 이유를 설명할 수 있고, 더 나아가 Virtual Thread가 왜 “두 모델의 장점을 합친 것”인지 이해할 수 있습니다.
- 스레드 풀 사이징의 이론적 배경: 스레드 풀의 크기를 왜 무한히 늘릴 수 없는지, I/O-bound 워크로드에서 스레드 수와 동시성이 왜 비례하지 않는지를 이 문제가 설명합니다.
왜 이렇게 설계했는가?
- 배경 (1999년): Dan Kegel이 “The C10K Problem”이라는 글을 발표하며 문제를 정의했습니다. 당시 하드웨어(1GHz CPU, 2GB RAM)는 10,000개의 동시 접속을 처리할 충분한 성능이 있었지만, 소프트웨어 모델이 이를 활용하지 못하고 있었습니다.
- 핵심 병목 — 스레드 자체가 비용: thread-per-connection 모델에서 동시 접속 10,000개는 스레드 10,000개를 의미하며, 이때 세 가지 병목이 발생합니다.
병목 1: 메모리
스레드 하나당 스택 메모리가 기본 1MB (Linux 기준 ulimit -s)입니다.
10,000개 스레드 = 10GB 스택 메모리만으로 당시 서버 RAM을 초과했습니다.
스택 외에도 커널의 task_struct, 파일 디스크립터 테이블 등 부가 자원이 스레드마다 할당됩니다.
병목 2: 스케줄링 오버헤드
OS 스케줄러는 모든 스레드를 순회하며 실행 대상을 결정합니다. 스레드 10,000개 중 대부분이 I/O 대기(WAITING) 상태인데도 스케줄러는 이들을 관리해야 하고, 컨텍스트 스위칭이 빈번하게 발생합니다. 실제 작업 시간보다 스레드 전환에 CPU를 더 쓰는 상황이 됩니다.
병목 3: I/O 다중화의 부재
초기 Unix의 select()/poll()은 매 호출마다 모든 파일 디스크립터를 순회하므로 O(n)입니다.
소켓 10,000개를 감시하면 매번 10,000개를 검사해야 하고, 이 자체가 CPU 병목이 됩니다.
어떻게 동작하는가?
해결의 핵심: 이벤트 기반 I/O 다중화
C10K의 돌파구는 OS 커널이 제공하는 효율적인 I/O 이벤트 통지 메커니즘이었습니다.
| 메커니즘 | OS | 시간 복잡도 | 등장 시기 |
|---|---|---|---|
select() | POSIX | O(n) — 매번 전체 fd 순회 | 1983 |
poll() | POSIX | O(n) — select와 동일, fd 수 제한 해제 | 1986 |
epoll | Linux | O(1) — 이벤트 발생한 fd만 반환 | 2002 (Linux 2.5.44) |
kqueue | BSD/macOS | O(1) — epoll과 유사 | 2000 (FreeBSD 4.1) |
| IOCP | Windows | O(1) — 완료 기반 모델 | 2000 (Windows 2000) |
epoll이 O(1)인 이유:
epoll_create()로 관심 fd 집합을 커널에 등록해두면, 커널이 I/O 이벤트가 발생한 fd만 콜백으로 알려줍니다.
10,000개 소켓 중 5개에만 데이터가 도착했으면 5개만 반환하므로, 전체 소켓 수와 무관하게 동작합니다.
select()/poll()이 “매번 전체 명단을 돌며 확인하는 것”이라면, epoll은 “이벤트가 생긴 것만 알림이 오는 것”입니다.
해결 방향의 분화
C10K 이후 서버 동시성 모델은 크게 세 방향으로 나뉘었습니다.
1. 이벤트 루프 (Event Loop): 소수의 스레드가 epoll/kqueue로 I/O 이벤트를 감지하고, 이벤트 핸들러를 호출하는 방식입니다. 스레드를 블로킹하지 않으므로 적은 스레드로 수만 접속을 처리할 수 있습니다. Nginx, Node.js, Netty가 이 모델을 사용하며, 코드가 콜백/리액티브 스타일로 바뀌어야 한다는 단점이 있습니다.
2. 코루틴/경량 스레드 (M:N 모델): 소수의 OS 스레드 위에 수백만 개의 경량 스레드를 올리는 방식입니다. 블로킹 코드 스타일을 유지하면서도 내부적으로는 논블로킹으로 동작합니다. Go goroutine, Java Virtual Thread, Kotlin Coroutine이 이 접근입니다.
3. 하이브리드: 이벤트 루프와 스레드 풀을 조합하는 방식입니다. Netty의 Boss/Worker 모델이나 Spring WebFlux가 이에 해당합니다.
C10K에서 C10M으로
C10K가 해결된 이후, 현재의 도전은 C10M Problem — 단일 서버에서 동시 접속 10,000,000개 처리입니다. 이 단계에서는 커널 바이패스(DPDK, io_uring), 제로카피(zero-copy), 사용자 공간 네트워크 스택 등 OS 커널 자체를 우회하는 기술이 필요합니다.
시각화
thread-per-connection의 한계
동시 접속 10,000개 → 스레드 10,000개
스레드 1: [██░░░░░░░░] DB 대기... (1MB 스택 점유)
스레드 2: [░░░░░░░░░░] I/O 대기... (1MB 스택 점유)
스레드 3: [█░░░░░░░░░] HTTP 대기... (1MB 스택 점유)
...
스레드 10000: [░░░░░░░░░░] I/O 대기... (1MB 스택 점유)
█ = CPU 사용 ░ = I/O 대기 (CPU 유휴)
메모리: 10,000 × 1MB = 10GB (스택만)
CPU: 대부분 컨텍스트 스위칭에 소모
select vs epoll
graph LR subgraph Select["select() — O(n)"] S["매 호출마다"] S --> F1["fd 1 확인 ❌"] S --> F2["fd 2 확인 ❌"] S --> F3["fd 3 확인 ✅"] S --> F4["... 9,997개 더 확인"] S --> F5["fd 10000 확인 ❌"] end subgraph Epoll["epoll — O(1)"] E["커널이 알림"] E --> R1["fd 3: 데이터 도착"] E --> R2["fd 7821: 데이터 도착"] end
정리
| 기준 | thread-per-connection | event-loop | 경량 스레드 (M:N) |
|---|---|---|---|
| 동시 접속 한계 | ~수천 (메모리 제한) | ~수십만 (fd 수 제한) | ~수백만 |
| I/O 대기 시 비용 | OS 스레드 점유 (1MB) | 없음 (fd만 등록) | Heap 객체 (수 KB) |
| 코드 스타일 | 블로킹 (직관적) | 콜백/리액티브 (복잡) | 블로킹 (직관적) |
| 대표 구현 | Apache prefork, Tomcat BIO | Nginx, Node.js, Netty | Go, Virtual Thread, Coroutine |
| 디버깅 | 스택 트레이스 완전 | 스택 트레이스 끊김 | 대부분 완전 |
실무에서 만난 사례
- Apache vs Nginx: Apache의 prefork MPM은 요청당 프로세스를 할당하는 모델로, 동시 접속 수천 개에서 메모리 한계에 부딪혔습니다. Nginx는 이벤트 루프 모델로 같은 하드웨어에서 수만 동시 접속을 처리하며 Apache를 대체하기 시작했습니다.
- Node.js의 등장: “JavaScript로 서버를?”이라는 의문에도 불구하고 Node.js가 성공한 핵심 이유는 싱글 스레드 이벤트 루프로 C10K를 자연스럽게 해결했기 때문입니다. 다만 CPU-bound 작업에서는 이벤트 루프를 블로킹하는 문제가 있습니다.
- Spring의 진화: Spring MVC(Tomcat, thread-per-request) → Spring WebFlux(Netty, event-loop) → Spring Boot 3.2+(Tomcat + Virtual Thread)로 진화한 흐름이 그대로 C10K 해결 방법의 역사를 따릅니다.
관련 개념
출처
- Dan Kegel, “The C10K Problem” (1999) — http://www.kegel.com/c10k.html
- Robert Love, “Linux System Programming” Chapter 2: File I/O
- Linux man pages: epoll(7)