F-LAB JAVA · 5주차 · Phase 1 · 스레드 풀의 필요성 재정리
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
컨텍스트 스위칭은 CPU 가 한 스레드에서 다른 스레드로 전환하며 (1) 현재 스레드 레지스터를 메모리에 백업, (2) 다음 스레드 상태를 복원, (3) CPU 캐시 무효화, (4) 스택·PC 카운터 전환을 수행하는 작업으로, 스레드가 많을수록 이 비용이 누적되어 CPU 가 실제 작업보다 전환에 더 많은 시간을 쓰게 된다.
스위칭의 직접 비용은 레지스터 저장·복원이지만, 더 큰 숨은 비용은 CPU 캐시 무효화 다 — 이전 스레드가 데우던 캐시가 무효화되고 새 스레드는 캐시 미스를 겪으며 느린 메모리에서 데이터를 다시 가져와야 한다.
스위칭 횟수는 스레드 수에 비례 하므로, 코어 4개에 스레드 4개면 거의 전환 없이 처리하지만 스레드 100개면 끊임없이 전환하며 전환 오버헤드가 처리 시간을 압도한다.
노는 스레드도 스위칭 대상 이며, 스케줄러가 깨워서 확인하고 다시 재우는 비용이 발생하므로, 결국 적정 수의 스레드 풀로 전환 횟수를 통제하는 것이 핵심이다.
컨텍스트 스위칭 = 여러 일 번갈아 하기:
일 하나에 집중 (스레드 적음):
- 책상에 일A 자료만
- 계속 일A
- 효율적
여러 일 번갈아 (스레드 많음):
1. 일A 자료 치우기 (레지스터 백업)
2. 일B 자료 꺼내기 (복원)
3. 일B 맥락 떠올리기 (캐시 다시 채움)
4. 잠깐 일B
5. 또 일C로... (반복)
→ 일하는 시간보다
→ 자료 바꾸는 시간이 더 많음
캐시 무효화 (가장 큰 비용):
- 일A 하다가 머릿속에 일A 맥락 (캐시)
- 일B 로 전환 → 일A 맥락 사라짐
- 다시 일A 오면 → 처음부터 떠올림 (캐시 미스)
→ 컨텍스트 스위칭 = 레지스터 백업/복원 + 캐시 무효화, 스레드 ↑ → 전환 비용 압도.
1. 스위칭 시 일어나는 4단계
2. 레지스터 백업/복원
3. 캐시 무효화 (숨은 비용)
4. 스위칭 횟수 = 스레드 수 비례
5. 스레드 4개 vs 100개
6. 노는 스레드도 스위칭 대상
7. 타임 슬라이스
8. 스위칭 비용 줄이기
9. 면접 + 자기 점검
컨텍스트 스위칭 4단계:
1. 현재 스레드 레지스터 → 메모리 백업
2. 다음 스레드 이전 상태 → 메모리에서 복원
3. CPU 캐시 무효화 (캐시 미스)
4. 스택, PC 카운터 등 컨텍스트 전환
컨텍스트 (Context):
스레드 실행에 필요한 상태:
- 레지스터 값
- 프로그램 카운터 (PC)
- 스택 포인터
- 스레드 상태
→ 스위칭 시 저장/복원
스위칭 흐름:
스레드 A 실행 중
↓ (스케줄러 결정)
A 컨텍스트 저장 (메모리)
↓
B 컨텍스트 복원 (메모리)
↓
캐시 무효화/재채움
↓
스레드 B 실행
커널 개입:
스위칭은 OS 커널이 수행:
- 사용자 모드 → 커널 모드
- 스케줄러 실행
- 컨텍스트 교체
- 커널 모드 → 사용자 모드
→ 모드 전환 비용도 포함
@Service
public class ContextSwitchingSteps {
// 스위칭 최소화 = 적정 스레드
private final int cores = Runtime.getRuntime().availableProcessors();
private final ExecutorService pool = Executors.newFixedThreadPool(cores);
public void process(List<Shipment> shipments) {
// 코어 수 스레드 → 스위칭 최소
for (Shipment s : shipments) {
pool.submit(() -> {
// 각 스레드가 충분히 작업
// 잦은 스위칭 X
doProcess(s);
});
}
}
private void doProcess(Shipment s) { }
}
스위칭 시 일어나는 일 4단계는?
답:
1. 4단계:
컨텍스트:
흐름:
커널:
레지스터:
CPU 내부 초고속 저장소:
- 현재 계산 값
- 변수, 주소
- 가장 빠름
스레드별 다름:
- 전환 시 저장/복원
백업 (저장):
현재 스레드 전환 시:
- 레지스터 값 → 메모리 (TCB)
- 나중에 복원 위해
TCB (Thread Control Block):
- 스레드 상태 저장 공간
복원:
다음 스레드 실행 전:
- TCB 에서 레지스터 복원
- 이전 중단 지점부터
→ 중단했던 곳 이어서
레지스터 백업/복원 비용:
- 메모리 접근 (느림)
- 여러 레지스터
- 매 스위칭마다
직접 비용:
- 수십~수백 나노초
레지스터 백업/복원:
스레드 A 레지스터:
[값1][값2][값3] → 메모리 (백업)
스레드 B:
메모리 → [값a][값b][값c] (복원)
전환:
A 백업 + B 복원
= 레지스터 교체
// 레지스터 백업/복원은 OS/CPU 수준
// 개발자는 스레드 수로 빈도 제어
@Service
public class RegisterSwitchContext {
// 적정 스레드 → 레지스터 전환 최소
private final ExecutorService pool =
Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
// 긴 작업 → 전환 빈도 ↓ (효율)
public void longTask(Shipment shipment) {
pool.submit(() -> {
// 충분히 긴 작업
// → 작업 중 전환 적음
// → 레지스터 백업/복원 ↓
heavyProcess(shipment);
});
}
private void heavyProcess(Shipment s) { }
}
레지스터 백업/복원의 의미는?
답:
1. 레지스터:
백업:
복원:
비용:
캐시 무효화 (숨은 비용):
스위칭의 가장 큰 비용:
- 이전 스레드 캐시 무효
- 새 스레드 캐시 미스
- 느린 메모리 재접근
캐시 데우기 (warm-up):
스레드 실행 중:
- 자주 쓰는 데이터 캐시에
- 캐시 적중 (빠름)
전환 후:
- 새 스레드 다른 데이터
- 캐시 미스 (느림)
- 다시 데우기
캐시 미스 비용:
L1 적중: ~1ns
L2 적중: ~4ns
L3 적중: ~10ns
메모리: ~100ns
미스 시 메모리 접근 (~100ns)
= L1 의 100배
→ 잦은 전환 = 잦은 미스 = 느림
왜 숨은 비용:
레지스터 백업/복원:
- 직접 측정 가능
- 명시적
캐시 무효화:
- 측정 어려움
- 간접적 (이후 미스)
- 더 큰 영향
→ 숨은 비용 (더 중요)
캐시 영향:
스레드 적음 (캐시 유지):
A: [캐시 데움][적중][적중][적중]
→ 빠름
스레드 많음 (캐시 파괴):
A:[데움] B:[데움] A:[다시 데움]...
→ 매번 미스
→ 느림
@Service
public class CacheInvalidationContext {
// 캐시 지역성 고려 — 적정 스레드
private final ExecutorService pool =
Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
// 같은 데이터 묶어 처리 (캐시 지역성)
public void processBatch(List<Shipment> shipments) {
// 배치로 묶어 한 스레드가 연속 처리
// → 캐시 적중 ↑
int batchSize = shipments.size() /
Runtime.getRuntime().availableProcessors();
for (int i = 0; i < shipments.size(); i += batchSize) {
int end = Math.min(i + batchSize, shipments.size());
List<Shipment> batch = shipments.subList(i, end);
pool.submit(() -> {
// 한 스레드가 배치 연속 처리
// 캐시 무효화 최소
batch.forEach(this::process);
});
}
}
private void process(Shipment s) { }
}
캐시 무효화의 영향은?
답:
1. 캐시 무효화:
캐시 미스:
숨은 비용:
영향:
스위칭 횟수 ∝ 스레드 수:
스레드 ↑ → 스위칭 ↑
코어 고정, 스레드 증가:
- 더 자주 번갈아
- 스위칭 횟수 증가
시분할 (Time Slicing):
각 스레드에 시간 할당 (타임 슬라이스):
- 슬라이스 끝나면 전환
- 다음 스레드
스레드 많으면:
- 슬라이스 자주 소진
- 자주 전환
스위칭 횟수 계산:
타임 슬라이스 10ms, 1초:
- 스레드 4개: 각 250ms → 전환 적음
- 스레드 100개: 각 10ms → 전환 100배
→ 스레드 ↑ → 전환 ↑
임계점:
스레드 수 < 코어 수:
- 전환 거의 없음 (각자 코어)
스레드 수 > 코어 수:
- 전환 시작
- 많을수록 ↑
스레드 수 >> 코어 수:
- 전환이 작업 압도
@Service
public class SwitchingProportional {
private final int cores = Runtime.getRuntime().availableProcessors();
// 코어 수 = 전환 최소
@Bean("optimal")
public ExecutorService optimal() {
return Executors.newFixedThreadPool(cores);
// 스레드 = 코어 → 거의 전환 X
}
// 과도 = 전환 폭증
@Bean("excessive")
public ExecutorService excessive() {
return Executors.newFixedThreadPool(cores * 50);
// 스레드 = 코어 × 50 → 전환 폭증
// CPU 작업 비효율
}
}
스위칭 횟수 = 스레드 수 비례의 의미는?
답:
1. 비례:
시분할:
임계점:
계산:
코어 4개 시스템:
스레드 4개 (CPU 바운드):
- 각 코어 1개
- 전환 거의 X
- 100% 작업
스레드 100개 (CPU 바운드):
- 25배 초과
- 끊임없이 전환
- 작업 < 전환
처리 시간 (CPU 바운드):
스레드 4개:
- 전환 오버헤드 ~0
- 거의 순수 작업
스레드 100개:
- 전환 오버헤드 막대
- 처리 시간 ↑ (느림)
→ 4개가 더 빠름 (CPU 바운드)
I/O 바운드:
스레드 4개:
- I/O 대기 시 코어 놀음
- 처리량 ↓
스레드 100개:
- 대기 중 다른 작업
- 처리량 ↑ (어느 정도)
→ I/O 는 많은 스레드 유리 (한계까지)
실험 (CPU 바운드):
스레드 수 처리 시간
4 100ms (기준)
8 105ms
16 120ms
100 300ms (느림!)
1000 더 느림
→ 코어 수 근처가 최적
→ 초과 시 전환으로 느려짐
@Service
public class FourVsHundred {
private final int cores = Runtime.getRuntime().availableProcessors();
// CPU 바운드 — 코어 수 (4 vs 100 중 4 선택)
public BigDecimal calculateAllFreight(List<Shipment> shipments)
throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(cores);
// 코어 수 (예: 4)
// 100개 만들면 전환으로 느림
List<Future<BigDecimal>> futures = shipments.stream()
.map(s -> pool.submit(() -> freightCalculator.calculate(s)))
.toList();
BigDecimal total = BigDecimal.ZERO;
for (Future<BigDecimal> f : futures) {
total = total.add(f.get());
}
pool.shutdown();
return total;
}
private BigDecimal calculateFreight(Shipment s) { return s.getWeight(); }
}
코어 4개에서 스레드 4개 vs 100개 차이는?
답:
1. CPU 바운드:
처리 시간:
I/O 바운드:
최적:
노는 스레드도 스위칭 대상:
대기 (WAITING/BLOCKED) 스레드:
- 스케줄러가 고려
- 깨울지 확인
- 큐에 존재
→ 노는데도 관리 비용
스케줄러 부담:
모든 스레드:
- 스케줄러 큐에
- 상태 확인
- 깨우기 판단
스레드 많으면:
- 큐 길어짐
- 스케줄링 오버헤드 ↑
깨우기 비용:
대기 스레드 깨울 때:
- WAITING → RUNNABLE
- 컨텍스트 복원
- 캐시 다시 (오래 잤으면 차가움)
→ 깨우기도 스위칭 비용
결론:
노는 스레드:
- 메모리 (스택)
- 스케줄링 (관리)
- 깨우기 (스위칭)
→ 노는데도 비용
→ 불필요한 스레드 제거 (풀 회수)
@Service
public class IdleThreadSwitching {
// 노는 스레드 회수 (keepAlive)
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // core
20, // max (피크)
30L, TimeUnit.SECONDS, // 노는 스레드 30초 후 회수
new LinkedBlockingQueue<>(100)
);
// 피크 후 노는 스레드 정리
// → 스케줄링 부담 ↓
// → 메모리 ↓
public void process(Shipment shipment) {
executor.submit(() -> doProcess(shipment));
}
// allowCoreThreadTimeOut 으로 core 도 회수 가능
@PostConstruct
public void config() {
executor.allowCoreThreadTimeOut(true);
// 완전 유휴 시 0까지 (노는 스레드 제거)
}
private void doProcess(Shipment s) { }
}
노는 스레드도 스위칭 대상인가?
답:
1. 대상:
스케줄러 부담:
깨우기:
결론:
타임 슬라이스 (Time Slice / Quantum):
각 스레드에 할당된 실행 시간.
- 슬라이스 끝나면 전환
- 공정한 CPU 분배
선점형 스케줄링:
OS 가 강제 전환:
- 슬라이스 소진 시
- 우선순위 높은 작업
- 공정성
→ 자바 스레드 (선점형)
슬라이스 크기 트레이드오프:
작으면:
- 응답성 ↑ (자주 전환)
- 스위칭 오버헤드 ↑
크면:
- 스위칭 ↓
- 응답성 ↓ (오래 기다림)
→ OS 가 균형
스레드 수 영향:
스레드 많으면:
- 각 스레드 슬라이스 자주 소진
- 자주 전환
- 한 작업 완료 느림
스레드 적으면:
- 슬라이스 여유
- 전환 적음
// 타임 슬라이스는 OS 수준
// 개발자는 스레드 수로 영향
@Service
public class TimeSliceContext {
// 적정 스레드 → 슬라이스 효율
private final ExecutorService pool =
Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
// 작업을 적절히 나눔 (너무 작게 X)
public void process(List<Shipment> shipments) {
// 너무 많은 작은 작업 → 전환 ↑
// 적정 크기 배치 → 슬라이스 활용
int batchSize = Math.max(100,
shipments.size() / Runtime.getRuntime().availableProcessors());
for (int i = 0; i < shipments.size(); i += batchSize) {
int end = Math.min(i + batchSize, shipments.size());
List<Shipment> batch = shipments.subList(i, end);
pool.submit(() -> batch.forEach(this::process));
}
}
private void process(Shipment s) { }
}
타임 슬라이스와 스위칭은?
답:
1. 타임 슬라이스:
선점형:
크기:
스레드 수:
스위칭 비용 줄이기:
1. 적정 스레드 수
- 코어 수 근처 (CPU)
2. 스레드 풀
- 재사용, 제한
3. 작업 적정 크기
- 너무 작게 X
4. 캐시 지역성
- 관련 작업 묶기
적정 스레드 수:
CPU 바운드: 코어 + 1
I/O 바운드: 더 많이
→ 전환 최소화
작업 크기:
너무 작은 작업:
- 전환 비용 > 작업 비용
- 비효율
적정 크기:
- 전환 비용 < 작업 비용
- 효율
캐시 지역성:
관련 데이터 묶어 처리:
- 같은 스레드 연속
- 캐시 적중 ↑
- 무효화 ↓
@Service
public class ReduceSwitchingCost {
private final int cores = Runtime.getRuntime().availableProcessors();
// 1. 적정 스레드 (코어 수)
private final ExecutorService pool = Executors.newFixedThreadPool(cores);
// 2. 적정 배치 크기 (작업 묶기)
public void processOptimized(List<Shipment> shipments) {
int batchSize = Math.max(100, shipments.size() / cores);
for (int i = 0; i < shipments.size(); i += batchSize) {
int end = Math.min(i + batchSize, shipments.size());
List<Shipment> batch = shipments.subList(i, end);
// 3. 한 스레드가 배치 연속 처리 (캐시 지역성)
pool.submit(() -> {
batch.forEach(this::process);
// 같은 데이터 영역 연속 → 캐시 적중
});
}
// 전환 최소 + 캐시 효율
}
private void process(Shipment s) { }
}
스위칭 비용을 줄이는 방법은?
답:
1. 적정 스레드:
스레드 풀:
작업 크기:
캐시 지역성:
| Q | 핵심 답변 |
|---|---|
| 스위칭 4단계? | 백업/복원/캐시/전환 |
| 레지스터? | CPU 저장, 백업/복원 |
| 캐시 무효화? | 숨은 비용 (미스) |
| 스위칭 비례? | 스레드 수 |
| 4개 vs 100개? | CPU 는 4개 빠름 |
| 노는 스레드? | 스위칭 대상 |
| 타임 슬라이스? | 할당 시간 |
| 선점형? | OS 강제 |
| 비용 줄이기? | 적정 수, 캐시 지역성 |
| 캐시 미스 비용? | 메모리 ~100배 |
답:
답:
답:
답:
답:
1. 스위칭 4단계
2. 스레드 수 비례
3. 비용 줄이기
이번 Unit에서 스위칭 비용을 봤다면, 다음은 스레드 풀이라는 답 (Phase 1 마지막).
🚀 Phase 1 — 스레드 풀의 필요성 재정리
✅ Unit 1.1 스레드를 많이 쓸 때의 함정
✅ Unit 1.2 컨텍스트 스위칭의 진짜 비용 ← 여기
⏭ Unit 1.3 스레드 풀이라는 답
🚀 Part A — 동시성 마무리
Phase 1 — 스레드 풀 필요성 (2/3)
총: 2/26 Unit