프로그램 하나가 동시에 여러 가지 일을 한다는 건 어떤 의미일까? 음악을 재생하면서 파일을 다운로드하고, 화면도 갱신한다. 이걸 가능하게 하는 게 바로 스레드(Thread) 다.

이 글에서는 자바에서 스레드를 만들고 제어하는 기본기를 다루고, 공유 자원 문제가 왜 발생하는지, synchronized와 volatile이 각각 어떤 문제를 해결하는지까지 정리해 보겠습니다.

프로세스와 스레드 — 간단 복습

자세한 내용은 OS 글에서 다뤘으니 핵심만 짚어 볼게요.

  • 프로세스(Process): 실행 중인 프로그램. 독립된 메모리 공간(코드, 데이터, 힙, 스택)을 가집니다.
  • ** 스레드(Thread)**: 프로세스 안에서 실행되는 작업 단위. 같은 프로세스의 스레드끼리 ** 힙 메모리를 공유 **합니다.
PLAINTEXT
프로세스 A
├── 스레드 1 (스택 독립)
├── 스레드 2 (스택 독립)
└── 힙 메모리 (공유!)

스레드끼리 힙을 공유하기 때문에 데이터 교환이 쉽습니다. 하지만 그만큼 ** 동시에 같은 데이터를 건드리는 문제 **(동시성 문제)가 생겨요. 이 글 후반에서 다룰 내용입니다.

Thread 생성 — 세 가지 방법

1. Thread 클래스 상속

가장 직관적인 방법입니다. Thread를 상속받아 run() 메서드를 오버라이드해요.

JAVA
class MyThread extends Thread {
    @Override
    public void run() {
        // 새 스레드에서 실행될 코드
        System.out.println("스레드 이름: " + getName());
    }
}

MyThread t = new MyThread();
t.start(); // 새 스레드 시작

단점이 명확합니다. Java는 ** 단일 상속 **이라 Thread를 상속받으면 다른 클래스를 상속받을 수 없어요. 실무에서는 거의 쓰지 않는 방법입니다.

2. Runnable 인터페이스 구현

인터페이스 구현이니까 상속 제약이 없습니다. 가장 기본적인 방법이에요.

JAVA
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Runnable: " + Thread.currentThread().getName());
    }
}

Thread t = new Thread(new MyRunnable());
t.start();

3. 람다 표현식

Runnable은 추상 메서드가 하나인 함수형 인터페이스입니다. 람다로 간결하게 쓸 수 있어요.

JAVA
Thread t = new Thread(() -> {
    System.out.println("람다로 생성한 스레드: " + Thread.currentThread().getName());
});
t.start();

실무에서 간단한 작업은 대부분 이 방식을 씁니다. 어떤 방식을 쓰든 핵심은 같아요 — run() 안에 스레드가 실행할 코드를 넣고, start()를 호출하는 것입니다.

구분Thread 상속Runnable 구현람다
상속 제약있음 (단일 상속)없음없음
코드 간결성보통보통좋음
실무 사용거의 안 씀기본간단한 작업에 선호

start() vs run() — 왜 run()을 직접 호출하면 안 되는가

run()을 직접 호출하면 새 스레드가 생기지 않습니다. 왜 그런지 볼게요.

JAVA
Thread t = new Thread(() -> {
    System.out.println("실행 스레드: " + Thread.currentThread().getName());
});

t.run();   // 출력: 실행 스레드: main ← 새 스레드가 아님!
t.start(); // 출력: 실행 스레드: Thread-0 ← 새 스레드에서 실행

run()은 일반 메서드 호출이라 현재 스레드(main)에서 순차 실행됩니다. 반면 start()는 JVM이 내부적으로 세 단계를 거쳐요.

  1. 새 스레드를 위한 ** 콜 스택(call stack)**을 생성합니다
  2. 네이티브 메서드를 통해 OS 스레드를 생성합니다
  3. 새 스레드에서 run()이 호출됩니다

start()를 호출해야 진짜 멀티스레딩이 된다. run() 직접 호출은 싱글스레드에서 메서드를 하나 더 호출한 것과 같다.

스레드 생명주기

스레드는 6가지 상태를 가집니다. Thread.State enum으로 정의되어 있어요.

PLAINTEXT
        start()
NEW ──────────→ RUNNABLE ──────→ TERMINATED
                  ↑   |
                  |   | synchronized 락 대기
                  |   ↓
                  | BLOCKED
                  |
                  | wait(), join()

                WAITING
                  |
                  | sleep(ms), wait(ms), join(ms)

              TIMED_WAITING
