본문 바로가기

Runnable과 데몬 스레드

할 일을 Runnable로 떼어 Thread와 분리하는 방법, run()과 start()로 실행할 때 예외가 전달되는 곳의 차이, JVM이 데몬 스레드를 기다리지 않고 종료하는 규칙, 여러 스레드의 로그를 읽기 쉽게 남기는 방법을 배웁니다.

앞 글에서는 Thread를 상속하고 run()을 재정의해 스레드가 할 일을 정했습니다. 이 방법은 할 일과 그 일을 실행할 스레드를 한 클래스에 묶어 버립니다. 구간 합 계산은 새 스레드에서만 해야 하는 일이 아닌데도 RangeSumThread를 쓰려면 매번 스레드 객체를 만들어야 했던 것이 그 때문입니다. 이 글에서는 할 일을 Runnable로 떼어 내, 무엇을 할지와 어느 스레드에서 실행할지를 따로 정하는 방법부터 살펴봅니다.

할 일을 떼어 내면 같은 작업을 여러 방식으로 실행할 수 있지만, 실행 방식에 따라 달라지는 것도 생깁니다. 같은 작업에서 난 예외라도 run()으로 직접 실행했는지 start()로 새 스레드에서 실행했는지에 따라 받는 곳이 다릅니다. 또 스레드를 데몬으로 만들면 JVM이 그 스레드가 끝나기를 기다리지 않고 종료할 수 있어서, 중요한 작업이 아무 흔적 없이 사라지기도 합니다. 글의 뒷부분에서는 시작한 스레드를 끝까지 챙기는 방법과 여러 스레드가 함께 남기는 로그를 읽기 쉽게 만드는 방법을 정리합니다.

할 일을 Runnable로 떼어 내기

Runnable은 추상 메서드 run() 하나만 가진 인터페이스입니다. 스레드에게 시킬 일을 Runnable 구현 클래스의 run()에 적고, 그 객체를 new Thread(task, name)처럼 Thread 생성자에 넘기면 됩니다. 새 스레드가 시작되면 Thread가 넘겨받은 작업의 run()을 호출합니다. 프로세스와 스레드에서 람다식으로 만든 Runnable을 Thread에 넘긴 것도 같은 방식입니다.

다음 DocumentIndexTask는 문서 이름 목록을 받아 하나씩 색인하는 작업입니다. 실제로 색인을 만들지는 않고 어느 스레드가 어떤 문서를 처리했는지만 출력하며, Thread가 아니라 Runnable을 구현하므로 클래스 안에 스레드를 다루는 코드가 없습니다. main은 이 작업 객체 하나를 두 Thread에 차례로 넘깁니다. 먼저 index-user 스레드로 실행해 끝나기를 기다리고, 그다음 index-preview 스레드로 한 번 더 실행합니다. preview.setDaemon(true)는 이 스레드를 데몬 스레드로 만드는 줄인데, 그 뜻은 뒤에서 살펴봅니다.

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();
    }
}
실행 결과
index-user indexed thread
index-user indexed memory
index-preview indexed thread
index-preview indexed memory

이 출력은 몇 번을 실행해도 같습니다. 두 스레드가 동시에 실행되는 구간이 없기 때문입니다. main은 user.join()으로 첫 스레드가 끝나기를 기다린 뒤에야 두 번째 스레드를 시작하고, 각 스레드 안에서는 목록 순서대로 출력합니다. 같은 작업 객체를 실행했는데도 줄마다 스레드 이름이 다른 것은 Thread.currentThread()가 그 순간 작업을 실행하고 있는 스레드를 돌려주기 때문입니다.

앞 글에서 끝난 Thread 객체는 다시 시작할 수 없다고 했는데, 여기서 다시 쓴 것은 Thread가 아니라 작업 객체입니다. 스레드는 user와 preview 두 개를 따로 만들었고 각각 한 번씩만 시작했습니다. 할 일과 스레드를 분리했기 때문에 작업은 몇 번이든 새 스레드에 다시 넘길 수 있고, 나중에 배울 실행기에게 맡길 때도 이 객체를 그대로 넘기면 됩니다. 테스트에서는 task.run()을 직접 호출해 스레드 없이 작업의 동작을 확인할 수도 있습니다. 다만 이렇게 확인하는 것은 작업 자체의 동작이지, 여러 스레드에서 함께 실행될 때의 동작은 아닙니다.

