F-LAB JAVA · 5주차 · Phase 1 · 스레드 풀의 필요성 재정리
🚀 5주차 시작 — Part A 동시성 마무리
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
스레드를 많이 쓸수록 항상 빠른 것은 아니며, 컨텍스트 스위칭 오버헤드·데드락 가능성·노는 스레드의 자원 낭비라는 3가지 함정 때문에 "스레드는 존재 자체로 비용" 이다.
컨텍스트 스위칭 오버헤드 — 스레드가 많아질수록 CPU 가 스레드를 번갈아 실행하기 위한 전환 비용 (레지스터 백업·복원, 캐시 무효화) 이 늘어나, 실제 작업보다 전환에 시간을 더 쓰게 된다.
데드락 가능성 — 스레드와 락이 많아질수록 서로의 자원을 기다리는 데드락이 발생할 확률이 높아진다.
노는 스레드의 자원 낭비 — 작업하지 않는 스레드도 스택 메모리 (기본 1MB) 를 차지하고 스케줄링 대상이 되어 비용을 발생시킨다.
결론적으로 스레드는 공짜가 아니므로, 무한정 생성하지 말고 스레드 풀로 개수를 제한하고 재사용 해야 한다 (Phase 1 의 주제).
스레드 = 식당 직원:
"직원 많을수록 빠를까?" → NO
함정 1 — 컨텍스트 스위칭:
- 주방 좁은데 직원 100명
- 서로 부딪히고 비켜주느라
- 실제 요리보다 정리에 시간
함정 2 — 데드락:
- 직원 A: 칼 들고 도마 기다림
- 직원 B: 도마 들고 칼 기다림
- 서로 영원히 대기
함정 3 — 노는 직원:
- 일 없는 직원도 월급 (메모리)
- 자리 차지 (스케줄링)
- 매니저가 신경 써야 (오버헤드)
→ 직원은 공짜가 아니다
→ 적정 인원 (스레드 풀)
→ 스레드 많다고 빠른 게 아니다 (스위칭/데드락/노는 스레드), 공짜 아님.
1. "많을수록 빠를까?" 의 답
2. 함정 1 — 컨텍스트 스위칭
3. 함정 2 — 데드락 가능성
4. 함정 3 — 노는 스레드 낭비
5. 스레드는 공짜가 아니다
6. 멀티 프로세스 vs 멀티 스레드 스위칭
7. 적정 스레드 수
8. 스레드 풀이 답
9. 면접 + 자기 점검
직관:
"스레드 많으면 → 병렬 ↑ → 빠름"
현실:
"스레드 너무 많으면 → 오히려 느림"
이유:
- 코어 수 한계
- 스위칭 오버헤드
- 자원 경쟁
코어 수 한계:
코어 4개:
- 동시 실행 = 최대 4개
- 스레드 100개 → 25배 대기
나머지 96개:
- 대기 (스위칭 대상)
- 자원만 차지
스레드 수 vs 처리량:
처리량
│ ╭──────╮
│ ╱ ╲___ (스위칭 오버헤드)
│ ╱
│ ╱
└──────────────────→ 스레드 수
최적점
- 최적점까지: 처리량 ↑
- 초과: 처리량 ↓ (오버헤드)
작업 유형:
CPU 바운드:
- 코어 수 정도가 최적
- 초과 시 스위칭만
I/O 바운드:
- 대기 많음
- 코어보다 많이 가능
- 단, 한계 있음
@Service
public class ThreadCountTrap {
// ❌ 무조건 많이 (역효과)
public void processWrong(List<Shipment> shipments) {
for (Shipment s : shipments) {
new Thread(() -> process(s)).start();
// 1만 건 = 1만 스레드
// 코어 8개 → 스위칭 폭증
// 오히려 느림
}
}
// ✓ 적정 (스레드 풀)
private final ExecutorService executor =
Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
public void processRight(List<Shipment> shipments) {
for (Shipment s : shipments) {
executor.submit(() -> process(s));
// 코어 수만큼 (최적)
}
}
private void process(Shipment s) { }
}
"스레드를 많이 쓸수록 빠를까?" 의 답은?
답:
1. 답:
이유:
곡선:
유형:
컨텍스트 스위칭 (Context Switching):
CPU 가 한 스레드에서
다른 스레드로 전환하는 작업.
비용:
- 레지스터 백업/복원
- 캐시 무효화
- 스케줄링
스위칭 시 일어나는 일:
1. 현재 스레드 상태 저장
- 레지스터 → 메모리
2. 다음 스레드 상태 복원
- 메모리 → 레지스터
3. 캐시 무효화
- 캐시 미스 발생
4. 컨텍스트 전환
- 스택, PC 카운터
스위칭 횟수:
스레드 ↑ → 스위칭 ↑
코어 4개, 스레드 100개:
- 25배 더 자주 전환
- 작업보다 전환에 시간
→ 스레드 ↑ ≠ 처리량 ↑
캐시 영향:
스레드 전환 시:
- 이전 스레드 캐시 무효
- 새 스레드 캐시 채우기 (미스)
- 메모리 접근 ↑
잦은 전환:
- 캐시 효율 ↓
- 성능 저하
컨텍스트 스위칭:
스레드 적음 (효율):
[작업A────][작업B────][작업A────]
(긴 작업, 적은 전환)
스레드 많음 (비효율):
[A][B][C][A][D][B][전환][전환]...
(짧게 쪼개짐, 잦은 전환)
↑ 전환 비용 누적
@Service
public class ContextSwitchingCost {
private final int cores = Runtime.getRuntime().availableProcessors();
// CPU 바운드 — 코어 수 (스위칭 최소)
private final ExecutorService cpuPool =
Executors.newFixedThreadPool(cores);
// 코어 수 = 스위칭 최소
// 운임 계산 등 CPU 작업
public BigDecimal calculate(Shipment shipment) {
// CPU 작업은 코어 수만큼
return freightCalculator.calculate(shipment);
}
// ❌ 과도한 스레드 (스위칭 폭증)
public void overThreaded(List<Shipment> shipments) {
ExecutorService tooMany = Executors.newFixedThreadPool(1000);
// 코어 8개에 1000 스레드
// → 스위칭 오버헤드 막대
// → CPU 작업 비효율
}
private BigDecimal calculateFreight(Shipment s) { return s.getWeight(); }
}
컨텍스트 스위칭 오버헤드란?
답:
1. 정의:
비용:
횟수:
영향:
데드락과 스레드 수:
스레드 많음 + 락 많음:
- 자원 경쟁 ↑
- 순환 대기 가능성 ↑
- 데드락 확률 ↑
복잡도 증가:
스레드 2개:
- 상호작용 단순
- 데드락 추적 쉬움
스레드 100개:
- 상호작용 복잡
- 락 의존 얽힘
- 데드락 추적 어려움
데드락 (4주차 복습):
4조건:
- 상호 배제
- 점유 대기
- 비선점
- 순환 대기
스레드 ↑ → 순환 대기 가능성 ↑
진단 어려움:
스레드 많으면:
- jstack 출력 방대
- 데드락 찾기 어려움
- 재현 어려움 (타이밍)
→ 스레드 적을수록 관리 쉬움
@Service
public class DeadlockRisk {
// 스레드 많을수록 데드락 위험 ↑
private final ReentrantLock lockA = new ReentrantLock();
private final ReentrantLock lockB = new ReentrantLock();
// 스레드 풀로 제한 (데드락 관리 쉬움)
private final ExecutorService pool = Executors.newFixedThreadPool(4);
// 락 순서 일관 (데드락 방지, 4주차)
public void transfer(ShipmentAccount from, ShipmentAccount to, BigDecimal amount) {
ShipmentAccount first = from.id < to.id ? from : to;
ShipmentAccount second = from.id < to.id ? to : from;
first.lock.lock();
try {
second.lock.lock();
try {
from.balance = from.balance.subtract(amount);
to.balance = to.balance.add(amount);
} finally {
second.lock.unlock();
}
} finally {
first.lock.unlock();
}
// 스레드 적고 + 락 순서 일관 → 데드락 위험 ↓
}
static class ShipmentAccount {
long id;
ReentrantLock lock = new ReentrantLock();
BigDecimal balance = BigDecimal.ZERO;
}
}
스레드 수와 데드락의 관계는?
답:
1. 관계:
복잡도:
진단:
관리:
노는 스레드 (idle thread):
작업하지 않는 스레드도:
- 스택 메모리 차지 (1MB)
- 스케줄링 대상
- 자원 낭비
메모리 낭비:
노는 스레드도:
- 스택 메모리 (기본 1MB)
- 1000 노는 스레드 = 1GB
- 작업 안 해도 차지
→ 메모리 압박
스케줄링 부담:
노는 스레드도:
- 스케줄러가 고려
- 큐에 존재
- 스케줄링 오버헤드
→ 노는데도 비용
노는 스레드가 CPU 부담:
- 스케줄러가 모든 스레드 관리
- 노는 스레드도 상태 확인
- 스위칭 후보
- 메모리 관리 (GC 영향)
→ 존재 자체로 부담
@Service
public class IdleThreadWaste {
// ❌ 노는 스레드 많음 (낭비)
public void wasteful() {
// 1000 스레드 생성, 대부분 노는 중
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
try { Thread.sleep(Long.MAX_VALUE); } catch (Exception e) {}
// 영원히 노는 스레드
// 1MB × 1000 = 1GB 낭비
}).start();
}
}
// ✓ 풀 + keepAlive (노는 스레드 회수)
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // core
20, // max
60L, TimeUnit.SECONDS, // 노는 스레드 60초 후 회수
new LinkedBlockingQueue<>(100)
);
// 피크 후 노는 스레드 정리 (20 → 4)
// 메모리 절약
public void efficient(Shipment shipment) {
executor.submit(() -> process(shipment));
}
private void process(Shipment s) { }
}
노는 스레드의 자원 낭비는?
답:
1. 노는 스레드:
메모리:
스케줄링:
회수:
스레드는 공짜가 아니다:
존재 자체로 비용:
- 생성 비용
- 메모리 (스택)
- 스케줄링
- 스위칭
→ 무한 생성 X
→ 신중히 관리
스레드 비용 정리:
1. 생성 비용
- OS 스레드, 초기화
2. 메모리
- 스택 1MB
3. 스위칭
- 전환 오버헤드
4. 스케줄링
- 관리 부담
5. 데드락
- 복잡도
트레이드오프:
스레드 적음:
+ 스위칭 적음
+ 관리 쉬움
- 병렬성 ↓
스레드 많음:
+ 병렬성 ↑ (한계까지)
- 스위칭 ↑
- 자원 ↑
→ 적정점 찾기
실무 원칙:
"스레드를 직접 만들지 마라.
ExecutorService 를 써라.
풀 크기는 신중히 정하라."
- 재사용 (생성 비용)
- 개수 제한 (자원)
- 적정 크기 (성능)
@Service
public class ThreadNotFree {
// 스레드 비용 인식 → 풀 사용
private final int cores = Runtime.getRuntime().availableProcessors();
// CPU 작업: 코어 수
private final ExecutorService cpuPool =
Executors.newFixedThreadPool(cores);
// I/O 작업: 더 많이 (대기 고려)
private final ExecutorService ioPool =
Executors.newFixedThreadPool(cores * 4);
public BigDecimal calculate(Shipment shipment) {
// CPU → cpuPool
try {
return cpuPool.submit(() ->
freightCalculator.calculate(shipment)).get();
} catch (Exception e) {
return BigDecimal.ZERO;
}
}
public TrackingInfo fetchTracking(String blNo) {
// I/O → ioPool
try {
return ioPool.submit(() -> trackingApi.fetch(blNo)).get();
} catch (Exception e) {
return null;
}
}
private BigDecimal calculateFreight(Shipment s) { return s.getWeight(); }
record TrackingInfo() {}
}
스레드는 공짜가 아니다의 의미는?
답:
1. 핵심:
비용:
트레이드오프:
원칙:
프로세스 vs 스레드 스위칭:
프로세스 스위칭:
- 메모리 공간 전환 (무거움)
- 페이지 테이블 교체
- TLB 무효화
스레드 스위칭:
- 같은 메모리 공유 (가벼움)
- 레지스터/스택만
- 메모리 공간 유지
스레드 스위칭이 싼 이유:
같은 프로세스 내 스레드:
- 메모리 공간 공유
- 페이지 테이블 그대로
- TLB 유지
- 레지스터/스택만 전환
→ 메모리 공간 전환 X
→ 가벼움
메모리 공유:
스레드 (공유):
- 힙, 메서드 영역 공유
- 스택만 개별
→ 전환 시 공유 부분 유지
→ 가벼운 전환
프로세스 (독립):
- 메모리 완전 독립
- 전환 시 전체 교체
→ 무거운 전환
공유의 트레이드오프:
스레드 (공유):
+ 가벼운 스위칭
+ 통신 빠름 (공유 메모리)
- 동기화 필요 (경쟁)
- 한 스레드 오류 → 전체
프로세스 (독립):
+ 안전 (격리)
- 무거운 스위칭
- 통신 비쌈 (IPC)
@Service
public class ProcessVsThreadSwitching {
// 스레드 — 같은 프로세스 (가벼운 스위칭)
private final ExecutorService pool = Executors.newFixedThreadPool(8);
// 공유 메모리 (스레드 간 통신 빠름)
private final ConcurrentHashMap<Long, Shipment> sharedCache =
new ConcurrentHashMap<>();
public void process(Shipment shipment) {
pool.submit(() -> {
// 같은 힙 메모리 접근 (공유)
sharedCache.put(shipment.getId(), shipment);
// 스레드 간 통신 = 공유 메모리 (빠름)
// 단, 동기화 필요 (ConcurrentHashMap)
});
}
// 스레드 스위칭이 가벼워 멀티스레드 선택
// (멀티 프로세스보다 효율)
}
멀티 프로세스보다 멀티 스레드 스위칭이 싼 이유는?
답:
1. 차이:
이유:
공유:
트레이드오프:
적정 스레드 수:
작업 유형에 따라 다름:
- CPU 바운드
- I/O 바운드
CPU 바운드:
계산 위주 (CPU 사용):
- 적정 = 코어 수 (+1)
- 더 많으면 스위칭만
공식:
N(threads) = N(cores) + 1
I/O 바운드:
대기 위주 (네트워크, 디스크):
- 대기 중 다른 작업 가능
- 코어보다 많이
공식:
N = N(cores) × (1 + 대기시간/계산시간)
측정 기반:
공식은 출발점:
- 부하 테스트
- 모니터링
- 튜닝
실측 > 공식
Tomcat maxThreads:
기본값: 200
- 동시 처리 요청 수
- I/O 바운드 (웹 요청)
- 너무 크면 메모리/스위칭
- 너무 작으면 처리량 ↓
→ 워크로드에 맞게 조정
@Configuration
public class OptimalThreadCount {
private final int cores = Runtime.getRuntime().availableProcessors();
// CPU 바운드 (운임 계산)
@Bean("cpuPool")
public ExecutorService cpuPool() {
return Executors.newFixedThreadPool(cores + 1);
// 코어 + 1
}
// I/O 바운드 (외부 API)
@Bean("ioPool")
public ExecutorService ioPool() {
// 대기 90%, 계산 10% 가정
// N = cores × (1 + 0.9/0.1) = cores × 10
return Executors.newFixedThreadPool(cores * 10);
}
// 측정 후 조정 권장
}
적정 스레드 수의 개념은?
답:
1. 작업 유형:
CPU:
I/O:
측정:
문제 → 스레드 풀:
생성 비용 ↑ → 재사용
무한 생성 위험 → 개수 제한
자원 낭비 → idle 회수
= Executor 프레임워크 (4주차)
재사용:
스레드 풀:
- 미리 생성
- 작업마다 재사용
- 생성 비용 1회
→ 스위칭 함정 + 생성 비용 해결
개수 제한:
최대 수 제한:
- 무한 생성 방지
- 자원 보호
- 적정 동시성
→ 스위칭/데드락 함정 완화
idle 회수:
keepAliveTime:
- 노는 스레드 정리
- core 초과분
- 메모리 절약
→ 노는 스레드 함정 해결
실무 결론:
스레드 직접 생성 X
ExecutorService 사용
풀 크기 신중
- 재사용
- 제한
- 적정 크기
- idle 회수
@Service
public class ThreadPoolAnswer {
// 3가지 함정 해결
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(), // core (적정)
Runtime.getRuntime().availableProcessors() * 2, // max (제한)
60L, TimeUnit.SECONDS, // idle 회수
new LinkedBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// - 재사용 (생성 비용)
// - 제한 (무한 생성, 스위칭)
// - idle 회수 (노는 스레드)
public void process(Shipment shipment) {
executor.submit(() -> doProcess(shipment));
}
@PreDestroy
public void shutdown() {
executor.shutdown();
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
private void doProcess(Shipment s) { }
}
스레드 풀이 답인 이유는?
답:
1. 문제 해결:
3함정 대응:
실무:
결론:
| Q | 핵심 답변 |
|---|---|
| 많을수록 빠른가? | NO (최적점) |
| 함정 3가지? | 스위칭/데드락/노는 스레드 |
| 컨텍스트 스위칭? | 전환 비용 |
| 데드락 관계? | 스레드 ↑ → 위험 ↑ |
| 노는 스레드? | 메모리/스케줄링 낭비 |
| 공짜 아님? | 존재 자체 비용 |
| 프로세스 vs 스레드 스위칭? | 메모리 공유 (가벼움) |
| 적정 수? | CPU 코어+1, I/O 더 많이 |
| 스레드 풀? | 재사용/제한/회수 |
| Tomcat maxThreads? | 기본 200 |
답:
답:
답:
답:
답:
1. 많을수록 빠른가
2. 3가지 함정
3. 결론
이번 Unit에서 스레드의 함정을 봤다면, 다음은 컨텍스트 스위칭의 진짜 비용 (상세).
🚀 Phase 1 — 스레드 풀의 필요성 재정리
✅ Unit 1.1 스레드를 많이 쓸 때의 함정 ← 여기
⏭ Unit 1.2 컨텍스트 스위칭의 진짜 비용
⏭ Unit 1.3 스레드 풀이라는 답
🚀 Part A — 동시성 마무리
Phase 1 — 스레드 풀 필요성 (1/3)
⏭ Part B — 토비의 스프링
총: 1/26 Unit