Skip to Content

8장. 스레드에는 천장이 있다 — 22.5배의 범인을 가른다

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

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

1장의 22.5배는 스택 크기 탓이 아니었습니다. 16배로 키워도 지연은 그대로였고, 가상 스레드 10만 개는 인계 지연에 아무 일도 일으키지 않았어요.

면접 실전 질문: ① 대기 중인 스레드가 많으면 왜 느려지나요? ② 스레드 스택 크기를 줄이면 그 문제가 완화되나요? ③ 가상 스레드가 싼 이유를 한 문장으로 말한다면?


배경 — 미뤄둔 빚

1장에서 이런 표를 냈습니다.

배경에서 자는 스레드인계 1회 평균
0개2.3 μs
4,000개51.7 μs (22.5배)

그리고 이렇게 썼어요. “왜 이런 일이 생기는지 이 측정만으로는 단정할 수 없습니다. … 어느 요인이 얼마를 먹는지는 8장에서 하나씩 뜯어 계측합니다.”

7장도 같은 질문으로 끝났습니다. “커널이 깨우는 게 루프 스레드 하나라면, 그 스레드가 깨어나는 비용은 배경에 자는 스레드가 몇 개냐에 따라 달라질까요?”

이번 장이 그 빚을 갚습니다. 그리고 갚고 나면 가상 스레드가 왜 싼지가 저절로 설명됩니다.

스토리 — 용의자는 셋이었다

1장에서 원인 후보로 셋을 나열했습니다.

  1. 스택 메모리와 TLB 압박 — 스레드 4,000개면 스택도 4,000개. 캐시와 TLB에서 다른 데이터를 밀어냈을 수 있다.
  2. OS 스레드 개수 자체 — 커널이 관리해야 할 태스크 구조체가 늘어나 스케줄러 일이 많아졌을 수 있다.
  3. 깨울 스레드를 고르는 비용 — 후보가 많으니 고르는 데 오래 걸렸을 수 있다.

세 개를 한꺼번에 재면 구분이 안 됩니다. 하나씩 떼어내는 실험이 필요해요.

다행히 각각을 겨냥할 손잡이가 있습니다.

가설손잡이예상
H1 — 스택 메모리·TLB-Xss로 스택 크기를 바꾼다스택을 줄이면 지연도 줄어야 한다
H2 — OS 스레드 개수가상 스레드로 바꾼다대기하는 개수는 같은데 OS 스레드가 아니니, 원인이 개수라면 사라져야 한다

H2 쪽 손잡이가 특히 깔끔합니다. 기다리는 주체는 4,000개 그대로 두고 커널이 아는 스레드만 떼어낼 수 있거든요.

다만 미리 실토해둘 게 있습니다. 이 손잡이는 용의자를 하나씩 가르지 못합니다. 가상 스레드로 바꾸면 네이티브 스택 4,000개도, 커널 태스크 구조체 4,000개도, 깨울 후보 집합도 한꺼번에 사라져요. 그러니 H2가 답해주는 건 “셋 중 누구냐”가 아니라 **“셋이 전부 OS 스레드 개수에 매달려 있느냐”**입니다.

그리고 용의자 3에는 손잡이가 아예 없습니다. “깨울 대상을 고르는 비용”은 커널 스케줄러 안의 일이라 유저 공간에서 돌릴 노브가 없어요. 커널 프로파일러가 필요합니다. 그래서 이 장은 3번을 2번과 한 덩어리로 취급합니다 — 둘 다 OS 스레드 개수에 비례하니까요.

측정은 1장과 완전히 같습니다. 배경에 N개를 재워두고, 별도의 두 스레드가 park/unpark로 주고받는 인계 1회 평균을 잽니다. 바뀌는 건 딱 두 줄이에요.

Runnable r = () -> { ready.countDown(); release.await(); }; // 만들자마자 잠든다 if (virtual) Thread.ofVirtual().start(r); // ← 이 두 줄이 else Thread.ofPlatform().daemon().start(r); // ← 표의 전부입니다 // getAllStackTraces()는 '플랫폼' 스레드만 셉니다 — 가상 스레드는 여기 안 잡혀요 liveAtMeasure = Thread.getAllStackTraces().size();

