본문 바로가기

Runnable과 데몬 스레드

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

Thread는 실행 자원이고 Runnable은 실행할 작업입니다.

두 책임을 나누면 같은 작업을 직접 호출해 개별 동작을 확인할 수도 있고 새 스레드나 실행기에서 실행할 수도 있습니다.

다만 호출 방식과 daemon 여부가 프로그램 종료 방식을 바꾸므로 생성 지점에서 의도를 드러내야 합니다.


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

JVM은 살아 있는 사용자 스레드가 하나도 없으면 daemon의 완료를 기다리지 않고 종료할 수 있습니다.

아래 프로그램의 실제 출력에는 main-exits-with-pending=true가 있지만 saved=report는 나오지 않습니다.

래치가 해제되지 않아 작업자는 저장 문장까지 도달할 수 없고, main이 끝나면 daemon만 남기 때문입니다.

여기서 CountDownLatch(1)은 카운트가 0이 될 때까지 await() 호출자를 기다리게 하는 문입니다. countDown() 한 번으로 열 수 있지만 이 실패 예제는 그 호출을 하지 않습니다. 따라서 출력 누락은 50ms 같은 타이밍 추측이 아니라, 저장 문장에 도달할 수 없는 대기 조건으로 확인합니다.

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 생존을 결정하지 않는 보조 작업에 적합합니다.

데몬 여부는 CPU 우선순위나 성공 여부가 아닙니다. 공개 Thread 생성자로 만든 플랫폼 스레드는 생성자의 호출 스레드에서 데몬 여부를 물려받습니다. 이 절의 main에서 만든 작업자는 기본적으로 사용자 스레드이지만, 데몬 작업자 안에서 새로 만들면 그 자식도 데몬일 수 있습니다. 필요한 정책은 start() 전에 setDaemon(false) 또는 setDaemon(true)로 정하세요. 살아 있는 스레드에 뒤늦게 설정하면 예외가 발생하며, 종료된 객체의 설정 동작에 의존하지 않습니다.

반드시 끝내야 하는 저장, 결제, 상태 전송을 daemon 완료에 맡기면 데이터가 사라질 수 있습니다.

중요한 작업은 완료를 명시적으로 회수합니다. 시작한 Thread는 join으로 기다리고, 실행기는 종료 요청 뒤 종료 대기나 결과 확인을 함께 수행합니다.

daemon의 finally도 실행을 보장받지 못합니다.


실행 정책으로 본 start·run

task.run()은 현재 호출자가 즉시 작업을 수행합니다.

new Thread(task).start()는 별도 실행 흐름에 작업을 배치합니다.

같은 Runnable이라도 두 호출의 지연, 예외 전달, 스레드-지역 문맥, 스택 추적이 다릅니다.

테스트 편의를 위해 run을 호출할 수 있지만 그것이 동시 실행 검증은 아닙니다.

새 스레드에서 발생한 비검사 예외는 start를 호출한 메서드의 try-catch로 돌아오지 않습니다.

해당 스레드의 미처리 예외 처리기가 받습니다.

반면 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());
    }
}

같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다

direct run 예외는 main catch, start 예외는 worker 처리기로 가고 join은 재던지지 않습니다. 두 호출 경로의 발생·전파·회수 장면이 유용합니다.

같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다

직접 호출의 예외는 main의 catch가 받고 새 스레드의 예외는 async-failure의 uncaught 처리기가 받습니다. join 뒤 main은 TERMINATED를 출력합니다.

예외 경로 · 직접 호출 / 새 스레드

같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다

직접 run의 예외는 main의 catch가 받고, 새 스레드의 예외는 그 스레드의 처리기가 받습니다.

