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

안동민 개발노트

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

Runnable과 데몬 스레드

`start`와 `run`의 호출 차이를 재확인하고 Runnable, 스레드 이름 로그, `daemon` 종료 방식을 함께 설계합니다.

Thread는 실행 자원이고 Runnable은 실행할 작업입니다. 두 책임을 나누면 같은 작업을 직접 호출해 단위 확인할 수도 있고 새 스레드나 실행기에서 실행할 수도 있습니다. 다만 호출 방식과 daemon 여부가 프로그램 종료 방식을 바꾸므로 생성 지점에서 의도를 드러내야 합니다.

데몬 스레드의 파일 저장 보장 오해

JVM은 살아 있는 사용자 스레드가 하나도 없으면 daemon의 완료를 기다리지 않고 종료할 수 있습니다. 아래 프로그램의 실제 출력에는 main-exits-with-pending=true가 있지만 saved=report는 나오지 않습니다. 작업자가 래치에서 기다리는 사이 main이 끝나고 daemon만 남기 때문입니다.

lab/DaemonCompletionAssumption.java
import java.util.concurrent.CountDownLatch;

public final class DaemonCompletionAssumption {
    public static void main(String[] args) {
        CountDownLatch neverReleased = new CountDownLatch(1);
        Thread saver =
                new Thread(
                        () -> {
                            try {
                                neverReleased.await();
                                System.out.println("saved=report");
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                            }
                        },
                        "background-save");
        saver.setDaemon(true);
        saver.start();
        System.out.println("main-exits-with-pending=" + saver.isAlive());
    }
}

daemon은 JVM 생존을 결정하지 않는 보조 작업에 적합합니다. 반드시 끝내야 하는 저장, 결제, 상태 전송을 daemon 완료에 맡기면 데이터가 사라질 수 있습니다. 중요한 작업은 사용자 스레드로 실행하고 join·실행기 shutdown 같은 명시적 완료 구분을 둡니다. daemonfinally도 실행을 보장받지 못합니다.

실행 정책으로 본 start·run

task.run()은 현재 호출자가 즉시 작업을 수행합니다. new Thread(task).start()는 별도 실행 흐름에 작업을 배치합니다. 같은 Runnable이라도 두 호출의 지연, 예외 전달, 스레드-지역 문맥, 스택 추적이 다릅니다. 테스트 편의를 위해 run을 호출할 수 있지만 그것이 동시 실행 검증은 아닙니다.

새 스레드에서 발생한 비검사 예외는 start를 호출한 메서드의 try-catch로 돌아오지 않습니다. 해당 스레드의 uncaught 예외 처리기가 받습니다. 반면 run 직접 호출은 같은 호출 스택이므로 호출자 catch가 잡습니다. 운영에서는 예외가 로그만 남고 사라지지 않도록 완료 결과를 수집하거나 처리기 정책을 둡니다.

src/RunAndStartExceptionRoute.java
public final class RunAndStartExceptionRoute {
    public static void main(String[] args) throws InterruptedException {
        Runnable failure =
                () -> {
                    throw new IllegalArgumentException("invalid-task");
                };
        try {
            failure.run();
        } catch (IllegalArgumentException e) {
            System.out.println("direct-caught=" + e.getMessage());
        }
        Thread worker = new Thread(failure, "async-failure");
        worker.setUncaughtExceptionHandler(
                (thread, error) ->
                        System.out.println(
                                "handler=" + thread.getName() + ":" + error.getMessage()));
        worker.start();
        worker.join();
        System.out.println("worker-state=" + worker.getState());
    }
}

두 실패 모두 관찰되지만 전달 통로가 다릅니다. join()은 종료를 기다릴 뿐 작업자의 예외를 main에 다시 던지지 않습니다. 반환값과 예외가 필요하면 Future 같은 결과 추상화를 선택합니다.

Runnable로 업무와 스레드 설정 분리

Thread 상속에서는 업무 객체가 이름, daemon, 우선순위 같은 실행 속성까지 품기 쉽습니다. Runnable은 run() 하나의 작업 규칙이라 같은 인스턴스를 여러 실행 정책에 붙일 수 있습니다. 단, 같은 가변 Runnable을 여러 스레드가 동시에 실행하면 그 필드는 공유된다는 점은 달라지지 않습니다.

src/DocumentIndexTask.java
import java.util.List;

public final class DocumentIndexTask implements Runnable {
    private final List<String> documents;

    public DocumentIndexTask(List<String> documents) {
        this.documents = List.copyOf(documents);
    }

    @Override
    public void run() {
        for (String document : documents)
            System.out.println(Thread.currentThread().getName() + " indexed " + document);
    }

    public static void main(String[] args) throws InterruptedException {
        Runnable task = new DocumentIndexTask(List.of("thread", "memory"));
        Thread user = new Thread(task, "index-user");
        user.start();
        user.join();
        Thread preview = new Thread(task, "index-preview");
        preview.setDaemon(true);
        preview.start();
        preview.join();
    }
}