상태설명진입 조건
NEW생성만 되고 아직 시작 안 됨new Thread()
RUNNABLE실행 중이거나 실행 대기 중start() 호출
BLOCKED모니터 락을 얻기 위해 대기synchronized 블록 진입 시도
WAITING다른 스레드가 깨워줄 때까지 대기wait(), join(), park()
TIMED_WAITING지정 시간만큼 대기sleep(ms), wait(ms), join(ms)
TERMINATED실행 완료run() 종료 또는 예외

상태 확인은 getState()로 할 수 있습니다.

JAVA
Thread t = new Thread(() -> {
    try {
        Thread.sleep(1000); // TIMED_WAITING 상태
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

System.out.println(t.getState()); // NEW
t.start();
System.out.println(t.getState()); // RUNNABLE (또는 TIMED_WAITING)

BLOCKED와 WAITING의 핵심 차이: BLOCKED는 **synchronized 락을 기다리는 상태 **, WAITING은 wait()/join() 등으로 명시적으로 대기에 들어간 상태 다.

sleep(), join(), interrupt() — 스레드 제어 기본

sleep() — 지정 시간만큼 멈추기

JAVA
System.out.println("작업 시작");
Thread.sleep(2000); // 2초 대기 (TIMED_WAITING 상태)
System.out.println("2초 후 재개");
  • 현재 스레드를 지정 시간 동안 멈춥니다
  • 락을 놓지 않습니다 — synchronized 블록 안에서 sleep하면 다른 스레드는 여전히 대기해요
  • InterruptedException을 던질 수 있으므로 try-catch 필수입니다

join() — 다른 스레드가 끝날 때까지 기다리기

JAVA
Thread worker = new Thread(() -> {
    try {
        Thread.sleep(2000);
        System.out.println("작업 완료");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

worker.start();
System.out.println("worker 스레드 시작됨");

worker.join(); // worker가 끝날 때까지 main 스레드가 대기
System.out.println("worker 종료 확인, 다음 작업 진행");

출력 순서:

PLAINTEXT
worker 스레드 시작됨
작업 완료
worker 종료 확인, 다음 작업 진행

join()이 없으면 main 스레드가 먼저 끝나버릴 수 있습니다. 여러 스레드의 작업이 끝난 후에 결과를 종합해야 할 때 유용해요.

JAVA
// 타임아웃 설정도 가능
worker.join(5000); // 최대 5초만 대기

interrupt() — 스레드에게 중단 신호 보내기

interrupt()는 스레드를 ** 강제로 죽이는 게 아니라 **, "그만하라"는 신호를 보내는 겁니다. 스레드가 이 신호를 어떻게 처리할지는 스레드 스스로 결정해요.

JAVA
Thread longTask = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        System.out.println("작업 중...");
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            System.out.println("인터럽트 받음, 정리하고 종료");
            Thread.currentThread().interrupt(); // 인터럽트 상태 복원
            break;
        }
    }
});

외부에서 longTask.interrupt()를 호출하면, sleep 중이라면 InterruptedException이 발생하고, 아니라면 isInterrupted() 플래그가 true로 바뀝니다.

** 인터럽트 처리의 핵심 규칙:**

  1. sleep(), wait(), join() 중에 인터럽트가 오면 InterruptedException이 발생합니다
  2. 예외를 잡으면 인터럽트 상태가 초기화되므로, Thread.currentThread().interrupt()로 상태를 복원해야 합니다
  3. Thread.stop()은 deprecated입니다. 리소스 정리 기회 없이 강제 종료되기 때문에, 반드시 interrupt() + 플래그 체크 패턴을 써야 해요

공유 자원 문제 — race condition

스레드의 진짜 어려움은 여기서 시작됩니다. 같은 변수를 여러 스레드가 동시에 수정하면 예상치 못한 결과가 나와요.

JAVA
class Counter {
    private int count = 0;

    public void increment() {
        count++; // 이 한 줄이 문제다
    }

    public int getCount() {
        return count;
    }
}

count++는 한 줄이지만, 실제로는 세 단계입니다:

  1. 메모리에서 count 값 읽기 (Read)
  2. 값에 1 더하기 (Modify)
  3. 결과를 메모리에 쓰기 (Write)
JAVA
Counter counter = new Counter();

// 스레드 2개가 각각 10000번씩 increment
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 10000; i++) counter.increment();
});
Thread t2 = new Thread(() -> {
    for (int i = 0; i < 10000; i++) counter.increment();
});

t1.start();
t2.start();
t1.join();
t2.join();

System.out.println("결과: " + counter.getCount());
// 기대값: 20000
// 실제값: 17834 (실행할 때마다 다름!)

