Day 37 — 가상 스레드: 막혀도 괜찮은 가벼운 스레드
목차 18
지난 시간 Day 36 에서 우리는 CompletableFuture 로 "결과가 올 때까지 스레드를 막지 않는" 법을 배웠어요. 작업을 점으로 줄줄이 이어 붙여서, 기다리는 동안에도 흐름이 멈추지 않게 했죠. 그런데 마지막에 한 가지를 살짝 흘려뒀어요. 아무리 안 막아도, 그 일을 하는 스레드 하나하나가 사실 꽤 비싼 자원 이라는 점이었어요.
지난 두 시간 동안 계속 강조한 게 있죠. "스레드를 만드는 데 시간과 메모리가 든다", "공용 풀의 일꾼은 몇 명뿐이라, 다들 외부 응답을 기다리며 막혀 있으면 뒤에 줄 선 작업이 굶주린다". 결국 우리가 부딪힌 벽은 똑같았어요. 스레드가 비싸서 많이 못 만든다. 그래서 안 막으려고 코드를 복잡하게 비동기로 쪼개야 했던 거예요.
오늘은 그 벽 자체를 허무는 도구, 가상 스레드(virtual thread) 를 배워요. 자바 21부터 정식으로 들어온 기능인데, 한마디로 "막혀도 부담 없을 만큼 가볍게 만든 스레드" 예요. 얼마나 가볍냐면, 수만 개를 동시에 만들어도 끄떡없어요. 그래서 "한 작업에 스레드 하나씩" 이라는 가장 단순한 모델이 다시 통하게 됐죠. 오늘 "스레드를 안 막으려고" 애쓴 지난 이야기가, "막혀도 괜찮은 스레드" 라는 새로운 답으로 이어져요. 시작해봐요!
🎯 학습 목표
- 플랫폼 스레드(전통 스레드)가 왜 비싼지, 그래서 무엇이 한계였는지 이해해요.
- 가상 스레드를 만드는 두 가지 방법
Thread.ofVirtual()과Thread.startVirtualThread()를 다뤄요. - 가상 스레드를 수만 개 만들어도 끄떡없는 이유를
isVirtual()로 확인하며 체감해요. - 가상 스레드가 어떻게 가벼운지 — carrier(운반) 스레드 위에서 갈아타는 원리를 그림으로 잡아요.
Executors.newVirtualThreadPerTaskExecutor()로 "한 작업 한 스레드" 모델을 되살려요.- 가상 스레드가 빛나는 곳(블로킹 작업)과 이득이 없는 곳(CPU 작업)을 구분하고, 풀링하면 안 되는 이유를 알아요.
오늘의 로드맵
- Step 1 — 왜 가상 스레드인가: 플랫폼 스레드는 비싸다.
- Step 2 — 첫 가상 스레드 만들기:
Thread.ofVirtual()·startVirtualThread(). - Step 3 — 만 개를 만들어도 끄떡없다: 스케일 비교.
- Step 4 — 어떻게 가벼운가: carrier 스레드와 마운트·언마운트.
- Step 5 — 한 작업 한 스레드의 부활:
newVirtualThreadPerTaskExecutor(). - Step 6 — 언제 쓰고 언제 안 쓰나: 블로킹엔 좋고 CPU엔 이득 없다.
- Step 7 — (맛보기) 묶어서 다루기: Structured Concurrency.
Step 1: 왜 가상 스레드인가 — 플랫폼 스레드는 비싸다
지금까지 우리가 만든 스레드는 전부 플랫폼 스레드(platform thread) 였어요. 새 이름 같지만 어렵지 않아요. Day 33 부터 new Thread() 나 스레드풀로 만들던 그 스레드가 바로 플랫폼 스레드예요. 이름이 "플랫폼" 인 이유는, 이 스레드 하나가 운영체제(OS) 가 직접 관리하는 진짜 스레드 하나와 1:1 로 묶여 있기 때문이에요.
이게 왜 문제일까요? OS 스레드는 비싸요. 하나를 만들 때마다 그 스레드가 함수 호출 정보를 쌓아둘 공간(스택)을 통째로 잡는데, 보통 1MB 안팎이에요. 1,000 개면 약 1GB, 10,000 개면 약 10GB 예요. 평범한 서버 메모리로는 감당이 안 되죠. 게다가 OS 가 스레드를 이리저리 바꿔 끼우는(컨텍스트 스위칭) 비용도 만만치 않아요.
플랫폼 스레드 — OS 스레드와 1:1 로 묶임
Thread-1 ──── OS 스레드 (스택 ~1MB)
Thread-2 ──── OS 스레드 (스택 ~1MB)
Thread-3 ──── OS 스레드 (스택 ~1MB)
... ...
10000개 ──→ 메모리 ~10GB + 생성·전환 비용 폭발 ✗
지난 시간에 부딪힌 벽이 바로 이거였어요. 인스타그램 서버에 동시에 5만 명이 접속하면, "한 명당 스레드 하나" 로 처리하고 싶어도 5만 개의 플랫폼 스레드를 만들 수가 없어요. 메모리가 터지니까요. 그래서 어쩔 수 없이 스레드풀로 일꾼 수를 코어 수만큼(수십 개) 제한하고, 그 적은 일꾼을 여러 작업이 나눠 쓰게 했죠.
그런데 각 작업이 외부 응답을 기다리느라 오래 막혀 있다면? 일꾼은 수십 명뿐인데 다들 멍하니 응답을 기다리며 묶여 있고, 뒤에 줄 선 수천 개의 작업은 일꾼이 빌 때까지 굶주려요. Day 36 마지막에 "병렬이 만능이 아닌 이유" 로 짚었던 그 장면이에요. 문제의 뿌리는 하나였어요. 스레드가 비싸서, 막히는 작업 하나하나에 넉넉히 배정할 수가 없다.
💡 플랫폼 스레드는 OS 스레드와 1:1 로 묶인 무거운 스레드예요. 하나에 약 1MB 의 메모리가 들어서 수만 개를 만들 수 없고, 그래서 "막히는 작업마다 스레드 하나씩" 이라는 단순한 방법을 쓸 수 없었어요. 오늘 배울 가상 스레드는 바로 이 "비싸다" 는 전제를 무너뜨려요.
Step 2: 첫 가상 스레드 만들기 — Thread.ofVirtual() · startVirtualThread()
그럼 가벼운 스레드를 직접 만들어볼까요. 만드는 법은 어렵지 않아요. 기존 Thread 에 "가상으로 만들어줘" 라고 부탁하는 방법이 새로 생겼을 뿐이에요.
가장 기본은 Thread.ofVirtual() 로 빌더를 받아 start() 로 시작하는 거예요. 그리고 지금 작업을 돌리는 스레드가 가상인지 아닌지는 isVirtual() 로 물어볼 수 있어요. 가상이면 true 를 돌려줘요.
// com/instagram/javabasic/concurrent/VirtualThreadIntro.java
// 가상 스레드 위에서 작업을 돌려보고, 정말 가상 스레드에서 실행됐는지 알려줘요.
public static boolean runsOnVirtualThread() throws InterruptedException {
AtomicBoolean wasVirtual = new AtomicBoolean(false);
// ofVirtual() 로 가상 스레드를 만들어 start() 로 시작해요.
Thread vt = Thread.ofVirtual().start(() -> {
wasVirtual.set(Thread.currentThread().isVirtual()); // 지금 이 스레드가 가상인가?
});
vt.join(); // 작업이 끝날 때까지 기다려요 (Day 33 에서 배운 join)
return wasVirtual.get();
}
runsOnVirtualThread() 를 실행하면 true 가 나와요. ofVirtual().start() 로 만든 스레드 위에서 람다가 돌았고, 그 안에서 isVirtual() 이 true 였다는 뜻이에요. 작동 방식은 우리가 아는 스레드와 똑같아요. start() 로 시작하고 join() 으로 끝나길 기다리죠. 가벼워졌을 뿐, 다루는 법은 그대로예요.
자주 쓰는 짧은 표현도 있어요. Thread.startVirtualThread(작업) 은 ofVirtual().start(작업) 과 똑같은데 한 줄로 끝나요. 비교용으로, 예전 방식인 Thread.ofPlatform().start(...) 로 만들면 그건 무거운 플랫폼 스레드라 isVirtual() 이 false 예요.
// 더 짧게: startVirtualThread 는 ofVirtual().start() 의 줄임 표현이에요.
Thread vt = Thread.startVirtualThread(() -> System.out.println("가벼운 스레드!"));
// 비교용 — 전통 플랫폼 스레드는 isVirtual() 이 false 예요.
Thread pt = Thread.ofPlatform().start(() -> { /* ... */ });
🙋 학생 질문 — "튜터님, 그럼 이제 new Thread() 는 안 쓰고 전부 가상 스레드로 만들면 되나요?"
좋은 질문이에요. 결론부터 말하면 "막히는 작업이라면 그래도 돼요" 예요. 네트워크 응답이나 파일을 기다리는 작업처럼 대부분의 시간을 "기다림" 으로 보내는 일은 가상 스레드가 딱이에요. 사실 그게 서버가 하는 일의 대부분이라, 요즘 자바에선 이런 작업에 가상 스레드를 기본으로 권해요.
다만 "전부" 는 아니에요. 계산만 빡세게 하는 작업(이미지 변환, 암호 계산 같은)은 가상 스레드로 바꿔도 빨라지지 않아요. 이건 Step 6 에서 따로 짚을게요. 지금은 "기다리는 작업엔 가상 스레드" 라고만 기억해두면 충분해요.
Step 3: 만 개를 만들어도 끄떡없다 — 스케일 비교
가상 스레드가 가벼운 게 왜 좋을까요? 한꺼번에 아주 많이 만들어도 끄떡없기 때문이에요. 말로만 하면 와닿지 않으니 직접 1만 개를 만들어볼게요.
아래 코드는 가상 스레드를 count 개 만들어, 각자 잠깐 쉰(sleep) 뒤 "나 끝났어요" 하고 카운터를 1 올리게 해요. 마지막에 카운터가 count 와 같으면 전부 무사히 끝난 거예요.
// com/instagram/javabasic/concurrent/ThreadScaleDemo.java
// 가상 스레드 count 개를 만들어 전부 실행하고, 끝까지 완료된 개수를 돌려줘요.
public static int runManyVirtualThreads(int count) throws InterruptedException {
AtomicInteger completed = new AtomicInteger(0); // 여러 스레드가 함께 올리므로 Atomic
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < count; i++) {
Thread vt = Thread.ofVirtual().start(() -> {
sleepQuietly(50); // 짧은 멈춤 = 외부 응답을 기다리는 척
completed.incrementAndGet(); // 다 했으면 카운터 +1
});
threads.add(vt);
}
for (Thread t : threads) { // 만들어 둔 스레드가 전부 끝날 때까지 대기
t.join();
}
return completed.get();
}
runManyVirtualThreads(10_000) 을 실행하면 10000 이 나와요. 1만 개의 가상 스레드가 각자 50ms 씩 쉬었는데도, 전체는 거의 순식간에 끝나요. 코드베이스 ThreadScaleDemoTest.java 에서 이 1만 개가 빠짐없이 완료된다는 걸 확인해뒀어요.
여기서 멈칫할 수 있어요. "1만 개가 각자 50ms 쉬면 합쳐서 500 초 아닌가요?" 아니에요. 그게 가상 스레드의 핵심이에요. 가상 스레드는 sleep 처럼 막히는 순간 OS 스레드를 놓아줘요. 그래서 1만 개가 동시에 쉬어도, 그 쉬는 동안 OS 스레드는 다른 가상 스레드를 처리해요. 50ms 가 1만 번 줄 서는 게 아니라, 1만 개가 거의 동시에 쉬고 거의 동시에 깨어나는 거죠.
같은 일을 플랫폼 스레드 1만 개로 시도하면 어떻게 될까요? 앞에서 봤듯 메모리가 약 10GB 필요해서 대개 그 전에 프로그램이 쓰러져요. 똑같은 1만 개인데 결과가 하늘과 땅 차이예요.
같은 "1만 개 작업"
플랫폼 스레드 1만 개
■ ■ ■ ... ■ ■ → OS 스레드 1만 개를 통째로 점유 → 메모리 폭발 ✗
가상 스레드 1만 개
▪ ▪ ▪ ... ▪ ▪ → 소수의 OS 스레드가 번갈아 처리 → 가뿐 ✓
💡 가상 스레드는 막히는 순간 OS 스레드를 놓아주기 때문에, 수만 개를 동시에 만들어도 메모리가 터지지 않아요. "스레드를 많이 못 만든다" 는 지난 시간의 벽이 여기서 사라져요.
Step 4: 어떻게 가벼운가 — carrier 스레드와 마운트·언마운트
여기서 한 발 더 들어가 볼게요. 가상 스레드는 대체 어떻게 이렇게 가벼울 수 있을까요? "아직 몰라도 돼요" 라고 넘어가도 되지만, 원리를 한 번 그려두면 나중에 헷갈릴 일이 없어요.
핵심은 이거예요. 가상 스레드도 결국 진짜 일을 하려면 OS 스레드 위에서 돌아야 해요. 그런데 OS 스레드를 독차지하지 않고 잠깐씩 빌려 타요. 이때 가상 스레드를 등에 업고 실제로 굴려주는 OS 스레드를 carrier(캐리어, 운반) 스레드 라고 불러요.
가상 스레드가 sleep 이나 네트워크 응답 대기처럼 "막히는" 순간이 오면, carrier 에서 스르륵 내려와요(unmount, 언마운트). 그러면 비어버린 그 carrier 에 다른 가상 스레드가 올라타서(mount, 마운트) 일을 시작해요. 막혀 있던 가상 스레드는 응답이 오면 다시 빈 carrier 에 올라타 이어서 돌고요.
carrier = 진짜 OS 스레드. 그 위에 가상 스레드가 갈아탄다.
[carrier-1] ← VT-A 올라탐(mount), 실행 중
│
│ VT-A 가 막힘 (sleep · 네트워크 대기)
↓
[carrier-1] ← VT-A 내려옴(unmount), VT-B 올라탐(mount)
│
↓ VT-A 가 막힌 동안 carrier 는 놀지 않고 VT-B 를 처리해요
이게 바로 "막혀도 부담 없는" 의 정체예요. 플랫폼 스레드는 막히면 그 OS 스레드도 같이 묶여서 아무 일도 못 했어요. 가상 스레드는 막히는 순간 OS 스레드를 풀어줘서, 그 비싼 자원이 놀지 않아요. carrier 몇 개만 있으면 수만 개의 가상 스레드를 번갈아 태워 돌릴 수 있는 거죠. 이 carrier 들은 자바가 내부적으로 마련해 둔 풀에서 와요. 우리가 직접 만들 필요는 없어요.
🙋 학생 질문 — "튜터님, 옛날 자료를 보면 synchronized 안에서 막히면 carrier가 묶인다는 경고가 있던데요?"
기억력이 좋으시네요. 맞아요, 예전 자바(21~23) 에는 그런 함정이 있었어요. 가상 스레드가 synchronized 블록(Day 34 에서 배운 그 동기화) 안에서 막히면, 예외적으로 carrier 에서 못 내려와 묶여버리는 경우였죠. 이걸 "pinning(피닝, 핀으로 박힘)" 이라고 불렀어요.
그런데 자바 24 부터 이 문제가 거의 해결됐어요. 이제는 synchronized 안에서 막혀도 carrier 를 제대로 놓아줘요. 그래서 지금 우리가 쓰는 자바(25) 에서는 이 옛 함정을 크게 신경 쓰지 않아도 돼요. "예전엔 조심할 게 있었는데 지금은 고쳐졌다" 정도로만 알아두면 충분해요.
Step 5: 한 작업 한 스레드의 부활 — newVirtualThreadPerTaskExecutor()
이제 실전이에요. Day 35 에서 ExecutorService 로 일감을 풀에 던졌던 걸 떠올려봐요. 그때 늘 따라다니던 고민이 있었죠. "풀 크기를 몇으로 잡지?" 너무 작으면 일꾼이 부족해 줄이 밀리고, 너무 크면 무거운 플랫폼 스레드가 메모리를 잡아먹으니, 적당한 숫자를 찾느라 머리를 싸맸어요.
가상 스레드는 이 고민 자체를 없애줘요. Executors.newVirtualThreadPerTaskExecutor() 라는 긴 이름의 executor 가 있는데, 이름을 그대로 읽으면 "작업(task) 하나당 가상 스레드 하나(per task)" 예요. 일감을 던질 때마다 새 가상 스레드를 하나씩 띄워줘요. 가상 스레드는 가벼우니, 일감이 1만 개여도 그냥 1만 개를 띄우면 돼요. 풀 크기를 정할 필요가 사라진 거예요.
// com/instagram/javabasic/concurrent/VirtualFeedLoader.java
// 게시물 postCount 개를 동시에 불러오고, 다 불러온 개수를 돌려줘요.
public static int loadFeed(int postCount) {
AtomicInteger loaded = new AtomicInteger(0);
// try-with-resources: 블록을 벗어나면 모든 작업 완료를 기다린 뒤 정리해요.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int postId = 1; postId <= postCount; postId++) {
executor.submit(() -> {
sleepQuietly(20); // 게시물 한 개를 네트워크로 불러오는 척 (블로킹)
loaded.incrementAndGet();
});
}
} // ← 여기서 모든 게시물 로딩이 끝날 때까지 기다린 뒤 닫혀요
return loaded.get();
}
쓰는 모양은 Day 35 의 ExecutorService 와 똑같아요. submit() 으로 일감을 던지고, try-with-resources (Day 23 에서 배운 그 괄호) 로 자동 정리하죠. 딱 하나, 풀을 만드는 줄만 newFixedThreadPool(4) 에서 newVirtualThreadPerTaskExecutor() 로 바뀌었어요. 그런데 이 한 줄 차이로, "일꾼 4명이 100개의 일감을 나눠 처리" 하던 게 "100개의 일감에 100개의 가상 스레드가 각자 붙어 동시에 처리" 로 바뀌어요.
바로 이게 지난 시간에 흘려둔 그림의 답이에요. 막히는 작업마다 스레드 하나씩 넉넉히 붙여도 부담이 없으니, 굳이 CompletableFuture 로 잘게 쪼개지 않고도 그냥 "한 작업 한 스레드" 로 단순하게 쓸 수 있어요. 복잡한 비동기 조합 없이, 평범하게 위에서 아래로 읽히는 코드를 쓰면서도 수만 개를 동시에 처리하는 거죠.
💡
newVirtualThreadPerTaskExecutor()는 작업마다 가벼운 가상 스레드를 하나씩 띄워줘요. 풀 크기를 고민할 필요가 없어지고, "한 작업 한 스레드" 라는 가장 읽기 쉬운 모델이 다시 통하게 돼요. Day 35 코드에서 풀 만드는 한 줄만 바꾸면 끝이에요.
Step 6: 언제 쓰고 언제 안 쓰나 — 블로킹엔 좋고 CPU엔 이득 없다
가상 스레드가 이렇게 좋으면 "무조건 다 가상으로!" 싶지만, 두 가지만 기억하면 헷갈릴 일이 없어요.
첫째, 가상 스레드는 풀에 모아 재사용하지 마세요. 플랫폼 스레드는 비싸서 풀에 모아두고 돌려 썼지만, 가상 스레드는 싸니까 그럴 이유가 없어요. 작업마다 새로 하나씩 만들면 돼요. 실제로 newVirtualThreadPerTaskExecutor() 가 바로 그렇게 동작하는데, 아래 코드로 "작업마다 매번 새 스레드" 라는 걸 눈으로 확인할 수 있어요.
// com/instagram/javabasic/concurrent/VirtualThreadPitfalls.java
// 작업마다 새 가상 스레드가 만들어지므로, 서로 다른 스레드 수 = 작업 수예요.
// (= 재사용이 없다 = 풀로 모아둘 이유가 없다)
public static int distinctThreadCount(int taskCount) {
Set<Long> threadIds = ConcurrentHashMap.newKeySet();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> threadIds.add(Thread.currentThread().threadId()));
}
}
return threadIds.size(); // taskCount 와 같음 → 매번 새 스레드라는 증거
}
distinctThreadCount(50) 은 50 이에요. 50개의 작업이 서로 다른 50개의 스레드에서 돌았다는 뜻이죠. 재사용이 전혀 없으니, 굳이 풀에 모아둘 이유가 없다는 게 숫자로 드러나요.
둘째, CPU 를 계속 쓰는 계산 작업은 가상 스레드로도 빨라지지 않아요. 가상 스레드가 빛나는 건 "기다리느라 막히는" 작업이에요. 막히는 순간 OS 스레드를 놓아주는 게 핵심인데, 계산만 빡세게 하는 작업은 애초에 막히질 않아요. 쉬지 않고 CPU 를 굴리니까요. 이런 작업은 수천 개를 띄워봐야 결국 CPU 코어 수가 한계라, 코어보다 빨라지진 않아요. 오히려 무작정 많이 띄우면 서로 자원을 다투느라 느려질 수도 있어요.
이럴 땐 세마포어(Semaphore) 로 "동시에 몇 개까지만" 하고 제한하는 게 좋아요. 세마포어는 놀이기구 정원처럼, 정해진 인원만 동시에 들여보내는 도구예요. 아래는 동시 실행을 maxConcurrent 개로 묶고, 실제로 한 번에 몇 개까지 동시에 일했는지(피크) 를 재보는 코드예요.
// CPU 위주 작업엔 세마포어로 "동시에 몇 개까지" 를 제한해요.
public static int peakConcurrency(int taskCount, int maxConcurrent) {
Semaphore gate = new Semaphore(maxConcurrent); // 동시 입장 정원 = maxConcurrent
AtomicInteger inside = new AtomicInteger(0);
AtomicInteger peak = new AtomicInteger(0);
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
gate.acquireUninterruptibly(); // 자리가 날 때까지 기다렸다 입장
try {
int now = inside.incrementAndGet();
peak.accumulateAndGet(now, Math::max); // 최대치 갱신
sleepQuietly(5); // 잠깐 일하는 동안 자리를 차지
inside.decrementAndGet();
} finally {
gate.release(); // 자리 비우기 (다음 작업 입장 가능)
}
});
}
}
return peak.get(); // 정원이 maxConcurrent 라서 피크는 그 이하
}
peakConcurrency(100, 4) 를 실행하면, 작업이 100개여도 동시에 일한 최대 개수는 4 를 넘지 않아요. 세마포어 정원이 4 이기 때문이죠. 코드베이스 VirtualThreadPitfallsTest.java 에서 이 피크가 항상 4 이하라는 걸 확인해뒀어요. CPU 작업처럼 동시에 너무 많이 돌면 안 되는 상황에서, 이렇게 적정선을 지키는 거예요.
⚠️ 가상 스레드의 강점은 "막히는(블로킹) 작업" 에 있어요. 네트워크·파일·DB 응답을 기다리는 일이 그렇죠. 반대로 쉬지 않고 계산만 하는 CPU 작업은 가상 스레드로 바꿔도 빨라지지 않아요. 이럴 땐 세마포어로 동시 개수를 코어 수에 맞춰 제한하세요.
Step 7: (맛보기) 묶어서 다루기 — Structured Concurrency
마지막은 가볍게 "앞으로 이런 게 온다" 는 맛보기예요. 외울 필요는 전혀 없고, 그림만 느끼고 가면 돼요.
Day 36 에서 allOf 로 여러 작업을 한꺼번에 기다렸던 걸 떠올려봐요. 편했지만 아쉬운 점이 있었어요. 작업들을 따로따로 시작하다 보니, 그중 하나가 실패했을 때 나머지를 취소하고 정리하는 건 우리가 손으로 챙겨야 했죠. 작업들이 서로 "한 묶음" 이라는 걸 코드가 모르니까요.
Structured Concurrency(구조적 동시성) 는 이걸 깔끔하게 풀어요. 관련된 하위 작업들을 하나의 묶음(scope) 으로 묶어서 시작하고, 한 번에 기다려요. 그리고 묶음 안에서 하나가 실패하면 나머지를 자동으로 취소 해줘요. 마치 부모-자식처럼, 부모 작업의 운명에 자식 작업들이 함께 묶이는 거죠.
allOf (지난 시간)
- 작업을 따로따로 시작해 allOf 로 전부 기다림
- 하나가 실패해도 나머지는 계속 돎 → 취소·정리는 손으로 챙김
Structured Concurrency (다음 단계)
- 관련된 작업을 한 묶음(scope)으로 묶어 시작
- 묶음 중 하나가 실패하면 나머지를 자동으로 취소·정리
모양만 살짝 보면 이래요. 자바 27 쯤 정식화될 예정인 미리보기(preview) 기능이라, 지금은 "이렇게 생겼구나" 만 느끼면 돼요.
// 미리보기 기능이라 지금 당장 외울 필요는 없어요. 모양만 느껴보세요.
try (var scope = StructuredTaskScope.open()) {
var name = scope.fork(() -> loadName(userId)); // 하위 작업 1
var bio = scope.fork(() -> loadBio(userId)); // 하위 작업 2
scope.join(); // 둘 다 끝나길 한 번에 대기
return name.get() + " / " + bio.get();
} // 하나가 실패하면 나머지는 자동으로 취소돼요
try-with-resources 로 묶음을 열고, fork 로 하위 작업을 던지고, join 으로 한 번에 기다려요. Day 36 의 비동기 조합과 닮았지만, "한 묶음" 이라는 구조가 더 또렷해졌죠. 가상 스레드가 싸기 때문에 이런 "작업마다 하나씩, 게다가 묶어서" 같은 발상이 가능해진 거예요. 가상 스레드가 깔아둔 길 위에 이런 새 도구들이 계속 올라오고 있어요.
마무리
오늘은 "스레드는 비싸다" 는 오랜 전제를 무너뜨리는 가상 스레드 를 배웠어요. 지난 두 시간 "스레드를 안 막으려고" 애쓰던 이야기가, "막혀도 괜찮은 가벼운 스레드" 라는 새로운 답으로 이어졌죠.
- 플랫폼 스레드는 비싸다 — OS 스레드와 1:1 로 묶여 하나에 약 1MB. 그래서 수만 개를 못 만들었어요.
- 가상 스레드 만들기 —
Thread.ofVirtual().start()또는Thread.startVirtualThread(). 가상인지는isVirtual()로 확인해요. - 만 개도 끄떡없다 — 막히는 순간 OS 스레드를 놓아주기 때문에, 1만 개가 동시에 쉬어도 가뿐해요.
- carrier 와 마운트·언마운트 — 가상 스레드는 carrier(OS 스레드) 를 잠깐씩 빌려 타고, 막히면 내려와 다른 가상 스레드에게 자리를 내줘요.
- 한 작업 한 스레드의 부활 —
newVirtualThreadPerTaskExecutor()로 풀 크기 고민 없이 작업마다 스레드 하나씩 붙여요. - 언제 쓰나 — 막히는(블로킹) 작업엔 최고, CPU 계산 작업엔 이득 없음. 풀링하지 말고, 필요하면 세마포어로 동시 개수를 제한해요.
오늘 내내 가상 스레드가 빛나는 곳이 "막히는 작업" 이라고 했죠. 그런데 그 "막힘" 의 대표 선수가 뭘까요? 바로 파일을 읽고 쓰는 일 이에요. 디스크는 메모리보다 한참 느려서, 파일 한 줄을 읽는 동안에도 스레드는 한참을 기다리며 막혀 있어요. 지금까지 "외부 응답을 기다리는 척" 으로 sleep 을 썼는데, 다음 시간엔 그 진짜 주인공인 파일 입출력(I/O) 을 본격적으로 다뤄요. 데이터를 파일에 저장하고 다시 불러오는, 프로그램의 "기억" 을 만드는 일이에요. 수고 많으셨어요!
과제
오늘 배운 가상 스레드를 직접 만들어볼 시간이에요. 셋 다 인스타그램에서 실제로 일어날 법한 상황이에요. 막히면 본문의 데모 코드를 참고하되, 먼저 스스로 흐름을 그려보세요.
과제 1 (기초): 가상 스레드로 알림 보내기
상황 배경: 새 게시물이 올라오면 알림을 여러 건 보내야 해요. 각 알림 전송을 가상 스레드 하나씩에 맡겨, 가볍게 동시에 보내봐요. Step 2 의 VirtualThreadIntro 를 참고하세요.
🎯 해결 미션:
VirtualNotificationSender같은 클래스에sendAll(String[] messages)메서드를 두세요.- 각 메시지마다
Thread.startVirtualThread(...)로 가상 스레드를 하나씩 띄워, 그 안에서 "전송됨" 을 세게 하세요. 단,isVirtual()이true일 때만 세서 정말 가상 스레드에서 도는지 확인하세요. - 띄운 스레드를 전부
join()으로 기다린 뒤, 보낸 알림 개수를 돌려주세요. sendAll에 메시지 3개를 넘기면3이 나오는지 확인하세요.new Thread()가 아니라 가상 스레드를 쓰면 무엇이 좋은지 한 줄로 적어보세요.
과제 2 (응용): 팔로워에게 알림 팬아웃하기
상황 배경: 인기 계정이 게시물을 올리면, 수천 명의 팔로워에게 푸시 알림을 보내야 해요. 팔로워가 많아도 풀 크기 고민 없이 가상 스레드 executor 로 한꺼번에 처리해봐요. Step 5 의 VirtualFeedLoader 를 참고하세요.
🎯 해결 미션:
FollowerNotifier같은 클래스에notifyFollowers(int followerCount)메서드를 두세요.Executors.newVirtualThreadPerTaskExecutor()를try-with-resources로 열어, 팔로워 한 명당 작업을 하나씩submit하세요. 각 작업은 잠깐 막히는 척(sleep) 한 뒤 "보냄" 을 1 세게 하세요.- 블록을 벗어나면 모든 알림이 끝나니, 보낸 총개수를 돌려주세요.
notifyFollowers(500)이500을 돌려주는지 확인하세요. 똑같은 코드에서 풀을newFixedThreadPool(4)로 바꾸면 무엇이 달라질지 한 줄로 적어보세요.
과제 3 (심화): 썸네일 생성 동시성을 제한하기
상황 배경: 한 번에 여러 장의 이미지를 업로드받아 썸네일을 만들어요. 썸네일 생성은 CPU 를 빡세게 쓰는 작업이라, 무작정 다 동시에 돌리면 서버가 버거워요. 세마포어로 "동시에 4장까지만" 으로 제한해봐요. Step 6 의 VirtualThreadPitfalls 를 참고하세요.
🎯 해결 미션:
ThumbnailRateLimiter같은 클래스에generateWithLimit(int imageCount, int maxConcurrent)메서드를 두세요.Semaphore(maxConcurrent)로 정원을 정하고, 가상 스레드 executor 로 이미지마다 작업을 던지세요. 각 작업은 입장(acquire) → 잠깐 작업(sleep) → 퇴장(release) 순서로 동작하게 하세요.- 작업 안에서 "지금 동시에 몇 개가 일하는지" 를 세어, 최대치(피크) 를 돌려주세요.
generateWithLimit(100, 4)의 피크가4이하인지 확인하세요. CPU 작업에 동시 개수 제한이 왜 필요한지 한 줄로 적어보세요. (가상 스레드는 싸다면서, 왜 굳이 막아둘까요?)
생각해볼 주제
혼자 고민해도 좋고, 동료와 토론해도 좋아요. 정답을 외우기보다 "나라면 어떻게 설명할까" 를 떠올리며 읽어보세요.
1. 가상 스레드가 이렇게 좋은데, 왜 플랫폼 스레드를 아예 없애지 않았을까?
오늘 보니 가상 스레드는 가볍고, 수만 개를 만들어도 끄떡없었어요. 그럼 자바는 왜 무거운 플랫폼 스레드를 그대로 남겨뒀을까요?
힌트는 Step 6 에 있어요. 가상 스레드가 빛나는 건 "막히는" 작업이었죠. 반대로 쉬지 않고 CPU 만 굴리는 작업에선 어땠나요? 그리고 가상 스레드도 결국 누군가의 등(carrier) 에 업혀 돌아야 한다는 점을 떠올려보면, 두 스레드가 각자 어디에 어울리는지 보일 거예요.
2. 가상 스레드는 풀에 모으지 말라면서, newVirtualThreadPerTaskExecutor는 왜 executor(풀처럼 생긴 것)일까?
Step 6 에서 "가상 스레드는 풀링하지 마라" 고 했는데, 정작 우리가 쓴 건 executor 였어요. executor 는 Day 35 에서 스레드풀을 다룰 때 쓰던 그 도구잖아요. 모순처럼 보이죠?
newVirtualThreadPerTaskExecutor 가 실제로 무엇을 모아두는지(스레드인지, 아니면 그냥 일감을 받아 처리하는 창구인지) 떠올려보세요. distinctThreadCount 의 결과가 작업 수와 똑같았던 걸 함께 생각하면, "풀처럼 생겼지만 스레드를 재사용하지는 않는다" 는 게 무슨 뜻인지 풀려요.
3. 막혀도 괜찮다면서, 왜 CPU 작업엔 이득이 없을까?
가상 스레드의 마법은 "막히면 OS 스레드를 놓아준다" 였어요. 그래서 1만 개가 동시에 쉬어도 가뿐했죠. 그런데 CPU 계산 작업은 이 마법이 안 통한다고 했어요.
계산만 하는 작업은 애초에 "막히는" 순간이 있는지 떠올려보세요. 막히지 않고 쉼 없이 CPU 를 쓰는 작업이라면, OS 스레드를 놓아줄 틈이 있을까요? 그리고 진짜 계산을 하는 건 결국 CPU 코어인데, 코어가 8개인 컴퓨터에서 계산 작업 1만 개를 동시에 던진다고 8개가 16개로 늘어날까요? 가상 스레드의 강점이 정확히 어디에서 오는지 다시 짚어보면, 왜 CPU 작업엔 통하지 않는지 분명해질 거예요.
✅ 예시 답안정답 보기
아래는 과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니니, 내 코드와 흐름을 비교하며 "왜 이렇게 했는지" 를 짚어보세요.
과제 예시답안
과제 1 예시답안 — 가상 스레드로 알림 보내기
핵심 접근
메시지마다 가상 스레드를 하나씩 띄워 동시에 보내는 게 목표예요. Thread.startVirtualThread(...) 한 줄이면 가상 스레드를 만들고 바로 시작할 수 있어요. 각 작업 안에서 isVirtual() 로 "정말 가상 스레드에서 도는지" 를 확인하고, 맞을 때만 카운터를 올려요. 마지막엔 띄운 스레드를 전부 join() 으로 기다린 뒤 개수를 돌려주면 돼요. 여러 스레드가 같은 카운터를 올리므로 AtomicInteger 를 써야 안전해요(Day 35 에서 배운 그 도구예요).
예시 구현
// com/instagram/javabasic/concurrent/solution/day37/VirtualNotificationSender.java
public static int sendAll(String[] messages) throws InterruptedException {
AtomicInteger sent = new AtomicInteger(0);
List<Thread> threads = new ArrayList<>();
for (String message : messages) {
Thread vt = Thread.startVirtualThread(() -> {
if (Thread.currentThread().isVirtual()) { // 정말 가상 스레드인가?
sent.incrementAndGet();
}
});
threads.add(vt);
}
for (Thread t : threads) { // 띄운 스레드가 전부 끝날 때까지 대기
t.join();
}
return sent.get();
}
sendAll 에 메시지 3건을 넘기면 3 이 나와요. 셋 다 가상 스레드에서 돌았다는 뜻이죠.
채점 포인트
Thread.startVirtualThread(...)(또는Thread.ofVirtual().start(...)) 로 가상 스레드를 만들었는가- 각 작업 안에서
isVirtual()로 가상 여부를 확인했는가 - 띄운 스레드를 모두
join()으로 기다린 뒤 개수를 셌는가 - 공유 카운터에
AtomicInteger를 써서 동시 증가를 안전하게 처리했는가
흔한 실수
join()을 빼먹어, 알림이 다 보내지기 전에 개수를 읽어 결과가 들쭉날쭉해지는 경우. 만든 스레드는 반드시 기다려야 해요.int sent같은 평범한 변수를 여러 스레드가 함께 올려 값이 새는 경우. Day 34 에서 봤던 경쟁 상태예요.AtomicInteger로 막아요.- "가상 스레드를 쓰면 좋은 점" 을 적을 때 — 답은 "스레드가 가벼워서,
new Thread()처럼 무거운 OS 스레드를 쓰지 않고도 많이 만들 수 있다" 예요. 알림이 수천 건이어도 부담 없이 하나씩 띄울 수 있어요.
과제 2 예시답안 — 팔로워에게 알림 팬아웃하기
핵심 접근
팔로워가 많아도 풀 크기를 고민하지 않는 게 이번 과제의 핵심이에요. Executors.newVirtualThreadPerTaskExecutor() 를 try-with-resources 로 열면, submit 할 때마다 가상 스레드가 하나씩 새로 붙어요. 팔로워 한 명당 작업 하나를 던지고, 블록을 벗어날 때 close() 가 모든 알림 완료를 기다려주니, 끝나면 보낸 개수를 돌려주면 돼요. Step 5 의 VirtualFeedLoader 와 모양이 똑같아요 — 풀 만드는 한 줄만 바뀐 거예요.
예시 구현
// com/instagram/javabasic/concurrent/solution/day37/FollowerNotifier.java
public static int notifyFollowers(int followerCount) {
AtomicInteger notified = new AtomicInteger(0);
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= followerCount; i++) {
executor.submit(() -> {
sleepQuietly(10); // 푸시 알림을 보내며 잠깐 막히는 척 (블로킹)
notified.incrementAndGet();
});
}
} // ← 블록을 벗어나면 모든 알림이 끝날 때까지 기다린 뒤 닫혀요
return notified.get();
}
notifyFollowers(500) 은 500 을 돌려줘요. 팔로워 500명에게 가상 스레드 500개가 각자 붙어 동시에 알림을 보낸 거예요.
채점 포인트
newVirtualThreadPerTaskExecutor()를try-with-resources로 열고 닫았는가- 팔로워 한 명당 작업 하나를
submit했는가 close()가 모든 작업 완료를 기다려준다는 걸 이해하고, 그 뒤에 개수를 읽었는가- 공유 카운터에
AtomicInteger를 썼는가
흔한 실수
- executor 를
try-with-resources없이 열고 닫지 않아, 프로그램이 안 끝나거나 알림이 다 끝나기 전에 개수를 읽는 경우. - "풀을
newFixedThreadPool(4)로 바꾸면?" 의 답 — 일꾼이 4명뿐이라 500건이 4개씩 줄 서서 처리돼요. 각 알림이 막히는 동안 일꾼이 묶이니 전체가 훨씬 느려지죠. 가상 스레드 executor 는 500개를 동시에 띄워 이 줄서기를 없애요. - 가상 스레드라고
join()을 일일이 부르려는 경우 — executor 를 쓰면close()가 한꺼번에 기다려주니, 스레드를 따로 모아join()할 필요가 없어요.
과제 3 예시답안 — 썸네일 생성 동시성을 제한하기
핵심 접근
가상 스레드는 싸지만, 썸네일 생성처럼 CPU 를 빡세게 쓰는 작업은 무작정 많이 돌리면 안 돼요. 동시 개수를 코어 수에 맞춰 제한해야 하죠. 이때 쓰는 게 세마포어(Semaphore) 예요. 정원을 maxConcurrent 로 정하고, 각 작업이 입장(acquire) → 작업 → 퇴장(release) 순서로 돌게 하면, 한 번에 정원만큼만 동시에 일해요. 실제로 동시에 몇 개가 일했는지(피크) 를 세서 돌려주면, 제한이 잘 걸렸는지 확인할 수 있어요.
예시 구현
// com/instagram/javabasic/concurrent/solution/day37/ThumbnailRateLimiter.java
public static int generateWithLimit(int imageCount, int maxConcurrent) {
Semaphore gate = new Semaphore(maxConcurrent); // 동시 입장 정원
AtomicInteger inside = new AtomicInteger(0); // 지금 안에서 일하는 수
AtomicInteger peak = new AtomicInteger(0); // 지금까지 본 최대 동시 수
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < imageCount; i++) {
executor.submit(() -> {
gate.acquireUninterruptibly(); // 자리가 날 때까지 기다렸다 입장
try {
int now = inside.incrementAndGet();
peak.accumulateAndGet(now, Math::max); // 동시 실행 최대치 갱신
sleepQuietly(5); // 썸네일 만드는 동안 자리를 차지
inside.decrementAndGet();
} finally {
gate.release(); // 자리 비우기 → 다음 이미지 입장
}
});
}
}
return peak.get();
}
generateWithLimit(100, 4) 의 결과(피크) 는 항상 4 이하예요. 이미지가 100장이어도, 세마포어 정원이 4 라서 동시에 일하는 건 최대 4장이에요.
채점 포인트
Semaphore(maxConcurrent)로 정원을 정했는가acquire→ 작업 →release순서를 지켰는가 (특히release를finally에 둬서 빠뜨리지 않았는가)- 동시 실행 개수(피크) 를
AtomicInteger로 안전하게 추적했는가 - 결과 피크가
maxConcurrent이하로 나오는지 확인했는가
흔한 실수
release()를finally가 아닌 곳에 둬서, 작업 중 문제가 생기면 자리가 영영 안 비고 멈춰버리는 경우. 입장했으면 무슨 일이 있어도 나와야 해요.acquire()를acquireUninterruptibly()대신 그냥acquire()로 쓰면InterruptedException을 처리해야 해서 람다가 번거로워져요. (둘 다 맞지만, 여기선 간단한 쪽을 골랐어요.)- "CPU 작업에 동시 제한이 왜 필요한가" 의 답 — 가상 스레드는 싸서 수백 개를 띄울 수 있지만, 진짜 계산을 하는 CPU 코어 수는 정해져 있어요. 코어보다 많이 동시에 돌리면 서로 자원을 다투느라 오히려 느려지고 서버가 버거워져요. 그래서 CPU 작업은 코어 수에 맞춰 막아둬요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — 가상 스레드가 이렇게 좋은데, 왜 플랫폼 스레드를 아예 없애지 않았을까?
[문제 상황 요약]
가상 스레드는 가볍고 수만 개를 만들어도 끄떡없었어요. 그렇다면 무거운 플랫폼 스레드는 이제 쓸모가 없어 보이는데, 왜 자바는 그걸 그대로 남겨뒀을까 하는 의문이에요.
[튜터의 가이드 및 해설]
핵심은 "둘이 잘하는 일이 다르다" 예요. 가상 스레드의 마법은 "막히면 OS 스레드를 놓아준다" 였죠. 그래서 네트워크·파일 응답을 기다리며 대부분의 시간을 멍하니 보내는 작업에 딱이에요. 서버 일의 대부분이 이런 "기다림" 이라, 여기선 가상 스레드가 압도적이에요.
그런데 쉬지 않고 CPU 만 굴리는 계산 작업은 사정이 달라요. 막히는 순간이 없으니 OS 스레드를 놓아줄 틈도 없고, 결국 진짜 계산을 하는 건 carrier 인 OS 스레드(그리고 그 아래 CPU 코어) 예요. 이런 작업은 가상 스레드로 감싸도 빨라지지 않고, 오히려 carrier 위에 얹는 관리 비용만 더해져요. 이럴 땐 코어 수에 맞춘 플랫폼 스레드(또는 동시 개수를 제한한 실행) 가 더 솔직하고 효율적이에요.
게다가 가상 스레드도 결국 누군가의 등(carrier 플랫폼 스레드) 에 업혀 돌아요. 즉 플랫폼 스레드는 사라진 게 아니라 가상 스레드 아래에서 묵묵히 받쳐주고 있어요. 그러니 "교체" 가 아니라 "역할 분담" 인 거죠. 기다리는 작업엔 가상 스레드, 계산만 하는 작업엔 플랫폼 스레드.
🎯 면접관을 홀리는 핵심 멘트
"가상 스레드는 블로킹 작업을 위한 거예요. 막히면 carrier 를 놓아주는 게 핵심이라, I/O 처럼 기다리는 일엔 압도적이죠. 반대로 CPU-bound 작업은 막히는 순간이 없어서 이득이 없고, 결국 코어 수가 한계예요. 게다가 가상 스레드도 carrier 플랫폼 스레드 위에서 도니까, 플랫폼 스레드는 대체된 게 아니라 그 아래를 받치는 토대로 남은 겁니다."
생각해볼 주제 2 예시답안 — 가상 스레드는 풀에 모으지 말라면서, newVirtualThreadPerTaskExecutor는 왜 executor(풀처럼 생긴 것)일까?
[문제 상황 요약]
Step 6 에서 "가상 스레드는 풀링하지 마라" 고 했는데, 정작 우리가 쓴 건 executor 였어요. executor 는 Day 35 에서 스레드풀을 다룰 때 쓰던 도구라, 모순처럼 느껴지는 상황이에요.
[튜터의 가이드 및 해설]
열쇠는 "executor 가 모아두는 게 무엇인가" 예요. Day 35 의 newFixedThreadPool(4) 는 비싼 스레드 4개를 미리 만들어 모아두고 돌려 썼어요. 작업이 와도 새 스레드를 안 만들고, 풀에 있는 기존 스레드를 재사용했죠. 그게 "스레드 풀링" 이에요.
반면 newVirtualThreadPerTaskExecutor 는 스레드를 모아두지 않아요. 이름 그대로 "작업마다(per task) 가상 스레드를 새로" 띄우고, 작업이 끝나면 그 스레드는 버려요. 본문 distinctThreadCount(50) 이 정확히 50 이었던 게 그 증거예요 — 50개의 작업이 서로 다른 50개의 스레드에서 돌았으니, 재사용이 전혀 없었던 거죠.
그러니 이 executor 는 "스레드를 모아두는 풀" 이 아니라, "일감을 받아 가상 스레드에 하나씩 배달하는 창구" 에 가까워요. executor 라는 이름은 같지만, 안에서 스레드를 재사용하느냐 마느냐가 완전히 달라요. "풀처럼 생겼지만 스레드 풀이 아니다" 라는 말이 이래서 나와요. 우리가 executor 를 쓰는 이유도 스레드 재사용이 아니라 submit·close 같은 편한 관리 기능 때문이에요.
🎯 면접관을 홀리는 핵심 멘트
"newVirtualThreadPerTaskExecutor 는 스레드를 모아두는 풀이 아니에요. 작업마다 새 가상 스레드를 띄우고 끝나면 버리죠 — 재사용이 없으니 스레드 풀링이 아닙니다. executor 라는 껍데기는 submit·close 같은 관리 편의를 위한 거고, 안에서는 'task 당 thread 하나' 라 풀링 안티패턴과 무관해요. 풀처럼 생겼지만 스레드를 모으지 않는다, 이게 핵심입니다."
생각해볼 주제 3 예시답안 — 막혀도 괜찮다면서, 왜 CPU 작업엔 이득이 없을까?
[문제 상황 요약]
가상 스레드의 마법은 "막히면 OS 스레드를 놓아준다" 였어요. 그래서 1만 개가 동시에 쉬어도 가뿐했죠. 그런데 CPU 계산 작업엔 이 마법이 안 통한다고 했어요. 왜 그럴까 하는 질문이에요.
[튜터의 가이드 및 해설]
먼저 "막힌다" 가 무슨 뜻이었는지 떠올려봐요. sleep, 네트워크 응답 대기, 파일 읽기 — 전부 "내가 할 일은 없고 무언가를 기다리는" 순간이에요. 가상 스레드는 바로 이 기다리는 순간에 carrier 를 놓아줘서, 그 비싼 OS 스레드가 다른 일을 하게 해요. 강점이 100% 여기서 나와요.
그런데 CPU 계산 작업(이미지 변환, 암호 계산 등) 은 "기다리는" 순간이 없어요. 쉼 없이 CPU 를 굴리며 계속 일하죠. 막히질 않으니 carrier 를 놓아줄 틈도 없어요. 가상 스레드의 마법이 발동할 자리가 아예 없는 거예요.
게다가 진짜 계산을 하는 건 CPU 코어예요. 코어가 8개인 컴퓨터에서 계산 작업 1만 개를 동시에 던진다고 코어가 16개로 늘어나진 않아요. 8개가 쉬지 않고 돌 뿐이고, 1만 개를 띄우면 오히려 carrier 를 서로 차지하려 다투고 전환 비용만 늘어 더 느려질 수 있어요. 그래서 CPU 작업은 가상 스레드를 마구 띄우는 대신, 세마포어 같은 도구로 동시 개수를 코어 수에 맞춰 제한하는 게 정답이에요.
🎯 면접관을 홀리는 핵심 멘트
"가상 스레드의 이점은 블로킹 순간에 carrier 를 반납하는 데서 나옵니다. CPU-bound 작업은 블로킹이 없어서 반납할 틈이 없고, 실제 처리량은 코어 수가 정하죠. 코어가 8개면 스레드를 1만 개 띄워도 동시에 계산되는 건 8개고, 오히려 컨텍스트 스위칭만 늘어요. 그래서 CPU 작업은 세마포어로 동시성을 코어 수만큼 묶는 게 정석입니다."