마지막 줄이 중요합니다. 표의 “살아있는 플랫폼 스레드” 열이 이걸로 세어진 값이고, 가상 스레드는 애초에 안 세어집니다. 그래서 10만 개를 띄워도 15가 나오는 거예요.

java -Xss512k Ch08Decompose.java 4000 platform java Ch08Decompose.java 100000 virtual

측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5 / JDK 25. 전체 소스는 docs/book/code/async/Ch08Decompose.java에 있습니다.

핵심 — 용의자 둘을 지운다

H1 기각 — 스택 크기는 범인이 아니다

배경 스레드 4,000개를 고정하고 스택 크기만 바꿔봤습니다.

-Xss스레드당 스택 예약인계 1회
512k0.5 MB46,120 ns
기본(2m)2 MB48,829 ns
8m8 MB48,836 ns

✅ 실측 (같은 환경, 2026-08. 배경 스레드는 세 조건 모두 4,000개, 살아있는 플랫폼 스레드 4,006개로 동일)

스택을 16배 키웠는데 지연은 그대로입니다. 512k가 조금 빠르긴 합니다, 6% 정도요. 그런데 우리가 설명하려는 건 21배예요. 6%로는 21배가 설명이 안 됩니다.

여기서 눈여겨볼 게 하나 있습니다. 총 예약량으로 보면 2GB에서 32GB로 늘어났는데, 램 16GB 기계에서 프로그램이 멀쩡히 돕니다. 예약은 주소 공간일 뿐 물리 메모리가 아니거든요 — 1장에서 본 “2MB 예약, 62KB 사용”이 이겁니다.

그래서 이 실험이 흔든 건 예약량이고, 자고 있는 스레드가 실제로 손댄 페이지 수는 그대로였습니다. 결론도 딱 거기까지만 내야 해요.

H1의 절반이 기각입니다. 스택 하나의 크기는 이 지연과 무관합니다. 스택 개수가 만드는 캐시·TLB 압박은 이 손잡이로 못 지웠습니다.

이게 실무적으로 중요한 이유가 있어요. “스레드가 많아 느리니 -Xss를 줄이자”는 처방을 종종 봅니다. 그 처방으로는 이 문제가 안 풀립니다. 스택을 줄이면 더 많은 스레드를 만들 수는 있지만, 만들고 나면 똑같이 느려집니다.

H2 채택 — 범인은 OS 스레드의 개수다

이번엔 배경 스레드를 가상 스레드로 바꿨습니다. 대기하는 개수는 그대로, OS 스레드만 사라집니다.

배경에서 대기 중살아있는 플랫폼 스레드인계 1회기준 대비
없음 (기준선)62,275 ns
플랫폼 스레드 4,000개4,00648,829 ns21.5×
가상 스레드 4,000개152,255 ns1.0×
가상 스레드 100,000개152,938 ns1.3×

✅ 실측 (같은 환경, 2026-08. 조건마다 독립 JVM. 저부하 구간(23μs)은 회차 편차가 커서 재실행하면 1.93.3μs를 오갑니다 — 아래 해석은 그 편차를 감안한 것입니다.)

1장에서는 같은 조건(플랫폼 4,000개)이 51.7μs였습니다. 여기선 48.8μs예요. 다른 날 다른 JVM에서 다시 돌린 값이라 이 정도(6%)는 흔들립니다. 그래서 이 장의 주장은 ‘21.5’라는 값이 아니라 한 자릿수가 아니라 스무 배대라는 자릿수입니다.

세 번째 줄을 보세요. 가상 스레드 4,000개는 기준선과 구분이 안 됩니다. 2,255 ns vs 2,275 ns — 회차 편차 안이에요.

네 번째 줄. 10만 개를 재워도 기준선과 구분이 안 됩니다. 표에는 2,938 ns로 찍혔지만 재실행하면 1,855 ns가 나오기도 합니다(기준선보다 빠릅니다). 즉 25배 많은 대기를 만들어도 측정 가능한 차이가 안 생겨요. 플랫폼 스레드 4,000개가 21.5배를 만든 것과 정반대입니다.

그리고 두 번째 열이 왜 그런지 말해줍니다. 가상 스레드를 몇 개 띄우든 살아있는 플랫폼 스레드는 15개입니다. 커널이 아는 스레드 수가 안 늘어나요.

여기서 결론이 나옵니다.

비싼 건 기다림 자체가 아니라, 기다림을 OS 스레드로 표현하는 일이었습니다. 4,000개가 대기한다는 사실은 — 인계 지연에 관한 한 — 공짜에 가깝습니다. 그 대기를 OS 스레드로 표현하는 순간 21.5배를 물게 됩니다.

5장에서 “비싼 건 블로킹 함수가 아니라 기다림”이라고 했죠. 8장은 그 기다림의 청구서가 어디서 발행되는지를 말합니다. 대기 그 자체가 아니라 대기를 담는 그릇에서요.

어디까지 밝혀졌고, 어디부터 모르는가

정직하게 선을 그어둡시다. 이번 측정으로 용의자 1의 절반(스택 크기 축)을 지웠고, 나머지는 전부 “OS 스레드 개수”라는 한 다발로 묶였습니다. 하지만 그 다발 안에서 정확히 무엇이 — 스케줄러의 자료구조인지, 커널의 태스크 관리 비용인지, 깨울 대상을 고르는 로직인지, 아니면 네이티브 스택 4,000개가 캐시와 TLB에 주는 압박인지 — 얼마를 먹는지는 여전히 모릅니다. -Xss 실험이 지운 건 스택 하나의 크기개수가 아니었으니까요. 그 안을 가르려면 커널 프로파일러가 필요하고, 이 책의 범위를 넘습니다.

또 이 숫자는 macOS 스케줄러의 것입니다. 리눅스 CFS에서는 기울기가 다를 수 있어요(미측정). 다만 앞 장들과 같은 이야기입니다 — 이 장의 주장은 ‘21.5’라는 값이 아니라 가상 스레드로 바꿨더니 기울기가 사라졌다는 사실입니다.

그럼 천장은 어디로 갔나

1장에서 이 기계는 플랫폼 스레드를 4,068개에서 더 못 만들었습니다. pthread_createEAGAIN으로 거부했고, 원인은 프로세스당 스레드 수 상한(kern.num_taskthreads = 4,096)이었죠.

방금 우리는 가상 스레드 10만 개를 아무 문제 없이 띄웠습니다. 커널이 아는 스레드는 15개뿐이니 그 상한에 걸릴 일이 없어요.

4장에서 “1장의 천장이 실질적으로 문제가 안 되는 높이로 올라간다”고 쓰고 정확한 높이는 17장에서 재기로 했습니다. 여기서 실측이 말해주는 것까지만 적어둡시다. 10만 개까지는 커널의 스레드 수 상한과 무관했습니다. 가상 스레드의 스택은 힙 위의 객체라 커널이 세는 스레드가 아니거든요. 그럼 진짜 천장은 어디냐 — 힙일 가능성이 높지만, 이 장에서는 재지 않았습니다. 17장의 몫입니다.

7장이 남긴 질문 — 1장의 세 값은 어떻게 됐나

7장은 이렇게 물었습니다. “1장에서 본 세 가지 값(개수 천장·자리값·깨우기 비용) 중 무엇이 사라지고 무엇이 남았을까요?” 이제 두 답을 나란히 놓을 수 있습니다.

1장이 잰 값이벤트 루프 (7장)가상 스레드 (8장)
개수 천장 4,068개사라짐 — 스레드 1개로 8,000 연결사라짐 — 10만 개가 떴고 커널 스레드는 15개
자리값 62KB/스레드사라짐 — 스레드가 하나뿐힙으로 옮겨감 (크기는 17장)
깨우기 비용 22.5배사라짐 — 깨울 스레드가 하나뿐사라짐 — 21.5배 → 구분 불가

