본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
18장 : 스레드 생성·제어·생명 주기

interrupt 플래그와 취소

interrupted와 isInterrupted의 차이, InterruptedException의 플래그 소거, 큐 기반 printer 취소를 실행으로 확인합니다.

interruptThread 안에 있는 상태와 중단 가능 API의 예외 경로가 함께 만드는 프로토콜입니다. 플래그를 읽는 메서드마다 소거 여부가 다르고, 블로킹 호출이 던지는 InterruptedException도 플래그를 지웁니다. 이 규칙을 모르면 한 계층에서 취소 요청을 소비해 상위 코드가 계속 일하게 됩니다.

interrupted 플래그 소비

정적 Thread.interrupted()current 스레드의 플래그를 반환한 뒤 false로 지웁니다. 아래 프로그램은 자기 자신을 interrupt하고 두 번 읽으므로 실제 출력은 first=true, second=false입니다. 두 번째도 true일 것이라는 기대가 잘못됐습니다.

lab/InterruptedFlagClearedBug.java
public final class InterruptedFlagClearedBug {
    public static void main(String[] args) {
        Thread.currentThread().interrupt();
        boolean first = Thread.interrupted();
        boolean second = Thread.interrupted();
        System.out.println("first=" + first + ", second=" + second);
        System.out.println("wrong-assumption=" + (first == second));
    }
}

반면 current.isInterrupted()는 지정한 Thread의 플래그를 지우지 않고 조회합니다. 반복문 조건에서 상태를 유지하며 보고 싶으면 인스턴스 메서드가 맞고, 현재 계층에서 요청을 소비하기로 했다면 static 메서드의 소거 의미를 의도적으로 사용할 수 있습니다.

interrupt 요청이 전달되는 세 경로

RUNNABLE 계산 스레드에 interrupt하면 플래그가 true가 되고 코드는 확인할 때까지 계속 실행합니다. sleep·wait·join 중이면 대기가 풀리고 InterruptedException이 발생하며 플래그는 false로 정리됩니다. 이미 플래그가 true인 상태에서 중단 가능 블로킹 호출로 들어가도 즉시 예외가 날 수 있습니다.

catch에서 작업을 끝내고 반환한다면 플래그 복원이 반드시 필요한 것은 아닙니다. 하지만 라이브러리 계층이 정리만 하고 예외를 던질 수 없어서 반환한다면 Thread.currentThread().interrupt()로 상위에 신호를 보존하는 관례가 중요합니다. 아무 처리 없이 catch를 비우는 것이 가장 위험합니다.

src/InterruptedExceptionRestoration.java
public final class InterruptedExceptionRestoration {
    public static void main(String[] args) throws InterruptedException {
        Thread worker =
                new Thread(
                        () -> {
                            boolean completed = waitForInput();
                            System.out.println(
                                    "completed="
                                            + completed
                                            + ", restored="
                                            + Thread.currentThread().isInterrupted());
                        },
                        "restore-worker");
        worker.start();
        Thread.sleep(10);
        worker.interrupt();
        worker.join();
    }