왜 이런 일이 생길까요? 두 스레드가 거의 동시에 같은 값을 읽고, 각자 1을 더해서 쓰면 ** 하나의 증가가 사라집니다 **. 이걸 ** 경쟁 상태(race condition)**라고 해요.

PLAINTEXT
스레드1: read(count=5) → add(5+1=6) → write(count=6)
스레드2: read(count=5) → add(5+1=6) → write(count=6)
         ↑ 같은 값을 읽음!         결과: 1만 증가됨

이 문제를 해결하려면 ** 동기화(synchronization)**가 필요합니다.

synchronized 기초 — 메서드 동기화와 블록 동기화

synchronized는 자바에서 가장 기본적인 동기화 도구입니다. 한 번에 하나의 스레드만 임계 영역(critical section)에 진입하도록 보장해요.

메서드 동기화

JAVA
class Counter {
    private int count = 0;

    // synchronized 메서드 — this 객체의 락을 잡는다
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

이제 increment()에는 한 번에 하나의 스레드만 들어갈 수 있습니다. 아까 실행하면 정확히 20000이 나와요.

블록 동기화

메서드 전체가 아니라, 꼭 필요한 부분만 동기화할 수도 있습니다.

JAVA
class Counter {
    private int count = 0;
    private final Object lock = new Object(); // 락 전용 객체

    public void increment() {
        // 이 블록 안에서만 동기화
        synchronized (lock) {
            count++;
        }
    }
}

블록 동기화를 쓰는 이유는, 동기화 범위를 최소화하면 ** 다른 스레드가 대기하는 시간이 줄어들기** 때문입니다. 메서드에 동기화가 필요 없는 코드가 많다면 블록 동기화가 유리해요.

인스턴스 락 vs 클래스 락 (간단히)

JAVA
// 인스턴스 락 — 같은 객체에 대해서만 동기화
public synchronized void instanceMethod() { }

// 클래스 락 — 모든 인스턴스에 걸쳐 동기화
public static synchronized void classMethod() { }

인스턴스가 다르면 인스턴스 락은 서로 간섭하지 않습니다. 이 부분은 다음 글 ** 동시성 심화 **에서 더 자세히 다룰게요.

synchronized의 한계(타임아웃 불가, 공정성 보장 안 됨 등)와 대안인 ReentrantLock, 그리고 ExecutorService를 통한 스레드 풀 관리는 다음 글에서 다룬다.

volatile 기초 — 가시성 문제

volatile은 synchronized와 다른 문제를 해결합니다. ** 가시성(visibility)** 문제예요.

JAVA
class StopFlag {
    private boolean running = true; // volatile 없음

    public void stop() {
        running = false;
    }

    public void run() {
        while (running) {
            // 작업 수행
        }
        System.out.println("종료");
    }
}

이 코드에서 stop()을 호출해도 루프가 끝나지 않을 수 있습니다. 왜일까요? CPU 캐시 때문입니다.

PLAINTEXT
메인 메모리: running = false  (stop()이 변경)
스레드 캐시: running = true   (아직 이전 값을 보고 있음!)

각 스레드는 성능을 위해 변수의 복사본을 CPU 캐시에 저장합니다. 한 스레드가 값을 바꿔도 다른 스레드는 자기 캐시에 있는 오래된 값을 계속 볼 수 있어요.

volatile을 붙이면 해결됩니다:

JAVA
private volatile boolean running = true;
  • 읽기: 항상 ** 메인 메모리 **에서 읽습니다
  • 쓰기: 항상 ** 메인 메모리 **에 즉시 반영합니다

** 주의: volatile은 원자성을 보장하지 않습니다.**

JAVA
private volatile int count = 0;

// 이것은 여전히 안전하지 않다!
count++; // Read → Modify → Write (3단계)

count++ 같은 복합 연산에는 synchronizedAtomicInteger가 필요합니다. volatile은 단순 읽기/쓰기 플래그에 적합해요.

구분synchronizedvolatile
해결하는 문제원자성 + 가시성가시성만
적용 대상메서드, 블록변수
성능 비용상대적으로 큼가벼움
사용 예카운터, 복합 연산boolean 플래그, 상태값

wait()와 notify() — 스레드 간 협력

지금까지는 "다른 스레드를 막는" 동기화였습니다. wait()notify()는 스레드끼리 ** 협력 **할 때 씁니다.

전형적인 예는 ** 생산자-소비자 패턴 **입니다. 생산자는 큐가 가득 차면 대기하고, 소비자는 큐가 비면 대기해요.

JAVA
class SharedQueue {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int MAX_SIZE = 5;