두 길이 같은 곳에 도착했습니다. 방법은 정반대인데(코드를 뒤집기 vs 스레드를 가볍게 하기) 지운 것은 똑같아요. 4장에서 “출발은 같고 도착 시각이 달랐다”고 한 그 두 답입니다.

2부가 보여준 것

이 장으로 2부가 끝납니다. 네 장을 한 줄씩 이으면 이렇습니다.

  • 5장 — “재운다”는 셀 수 있는 사건입니다. 데이터가 있으면 컨텍스트 스위치 0회, 없으면 정확히 1회. 깨어나는 데만 31μs.
  • 6장 — 재우지 말라고 할 수는 있지만(O_NONBLOCK), 그것만으로는 CPU를 40배 씁니다.
  • 7장 — 그래서 필요한 이벤트 루프는 커널에 없습니다. 유저 공간 while문이고, 스레드 하나가 8,000 연결을 감당했습니다.
  • 8장 — 그 모든 비용의 근원은 OS 스레드의 개수였습니다. 대기 자체가 아니라요.

1부는 “왜 이런 도구들이 생겼나”를 따라왔고, 2부는 “그 밑에서 실제로 무슨 일이 일어나나”를 봤습니다. 이제 남은 질문은 하나예요. 자바는 이걸 어떤 API로 우리에게 건넸나.

정리

  • 스택 하나의 크기는 범인이 아닙니다. -Xss를 512k에서 8m으로 16배 키워도 인계 지연은 46μs → 49μs, 6% 차이였습니다. 21배를 설명하지 못해요. “스레드가 많아 느리니 스택을 줄이자”는 처방은 이 문제를 못 풉니다.
  • 범인은 OS 스레드 개수에 매달린 무언가입니다. 같은 4,000개가 대기해도 가상 스레드면 지연이 기준선과 구분되지 않았고(2,255 vs 2,275 ns), 10만 개를 재워도 마찬가지였습니다. 살아있는 플랫폼 스레드는 내내 15개였고요.
  • 그래서 가상 스레드가 싼 이유는 한 문장입니다. 대기를 OS 스레드로 표현하지 않기 때문입니다.
  • 다만 그 “무언가”를 더 가르지는 못했습니다. 스케줄러 자료구조인지, 태스크 관리 비용인지, 깨울 대상 선택인지, 네이티브 스택 4,000개의 캐시·TLB 압박인지 — 가상 스레드 실험은 넷을 한꺼번에 지웁니다. 그 안을 가르려면 커널 프로파일러가 필요하고, 숫자는 macOS의 것입니다.
  • 천장이 어디로 갔는지는 실측 범위까지만. 10만 개까지는 커널의 스레드 수 상한과 무관했습니다. 진짜 천장은 17장에서 잽니다.

생각해볼 질문

  1. “대기를 OS 스레드로 표현하지 마라”가 결론이라면, 그 결론에 도달하는 길은 두 개였습니다(코드를 뒤집기 vs 스레드를 가볍게 하기). 4장에서 본 그 둘 중 이 장의 측정은 어느 쪽을 지지하나요?
  2. 이벤트 루프 방식도 “OS 스레드를 적게 쓴다”는 점에서는 같습니다. 그럼 이벤트 루프와 가상 스레드의 차이는 어디에 남을까요?
  3. 자바에서 “연결마다 스레드 하나”를 처음 쓴 코드는 어떻게 생겼을까요? 그리고 그 코드는 이 장의 어느 숫자에서 멈출까요?

3번이 3부의 시작입니다. 지금까지 커널과 C를 봤으니, 이제 자바로 올라갑니다. Thread에서 시작해 Future의 한계를 지나 CompletableFuture의 내부까지 — 2부에서 본 기계가 API로 어떻게 포장됐는지 볼 차례예요.

9장. 연결마다 스레드를 주던 시절

Last updated on