F-LAB JAVA · 4주차 · Phase 3 · 스레드 만들고 다루기
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
Runnable 은 단 하나의 추상 메서드
run()을 가진 함수형 인터페이스로, Thread 에 작업을 전달하는 방식이며, Thread 상속보다 권장된다.
Thread 상속의 한계 — 자바는 단일 상속만 가능하여 다른 클래스를 상속할 수 없고, 작업과 스레드 제어가 결합된다.
Runnable 의 장점 3가지 — (1) 다른 클래스 상속 가능 (인터페이스라 다중 구현), (2) 작업과 스레드 분리 (객체지향적 설계), (3) 메모리 효율 (여러 스레드가 같은 Runnable 공유 가능).
Runnable 은 함수형 인터페이스 (SAM) 이므로 람다로 간결하게 작성할 수 있다 (() -> {...}).
Spring 의@Async는 내부적으로 작업을 Runnable (또는 Callable) 로 감싸 ThreadPoolTaskExecutor 에 제출한다 — Runnable 기반.
Thread 상속 = 일꾼이 일을 내장:
class 청소일꾼 extends 일꾼 {
void 일() { 청소하기 }
}
- 일꾼 = 청소만 하는 일꾼
- 다른 일 시키려면 새 클래스
Runnable = 일(작업서)과 일꾼 분리:
Runnable 청소 = () -> 청소하기;
Runnable 요리 = () -> 요리하기;
일꾼(Thread)에게 작업서 전달:
new 일꾼(청소).start();
new 일꾼(요리).start();
- 일꾼은 범용
- 작업서만 바꿈
- 같은 작업서를 여러 일꾼이 (공유)
장점:
- 일꾼 재사용
- 작업 교체 쉬움
- 일꾼이 다른 역할도 (상속)
→ Runnable = 작업서, Thread = 일꾼, 분리가 핵심.
1. Runnable 인터페이스의 정의
2. Thread 상속의 한계
3. 장점 1 — 다른 클래스 상속 가능
4. 장점 2 — 작업과 스레드 분리
5. 장점 3 — 메모리 효율 (공유)
6. 람다로 Runnable
7. Spring @Async의 내부
8. Runnable vs Callable, 선택 가이드
9. 면접 + 자기 점검
@FunctionalInterface
public interface Runnable {
void run(); // 단 하나의 추상 메서드 (SAM)
}
핵심:
run() 메서드 하나// 1. 클래스로 구현
class MyTask implements Runnable {
@Override
public void run() {
System.out.println("Task running");
}
}
Thread t = new Thread(new MyTask());
t.start();
// 2. 람다 (함수형 인터페이스)
Runnable task = () -> System.out.println("Lambda task");
Thread t2 = new Thread(task);
t2.start();
// 3. 익명 클래스
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Anonymous task");
}
});
t3.start();
// Thread 도 Runnable 구현
public class Thread implements Runnable {
private Runnable target; // 전달받은 Runnable
public Thread(Runnable target) {
this.target = target;
}
@Override
public void run() {
if (target != null) {
target.run(); // Runnable 실행
}
}
}
// 방식 1: Thread 상속
class MyThread extends Thread {
@Override
public void run() { doWork(); }
}
new MyThread().start();
// 방식 2: Runnable 구현 (권장)
class MyTask implements Runnable {
@Override
public void run() { doWork(); }
}
new Thread(new MyTask()).start();
// Runnable 구현
public class ShipmentTask implements Runnable {
private final Shipment shipment;
private final ShipmentService service;
public ShipmentTask(Shipment shipment, ShipmentService service) {
this.shipment = shipment;
this.service = service;
}
@Override
public void run() {
service.process(shipment);
}
}
// 사용
Runnable task = new ShipmentTask(shipment, service);
new Thread(task).start();
// 또는 람다
new Thread(() -> service.process(shipment)).start();
Runnable 인터페이스의 정의는?
답:
1. 정의:
사용:
Thread 와 관계:
방식:
Thread 상속의 한계:
1. 단일 상속 제약
- 자바는 단일 상속
- Thread 상속 시 다른 클래스 X
2. 작업과 제어 결합
- 작업 (run) 과 스레드 제어 섞임
- 객체지향적 X
- 재사용 어려움
// ❌ Thread 상속 + 다른 클래스 상속 불가
class BaseProcessor {
protected void log(String msg) { }
}
class ShipmentProcessor extends BaseProcessor { // 이미 상속
// extends Thread 불가!
// 자바는 단일 상속
}
// 해결: Runnable 구현
class ShipmentProcessor extends BaseProcessor
implements Runnable { // 인터페이스 추가 (다중 가능)
@Override
public void run() {
log("Processing"); // BaseProcessor 메서드 활용
}
}
// Thread 상속 — 작업 + 제어 결합
class ShipmentThread extends Thread {
// 스레드 제어 (Thread 의 것)
// + 작업 (run)
// 섞여 있음
@Override
public void run() {
// 작업
}
}
// 문제:
// - 작업만 따로 쓰기 어려움
// - 스레드 없이 작업 실행 X
// - 테스트 어려움
// Thread 상속 — 작업 재사용 어려움
class MyThread extends Thread {
@Override
public void run() { doWork(); }
}
// 같은 작업을 여러 스레드가?
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
// 각각 별도 인스턴스 (작업 정의 중복)
// Runnable — 작업 공유
Runnable task = () -> doWork();
new Thread(task).start();
new Thread(task).start(); // 같은 작업 공유
Thread 상속:
┌─────────────────┐
│ MyThread │
│ - 스레드 제어 │ ← Thread 의 기능
│ - run() 작업 │ ← 작업
│ (결합) │
└─────────────────┘
단일 상속 (다른 클래스 X)
Runnable 구현:
┌──────────┐ ┌──────────┐
│ Thread │ ── │ Runnable │
│ (제어) │ │ (작업) │
└──────────┘ └──────────┘
분리 (다른 클래스 상속 가능)
// ❌ Thread 상속의 한계
public class ShipmentThreadBad extends Thread {
// BaseService 상속 불가 (이미 Thread)
// 작업 + 제어 결합
@Override
public void run() {
// process()...
}
}
// ✓ Runnable — 한계 극복
public class ShipmentTaskGood extends BaseService
implements Runnable { // 다른 클래스 + Runnable
@Override
public void run() {
// BaseService 의 메서드 활용
process(); // 상속 메서드
}
}
Thread 상속의 한계는?
답:
1. 단일 상속:
작업 + 제어 결합:
재사용 어려움:
해결:
Runnable 의 장점 1:
Runnable 은 인터페이스.
- 클래스는 다른 클래스 상속 가능
- + Runnable 인터페이스 구현
- 다중 인터페이스도 가능
자바:
- 클래스 단일 상속
- 인터페이스 다중 구현
// 다른 클래스 상속 + Runnable
class ShipmentProcessor extends BaseProcessor // 클래스 상속
implements Runnable, Comparable<ShipmentProcessor> { // 다중 인터페이스
@Override
public void run() {
// 작업
}
@Override
public int compareTo(ShipmentProcessor o) {
return 0;
}
}
// Thread 상속으로는 불가능
// 기존 클래스
class ExistingService {
public void doBusinessLogic() { }
}
// Runnable 추가 (상속 유지)
class ExistingServiceTask extends ExistingService
implements Runnable {
@Override
public void run() {
doBusinessLogic(); // 기존 메서드 활용
}
}
// 기존 코드 변경 없이 스레드화
// Spring 의 클래스 상속 + Runnable
class ScheduledTask extends AbstractTask // 프레임워크 클래스
implements Runnable {
@Override
public void run() {
// 스케줄 작업
}
}
// AbstractTask 상속 유지
// + 스레드로 실행 가능
// 기존 서비스 + Runnable
@Service
public class ShipmentService extends AbstractAuditableService {
// AbstractAuditableService 상속 (감사 로그 등)
public void process(Shipment s) {
audit("Processing " + s.getId()); // 상속 메서드
// 처리
}
}
// 스레드화 — Runnable 구현 별도 클래스
public class ShipmentProcessTask implements Runnable {
private final ShipmentService service;
private final Shipment shipment;
@Override
public void run() {
service.process(shipment);
}
}
// 또는 람다
Runnable task = () -> service.process(shipment);
장점 1 — 다른 클래스 상속 가능은?
답:
1. 인터페이스 다중:
예시:
활용:
이유:
Runnable 의 장점 2:
작업 (Runnable) 과 스레드 제어 (Thread) 를
분리.
효과:
- 객체지향적 설계
- 작업 단독 사용 가능
- 테스트 쉬움
- 유연성
// Runnable — 작업 단독 사용 가능
Runnable task = () -> processShipment();
// 1. 스레드로 실행
new Thread(task).start();
// 2. 직접 실행 (테스트 등)
task.run(); // 현재 스레드
// 3. Executor 에 제출
executor.submit(task);
// 4. 다른 곳에 전달
scheduleService.schedule(task);
// 같은 작업, 여러 실행 방식
// Runnable — 테스트 쉬움
public class ShipmentTask implements Runnable {
@Override
public void run() {
process();
}
void process() {
// 작업
}
}
// 테스트 — 스레드 없이
@Test
void testProcess() {
ShipmentTask task = new ShipmentTask();
task.run(); // 직접 호출 (스레드 X)
// 결과 검증
}
// Thread 상속이면 스레드 띄워야 (테스트 복잡)
// 작업 정의 (Runnable)
Runnable task = () -> processData();
// 실행 방식 자유롭게 교체
// 1. 단순 스레드
new Thread(task).start();
// 2. 스레드 풀
executor.submit(task);
// 3. 스케줄러
scheduledExecutor.scheduleAtFixedRate(task, 0, 1, SECONDS);
// 4. 비동기
CompletableFuture.runAsync(task);
// 작업은 그대로, 실행만 변경
작업과 스레드 분리:
작업 (Runnable):
┌──────────────┐
│ run() { ... } │
└──────┬───────┘
│ 다양한 실행
┌────┼────┬────────┐
↓ ↓ ↓ ↓
Thread Executor 스케줄러 CompletableFuture
하나의 작업, 여러 실행
@Service
public class ShipmentExecutionService {
private final ExecutorService executor;
private final ScheduledExecutorService scheduler;
// 작업 정의 (Runnable)
private Runnable createTask(Shipment shipment) {
return () -> shipmentService.process(shipment);
}
// 1. 즉시 실행 (스레드 풀)
public void processNow(Shipment shipment) {
executor.submit(createTask(shipment));
}
// 2. 지연 실행 (스케줄러)
public void processLater(Shipment shipment, long delaySeconds) {
scheduler.schedule(createTask(shipment), delaySeconds, TimeUnit.SECONDS);
}
// 3. 비동기
public CompletableFuture<Void> processAsync(Shipment shipment) {
return CompletableFuture.runAsync(createTask(shipment), executor);
}
// 같은 작업 정의, 다양한 실행
}
장점 2 — 작업과 스레드 분리는?
답:
1. 관심사 분리:
효과:
실행 방식:
객체지향:
Runnable 의 장점 3:
하나의 Runnable 인스턴스를
여러 스레드가 공유 가능.
효과:
- 메모리 효율
- 작업 정의 한 번
- 여러 스레드 같은 작업
// 하나의 Runnable
Runnable task = () -> processShipments();
// 여러 스레드가 공유
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
Thread t3 = new Thread(task);
// 같은 task 인스턴스 (메모리 1개)
t1.start();
t2.start();
t3.start();
// 3 스레드가 같은 작업 실행
// Thread 상속 — 각각 인스턴스
class MyThread extends Thread {
@Override
public void run() { doWork(); }
}
MyThread t1 = new MyThread(); // 인스턴스 1
MyThread t2 = new MyThread(); // 인스턴스 2
MyThread t3 = new MyThread(); // 인스턴스 3
// 작업 정의가 각 인스턴스에
// Runnable — 작업 공유
Runnable task = () -> doWork(); // 작업 1개
new Thread(task); // 공유
new Thread(task); // 공유
new Thread(task); // 공유
// 공유 Runnable 의 상태 주의
class CounterTask implements Runnable {
private int count = 0; // ★ 공유 상태!
@Override
public void run() {
count++; // 여러 스레드 동시 → 위험
}
}
// 같은 인스턴스 공유
CounterTask task = new CounterTask();
new Thread(task).start();
new Thread(task).start();
// count 동시 수정 → 데이터 손실
// 무상태 Runnable 권장
Runnable stateless = () -> {
int local = 0; // 지역 변수 (안전)
local++;
};
Runnable 공유:
Runnable task (메모리 1개)
↑ ↑ ↑
│ │ │
Thread1 Thread2 Thread3
(각자 실행)
작업 정의 1개, 스레드 여러 개
Thread 상속:
MyThread1 MyThread2 MyThread3
(각자 작업 내장)
메모리 3개
public class SharedTaskExample {
// 무상태 작업 (공유 안전)
public void processWithSharedTask(List<Shipment> shipments) {
// 작업 정의 (무상태)
Runnable healthCheck = () -> {
log.info("Health check on {}", Thread.currentThread().getName());
checkSystemHealth();
};
// 여러 스레드가 같은 작업
for (int i = 0; i < 5; i++) {
new Thread(healthCheck).start(); // 공유
}
// healthCheck 인스턴스 1개
// 5 스레드가 공유 (안전 — 무상태)
}
// 상태 있는 작업 (스레드별 인스턴스 또는 동기화)
public void processWithState(List<Shipment> shipments) {
for (Shipment s : shipments) {
// 각 스레드마다 별도 (상태 격리)
Runnable task = () -> process(s); // s 캡처
new Thread(task).start();
}
}
private void checkSystemHealth() { }
private void process(Shipment s) { }
}
장점 3 — 메모리 효율은?
답:
1. 공유:
효과:
vs Thread 상속:
주의:
Runnable 은 함수형 인터페이스:
@FunctionalInterface
interface Runnable {
void run(); // SAM
}
→ 람다로 작성 가능
// 익명 클래스 (옛)
Runnable task1 = new Runnable() {
@Override
public void run() {
System.out.println("Hello");
}
};
// 람다 (Java 8+)
Runnable task2 = () -> System.out.println("Hello");
// 훨씬 간결
// 여러 줄
Runnable task3 = () -> {
System.out.println("Line 1");
System.out.println("Line 2");
};
// Thread 에 직접 람다
new Thread(() -> {
processShipment();
}).start();
// 메서드 참조
new Thread(this::processShipment).start();
// 간결, 가독성
람다가 가능한 이유:
Runnable 이 SAM (Single Abstract Method).
- 추상 메서드 1개 (run)
- 컴파일러가 람다 → run() 매핑
() -> {...}
→ run() 의 구현으로 해석
// 익명 클래스
Runnable r1 = new Runnable() {
@Override
public void run() { doWork(); }
};
// 람다 (더 간결)
Runnable r2 = () -> doWork();
// 차이:
// - 람다: 간결, this 는 외부
// - 익명: 길음, this 는 자신
// (3주차 Phase 10 람다 참고)
public class LambdaRunnableExample {
private final ShipmentService service;
public void variousLambdas(Shipment shipment) {
// 1. 간단한 람다
new Thread(() -> service.process(shipment)).start();
// 2. 메서드 참조
Runnable healthCheck = this::checkHealth;
new Thread(healthCheck).start();
// 3. 여러 줄
new Thread(() -> {
log.info("Start processing {}", shipment.getId());
service.process(shipment);
log.info("Done");
}).start();
// 4. Executor 와 함께
executor.submit(() -> service.process(shipment));
// 5. CompletableFuture
CompletableFuture.runAsync(() -> service.process(shipment));
}
private void checkHealth() { }
}
람다로 Runnable을 만들 수 있는 이유는?
답:
1. 함수형 인터페이스:
표현:
이유:
장점:
Spring @Async:
메서드를 비동기로 실행.
내부적으로 작업을 TaskExecutor 에 제출.
동작:
1. @Async 메서드 호출
2. Spring 프록시가 가로챔
3. 작업을 Runnable/Callable 로 감쌈
4. TaskExecutor 에 제출
5. 별도 스레드에서 실행
@Service
public class ShipmentAsyncService {
@Async
public void processAsync(Shipment shipment) {
// 별도 스레드에서 실행
service.process(shipment);
}
@Async
public CompletableFuture<Result> processWithResult(Shipment shipment) {
Result result = service.process(shipment);
return CompletableFuture.completedFuture(result);
}
}
// 호출
shipmentAsyncService.processAsync(shipment);
// 즉시 반환 (별도 스레드에서 처리)
@Async 의 내부:
void 반환:
- 작업을 Runnable 로 감쌈
- taskExecutor.execute(runnable)
CompletableFuture/Future 반환:
- Callable 로 감쌈
- taskExecutor.submit(callable)
핵심:
- Runnable/Callable 기반
- ThreadPoolTaskExecutor 사용
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
}
// @Async 가 이 executor 사용
// 내부적으로 Runnable/Callable 제출
@Async 의 주의:
1. public 메서드만
- 프록시 기반
2. self-invocation X
- 같은 클래스 내부 호출 시 비동기 X
- 프록시 우회
3. 반환 타입
- void
- Future / CompletableFuture
4. 예외 처리
- void: AsyncUncaughtExceptionHandler
- Future: get() 시 예외
@Configuration
@EnableAsync
public class ShipmentAsyncConfig {
@Bean("shipmentExecutor")
public Executor shipmentExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int cores = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(cores);
executor.setMaxPoolSize(cores * 2);
executor.setThreadNamePrefix("shipment-async-");
executor.initialize();
return executor;
}
}
@Service
public class ShipmentNotificationService {
// 비동기 알림 (Runnable 기반)
@Async("shipmentExecutor")
public void sendNotificationAsync(Shipment shipment) {
// 별도 스레드 (executor 의)
// 내부적으로 Runnable 로 감싸짐
emailService.send(shipment.getConsignee(), buildMessage(shipment));
}
// 비동기 + 결과 (Callable 기반)
@Async("shipmentExecutor")
public CompletableFuture<TrackingInfo> fetchTrackingAsync(String blNo) {
TrackingInfo info = trackingApi.fetch(blNo);
return CompletableFuture.completedFuture(info);
}
}
Spring @Async는 내부적으로 무엇을 쓰나?
답:
1. 동작:
내부:
TaskExecutor:
제약:
| 항목 | Runnable | Callable |
|---|---|---|
| 메서드 | void run() | V call() throws Exception |
| 반환값 | ❌ | ✅ (제네릭) |
| 체크 예외 | ❌ | ✅ |
| 도입 | Java 1.0 | Java 5 |
| 사용 | Thread, Executor | Executor (submit) |
// Callable — 반환값 + 예외
Callable<Shipment> task = () -> {
Shipment s = repository.findById(1L); // 예외 가능
return s; // 반환값
};
// Executor 에 제출
Future<Shipment> future = executor.submit(task);
Shipment result = future.get(); // 결과 회수
// Runnable 은 반환값 X
Runnable r = () -> {
repository.findById(1L); // 반환 안 됨
// throws 불가
};
Runnable 사용:
- 반환값 불필요
- 단순 작업
- 부수 효과 (로그, 알림)
Callable 사용:
- 반환값 필요
- 체크 예외 처리
- 결과 회수 (Future)
예:
- 알림 발송: Runnable
- 계산 + 결과: Callable
선택 가이드:
Runnable 구현 (권장):
✓ 다른 클래스 상속 필요
✓ 작업과 스레드 분리
✓ 작업 공유
✓ 대부분의 경우
Thread 상속 (드물게):
✓ Thread 기능 확장 필요
✓ 매우 단순한 경우
실무:
- Runnable/Callable + Executor
- Thread 직접 거의 안 씀
// ❌ 옛 방식 — Thread 직접
new Thread(() -> doWork()).start();
// ✓ 권장 — Executor + Runnable/Callable
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> doWork()); // Runnable
Future<Result> f = executor.submit(() -> compute()); // Callable
// ✓✓ 현대 — Virtual Thread (Java 21+)
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
ex.submit(() -> doWork());
}
@Service
public class ShipmentTaskService {
private final ExecutorService executor;
// Runnable — 반환값 불필요 (알림)
public void sendNotification(Shipment shipment) {
executor.submit(() -> { // Runnable
emailService.send(shipment);
});
}
// Callable — 반환값 필요 (계산)
public Future<BigDecimal> calculateFreight(Shipment shipment) {
return executor.submit(() -> { // Callable
return freightCalculator.calculate(shipment); // 반환 + 예외
});
}
// CompletableFuture (권장 — Phase 8)
public CompletableFuture<BigDecimal> calculateFreightAsync(Shipment shipment) {
return CompletableFuture.supplyAsync(
() -> freightCalculator.calculate(shipment),
executor);
}
}
Runnable vs Callable, 선택은?
답:
1. Runnable:
Callable:
선택:
Thread vs Runnable:
| Q | 핵심 답변 |
|---|---|
| Runnable? | run() 하나 (함수형 인터페이스) |
| Thread 상속 한계? | 단일 상속, 결합 |
| Runnable 장점 3가지? | 상속 가능, 분리, 공유 |
| 람다 가능 이유? | SAM (함수형) |
| 작업 공유? | 하나의 Runnable 여러 스레드 |
| 작업/스레드 분리? | 객체지향, 유연 |
| @Async 내부? | Runnable/Callable + TaskExecutor |
| Runnable vs Callable? | 반환값/예외 유무 |
| Thread vs Runnable 선택? | Runnable 권장 |
| 공유 시 주의? | 상태 (무상태 권장) |
답:
답:
답:
답:
답:
1. Runnable
2. 장점 3가지
3. 실무
이번 Unit에서 Runnable 을 봤다면, 다음은 데몬 스레드.
🚀 Phase 3 — 스레드 만들고 다루기
✅ Unit 3.1 스레드 상태 다이어그램
✅ Unit 3.2 Thread 클래스 상속
✅ Unit 3.3 Runnable 인터페이스 ← 여기
⏭ Unit 3.4 데몬 스레드
⏭ Unit 3.5 join()
✅ Phase 1 — 동시성의 기초 (4 Unit)
✅ Phase 2 — 4분면 매트릭스 (3 Unit)
🚀 Phase 3 — 스레드 다루기 (3/5 진행)
총: 10/35 Unit