호출 경로에 따라 갈리는 예외 수신 직접run의 예외는 main.catch, 새worker의 예외는 해당uncaught처리기에서 받습니다. join은 종료만 회수하고 예외를 main에 재던지지 않습니다. failure.run 직접 호출 main에서 예외 발생 invalid-task 같은 main의 catch direct-caught= invalid-task worker.start로 실행 새 worker에서 예외 async-failure worker의 처리기 handler=async-failure: invalid-task main의 join 정상 반환 TERMINATED 조회 작업자 예외를 재던지지 않음 실선: 예외가 전달되는 각 경로 종료 관찰과 업무 성공을 구별
호출 경로에 따라 갈리는 예외 수신 직접run의 예외는 main.catch, 새worker의 예외는 해당uncaught처리기에서 받습니다. join은 종료만 회수하고 예외를 main에 재던지지 않습니다. failure.run 직접 호출 main에서 예외 발생 invalid-task 같은 main의 catch direct-caught= invalid-task worker.start로 실행 새 worker에서 예외 async-failure worker의 처리기 handler=async-failure: invalid-task main의 join 정상 반환 TERMINATED 조회 작업자 예외를 재던지지 않음 실선: 예외가 전달되는 각 경로 종료 관찰과 업무 성공을 구별
  1. failure.run()

    예외나 결과를 받는 곳
    main의 catch
    실제 출력
    direct-caught=invalid-task
  2. worker.start()로 실행한 작업

    예외나 결과를 받는 곳
    async-failure의 uncaught 처리기
    실제 출력
    handler=async-failure:invalid-task
  3. worker.join() 뒤

    예외나 결과를 받는 곳
    main이 종료 상태 조회
    실제 출력
    worker-state=TERMINATED

join()은 작업자의 예외를 main에 다시 던지지 않습니다. 이 예제는 두 전달 경로를 처리하므로 정상 종료하며, 세 줄은 모두 표준 출력입니다.

이 예의 조건

  • join은 작업자 예외를 main에 재던지지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2직접 run의 예외는 main.catch, 새 worker의 예외는 해당 uncaught 처리기에서 받습니다. join은 종료만 회수하고 예외를 main에 재던지지 않습니다.
원 도표의 설명과 주석
구분내용
설명직접 run의 예외는 main.catch, 새 worker의 예외는 해당 uncaught 처리기에서 받습니다. join은 종료만 회수하고 예외를 main에 재던지지 않습니다.
전체 읽기 정보같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다 같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다 직접 호출의 예외는 main의 catch가 받고 새 스레드의 예외는 async-failure의 uncaught 처리기가 받습니다. join 뒤 main은 TERMINATED를 출력합니다. 예외 경로 · 직접 호출 / 새 스레드 같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다 직접 run의 예외는 main의 catch가 받고, 새 스레드의 예외는 그 스레드의 처리기가 받습니다. failure.run() 예외나 결과를 받는 곳 main 의 catch 실제 출력 direct-caught=invalid-task worker.start() 로 실행한 작업 예외나 결과를 받는 곳 async - failure 의 uncaught 처리기 실제 출력 handler=async-failure:invalid-task worker.join() 뒤 예외나 결과를 받는 곳 main 이 종료 상태 조회 실제 출력 worker-state=TERMINATED join() 은 작업자의 예외를 main 에 다시 던지지 않습니다. 이 예제는 두 전달 경로를 처리하므로 정상 종료하며, 세 줄은 모두 표준 출력입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
직접 호출

failure.run에서 main에 오류가 납니다.

  • 예외: invalid-task
  • 수신: main.catch
  • 일반 메서드 호출은 같은 실행 흐름으로 전파된다.
새 worker 실행

start가 새 흐름에서 실패를 만듭니다.

  • worker: async-failure
  • 수신: uncaught handler
  • 출력: handler=async-failure:invalid-task
  • 작업자 예외가 main 호출 스택에 직접 올라오지 않는다.
종료 회수

main이 worker.join 뒤 상태를 봅니다.

  • 상태: TERMINATED
  • main 재던짐: 없음
  • 종료 관찰과 업무 성공을 따로 판단해야 한다.

같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다

direct run 예외는 main catch, start 예외는 worker 처리기로 가고 join은 재던지지 않습니다. 두 호출 경로의 발생·전파·회수 장면이 유용합니다.

같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다

직접 호출의 예외는 main의 catch가 받고 새 스레드의 예외는 async-failure의 uncaught 처리기가 받습니다. join 뒤 main은 TERMINATED를 출력합니다.

예외 경로 · 직접 호출 / 새 스레드

같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다

직접 run의 예외는 main의 catch가 받고, 새 스레드의 예외는 그 스레드의 처리기가 받습니다.

