Structuring Concurrent Applications
- 2.1 태스크와 실행 정책
- 2.2 작업 취소와 종료
- 2.3 스레드 풀 활용
- 2.4 GUI 애플리케이션
동시성 프로그래밍에서 진짜 어려운 건 "어떻게 구조화하느냐"야. 1부에서 스레드 안전성의 기초를 다졌다면, 2부는 작업을 어떻게 정의하고, 실행하고, 취소하고, 종료하느냐를 다뤄. 작업 제출과 실행의 분리, 안전한 취소 메커니즘, 스레드 풀 튜닝, 그리고 GUI라는 특수한 환경까지.
서버 애플리케이션에서 요청을 처리하는 가장 단순한 방법은 단일 스레드로 순차 처리하는 거야. 당연히 느리지. 그다음 시도는 요청마다 스레드를 생성하는 건데, new Thread(task).start() 이런 식으로. 병렬성은 좋아지지만 요청이 폭주하면 스레드가 수천 개 만들어지고 메모리가 부족해지고 컨텍스트 스위칭 비용이 커져서 오히려 성능이 나빠져. 무제한 스레드 생성은 위험해. 스레드 수를 제한하면서 효율적으로 작업을 분배하는 메커니즘이 필요하지.
Executor 인터페이스가 그 해답이야. 작업 제출과 작업 실행을 분리하거든.
public interface Executor {
void execute(Runnable command);
}
단순해 보이지만 이 분리가 엄청나게 강력해. 작업을 제출하는 코드는 그 작업이 어떤 스레드에서, 언제, 어떻게 실행되는지 신경 쓸 필요 없잖아. **실행 정책(execution policy)**은 작업을 어떤 스레드에서 실행할지, 어떤 순서로 실행할지, 동시에 몇 개를 실행할지, 큐에 대기할 수 있는 작업이 몇 개인지, 거부할 작업은 어떻게 처리할지를 결정하는 건데, 이걸 바꿔도 제출 코드는 그대로야.
Executors 팩토리 클래스가 다양한 스레드 풀을 제공하지. **newFixedThreadPool(n)**은 고정 크기 스레드 풀이고, **newCachedThreadPool()**은 필요할 때 스레드를 만들고 60초간 유휴면 제거하고, **newSingleThreadExecutor()**는 스레드 하나로 순차 실행하고, **newScheduledThreadPool(n)**은 지연 실행이나 주기적 실행을 지원해. ExecutorService는 Executor를 확장해서 생명주기 관리를 추가한 거야. shutdown()은 새 작업 제출을 거부하고 이미 제출된 작업은 완료까지 실행하고, shutdownNow()는 실행 중인 작업을 중단 시도하고 대기 중인 작업 목록을 반환하지.
Runnable은 결과를 반환할 수 없고 예외를 던질 수 없잖아. Callable은 결과를 반환하고 예외도 던질 수 있어. Future는 비동기 연산의 결과를 나타내는데, get()으로 결과를 받고 아직 완료되지 않았으면 완료될 때까지 블로킹하지. 타임아웃 버전도 있고 future.cancel(true)로 작업을 취소할 수도 있어.
Future<Integer> future = executor.submit(task);
Integer result = future.get(5, TimeUnit.SECONDS); // 최대 5초 대기
여러 작업을 동시에 실행하고 완료된 순서대로 결과를 받고 싶을 때는 CompletionService를 써. invokeAll은 모든 작업이 끝날 때까지 기다려야 하지만, CompletionService는 빨리 끝난 것부터 처리할 수 있거든. 이미지 렌더링에서 다운로드 완료된 이미지부터 바로 보여주는 식의 시나리오에 딱이야.
자바에는 스레드를 강제 종료하는 안전한 방법이 없어. Thread.stop()은 deprecated된 지 오래지. 대신 협력적 취소(cooperative cancellation) 메커니즘을 사용해. 취소를 요청하면 작업이 스스로 주기적으로 확인하고 멈추는 방식이야. 가장 기본적인 방법은 volatile 플래그인데, 문제는 블로킹 연산에서 막힌 스레드를 깨울 수 없다는 거야. BlockingQueue.put()에서 블로킹 중인 스레드는 cancelled 플래그를 확인할 기회가 없어서 영원히 멈춰있을 수 있지.
public class PrimeGenerator implements Runnable {
private volatile boolean cancelled = false;
public void run() {
while (!cancelled) { /* 소수 찾기... */ }
}
public void cancel() { cancelled = true; }
}
그래서 **인터럽트(interrupt)**가 필요해. thread.interrupt()를 호출하면 대상 스레드에 인터럽트 플래그가 설정되고, 대상 스레드가 블로킹 메서드(sleep, wait, BlockingQueue.take 등)에 있으면 InterruptedException이 발생하지. 인터럽트를 제대로 처리하는 방법은 두 가지야. InterruptedException을 전파하거나, 인터럽트 상태를 복원하거나(Thread.currentThread().interrupt() 호출). 절대 하면 안 되는 건 InterruptedException을 잡아서 무시하는 거야. 인터럽트 요청을 삼켜버리면 상위 코드가 취소 요청을 알 수 없게 되거든.
스레드를 소유하는 서비스는 생명주기 관리 메서드를 제공해야 해. 서비스를 중단할 때는 새 작업 제출을 거부하고, 이미 제출된 작업은 완료까지 처리하고, 소비자 스레드를 종료하는 순서를 따라야 하지. ExecutorService의 shutdown()이 바로 이 패턴이야. 독약(poison pill) 패턴도 있어. 특수한 객체를 큐에 넣어서 "이걸 꺼내면 종료해라"라는 신호를 보내는 건데, 생산자와 소비자 수가 고정되어 있을 때 유용하지.
스레드가 예상치 못한 예외로 죽을 수도 있어. 특히 스레드 풀에서 RuntimeException이 발생하면 스레드가 그냥 사라지거든. UncaughtExceptionHandler를 등록하면 스레드가 예외로 죽을 때 통보받을 수 있어. 주의할 점은 execute()로 제출한 작업은 UncaughtExceptionHandler로 잡을 수 있지만, submit()으로 제출한 작업은 예외가 Future 안에 갇혀서 future.get()을 호출해야 ExecutionException으로 감싸져서 나온다는 거야.
JVM 종료 시 **셧다운 훅(shutdown hook)**이 실행되는데, 모두 동시에 실행되니까 훅 자체가 스레드 안전해야 하고 데드락에 빠지지 않도록 조심해야 해. 데몬 스레드는 JVM 종료를 막지 않아서 비데몬 스레드가 모두 끝나면 그냥 버려지거든. finally 블록도 실행되지 않아. 그래서 데몬 스레드에서 I/O 작업을 하면 위험하지. finalizer는 쓰지 마. 실행 시점이 보장되지 않고 성능도 나쁘고 정확성도 보장 못 해. 리소스 정리는 명시적으로 close() 메서드를 호출하는 게 맞아.
모든 작업이 모든 실행 정책과 잘 맞는 건 아니야. 작업 A가 작업 B의 결과를 기다리는데 둘 다 같은 단일 스레드 Executor에서 실행되면 **스레드 기아 데드락(thread starvation deadlock)**이 발생하지. 스레드 풀이 작아도 비슷한 문제가 생길 수 있어.
// 스레드 기아 데드락
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.submit(() -> {
Future<String> future = exec.submit(() -> "hello");
return future.get(); // 유일한 스레드인데, 새 작업을 위한 스레드가 없다!
});
스레드 풀 크기를 산정하는 공식이 있어. N_threads = N_cpu * U_cpu * (1 + W/C). N_cpu는 CPU 코어 수, U_cpu는 목표 CPU 활용률, W/C는 대기 시간 대 연산 시간 비율이야. CPU 집약적 작업이면 스레드 수가 코어 수에 가깝고, I/O 집약적 작업이면 훨씬 많이 만들어야 해. CPU 4코어에 대기 시간이 연산 시간의 9배면 4 * 1 * (1 + 9) = 40개가 되는 거지.
Executors 팩토리 메서드 뒤에는 ThreadPoolExecutor가 있어. 코어 풀 크기와 최대 풀 크기를 설정하고, 작업 큐도 선택할 수 있지. 무제한 큐(LinkedBlockingQueue)는 메모리가 허용하는 한 계속 쌓이고, 제한 큐(ArrayBlockingQueue)는 크기 제한이 있고, 직접 핸드오프(SynchronousQueue)는 큐에 쌓지 않고 바로 스레드에 전달해.
**포화 정책(saturation policy)**도 중요해. 큐도 차고 스레드도 최대인데 작업이 오면 어떻게 할 건지. AbortPolicy는 예외를 던지고(기본값), CallerRunsPolicy는 제출한 스레드에서 직접 실행해서 자연스러운 흐름 제어 역할을 하고, DiscardPolicy는 조용히 버리고, DiscardOldestPolicy는 큐에서 가장 오래된 작업을 버리고 새 작업을 넣지. ThreadFactory를 커스텀하면 스레드 이름을 "pool-1-thread-3" 대신 "order-processor-3"으로 바꿀 수 있어서 디버깅할 때 훨씬 편하거든.
순차적 재귀 알고리즘도 병렬화할 수 있어. 각 재귀 호출이 독립적이면 각 호출을 별도 작업으로 제출하면 되지. 트리 구조에서 각 노드의 처리가 독립적이면 DFS를 병렬화할 수 있고, 퍼즐 풀기 같은 탐색 문제에도 적용할 수 있어.
거의 모든 GUI 프레임워크는 단일 스레드 이벤트 루프 모델을 사용해. Swing의 EDT, JavaScript의 이벤트 루프, Android의 메인 스레드가 다 그렇지. 멀티스레드 GUI를 많이 시도해봤는데 다 실패했거든. MVC 패턴에서 모델 변경이 뷰를 업데이트하고 뷰의 이벤트가 모델을 변경하는 양방향 흐름 때문에 데드락과 경쟁 조건이 너무 쉽게 발생하니까. 업계가 내린 결론은 — GUI 이벤트 처리와 렌더링은 전용 스레드 하나에서만 하자는 거야. 동기화가 필요 없어지지. 대신 그 스레드를 블로킹하면 UI가 얼어버려.
짧은 작업은 EDT에서 바로 처리하면 되지만, 오래 걸리는 작업은 백그라운드 스레드에서 실행해야 해. 핵심 규칙은 두 가지야. 오래 걸리는 작업은 백그라운드 스레드에서 실행하고, UI 업데이트는 반드시 EDT에서 해야 하지.
button.addActionListener(e -> {
button.setEnabled(false);
executor.execute(() -> {
String result = longRunningComputation();
SwingUtilities.invokeLater(() -> {
label.setText(result);
button.setEnabled(true);
});
});
});
GUI에서 데이터 모델을 여러 스레드가 접근하는 경우에는 분할 데이터 모델(split model) 패턴이 유용해. 표현 모델(EDT 전용)과 공유 모델(스레드 안전)을 분리해서, 백그라운드 스레드가 공유 모델을 업데이트하면 주기적으로 또는 이벤트 기반으로 표현 모델에 동기화하는 거야. 프론트엔드 개발자라면 이 패턴이 익숙할 거야. React의 상태 관리에서 서버 상태와 UI 상태를 분리하는 것과 본질적으로 같은 아이디어거든.
정리
2장 읽고 기억할 거 세 가지:
- 작업 제출과 실행을 분리하는 게 핵심이야. Executor 프레임워크를 쓰면 실행 정책을 유연하게 바꿀 수 있고, 무제한 스레드 생성의 위험을 피할 수 있지
- 인터럽트는 자바에서 작업 취소의 표준 메커니즘이야. volatile 플래그는 블로킹 연산에서 무력하니까, InterruptedException을 절대 무시하지 말고 전파하거나 인터럽트 상태를 복원해야 해
- 스레드 풀 크기는 N_threads = N_cpu * U_cpu * (1 + W/C)로 산정하고, ThreadPoolExecutor의 큐 타입과 포화 정책을 신중하게 선택해야 해. CallerRunsPolicy는 자연스러운 흐름 제어를 제공하는 좋은 선택이지