    private static boolean waitForInput() {
        try {
            Thread.sleep(5_000);
            return true;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }
}

출력은 완료된=false, restored=true입니다. 도우미가 검사 예외를 시그니처에 올릴 수 있다면 catch하지 않고 호출자에게 전달하는 쪽이 더 단순할 수 있습니다.

블로킹 큐의 interrupt 대기

printer가 큐를 계속 확인하며 비어 있으면 sleep하는 구조는 지연과 불필요한 wake-up을 만듭니다. BlockingQueue.take()는 항목이 없을 때 WAITING하고 interrupt에 응답합니다. 생산자는 put 또는 offer로 작업을 전달합니다.

src/InterruptiblePrinter.java
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public final class InterruptiblePrinter {
    private final BlockingQueue<String> jobs = new LinkedBlockingQueue<>();
    private final Thread worker = new Thread(this::loop, "printer-worker");

    public void start() {
        worker.start();
    }

    public void submit(String document) {
        jobs.add(document);
    }

    public void stop() throws InterruptedException {
        worker.interrupt();
        worker.join();
    }

    private void loop() {
        try {
            while (true) {
                String job = jobs.take();
                System.out.println("printed=" + job);
            }
        } catch (InterruptedException e) {
            System.out.println("printer-cancelled pending=" + jobs.size());
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        InterruptiblePrinter printer = new InterruptiblePrinter();
        printer.start();
        printer.submit("chapter-18");
        while (!printer.jobs.isEmpty()) {
            Thread.onSpinWait();
        }
        printer.stop();
    }
}

stop은 요청만 보내고 끝나지 않고 join으로 종료까지 회수합니다. interrupt 순간 큐에 남은 작업을 폐기할지 저장할지 재처리할지는 기능 정책입니다. 예제는 대기 중 count를 로그로 남기고 종료합니다.

interrupt와 정상 완료 구분

취소는 사용자 요청, 기한 만료, 프로세스 shutdown 등 원인이 다를 수 있습니다. 모든 경우를 false로 반환하면 재시도 여부를 정할 수 없습니다. 완료·취소·실패를 separate 결과로 표현하고 예외 원인을 보존합니다.

interrupt를 업무 오류로 로깅할 때 스택 추적을 매번 오류 수준으로 남기면 정상 shutdown이 장애처럼 보입니다. 요청된 shutdown인지 예상 밖 interrupt인지 소유자 상태와 비교합니다. 반대로 아무 로그도 없이 종료하면 누락 작업을 찾기 어렵습니다.

app/CancellableDocumentConsumer.java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public final class CancellableDocumentConsumer {
    private final BlockingQueue<String> queue = new LinkedBlockingQueue<>();
    private final List<String> completed = new ArrayList<>();
    private final Thread thread = new Thread(this::consume, "document-consumer");

    public void start() {
        thread.start();
    }

    public void add(String value) {
        queue.add(value);
    }

    public void cancel() throws InterruptedException {
        thread.interrupt();
        thread.join();
    }

    private void consume() {
        try {
            while (!Thread.currentThread().isInterrupted()) {
                String value = queue.take();
                synchronized (completed) {
                    completed.add(value);
                }
                System.out.println("consumed=" + value);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public List<String> completed() {
        synchronized (completed) {
            return List.copyOf(completed);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        CancellableDocumentConsumer c = new CancellableDocumentConsumer();
        c.start();
        c.add("thread");
        while (c.completed().isEmpty()) {
            Thread.onSpinWait();
        }
        c.cancel();
        System.out.println("snapshot=" + c.completed());
    }
}

완료 목록을 락 아래 갱신하고 스냅샷으로 반환합니다. 취소가 join까지 수행하므로 호출 이후에는 새 완료가 추가되지 않습니다. 큐에 남은 입력은 별도 보상 처리 대상으로 볼 수 있습니다.

중첩 메서드의 interrupt 책임

가장 안쪽 블로킹 메서드가 정책을 모두 결정하면 재사용이 어렵습니다. 가능하면 InterruptedException을 올려 소유자 반복문이 종료·재시도·무시 중 하나를 택하게 합니다. Runnable.run처럼 검사 예외를 선언할 수 없는 최상위 접점에서 catch하고 최종 결과를 기록합니다.

InterruptedException을 RuntimeException으로 감쌀 때도 원 원인과 플래그 정책을 문서화합니다. 실행기 Futureinterrupt 요청과 작업 결과를 별도 API로 표현하므로 직접 Thread보다 선택지가 선명합니다.

연습 문제

작업자가 20ms마다 heartbeat 번호를 올리며 sleep하게 만드세요. main은 첫 heartbeat를 확인한 뒤 interrupt하고 join합니다. 작업자는 catch에서 플래그를 복원한 후 종료 이유를 기록해야 합니다.

정답과 경합 방지

래치로 첫 반복 진입을 확인해 interruptstart보다 앞서는 불확실성을 없앱니다. 출력 횟수 자체는 스케줄러에 따라 달라 규칙에 넣지 않습니다.

exercise/InterruptibleHeartbeatSolution.java
import java.util.concurrent.CountDownLatch;

public final class InterruptibleHeartbeatSolution {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch first = new CountDownLatch(1);
        int[] beats = {0};
        Thread worker =
                new Thread(
                        () -> {
                            try {
                                while (true) {
                                    beats[0]++;
                                    first.countDown();
                                    Thread.sleep(20);
                                }
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                                System.out.println(
                                        "reason=interrupt, flag="
                                                + Thread.currentThread().isInterrupted());
                            }
                        },
                        "heartbeat");
        worker.start();
        first.await();
        worker.interrupt();
        worker.join();
        System.out.println("terminated=" + !worker.isAlive() + ", beats=" + beats[0]);
    }
}

reason의 플래그는 true이고 terminated도 true입니다. beats는 1 이상이라는 조건만 안정적입니다.

interrupt를 올바르게 쓰려면 플래그를 지우는 연산, 블로킹 예외 경로, 복원할 계층, 남은 업무 정책을 함께 정해야 합니다. 단순히 interrupt() 한 줄을 호출하는 것으로 shutdown이 완성되지 않습니다.