호출 경로에 따라 갈리는 예외 수신 직접run의 예외는 main.catch, 새worker의 예외는 해당uncaught처리기에서 받습니다. join은 종료만 회수하고 예외를 main에 재던지지 않습니다. failure.run 직접 호출 main에서 예외 발생 invalid-task 같은 main의 catch direct-caught= invalid-task worker.start로 실행 새 worker에서 예외 async-failure worker의 처리기 handler=async-failure: invalid-task main의 join 정상 반환 TERMINATED 조회 작업자 예외를 재던지지 않음 실선: 예외가 전달되는 각 경로 종료 관찰과 업무 성공을 구별
호출 경로에 따라 갈리는 예외 수신 직접run의 예외는 main.catch, 새worker의 예외는 해당uncaught처리기에서 받습니다. join은 종료만 회수하고 예외를 main에 재던지지 않습니다. failure.run 직접 호출 main에서 예외 발생 invalid-task 같은 main의 catch direct-caught= invalid-task worker.start로 실행 새 worker에서 예외 async-failure worker의 처리기 handler=async-failure: invalid-task main의 join 정상 반환 TERMINATED 조회 작업자 예외를 재던지지 않음 실선: 예외가 전달되는 각 경로 종료 관찰과 업무 성공을 구별
  1. failure.run()

    예외나 결과를 받는 곳
    main의 catch
    실제 출력
    direct-caught=invalid-task
  2. worker.start()로 실행한 작업

    예외나 결과를 받는 곳
    async-failure의 uncaught 처리기
    실제 출력
    handler=async-failure:invalid-task
  3. worker.join() 뒤

    예외나 결과를 받는 곳
    main이 종료 상태 조회
    실제 출력
    worker-state=TERMINATED

join()은 작업자의 예외를 main에 다시 던지지 않습니다. 이 예제는 두 전달 경로를 처리하므로 정상 종료하며, 세 줄은 모두 표준 출력입니다.

이 예의 조건

  • join은 작업자 예외를 main에 재던지지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2직접 run의 예외는 main.catch, 새 worker의 예외는 해당 uncaught 처리기에서 받습니다. join은 종료만 회수하고 예외를 main에 재던지지 않습니다.
원 도표의 설명과 주석
구분내용
설명직접 run의 예외는 main.catch, 새 worker의 예외는 해당 uncaught 처리기에서 받습니다. join은 종료만 회수하고 예외를 main에 재던지지 않습니다.
전체 읽기 정보같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다 같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다 직접 호출의 예외는 main의 catch가 받고 새 스레드의 예외는 async-failure의 uncaught 처리기가 받습니다. join 뒤 main은 TERMINATED를 출력합니다. 예외 경로 · 직접 호출 / 새 스레드 같은 작업의 예외도 호출 경로에 따라 받는 곳이 다르다 직접 run의 예외는 main의 catch가 받고, 새 스레드의 예외는 그 스레드의 처리기가 받습니다. failure.run() 예외나 결과를 받는 곳 main 의 catch 실제 출력 direct-caught=invalid-task worker.start() 로 실행한 작업 예외나 결과를 받는 곳 async - failure 의 uncaught 처리기 실제 출력 handler=async-failure:invalid-task worker.join() 뒤 예외나 결과를 받는 곳 main 이 종료 상태 조회 실제 출력 worker-state=TERMINATED join() 은 작업자의 예외를 main 에 다시 던지지 않습니다. 이 예제는 두 전달 경로를 처리하므로 정상 종료하며, 세 줄은 모두 표준 출력입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
직접 호출

failure.run에서 main에 오류가 납니다.

  • 예외: invalid-task
  • 수신: main.catch
  • 일반 메서드 호출은 같은 실행 흐름으로 전파된다.
새 worker 실행

start가 새 흐름에서 실패를 만듭니다.

  • worker: async-failure
  • 수신: uncaught handler
  • 출력: handler=async-failure:invalid-task
  • 작업자 예외가 main 호출 스택에 직접 올라오지 않는다.
종료 회수

main이 worker.join 뒤 상태를 봅니다.

  • 상태: TERMINATED
  • main 재던짐: 없음
  • 종료 관찰과 업무 성공을 따로 판단해야 한다.

