Skip to Content

1장. 1만 개의 연결 — C10K가 드러낸 것

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

연결 1만 개를 감당할 하드웨어는 이미 있었습니다. 없었던 건 그걸 쓰는 방법이었어요.

면접 실전 질문: ① C10K 문제는 하드웨어 문제였나요, 소프트웨어 문제였나요? ② 연결당 스레드 모델은 정확히 무엇 때문에 무너지나요? ③ 아무 일도 하지 않고 대기만 하는 스레드도 비용이 있나요?


배경 — 자원은 남는데 서버가 죽는다

여는 글의 그 화면 — CPU 3%인데 새 요청을 못 받던 서버. “놀고 있는 CPU가 이렇게 많은데 왜?”

이 질문을 처음 한 사람은 여러분이 아닙니다. 1999년에 누군가 똑같은 걸 물었고, 그 질문에는 이름까지 붙었어요. C10K — Connection 10,000. 서버 하나가 동시 연결 1만 개를 감당하는 문제입니다.

숫자만 보면 시시합니다. 요즘 웹서버는 노트북에서도 1만 연결쯤 우습게 받으니까요. 하지만 이 문제가 중요한 이유는 숫자가 아니라 진단에 있습니다. C10K가 밝혀낸 건 이거예요.

한계는 하드웨어가 아니라, 연결 하나에 스레드 하나를 붙이는 모델에 있었다.

이 모델에는 이름이 있습니다. 연결당 스레드(thread-per-connection). 이 책에서 계속 이 이름으로 부르겠습니다. 그리고 이 책 전체가 위 진단에서 출발합니다.

스토리 — “웹은 이제 큰 곳이 됐잖아요”

1999년, 댄 케겔(Dan Kegel)이라는 엔지니어가 「The C10K problem」 이라는 페이지를 올립니다. 논문도 아니고 제품 발표도 아닌, 그냥 개인 웹페이지였어요. 첫 문장은 도발에 가깝습니다.

“It’s time for web servers to handle ten thousand clients simultaneously, don’t you think?” (이제 웹서버가 동시에 1만 명을 감당할 때도 되지 않았나요?)

당시 상식으로는 무리한 요구였습니다. 그때 웹서버의 표준은 아파치였고, 아파치 1.x의 방식은 단순했어요. 연결이 하나 들어오면 프로세스를 하나 띄운다(prefork). 몇 해 뒤 아파치 2.0이 스레드 기반 worker MPM을 더하지만 뼈대는 같았습니다. 연결 하나 = 실행 흐름 하나.

이 모델의 장점은 셉니다. 코드가 쉬워요. read()를 부르면 데이터가 올 때까지 알아서 멈춰 있고, 오면 다음 줄이 실행됩니다. 위에서 아래로 읽히는 코드, 순서대로 도는 로직. 디버깅도 쉽고 스택 트레이스도 읽힙니다.

케겔이 지적한 건 이 모델이 틀렸다는 게 아니었습니다. 비싸다는 거였죠. 그리고 그 비용이 연결 수에 정비례해서 붙는다는 것.

그가 페이지에 적어둔 계산을 보면 지적의 무게가 달라집니다.

“You can buy a 1000MHz machine with 2 gigabytes of RAM and an 1000Mbit/sec Ethernet card for $1200 or so. Let’s see - at 20000 clients, that’s 50KHz, 100Kbytes, and 50Kbits/sec per client.” (1,200달러면 1,000MHz에 램 2GB, 기가비트 랜카드를 단 기계를 삽니다. 클라이언트 2만 개로 나누면 하나당 50KHz, 100KB, 50Kbit/s죠.)

📄 문서 기반 — 이 페이지는 1999년에 처음 올라와 2014년까지 갱신됐으므로, 위 사양은 초판이 아니라 개정판의 기준입니다.

논지는 분명합니다. 클라이언트 하나에 50KHz와 100KB면 충분한 일인데, 왜 못 하고 있느냐. 네트워크 대역폭도, 메모리도 부족하지 않았어요. 부족한 건 프로세스와 스레드였습니다.

그가 나열한 선택지들

