스레드 기초 — Thread, Runnable, 그리고 동기화의 시작
프로그램 하나가 동시에 여러 가지 일을 한다는 건 어떤 의미일까? 음악을 재생하면서 파일을 다운로드하고, 화면도 갱신한다. 이걸 가능하게 하는 게 바로 스레드(Thread) 다.
이 글에서는 자바에서 스레드를 만들고 제어하는 기본기를 다루고, 공유 자원 문제가 왜 발생하는지, synchronized와 volatile이 각각 어떤 문제를 해결하는지까지 정리해 보겠습니다.
프로세스와 스레드 — 간단 복습
자세한 내용은 OS 글에서 다뤘으니 핵심만 짚어 볼게요.
- 프로세스(Process): 실행 중인 프로그램. 독립된 메모리 공간(코드, 데이터, 힙, 스택)을 가집니다.
- ** 스레드(Thread)**: 프로세스 안에서 실행되는 작업 단위. 같은 프로세스의 스레드끼리 ** 힙 메모리를 공유 **합니다.
프로세스 A
├── 스레드 1 (스택 독립)
├── 스레드 2 (스택 독립)
└── 힙 메모리 (공유!)
스레드끼리 힙을 공유하기 때문에 데이터 교환이 쉽습니다. 하지만 그만큼 ** 동시에 같은 데이터를 건드리는 문제 **(동시성 문제)가 생겨요. 이 글 후반에서 다룰 내용입니다.
Thread 생성 — 세 가지 방법
1. Thread 클래스 상속
가장 직관적인 방법입니다. Thread를 상속받아 run() 메서드를 오버라이드해요.
class MyThread extends Thread {
@Override
public void run() {
// 새 스레드에서 실행될 코드
System.out.println("스레드 이름: " + getName());
}
}
MyThread t = new MyThread();
t.start(); // 새 스레드 시작
단점이 명확합니다. Java는 ** 단일 상속 **이라 Thread를 상속받으면 다른 클래스를 상속받을 수 없어요. 실무에서는 거의 쓰지 않는 방법입니다.
2. Runnable 인터페이스 구현
인터페이스 구현이니까 상속 제약이 없습니다. 가장 기본적인 방법이에요.
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은 추상 메서드가 하나인 함수형 인터페이스입니다. 람다로 간결하게 쓸 수 있어요.
Thread t = new Thread(() -> {
System.out.println("람다로 생성한 스레드: " + Thread.currentThread().getName());
});
t.start();
실무에서 간단한 작업은 대부분 이 방식을 씁니다. 어떤 방식을 쓰든 핵심은 같아요 — run() 안에 스레드가 실행할 코드를 넣고, start()를 호출하는 것입니다.
| 구분 | Thread 상속 | Runnable 구현 | 람다 |
|---|---|---|---|
| 상속 제약 | 있음 (단일 상속) | 없음 | 없음 |
| 코드 간결성 | 보통 | 보통 | 좋음 |
| 실무 사용 | 거의 안 씀 | 기본 | 간단한 작업에 선호 |
start() vs run() — 왜 run()을 직접 호출하면 안 되는가
run()을 직접 호출하면 새 스레드가 생기지 않습니다. 왜 그런지 볼게요.
Thread t = new Thread(() -> {
System.out.println("실행 스레드: " + Thread.currentThread().getName());
});
t.run(); // 출력: 실행 스레드: main ← 새 스레드가 아님!
t.start(); // 출력: 실행 스레드: Thread-0 ← 새 스레드에서 실행
run()은 일반 메서드 호출이라 현재 스레드(main)에서 순차 실행됩니다. 반면 start()는 JVM이 내부적으로 세 단계를 거쳐요.
- 새 스레드를 위한 ** 콜 스택(call stack)**을 생성합니다
- 네이티브 메서드를 통해 OS 스레드를 생성합니다
- 새 스레드에서
run()이 호출됩니다
start()를 호출해야 진짜 멀티스레딩이 된다.run()직접 호출은 싱글스레드에서 메서드를 하나 더 호출한 것과 같다.
스레드 생명주기
스레드는 6가지 상태를 가집니다. Thread.State enum으로 정의되어 있어요.
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()로 할 수 있습니다.
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() — 지정 시간만큼 멈추기
System.out.println("작업 시작");
Thread.sleep(2000); // 2초 대기 (TIMED_WAITING 상태)
System.out.println("2초 후 재개");
- 현재 스레드를 지정 시간 동안 멈춥니다
- 락을 놓지 않습니다 — synchronized 블록 안에서 sleep하면 다른 스레드는 여전히 대기해요
InterruptedException을 던질 수 있으므로 try-catch 필수입니다
join() — 다른 스레드가 끝날 때까지 기다리기
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 종료 확인, 다음 작업 진행");
출력 순서:
worker 스레드 시작됨
작업 완료
worker 종료 확인, 다음 작업 진행
join()이 없으면 main 스레드가 먼저 끝나버릴 수 있습니다. 여러 스레드의 작업이 끝난 후에 결과를 종합해야 할 때 유용해요.
// 타임아웃 설정도 가능
worker.join(5000); // 최대 5초만 대기
interrupt() — 스레드에게 중단 신호 보내기
interrupt()는 스레드를 ** 강제로 죽이는 게 아니라 **, "그만하라"는 신호를 보내는 겁니다. 스레드가 이 신호를 어떻게 처리할지는 스레드 스스로 결정해요.
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로 바뀝니다.
** 인터럽트 처리의 핵심 규칙:**
sleep(),wait(),join()중에 인터럽트가 오면InterruptedException이 발생합니다- 예외를 잡으면 인터럽트 상태가 초기화되므로,
Thread.currentThread().interrupt()로 상태를 복원해야 합니다 Thread.stop()은 deprecated입니다. 리소스 정리 기회 없이 강제 종료되기 때문에, 반드시interrupt()+ 플래그 체크 패턴을 써야 해요
공유 자원 문제 — race condition
스레드의 진짜 어려움은 여기서 시작됩니다. 같은 변수를 여러 스레드가 동시에 수정하면 예상치 못한 결과가 나와요.
class Counter {
private int count = 0;
public void increment() {
count++; // 이 한 줄이 문제다
}
public int getCount() {
return count;
}
}
count++는 한 줄이지만, 실제로는 세 단계입니다:
- 메모리에서 count 값 읽기 (Read)
- 값에 1 더하기 (Modify)
- 결과를 메모리에 쓰기 (Write)
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)**라고 해요.
스레드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)에 진입하도록 보장해요.
메서드 동기화
class Counter {
private int count = 0;
// synchronized 메서드 — this 객체의 락을 잡는다
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
이제 increment()에는 한 번에 하나의 스레드만 들어갈 수 있습니다. 아까 실행하면 정확히 20000이 나와요.
블록 동기화
메서드 전체가 아니라, 꼭 필요한 부분만 동기화할 수도 있습니다.
class Counter {
private int count = 0;
private final Object lock = new Object(); // 락 전용 객체
public void increment() {
// 이 블록 안에서만 동기화
synchronized (lock) {
count++;
}
}
}
블록 동기화를 쓰는 이유는, 동기화 범위를 최소화하면 ** 다른 스레드가 대기하는 시간이 줄어들기** 때문입니다. 메서드에 동기화가 필요 없는 코드가 많다면 블록 동기화가 유리해요.
인스턴스 락 vs 클래스 락 (간단히)
// 인스턴스 락 — 같은 객체에 대해서만 동기화
public synchronized void instanceMethod() { }
// 클래스 락 — 모든 인스턴스에 걸쳐 동기화
public static synchronized void classMethod() { }
인스턴스가 다르면 인스턴스 락은 서로 간섭하지 않습니다. 이 부분은 다음 글 ** 동시성 심화 **에서 더 자세히 다룰게요.
synchronized의 한계(타임아웃 불가, 공정성 보장 안 됨 등)와 대안인
ReentrantLock, 그리고ExecutorService를 통한 스레드 풀 관리는 다음 글에서 다룬다.
volatile 기초 — 가시성 문제
volatile은 synchronized와 다른 문제를 해결합니다. ** 가시성(visibility)** 문제예요.
class StopFlag {
private boolean running = true; // volatile 없음
public void stop() {
running = false;
}
public void run() {
while (running) {
// 작업 수행
}
System.out.println("종료");
}
}
이 코드에서 stop()을 호출해도 루프가 끝나지 않을 수 있습니다. 왜일까요? CPU 캐시 때문입니다.
메인 메모리: running = false (stop()이 변경)
스레드 캐시: running = true (아직 이전 값을 보고 있음!)
각 스레드는 성능을 위해 변수의 복사본을 CPU 캐시에 저장합니다. 한 스레드가 값을 바꿔도 다른 스레드는 자기 캐시에 있는 오래된 값을 계속 볼 수 있어요.
volatile을 붙이면 해결됩니다:
private volatile boolean running = true;
- 읽기: 항상 ** 메인 메모리 **에서 읽습니다
- 쓰기: 항상 ** 메인 메모리 **에 즉시 반영합니다
** 주의: volatile은 원자성을 보장하지 않습니다.**
private volatile int count = 0;
// 이것은 여전히 안전하지 않다!
count++; // Read → Modify → Write (3단계)
count++ 같은 복합 연산에는 synchronized나 AtomicInteger가 필요합니다. volatile은 단순 읽기/쓰기 플래그에 적합해요.
| 구분 | synchronized | volatile |
|---|---|---|
| 해결하는 문제 | 원자성 + 가시성 | 가시성만 |
| 적용 대상 | 메서드, 블록 | 변수 |
| 성능 비용 | 상대적으로 큼 | 가벼움 |
| 사용 예 | 카운터, 복합 연산 | boolean 플래그, 상태값 |
wait()와 notify() — 스레드 간 협력
지금까지는 "다른 스레드를 막는" 동기화였습니다. wait()와 notify()는 스레드끼리 ** 협력 **할 때 씁니다.
전형적인 예는 ** 생산자-소비자 패턴 **입니다. 생산자는 큐가 가득 차면 대기하고, 소비자는 큐가 비면 대기해요.
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()로 대기하고, 아이템을 꺼낸 뒤 생산자를 깨워요.
public synchronized int consume() throws InterruptedException {
while (queue.isEmpty()) {
wait();
}
int item = queue.poll();
notifyAll(); // 대기 중인 생산자 깨우기
return item;
}
}
여기서 wait()와 sleep()의 결정적 차이가 드러납니다. wait()는 ** 락을 놓고** 대기하기 때문에 다른 스레드가 같은 synchronized 블록에 진입할 수 있어요. sleep()은 락을 유지한 채 멈추므로 다른 스레드가 접근할 수 없습니다.
wait()/notify() 핵심 규칙:
- 반드시
synchronized블록 안에서 호출해야 합니다 (아니면IllegalMonitorStateException) - 조건 체크는 반드시
while로 해야 합니다 (if로 하면 spurious wakeup 문제) notify()는 대기 스레드 하나만,notifyAll()은 전부 깨웁니다
실무에서는 wait/notify 대신
java.util.concurrent의BlockingQueue를 쓴다. wait/notify는 동작 원리를 이해하는 차원에서 알아두면 충분하다.
데몬 스레드
데몬 스레드(Daemon Thread)는 ** 백그라운드에서 보조 작업을 수행하는 스레드 **입니다. 모든 일반 스레드(user thread)가 종료되면 데몬 스레드는 자동으로 종료돼요.
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을 더해 쓰면 하나의 증가가 사라져요. 복합 연산에는 반드시 synchronized나 AtomicInteger를 써야 합니다.
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에서 직접 실행해볼 수 있다.