두 실패 모두 관찰되지만 전달 통로가 다릅니다.

worker.join()이 정상 반환해도 invalid-task 예외를 다시 던지지는 않습니다. 이 실행에서 TERMINATED는 성공 표시가 아니라 실패한 작업도 끝났다는 상태입니다. 처리기가 남긴 실패와 결과 객체를 함께 확인해야 완료를 성공으로 오해하지 않습니다.

반환값과 예외가 필요하면 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();
    }
}

데몬 상태와 완료 대기는 별도로 정한다

daemon 대기 중 main 종료와 별도 프리뷰 join 회수의 실제 두 진행입니다. 시작·완료 기다림·종료 결과를 분리하면 daemon과 성공의 차이가 명확합니다.

데몬 상태와 완료 대기는 별도로 정한다

background-save는 해제되지 않는 래치 뒤의 saved 출력에 도달하지 못합니다. index-preview도 daemon이지만 main이 join하므로 두 색인 로그까지 확인합니다.

DAEMON · 완료 회수

데몬 상태와 완료 대기는 별도로 정한다

daemon 여부는 JVM 생존 정책입니다. 필요한 작업의 완료는 join 같은 별도 대기로 확인합니다.

daemon 생존 정책과 별도 완료 회수 해제되지않는래치의 background-save와join으로기다리는index-preview를 별도프로그램영역에서 비교합니다. 콘솔색인두로그는 사용자join뒤 프리뷰join으로 회수됩니다. background-save daemon을 시작 해제 안 하는 래치 await main이 종료 완료를 기다리지 않음 main 종료 / saved 없음 index-preview 사용자 작업 join 먼저 그 뒤 daemon 프리뷰 시작 프리뷰도 join으로 회수 thread·memory 두 색인 로그 실선: 각 프로그램의 실행·회수 daemon은 생존 정책 / 완료와 별개 실제 파일 저장 예제가 아님
daemon 생존 정책과 별도 완료 회수 해제되지않는래치의 background-save와join으로기다리는index-preview를 별도프로그램영역에서 비교합니다. 콘솔색인두로그는 사용자join뒤 프리뷰join으로 회수됩니다. background-save daemon을 시작 해제 안 하는 래치 await main이 종료 완료를 기다리지 않음 main 종료 / saved 없음 index-preview 사용자 작업 join 먼저 그 뒤 daemon 프리뷰 시작 프리뷰도 join으로 회수 thread·memory 두 색인 로그 실선: 각 프로그램의 실행·회수 daemon은 생존 정책 / 완료와 별개 실제 파일 저장 예제가 아님

background-save

완료를 기다리는 코드
없음; 래치도 해제하지 않음
실제 출력의 보장 범위
main-exits-with-pending=true
saved=report는 출력되지 않음

index-preview

완료를 기다리는 코드
preview.start() 뒤 preview.join()
실제 출력의 보장 범위
index-preview indexed thread
index-preview indexed memory

두 예제 모두 실제 파일 쓰기 없이 메시지를 출력합니다. 색인 예제는 사용자 작업을 먼저 join()하고 프리뷰를 시작하므로 두 작업은 차례로 실행됩니다.

이 예의 조건

  • background-save/index-preview는 독립 프로그램이며 실제 파일 저장이 아니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2해제되지 않는 래치의 background-save와 join으로 기다리는 index-preview를 별도 프로그램 영역에서 비교합니다. 콘솔 색인의 두 로그는 사용자 join 뒤 프리뷰 join으로 회수됩니다.
