Theme:

프로그램 하나가 동시에 여러 가지 일을 한다는 건 어떤 의미일까? 음악을 재생하면서 파일을 다운로드하고, 화면도 갱신한다. 이걸 가능하게 하는 게 바로 **스레드(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()을 직접 호출하면 되지, 왜 굳이 start()를 써야 할까?

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

t.run();   // 출력: 실행 스레드: main ← 새 스레드가 아님!
t.start(); // 출력: 실행 스레드: Thread-0 ← 새 스레드에서 실행
  • run() 직접 호출: 그냥 일반 메서드 호출이다. 현재 스레드(main)에서 순차 실행된다.
  • start() 호출: JVM이 새로운 스레드를 생성하고, 그 스레드에서 run()을 실행한다.

start() 내부에서 일어나는 일:

  1. JVM이 새 스레드를 위한 **콜 스택(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은 명시적으로 대기에 들어간 상태라고 기억하면 된다.

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) {
            // sleep 중 interrupt되면 여기로 온다
            System.out.println("인터럽트 받음, 정리하고 종료");
            Thread.currentThread().interrupt(); // 인터럽트 상태 복원
            break;
        }
    }
    System.out.println("스레드 종료");
});

longTask.start();
Thread.sleep(2000);
longTask.interrupt(); // 2초 후 중단 신호

인터럽트 처리의 핵심:

  • sleep(), wait(), join() 중에 인터럽트가 오면 InterruptedException이 발생한다
  • 예외를 잡았을 때 인터럽트 상태가 초기화되므로, 필요하면 Thread.currentThread().interrupt()로 상태를 다시 설정한다
  • 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) {
            System.out.println("큐가 가득 참, 생산자 대기");
            wait(); // 락을 놓고 대기
        }
        queue.add(item);
        System.out.println("생산: " + item + " (큐 크기: " + queue.size() + ")");
        notifyAll(); // 대기 중인 소비자 깨우기
    }

    // 소비자: 큐가 비면 대기
    public synchronized int consume() throws InterruptedException {
        while (queue.isEmpty()) {
            System.out.println("큐가 비었음, 소비자 대기");
            wait(); // 락을 놓고 대기
        }
        int item = queue.poll();
        System.out.println("소비: " + item + " (큐 크기: " + queue.size() + ")");
        notifyAll(); // 대기 중인 생산자 깨우기
        return item;
    }
}

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

  • 반드시 synchronized 블록 안에서 호출해야 한다 (그렇지 않으면 IllegalMonitorStateException)
  • wait()락을 놓고 대기한다 (sleep()은 락을 놓지 않는다!)
  • 조건 체크는 반드시 while로 해야 한다 (if로 하면 spurious wakeup 문제)
  • notify()는 하나만, notifyAll()은 모든 대기 스레드를 깨운다

실무에서는 wait/notify 대신 java.util.concurrentBlockingQueue를 쓰는 경우가 훨씬 많다. 원리를 이해하는 차원에서 알아두면 좋다.

데몬 스레드

데몬 스레드(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 연결 해제 등)은 데몬 스레드에 맡기면 안 된다.

정리 테이블

주제핵심 요약
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에서 직접 실행해볼 수 있다.

다음 글 미리보기

이번 글에서는 스레드의 기본기를 다뤘다. 하지만 실무에서는 synchronized만으로는 부족한 경우가 많다. 타임아웃이 필요하거나, 스레드를 매번 직접 생성하는 건 비효율적이다.

다음 글에서는 동시성 심화를 다룬다. ReentrantLock, ExecutorService, CompletableFuture까지 — 실무에서 쓰는 동시성 도구를 정리한다.

댓글 로딩 중...