케겔의 페이지는 해결책 목록이기도 했습니다. 원문은 I/O 전략을 다섯 가지로 나누는데, 오늘의 눈으로 묶으면 네 갈래입니다.

  1. 논블로킹 I/O + 준비 통보로, 스레드 하나가 여러 연결을 담당한다 — 원문에서는 이게 두 갈래로 갈립니다. 레벨 트리거 통보와 변화(엣지) 트리거 통보. 이 구분은 2장에서 다시 만납니다
  2. 비동기 I/O(완료 통보)를 쓴다
  3. 연결마다 스레드를 준다 — 당시의 기본값
  4. 아예 웹서버를 커널 안에 넣는다 (실제로 시도됐습니다 — 리눅스의 TUX, khttpd)

역사가 고른 답은 1번이었습니다. 이 선택지의 이름이 이벤트 기반(event-driven) 서버예요.

물어볼 창구 자체는 1983년부터 있었습니다. select요. 문제는 그 창구가 1만 개 앞에서 무너졌다는 거였죠. 쓸 만한 답은 kqueue(2000)·epoll(2002)에야 옵니다. 창구가 있는데도 20년이 걸린 이야기가 2장입니다.

도구가 생기자 증거는 빠르게 나왔습니다. 2003년 lighttpd, 2004년 nginx가 등장합니다. 특히 nginx는 러시아 포털 Rambler의 시스템 관리자였던 이고르 시쇼프(Igor Sysoev)가 자사 사이트들의 부하를 감당하려고 만든 서버인데, 설계 목표에 C10K가 대놓고 적혀 있었어요. 프로세스 몇 개가 수만 연결을 나눠 맡는 구조였고, 결국 웹서버의 표준 설계를 갈아치웁니다.

핵심 — 잠든 스레드는 정말 공짜인가

여기서 자연스러운 반박이 나옵니다.

“그건 1999년 얘기잖아요. 지금 노트북 메모리가 16GB인데, 스레드 좀 많이 만들면 안 되나요?”

좋은 반박입니다. 그래서 직접 해봤습니다. 측정 환경: Apple M1(8코어), 램 16GB, macOS 26.5, JDK 25 (Corretto). 아래 세 수치는 전부 이 기계에서 실제로 실행해 얻었습니다.

① 스레드는 무한하지 않다 — 4,068개에서 커널이 거부한다

스레드를 계속 만들어봤습니다. 각 스레드는 만들어지자마자 잠들고, 아무 일도 하지 않습니다.