8main 종료
9saved 없음
16daemon은 생존 정책
17완료와 별개
원 도표의 설명과 주석
구분내용
설명해제되지 않는 래치의 background-save와 join으로 기다리는 index-preview를 별도 프로그램 영역에서 비교합니다. 콘솔 색인의 두 로그는 사용자 join 뒤 프리뷰 join으로 회수됩니다.
전체 읽기 정보데몬 상태와 완료 대기는 별도로 정한다 데몬 상태와 완료 대기는 별도로 정한다 background-save는 해제되지 않는 래치 뒤의 saved 출력에 도달하지 못합니다. index-preview도 daemon이지만 main이 join하므로 두 색인 로그까지 확인합니다. DAEMON · 완료 회수 데몬 상태와 완료 대기는 별도로 정한다 daemon 여부는 JVM 생존 정책입니다. 필요한 작업의 완료는 join 같은 별도 대기로 확인합니다. background-save 완료를 기다리는 코드 없음; 래치도 해제하지 않음 실제 출력의 보장 범위 main-exits-with-pending=true saved=report 는 출력되지 않음 index-preview 완료를 기다리는 코드 preview.start() 뒤 preview.join() 실제 출력의 보장 범위 index-preview indexed thread index-preview indexed memory 두 예제 모두 실제 파일 쓰기 없이 메시지를 출력합니다. 색인 예제는 사용자 작업을 먼저 join() 하고 프리뷰를 시작하므로 두 작업은 차례로 실행됩니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
daemon 시작

background는 해제되지 않는 래치를 기다립니다.

  • 대기: await
  • saved 출력: 아직 없음
  • daemon 속성이 작업 완료를 보장하지 않는다.
소유자 정책

background main은 기다리지 않고 별도 index 예제는 사용자 작업을 join합니다.

  • background: main 종료
  • index: 사용자 작업 완료 회수
  • 프로세스 생존 정책과 완료 대기 정책은 다르다.
프리뷰 회수

index 예제는 daemon 프리뷰도 join합니다.

  • index 로그: thread,memory
  • background saved: 없음
  • daemon도 명시 join하면 해당 작업 완료를 기다린다.

데몬 상태와 완료 대기는 별도로 정한다

daemon 대기 중 main 종료와 별도 프리뷰 join 회수의 실제 두 진행입니다. 시작·완료 기다림·종료 결과를 분리하면 daemon과 성공의 차이가 명확합니다.

데몬 상태와 완료 대기는 별도로 정한다

background-save는 해제되지 않는 래치 뒤의 saved 출력에 도달하지 못합니다. index-preview도 daemon이지만 main이 join하므로 두 색인 로그까지 확인합니다.

DAEMON · 완료 회수

데몬 상태와 완료 대기는 별도로 정한다

daemon 여부는 JVM 생존 정책입니다. 필요한 작업의 완료는 join 같은 별도 대기로 확인합니다.

daemon 생존 정책과 별도 완료 회수 해제되지않는래치의 background-save와join으로기다리는index-preview를 별도프로그램영역에서 비교합니다. 콘솔색인두로그는 사용자join뒤 프리뷰join으로 회수됩니다. background-save daemon을 시작 해제 안 하는 래치 await main이 종료 완료를 기다리지 않음 main 종료 / saved 없음 index-preview 사용자 작업 join 먼저 그 뒤 daemon 프리뷰 시작 프리뷰도 join으로 회수 thread·memory 두 색인 로그 실선: 각 프로그램의 실행·회수 daemon은 생존 정책 / 완료와 별개 실제 파일 저장 예제가 아님
daemon 생존 정책과 별도 완료 회수 해제되지않는래치의 background-save와join으로기다리는index-preview를 별도프로그램영역에서 비교합니다. 콘솔색인두로그는 사용자join뒤 프리뷰join으로 회수됩니다. background-save daemon을 시작 해제 안 하는 래치 await main이 종료 완료를 기다리지 않음 main 종료 / saved 없음 index-preview 사용자 작업 join 먼저 그 뒤 daemon 프리뷰 시작 프리뷰도 join으로 회수 thread·memory 두 색인 로그 실선: 각 프로그램의 실행·회수 daemon은 생존 정책 / 완료와 별개 실제 파일 저장 예제가 아님

background-save

완료를 기다리는 코드
없음; 래치도 해제하지 않음
실제 출력의 보장 범위
main-exits-with-pending=true
saved=report는 출력되지 않음

index-preview

완료를 기다리는 코드
preview.start() 뒤 preview.join()
실제 출력의 보장 범위
index-preview indexed thread
index-preview indexed memory

두 예제 모두 실제 파일 쓰기 없이 메시지를 출력합니다. 색인 예제는 사용자 작업을 먼저 join()하고 프리뷰를 시작하므로 두 작업은 차례로 실행됩니다.