작업 객체 하나를 여러 스레드에 넘겨도 괜찮은 것은 이 객체가 바뀌지 않기 때문입니다. 생성자는 받은 목록을 List.copyOf로 변경할 수 없는 목록으로 복사해 두고, run()은 그 목록을 읽기만 합니다. 반대로 작업 객체에 값이 바뀌는 필드가 있다면, 그 객체를 넘겨받은 스레드들은 모두 같은 필드를 함께 쓰게 됩니다. 이 예제를 두 스레드가 동시에 실행하도록 바꾸면 출력 순서는 섞이겠지만 작업 객체의 상태가 망가질 일은 없는데, 함께 쓰는 것이 바뀌지 않는 목록뿐이기 때문입니다. 바뀌는 필드를 가진 작업 객체를 여러 스레드가 함께 쓸 때 생기는 문제는 다음 글에서 직접 확인합니다.

run()으로 실행할 때와 start()로 실행할 때 예외가 가는 곳

같은 작업이라도 실행 방식에 따라 예외가 전달되는 곳이 달라집니다. 다음 프로그램의 failure는 실행하자마자 IllegalArgumentException을 던지는 작업입니다. main은 먼저 이 작업의 run()을 직접 호출하면서 try-catch로 감쌉니다. 다음에는 같은 작업을 async-failure 스레드에 넘겨 start()로 실행하는데, 이 스레드에는 앞 글에서 본 잡히지 않은 예외의 처리기를 등록해 둡니다.

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-caught=invalid-task
handler=async-failure:invalid-task
worker-state=TERMINATED

이 출력은 매번 같습니다. 첫 줄은 main의 catch가 출력했습니다. failure.run()은 보통 메서드 호출이므로 예외가 main의 호출 스택을 거슬러 올라오고, 감싸 둔 catch가 그 예외를 잡습니다. RuntimeException과 비검사 예외에서 본 예외의 전파 그대로입니다.

둘째 줄은 처리기가 출력했습니다. start()로 실행한 작업은 async-failure 스레드의 호출 스택에서 실행되므로, 거기서 던진 예외는 그 스레드의 스택을 거슬러 올라갈 뿐 main의 스택으로는 오지 않습니다. 예외가 그 스택의 맨 아래까지 잡히지 않고 빠져나오면 스레드가 끝나기 직전에 JVM이 그 스레드의 처리기를 호출하며, 처리기도 main이 아니라 예외가 난 그 스레드에서 실행됩니다. 그래서 worker.start()와 worker.join()을 try로 감싸 두어도 이 예외는 잡히지 않습니다. 실제로 그렇게 바꿔 실행하면 처리기의 출력만 나오고 catch 블록은 실행되지 않습니다.

처리기를 등록하지 않았다면 JVM의 기본 처리기가 Exception in thread "async-failure"로 시작하는 스택 트레이스를 표준 오류에 출력합니다. 어느 쪽이든 예외는 그 스레드 안에서 처리되고 끝납니다. 아래 그림은 같은 작업의 예외가 호출 방식에 따라 두 경로로 나뉘어 전달되는 과정을 보여 줍니다.

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

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 재던짐: 없음
  • 종료 관찰과 업무 성공을 따로 판단해야 한다.

셋째 줄은 main이 join()에서 돌아온 뒤 출력했습니다. join()은 작업자의 예외를 main에 다시 던지지 않고 정상적으로 돌아오며, 이때 작업자의 상태는 TERMINATED입니다. TERMINATED는 작업이 성공했다는 뜻이 아니라 끝났다는 뜻일 뿐이고, 이 실행에서도 작업은 예외로 끝났습니다. 프로세스의 종료 코드도 성공을 뜻하는 0입니다. 작업자 스레드가 예외로 끝나도 main 메서드가 정상적으로 끝나면 종료 코드는 0이며, 처리기를 등록하지 않아 기본 처리기가 스택 트레이스를 출력하는 경우에도 마찬가지입니다.