int i = 0; try { for (; i < 20_000; i++) { Thread t = new Thread(LockSupport::park); // 만들자마자 잠든다 t.setDaemon(true); t.start(); } System.out.println("20,000개 전부 생성됨"); } catch (OutOfMemoryError e) { System.out.println("멈춘 지점: " + i + "번째 — " + e.getMessage()); }

LockSupport.park()는 스레드를 대기 상태로 재우는 호출입니다. 커널 관점에서는 그냥 “깨울 때까지 자는” 상태예요. 앞으로 이 책에서 park = 스레드가 잠든다로 읽으시면 됩니다.

결과는 이렇습니다.

[warning][os,thread] Failed to start thread - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached. 멈춘 지점: 4068번째 — unable to create native thread: possibly out of memory or process/resource limits reached

4,068개에서 멈췄습니다. ✅ 실측 (같은 환경, 2026-08. 실행할 때마다 조금씩 달라져 4,067~4,073 사이에서 걸렸습니다 — JVM 자신이 쓰는 스레드 수가 그때그때 다르기 때문이에요.)

전체 소스는 docs/book/code/async/Ch01C10K.java에 있습니다.

주목할 건 예외 이름이 OutOfMemoryError인데 메모리 부족이 아니라는 점입니다. 실제 원인은 pthread_create가 돌려준 EAGAIN, 즉 더 이상 스레드를 만들 수 없다는 거부예요. 이 맥에서는 sysctl kern.num_taskthreads가 4,096이고, 이건 프로세스 하나가 가질 수 있는 스레드 수 상한입니다.

리눅스는 층이 좀 다릅니다. /proc/sys/kernel/threads-max시스템 전체 상한, RLIMIT_NPROC사용자 단위 상한이고, 컨테이너로 돌린다면 cgroup의 pids.max가 대개 가장 먼저 걸립니다. 이름도 범위도 다르지만 결론은 하나예요. 스레드 개수의 천장은 애플리케이션이 아니라 커널이 정합니다.

메모리를 아무리 늘려도 이 벽은 그대로 있습니다. 연결 수와 스레드 수를 1:1로 묶어두는 한, 동시 연결 수의 천장도 커널이 정하게 됩니다.

② 대기만 하는 스레드도 자리값을 낸다

만들어진 스레드는 자고 있어도 자리를 차지합니다. 플랫폼 스레드 2,000개가 대기 중일 때 프로세스 RSS가 125.7MB 늘었습니다. 개당 약 62KB죠. ✅ 실측 (같은 환경, 2026-08 — 측정 과정은 블로킹 I/O와 이벤트 루프에 있습니다)

스레드 하나는 2MB를 예약하고 62KB만 실제로 씁니다. 위 경고 로그의 stacksize: 2048k가 예약분이고, 실제로 손댄 페이지만 물리 메모리를 먹어 62KB로 관측되는 거예요.

③ 자는 스레드 4,000개가 남의 인계 지연을 22.5배로 올린다

앞의 두 개는 어느 정도 예상 가능합니다. 세 번째는 그렇지 않았어요.

배경에 아무 일도 안 하고 park 상태로 자고 있는 스레드를 N개 깔아둡니다. 그 상태에서 별도의 두 스레드가 서로를 깨우고 잠들기를 주고받습니다. 두 스레드가 각각 10만 번씩 도니 인계는 총 20만 번이고, 인계 1회 평균을 잽니다.

가설을 먼저 세워두죠. 자는 스레드가 정말 공짜라면, N이 늘어도 이 시간은 그대로여야 합니다.

// 배경: 아무 일도 안 하고 자기만 하는 스레드 N개 for (int i = 0; i < n; i++) { Thread t = new Thread(() -> LockSupport.park()); t.setDaemon(true); t.start(); } // 측정: 두 스레드가 서로 깨우고 잠들기를 각각 10만 번 (인계 20만 회) final Thread[] pair = new Thread[2]; final int[] turn = {0}; Runnable pingpong = () -> { int me = Thread.currentThread().getName().equals("ping") ? 0 : 1; for (int i = 0; i < 100_000; i++) { while (turn[0] != me) LockSupport.park(); turn[0] = 1 - me; LockSupport.unpark(pair[1 - me]); } }; long t0 = System.nanoTime(); // pair[0], pair[1] 을 "ping"/"pong" 이름으로 start() 하고 둘 다 join() long perHandoff = (System.nanoTime() - t0) / 200_000; // 인계 1회 평균 ns

결과입니다.

배경에서 자는 스레드측정 시점 실제 스레드 수인계 1회 평균기준 대비
0개62.3 μs
100개1063.3 μs1.4×
1,000개1,00618.2 μs7.9×
4,000개4,00651.7 μs22.5×

✅ 실측 (같은 환경, 2026-08). 조건마다 독립된 JVM에서 2회씩 실행한 평균이고, 두 회차 편차는 5% 이내였습니다. 측정 직전에 실제로 살아 있는 플랫폼 스레드 수를 함께 찍어(둘째 열), 배경 스레드가 정말 그만큼 있었는지 확인했습니다.

아무 일도 안 하는 스레드 4,000개가, 무관한 두 스레드의 parkunpark 인계 지연을 22.5배로 늘렸습니다.

여기서 세 가지를 정확히 해둡시다.

첫째, 이건 응답 시간이 아니라 깨우는 비용입니다. 측정한 건 스레드 하나를 재우고 깨우는 인계 한 번이에요. 그 자체로는 51.7 μs, 눈에 띄지도 않는 시간입니다. 문제는 요청마다 자고 깨는 서버라면 이 비용이 요청 수만큼 곱해진다는 데 있습니다.

둘째, 왜 이런 일이 생기는지 이 측정만으로는 단정할 수 없습니다. 스케줄러가 훑어야 할 커널 자료구조가 커진 것, 스레드 스택 4,000개가 캐시와 TLB를 밀어낸 것, 깨울 스레드를 고르는 비용이 늘어난 것 — 이 요인들이 섞여 있을 겁니다(추정). 어느 요인이 얼마를 먹는지는 8장에서 하나씩 뜯어 계측합니다.

셋째, 이 숫자는 macOS 스케줄러의 것입니다. 리눅스 CFS에서는 기울기가 다를 수 있어요(미측정). 다만 기울기가 0이 아니라는 것 — 그게 이 장의 주장입니다.

결론은 귀속과 무관하게 성립합니다. “자고 있으니까 공짜”는 사실이 아닙니다. 스레드는 존재하는 것만으로 값을 치릅니다.

다시, CPU 3%인 서버로

이제 처음의 화면으로 돌아가 봅시다. CPU 3%, 그런데 요청을 못 받던 그 화면.

CPU가 3%인 건 서버가 놀고 있어서가 아닙니다. 모든 스레드가 응답을 기다리며 자고 있어서입니다. 스레드 풀 200개짜리 서버가 100ms 걸리는 외부 API를 부르면, 200개가 전부 자는 동안 새 요청은 큐에서 대기합니다. CPU는 할 일이 없어요. 일할 자격이 있는 스레드가 없으니까요.

엄밀히 하면 요즘 웹서버는 연결이 아니라 요청마다 스레드를 잡습니다(thread-per-request). 연결을 재사용하니 조금 낫지만, “대기를 스레드로 표현한다”는 구조는 똑같아요. 이 구분은 9장에서 다룹니다.

이 진단은 규모만 키워 다시 돌아옵니다. 2013년 로버트 그레이엄(Robert Graham)이 Shmoocon에서 「C10M: Defending the Internet at Scale」을 발표하며 천만 연결을 이야기하는데, 이번에 그가 지목한 병목은 애플리케이션이 아니라 커널 자체였어요. 이 진단은 7장에서 다시 만납니다 — “이벤트 루프는 OS에 없다”와 같은 뿌리입니다.

병목은 계산이 아니라 대기였습니다. 그리고 우리는 대기를 스레드로 표현하고 있었습니다.

정리

  • C10K는 하드웨어 문제가 아니었습니다. 자원은 충분했고, 부족한 건 연결 하나를 스레드 하나로 표현하는 연결당 스레드 모델이었습니다.
  • 이 모델은 세 군데서 값을 치릅니다. 개수 천장(4,068개에서 EAGAIN), 자리값(스레드당 RSS 62KB·주소 공간 2MB 예약), 그리고 자는 스레드가 올리는 깨우기 비용(22.5배). 먼저 무너지는 건 메모리가 아니라 개수 천장이었습니다. 기전 분해는 8장의 몫이에요.
  • 역사가 고른 답은 “스레드 하나가 여러 연결을 맡고, 준비된 것만 커널에게 물어보기”였습니다. nginx가 웹서버의 표준 설계를 갈아치운 방식이 이겁니다. 다만 이 답에는 청구서가 따라옵니다 — 콜백으로 갈라진 코드, 사라지는 로그, 루프를 막는 한 줄. 4부가 그 청구서예요.
  • 이 책의 한 문장을 여기서 미리 꺼내둡니다. 비동기는 더 빨리 하는 기술이 아니라, 기다리는 동안 스레드를 놓아주는 기술입니다. 1장이 보여준 건 “놓아주지 않으면 무슨 일이 생기는가”였어요.

생각해볼 질문

  1. 스레드 풀 크기를 200에서 2,000으로 늘리면 처리량은 10배가 될까요? 위 세 측정 중 어떤 것이 먼저 발목을 잡을까요?
  2. “준비된 연결만 알려달라”고 커널에 물어보려면, 커널은 무엇을 알고 있어야 할까요? 그 목록을 어떻게 관리해야 1만 개에서도 빠를까요?
  3. 자는 스레드가 값을 치른다면, 자지 않는 대기는 어떤 모습이어야 할까요?

2번 질문이 다음 장의 주제입니다. 커널에게 물어보는 방법은 처음부터 있었어요. select라는 이름으로요. 문제는 그 물어보는 방식 자체가 1만 개에서 무너졌다는 겁니다.

2장. select에서 epoll까지 — 커널이 답을 바꾼 20년

Last updated on