F-LAB JAVA · 4주차 · Phase 3 · 스레드 만들고 다루기
🏆 Phase 3 완주 — 스레드 다루기 마스터
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
join()은 대상 스레드가 종료될 때까지 현재 (호출한) 스레드를 대기시키는 메서드로, 여러 스레드의 작업 완료를 기다리는 가장 단순한 방법이다.
t.join()을 호출하면 현재 스레드는 WAITING 상태가 되어 t 가 종료될 때까지 대기하고, t 종료 시 RUNNABLE 로 복귀한다.
병렬 실행 은 모든 스레드를 먼저start()한 뒤 각각join()하는 것 (전체 시간 ≈ 가장 긴 작업), 직렬 실행 은 start → join 을 하나씩 반복하는 잘못된 패턴 (전체 시간 = 모든 작업의 합).
join(ms)는 최대 지정 시간만 대기하고, 시간이 지나면 대상이 안 끝났어도 복귀한다 (TIMED_WAITING).
내부적으로join()은wait()기반으로 구현되며, 결과 값을 반환하지 않으므로 (run 은 void) 결과 회수가 필요하면 Future / CompletableFuture 를 사용한다.
join() = 친구가 일 끝날 때까지 기다림:
병렬 (올바름):
- 친구 3명에게 각자 일 시킴 (start × 3)
- 모두 동시에 일 시작
- "다 끝났어?" 한 명씩 확인 (join × 3)
- 가장 오래 걸리는 친구 시간만큼
직렬 (잘못됨):
- 친구1에게 일 시키고 (start)
- 끝날 때까지 기다림 (join)
- 그 다음 친구2 (start → join)
- 한 명씩 순차 → 모든 시간 합
병렬: 3명 각 1초 → 약 1초
직렬: 3명 각 1초 → 3초
→ join = 종료 대기, 병렬은 start 먼저 + join 나중.
1. join()의 정의와 동작
2. join() 시 호출자 상태
3. 병렬 실행 패턴
4. 직렬 실행 (잘못된 패턴)
5. join(ms) 타임아웃
6. join()의 내부 구현
7. join 외 결과 회수 (Future)
8. Phase 3 완주 정리
9. 면접 + 자기 점검 + Phase 3 졸업 시험
join():
대상 스레드가 종료될 때까지
현재 (호출한) 스레드를 대기시킴.
용도:
- 스레드 완료 대기
- 결과 회수의 단순 방식
- 작업 순서 보장
Thread worker = new Thread(() -> {
doWork(); // 작업
});
worker.start(); // 시작
worker.join(); // worker 종료까지 대기 (현재 스레드)
System.out.println("Worker done"); // worker 완료 후
// 무한 대기
void join() throws InterruptedException;
// 최대 ms 대기
void join(long millis) throws InterruptedException;
// 최대 ms + ns 대기
void join(long millis, int nanos) throws InterruptedException;
// 모두 InterruptedException 던짐
join() 흐름:
현재 스레드 (main):
worker.start() → worker 시작
worker.join() → main 대기 (WAITING)
...
(worker 종료)
→ main RUNNABLE 복귀
다음 코드
join() 동작:
main: [start][join 대기────][계속]
↑
worker: ┌─[작업────]─┘
worker 종료 시 main 깨어남
main 은 worker 종료까지 대기
public class ShipmentJoinExample {
public void processAndWait(Shipment shipment) throws InterruptedException {
Thread worker = new Thread(() -> {
service.process(shipment);
});
worker.start(); // 처리 시작
// 다른 일 (선택)
doOtherWork();
worker.join(); // 처리 완료까지 대기
log.info("Processing complete for {}", shipment.getId());
// worker 완료 후 실행
}
private void doOtherWork() { }
}
join()의 정의와 동작은?
답:
1. 정의:
사용:
종류:
흐름:
join() 호출자 상태:
t.join() 호출 시
현재 (호출자) 스레드 → WAITING
- 대상 t 종료까지 무한 대기
- join(ms) 면 TIMED_WAITING
Thread worker = new Thread(() -> {
sleep(2000); // 2초 작업
});
worker.start();
// 다른 스레드에서 main 상태 확인
Thread checker = new Thread(() -> {
sleep(500);
System.out.println("Main state: " + mainThread.getState());
// WAITING (join 대기 중)
});
checker.start();
worker.join(); // main → WAITING (worker 종료까지)
join() 의 상태 전이:
호출자 (main):
RUNNABLE
↓ worker.join()
WAITING (worker 종료 대기)
↓ worker 종료
RUNNABLE (복귀)
대상 (worker):
RUNNABLE → ... → TERMINATED
↑ 여기서 main 깨어남
Thread worker = new Thread(() -> sleep(5000));
worker.start();
worker.join(1000); // 최대 1초 대기
// main → TIMED_WAITING (1초 또는 worker 종료)
// 1초 후:
// - worker 아직 실행 중 (5초)
// - main 복귀 (시간 초과)
if (worker.isAlive()) {
System.out.println("Worker still running");
}
join 상태:
join() (무한):
main: RUNNABLE → WAITING → RUNNABLE
(worker 종료까지)
join(1000):
main: RUNNABLE → TIMED_WAITING → RUNNABLE
(1초 또는 worker 종료)
public class JoinStateExample {
public void demonstrateState() throws InterruptedException {
Thread worker = new Thread(() -> {
try { Thread.sleep(2000); } catch (Exception e) {}
});
worker.start();
// 모니터링 스레드
Thread monitor = new Thread(() -> {
try {
Thread.sleep(500);
// main 은 join 으로 WAITING
log.info("Main is waiting on worker");
} catch (Exception e) {}
});
monitor.setDaemon(true);
monitor.start();
worker.join(); // main → WAITING
log.info("Worker completed, main resumed");
}
}
join() 시 호출자 상태는?
답:
1. WAITING:
TIMED_WAITING:
전이:
복귀:
병렬 실행 패턴:
1. 모든 스레드 start() (먼저)
2. 각각 join() (나중)
효과:
- 모든 스레드 동시 실행
- 전체 시간 ≈ 가장 긴 작업
// 병렬 실행 (올바름)
public void parallel() throws InterruptedException {
Thread t1 = new Thread(() -> work(3000)); // 3초
Thread t2 = new Thread(() -> work(2000)); // 2초
Thread t3 = new Thread(() -> work(2500)); // 2.5초
// 모두 먼저 시작
t1.start();
t2.start();
t3.start();
// 각각 대기
t1.join();
t2.join();
t3.join();
// 전체 시간 ≈ 3초 (가장 긴 t1)
// (동시 실행)
}
병렬 실행:
t1: [████████] 3초
t2: [█████] 2초 (동시)
t3: [██████] 2.5초
↑ 모두 동시 시작
↑ t1 종료 (가장 늦음)
전체: 약 3초 (가장 긴 작업)
// join 순서는 중요하지 않음 (병렬이면)
t1.start();
t2.start();
t3.start();
// 어떤 순서로 join 해도 OK
t3.join(); // t3 먼저 join
t1.join();
t2.join();
// 모두 동시 실행 중이므로
// join 순서는 전체 시간에 영향 X
// (가장 긴 작업이 끝나야 모두 끝남)
// 여러 스레드 병렬 (리스트)
public void parallelMany(List<Shipment> shipments) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
// 모두 시작
for (Shipment s : shipments) {
Thread t = new Thread(() -> process(s));
t.start();
threads.add(t);
}
// 모두 대기
for (Thread t : threads) {
t.join();
}
// 모든 처리 완료
// 전체 시간 ≈ 가장 긴 처리
}
public class ShipmentParallelProcessing {
// 병렬 처리 (올바름)
public List<Result> processParallel(List<Shipment> shipments)
throws InterruptedException {
List<Thread> threads = new ArrayList<>();
List<Result> results = Collections.synchronizedList(new ArrayList<>());
// 모두 시작 (병렬)
for (Shipment s : shipments) {
Thread t = new Thread(() -> {
Result r = service.process(s);
results.add(r);
});
t.start();
threads.add(t);
}
// 모두 완료 대기
for (Thread t : threads) {
t.join();
}
return results;
// 전체 시간 ≈ 가장 긴 처리
}
// 실무: ExecutorService.invokeAll 권장 (Phase 7)
}
병렬 실행 패턴은?
답:
1. 패턴:
효과:
join 순서:
여러 스레드:
직렬 실행 (잘못된 패턴):
start → join 을 하나씩 반복.
문제:
- 한 스레드 끝나야 다음 시작
- 사실상 순차 실행
- 멀티스레드 의미 없음
- 전체 시간 = 모든 작업의 합
// ❌ 직렬 실행 (잘못됨)
public void serial() throws InterruptedException {
Thread t1 = new Thread(() -> work(3000));
Thread t2 = new Thread(() -> work(2000));
t1.start();
t1.join(); // ★ t1 끝날 때까지 대기 (여기서 블록)
t2.start(); // t1 끝난 후에야 시작
t2.join();
// 전체 시간 = 3초 + 2초 = 5초 (순차!)
// 멀티스레드 의미 없음
}
직렬 실행 (잘못됨):
t1: [████████] 3초
↑ t1 종료 후
t2: [█████] 2초
전체: 3초 + 2초 = 5초 (순차)
vs 병렬:
t1: [████████] 3초
t2: [█████] 2초 (동시)
전체: 3초
직렬 패턴의 문제:
start → join → start → join
- 첫 join 에서 블록
- 다음 start 가 늦어짐
- 동시 실행 X
- 순차와 동일
올바름:
start → start → join → join
- 모두 시작 후 대기
// ❌ 직렬 (5초)
t1.start(); t1.join();
t2.start(); t2.join();
// ✓ 병렬 (3초)
t1.start(); t2.start();
t1.join(); t2.join();
// 차이:
// - 직렬: 시작과 대기 교차
// - 병렬: 시작 먼저, 대기 나중
public class SerialVsParallel {
// ❌ 직렬 (잘못됨)
public void processSerial(List<Shipment> shipments)
throws InterruptedException {
for (Shipment s : shipments) {
Thread t = new Thread(() -> process(s));
t.start();
t.join(); // ★ 매번 대기 → 순차
}
// 전체 = 모든 처리 합
// 멀티스레드 의미 없음
}
// ✓ 병렬 (올바름)
public void processParallel(List<Shipment> shipments)
throws InterruptedException {
List<Thread> threads = shipments.stream()
.map(s -> new Thread(() -> process(s)))
.toList();
threads.forEach(Thread::start); // 모두 시작
for (Thread t : threads) {
t.join(); // 나중에 대기
}
// 전체 ≈ 가장 긴 처리
}
}
직렬 실행 (잘못된 패턴)은?
답:
1. 직렬:
문제:
원인:
올바름:
join(long millis):
최대 millis 밀리초 대기.
시간 초과 시 대상이 안 끝났어도 복귀.
동작:
- 대상 종료 → 즉시 복귀
- 시간 초과 → 복귀 (대상 계속)
Thread worker = new Thread(() -> work(5000)); // 5초
worker.start();
worker.join(1000); // 최대 1초 대기
// 1초 후:
if (worker.isAlive()) {
System.out.println("Worker still running after 1s");
// worker 는 계속 실행 (5초)
// main 은 복귀
}
public boolean processWithTimeout(Shipment shipment, long timeoutMs)
throws InterruptedException {
Thread worker = new Thread(() -> service.process(shipment));
worker.start();
worker.join(timeoutMs); // 최대 대기
if (worker.isAlive()) {
// 타임아웃
worker.interrupt(); // 인터럽트 시도
log.warn("Processing timeout for {}", shipment.getId());
return false;
}
return true; // 완료
}
// join(0) = join() (무한 대기)
worker.join(0); // 무한 (0은 특수)
worker.join(); // 동일
// 주의: join(0) 은 "0초 대기" 가 아니라 "무한"
join(1000):
worker: [████████████] 5초
main: [join 대기──]
↑ 1초 후 복귀 (worker 계속)
worker.isAlive() == true
vs join() (무한):
worker: [████████████] 5초
main: [join 대기────────────]
↑ 5초 후 복귀
public class JoinTimeoutExample {
// 타임아웃 처리
public ProcessResult processWithLimit(Shipment shipment)
throws InterruptedException {
AtomicReference<Result> result = new AtomicReference<>();
Thread worker = new Thread(() -> {
result.set(service.process(shipment));
});
worker.start();
worker.join(30000); // 최대 30초
if (worker.isAlive()) {
// 30초 초과
worker.interrupt();
return ProcessResult.timeout(shipment.getId());
}
return ProcessResult.success(result.get());
}
// 실무: Future.get(timeout) 권장 (Phase 7)
}
join(ms)의 동작은?
답:
1. 동작:
복귀:
상태:
활용:
join(0):
join() 의 내부:
wait() 기반으로 구현.
- 대상 스레드 종료 시 notify
- 호출자 대기 (wait)
// Thread.join() 의 개념적 구현 (단순화)
public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis == 0) {
while (isAlive()) {
wait(0); // 무한 대기 (notify 까지)
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) break;
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
스레드 종료 시:
스레드가 run() 종료
↓
JVM 이 내부적으로 notifyAll()
↓
join() 중인 스레드들 깨어남
↓
isAlive() == false 확인
↓
join 복귀
join() 은 synchronized:
- Thread 객체의 모니터 락
- wait() 호출 (락 반납)
- 종료 시 notifyAll (JVM)
따라서:
- join 은 Thread 객체에 동기화
- wait/notify 메커니즘
// while 로 isAlive 재확인 (spurious wakeup 대비)
while (isAlive()) {
wait(0);
// 깨어나도 isAlive 재확인
// 진짜 종료인지 확인
}
// if 가 아니라 while 사용
// (가짜 깨어남 대비)
// join 의 내부 이해 (직접 구현 X, 개념)
public class JoinInternals {
// join 은 wait/notify 기반
// 직접 비슷하게 구현하면:
private final Object lock = new Object();
private volatile boolean done = false;
public void waitForCompletion() throws InterruptedException {
synchronized (lock) {
while (!done) { // while (spurious wakeup 대비)
lock.wait();
}
}
}
public void markDone() {
synchronized (lock) {
done = true;
lock.notifyAll(); // 대기자 깨움
}
}
// join 도 이와 유사 (Thread 객체에 동기화)
}
join()의 내부 구현은?
답:
1. wait 기반:
종료 시:
synchronized:
spurious wakeup:
join() 의 한계:
- 결과 값 반환 X (run 은 void)
- 완료 대기만
- 예외 전파 어려움
결과 회수하려면:
- 공유 변수 (위험)
- Future / Callable (권장)
// 공유 변수로 결과 (번거롭고 위험)
public Result processWithSharedVar(Shipment shipment)
throws InterruptedException {
AtomicReference<Result> result = new AtomicReference<>();
Thread worker = new Thread(() -> {
Result r = service.process(shipment);
result.set(r); // 공유 변수에
});
worker.start();
worker.join(); // 완료 대기
return result.get(); // 회수
// 번거롭고 예외 처리 어려움
}
// Future — 결과 회수 (권장)
public Result processWithFuture(Shipment shipment) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Result> future = executor.submit(() -> {
return service.process(shipment); // Callable (반환값)
});
Result result = future.get(); // 완료 대기 + 결과
// join + 결과 회수를 한 번에
executor.shutdown();
return result;
}
| 항목 | join() | Future.get() |
|---|---|---|
| 완료 대기 | O | O |
| 결과 반환 | X | O |
| 예외 전파 | 어려움 | ExecutionException |
| 타임아웃 | join(ms) | get(timeout) |
| 권장 | 단순 대기 | 결과 필요 |
// CompletableFuture — 비동기 + 결과 (Phase 8)
public CompletableFuture<Result> processAsync(Shipment shipment) {
return CompletableFuture.supplyAsync(() ->
service.process(shipment));
// join 불필요, 콜백으로
}
// 여러 작업
public CompletableFuture<List<Result>> processManyAsync(List<Shipment> shipments) {
List<CompletableFuture<Result>> futures = shipments.stream()
.map(s -> CompletableFuture.supplyAsync(() -> service.process(s)))
.toList();
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream().map(CompletableFuture::join).toList());
// join 대신 콜백 체이닝
}
public class ResultRetrievalExample {
private final ExecutorService executor = Executors.newFixedThreadPool(4);
// ❌ join + 공유 변수 (번거로움)
public Result joinApproach(Shipment shipment) throws InterruptedException {
AtomicReference<Result> ref = new AtomicReference<>();
Thread t = new Thread(() -> ref.set(service.process(shipment)));
t.start();
t.join();
return ref.get();
}
// ✓ Future (권장)
public Result futureApproach(Shipment shipment) throws Exception {
Future<Result> future = executor.submit(() -> service.process(shipment));
return future.get(); // 대기 + 결과
}
// ✓✓ CompletableFuture (현대)
public CompletableFuture<Result> completableApproach(Shipment shipment) {
return CompletableFuture.supplyAsync(
() -> service.process(shipment), executor);
}
}
join 외 결과 회수 방법은?
답:
1. join 한계:
공유 변수:
Future (권장):
CompletableFuture (현대):
Phase 3 — 스레드 만들고 다루기
Unit 3.1 — 스레드 상태 다이어그램
- 6가지 상태
- NEW → RUNNABLE → TERMINATED
- BLOCKED/WAITING/TIMED_WAITING
Unit 3.2 — Thread 클래스 상속
- extends Thread
- start() vs run()
- start 두 번 (예외)
Unit 3.3 — Runnable 인터페이스
- 함수형 인터페이스
- 장점 3가지
- @Async 내부
Unit 3.4 — 데몬 스레드
- 보조 스레드
- JVM 종료 조건
- 완료 보장 위험
Unit 3.5 — join()
- 종료 대기
- 병렬 vs 직렬
- Future 대안
스레드 다루기:
1. 상태 (생애 주기)
- 6가지 상태
- 전이
2. 생성
- Thread 상속
- Runnable (권장)
3. 종류
- 일반 vs 데몬
4. 협력
- join (완료 대기)
핵심:
- 스레드 직접 다루기 기초
- Phase 7 에서 Executor 로 발전
Phase 3 → Phase 4:
- 스레드 생성 → 동기화
Phase 4 — synchronized & volatile (★ 1차 정점):
- 임계 영역
- synchronized
- 모니터 락 (★ 마스터)
- volatile (★ 마스터)
Phase 3 핵심 통찰 5가지:
1. 6가지 상태
- NEW → RUNNABLE → TERMINATED
2. start() vs run()
- 새 스레드 vs 메서드 호출
3. Runnable 권장
- 분리, 공유, 상속
4. 데몬은 보조
- 완료 보장 X
5. join 으로 대기
- 병렬은 start 먼저
✅ Phase 1 — 동시성의 기초 (4 Unit)
✅ Phase 2 — 4분면 매트릭스 (3 Unit)
✅ Phase 3 — 스레드 다루기 (5 Unit) ← 완주
⏭ Phase 4 — synchronized & volatile (5 Unit) ★ 1차 정점
⏭ Phase 5 — Lock 도구 (4 Unit)
⏭ Phase 6 — 스레드 협력 (4 Unit)
⏭ Phase 7 — Executor (7 Unit) ★ 2차 정점
⏭ Phase 8 — 고급 비동기 (3 Unit)
총: 12/35 Unit (Phase 3 완주, 약 34%)
Phase 3 의 종합은?
답:
1. 5개 Unit:
큰 그림:
핵심:
| Q | 핵심 답변 |
|---|---|
| join()? | 대상 종료까지 대기 |
| join 호출자 상태? | WAITING |
| 병렬 패턴? | start 먼저, join 나중 |
| 직렬 (잘못)? | start→join 반복 |
| join(ms)? | 최대 시간 대기 |
| join 내부? | wait 기반 |
| join 결과? | 반환 X (Future 대안) |
| 병렬 시간? | 가장 긴 작업 |
| 직렬 시간? | 모든 작업 합 |
| join(0)? | 무한 (= join()) |
Q1. 6가지 상태? → NEW/RUNNABLE/BLOCKED/WAITING/TIMED_WAITING/TERMINATED
Q2. NEW → RUNNABLE? → start()
Q3. BLOCKED? → synchronized 락 대기
Q4. WAITING? → wait/join (무한)
Q5. TIMED_WAITING? → sleep/wait(ms)
Q6. BLOCKED vs WAITING? → 락 반납 vs notify/종료
Q7. RUNNABLE 두 의미? → 실행 중 + 가능
Q8. getState RUNNING? → 없음 (RUNNABLE)
Q9. I/O 대기 상태? → RUNNABLE
Q10. TERMINATED 재시작? → 불가
Q11. sleep vs wait? → 락 유지 vs 반납
Q12. 인터럽트 + WAITING? → InterruptedException
Q13. Thread 상속? → extends + run 오버라이드
Q14. start()? → 새 스레드 + run
Q15. run() 직접? → 현재 스레드
Q16. 왜 새 스레드 없나? → 그냥 메서드
Q17. start 두 번? → IllegalThreadStateException
Q18. start 비동기? → 즉시 반환
Q19. run 예외? → checked 못 던짐
Q20. UncaughtExceptionHandler? → 미처리 예외
Q21. currentThread? → 현재 스레드
Q22. Thread 한계? → 단일 상속
Q23. setDaemon 시점? → start 전
Q24. 우선순위? → 힌트
Q25. Runnable? → run() 하나 (SAM)
Q26. 장점 1? → 다른 클래스 상속
Q27. 장점 2? → 작업/스레드 분리
Q28. 장점 3? → 메모리 효율 (공유)
Q29. 람다 가능? → 함수형 인터페이스
Q30. 작업 공유? → 하나 여러 스레드
Q31. @Async 내부? → Runnable/Callable
Q32. Runnable vs Callable? → 반환/예외
Q33. Callable 메서드? → call() throws
Q34. Thread vs Runnable? → Runnable 권장
Q35. 공유 주의? → 상태 (무상태)
Q36. Thread도 Runnable? → 구현함
Q37. 메서드 참조? → this::method
Q38. 데몬? → 보조, 자동 종료
Q39. JVM 종료? → 모든 일반 스레드 종료
Q40. setDaemon? → start 전
Q41. 데몬 용도? → GC, 모니터링
Q42. 메인 종료 = 데몬? → 아니다
Q43. 파일 저장 데몬? → 위험
Q44. 데몬 부적합? → 완료 보장 작업
Q45. join()? → 종료 대기
Q46. join 상태? → WAITING
Q47. 병렬? → start 먼저, join 나중
Q48. 직렬 (잘못)? → start→join 반복
Q49. join(ms)? → 최대 시간
Q50. join 결과? → Future 대안
50 / 50 → Phase 3 마스터
45-49 → 거의 마스터
40-44 → 복습
< 40 → Unit 3.1 ~ 3.5 재학습
답:
답:
답:
답:
답:
1. join()
2. 병렬 vs 직렬
3. join(ms)와 대안
🚀 Phase 3 — 스레드 만들고 다루기
✅ Unit 3.1 스레드 상태 다이어그램
✅ Unit 3.2 Thread 클래스 상속
✅ Unit 3.3 Runnable 인터페이스
✅ Unit 3.4 데몬 스레드
✅ Unit 3.5 join() ← 여기, Phase 3 완주
→ 상태 + 생성 + 종류 + 협력
→ 스레드 직접 다루기 기초 완성
Phase 4 — synchronized & volatile (5 Unit)
Unit 4.1 — 임계 영역과 동기화의 필요성
Unit 4.2 — synchronized 메서드
Unit 4.3 — synchronized 블록
Unit 4.4 — 모니터 락의 동작 (★ 마스터)
Unit 4.5 — volatile (★ 마스터)
✅ Phase 1 — 동시성의 기초 (4 Unit)
✅ Phase 2 — 4분면 매트릭스 (3 Unit)
✅ Phase 3 — 스레드 다루기 (5 Unit)
⏭ Phase 4 — synchronized & volatile (5 Unit) ★ 1차 정점
총: 12/35 Unit (약 34%)