1. Thread.interrupt()와 협력적 취소
실제 애플리케이션에서는 작업이 오래 걸리거나, 때로는 네트워크, 파일, 외부 서비스와 작업할 때 완전히 ‘멈추는’ 경우도 있습니다. 사용자가 작업을 취소할 수도 있고, 서버가 요청 처리를 중단할 수도 있으며, 공통 타임아웃이 만료될 수도 있습니다. 작업을 올바르게 취소할 수 없다면 애플리케이션은 멈춘 듯 보이고, 자원을 낭비하며, 외부 이벤트에 둔감하게 반응합니다.
핵심 아이디어: 취소는 협력적이어야 합니다 — 작업이 스스로 종료 요청을 확인하고 자원을 올바르게 해제해야 합니다.
Thread.interrupt()는 어떻게 동작하는가
각 스레드에는 ‘인터럽트’ 플래그가 있습니다. thread.interrupt()를 호출하면 이 플래그가 true로 설정됩니다. 스레드가 즉시 ‘죽는’ 것은 아니며, 자신의 상태를 확인하고 스스로 종료해야 합니다. 즉, 주기적으로 Thread.currentThread().isInterrupted()를 호출하고 정상적으로 빠져나와야 합니다.
예:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 작업 중...
try {
Thread.sleep(100); // 인터럽트될 수 있음
} catch (InterruptedException e) {
// 인터럽트 플래그가 클리어되므로, 다시 자신을 인터럽트
Thread.currentThread().interrupt();
break;
}
}
System.out.println("인터럽트로 스레드가 종료되었습니다.");
});
worker.start();
// ... 나중에
worker.interrupt();
플래그가 자동으로 동작하는 곳은?
- 블로킹될 수 있는 메서드들(sleep, wait, join, 블로킹 자료구조의 연산)은 인터럽트 시 InterruptedException을 던집니다.
- 그 밖의 경우(예: 계산 루프)에는 isInterrupted()를 수동으로 확인해야 합니다.
패턴: 플래그를 설정하고 빠르게 종료
- 호출 측 코드에서: thread.interrupt()
- 작업 내부에서: 주기적으로 Thread.currentThread().isInterrupted()를 확인
- 필요하다면 자원을 올바르게 해제하고 종료합니다.
전형적인 실수: interrupt()가 즉시 스레드를 ‘죽인다’고 기대하는 것. 그렇지 않습니다 — 이는 신호일 뿐이며, 작업이 스스로 반응해야 합니다.
2. Future.cancel(), CancellationException과 작업 취소
Future.cancel은 어떻게 동작하는가
ExecutorService.submit()으로 작업을 시작하면 Future를 받습니다. 여기에는 cancel(boolean mayInterruptIfRunning) 메서드가 있습니다:
- 작업이 아직 시작되지 않았다면 — 시작되지 않습니다.
- 작업이 이미 실행 중이고 mayInterruptIfRunning == true라면 — 작업을 실행 중인 스레드에 대해 interrupt()가 호출됩니다.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 오래 걸리는 작업
}
System.out.println("취소로 작업이 종료되었습니다.");
});
// ... 나중에
future.cancel(true); // 작업 취소를 요청
실제로 작업에는 무슨 일이 일어나는가
Future를 통한 취소는 ‘스레드를 죽이는 마법 버튼’이 아니라, 사실상 예의 바른 Thread.interrupt() 신호에 가깝습니다. 작업이 인터럽트 플래그를 제대로 확인한다면 깔끔하게 종료됩니다. 그렇지 않다면 — 자연스럽게 끝날 때까지 계속 실행될 수 있습니다.
취소 후에 future.get()을 호출하면 CancellationException이 발생합니다 — 작업이 취소되었음을 알려줍니다.
3. CompletableFuture: 취소, 타임아웃과 체인
CompletableFuture 취소
CompletableFuture에도 cancel(boolean)이 있습니다. 작업이 아직 끝나지 않았다면 취소되며, 이후의 모든 처리기(thenApply, thenAccept 등)는 호출되지 않습니다.
CompletableFuture<Void> cf = CompletableFuture.runAsync(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 작업 중...
}
System.out.println("취소로 CF가 종료되었습니다.");
});
// ... 나중에
cf.cancel(true);
타임아웃: orTimeout과 completeOnTimeout
- orTimeout(timeout, unit) — 제한 시간 내에 끝나지 않으면 CompletableFuture를 TimeoutException으로 완료합니다.
- completeOnTimeout(value, timeout, unit) — 제한 시간 내에 끝나지 않으면 지정한 값으로 완료합니다.
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(5000); } catch (InterruptedException e) {}
return "OK";
});
cf.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> "TIMEOUT")
.thenAccept(System.out::println); // 2초 후: "TIMEOUT"
체인에서의 취소 전파
‘상위’ CompletableFuture를 취소하면 이후 단계는 호출되지 않습니다. 하지만 내부 비동기 작업을 시작하기 위해 thenCompose를 사용할 때는 취소가 자동으로 ‘상향/하향’ 전파되지 않습니다 — 상태를 확인하고, 자식 작업을 취소하며, 공통 데드라인을 사용하는 등 명시적으로 설계해야 합니다.
주의: thenCompose와 커스텀 Executor 조합! 내부 작업이 인터럽트/취소에 반응하거나 공통 타임아웃을 받도록 설계되어 있는지 확인하세요.
4. StructuredTaskScope: 작업 그룹 취소
구조화된 동시성과 취소
StructuredTaskScope(Java 21+)는 작업 그룹을 하나의 단위로 실행하고 그 생명주기를 전체적으로 관리할 수 있게 해줍니다. 작업 중 하나가 실패하거나 타임아웃이 만료되면 — 나머지 작업이 자동으로 취소됩니다.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchData1());
Future<String> f2 = scope.fork(() -> fetchData2());
scope.join(); // 모든 작업이 끝날 때까지 대기
scope.throwIfFailed(); // 하나라도 실패했다면 예외를 던짐
String result = f1.resultNow() + f2.resultNow();
System.out.println(result);
}
- 어떤 작업이든 오류로 종료되면 — scope가 나머지 모든 작업을 취소합니다.
- 타임아웃이 만료되면(scope.joinUntil(deadline)) — scope가 모든 작업을 취소합니다.
종료 정책
- ShutdownOnFailure — 첫 실패가 발생하면 모든 작업을 취소합니다.
- ShutdownOnSuccess — 하나가 성공하면 나머지 작업을 취소합니다.
5. 실습: 오래 걸리는 작업을 안전하게 취소하기
예: 블로킹 IO 취소
작업이 파일이나 네트워크 읽기에서 블로킹된다면, 스레드 인터럽트가 항상 도움이 되지는 않습니다 — 일부 IO 연산은 interrupt에 반응하지 않습니다. 최신 API(NIO, AsynchronousFileChannel)에서는 인터럽트 지원이 더 좋아졌지만, 여전히 예외가 있습니다.
권장 사항:
- 취소가 필요하다면 논블로킹 IO를 사용하세요.
- 블로킹 IO에서는 — API 수준에서 타임아웃을 설정하세요(예: Socket.setSoTimeout).
- 비동기 작업에는 — Future.cancel을 사용하고, 인터럽트에 올바르게 반응하도록 하세요.
예: 큐/배리어 대기 취소
많은 동기화 도구(BlockingQueue.take(), CountDownLatch.await(), CyclicBarrier.await())는 인터럽트 시 InterruptedException을 던집니다. 처리기에서 예외를 잡고, 필요하다면 플래그를 복원한 뒤, 작업을 정상적으로 종료하세요.
6. 패턴 ‘time‑budget’: 일련의 작업에 대한 공통 데드라인
복잡한 애플리케이션에서는 종종 여러 작업 묶음에 대해 공통 타임아웃을 지정해야 합니다. 예를 들어 사용자가 2초 이상 기다리지 않는다고 할 때, 내부적으로 3개의 네트워크 요청을 수행한다면 — 모두 공통 데드라인 안에 끝나야 합니다.
데드라인을 스택 아래로 전파하는 방법?
- 데드라인 객체(예: Instant deadline)를 잠재적으로 블로킹되는 모든 메서드에 전달하세요.
- 각 메서드에서 남은 시간을 계산하세요: Duration.between(Instant.now(), deadline).
- 이 시간을 블로킹 연산의 타임아웃에 사용하세요(await(timeout), poll(timeout), orTimeout(timeout)).
Instant deadline = Instant.now().plusSeconds(2);
void doWork(Instant deadline) throws TimeoutException, InterruptedException {
Duration left = Duration.between(Instant.now(), deadline);
if (left.isNegative() || left.isZero()) throw new TimeoutException();
// 남은 시간(left)을 타임아웃에 사용
queue.poll(left.toMillis(), TimeUnit.MILLISECONDS);
}
Scoped Values / 컨텍스트
Java 21+에서는 Scoped Values를 사용해 데드라인을 호출 스택을 따라 전달할 수 있어, 각 메서드에 명시적으로 넘기지 않아도 됩니다.
7. Structured Concurrency: 실패/타임아웃 시 전체 scope 취소
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchData1());
Future<String> f2 = scope.fork(() -> fetchData2());
boolean completed = scope.joinUntil(Instant.now().plusSeconds(2));
if (!completed) {
scope.shutdown();
throw new TimeoutException("데드라인이 만료되었습니다!");
}
scope.throwIfFailed();
// ...
}
- 데드라인이 만료되면 — scope가 모든 작업을 취소합니다.
- 작업 하나가 실패하면 — 나머지는 자동으로 취소됩니다.
8. 취소와 타임아웃을 다룰 때 흔한 실수
실수 1: interrupt() 가 스레드를 즉시 종료시킨다고 기대함. 실제로 이것은 신호일 뿐 — 작업이 스스로 상태를 확인하고 올바르게 종료해야 합니다.
실수 2: isInterrupted() 를 긴 루프에서 확인하지 않음. 인터럽트 플래그를 확인하지 않으면, 종료 요청을 받아도 작업이 영원히 계속될 수 있습니다.
실수 3: Future.cancel() 이 작업이 interrupt에 반응하지 않으면 취소로 이어지지 않음. 작업이 ‘귀머거리’라면 cancel()은 도움이 되지 않습니다.
실수 4: 타임아웃을 스택 아래로 전파하지 않음. 데드라인을 모든 메서드에 전달하지 않으면, 내부 연산이 필요한 시간보다 더 오래 ‘걸려버릴’ 수 있습니다.
실수 5: thenCompose 체인에서 CompletableFuture 의 취소가 자동으로 전파된다고 가정함. ‘상위’ future를 취소해도 내부 작업이 계속 돌 수 있으므로 — 취소를 명시적으로 처리하세요.
실수 6: StructuredTaskScope 를 닫지 않음(try‑with‑resources 없음). scope를 닫지 않으면 자식 작업이 ‘떠 있는’ 상태로 남을 수 있습니다.
GO TO FULL VERSION