한 줄 정의

단일 서버에서 동시 접속 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()POSIXO(n) — 매번 전체 fd 순회1983
poll()POSIXO(n) — select와 동일, fd 수 제한 해제1986
epollLinuxO(1) — 이벤트 발생한 fd만 반환2002 (Linux 2.5.44)
kqueueBSD/macOSO(1) — epoll과 유사2000 (FreeBSD 4.1)
IOCPWindowsO(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-connectionevent-loop경량 스레드 (M:N)
동시 접속 한계~수천 (메모리 제한)~수십만 (fd 수 제한)~수백만
I/O 대기 시 비용OS 스레드 점유 (1MB)없음 (fd만 등록)Heap 객체 (수 KB)
코드 스타일블로킹 (직관적)콜백/리액티브 (복잡)블로킹 (직관적)
대표 구현Apache prefork, Tomcat BIONginx, Node.js, NettyGo, 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)