이 예의 조건

  • background-save/index-preview는 독립 프로그램이며 실제 파일 저장이 아니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2해제되지 않는 래치의 background-save와 join으로 기다리는 index-preview를 별도 프로그램 영역에서 비교합니다. 콘솔 색인의 두 로그는 사용자 join 뒤 프리뷰 join으로 회수됩니다.
8main 종료
9saved 없음
16daemon은 생존 정책
17완료와 별개
원 도표의 설명과 주석
구분내용
설명해제되지 않는 래치의 background-save와 join으로 기다리는 index-preview를 별도 프로그램 영역에서 비교합니다. 콘솔 색인의 두 로그는 사용자 join 뒤 프리뷰 join으로 회수됩니다.
전체 읽기 정보데몬 상태와 완료 대기는 별도로 정한다 데몬 상태와 완료 대기는 별도로 정한다 background-save는 해제되지 않는 래치 뒤의 saved 출력에 도달하지 못합니다. index-preview도 daemon이지만 main이 join하므로 두 색인 로그까지 확인합니다. DAEMON · 완료 회수 데몬 상태와 완료 대기는 별도로 정한다 daemon 여부는 JVM 생존 정책입니다. 필요한 작업의 완료는 join 같은 별도 대기로 확인합니다. background-save 완료를 기다리는 코드 없음; 래치도 해제하지 않음 실제 출력의 보장 범위 main-exits-with-pending=true saved=report 는 출력되지 않음 index-preview 완료를 기다리는 코드 preview.start() 뒤 preview.join() 실제 출력의 보장 범위 index-preview indexed thread index-preview indexed memory 두 예제 모두 실제 파일 쓰기 없이 메시지를 출력합니다. 색인 예제는 사용자 작업을 먼저 join() 하고 프리뷰를 시작하므로 두 작업은 차례로 실행됩니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
daemon 시작

background는 해제되지 않는 래치를 기다립니다.

  • 대기: await
  • saved 출력: 아직 없음
  • daemon 속성이 작업 완료를 보장하지 않는다.
소유자 정책

background main은 기다리지 않고 별도 index 예제는 사용자 작업을 join합니다.

  • background: main 종료
  • index: 사용자 작업 완료 회수
  • 프로세스 생존 정책과 완료 대기 정책은 다르다.
프리뷰 회수

index 예제는 daemon 프리뷰도 join합니다.

  • index 로그: thread,memory
  • background saved: 없음
  • daemon도 명시 join하면 해당 작업 완료를 기다린다.

여기서는 불변 String 값으로 구성한 목록을 List.copyOf로 고정하고 두 실행이 같은 스냅샷을 차례로 읽습니다.

프리뷰도 결과를 보기 위해 join했으므로 daemon 설정이 출력 누락을 일으키지 않습니다.

join()을 호출한 main은 여전히 살아 있는 사용자 스레드입니다. 따라서 그 대기 동안에는 데몬만 남아서 자연 종료되는 조건이 성립하지 않습니다. 다만 join이 작업의 대기 원인을 해제하거나 저장 성공을 만들어 주지는 않습니다. 종료를 확인한 다음 성공 결과를 확인하는 책임은 별도입니다.

daemon 여부와 join 여부는 독립된 선택입니다.

DocumentIndexTask의 같은 작업 객체는 불변 목록을 보관하고 두 Thread에서 차례로 실행합니다. 작업 객체를 다시 전달하는 것과 종료된 Thread 인스턴스를 다시 start하는 것은 다릅니다. 여기서 프리뷰가 같은 입력을 다시 읽는 것은 허용되지만, 앞 절 oneShot 객체의 두 번째 start는 여전히 거부됩니다. 두 실행을 동시에 바꿀 경우에도 참조가 가리키는 상태가 불변인지부터 확인합니다.


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

여러 실행 흐름의 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-complete는 join이 만드는 순서입니다.

관찰 도구와 업무 동기화를 같은 것으로 보지 않습니다.


다중 스레드의 종료 책임

반복문에서 작업자를 생성한 뒤 참조를 버리면 join, interrupt, 상태 확인을 할 통로가 사라집니다.