그래서 작업이 성공했는지 알려면 결과나 예외를 따로 전달받아야 합니다. 작업이 결과를 돌려주고 작업에서 난 예외를 작업을 맡긴 쪽에서 받을 수 있게 하려면 Callable과 Future로 작업 결과 받기를 씁니다. 운영 중인 서비스라면 실패가 로그 한 줄만 남기고 묻히지 않도록, 처리기에서 실패를 기록하고 알리는 방법도 정해 두어야 합니다.

데몬 스레드는 JVM을 붙잡지 않는다

Java 프로그램은 main 메서드가 끝났다고 바로 종료되지 않습니다. JVM은 데몬이 아닌 스레드가 하나라도 살아 있으면 계속 실행되고, 살아 있는 비데몬 스레드가 처음으로 하나도 없게 되면 종료 절차를 시작합니다. 데몬이 아닌 스레드를 사용자 스레드(user thread)라고 하며, main 스레드와 main에서 만든 스레드는 기본적으로 사용자 스레드입니다. 데몬 스레드(daemon thread)는 이 셈에서 빠지는 스레드여서, 사용자 스레드가 모두 끝나면 JVM은 데몬 스레드가 아직 일하고 있어도 기다리지 않고 종료합니다.

다음 프로그램은 저장 작업을 데몬 스레드 background-save에 맡깁니다. 이 작업은 CountDownLatch가 열리기를 기다렸다가 saved=report를 출력합니다. CountDownLatch(1)은 누군가 countDown()을 한 번 호출할 때까지 await()를 호출한 스레드를 멈춰 두는 문이라고 생각하면 됩니다. 이 프로그램은 일부러 countDown()을 호출하지 않으므로 작업자는 저장 줄에 영원히 도달하지 못합니다. 이렇게 하면 sleep 같은 타이밍에 기대지 않고도 '저장이 끝나지 않은 채 main이 끝나는' 상황을 매번 똑같이 만들 수 있습니다.

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());
    }
}
실행 결과
main-exits-with-pending=true

main은 작업자를 시작한 뒤 isAlive()로 작업자가 아직 살아 있는지 출력하고 끝납니다. 작업자는 래치가 열리지 않는 한 끝날 수 없으므로 언제나 true가 나옵니다. 그런데 saved=report는 나오지 않고 프로그램은 바로 끝나며, 종료 코드도 성공을 뜻하는 0입니다. main이 끝나는 순간 남은 스레드는 데몬인 background-save뿐이어서 JVM이 이 스레드를 기다리지 않고 종료했기 때문입니다. 저장은 끝나지 않았는데 오류 메시지도, 실패를 알리는 종료 코드도 없습니다.

saver.setDaemon(true); 한 줄을 지우고 실행하면 결과가 정반대가 됩니다. 같은 줄이 출력된 뒤 프로그램이 끝나지 않고 멈춰 있습니다. 이번에는 background-save가 사용자 스레드여서 JVM이 그 스레드가 끝나기를 기다리는데, 래치가 열리지 않으니 그 스레드는 영원히 끝나지 않기 때문입니다. 이 환경에서 실행했을 때도 강제로 끝낼 때까지 프로세스가 그대로 남아 있었습니다.

그러니 저장, 결제, 상태 전송처럼 반드시 끝나야 하는 작업을 데몬 스레드에 맡기면 프로그램이 끝날 때 작업이 중간에 사라질 수 있습니다. 데몬 스레드에서는 정리 코드를 넣은 finally 블록도 믿을 수 없습니다. 앞 프로그램의 작업에 finally 블록을 붙여 정리 문장을 출력하게 해도, 20번 실행하는 동안 그 문장은 한 번도 나오지 않았습니다. Runtime 문서에 따르면 JVM이 종료되는 순간 모든 스레드는 그 자리에서 Java 코드 실행을 멈추고, finally 절도 실행되지 않습니다.

데몬 스레드에 알맞은 일은 프로그램이 끝날 때 중간에 멈춰도 잃을 것이 없는 보조 작업입니다. 예를 들어 캐시에서 오래된 항목을 주기적으로 지우거나 상태를 감시하는 스레드라면 프로그램과 함께 사라져도 괜찮습니다. 반드시 끝나야 하는 작업은 사용자 스레드로 두고 join()으로 끝나기를 기다립니다. 22장에서 배울 실행기를 쓴다면 종료를 요청한 뒤 작업이 끝나기를 기다리는 단계를 함께 둡니다.

데몬 여부를 정하는 규칙

데몬 여부는 스레드를 시작하기 전에 정해야 합니다. 이미 시작해 살아 있는 스레드에 setDaemon()을 호출하면 IllegalThreadStateException이 발생합니다. setDaemon()을 호출하지 않은 새 스레드는 자기를 만든 스레드의 데몬 여부를 물려받습니다. main에서 만든 스레드가 사용자 스레드인 것도 main이 사용자 스레드이기 때문이고, 데몬 스레드 안에서 만든 스레드는 따로 정하지 않으면 데몬이 됩니다.

이 규칙 때문에 생길 수 있는 일을 확인해 보겠습니다. 데몬으로 만든 부모 스레드 안에서, 1초 동안 잠들었다가 child done을 출력하는 자식 스레드를 만들어 시작합니다. main이 부모 스레드가 끝나기를 기다린 뒤 끝나면, 데몬 여부를 물려받은 자식의 isDaemon()은 true이므로 프로그램은 곧바로 끝나고 child done은 나오지 않습니다. 자식을 시작하기 전에 setDaemon(false)를 호출하면 main이 끝난 뒤에도 프로그램이 1초쯤 더 실행되다가 child done을 출력하고 끝납니다. 그래서 반드시 끝나야 하는 작업을 다른 스레드 안에서 만들 때는 start() 전에 setDaemon(false)를 적어 의도를 드러내는 편이 안전합니다.

데몬 여부는 우선순위나 중요도와도 관계가 없습니다. 데몬 스레드라고 CPU를 덜 받거나 늦게 실행되는 것이 아니고, 데몬 여부가 정하는 것은 JVM이 종료할 때 그 스레드를 기다리는지 하나뿐입니다.

데몬 스레드도 join으로 기다릴 수 있다

이제 앞의 DocumentIndexTask로 돌아가 봅시다. index-preview도 데몬 스레드였지만 두 줄을 빠짐없이 출력했습니다. main이 preview.join()으로 그 스레드가 끝나기를 기다렸기 때문입니다. join()을 호출한 main은 기다리는 동안에도 살아 있는 사용자 스레드이므로, 그동안에는 사용자 스레드가 하나도 없는 상황이 생기지 않습니다.

즉 데몬 여부는 'JVM이 종료할 때 이 스레드를 기다리는가'를 정하고, join()은 '특정 스레드가 이 스레드가 끝나기를 기다리는가'를 정합니다. 둘은 서로 독립된 선택입니다. 아래 그림은 끝나기를 기다려 주는 스레드가 없는 background-save와, main이 join()으로 기다리는 index-preview를 나란히 비교합니다.

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

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하면 해당 작업 완료를 기다린다.

다만 join()은 기다리기만 할 뿐 작업을 끝내 주지는 않습니다. background-save처럼 래치가 열리지 않아 끝날 수 없는 스레드를 join()하면 main도 함께 영원히 기다립니다. 또 끝난 것을 확인했더라도 작업이 성공했는지는 앞에서 본 것처럼 결과로 따로 확인해야 합니다.

더 깊이: JVM이 종료되는 순간

Runtime 문서는 JVM의 종료 절차(shutdown sequence)를 정의합니다. 종료 절차는 살아 있는 비데몬 스레드가 처음으로 0개가 될 때, System.exit()가 처음 호출될 때, 운영체제에서 인터럽트나 신호 같은 외부 사건이 올 때 시작됩니다. 절차가 시작되면 등록된 종료 훅(shutdown hook)들이 실행되고, 그동안 아직 살아 있는 데몬 스레드와 비데몬 스레드도 함께 실행됩니다. 모든 종료 훅이 끝나면 JVM이 종료되는데, 그 순간 모든 스레드가 더는 Java 코드를 실행하지 못합니다. 실행 중이던 메서드는 정상적으로도 예외로도 끝나지 않고, finally 절과 잡히지 않은 예외의 처리기도 실행되지 않으며, try-with-resources로 연 자원도 닫히지 않습니다.

JVM이 스스로 만드는 스레드가 프로그램 종료를 막지 않는 것도 그 스레드들이 데몬이기 때문입니다. 이 환경(JDK 21)에서 막 시작한 프로그램의 스레드를 Thread.getAllStackTraces()로 나열해 보면 Reference Handler, Finalizer, Signal Dispatcher, Common-Cleaner 같은 스레드가 보이는데, main을 뺀 나머지는 모두 데몬입니다. 또 가상 스레드는 언제나 데몬이어서, 가상 스레드에 setDaemon(false)를 호출하면 IllegalArgumentException이 발생합니다.

끝나지 않는 사용자 스레드를 데몬으로 숨기지 않기

앞에서 setDaemon(true)를 지우자 프로그램이 끝나지 않았습니다. 이런 증상을 만나면 스레드를 데몬으로 바꿔 프로그램이 끝나게 만들고 싶어지지만, 그것은 문제를 고친 것이 아니라 숨긴 것입니다. 작업은 여전히 끝나지 않았고, 이제는 끝나지 않았다는 사실조차 드러나지 않습니다. 사용자 스레드가 끝나지 않는다면 무엇을 기다리고 있는지부터 찾고, 그 기다림을 풀어 주거나 작업에 그만두라고 알리는 방법을 만들어야 합니다. 다른 스레드에 중단을 요청하는 interrupt()는 interrupt 플래그와 InterruptedException 처리에서, 작업을 안전하게 끝내는 방법은 작업자 스레드 안전하게 종료하기에서 배웁니다.

그러려면 시작한 스레드의 참조를 버리지 말아야 합니다. 반복문 안에서 스레드를 만들어 시작만 하고 참조를 버리면, 나중에 join()으로 기다리거나 interrupt()로 중단을 요청하거나 상태를 확인할 방법이 사라집니다. 앞 글의 NamedImportWorkers처럼 배열이나 컬렉션에 담아 두고, 각 스레드가 성공했는지 실패했는지 취소됐는지를 끝까지 확인합니다. 여러 스레드를 시작하는 도중에 하나가 실패했다면 이미 시작한 스레드를 어떻게 멈출지도 미리 정해 두어야 합니다.

여러 스레드의 로그에 이름 남기기

여러 스레드가 함께 남기는 로그는 줄의 순서가 실행할 때마다 바뀌므로, 줄마다 어느 스레드에서 나왔는지 적어 두어야 나중에 흐름을 따라갈 수 있습니다. 다음 프로그램은 작업 세 개를 pipeline-1부터 pipeline-3까지 이름 붙인 스레드에서 실행하고, 각 작업의 시작과 끝을 기록합니다. 로그 한 줄에는 시각, 대괄호로 감싼 스레드 이름, 사건 이름과 값이 들어갑니다. log 메서드에 붙은 synchronized는 한 번에 한 스레드만 이 메서드를 실행할 수 있게 하는 키워드로, 자세한 내용은 19장에서 배웁니다.

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));
    }
}
실행 결과(한 예)
2026-10-26T12:54:57.590848660Z [pipeline-1] begin=parse
2026-10-26T12:54:57.601122050Z [pipeline-3] begin=publish
2026-10-26T12:54:57.601453002Z [pipeline-3] complete=publish
2026-10-26T12:54:57.601755049Z [pipeline-2] begin=index
2026-10-26T12:54:57.602149128Z [pipeline-2] complete=index
2026-10-26T12:54:57.602395030Z [pipeline-1] complete=parse
2026-10-26T12:54:57.602802317Z [main] all-complete=3

시각은 실행할 때마다 당연히 다르고, 작업 줄 여섯 개의 순서도 달라집니다. 30번 실행했을 때 열 가지 순서가 나왔고, 위 출력처럼 pipeline-1이 시작한 뒤 다른 두 작업이 먼저 끝나는 경우도 있었습니다. 그래도 바뀌지 않는 것이 있습니다. 같은 스레드의 begin은 언제나 그 스레드의 complete보다 앞에 오는데, 한 스레드 안에서는 코드에 적힌 순서대로 log를 호출하기 때문입니다. 마지막 줄 all-complete=3은 main이 세 스레드를 모두 join()한 뒤에 기록하므로 언제나 맨 끝에 옵니다.

log의 synchronized는 작업의 순서를 정하지 않습니다. 어느 스레드가 먼저 log에 들어갈지는 여전히 스케줄러가 정합니다. 이 키워드가 하는 일은 한 스레드가 시각을 읽고 그 시각이 담긴 줄을 출력하는 동안 다른 스레드가 끼어들지 못하게 하는 것이고, 그래서 이 로그에서는 줄의 순서와 시각의 순서가 일치합니다. 같은 형식의 로그를 세 스레드가 3,000줄씩 남기는 프로그램으로 실험해 보면 차이가 드러납니다. synchronized 없이 실행하면 9,000줄 가운데 바로 앞 줄보다 이른 시각이 찍힌 줄이 실행마다 35개에서 84개 나왔고, synchronized를 붙이면 그런 줄이 하나도 없었습니다.

잠금이 없으면 한 스레드가 시각을 읽은 뒤 출력하기 전에, 더 늦게 시각을 읽은 다른 스레드가 먼저 출력할 수 있기 때문입니다. 시계의 정밀도에 따라서는 서로 다른 사건에 같은 시각이 찍힐 수도 있습니다. 그러니 로그 줄의 순서나 시각만 보고 사건이 일어난 순서를 단정해서는 안 되고, 순서가 중요한 사건이라면 join()처럼 코드에서 순서를 정하는 장치가 있는지 확인해야 합니다.

한 줄을 한 번의 출력 호출로 만드는 것도 중요합니다. 이 글에서 쓴 JDK의 System.out은 출력 메서드 호출 하나를 처리하는 동안 다른 스레드의 출력이 끼어들지 못하게 내부에서 잠금을 겁니다. 그래서 log처럼 한 줄 전체를 printf 한 번으로 출력하면 줄 중간이 섞이지 않습니다. 반면 한 줄을 여러 번의 호출로 나눠 출력하면 호출과 호출 사이에 다른 스레드가 끼어들 수 있습니다. 다음 프로그램은 세 스레드가 각자 2,000줄을 출력하면서 한 줄을 print 두 번과 println 한 번으로 나눠 씁니다.

lab/SplitPrintLines.java
public final class SplitPrintLines {
    public static void main(String[] args) throws InterruptedException {
        Thread[] workers = new Thread[3];
        for (int i = 0; i < workers.length; i++) {
            workers[i] = new Thread(() -> {
                String name = Thread.currentThread().getName();
                for (int n = 0; n < 2000; n++) {
                    System.out.print("[" + name + "] ");
                    System.out.print("event=");
                    System.out.println(n);
                }
            }, "w-" + (i + 1));
            workers[i].start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
    }
}
형식이 깨진 줄 가운데 일부
[w-1] event=[w-2] [w-3] event=0
[w-3] event=event=0
42

전체 6,000줄 가운데 대부분은 [w-1] event=5처럼 정상이지만, 위와 같이 한 줄에 여러 스레드의 조각이 섞이거나 event=가 두 번 찍히거나 숫자만 따로 떨어진 줄이 생겼습니다. 아홉 번 실행했을 때 깨진 줄은 적게는 10줄, 많게는 74줄로 실행마다 달랐습니다. 세 조각을 문자열 하나로 합쳐 System.out.println("[" + name + "] event=" + n)처럼 한 번에 출력하도록 바꾸면, 여덟 번 실행하는 동안 깨진 줄이 하나도 나오지 않았습니다.

이 글의 log 메서드는 원리를 보이기 위한 것입니다. 실제 서비스에서는 로그 수준, 예외 스택 트레이스, 구조화된 필드 같은 기능을 갖춘 로깅 라이브러리를 쓰며, 대부분은 설정만으로 모든 로그에 스레드 이름을 넣어 줍니다. 그런 라이브러리를 써도 로그 줄의 순서가 실제 사건의 순서라고 가정하면 안 된다는 점은 같습니다.

Thread 상속, Runnable, Callable 중 무엇을 쓸까

지금까지 본 방법을 정리하면 다음과 같습니다. 대부분의 작업은 Runnable로 만들고, Thread를 상속하는 것은 Thread 자체의 동작을 바꿔야 하는 드문 경우로 한정합니다.

방법알맞은 경우한계
Thread 상속Thread 자체의 동작을 바꿔야 하는 드문 경우다른 클래스를 상속할 수 없고 할 일이 스레드에 묶임
Runnable 구현대부분의 작업. 직접 호출, 새 스레드, 실행기 어디서든 실행 가능결과를 돌려줄 수 없고 run()이 검사 예외를 던질 수 없음
Callable과 Future결과나 예외를 작업을 맡긴 쪽에서 받아야 할 때주로 실행기와 함께 씀

Runnable의 run()은 반환 타입이 void이고 throws 절이 없어서, 결과를 돌려주거나 검사 예외를 던질 수 없습니다. 작업이 계산한 값을 받아야 하거나 실패를 작업을 맡긴 쪽에서 예외로 받고 싶다면 Callable과 Future로 작업 결과 받기를 씁니다.

정리

  • Runnable은 할 일을 담는 인터페이스이고, Thread는 그 일을 실행하는 수단입니다. 둘을 나누면 같은 작업 객체를 직접 호출하거나 새 스레드나 실행기에 다시 넘길 수 있습니다. 끝난 Thread는 다시 시작할 수 없지만 작업 객체는 몇 번이든 다시 쓸 수 있습니다.
  • 작업 객체를 여러 스레드가 함께 쓰면 그 필드도 함께 쓰게 됩니다. 바뀌지 않는 값만 담은 작업 객체는 안심하고 공유할 수 있습니다.
  • run()을 직접 호출했을 때 난 예외는 호출한 쪽의 catch로 전달됩니다. start()로 실행한 작업의 예외는 그 스레드 안에서 처리기로 가며, start()나 join()을 감싼 try로는 잡을 수 없습니다.
  • TERMINATED 상태와 정상적으로 돌아온 join()은 스레드가 끝났다는 뜻일 뿐 작업이 성공했다는 뜻이 아닙니다.
  • JVM은 살아 있는 사용자 스레드가 하나도 없게 되면 데몬 스레드를 기다리지 않고 종료하며, 그때 데몬 스레드의 finally도 실행되지 않습니다. 반드시 끝나야 하는 작업은 데몬에 맡기지 않습니다.
  • 데몬 여부는 start() 전에 정해야 하고, 정하지 않으면 그 스레드를 만든 스레드의 값을 물려받습니다. 데몬 여부와 join()은 서로 독립된 선택입니다.
  • 여러 스레드의 로그에는 스레드 이름을 넣고 한 줄을 한 번의 호출로 출력합니다. 줄의 순서나 시각만 보고 사건의 순서를 단정하지 않습니다.

연습 문제

1. 반드시 끝나야 하는 작업과 없어져도 되는 작업

저장 작업은 사용자 스레드 save-required로, 통계 작업은 데몬 스레드 metric-optional로 만들어 둘 다 시작하세요. 저장 작업은 save-required stored=document처럼 스레드 이름이 들어간 줄을 출력하고, 통계 작업은 50ms 동안 잠든 뒤 metric-optional metric=1을 출력합니다. main은 저장 작업이 끝나기만 기다린 뒤 required-save-complete를 출력하고 끝나야 합니다.

검사할 조건은 저장 줄이 required-save-complete보다 앞에 나온다는 것 하나입니다. 통계 줄은 나올 수도 있고 나오지 않을 수도 있으므로, 통계 줄이 꼭 나와야 한다고 검사하면 데몬으로 만든 선택과 검사가 서로 어긋납니다. 이 예제의 저장은 콘솔에 한 줄을 출력할 뿐 실제로 파일을 쓰지 않는다는 점도 구별해 두세요.

정답과 해설

통계 스레드는 start() 전에 setDaemon(true)로 데몬으로 만들고, main은 save.join()만 호출합니다.

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");
    }
}
실행 결과(한 예)
save-required stored=document
required-save-complete

main은 save.join()이 돌아온 뒤에 마지막 줄을 출력하므로 저장 줄은 언제나 그보다 앞에 나옵니다. 통계 줄은 이 환경에서 30번 실행하는 동안 한 번도 나오지 않았습니다. 통계 스레드가 50ms를 자는 사이에 main이 끝나 사용자 스레드가 하나도 남지 않으면, JVM이 데몬인 통계 스레드를 기다리지 않고 종료하기 때문입니다. 그렇다고 통계 줄이 절대 나오지 않는다고 단정할 수도 없으므로 이 줄은 검사 대상에서 뺍니다. 실제 서비스에서 통계를 잃고 싶지 않다면 종료하기 전에 모아 둔 통계를 내보내는 단계를 따로 두어야 합니다.

2. 데몬 저장을 기다리는 순서

아래 프로그램에서 데몬 스레드 gate-save는 래치가 열리기를 기다렸다가 저장 줄을 출력합니다. 실제 파일 대신 콘솔 한 줄로 저장을 나타내고, 다른 중단 요청이나 예외는 없다고 가정합니다. main은 안내 줄 출력, countDown(), join(), 종료 확인 줄 출력의 순서로 실행합니다.

  1. 반드시 이 순서로 나오는 세 줄을 적으세요.
  2. countDown()을 join() 뒤로 옮기면 어떻게 될까요? 작업자가 데몬이니 JVM이 알아서 끝내 줄까요? 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이 되기 전에는 저장 줄을 출력할 수 없고, main은 안내 줄을 출력한 뒤에야 countDown()을 호출합니다. 그래서 안내 줄이 저장 줄보다 먼저 나옵니다. 마지막 줄은 join()으로 작업자가 끝나기를 기다린 뒤에 출력되므로 저장 줄보다 뒤입니다. sleep이나 특정한 스케줄링 순서에 기대지 않고, 코드에 적힌 기다림만으로 정해지는 순서입니다.

2번. 작업자는 래치가 열리기를 기다리고, main은 join()으로 그 작업자가 끝나기를 기다립니다. 래치를 여는 countDown()이 join() 뒤에 있으니 둘 다 영원히 진행하지 못합니다. 작업자가 데몬이어도 소용없는데, main이 아직 살아 있는 사용자 스레드여서 JVM이 종료 절차를 시작하지 않기 때문입니다. 실제로 그렇게 바꿔 실행하면 main releases save만 출력된 채 프로그램이 멈춥니다.

3번. 말할 수 없습니다. TERMINATED는 스레드가 끝났다는 뜻일 뿐이고, 작업이 예외로 끝나도 같은 상태가 됩니다. 게다가 이 코드는 파일을 쓰지 않고 콘솔에 한 줄을 출력할 뿐입니다. 실제 저장이라면 저장 결과와 예외를 따로 전달받아 확인해야 합니다.

다음 글

Runnable을 만드는 세 가지 방법과 람다 캡처에서는 같은 작업을 이름 있는 클래스, 익명 클래스, 람다식으로 만들어 비교하고, 작업 객체 하나를 여러 스레드가 함께 쓸 때 바뀌는 필드에서 무슨 일이 생기는지 직접 확인합니다.