    public synchronized void produce(int item) throws InterruptedException {
        while (queue.size() == MAX_SIZE) {
            wait(); // 락을 놓고 대기
        }
        queue.add(item);
        notifyAll(); // 대기 중인 소비자 깨우기
    }

소비자 쪽도 같은 구조입니다. 큐가 비어있으면 wait()로 대기하고, 아이템을 꺼낸 뒤 생산자를 깨워요.

JAVA
    public synchronized int consume() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();
        }
        int item = queue.poll();
        notifyAll(); // 대기 중인 생산자 깨우기
        return item;
    }
}

여기서 wait()sleep()의 결정적 차이가 드러납니다. wait()는 ** 락을 놓고** 대기하기 때문에 다른 스레드가 같은 synchronized 블록에 진입할 수 있어요. sleep()은 락을 유지한 채 멈추므로 다른 스레드가 접근할 수 없습니다.

wait()/notify() 핵심 규칙:

  1. 반드시 synchronized 블록 안에서 호출해야 합니다 (아니면 IllegalMonitorStateException)
  2. 조건 체크는 반드시 while로 해야 합니다 (if로 하면 spurious wakeup 문제)
  3. notify()는 대기 스레드 하나만, notifyAll()은 전부 깨웁니다

실무에서는 wait/notify 대신 java.util.concurrentBlockingQueue를 쓴다. wait/notify는 동작 원리를 이해하는 차원에서 알아두면 충분하다.

데몬 스레드

데몬 스레드(Daemon Thread)는 ** 백그라운드에서 보조 작업을 수행하는 스레드 **입니다. 모든 일반 스레드(user thread)가 종료되면 데몬 스레드는 자동으로 종료돼요.

JAVA
Thread daemon = new Thread(() -> {
    while (true) {
        System.out.println("백그라운드 작업 실행 중...");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            break;
        }
    }
});

daemon.setDaemon(true); // 반드시 start() 전에 설정
daemon.start();

Thread.sleep(3000);
System.out.println("main 종료 → 데몬도 자동 종료");

대표적인 데몬 스레드:

  • GC(Garbage Collector): 사용하지 않는 객체를 정리
  • **JIT 컴파일러 **: 바이트코드를 네이티브 코드로 변환

데몬 스레드 안에서는 finally 블록이 실행되지 않을 수 있습니다. 중요한 정리 작업(파일 닫기, DB 연결 해제 등)은 데몬 스레드에 맡기면 안 돼요.

주의할 점

sleep() 안에서 락을 놓지 않는 실수

synchronized 블록 안에서 sleep()을 호출하면 락을 잡은 채로 멈춥니다. 다른 스레드가 같은 락을 필요로 하면 sleep이 끝날 때까지 전부 대기하게 돼요. 스레드 간 협력이 필요한 상황에서는 wait()를 써야 락을 반납합니다.

volatile로 count++를 보호하려는 실수

volatile은 가시성만 보장하지 원자성은 보장하지 않습니다. count++는 read-modify-write 세 단계이므로, 두 스레드가 동시에 같은 값을 읽고 각자 1을 더해 쓰면 하나의 증가가 사라져요. 복합 연산에는 반드시 synchronizedAtomicInteger를 써야 합니다.

InterruptedException을 삼켜버리는 실수

InterruptedException을 catch하고 아무것도 하지 않으면 인터럽트 신호가 사라집니다. 상위 코드에서 인터럽트를 감지할 수 없게 되므로, 반드시 Thread.currentThread().interrupt()로 상태를 복원하거나 예외를 다시 던져야 해요.

정리 테이블

주제핵심 요약
Thread 생성Thread 상속(비추) → Runnable 구현 → 람다(선호)
start() vs run()start()만이 새 스레드를 만든다. run()은 일반 메서드 호출
스레드 상태NEW → RUNNABLE → (BLOCKED/WAITING/TIMED_WAITING) → TERMINATED
sleep()현재 스레드 일시 정지. ** 락을 놓지 않음**
join()대상 스레드 종료까지 대기
interrupt()중단 신호 전송. 강제 종료가 아님
race condition공유 자원에 동시 접근 → 예상치 못한 결과
synchronized메서드/블록 동기화. 원자성 + 가시성 보장
volatile가시성만 보장. 단순 플래그용
wait()/notify()스레드 간 협력. synchronized 안에서, ** 락을 놓고** 대기
데몬 스레드백그라운드 보조 스레드. user thread 종료 시 자동 종료

TIP: 이 글의 예제 코드는 examples/16에서 직접 실행해볼 수 있다.

댓글 로딩 중...