코드 한 줄의 기록

Java 스레드 라이프사이클과 Runnable/Thread 완벽 이해하기 본문

JAVA

Java 스레드 라이프사이클과 Runnable/Thread 완벽 이해하기

CodeByJin 2025. 11. 16. 15:55
반응형

처음 멀티스레딩을 공부할 때 가장 헷갈리는 부분이 바로 스레드의 상태 변화와 Runnable 인터페이스 vs Thread 클래스의 선택이다. 나도 이 부분을 깊게 파고들어야 면접에서 자신 있게 답할 수 있겠다는 생각이 들어서 정리해보기로 했다. 이 글을 읽고 나면, 스레드가 어떻게 태어나고 자라고 죽는지, 그리고 Runnable과 Thread를 언제 어떻게 사용해야 하는지 명확하게 이해할 수 있을 거라 생각한다.

스레드라이프사이클, 왜 알아야 할까?

자바에서 멀티스레딩을 다루려면 스레드의 상태 변화를 이해하는 것이 필수다. 스레드는 생성되는 순간부터 종료될 때까지 다양한 상태를 거치면서 JVM과 OS의 스레드 스케줄러에 의해 관리된다. 이 상태들을 이해하지 못하면 왜 특정 메서드를 호출해야 하는지, 왜 스레드가 갑자기 실행되지 않는지 등을 디버깅하기 어려워진다. 특히 동시성 문제나 데드락 현상을 마주쳤을 때 원인을 파악할 수 없게 되는 경우가 많다.

 

직무 면접에서도 자주 나오는 주제다. "스레드의 상태를 설명해보세요"라는 질문에 명확하게 답하면 면접관에게 좋은 인상을 줄 수 있다. 더 나아가 프로덕션 환경에서 스레드를 다루는 복잡한 상황에서도 이론을 바탕으로 문제를 해결할 수 있는 개발자가 될 수 있다.

Java 스레드의 6가지 생명주기 상태

자바의 스레드는 총 6가지 상태를 가진다. java.lang.Thread.State 열거형에 정의되어 있으며, 각 상태 간의 전환이 스레드 라이프사이클을 만든다.

 

1. NEW (새로 만들어진 상태)

스레드 객체가 생성되었지만 아직 start() 메서드가 호출되지 않은 상태다. 이 상태의 스레드는 JVM에 의해 아직 실행 대기 큐에 들어가지 않았다. 쉽게 말해서 배우를 뽑았지만 아직 무대에 올리지 않은 상태라고 생각하면 된다.

Thread thread = new Thread(() -> System.out.println("Hello"));
// 이 시점에서 thread는 NEW 상태

2. RUNNABLE (실행 가능한 상태)

start() 메서드가 호출되면 스레드가 RUNNABLE 상태로 전환된다. 이 상태의 스레드들은 스레드 스케줄러의 관리 하에 들어가서 CPU 시간을 할당받을 때까지 대기한다. 중요한 점은 RUNNABLE 상태가 두 가지를 포함한다는 것이다. 첫 번째는 스케줄러에 의해 선택되기 전에 대기 중인 상태이고, 두 번째는 실제로 CPU에서 실행 중인 상태다. 자바에서는 이 둘을 구분하지 않고 모두 RUNNABLE로 표현한다.

Thread thread = new Thread(() -> System.out.println("Hello"));
thread.start(); // thread는 이제 RUNNABLE 상태

3. BLOCKED (차단된 상태)

스레드가 synchronized 블록이나 메서드에 진입하려고 할 때, 다른 스레드가 이미 그 객체의 모니터 락을 보유하고 있다면 BLOCKED 상태로 전환된다. 락을 얻을 수 있을 때까지 대기하고 있는 상태를 생각하면 된다. 동시성 문제를 해결하기 위해 synchronized를 사용할 때 자주 발생한다.

synchronized(obj) {
    // 다른 스레드가 이미 obj의 락을 가지고 있다면
    // 이 블록에 진입하려는 스레드는 BLOCKED 상태
}

4. WAITING (대기 상태)

