CodeGym /행동 /JAVA 25 SELF /스택 전반에서의 작업 취소와 타임아웃

스택 전반에서의 작업 취소와 타임아웃

JAVA 25 SELF
레벨 58 , 레슨 1
사용 가능

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()를 수동으로 확인해야 합니다.

패턴: 플래그를 설정하고 빠르게 종료

  1. 호출 측 코드에서: thread.interrupt()
  2. 작업 내부에서: 주기적으로 Thread.currentThread().isInterrupted()를 확인
  3. 필요하다면 자원을 올바르게 해제하고 종료합니다.

전형적인 실수: 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);

타임아웃: orTimeoutcompleteOnTimeout

  • orTimeout(timeout, unit) — 제한 시간 내에 끝나지 않으면 CompletableFutureTimeoutException으로 완료합니다.
  • 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를 닫지 않으면 자식 작업이 ‘떠 있는’ 상태로 남을 수 있습니다.

1
과제
JAVA 25 SELF, 레벨 58, 레슨 1
잠금
우주 탐사선 모니터링: 심우주로부터의 신호 🛰️
우주 탐사선 모니터링: 심우주로부터의 신호 🛰️
1
과제
JAVA 25 SELF, 레벨 58, 레슨 1
잠금
작전 "임계 기한": 사이버 위협 무력화 ⏱️
작전 "임계 기한": 사이버 위협 무력화 ⏱️
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION