스레드의 생명주기와 제어 (Lifecycle, join, interrupt)

핵심 요약

  • 스레드의 생명주기는 New(생성), Runnable(실행 대기/실행), Terminated(종료), 그리고 다양한 일시 중지 상태(Blocked, Waiting, Timed Waiting)로 나뉩니다.
  • Blocked는 락(Lock) 획득을 대기하는 상태, Waiting은 다른 스레드의 신호를 기다리는 상태입니다.
  • join()을 사용하여 다른 스레드의 작업이 끝날 때까지 기다릴 수 있습니다.
  • interrupt()는 스레드를 강제로 종료하는 것이 아니라, 중단 요청 신호를 보내 예외를 발생시키거나 상태 플래그를 변경하는 방식입니다.

목차

지난 포스트에서 스레드의 기본 개념을 잡았다면, 이번에는 스레드의 ‘일생(Life Cycle)’을 살펴볼 차례입니다. 스레드는 태어나서(New), 실행되고(Runnable), 잠시 멈췄다가(Waiting/Blocked), 결국 죽습니다(Terminated). 이 흐름을 개발자가 원하는 대로 제어할 수 있어야 진정한 멀티스레드 프로그래밍이 가능합니다. 오늘은 스레드의 상태 변화와 핵심 제어 메서드인 join, interrupt에 대해 깊이 파헤쳐 보겠습니다.

1. 스레드 생명주기 (Life Cycle) 상세 분석

자바 스레드는 JVM에 의해 관리되며, 크게 4가지, 세부적으로는 6가지의 상태 변화를 겪습니다.

Thread Status를 보여주는 그림

주요 상태 (Major States)

  • New (생성): 스레드 객체는 생성되었지만, 아직 start() 메서드가 호출되지 않은 상태입니다. 아직 운영체제의 스레드로 연결되지 않은 껍데기뿐인 단계입니다.
  • Runnable (실행 대기 + 실행): start()가 호출되면 이 상태가 됩니다. 주의할 점은 ‘실행 중(Running)’과 ‘실행 대기(Ready)’를 자바에서는 묶어서 RUNNABLE로 취급한다는 점입니다. 스케줄러가 선택하면 CPU를 점유해 실행되고, 다른 스레드에게 밀리면 다시 대기합니다.
  • Terminated (종료): run() 메서드의 코드가 모두 실행되거나, 예외가 발생하여 비정상 종료된 상태입니다. 한 번 종료된 스레드는 다시 start() 할 수 없습니다.
  • 일시 중지 상태: 실행을 멈추고 기다리는 상태들입니다. 상황에 따라 세 가지로 나뉩니다.

일시 중지 상태의 상세 구분

개발자들이 가장 헷갈려 하는 부분이 바로 ‘멈춰있는 상태’의 구분입니다.

  • Blocked (차단): synchronized 블록이나 메서드에 진입하려는데, 다른 스레드가 이미 락(Lock)을 잡고 있어서 들어가지 못하고 문 밖에서 서성이는 상태입니다. 락이 풀리기만을 하염없이 기다립니다.
  • Waiting (대기): 다른 스레드가 통지(Notify)를 해주거나 작업을 마칠 때까지 기다리는 상태입니다. join(), wait() 메서드가 여기에 해당합니다.
  • Timed Waiting (시간제한 대기): 마냥 기다리는 것이 아니라 ‘최대 대기 시간’이 설정된 상태입니다. sleep(ms), wait(timeout) 등이 해당하며, 시간이 지나면 자동으로 깨어납니다.

2. 스레드 제어 메서드: join()

멀티스레드 환경에서는 A 스레드의 결과값이 나와야 B 스레드가 일을 할 수 있는 경우가 많습니다. 이때 사용하는 것이 join()입니다.

다른 스레드 기다리기

  • join(): 호출한 대상 스레드가 끝날 때까지 현재 스레드는 무한 대기(Waiting) 상태로 들어갑니다.
  • join(long millis): 지정한 시간 동안만 기다립니다. 시간이 지나도 안 끝나면 그냥 다음 코드를 실행합니다 (Timed Waiting).
public static void main(String[] args) { 
  Thread t1 = new Thread(new Task(), "T1");
  t1.start();

  try {
    // main 스레드가 T1이 끝날 때까지 멈추고 기다림
    t1.join();
  } catch (InterruptedException e) {
    e.printStackTrace();
  }
  LogUtils.info("T1 종료 후 main 실행");
}

3. 우아한 종료 요청: interrupt()

실행 중인 스레드를 외부에서 강제로 멈춰야 할 때가 있습니다. 과거에는 stop()이라는 메서드가 있었지만, 데이터 불일치 등 심각한 부작용으로 인해 사용 중지(Deprecated) 되었습니다. 대신 우리는 interrupt()를 사용해야 합니다.

작동 원리

이 메서드는 스레드를 즉시 킬(Kill)하는 것이 아닙니다. “이제 그만 멈춰줄래?”라고 정중하게 신호(Signal)를 보내는 것입니다.

  1. interrupt()를 호출하면 해당 스레드의 내부 상태 플래그(interrupt status)가 true로 변경됩니다.
  2. 만약 해당 스레드가 sleep(), wait(), join() 등으로 일시 정지 상태였다면, 플래그가 켜지는 순간 InterruptedException이 발생하며 강제로 깨어납니다. (이때 예외 처리를 통해 종료 로직을 수행합니다.)
  3. 만약 스레드가 열심히 실행 중(Runnable)이었다면? 아무 일도 일어나지 않습니다. 따라서 개발자가 주기적으로 Thread.interrupted()를 확인하는 코드를 작성해야 합니다.

Thread worker = new Thread(() -> {
  // 실행 중일 때: 인터럽트 상태를 주기적으로 직접 확인해야 함
  while (!Thread.interrupted()) { LogUtils.info("작업 중..."); }
  
  LogUtils.info("자원 정리 및 종료"); 
});

worker.start();
ThreadUtils.sleep(100);
worker.interrupt(); // 외부에서 중단 요청 발송

4. 실행 양보: yield()

마지막으로 yield()입니다. 이름 그대로 CPU 점유권을 다른 스레드에게 양보(Yield)하는 메서드입니다.

  • 호출 시 현재 스레드는 실행 대기(Runnable) 상태로 돌아가고, 스케줄러에게 “나 지금 안 급하니까 다른 친구 먼저 시켜줘”라고 힌트를 줍니다.
  • 주의사항: 이것은 힌트일 뿐, 강제 명령이 아닙니다. OS 스케줄러가 무시하면 그만입니다. 따라서 로직의 정확성을 위해 yield()에 의존해서는 안 되며, 주로 테스트나 디버깅 목적으로 제한적으로 사용됩니다.

이 글은 어떠셨나요? 자유롭게 의견을 남겨주세요! 💬