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

안동민 개발노트

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

스레드 정보와 생명 주기

id·이름·우선순위·그룹을 관찰하고 NEW부터 TERMINATED까지 상태 전이를 제어된 사건으로 재현합니다.

Thread 객체는 작업뿐 아니라 식별 정보와 현재 생명 주기 정보를 제공합니다. 이 값은 관찰과 진단에 유용하지만 업무 순서를 보장하는 제어 장치로 쓰면 안 됩니다. 상태 이름이 어떤 대기 원인을 뜻하는지 실제 사건과 연결해야 스레드 덤프도 읽을 수 있습니다.

run 재정의의 검사 예외 제한

Runnable.run()Thread.run()은 검사 예외를 선언하지 않습니다. override 메서드는 부모보다 넓은 검사 예외를 새로 던질 수 없으므로 아래 코드는 실제 컴파일 오류입니다.

lab/CheckedExceptionRunFailure.java
public final class CheckedExceptionRunFailure extends Thread {
    @Override
    public void run() throws InterruptedException {
        Thread.sleep(10);
    }
}

스레드 접점에서는 InterruptedExceptioncatch해 중단 정책을 수행해야 합니다. 단순히 RuntimeException으로 감싸면 협력적 취소 의미를 잃을 수 있습니다. 복구할 수 없으면 상태를 정리하고 interrupt 플래그를 복원하거나 결과 추상화로 실패를 전달합니다.

스레드 ID·이름과 업무 키

threadId()는 JVM 실행 중 고유한 양수이며 재실행 후에도 같은 값을 유지하는 영구 식별자가 아닙니다. 이름은 생성자가 지정하지 않으면 Thread-N 계열 기본값을 가집니다. 운영 로그에는 역할이 드러나는 이름을 부여하되 주문 번호 같은 업무 키는 별도 구조화 필드로 남깁니다.

src/ThreadMetadataReport.java
public final class ThreadMetadataReport {
    public static void main(String[] args) throws InterruptedException {
        ThreadGroup group = new ThreadGroup("import-workers");
        Thread worker = new Thread(group, () -> report("inside"), "import-csv");
        report("main-before");
        System.out.println(
                "new-state=" + worker.getState() + ", group=" + worker.getThreadGroup().getName());
        worker.start();
        worker.join();
        System.out.println("end-state=" + worker.getState() + ", alive=" + worker.isAlive());
    }

    private static void report(String point) {
        Thread t = Thread.currentThread();
        System.out.println(
                point
                        + " id="
                        + t.threadId()
                        + ", name="
                        + t.getName()
                        + ", priority="
                        + t.getPriority()
                        + ", daemon="
                        + t.isDaemon());
    }
}

NEW 상태에서도 id와 이름을 조회할 수 있고 join 후에는 TERMINATED와 alive=false가 보입니다. 종료된 Thread의 그룹 참조는 null이 될 수 있으므로 그룹은 시작 전에 확인했습니다. ThreadGroup은 오래된 그룹화 API이고 새 설계의 자원 관리 수단으로는 실행기와 명시적 소유자를 우선합니다.

스레드 상태와 대기 원인

RUNNABLE은 CPU에서 실제 명령을 수행하는 순간뿐 아니라 실행 가능 큐에서 차례를 기다리는 상태도 포함합니다. BLOCKED는 synchronized 모니터 진입 대기입니다. WAITING은 시간 제한 없이 다른 사건을, TIMED_WAITING은 제한 시간과 함께 기다립니다.

아래 프로그램은 래치로 WAITING, sleep으로 TIMED_WAITING, main이 가진 모니터 때문에 BLOCKED를 각각 만듭니다. 상태 조회 자체는 순간 스냅샷이므로 래치와 모니터를 이용해 관찰 구간을 유지합니다.

src/ControlledThreadStates.java
import java.util.concurrent.CountDownLatch;