Thread 배열이나 컬렉션에 보관하고 성공·실패·취소를 회수합니다.

중간 생성 실패가 있을 때 이미 시작한 스레드를 어떻게 중단할지도 정해야 합니다.

사용자 스레드는 완료될 때까지 JVM을 살려 두지만 그것이 정상 shutdown은 아닙니다.

non-daemon 작업자가 무한 대기하면 프로세스도 끝나지 않습니다.

daemon으로 바꿔 증상을 숨기지 말고 대기 원인과 종료 프로토콜을 구현합니다.


Thread 상속과 Runnable 분리

새 실행 흐름 자체를 특수화하고 Thread API를 override해야 하는 드문 경우에는 상속을 검토합니다.

일반 업무는 Runnable로 두어 도메인 상태와 실행 정책을 분리합니다.

반환값, 예외, 취소가 필요하면 Callable·Future 또는 구조화된 상위 추상화가 더 맞습니다.


연습 문제

저장 작업은 사용자 스레드, 통계 작업은 daemon으로 만들고 저장 완료만 반드시 기다리세요.

두 작업 모두 이름이 들어간 로그를 남기되 main 종료 문장은 저장 뒤에 나와야 합니다.

확인해야 할 순서는 필수 저장 로그 → main 종료 문장 하나입니다. 통계 로그가 없는 실행도 조건을 만족할 수 있습니다. 통계 로그까지 항상 요구하면 선택한 daemon 정책과 검사가 서로 모순됩니다. 실제 저장 여부와 이 예제의 콘솔 메시지도 구별해 설명하세요.

정답과 종료 방식

이 예제에서는 저장 작업자만 join해 필수 로그가 끝난 뒤 main의 완료 로그를 출력합니다.

통계는 짧은 예제에서 결과를 기대하지 않으며 실제 서비스에서는 별도 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 생존 규칙, 이름과 종료 회수까지 함께 설계해야 분리가 실제 품질로 이어집니다.

연습: 데몬 저장을 기다리는 순서 만들기

실제 파일 I/O 대신 저장을 나타내는 콘솔 한 줄을 사용합니다. 래치 카운트는 1이고, 다른 인터럽트나 작업 예외는 없다고 가정합니다. main은 해제 로그 → countDown() → join() → 종료 관찰 로그 순서로 실행합니다.

  1. 아래 코드에서 반드시 관찰할 수 있는 세 로그의 순서를 구하세요.
  2. countDown()을 join() 뒤로 옮기면 데몬이라서 자동으로 대기를 끝내 주나요? main과 작업자가 각각 무엇을 기다리는지 설명하세요.
  3. TERMINATED만 검사하면 실제 파일 저장의 성공도 검증했다고 할 수 있나요?
exercise/DaemonGateJoinSolution.java
import java.util.concurrent.CountDownLatch;

public final class DaemonGateJoinSolution {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch release = new CountDownLatch(1);
        Thread saver = new Thread(() -> {
            try {
                release.await();
                System.out.println(Thread.currentThread().getName() + " saved=document");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "gate-save");
        saver.setDaemon(true);
        saver.start();
        System.out.println("main releases save");
        release.countDown();
        saver.join();
        System.out.println("main observed termination");
    }
}
대기 조건과 종료 관찰 풀이
  1. main releases save → gate-save saved=document → main observed termination입니다. 카운트가 0이 되기 전에 작업자는 저장 로그를 실행할 수 없고, 마지막 로그는 작업자 종료를 기다린 뒤입니다. sleep이나 특정 스케줄러 순서를 전제로 하지 않습니다.
  2. 작업자는 래치가 열리기를, main은 그 작업자가 끝나기를 기다립니다. 해제할 코드가 join 뒤에 있어 둘 다 진행할 수 없습니다. 살아 있는 사용자 스레드 main이 남아 있으므로 데몬이라는 이유로 JVM이 정상 종료하며 문제를 풀어 주지도 않습니다.
  3. 종료 상태만으로는 성공을 확인할 수 없습니다. 이 코드는 파일을 저장하지 않고 콘솔 메시지만 출력합니다. 실제 업무에서는 저장 결과·예외·취소를 따로 회수해야 합니다.