스레드가 wait(), join() 메서드를 호출하거나 LockSupport.park()를 호출하면 WAITING 상태로 전환된다. 이 상태의 스레드는 다른 스레드의 특정 작업이 완료될 때까지 또는 notify() 호출을 기다린다. BLOCKED와의 차이는 BLOCKED는 락을 기다리는 상태지만, WAITING은 특정 조건을 기다리는 상태라는 점이다.

synchronized(obj) {
    obj.wait(); // 스레드가 WAITING 상태로 전환
    // notify()가 호출될 때까지 대기
}

5. TIMED_WAITING (일정 시간 대기 상태)

WAITING 상태와 유사하지만 시간 제한이 있다는 점이 다르다. sleep(long), wait(long), join(long) 등의 메서드를 호출할 때 진입한다. 지정된 시간이 경과하거나 notify()가 호출되면 RUNNABLE 상태로 돌아간다. 타임아웃 기능이 필요한 상황에서 유용하게 사용된다.

Thread.sleep(1000); // 현재 스레드가 TIMED_WAITING 상태로 1초간 대기

6. TERMINATED (종료된 상태)

스레드의 run() 메서드가 완료되거나 예외가 발생하면 TERMINATED 상태로 전환된다. 이 상태의 스레드는 더 이상 실행될 수 없으며, 다시 start()를 호출할 수 없다. 생명주기가 끝난 상태다.

// run() 메서드가 완료되면 스레드는 TERMINATED 상태
public void run() {
    System.out.println("작업 완료");
    // run() 메서드 끝 = 스레드 종료
}

스레드 상태 변화의 흐름도

실제 개발에서 스레드가 어떻게 상태를 변화시키는지 정리하면 다음과 같다. 처음에는 NEW 상태에서 시작하고, start()가 호출되면 RUNNABLE 상태로 진입한다. 이후 여러 상황에 따라 BLOCKED, WAITING, TIMED_WAITING 등으로 전환될 수 있고, 최종적으로 run() 메서드가 완료되면 TERMINATED 상태가 된다.

가장 중요한 부분은 start() 메서드를 절대로 두 번 호출할 수 없다는 것이다. 한 번 TERMINATED 상태가 된 스레드는 다시 실행할 수 없으므로, 반복 실행이 필요하면 새로운 스레드 객체를 만들어야 한다.

Runnable 인터페이스 vs Thread 클래스, 무엇을 선택할까?

처음 스레드를 배울 때 가장 헷갈리는 부분이 "Thread 클래스를 상속해야 하나, Runnable 인터페이스를 구현해야 하나?"라는 질문이다. 둘 다 스레드를 만들 수 있지만, 각각의 특징과 장단점이 있다.

 

Runnable 인터페이스를 사용하는 방법

Runnable은 자바의 함수형 인터페이스로, 오직 하나의 추상 메서드인 run()만 가지고 있다. Runnable 인터페이스를 구현한 클래스를 작성하고, 그 객체를 Thread 생성자에 넘겨서 스레드를 만든다.

class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Runnable 스레드: " + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class RunnableExample {
    public static void main(String[] args) {
        Runnable runnable = new MyRunnable();
        Thread thread = new Thread(runnable);
        thread.start();
    }
}

람다 표현식을 사용하면 더 간단하게 작성할 수 있다.

Thread thread = new Thread(() -> {
    for (int i = 0; i < 5; i++) {
        System.out.println("람다 스레드: " + i);
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
});
thread.start();

Thread 클래스를 상속하는 방법

Thread 클래스를 직접 상속받아서 run() 메서드를 오버라이드하는 방식이다. Thread 클래스 자체가 이미 스레드이기 때문에 상속받은 클래스를 바로 start()할 수 있다.

class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Thread 상속: " + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class ThreadExample {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();
    }
}

둘의 주요 차이점

유연성 측면에서 Runnable이 훨씬 우수하다. 자바는 단일 상속만 가능하므로, Thread를 상속하면 다른 클래스를 상속할 수 없다. 반면 Runnable은 인터페이스이기 때문에 이미 다른 클래스를 상속받고 있는 경우에도 함께 구현할 수 있다.

// Thread를 상속하면 다른 클래스를 상속할 수 없다
class MyThread extends Thread, AnotherClass {  // 컴파일 에러!
// ...
}

// Runnable은 다른 상속과 함께 사용 가능하다
class MyClass extends AnotherClass implements Runnable {
    @Override
    public void run() {
        // ...
    }
}

람다 표현식 사용 가능 여부도 큰 차이다. Runnable은 함수형 인터페이스이기 때문에 람다로 간결하게 표현할 수 있지만, Thread는 불가능하다.

 

메모리 측면에서도 Runnable이 더 효율적이다. Thread를 상속하면 스레드와 무관한 메서드와 필드까지 모두 상속받지만, Runnable은 오직 필요한 부분만 구현하면 된다.

 

Thread 클래스의 메서드가 필요한 경우에만 Thread를 상속해야 한다. getName(), setName(), getPriority() 등 Thread 클래스의 고급 기능을 직접 사용해야 한다면 Thread를 상속하는 것이 맞다. 그러나 대부분의 경우 Runnable로 충분하고, 필요한 메서드는 Thread 객체의 정적 메서드나 현재 스레드 객체를 통해 접근할 수 있다.

// Runnable을 사용하면서 스레드 정보 얻기
Thread currentThread = Thread.currentThread();
System.out.println("현재 스레드: " + currentThread.getName());
currentThread.setPriority(Thread.MAX_PRIORITY);

start()와 run() 메서드의 결정적 차이

스레드를 다루면서 가장 흔한 실수 중 하나가 start() 대신 run()을 호출하는 것이다. 둘 다 메서드일 뿐이지만, 작동 방식이 완전히 다르다.

 

start() 메서드

start() 메서드를 호출하면 JVM이 새로운 스레드를 생성하고, 그 스레드에서 run() 메서드를 실행하게 한다. 즉, 진정한 멀티스레딩이 일어난다.

Thread thread = new Thread(() -> {
    System.out.println("실행 중인 스레드: " + Thread.currentThread().getName());
    System.out.println("스레드 ID: " + Thread.currentThread().getId());
});

thread.start();
// 출력: 실행 중인 스레드: Thread-0
// 새로운 스레드에서 실행됨

run() 메서드

run() 메서드를 직접 호출하면 새로운 스레드가 생성되지 않는다. 그냥 현재 스레드에서 일반 메서드처럼 순차적으로 실행될 뿐이다. 이는 멀티스레딩의 의미가 사라진다.

Thread thread = new Thread(() -> {
    System.out.println("실행 중인 스레드: " + Thread.currentThread().getName());
    System.out.println("스레드 ID: " + Thread.currentThread().getId());
});

thread.run();
// 출력: 실행 중인 스레드: main
// main 스레드에서 실행됨, 새로운 스레드 생성 안 됨

또 다른 중요한 차이

start()는 같은 Thread 객체에 대해 두 번 이상 호출할 수 없다. 한 번 호출되면 스레드가 실행되고 종료되면, 다시 start()를 호출하면 IllegalThreadStateException이 발생한다.

Thread thread = new Thread(() -> System.out.println("작업"));
thread.start();
thread.start(); // IllegalThreadStateException 발생!

반면 run()은 여러 번 호출할 수 있다. 하지만 이것은 멀티스레딩이 아니라 단순히 같은 코드를 순차적으로 여러 번 실행하는 것일 뿐이다.

Thread thread = new Thread(() -> System.out.println("작업"));
thread.run();
thread.run(); // OK, 하지만 멀티스레딩이 아님

시간 소비 작업을 할 때의 차이를 보면 더 명확하다.

long start = System.currentTimeMillis();

Thread thread = new Thread(() -> {
    for (int i = 0; i < 1000000000; i++) {
        // 오래 걸리는 작업
    }
});

thread.start(); // 또는 thread.run()
// 메인 스레드가 계속 실행되므로 즉시 다음 줄로 진행

long end = System.currentTimeMillis();
System.out.println("경과 시간: " + (end - start) + "ms");

start()를 사용하면 메인 스레드와 worker 스레드가 동시에 실행되므로 경과 시간이 짧지만, run()을 사용하면 메인 스레드가 오래 걸리는 작업이 완료될 때까지 블로킹되므로 경과 시간이 길다.

스레드 상태 제어 메서드들

스레드의 상태를 능동적으로 제어하는 메서드들이 있다. 이 메서드들을 이해하면 복잡한 멀티스레딩 상황을 다룰 수 있다.

 

join() 메서드

현재 스레드가 다른 스레드의 완료를 기다려야 할 때 사용한다. join()을 호출한 스레드는 WAITING 상태로 전환되고, 대상 스레드가 종료될 때까지 대기한다.

class WorkerThread extends Thread {
    @Override
    public void run() {
        try {
            for (int i = 0; i < 3; i++) {
                System.out.println("워커 스레드 작업: " + i);
                Thread.sleep(500);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class JoinExample {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("메인 스레드 시작");
        
        WorkerThread worker = new WorkerThread();
        worker.start();
        
        System.out.println("워커 스레드 완료를 기다립니다");
        worker.join(); // 메인 스레드가 WAITING 상태
        
        System.out.println("메인 스레드 종료");
    }
}

출력 결과를 보면 메인 스레드가 워커 스레드의 완료를 기다리고 나서 "메인 스레드 종료"를 출력한다는 것을 확인할 수 있다. 이를 통해 여러 스레드의 작업을 순차적으로 조율할 수 있다.

 

sleep() 메서드

현재 스레드를 지정된 시간만큼 중단시킨다. sleep() 상태인 스레드는 TIMED_WAITING 상태다. 중요한 것은 sleep()은 다른 스레드에게 CPU 시간을 양보한다는 점이다.

public class SleepExample {
    public static void main(String[] args) {
        System.out.println("시작");
        
        try {
            System.out.println("3초 대기 중...");
            Thread.sleep(3000); // 3초 대기
            System.out.println("대기 완료");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

InterruptedException을 catch해야 하는 이유는 다른 스레드가 sleep 중인 스레드를 interrupt()할 수 있기 때문이다.

 

yield() 메서드

현재 스레드가 자발적으로 CPU 시간을 다른 스레드에게 양보한다. yield() 호출 후에도 스레드는 RUNNABLE 상태를 유지하고 있으므로 바로 다시 실행될 수 있다.

Thread thread = new Thread(() -> {
    for (int i = 0; i < 100; i++) {
        System.out.println("작업: " + i);
        Thread.yield(); // CPU 시간을 다른 스레드에게 양보
    }
});

yield()는 선택적이고 스레드 스케줄러의 판단에 맡겨지므로, 실제 동작을 보장할 수 없다. 이 때문에 성능 튜닝 목적으로만 사용하는 것이 좋다.

 

wait()와 notify() 메서드

wait()와 notify()는 스레드 간 통신을 위해 사용된다. wait()는 특정 조건을 기다리는 상태로 진입하고, notify()는 대기 중인 스레드를 깨운다. 반드시 synchronized 블록 안에서만 사용해야 한다.

class DataBox {
    private String data;
    
    public synchronized String getData() {
        while (data == null) {
            try {
                this.wait(); // 데이터가 들어올 때까지 WAITING 상태
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        
        String returnValue = data;
        System.out.println("데이터 읽기: " + returnValue);
        data = null;
        this.notify(); // 대기 중인 다른 스레드 깨우기
        return returnValue;
    }
    
    public synchronized void setData(String data) {
        while (this.data != null) {
            try {
                this.wait(); // 데이터가 읽혀질 때까지 WAITING 상태
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        
        this.data = data;
        System.out.println("데이터 생성: " + data);
        this.notify(); // 대기 중인 다른 스레드 깨우기
    }
}

class Producer extends Thread {
    private DataBox dataBox;
    
    public Producer(DataBox dataBox) {
        this.dataBox = dataBox;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 3; i++) {
            dataBox.setData("데이터 " + i);
        }
    }
}

class Consumer extends Thread {
    private DataBox dataBox;
    
    public Consumer(DataBox dataBox) {
        this.dataBox = dataBox;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 3; i++) {
            dataBox.getData();
        }
    }
}

public class WaitNotifyExample {
    public static void main(String[] args) {
        DataBox dataBox = new DataBox();
        
        Producer producer = new Producer(dataBox);
        Consumer consumer = new Consumer(dataBox);
        
        producer.start();
        consumer.start();
    }
}

이 예제는 프로듀서와 컨슈머 패턴으로, 프로듀서가 데이터를 생성하면 컨슈머가 그 데이터를 읽는 구조다. wait()와 notify()를 사용해 두 스레드의 작업을 완벽하게 동기화한다.

실습: 스레드 상태 모니터링하기

스레드의 상태를 직접 모니터링해보는 것이 이해에 큰 도움이 된다. Thread.getState() 메서드를 사용하면 스레드의 현재 상태를 얻을 수 있다.

public class ThreadStateMonitor {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            try {
                System.out.println("스레드 작업 시작");
                Thread.sleep(2000);
                System.out.println("스레드 작업 종료");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        
        // NEW 상태
        System.out.println("스레드 상태: " + thread.getState()); // NEW
        
        // start() 호출 직후
        thread.start();
        System.out.println("start() 호출 후: " + thread.getState()); // RUNNABLE
        
        // sleep() 상태
        Thread.sleep(1000);
        System.out.println("sleep() 중: " + thread.getState()); // TIMED_WAITING
        
        // 작업 완료
        Thread.sleep(2000);
        System.out.println("작업 완료: " + thread.getState()); // TERMINATED
    }
}

이 코드를 실행하면 스레드가 생성되고 시작되고 대기하고 종료되는 전체 과정을 상태 변화로 확인할 수 있다.

 

실무에서 Runnable을 더 많이 쓰는 이유

실제 프로젝트에서는 대부분 Runnable을 사용한다. 그 이유를 정리하면 다음과 같다.

 

첫째, 유연성이 뛰어나다. 이미 다른 클래스를 상속받은 상태에서도 Runnable을 구현할 수 있기 때문에 코드 설계의 자유도가 높다.

둘째, 람다 표현식을 사용할 수 있다. 자바 8 이후로는 간단한 작업을 람다로 정의해서 즉석으로 스레드를 만들 수 있다.

셋째, ExecutorService나 ForkJoinPool 같은 고수준 동시성 API들이 모두 Runnable을 기반으로 설계되어 있다. 스레드 풀을 사용할 때도 Runnable이나 Callable을 제출하는 방식이 표준이다.

ExecutorService executorService = Executors.newFixedThreadPool(2);

// Runnable을 사용한 방식
executorService.execute(() -> {
    System.out.println("스레드 풀에서 실행");
});

executorService.shutdown();

넷째, 메모리 효율이 좋다. 스레드 풀에서 수십 개의 스레드를 관리할 때 Runnable의 경량성이 돋보인다.

 

스레드의 라이프사이클과 Runnable/Thread의 선택은 멀티스레딩을 제대로 다루기 위한 기초다. 처음에는 복잡해 보이지만 실제로 코드를 작성하면서 이해하다 보면 자연스럽게 체득된다.

 

정리하면, NEW 상태에서 시작한 스레드는 start()로 RUNNABLE 상태가 되고, 다양한 상황에 따라 상태를 변화시키다가 마지막에 TERMINATED 상태가 된다는 것이다. 그리고 스레드를 만들 때는 특별한 이유가 없는 한 Runnable을 사용하는 것이 코드 품질을 높이고 유지보수를 편하게 한다.

 

면접에서 "스레드 라이프사이클을 설명하세요"라는 질문이 나왔을 때, 이제는 6가지 상태와 그 전환 과정, 그리고 각 메서드의 역할을 명확하게 설명할 수 있을 거다. 코딩 테스트에서도 멀티스레딩 문제가 나왔을 때 더 이상 혼란스러워하지 않을 것이다.

 

다음에는 스레드 동기화와 락(Lock), 그리고 여러 스레드 간의 race condition을 어떻게 해결하는지에 대해 공부해보려고 한다. 그 주제도 많은 사람들이 어려워하는 부분이므로 충분히 깊이 있게 정리할 가치가 있다. 이 글이 그중 하나라도 도움이 되었다면 좋겠다. 스레드를 마스터하면 더 이상 멀티스레딩은 두렵지 않을 거다.

 

 

Java 직렬화 완벽 가이드: 직렬화, transient 키워드, 역직렬화 보안까지 한 번에 이해하기

개발을 하다 보면 객체의 상태를 파일에 저장해야 하거나, 네트워크를 통해 다른 시스템으로 객체를 전달해야 할 때가 있습니다. Java에서 이런 작업을 가능하게 해주는 것이 바로 직렬화(Serializa

byteandbit.tistory.com

 

반응형