public final class ControlledThreadStates {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch waitingGate = new CountDownLatch(1);
        Object monitor = new Object();
        Thread waiting = new Thread(() -> await(waitingGate), "state-waiting");
        Thread timed =
                new Thread(
                        () -> {
                            try {
                                Thread.sleep(500);
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                            }
                        },
                        "state-timed");
        Thread blocked =
                new Thread(
                        () -> {
                            synchronized (monitor) {
                                System.out.println("blocked-entered");
                            }
                        },
                        "state-blocked");
        waiting.start();
        timed.start();
        synchronized (monitor) {
            blocked.start();
            waitFor(waiting, Thread.State.WAITING);
            waitFor(timed, Thread.State.TIMED_WAITING);
            waitFor(blocked, Thread.State.BLOCKED);
            System.out.println(
                    waiting.getState() + "," + timed.getState() + "," + blocked.getState());
        }
        waitingGate.countDown();
        waiting.join();
        timed.join();
        blocked.join();
    }

    private static void await(CountDownLatch gate) {
        try {
            gate.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private static void waitFor(Thread t, Thread.State target) throws InterruptedException {
        for (int i = 0; i < 200 && t.getState() != target; i++) {
            Thread.sleep(1);
        }
        if (t.getState() != target)
            throw new IllegalStateException(t.getName() + "=" + t.getState());
    }
}

모니터가 풀리면 차단된 스레드는 RUNNABLE로 돌아가 임계 영역을 실행합니다. 래치의 count가 0이 되면 대기자도 진행합니다. 시간 제한은 시간이 지나거나 interrupt를 받으면 깨어납니다. 상태를 변경하는 원인은 상태 조회가 아니라 사건의 완료입니다.

스레드 생명 주기의 비가역성

Thread는 NEW에서 start 호출로 RUNNABLE이 되고, 실행 중 여러 대기 상태를 오갈 수 있습니다. run()이 정상 반환하거나 처리되지 않은 예외로 끝나면 TERMINATED입니다. TERMINATED 객체를 다시 start할 수 없습니다.

isAlive()start 이후 종료 전까지 true지만 작업이 유용하게 진행 중이라는 뜻은 아닙니다. 락 교착 상태나 무한 WAITING도 생존 상태입니다. 상태 확인에는 큐 진척, 마지막 완료 시각, 시간 제한 같은 애플리케이션 신호를 함께 사용합니다.

처리되지 않은 예외로 끝난 스레드도 TERMINATED입니다. getState()만 보면 성공·실패를 구별할 수 없으므로 처리기나 Future 결과로 원인을 기록합니다.

app/LifecycleOutcomeRegistry.java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public final class LifecycleOutcomeRegistry {
    public static void main(String[] args) throws InterruptedException {
        Map<String, String> outcomes = new ConcurrentHashMap<>();
        Thread good = worker("validate", () -> outcomes.put("validate", "ok"), outcomes);
        Thread bad =
                worker(
                        "publish",
                        () -> {
                            throw new IllegalStateException("offline");
                        },
                        outcomes);
        good.start();
        bad.start();
        good.join();
        bad.join();
        System.out.println("states=" + good.getState() + "," + bad.getState());
        System.out.println("outcomes=" + outcomes);
    }

    private static Thread worker(String name, Runnable task, Map<String, String> outcomes) {
        Thread t = new Thread(task, "job-" + name);
        t.setUncaughtExceptionHandler(
                (thread, error) -> outcomes.put(name, "failed:" + error.getMessage()));
        return t;
    }
}

두 상태는 모두 TERMINATED지만 결과는 다릅니다. ConcurrentHashMap은 처리기와 작업자가 서로 다른 스레드에서 쓰는 저장소라 선택했습니다.

priority·ThreadGroup의 한계

우선순위 범위는 1부터 10이고 기본은 5입니다. 값은 스케줄러 힌트이며 높은 우선순위가 먼저 시작하거나 먼저 끝난다는 이식 가능한 보장은 없습니다. 기아를 막는 업무 정책은 공정 큐, 세마포어, 비율 제한기처럼 의미 있는 제어 구조로 표현합니다.

ThreadGroup은 이름 묶음과 일부 일괄 작업을 제공하지만 구조화된 소유권과 결과 수집이 약합니다. 새 코드에서는 스레드 팩터리가 이름·daemon·처리기를 일관되게 설정하고 실행기가 shutdown을 소유하게 합니다. 현재 스레드 그룹을 보안 수단으로 보지 않습니다.

연습 문제

래치를 기다리는 작업자를 만들고 start 전, 대기 중, 게이트 해제와 join 후의 상태를 출력하세요. sleep만으로 WAITING 진입을 추측하지 말고 폴링에 상한을 두세요.

정답과 관찰 한계

래치 대기는 시간 제한이 없으므로 WAITING입니다. 폴링은 테스트가 무한히 멈추지 않도록 1초 제한을 둡니다.

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

public final class ThreeLifecycleSnapshotsSolution {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch gate = new CountDownLatch(1);
        Thread worker =
                new Thread(
                        () -> {
                            try {
                                gate.await();
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                            }
                        },
                        "snapshot-worker");
        System.out.println("before=" + worker.getState());
        worker.start();
        long deadline = System.nanoTime() + 1_000_000_000L;
        while (worker.getState() != Thread.State.WAITING && System.nanoTime() < deadline)
            Thread.onSpinWait();
        System.out.println("during=" + worker.getState());
        gate.countDown();
        worker.join();
        System.out.println("after=" + worker.getState());
    }
}

정상 결과는 NEW, WAITING, TERMINATED입니다. 실제 운영 로직이 상태 폴링에 의존해서는 안 되며 래치 자체가 조정 규칙입니다.

Thread 상태는 원인을 찾는 관찰 vocabulary입니다. 식별 정보와 결과를 별도로 남기고, 검사 예외 구분을 처리하며, 우선순위가 아니라 명시적 동기화로 순서를 정해야 합니다.