쓰레드 풀과 커넥션 풀 완벽 정리 — 자원 풀링부터 가상 쓰레드까지
왜 풀이 필요한가부터 ThreadPoolExecutor 내부, BlockingQueue, HikariCP, 가상 쓰레드의 Continuation·Poller·ForkJoinPool, OS↔JVM 경계까지 동시성 처리의 전체 그림을 정리했습니다.
쓰레드 풀과 커넥션 풀 완벽 정리
자원 풀링부터 가상 쓰레드까지 — 동시성 처리의 모든 것
들어가며 — 인터랙티브 시각화 두 개
이 글은 길어서 중간중간 막힐 수 있어요. 8장의 가상 쓰레드 부분을 읽다가 "도대체 캐리어와 VT의 관계가 어떻게 돌아가는지", "패킷이 어떻게 커널을 지나 VT까지 도달하는지" 헷갈리면, 같은 디렉터리에 올려둔 두 시각화로 단계별 애니메이션을 보면 빠르게 잡힙니다.
- 스케줄러 시각화 (ForkJoinPool · Work-Stealing · Poller) — VT 폭주, work-stealing, I/O 완료 트리거 같은 시나리오를 인터랙티브로 조작
- 커널 흐름 시각화 (NIC → 커널 → Poller → VT) — 하드웨어 인터럽트 한 번이 어떻게 user mode Java 코드까지 도달하는지 14단계 추적
본문 8.15 ~ 8.16에서 이 두 시각화로 직접 돌아옵니다.
목차
- 왜 "풀(Pool)"인가 — 공통 철학
- 쓰레드 풀 (Thread Pool)
- 쓰레드 풀의 내부 자료구조
- DB 커넥션 풀 (DB Connection Pool)
- 두 풀이 만나는 지점
- 필수 CS 지식
- 실무 장애 사례
- 가상 쓰레드 (Virtual Thread)
1. 왜 "풀(Pool)"인가 — 공통 철학
쓰레드 풀과 커넥션 풀은 이름은 다르지만 본질적으로 같은 패턴입니다. 둘 다 "생성 비용이 비싼 자원을 미리 만들어두고 재사용한다" 는 원리에서 출발합니다.
1.1 풀링이 필요한 이유
문제 1. 생성 비용
- 쓰레드 생성: OS 시스템 콜, 스택 메모리 할당(보통 1MB), 커널 자료구조 등록 → 수 ms
- DB 커넥션 생성: TCP 3-way handshake, TLS handshake, DB 인증, 세션 초기화 → 수십~수백 ms
API 응답 시간이 50ms인데 커넥션 만드는 데 100ms가 든다면 배보다 배꼽이 큰 상황입니다.
문제 2. 자원 한계
- 한 서버가 만들 수 있는 쓰레드 수에는 한계가 있음 (메모리, OS 제약)
- DB 서버가 받을 수 있는 동시 커넥션 수에도 한계가 있음 (MySQL 기본 151)
- 무제한으로 만들면 서버가 죽음
문제 3. 정리 비용
자원을 만들고 버리고 또 만들고 버리는 과정 자체가 GC 압박과 메모리 단편화를 유발합니다.
1.2 풀의 동작 원리
[애플리케이션] → 풀에서 자원 빌림(borrow)
↓
[자원 사용]
↓
[애플리케이션] → 풀에 자원 반납(return)
↓
다른 요청이 같은 자원을 재사용
핵심은 "빌려 쓰고 반납" 입니다. 도서관에서 책을 빌리는 것과 같습니다.
1.3 모든 풀의 공통 파라미터
| 파라미터 | 의미 |
|---|---|
| min size (최소 크기) | 미리 만들어둘 자원 개수 (idle 상태로 대기) |
| max size (최대 크기) | 동시에 가질 수 있는 자원의 최대치 |
| timeout | 자원을 빌리려고 기다리는 최대 시간 |
| idle timeout | 안 쓰는 자원을 얼마나 보관할지 |
2. 쓰레드 풀 (Thread Pool)
2.1 쓰레드 풀이 없으면?
요청 1초당 1000건 → 쓰레드 1000개 생성 + 1000개 종료
→ 컨텍스트 스위칭 폭증
→ 메모리 부족 (스택 1MB × 1000 = 1GB)
→ 서버 다운
2.2 쓰레드 풀의 구조
┌─────────────────────────────┐
요청 큐 ────► │ Thread 1 │ Thread 2 │ ... │ │ ← 미리 만들어둔 워커들
(작업 대기) └─────────────────────────────┘
↓
작업 처리 후
쓰레드는 살아있음 (반납)
쓰레드는 죽지 않고 요청 큐에서 다음 작업을 꺼내옵니다. 풀 + 큐 조합이 비동기 처리의 기본 골격입니다.
2.3 실무에서 쓰레드 풀을 만나는 곳
1. 웹 서버 / WAS
- Tomcat:
server.tomcat.threads.max(기본 200) - Spring Boot: 내부적으로 Tomcat 쓰레드 풀 사용
- Node.js: 메인은 이벤트 루프지만, 파일 I/O와 암호화는 libuv 쓰레드 풀(기본 4) 사용
2. 비동기 작업 처리
- Java:
ExecutorService,ForkJoinPool,@Async - Python:
concurrent.futures.ThreadPoolExecutor - Go: 고루틴 + GOMAXPROCS (조금 다른 모델이지만 본질은 같음)
3. 메시지 컨슈머
Kafka Consumer, RabbitMQ Consumer 모두 내부적으로 쓰레드 풀로 메시지 처리.
2.4 쓰레드 풀 크기 — 가장 흔한 함정
"CPU 코어 수만큼 만들면 되나요?" → 상황에 따라 다릅니다.
CPU-bound 작업
순수 계산 작업 (이미지 처리, 암호화, 수학 연산 등)
쓰레드 수 ≒ CPU 코어 수
코어보다 많으면 컨텍스트 스위칭 오버헤드만 늘어남.
I/O-bound 작업
대부분의 웹 애플리케이션 (DB 조회, 외부 API 호출, 파일 I/O)
쓰레드 수 = CPU 코어 수 × (1 + 대기시간/처리시간)
예: 응답 100ms 중 80ms가 DB 대기라면, 코어당 5개까지 가능 (1 + 80/20 = 5)
Brian Goetz의 공식 (Java Concurrency in Practice)
N_threads = N_cpu × U_cpu × (1 + W/C)
- N_cpu: CPU 코어 수
- U_cpu: 목표 CPU 사용률 (0~1)
- W: 평균 대기 시간
- C: 평균 계산 시간
2.5 거부 정책 (Rejection Policy)
풀이 가득 찼고 큐도 꽉 찼을 때 어떻게 할 것인가? Java ThreadPoolExecutor 기준:
| 정책 | 동작 | 사용처 |
|---|---|---|
| AbortPolicy (기본) | 예외 던짐 | 일반적, 호출자에게 책임 넘김 |
| CallerRunsPolicy | 호출자 쓰레드가 직접 실행 | 백프레셔 자연스럽게 발생 |
| DiscardPolicy | 조용히 버림 | 손실 허용 가능한 경우 (로그, 메트릭) |
| DiscardOldestPolicy | 가장 오래된 작업 버림 | 최신성이 중요한 경우 |
실무 팁: CallerRunsPolicy는 의외로 강력합니다. 풀이 가득 차면 요청을 받은 쓰레드가 직접 일하게 되어 자연스러운 백프레셔가 생깁니다.
3. 쓰레드 풀의 내부 자료구조
Java ThreadPoolExecutor를 기준으로 살펴봅니다.
3.1 큰 그림
┌─────────────────────────────────────────────────────────┐
│ ThreadPoolExecutor │
│ │
│ ┌─────────────────────┐ ┌────────────────────────┐ │
│ │ 작업 큐 │ │ 워커 집합 │ │
│ │ (BlockingQueue) │───►│ (HashSet<Worker>) │ │
│ │ │ │ │ │
│ │ [Task][Task][Task] │ │ [W1][W2][W3]... │ │
│ └─────────────────────┘ └────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 제어 상태 (AtomicInteger - ctl) │ │
│ │ 상위 3비트: 풀 상태 / 하위 29비트: 워커 수 │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ ReentrantLock (mainLock) │ │
│ │ + Condition (termination) │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
핵심은 3가지 자료구조:
- 작업 큐: 대기 중인 작업 보관 (
BlockingQueue) - 워커 집합: 활성 쓰레드 추적 (
HashSet<Worker>) - 제어 상태: 풀 상태와 워커 수를 원자적으로 관리 (
AtomicInteger)
3.2 작업 큐 — BlockingQueue
왜 BlockingQueue인가
일반 큐는 비어있으면 null을 반환하지만, 쓰레드 풀의 워커는 "작업이 들어올 때까지 기다려야" 합니다. take()는 내부적으로 Condition.await() 으로 쓰레드를 잠재웁니다. 새 작업이 들어오면 signal()로 깨웁니다.
BlockingQueue 종류와 선택
LinkedBlockingQueue (가장 흔함)
- 구조: 단방향 연결 리스트
- 크기: 기본
Integer.MAX_VALUE(사실상 무제한) - 특징: head와 tail에 별도 락 — 생산자와 소비자가 동시에 작업 가능
Executors.newFixedThreadPool()이 이걸 사용- 함정: 큐가 무제한이라 corePoolSize 이상으로 쓰레드가 안 늘어남. 작업이 쌓이면 OOM 위험.
ArrayBlockingQueue (크기 제한)
- 구조: 고정 크기 배열 + 원형 큐
- 크기: 생성 시 지정, 변경 불가
- 특징: 하나의 락 사용
- 큐 크기 제한 덕분에 백프레셔가 자연스럽게 생김. 안전한 선택.
SynchronousQueue (저장 안 함)
- 구조: 사실상 저장 공간이 없는 큐
- 특징: 작업을 큐에 쌓지 않고 워커에게 직접 전달(hand-off)
Executors.newCachedThreadPool()이 이걸 사용- max를 안 잡으면 무한정 쓰레드 생성 위험
PriorityBlockingQueue (우선순위)
- 구조: 이진 힙 (배열 기반)
- 특징: 작업의 우선순위에 따라 꺼냄
- 사용처: VIP 요청 우선 처리
DelayQueue (지연 실행)
- 구조: 힙 기반 (PriorityQueue + 시간 조건)
- 사용처: 예약 작업, 재시도 백오프
큐 선택의 영향
| 큐 종류 | 쓰레드 증가 패턴 | 위험 |
|---|---|---|
| LinkedBlockingQueue (무제한) | core까지만 증가, 그 다음은 큐에 무한 적재 | OOM |
| ArrayBlockingQueue (제한) | core까지 증가 → 큐 채움 → max까지 증가 → 거부 | 안전, 권장 |
| SynchronousQueue | 매번 새 쓰레드 생성 (max까지) | 쓰레드 폭발 |
실무에서는 ArrayBlockingQueue + 명시적 max + 적절한 거부 정책 조합이 가장 안전합니다.
부록 A — BlockingQueue 깊이 보기
쓰레드 풀의 작업 큐로 가장 흔히 쓰이는 자료구조. 프로듀서-컨슈머 패턴의 토대이기도 해서 따로 깊이 들어가 봅니다.
A.1 왜 "Blocking"인가
일반 큐의 문제:
Queue<Task> queue = new LinkedList<>();
Task t = queue.poll(); // 비어있으면 null 반환
// 컨슈머: null이면 어떻게? while 돌리면서 계속 확인? → busy waiting (CPU 낭비)BlockingQueue의 해결:
BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
Task t = queue.take(); // 비어있으면 들어올 때까지 잠듦 (CPU 안 씀)
queue.put(task); // 가득 차면 자리 날 때까지 잠듦쓰레드를 잠재웠다가 조건이 충족되면 깨워준다 는 게 핵심. 이게 쓰레드 풀의 워커 동작 방식의 토대.
A.2 메서드의 4가지 스타일
조건이 안 맞을 때 어떻게 행동할지가 4가지로 나뉩니다.
| 동작 | 예외 던짐 | 특수값 반환 | 블로킹 | 타임아웃 |
|---|---|---|---|---|
| 넣기 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
| 꺼내기 | remove() | poll() | take() | poll(time, unit) |
| 확인 | element() | peek() | — | — |
// 1. 예외 스타일 — 큐 가득 차면 IllegalStateException
queue.add(task);
// 2. 특수값 스타일 — 큐 가득 차면 false 반환
boolean ok = queue.offer(task);
// 3. 블로킹 스타일 — 자리 생길 때까지 무한정 대기
queue.put(task);
// 4. 타임아웃 스타일 — 3초까지만 대기, 실패하면 false
boolean ok = queue.offer(task, 3, TimeUnit.SECONDS);실무 팁: put()/take()보다 offer/poll + 타임아웃이 안전. 무한 대기는 장애 원인이 됩니다.
A.3 내부 동작 — 어떻게 잠재우나
LinkedBlockingQueue의 take()를 단순화하면:
public E take() throws InterruptedException {
takeLock.lock();
try {
while (count.get() == 0) { // 비어있으면
notEmpty.await(); // ← 잠듦 (Condition)
}
E item = dequeue();
if (count.getAndDecrement() > 1)
notEmpty.signal(); // 다른 컨슈머 깨움
return item;
} finally {
takeLock.unlock();
}
}
public void put(E e) throws InterruptedException {
putLock.lock();
try {
while (count.get() == capacity) { // 가득 차있으면
notFull.await(); // ← 잠듦
}
enqueue(e);
if (count.getAndIncrement() + 1 < capacity)
notFull.signal();
// 무언가 들어왔으니 take 대기자 깨움
if (count.get() == 1)
notEmpty.signal();
} finally {
putLock.unlock();
}
}핵심 구성:
ReentrantLock— 임계영역 보호Condition— 특정 조건(notEmpty,notFull)을 기다리는 대기실Condition.await()— 락을 풀고 잠듦Condition.signal()— 잠든 쓰레드 하나 깨움
일반 큐에 락만 씌운 게 아니라, "조건이 안 맞으면 잠들었다가, 조건 충족시키는 쪽이 깨워준다" 는 양방향 시그널링이 들어간 게 진짜 차이.
A.4 LinkedTransferQueue — 잘 안 알려진 강자 (Java 7+)
본문 3.2에서 다룬 5종 외에 하나 더:
- LinkedBlockingQueue + SynchronousQueue의 장점 결합
transfer(e)메서드 — 컨슈머가 받을 때까지 대기 (직접 전달 보장)- 락-프리(lock-free) 구현, 매우 빠름
ltq.put(e); // 일반 큐처럼 동작
ltq.transfer(e); // 누가 받을 때까지 기다림
ltq.tryTransfer(e); // 받을 사람 있으면 전달, 없으면 false성능 민감한 곳에서 LinkedBlockingQueue 대체로 검토할 만함.
A.5 프로듀서-컨슈머 패턴
BlockingQueue의 "Hello World" 같은 예제:
BlockingQueue<Order> queue = new LinkedBlockingQueue<>(100);
// Producer
new Thread(() -> {
while (true) {
Order order = receiveFromAPI();
queue.put(order); // 큐 가득 차면 자동 백프레셔
}
}).start();
// Consumer
new Thread(() -> {
while (true) {
Order order = queue.take(); // 비어있으면 잠듦
processOrder(order);
}
}).start();자연스러운 백프레셔: 컨슈머가 느리면 큐가 차고 → 프로듀서가 자동으로 멈춤. 메모리 폭증 방지.
A.6 ConcurrentLinkedQueue와의 차이
비슷한 이름의 ConcurrentLinkedQueue도 있는데 완전히 다릅니다.
| 항목 | BlockingQueue | ConcurrentLinkedQueue |
|---|---|---|
| 블로킹 | 지원 | 안 함 |
| 락 | 사용 | 락-프리 (CAS만) |
| size() | O(1) 보통 | O(n) — 순회해야 함 |
| null 허용 | 안 됨 | 안 됨 |
| 용도 | 워커 큐, 프로듀서-컨슈머 | 단순 동시성 큐 |
규칙: 컨슈머가 "기다려야" 하면 BlockingQueue, "있으면 가져가고 없으면 다른 일 한다"면 ConcurrentLinkedQueue.
A.7 함정과 실무 팁
함정 1. InterruptedException 무시
// 위험
try {
queue.take();
} catch (InterruptedException e) {
// 무시
}
// 인터럽트 상태 복원하거나 전파
try {
queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 복원
return;
}쓰레드 풀의 graceful shutdown이 인터럽트로 동작하기 때문에 중요.
함정 2. drainTo로 배치 처리
// 하나씩 꺼내지 말고
List<Task> batch = new ArrayList<>();
queue.drainTo(batch, 100); // 한 번에 최대 100개
processBatch(batch);배치 처리는 1개씩 처리보다 훨씬 효율적. Kafka Consumer가 이런 식.
함정 3. 무제한 큐의 OOM
// 폭주하면 메모리 다 먹음
new LinkedBlockingQueue<>();
// 항상 크기 제한
new LinkedBlockingQueue<>(10_000);함정 4. peek/contains는 동시성 안전하지 않음
// Race condition
if (queue.peek() != null) {
Task t = queue.poll(); // 그 사이 다른 쓰레드가 가져갔을 수 있음
}
// poll 한 번에
Task t = queue.poll();
if (t != null) {
process(t);
}A.8 어디서 만나나
- ThreadPoolExecutor의 작업 큐 — 가장 흔한 사용처
- 메시지 컨슈머 — Kafka, RabbitMQ 내부에서 사용
- 로깅 프레임워크 — Logback의 AsyncAppender
- 스케줄러 —
ScheduledThreadPoolExecutor는 내부에 DelayQueue 기반 큐 사용 - 이벤트 버스 — Guava EventBus의 비동기 모드
A.9 점검 질문
-
"LinkedBlockingQueue가 두 개의 락(put/take)을 쓰는 이유는?" → 생산자와 소비자가 큐의 양 끝(head/tail)을 만지니까 락을 분리하면 동시에 작업 가능. 처리량 증가.
-
"
Executors.newCachedThreadPool()이 SynchronousQueue를 쓰는 이유는?" → "저장하지 말고 즉시 워커에게 넘긴다"는 정책. 큐에 못 넘기면 새 쓰레드 생성. 짧고 많은 작업에 유리하지만 max를 안 잡으면 쓰레드 폭발 위험. -
"PriorityBlockingQueue를 ThreadPoolExecutor의 큐로 쓰면 어떤 문제가 생길까?" → 무제한 큐라 LinkedBlockingQueue처럼 maxPoolSize가 무의미해짐. 작업이 우선순위 순으로 처리되지만 쓰레드는 corePoolSize까지만 만들어짐.
-
"BlockingQueue 사이즈를 10으로 잡았는데, 모니터링하니 항상 10이다. 무슨 의미일까?" → 컨슈머가 너무 느림. 큐가 자주 가득 차면 백프레셔 발생 중 → 컨슈머 쪽 병목을 봐야 함.
-
"
take()호출한 쓰레드는 CPU를 쓰고 있을까?" → 아니요.Condition.await()로 OS가 잠재운 상태. 깨우는 쪽이signal()호출하면 그제서야 ready 큐로 돌아옴. busy waiting과의 결정적 차이.
3.3 워커 집합 — HashSet<Worker>
Worker 클래스
private final class Worker extends AbstractQueuedSynchronizer
implements Runnable {
final Thread thread; // 실제 OS 쓰레드
Runnable firstTask; // 처음 실행할 작업
volatile long completedTasks;
}워커는 쓰레드 그 자체가 아니라 쓰레드를 감싼 래퍼 입니다. 통계 수집, 인터럽트 제어, 라이프사이클 관리를 위해 감쌉니다.
왜 HashSet인가
- HashSet: O(1) 추가/삭제 — 쓰레드 추가/제거가 빈번할 수 있어 효율적
- 순서 보장 불필요
mainLock으로 보호되어 동시성 처리
3.4 제어 상태 — AtomicInteger의 비트 패킹
이건 정교한 트릭입니다. 풀의 상태와 워커 수를 하나의 int 변수로 관리 합니다.
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
// 32비트 int를 이렇게 쪼개 씀:
//
// ┌──────┬─────────────────────────────────────────┐
// │ 3비트 │ 29비트 │
// │ 상태 │ 워커 수 (workerCount) │
// └──────┴─────────────────────────────────────────┘
//
// 최대 워커 수: 2^29 - 1 = 약 5억 개풀 상태 5가지
RUNNING : 새 작업 받음, 큐의 작업 처리
SHUTDOWN : 새 작업 거부, 큐의 작업은 처리
STOP : 새 작업 거부, 큐 비움, 실행 중인 작업 인터럽트
TIDYING : 모든 작업 종료됨, terminated() 호출 전
TERMINATED : terminated() 완료
왜 비트 패킹인가? — 원자성
// 만약 상태와 워커 수가 별개 변수라면?
state = SHUTDOWN; // 1번째 연산
workerCount--; // 2번째 연산
// → 두 연산 사이에 다른 쓰레드가 끼어들 수 있음 (race condition)
// 비트 패킹 + CAS
ctl.compareAndSet(expect, ctlOf(SHUTDOWN, count - 1));
// → 한 번의 원자 연산으로 둘 다 변경CAS(Compare-And-Swap)는 락 없이 동시성을 처리하는 핵심 기법입니다.
3.5 전체 흐름 — submit()부터 실행까지
[Client] executor.submit(task)
│
▼
[1. 워커 수 확인]
ctl.get() → workerCountOf() < corePoolSize?
│
YES ──► [2a. addWorker(task, core=true)]
│ - CAS로 ctl의 워커 수 증가
│ - mainLock 획득 → workers.add(newWorker) → 해제
│ - thread.start()
│
NO ──► [2b. 큐에 넣기 시도]
workQueue.offer(task)
│
성공 ──► 끝 (워커가 알아서 가져감)
│
실패 (큐 가득 참)
│
▼
[3. 워커 추가 시도 (max까지)]
addWorker(task, core=false)
│
실패 (max 도달)
│
▼
[4. 거부 정책 실행]
3.6 다른 환경의 워커 풀 자료구조
Go의 워커 풀 (M:N 스케줄러)
- G (Goroutine): 작업
- M (Machine = OS Thread): 실제 OS 쓰레드
- P (Processor): 논리 프로세서, 로컬 큐 보유
자료구조:
- 각 P의 local run queue (256 크기 원형 큐)
- 글로벌 run queue (락 보호)
- work-stealing: P가 일 없으면 다른 P 큐에서 훔침
Java의 ForkJoinPool
각 워커가 자기만의 deque(이중 큐)를 가짐
- 자기 deque의 한쪽 끝에서 push/pop (LIFO)
- 다른 워커가 idle하면 다른 워커 deque의 반대쪽에서 훔침 (FIFO)
parallelStream()이 이걸 사용. 재귀적 분할 작업에 강력.
Node.js의 libuv 쓰레드 풀
- 단일 작업 큐 (linked list)
- 4개 워커 쓰레드 (기본, UV_THREADPOOL_SIZE로 조정)
- 워커들이 큐에서 작업 가져감
용도: 파일 I/O, DNS 조회, 일부 암호화 작업
3.7 자료구조 한눈에 보기
| 구성 요소 | 자료구조 | 역할 |
|---|---|---|
| 작업 큐 | BlockingQueue (Linked / Array / Synchronous / Priority) | 대기 작업 저장 |
| 워커 집합 | HashSet<Worker> | 활성 쓰레드 추적 |
| 제어 상태 | AtomicInteger (비트 패킹) | 풀 상태 + 워커 수를 원자적으로 |
| 동기화 | ReentrantLock + Condition | 집합 수정, 종료 대기 |
| Worker 내부 | AbstractQueuedSynchronizer 상속 | 인터럽트 제어, 락 |
4. DB 커넥션 풀 (DB Connection Pool)
4.1 커넥션 만드는 데 무슨 일이 일어나나
conn = DriverManager.getConnection(url, user, pw) 한 줄에 숨겨진 일들:
1. DNS 조회 (DB 호스트 IP 알아내기)
2. TCP 3-way handshake
3. TLS handshake (HTTPS면)
4. DB 인증 (사용자명/비밀번호 검증)
5. 세션 초기화 (charset, timezone, isolation level 등)
6. (선택) prepared statement 캐시 워밍업
이게 수십~수백 ms 걸립니다.
4.2 커넥션 풀의 동작
[Application]
│
│ borrow()
▼
┌────────────────────────────────────┐
│ Connection Pool (HikariCP 등) │
│ │
│ [Conn1] [Conn2] [Conn3] ... idle │
│ │
│ ▲ │
│ │ return() │
└──┼─────────────────────────────────┘
│
└─ TCP 연결은 살아있음, 재사용
실제 DB 서버 ◄────── 미리 맺어진 TCP 연결들
쿼리 실행할 때만 잠깐 빌렸다가 바로 반납. 커넥션 자체는 계속 살아있습니다.
4.3 대표 라이브러리
| 라이브러리 | 언어/생태계 | 특징 |
|---|---|---|
| HikariCP | Java | 사실상 표준. 빠르고 가볍다. Spring Boot 기본 |
| DBCP2 | Java | Apache Commons. 오래된 프로젝트에서 자주 봄 |
| Tomcat JDBC Pool | Java | Tomcat 내장 |
| node-postgres (pg) | Node.js | PostgreSQL용, 자체 풀 내장 |
| SQLAlchemy Pool | Python | SQLAlchemy 내장 |
| PgBouncer | PostgreSQL 전용 | 외부 프로세스로 동작하는 커넥션 풀러 |
4.4 커넥션 풀 크기 설정 — 매우 중요
가장 큰 오해: "많을수록 빠르다" → 틀렸습니다.
왜 커넥션이 많으면 안 되나
DB 서버는 커넥션마다 자원을 씁니다.
- 메모리 (PostgreSQL 기준 커넥션당 10MB+)
- 백엔드 프로세스/쓰레드
- 락 경합 증가
- 컨텍스트 스위칭
HikariCP 공식 가이드의 권장 공식
connections = ((core_count × 2) + effective_spindle_count)
- core_count: DB 서버의 CPU 코어 수
- effective_spindle_count: 디스크 수 (SSD면 1로 봐도 무방)
8코어 SSD 서버 → 17개 정도가 적정
실무에서 자주 보는 잘못된 설정
# 흔한 잘못된 설정
hikari:
maximum-pool-size: 100 # 너무 큼
minimum-idle: 100 # idle도 100? DB 자원 낭비# 더 합리적인 설정
hikari:
maximum-pool-size: 20 # DB가 받을 수 있는 만큼만
minimum-idle: 10
connection-timeout: 3000 # 3초 안에 못 얻으면 실패
idle-timeout: 600000 # 10분 idle이면 정리
max-lifetime: 1800000 # 30분마다 갱신4.5 커넥션 풀의 함정들
함정 1. 커넥션 누수 (Connection Leak)
// 위험한 코드
public User findUser(Long id) {
Connection conn = dataSource.getConnection();
// ... 쿼리 실행
return user; // conn.close() 안 함! 풀로 반환 안 됨
}
// try-with-resources
public User findUser(Long id) {
try (Connection conn = dataSource.getConnection()) {
// ... 쿼리 실행
return user;
} // 자동 close → 풀로 반환
}함정 2. 트랜잭션과 풀
긴 트랜잭션은 그동안 커넥션을 점유합니다. 트랜잭션 안에서 외부 API 호출 같은 짓을 하면 커넥션을 몇 초씩 잡고 있는 셈 입니다.
// 안티패턴
@Transactional
public void process() {
user = userRepository.find(id);
callExternalApi(); // 3초 걸림 → 그동안 커넥션 점유
userRepository.save(user);
}
// 트랜잭션 밖으로 빼기
public void process() {
User user = findUser(id);
Result result = callExternalApi(); // 트랜잭션 밖
saveResult(user, result);
}함정 3. max-lifetime과 DB의 wait_timeout 불일치
DB는 일정 시간 idle한 커넥션을 끊습니다 (MySQL wait_timeout 기본 8시간). 풀이 이걸 모르면 끊긴 커넥션을 빌려주려다 에러가 납니다.
원칙: 풀의 max-lifetime < DB의 wait_timeout
5. 두 풀이 만나는 지점
쓰레드 풀과 커넥션 풀은 서로 영향을 줍니다.
5.1 쓰레드 수 vs 커넥션 수
Tomcat 쓰레드: 200개
HikariCP 커넥션: 10개
→ 동시 요청 200개 들어옴
→ 쓰레드 200개가 동시에 DB 접근 시도
→ 커넥션 10개 빌리면 끝, 나머지 190개는 대기
→ connection-timeout 발생 → 줄줄이 에러
5.2 적정 비율 계산
요청 하나가 DB를 얼마나 점유하는가가 핵심입니다.
요청 처리 시간: 100ms
└ DB 사용 시간: 30ms (30%)
쓰레드 200개가 모두 일할 때
→ 평균 60개가 DB 사용 중
→ 커넥션 60개 필요
근데 분산이 있으니 여유 두고 80~100개로 설정?
→ NO! DB가 못 견딤
→ 커넥션 20~30개 + 큐잉으로 처리
5.3 서비스 아키텍처 전체 그림
[Load Balancer]
│
▼
[Web Server (Nginx) - Connection Queue]
│
▼
[WAS Thread Pool: 200]
│
▼
[Connection Pool: 20] ──► [DB: max_connections 150]
▲
│
[다른 서버들의 풀도 여기 연결됨]
WAS 인스턴스가 10대면 각 풀이 20개씩 → DB 입장에선 총 200개 커넥션. 이게 DB의 한계를 넘으면 또 장애.
6. 필수 CS 지식
6.1 쓰레드 vs 프로세스
| 항목 | 프로세스 | 쓰레드 |
|---|---|---|
| 메모리 | 독립적인 메모리 공간 | 같은 프로세스 내 메모리 공유 |
| 생성 비용 | 비쌈 | 상대적으로 쌈 |
| 컨텍스트 스위칭 | 비쌈 (TLB flush 등) | 상대적으로 쌈 |
| 통신 | IPC 필요 | 메모리 공유로 직접 |
| 격리 | 한 프로세스 죽어도 다른 건 살아있음 | 한 쓰레드 죽으면 프로세스 전체 영향 |
6.2 컨텍스트 스위칭
CPU가 쓰레드 A에서 쓰레드 B로 전환할 때:
- A의 레지스터 상태를 메모리에 저장 (PCB/TCB)
- B의 레지스터 상태를 메모리에서 로드
- 메모리 매핑 갱신 (프로세스 전환 시 TLB flush)
- 캐시 미스 폭증
비용: 수 마이크로초. 초당 만 번씩 일어나면 무시 못 함.
6.3 동시성(Concurrency) vs 병렬성(Parallelism)
- 동시성: 여러 작업이 논리적으로 동시에 진행 (시분할로 번갈아 실행)
- 병렬성: 여러 작업이 물리적으로 동시에 실행 (멀티코어)
싱글 코어 + 멀티 쓰레드 → 동시성 O, 병렬성 X
멀티 코어 + 멀티 쓰레드 → 동시성 O, 병렬성 O
6.4 블로킹 vs 논블로킹
- 블로킹: 작업이 끝날 때까지 쓰레드가 멈춤
- 논블로킹: 작업 요청만 하고 다른 일 함, 완료되면 콜백/이벤트로 통지
6.5 동기화 메커니즘
- Lock / Mutex: 한 번에 한 쓰레드만 진입
- Semaphore: N개까지 진입 허용 (커넥션 풀이 이걸로 구현됨)
- Atomic 연산: 락 없이 안전한 단순 연산 (CAS 기반)
- ConcurrentHashMap: 락 분할로 동시성 높인 자료구조
- BlockingQueue: 쓰레드 풀의 작업 큐로 자주 사용
6.6 TCP 연결의 비용
Client Server
│ ── SYN ─────────► │
│ ◄── SYN-ACK ── │
│ ── ACK ─────────► │ (3-way handshake)
│ │
│ ── ClientHello ─► │
│ ◄── ServerHello ─ │
│ ... TLS 협상 ... │ (TLS handshake)
│ │
│ ── AUTH ──────► │
│ ◄── OK ───── │ (DB 인증)
이 모든 과정이 새 커넥션마다 일어남. 풀이 없으면 매번 반복.
6.7 운영체제의 자원 제한
- ulimit -n: 한 프로세스가 열 수 있는 파일 디스크립터 수 (소켓 포함)
- TIME_WAIT 소켓: TCP 연결 종료 후 일정 시간 남는 상태. 너무 많으면 새 연결 못 맺음
- 포트 고갈: 한 IP에서 같은 목적지로 동시에 맺을 수 있는 연결 수 제한 (약 28,000개)
7. 실무 장애 사례
7.1 트래픽 급증 → 쓰레드 풀 고갈
상황: 평소 100 TPS인 서비스에 마케팅 이벤트로 1000 TPS
증상: 응답 시간 점진적 증가 → "thread pool exhausted" 알람
원인: Tomcat 쓰레드 200개 모두 점유 + 그중 180개가 DB 응답 대기 → 진짜 원인은 DB의 느린 쿼리
교훈: 쓰레드 풀 고갈은 증상 이지 원인이 아닐 때가 많음. 진짜 원인은 보통 다운스트림(DB, 외부 API)에 있음.
7.2 커넥션 풀 데드락
상황: 한 트랜잭션 안에서 두 번째 커넥션을 빌리는 코드
@Transactional
public void process() {
repositoryA.save(...); // 첫 번째 커넥션
CompletableFuture.supplyAsync(() -> {
return repositoryB.find(...); // 두 번째 커넥션 필요
}).get();
}문제: 풀 크기가 10인데 동시에 10개 요청이 들어오면 → 모두 두 번째 커넥션 대기 → 데드락
교훈: 한 트랜잭션 내에서는 한 커넥션만 쓰자.
7.3 DB 재시작 후 모든 커넥션이 좀비
상황: DB 점검 후 재시작했더니 애플리케이션 에러 폭증
원인: 풀의 커넥션들은 옛날 TCP 연결인데 DB가 재시작하면서 모두 끊어진 상태. 풀은 이걸 모르고 그대로 빌려줌.
해결: connection-test-query, validation-timeout, keepalive-time 설정
교훈: 풀에 있다고 다 살아있는 게 아니다. 검증 이 필요하다.
7.4 N+1 쿼리로 커넥션 폭발
List<User> users = userRepository.findAll(); // 1번 쿼리
for (User user : users) {
user.getOrders(); // 사용자마다 추가 쿼리 (Lazy Loading)
}100명 조회 시 101번 쿼리 → 풀 경합 발생
교훈: fetch join, batch size 같은 ORM 옵션을 잘 알아야 함.
7.5 idle timeout으로 인한 첫 요청 지연
상황: 새벽에는 트래픽이 없다가 아침에 첫 요청들이 매우 느림
원인: 야간에 idle 커넥션들이 모두 정리됨 → 아침 첫 요청들이 커넥션을 새로 만들어야 함
해결: minimum-idle을 0보다 크게 설정, 트래픽 적은 시간에도 일정 커넥션 유지
8. 가상 쓰레드 (Virtual Thread)
8.1 왜 만들어졌나
OS 쓰레드의 비용
자바의 전통적인 쓰레드는 OS 쓰레드와 1:1 매핑 입니다.
- 스택 메모리: 기본 1MB
- 커널 자료구조: TCB(Thread Control Block)
- 컨텍스트 스위칭: 시스템 콜 필요
- 생성 시간: 수 ms
산수: 쓰레드 10만 개 만들고 싶다 → 100GB 메모리 필요... 불가능
Thread-per-Request 모델의 한계
전통적인 Java 웹 서버는 요청마다 쓰레드 하나 를 할당. 쓰레드 200개가 대부분 놀고 있어요(I/O 대기). CPU는 한가한데 쓰레드가 부족해서 새 요청을 못 받는 모순.
기존 해결책: 비동기 / 리액티브의 단점
return userService.findById(id)
.thenCompose(user -> orderService.findByUser(user))
.thenApply(orders -> calculate(orders))
.exceptionally(this::handleError);- 콜백 지옥
- 디버깅 지옥 (스택 트레이스 의미 없음)
- 학습 곡선 가파름
- 기존 라이브러리(JDBC, Servlet)와 안 맞음
자바 진영의 결론: "비동기는 본질적인 해결책이 아니라 우회로다."
8.2 가상 쓰레드란
가상 쓰레드 100만 개 ┐
가상 쓰레드 99,999개 ├─ M개
가상 쓰레드 ... │
가상 쓰레드 1개 ┘
↓
스케줄링 (JVM)
↓
┌──────────────────┐
│ Carrier Thread 1 │
│ Carrier Thread 2 │ ← N개 (보통 CPU 코어 수)
│ Carrier Thread 3 │
│ Carrier Thread 4 │
└──────────────────┘
↓ 1:1
OS Thread
가상 쓰레드(VT) 는 실제로 일을 할 때만 캐리어 쓰레드(Carrier Thread) 에 올라타서 실행됩니다. 일이 끝나거나 대기 상태가 되면 캐리어에서 내려옵니다(unmount).
8.3 비용 비교
| 항목 | 플랫폼 쓰레드 (기존) | 가상 쓰레드 |
|---|---|---|
| 스택 크기 | 고정 1MB (기본) | 가변, 수 KB부터 시작 |
| 생성 비용 | 비쌈 (~ms) | 매우 쌈 (~μs) |
| 개수 한계 | 수천 ~ 수만 | 수백만 |
| 관리 주체 | OS 커널 | JVM |
| 컨텍스트 스위칭 | 시스템 콜 | 사용자 공간에서 |
8.4 코드 비교
// 플랫폼 쓰레드 (전통)
ExecutorService executor = Executors.newFixedThreadPool(200);
// 200개 쓰레드로 10000개 작업 처리 → 시간 오래 걸림
// 가상 쓰레드 (Java 21+)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> userRepository.find(id));
}
}
// 10000개 작업 = 10000개 가상 쓰레드 (동시에!)
// → 빠르고, 코드는 그대로 블로킹 스타일핵심은 "코드는 블로킹 스타일 그대로, 성능은 비동기처럼" 입니다.
8.5 어떻게 동작하나 — 내부 메커니즘
Mount / Unmount
[가상 쓰레드가 실행 중]
│
▼
[블로킹 작업 시작]
│
▼
[unmount: VT가 캐리어에서 내려옴]
│ ← 캐리어는 즉시 다른 VT 실행 가능
▼
[VT 상태가 힙(heap)에 저장됨]
│
(작업 완료 대기)
│
▼
[mount: VT가 다시 캐리어에 올라탐]
│ (꼭 같은 캐리어일 필요 없음)
▼
[실행 계속]
관건: 캐리어 쓰레드는 절대 블로킹되지 않습니다. VT가 블로킹 작업을 만나면 즉시 캐리어에서 내려옵니다.
컨티뉴에이션 (Continuation)
가상 쓰레드를 가능케 한 진짜 마법.
// 일반 함수 — 한 번에 끝까지 실행
String fetchData() {
String a = step1(); // 100ms
String b = step2(); // 200ms
return a + b;
}
// 컨티뉴에이션 개념 — 중간에 멈췄다가 이어갈 수 있음
String fetchData() {
String a = step1(); // 실행
// ← 여기서 멈출 수 있음 (상태 저장)
String b = step2(); // 나중에 여기서부터 다시 시작
return a + b;
}가상 쓰레드는 내부적으로 jdk.internal.vm.Continuation 을 사용합니다. 블로킹 지점에서 컨티뉴에이션을 yield(중단)하고 상태를 힙에 저장, 깨어날 때 resume(재개)합니다.
스택 관리
플랫폼 쓰레드:
┌──────────────────┐
│ │
│ 고정 1MB │ ← 95% 비어있어도 1MB 차지
│ │
└──────────────────┘
가상 쓰레드:
┌──────────┐
│ 실제 사용 │ ← 필요한 만큼만 (수 KB)
│ 부분만 │ 힙에 저장됨
└──────────┘
스케줄러
가상 쓰레드는 ForkJoinPool 을 스케줄러로 사용. 기본 캐리어 수 = CPU 코어 수.
8.6 어떤 작업에서 빛나나
| 작업 유형 | 가상 쓰레드 효과 |
|---|---|
| DB 쿼리 대기 | ⭐⭐⭐⭐⭐ 완벽 |
| 외부 API 호출 | ⭐⭐⭐⭐⭐ 완벽 |
| 파일 I/O | ⭐⭐⭐⭐⭐ 완벽 |
| 메시지 큐 컨슈머 | ⭐⭐⭐⭐ 좋음 |
| 순수 계산 | ⭐ 의미 없음 |
| 암호화/압축 | ⭐ 의미 없음 |
8.7 사용법
// 방법 1: 직접 생성
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Hello from virtual thread");
});
// 방법 2: ExecutorService (가장 흔함)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(task1);
executor.submit(task2);
}Spring Boot 3.2+에서
spring:
threads:
virtual:
enabled: true설정 한 줄로 Tomcat 쓰레드 풀을 가상 쓰레드로 교체.
8.8 주의해야 할 함정들
함정 1. Pinning (고정 현상)
synchronized 블록 안에서 블로킹되면 VT가 캐리어에서 못 내려옵니다.
// 위험
public synchronized void process() {
httpClient.send(...); // I/O 대기 — VT가 unmount 못 함!
}
// 가상 쓰레드 친화적
private final ReentrantLock lock = new ReentrantLock();
public void process() {
lock.lock();
try {
httpClient.send(...);
} finally {
lock.unlock();
}
}Java 24부터 는 이 제약이 해소됩니다(JEP 491).
함정 2. ThreadLocal 남용
// 위험
private static final ThreadLocal<HugeObject> CACHE = new ThreadLocal<>();
// 100만 개 VT × HugeObject = OOM
// 대안: ScopedValue (Java 21 프리뷰, 22 정식)
ScopedValue<HugeObject> SCOPED = ScopedValue.newInstance();함정 3. 풀링하면 안 됨
// 잘못된 사용
ExecutorService pool = Executors.newFixedThreadPool(200,
Thread.ofVirtual().factory());
// 올바른 사용
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 작업마다 새 VT 생성
}가상 쓰레드는 "매번 새로 만든다" 가 기본 철학입니다.
함정 4. DB 커넥션 풀은 여전히 필요
VT 100만 개 → 모두 커넥션 빌리려고 시도
→ DB max_connections 초과
→ 모든 요청 실패
쓰레드는 무한히 만들 수 있어도 DB 자원은 여전히 유한 합니다.
8.9 가상 쓰레드 시대의 변화
- 비동기 프레임워크의 위상 변화: WebFlux 같은 리액티브 필수성 감소
- 쓰레드 풀 튜닝의 종말: 적절한 풀 크기 고민 사라짐
- 코드 단순화: 디버깅, 스택 트레이스, 예외 처리 모두 직관적으로 돌아옴
8.10 한계도 분명
- CPU-bound 작업에는 도움 안 됨
- 메모리는 여전히 한정 자원
- 다운스트림(DB, 외부 API) 부하는 그대로
- 생태계 호환성: 일부 라이브러리는 아직 pinning 이슈
가상 쓰레드는 "동시 대기" 의 비용을 거의 0으로 만들지만, "동시 처리" 의 본질적 한계는 그대로입니다.
8.11 직관의 함정 — "와리가리"가 다른 이유
흔한 오해: "어차피 일을 다 끝내야 하면, 한 번에 다 끝내든 중간에 와리가리 치든 똑같지 않나?"
이건 CPU 작업에만 맞는 직관 입니다. 1+1을 100만 번 해야 하면 누가 해도 시간 똑같습니다. 가상 쓰레드 의미 없음.
하지만 DB 쿼리/API 호출/파일 I/O는 "내가 일하는 시간"이 아니라 "남이 일하는 동안 내가 기다리는 시간" 입니다. 이게 결정적 차이.
식당 웨이터 비유
손님이 "스테이크 주세요" 하면 요리는 주방 이 합니다. 웨이터는 그동안 뭘 할까요?
전통 쓰레드 (멍청한 웨이터):
- 주문 받음 → 주방에 전달 → 주방 앞에서 10분간 멍하니 서서 기다림 → 음식 받아서 서빙
- 웨이터 200명 있으면 동시에 200테이블만 받을 수 있음
- 200명 중 180명이 주방 앞에 줄 서서 멍 때리는 중
- 201번째 손님 오면? "자리는 많은데 웨이터가 없어요" → 돌려보냄
가상 쓰레드 (똑똑한 웨이터):
- 주문 받음 → 주방에 전달 → 딴 테이블 가서 다른 주문 받음 → 주방에서 "다 됐어요!" 부르면 그때 가서 받아옴
- 웨이터 200명이 2000테이블도 처리 가능
- 주방(=DB)이 못 따라오면 그건 별개 문제
핵심 차이는 "기다리는 비용"
[전통 쓰레드 200개로 1000 요청 처리]
쓰레드1: ████░░░░░░░░░░░░████ (■=CPU일, ░=DB 대기)
쓰레드2: ████░░░░░░░░░░░░████
...
쓰레드200: ████░░░░░░░░░░░░████
쓰레드201: 존재 불가 (메모리 부족, OS 한계)
→ 동시 처리량 = 200
→ CPU는 5%만 쓰고 노는 중인데 새 요청은 거부
쓰레드는 OS 자원이라 비쌉니다 (1MB 스택 + 커널 자료구조). 기다리기만 하는 데도 1MB씩 잡아먹습니다.
[가상 쓰레드 10000개]
VT 1~10000 모두 동시에 "DB 응답 대기" 상태 가능
실제 OS 쓰레드는 CPU 코어 수만큼만 (예: 8개)
→ DB 대기 중인 VT는 캐리어에서 내려와 힙에 상태만 저장 (수 KB)
→ 캐리어 쓰레드는 즉시 다른 VT 실행
와리가리 비용도 다르다
전통 쓰레드 컨텍스트 스위칭:
- OS 커널이 개입 (시스템 콜)
- 레지스터 저장/복원, TLB flush
- 캐시 다 날아감
- 비용: 수 μs
가상 쓰레드 mount/unmount:
- JVM이 사용자 공간에서 처리
- OS는 모름
- 비용: 수십 ns 수준
더 중요한 건, OS 쓰레드는 절대 안 기다린다 는 점. VT가 sleep이나 DB 응답 대기에 들어가면 캐리어에서 내려옵니다. 캐리어(=OS 쓰레드)는 블로킹되지 않고 즉시 다음 VT를 실행합니다.
핵심 한 줄: "동시에 1000명이 DB를 기다리려면 1000명이 동시에 줄 서있어야 한다. 그 줄 서있는 비용을 0에 가깝게 만든 게 가상 쓰레드다."
CPU 작업은 한 명이 한 번에 하나밖에 못 하지만, "기다리기"는 1000명이 동시에 할 수 있습니다. 단지 1000명의 "기다리는 사람"을 어떻게 싸게 만드냐의 문제였고, 가상 쓰레드가 그걸 해결한 겁니다.
8.12 VT 라이프사이클 상세 — Continuation의 마법
큰 그림: 누가 누구를 큐에 넣는가
┌─────────────────────────────────────────────────┐
│ ForkJoinPool (스케줄러) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Carrier1 │ │ Carrier2 │ │ Carrier3 │ ... │ ← OS 쓰레드 (CPU 코어 수)
│ │ + deque │ │ + deque │ │ + deque │ │
│ │ [VT][VT] │ │ [VT] │ │ [VT][VT] │ │ ← 실행 대기 VT
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘
▲ │
│ unpark (재실행 요청) │ mount (실행)
│ ▼
┌────────────────────┐ ┌──────────────────┐
│ Poller Thread(s) │ │ Heap에 저장된 │
│ (epoll/kqueue로 │◄────────│ Continuation들 │
│ I/O 완료 감시) │ park │ (대기 중인 VT) │
└────────────────────┘ └──────────────────┘
핵심 등장인물 셋:
- ForkJoinPool: VT 스케줄러. 실행 대기 큐 보유.
- Carrier Thread: 실제 OS 쓰레드. ForkJoinPool의 워커.
- Poller Thread: 별도 쓰레드들.
epoll로 I/O 완료 감시.
여기서부터 막히면 — 스케줄러 시각화를 새 탭에 띄워두고 본문을 따라오세요. "VT 10개 폭주", "I/O 완료 트리거" 버튼으로 work-stealing과 park/unpark 흐름을 직접 조작해볼 수 있습니다.
한 VT의 일생 (DB 쿼리 예시)
Thread.startVirtualThread(() -> {
String name = "Alice";
User user = jdbc.query("SELECT * FROM users WHERE name=?", name);
System.out.println(user);
});1단계: 생성 (NEW)
new VirtualThread(runnable)
└─ Continuation 객체 생성 (아직 실행 안 함)
└─ ForkJoinPool 큐에 enqueue
VT는 Continuation을 감싼 Runnable 일 뿐. 큐에 들어가서 차례를 기다림.
2단계: Mount (RUNNING)
Carrier-2가 한가해서 큐에서 VT를 꺼냄
└─ VT.run() 호출 → Continuation.run() 호출
└─ 캐리어 쓰레드의 스택에서 람다 코드 실행
이 순간 캐리어 쓰레드의 콜 스택:
Carrier-2 스택:
├─ ForkJoinPool.runWorker()
├─ VirtualThread.run()
├─ Continuation.run() ← 점프 지점
├─ 사용자 람다
└─ jdbc.query() ← 지금 여기
3단계: Park = Unmount (PARKED) — 진짜 마법
jdbc.query() 안에서 결국 SocketChannel.read()를 호출. JDK 내부 코드가 이걸 가로챕니다.
// JDK 내부 의사코드
public int read(ByteBuffer dst) {
if (현재_쓰레드가_가상쓰레드면) {
channel.configureBlocking(false);
Poller.register(channel, READ, currentVT);
LockSupport.park(); // ← 여기서 멈춤
return channel.read(dst);
}
return blockingRead(dst);
}LockSupport.park()가 호출되면 내부적으로:
Continuation.yield()
└─ 현재 스택 프레임들을 힙에 복사 (jdbc.query → 람다 → run)
└─ Continuation.run() 호출 지점으로 점프 (스택 되감기)
└─ Carrier-2의 스택은 ForkJoinPool.runWorker()까지 되돌아감
중요: 캐리어 쓰레드는 블로킹된 게 아니라, 그냥 메서드에서 리턴한 것처럼 Continuation.run() 호출이 끝나버린 겁니다. 캐리어는 즉시 다음 VT를 꺼내 실행 가능.
Carrier-2 스택 (Park 후):
└─ ForkJoinPool.runWorker() ← 깨끗하게 비워짐, 다음 VT 꺼내러 감
Heap (어딘가):
├─ Continuation 객체
│ └─ 저장된 스택 프레임들
│ ├─ 람다의 로컬 변수 (name="Alice")
│ ├─ jdbc.query()의 PC (어디서 멈췄는지)
│ └─ ...
4단계: I/O 완료 감지
별도의 Poller 쓰레드가 epoll_wait()로 모든 등록된 채널을 감시:
Poller Thread:
while (true) {
events = epoll.wait(); // 커널이 알려줌
for (event in events) {
VT vt = event.attachment;
LockSupport.unpark(vt); // VT를 깨움
}
}
unpark(vt)는 그 VT를 다시 ForkJoinPool 큐에 enqueue.
5단계: Re-mount
Carrier-5가 큐에서 깨어난 VT를 꺼냄
└─ Continuation.run() 다시 호출
└─ 힙에 저장됐던 스택 프레임들을 캐리어 스택에 복원
└─ park()가 리턴된 것처럼 jdbc.query()의 다음 줄부터 실행
꼭 같은 캐리어에 다시 올라탈 필요 없습니다. Carrier-2에 있다가 Carrier-5에 올라타도 됨.
6단계: 종료 (TERMINATED)
람다가 리턴하면 Continuation도 끝나고 VT 객체는 GC 대상.
상태 전이도
NEW
│ start()
▼
┌─── RUNNING (캐리어에 mount) ───┐
│ │
│ park() (I/O 대기, sleep 등) │ unpark()
│ │
▼ │
PARKED (힙에 보관) ──────────────┘
│
│ 람다 종료
▼
TERMINATED
예외 상태:
- PINNED:
synchronized안에서 park 시도하면 unmount 못 함. 캐리어를 그대로 점유 (Java 24에서 해결됨, JEP 491).
캐리어 스택 vs VT 스택
Carrier Thread Stack (고정 1MB)
┌────────────────────────────┐
│ ForkJoinPool.runWorker │
│ VT.run │
실행 중이면 ──► │ Continuation.run │
│ 사용자 람다 │
│ jdbc.query │
│ Socket.read │
└────────────────────────────┘
unmount되면 위쪽 부분이
복사되어 힙으로 옮겨감 ↓
Heap (수 KB)
┌──────────────┐
│ 람다 │
│ jdbc.query │ ← 필요할 때만 복원
│ Socket.read │
└──────────────┘
100만 개 VT가 모두 park된 상태? → 100만 개의 작은 힙 객체일 뿐. OS 쓰레드는 8개(코어 수)뿐.
8.13 ForkJoinPool 내부 자료구조
ForkJoinPool은 OS 쓰레드 풀 맞습니다. 단, 일반 ThreadPoolExecutor와는 자료구조가 완전히 다릅니다.
일반 쓰레드 풀과의 차이
일반 ThreadPoolExecutor:
┌────────────────────────┐
│ 공유 BlockingQueue │ ← 모든 워커가 락 잡고 꺼냄
└────────────────────────┘
↓ 경쟁
[W1] [W2] [W3] [W4]
ForkJoinPool:
[W1] [W2] [W3] [W4]
│ │ │ │
▼ ▼ ▼ ▼
WorkQueue WorkQueue WorkQueue WorkQueue ← 각자 보유
[T][T][T] [T][T] [] [T][T][T][T]
▲
│ steal (일 없으면 훔침)
└──── 다른 워커 큐 반대편에서
WorkQueue 자료구조
각 워커가 가진 큐는 Chase-Lev deque (이중 큐):
class WorkQueue {
ForkJoinTask<?>[] array; // 배열 기반 (원형)
int base; // 도둑이 훔쳐가는 쪽
int top; // 주인이 push/pop 하는 쪽
}동작 규칙:
- 주인 워커:
top에서 push/pop → LIFO (방금 넣은 거 바로 꺼냄, 캐시 친화적) - 도둑 워커:
base에서 take → FIFO (반대쪽 끝이라 충돌 최소) - 모두 CAS로 락 없이 동작
주인은 위에서: 도둑은 아래에서:
push ▼
pop ▲
┌─────┐
│ T5 │ ← top
│ T4 │
│ T3 │
│ T2 │
│ T1 │ ← base ──── steal ▲
└─────┘
이걸 work-stealing 스케줄러 라 부릅니다. 일이 한 워커에 몰리면 한가한 워커가 알아서 훔쳐갑니다. 부하 분산 자동.
"비동기용 컬렉션"이 아니다
정확히는 락-프리 분산 스케줄러 입니다. BlockingQueue처럼 "데이터 보관용 컬렉션"이 아니라, "누가 무슨 일을 언제 할지" 결정하는 인프라. WorkQueue는 그 구현 디테일.
VT 입장에서: VT를 시작하거나 unpark하면 ForkJoinTask로 감싸져서 적당한 WorkQueue에 들어감.
8.14 "힙(Heap)"의 오해 — 우선순위 큐 아님
"park된 VT는 힙에 저장됨"이라는 표현이 헷갈릴 수 있는데, 두 가지 "힙"을 구분해야 합니다.
"힙(Heap)"이라는 단어:
① JVM heap memory (메모리 영역, new 한 객체가 사는 곳)
② Heap data structure (우선순위 큐 자료구조, 이진 힙)
VT 컨텍스트에서의 "힙"은 ①번. 그냥 new Continuation() 으로 만들어진 평범한 자바 객체가 JVM 메모리에 있다는 뜻. 우선순위 정렬 같은 거 없습니다.
park된 VT는 어떻게 살아있나
GC 안 당하려면 누가 참조를 잡고 있어야 함. park 사유별로 다름.
| park 사유 | 누가 VT 참조를 잡고 있나? |
|---|---|
| Socket.read 대기 | Poller가 등록한 채널의 attachment |
| ReentrantLock 대기 | 락 객체의 wait queue (AQS) |
| Thread.sleep | ScheduledExecutor의 timer 큐 |
| CompletableFuture 대기 | future의 완료 콜백 리스트 |
깨어날 트리거가 오면 → LockSupport.unpark(vt) → ForkJoinPool 큐에 다시 enqueue.
우선순위는 없다
Thread.setPriority()는 VT에서 사실상 무의미 (필드는 있지만 스케줄러가 무시)- 순서가 있다면 ForkJoinPool deque 위치 순서뿐
- unpark된 순서대로 enqueue
- 워커가 LIFO로 꺼냄
- 다른 워커는 FIFO로 steal
"공정성도 우선순위도 보장 안 함. 빨리 깨어난 VT가 빨리 실행될 가능성이 클 뿐."
이게 의도된 설계입니다. 우선순위 큐를 쓰면 O(log n) 비용 + 전역 락 필요 → 락-프리 deque의 장점이 다 사라집니다. VT를 100만 개 굴리는데 매번 정렬 비용 내면 망함.
VIP 처리가 필요하면 별도 풀로 분리하거나 애플리케이션 레벨에서 처리.
8.15 Poller Thread 정밀 해부
이름은 Poller지만 busy polling 안 합니다. 용어 혼동부터 풀어야 합니다.
두 가지 "polling"
잘못된 이미지 (busy polling):
while (true) {
if (io_finished()) handle(); // CPU 100% 태움
}Poller의 실제 동작 (blocking syscall):
while (true) {
int n = epoll_wait(epollFd, events, -1); // ← 여기서 잠듦, CPU 0%
for (int i = 0; i < n; i++) {
VirtualThread vt = (VirtualThread) events[i].attachment;
LockSupport.unpark(vt);
}
}epoll_wait()는 블로킹 시스템 콜. 이벤트가 없으면 쓰레드는 OS 관점에서 잠들어 있고, CPU를 전혀 안 씁니다.
깨어나는 메커니즘
1. 네트워크 카드(NIC)가 패킷 도착
↓
2. 하드웨어 인터럽트 → 커널이 처리
↓
3. 커널이 해당 fd의 epoll 인스턴스에 이벤트 추가
↓
4. 커널이 epoll_wait()에 잠든 쓰레드를 ready 큐로 옮김
↓
5. CPU 스케줄러가 그 쓰레드를 깨움
↓
6. epoll_wait()가 리턴 → Java 코드 실행 재개
↓
7. unpark(vt) 호출 → VT가 ForkJoinPool 큐에 들어감
↓
8. 한가한 캐리어가 꺼내서 mount → I/O 결과 읽고 계속
이름이 왜 Poller인가
역사적으로 select() → poll() → epoll() 계열의 I/O multiplexing API를 통칭 "polling" 이라고 불러왔습니다. 사용자 코드 입장에선 "여러 fd를 한꺼번에 물어본다"는 느낌. 실제 구현은 인터럽트 기반인데도.
왜 쓰레드여야 하나
세 가지 이유:
① syscall은 결국 쓰레드가 호출해야 한다
epoll_wait()는 함수 호출이고, 그 안에서 잠들어 있어야 합니다. 잠들 수 있는 주체는 OS 쓰레드뿐. "콜백만 등록하고 끝" 같은 API는 OS가 안 줍니다. (Linux의 io_uring이 비슷한 시도지만 JDK는 아직 채택 안 함.)
② 캐리어가 하면 안 됨
캐리어는 VT 돌리는 게 본업. 캐리어가 epoll_wait()에 잠들면 → 그동안 VT 못 돌림. 캐리어 4개 중 1개가 빠지면 25% 손실.
③ 분리하면 캐리어 수 = 코어 수 유지 가능
전용 Poller를 두면 캐리어 풀은 깨끗하게 "CPU 작업 전용"이 됩니다.
JDK 21+ 실제 구현
sun.nio.ch.Poller 내부:
- 시스템 프로퍼티로 조정 가능:
jdk.readPollers (기본: 코어 수와 1 사이의 2의 거듭제곱)
jdk.writePollers
jdk.pollerMode (SYSTEM 또는 VTHREAD)
- 각 sub-poller가 자기 epoll fd 보유
- 채널 등록 시 fd 해시로 sub-poller 분산
- 한 poller가 너무 많은 fd를 들고 있는 것 방지
이름은 Read-Poller, Write-Poller로 나뉘어 동작.
전체 그림 — OS 쓰레드의 역할 분담
JVM의 OS 쓰레드들 (jstack 찍어보면 보임)
┌──────────────────────────────────────────────────────────┐
│ │
│ ForkJoinPool 워커 = Carrier 쓰레드들 (코어 수) │
│ └─ ForkJoinTask로 감싸진 VT의 Continuation.run() 실행 │
│ │
│ Read-Poller, Write-Poller (각 1~수 개) │
│ └─ epoll_wait()에서 잠들어 있다가 이벤트 오면 unpark │
│ │
│ Timer 쓰레드 (Thread.sleep, ScheduledExecutor용) │
│ └─ 시간 됐을 때 unpark │
│ │
│ GC 쓰레드, JIT 컴파일러, Reference Handler ... │
│ │
└──────────────────────────────────────────────────────────┘
▲ │
│ unpark(vt) → enqueue │ park
└──────────────────────────┘
ForkJoinPool deque
100만 VT를 굴려도 OS 쓰레드는 결국 수십 개 수준. 이게 가상 쓰레드의 본질입니다.
8.16 OS ↔ JVM 경계 — 자주 막히는 지점 Q&A
8.12 ~ 8.15에서 Poller 기반 unpark 흐름을 다뤘는데, 실제로 누가 누구를 깨우고 어떤 데이터가 OS와 JVM 사이를 오가는지 헷갈리기 쉽습니다. 이 절은 "OS는 VT를 모른다", "아무도 폴링하지 않는다" 같은 핵심 주제를 Q&A로 정리합니다.
시각화로 따라가기: 이 절의 6번 Q "패킷 한 번의 일생"이 그대로 커널 흐름 시각화의 14단계 애니메이션으로 구현돼 있습니다. ▶ 자동 재생 한 번 돌려보고 글을 읽으면 그림이 훨씬 빨리 잡힙니다.
Q1. OS는 VT를 모르는데, fd=42에 이벤트 오면 어떻게 VT-1234를 깨우지?
fd가 OS-JVM 사이의 공통 언어 입니다. VT라는 개념은 OS에 없습니다. 다리는 JDK가 들고 있는 매핑.
park 직전 (VT 안의 코드):
Poller.register(channel, READ, currentVT);
LockSupport.park();
이 register 안에서 두 가지가 동시에 일어남:
① OS에게: epoll_ctl(epollFd, ADD, fd=42, events=EPOLLIN)
→ 커널은 "fd=42 감시 시작"만 기억. VT 모름.
② Java 힙에: pollerMap.put(fd=42, vt=VT-1234)
→ 이 매핑은 평범한 Java 객체. 커널은 못 봄.
이벤트 발생 시:
1. 커널: epoll_wait()에 "fd=42 ready" 리턴
2. Java(Poller): pollerMap.get(42) → VT-1234
3. Java(Poller): LockSupport.unpark(VT-1234)
OS는 fd만 깨운다. "그 fd를 기다리던 VT가 누구인지"는 Java가 자기 힙 안의 매핑으로 알아낸다. Poller는 OS의 "fd 깨우기"를 Java의 "VT unpark"로 번역하는 통역사.
Q2. fd는 뭐의 약자고 정체가 뭐야?
File Descriptor (파일 디스크립터). OS가 열린 자원을 가리키는 작은 정수.
Unix 철학 "Everything is a file" 때문에 fd가 가리키는 건 파일만이 아님.
| fd가 가리키는 것 | 예 |
|---|---|
| 진짜 파일 | /etc/passwd |
| 소켓 | TCP 연결 (우리가 본 fd=42가 이거) |
| 파이프 | `ls |
| 터미널 | stdin(0), stdout(1), stderr(2) |
| epoll 인스턴스 | epoll_create() 결과 |
실체는 커널 안의 프로세스별 fd 테이블:
PID 9876의 fd 테이블 (커널 메모리):
┌─────┬──────────────────────────┐
│ 0 │ → stdin │
│ 1 │ → stdout │
│ 42 │ → TCP 소켓 (8080 연결) │
│ 43 │ → epoll 인스턴스 │
└─────┴──────────────────────────┘
사용자 프로그램은 숫자만 받습니다. 실제 소켓 구조체는 커널이 들고 있고, fd는 그 테이블의 인덱스.
Q3. Java가 커널 권한이 있어서 epoll 같은 걸 부르는 거야?
아니요. 권한이 아니라 syscall이라는 계약된 API. 모든 프로그램은 동일하게 syscall로 커널과 대화합니다.
일반 호출: printf, ArrayList 등 → 그냥 라이브러리
syscall 호출: epoll_wait() → CPU 명령어로 Ring 3 → Ring 0 모드 전환
(권한 같은 게 아님. CPU의 기능.)
epoll_ctl, epoll_wait, read, write 다 syscall. Java도 C 프로그램도 똑같이 부를 수 있습니다. 차이는 누가 더 잘 추상화했냐일 뿐.
Q4. 그럼 누가 8080 포트를 계속 훑어보고 있는 거야?
아무도 안 훑어봅니다. 모두가 잡니다. 시작 트리거는 NIC의 하드웨어 인터럽트.
잘못된 그림:
Poller 쓰레드가 while(true) { check 8080; check fd 42; ... } 돈다
실제 그림:
Poller-T: epoll_wait() 안에서 잠. CPU 0%.
애플리케이션 VT: park 상태로 힙에 보관. CPU 0%.
캐리어들: ready queue 또는 다른 VT 처리.
아무도 안 봄. 패킷이 올 때까지.
진짜 시작은 하드웨어:
NIC가 패킷 받음
↓
NIC가 CPU에 인터럽트 신호 발사 (전기 신호. 소프트웨어 아님.)
↓
CPU가 지금 뭐 하든 멈추고 → 커널 인터럽트 핸들러로 점프
↓
핸들러가 epoll 갱신 + wake_up()
↓
Poller-T가 ready queue로 → 결국 CPU 받아 깨어남
문서 8.15의 "이름은 Poller지만 busy polling 안 합니다"가 이 얘기입니다. select/poll/epoll 계열을 옛날부터 "polling"이라 불러왔을 뿐, 구현은 완전히 인터럽트 기반.
Q5. 커널은 OS만 접근 가능한 프로세스 같은 거야?
프로세스가 아니라 CPU의 특권 모드(privilege mode). x86 기준 Ring 0.
┌─────────────────────────────────────────────────┐
│ USER MODE (Ring 3) — 제한된 권한 │
│ Java, Chrome, bash, nginx ... │
└─────────────────────────────────────────────────┘
▲
│ syscall / interrupt (게이트)
▼
┌─────────────────────────────────────────────────┐
│ KERNEL MODE (Ring 0) — 모든 권한 │
│ 스케줄러, 드라이버, 네트워크 스택, 파일시스템 │
└─────────────────────────────────────────────────┘
같은 CPU 가 두 모드를 왔다갔다 합니다. ps 쳐도 커널은 안 나옵니다. PID 없음. 그냥 "특권 모드에서만 돌 수 있는 코드 + 그 메모리".
진입 경로 세 가지:
| 경로 | 컨텍스트 | 예 |
|---|---|---|
| ① Syscall | 부른 프로세스의 컨텍스트 | Java가 epoll_wait() |
| ② Interrupt | 인터럽트 컨텍스트 (특정 프로세스 없음) | NIC 패킷 도착 |
| ③ 커널 쓰레드 | 처음부터 Ring 0에서 사는 쓰레드 | kworker, ksoftirqd |
top의 %sy(system time)가 ①번에 해당. Java가 syscall로 커널 코드 돌린 시간.
Q6. 큐를 옮기는 건 CPU야 커널이야?
역할 분리:
CPU (하드웨어):
- 명령어를 그냥 실행. "이거 해" 하면 함.
- Ring 3↔Ring 0 모드 전환은 CPU가 함 (syscall/interrupt 명령어 시).
- "어느 쓰레드를 ready로 옮길지" 같은 판단은 안 함.
커널 코드 (소프트웨어):
- 스케줄러, wake_up(), wait queue/ready queue 관리.
- 큐를 실제로 조작하는 주체.
- 이 코드를 실행하는 건 CPU.
비유: CPU는 손, 커널 코드는 명령서. 손은 명령서대로 움직일 뿐.
Poller가 잠드는 순간:
Poller-T의 epoll_wait() 호출
└─ [CPU] syscall 명령어 → Ring 0 진입
└─ [커널 코드] sys_epoll_wait():
if (이벤트 있나?) return;
add_wait_queue(epoll.wq, current); ← wait queue에 넣음
set_current_state(TASK_INTERRUPTIBLE);
schedule(); ← 스케줄러 호출
└─ [커널 코드] schedule():
- 현재 쓰레드 ready queue에서 제거
- 다음 쓰레드 선택 → 컨텍스트 스위치
큐 조작 주체는 add_wait_queue(), deactivate_task() 같은 함수들. CPU는 그 함수의 instructions를 실행했을 뿐.
핵심 데이터 구조 두 종류
┌─────────────────────────────────────────────────────────┐
│ Wait Queue (자원별로 하나씩) │
│ "이 자원을 기다리며 잠든 쓰레드들" │
│ │
│ epoll #5 wait queue → [Poller-T1, Poller-T2] │
│ 뮤텍스 M wait queue → [ThreadA, ThreadB] │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Ready Queue (CPU 코어별로 하나) │
│ "지금 당장 CPU만 주면 뛸 쓰레드들" │
│ │
│ CPU0 run queue: [Bash, JavaCarrier-1, Chrome] │
│ CPU1 run queue: [JavaCarrier-2, kworker] │
└─────────────────────────────────────────────────────────┘
잠들 때: ready 큐 → wait 큐
깨울 때: wait 큐 → ready 큐
실행: 스케줄러가 ready 큐에서 골라 CPU에 올림
전체 흐름 — 패킷 한 번의 일생
HTTP 요청이 도착해서 VT가 깨어나기까지:
[0] 잠든 상태
- Poller-T : 커널 wait queue (epoll_wait 안)
- VT-1234 : Java 힙에 Continuation으로 박제됨 (park)
- 캐리어들 : ready queue 또는 다른 VT 처리
- CPU : 다른 프로세스 돌리는 중
[1] 클라이언트가 8080으로 패킷 송신
↓
[2] NIC가 패킷을 DMA로 RAM에 쓰고 CPU에 인터럽트 발사
↓ ← 여기까지 하드웨어
[3] CPU 강제 Ring 0 진입, 인터럽트 벡터로 점프
↓
[4] 이제부터 CPU 위에서 커널 코드 실행:
(a) NIC 드라이버: 패킷을 RAM에서 읽음
(b) TCP/IP 스택: "fd=42 소켓 행" 식별
(c) 소켓 코드: fd=42 수신 버퍼에 데이터 보관
(d) epoll 코드: epoll #5의 ready list에 fd=42 추가
(e) wake_up(): Poller-T를 wait queue → ready queue
↓
[5] 인터럽트 핸들러 끝
↓
[6] 스케줄러가 적당한 시점에 Poller-T를 CPU에 올림
↓ ← Poller는 자기가 부탁한 epoll_wait()에서 리턴된 것처럼 깨어남
[7] Ring 0 → Ring 3 복귀, Java 코드 실행:
events = epoll_wait()의 결과 // [fd=42, ...]
for each event:
VT vt = pollerMap.get(event.fd) // VT-1234
LockSupport.unpark(vt)
↓
[8] 한가한 캐리어가 ForkJoinPool 큐에서 VT-1234 꺼냄
↓
[9] 캐리어가 VT-1234를 mount → socket.read() 다음 줄부터 실행
행위자 세 종류만 기억
- 하드웨어 (NIC, CPU) — 신호 발사, 명령어 실행. 판단 없음.
- 커널 코드 — CPU 위에서 돌며 큐를 조작. wait↔ready 이동의 주체. fd만 안다.
- Java 코드 — Poller도 VT도 캐리어도 다 user mode. fd→VT 변환과 ForkJoinPool 운영을 담당.
위 9단계를 직접 단계별로 보고 싶다면 /visualizations/kernel-flow-viz.html을 새 탭에서 열어주세요. NIC 인터럽트부터 캐리어가 VT를 mount하는 순간까지 색깔로 활성 행위자를 보여줍니다.
8.17 질문 모음
-
"ForkJoinPool은 왜 LinkedBlockingQueue 같은 단일 공유 큐를 안 쓰고 워커마다 deque를 둘까?" → 공유 큐는 모든 워커가 같은 락을 두고 경쟁. work-stealing은 평소엔 자기 큐만 보니까 충돌 0, 부하 불균형 시에만 훔침.
-
"100만 VT가 동시에 unpark되면 어떻게 될까?" → ForkJoinPool deque에 100만 개 enqueue. 캐리어 8개가 부지런히 처리. 메모리는 OK지만 처리 자체는 결국 직렬화됨. 자원은 무한이 아니다.
-
"Poller 쓰레드가 죽으면 어떻게 될까?" → 그 Poller가 담당하던 채널들의 I/O 완료를 아무도 안 알려줌 → VT들 영원히 park. 그래서 JDK는 Poller를 데몬+예외 처리 잘 해서 만듭니다.
-
"VT가
Thread.sleep(1000)하면 캐리어 쓰레드도 1초 멈출까?" → 아니요. JDK가 sleep을 가로채서 park로 변환합니다. 캐리어는 다른 VT를 처리합니다. -
"순수 CPU 계산 100ms 도는 VT가 있으면 어떻게 될까?" → 그 100ms 동안 캐리어를 점유합니다 (park 지점이 없으니까). 다른 VT들이 줄 서서 기다림. 그래서 가상 쓰레드가 CPU-bound에 의미 없는 겁니다.
-
"
synchronized안에서 DB 호출하면 왜 위험할까?" → synchronized는 OS 모니터 락이라 unmount가 안 됨 → 캐리어 쓰레드 한 자리를 통째로 점유. 캐리어가 4개면 4번만 일어나도 전체 마비.
마치며
쓰레드 풀과 커넥션 풀은 결국 같은 질문을 다른 각도에서 푸는 도구입니다. "비싼 자원을 어떻게 효율적으로 공유할 것인가." 그리고 가상 쓰레드는 그 질문의 한 축(쓰레드 비용)을 거의 0으로 보낸 새로운 답안이고요.
핵심을 다섯 줄로 정리합니다.
- 풀의 본질은 "생성 비용 분산 + 자원 한계 방어". 큐와 한계를 모르면 풀이 오히려 장애의 원흉이 됩니다.
- 쓰레드 풀 고갈은 보통 증상이지 원인이 아니다. 다운스트림(DB, 외부 API)을 먼저 봐야 합니다.
- 커넥션 풀은 많을수록 좋다는 직관을 버려라. DB의 자원도 유한합니다.
- 가상 쓰레드는 "기다리는 비용"을 0에 가깝게 만든다. 단, 기다리지 않는 작업에는 의미 없습니다.
- OS는 fd만 알고 VT는 모른다. Java가 자기 힙에 들고 있는 매핑이 두 세계를 잇는 다리입니다.
본문이 길어서 한 번에 다 흡수하려 하지 말고, 막히는 부분이 생기면 위쪽의 두 시각화로 돌아와서 같은 흐름을 그림으로 다시 보면 좋습니다. 같은 개념을 글·도식·인터랙티브 세 가지로 보면 머릿속에 입체적으로 들어옵니다.