여기서는 입력을 List.copyOf로 고정해 여러 실행이 같은 불변 스냅샷을 읽습니다. 프리뷰도 결과를 보기 위해 join했으므로 daemon 설정이 출력 누락을 일으키지 않습니다. daemon 여부와 join 여부는 독립된 선택입니다.

동시 로그의 스레드 식별 정보

여러 실행 흐름의 System.out 문장은 서로 섞일 수 있습니다. 스레드 이름과 사건명을 한 줄로 출력하면 원인을 따라갈 수 있습니다. 여러 출력 호출로 한 논리 문장을 만들면 다른 스레드가 중간에 끼어들 수 있으므로 완성 문자열을 한 번에 전달합니다.

간단한 로거는 교육용으로 충분하지만 제품에서는 구조화 필드, 수준, 예외 스택, 비동기 큐 포화 정책을 지원하는 로깅 프레임워크를 씁니다. 시간만 보고 순서를 단정하지 않습니다. clock 해상도와 버퍼 때문에 같은 시각 또는 기록 지연이 생길 수 있습니다.

app/ThreadEventLog.java
import java.time.Instant;
import java.util.List;

public final class ThreadEventLog {
    private static synchronized void log(String event, String value) {
        System.out.printf(
                "%s [%s] %s=%s%n", Instant.now(), Thread.currentThread().getName(), event, value);
    }

    public static void main(String[] args) throws InterruptedException {
        List<String> jobs = List.of("parse", "index", "publish");
        Thread[] workers = new Thread[jobs.size()];
        for (int i = 0; i < jobs.size(); i++) {
            String job = jobs.get(i);
            workers[i] =
                    new Thread(
                            () -> {
                                log("begin", job);
                                log("complete", job);
                            },
                            "pipeline-" + (i + 1));
            workers[i].start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
        log("all-complete", Integer.toString(workers.length));
    }
}

로거 메서드의 synchronized는 한 이벤트 줄이 섞이지 않게 할 뿐 업무 순서를 고정하지 않습니다. 마지막 all-완료는 join이 만드는 순서입니다. 관찰 도구와 업무 동기화를 같은 것으로 보지 않습니다.

다중 스레드의 종료 책임

반복문에서 작업자를 생성한 뒤 참조를 버리면 join, interrupt, 상태 확인을 할 통로가 사라집니다. Thread 배열이나 컬렉션에 보관하고 성공·실패·취소를 회수합니다. 중간 생성 실패가 있을 때 이미 시작한 스레드를 어떻게 중단할지도 정해야 합니다.

사용자 스레드는 완료될 때까지 JVM을 살려 두지만 그것이 정상 shutdown은 아닙니다. non-daemon 작업자가 무한 대기하면 프로세스도 끝나지 않습니다. daemon으로 바꿔 증상을 숨기지 말고 대기 원인과 종료 프로토콜을 구현합니다.

Thread 상속과 Runnable 분리

새 실행 흐름 자체를 특수화하고 Thread API를 override해야 하는 드문 경우에는 상속을 검토합니다. 일반 업무는 Runnable로 두어 도메인 상태와 실행 정책을 분리합니다. 반환값, 예외, 취소가 필요하면 Callable·Future 또는 구조화된 상위 추상화가 더 맞습니다.

연습 문제

저장 작업은 사용자 스레드, 통계 작업은 daemon으로 만들고 저장 완료만 반드시 기다리세요. 두 작업 모두 이름이 들어간 로그를 남기되 main 종료 문장은 저장 뒤에 나와야 합니다.

정답과 종료 방식

저장 작업자만 join하면 필수 결과가 보장됩니다. 통계는 짧은 예제에서 결과를 기대하지 않으며 실제 서비스에서는 별도 flush 정책을 둡니다.

exercise/UserAndDaemonPolicySolution.java
public final class UserAndDaemonPolicySolution {
    public static void main(String[] args) throws InterruptedException {
        Thread save =
                new Thread(
                        () ->
                                System.out.println(
                                        Thread.currentThread().getName() + " stored=document"),
                        "save-required");
        Thread metric =
                new Thread(
                        () -> {
                            try {
                                Thread.sleep(50);
                                System.out.println(Thread.currentThread().getName() + " metric=1");
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                            }
                        },
                        "metric-optional");
        metric.setDaemon(true);
        save.start();
        metric.start();
        save.join();
        System.out.println("required-save-complete");
    }
}

지표 출력은 나타날 수도 생략될 수도 있으므로 기능 검증 대상에 포함하지 않습니다. 필수 작업을 나타내는 문장은 항상 main 종료 문장보다 먼저 나옵니다.

Runnable 분리의 가치는 문법 길이가 아니라 실행 정책을 바꿔도 업무 코드를 유지하는 데 있습니다. start·run 예외 경로, daemon 생존 규칙, 이름과 종료 회수까지 함께 설계해야 분리가 실제 품질로 이어집니다.