스레드 기초 — 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()을 직접 호출하면 되지, 왜 굳이 start()를 써야 할까?
Thread t = new Thread(() -> {
System.out.println("실행 스레드: " + Thread.currentThread().getName());
});
t.run(); // 출력: 실행 스레드: main ← 새 스레드가 아님!
t.start(); // 출력: 실행 스레드: Thread-0 ← 새 스레드에서 실행
run()직접 호출: 그냥 일반 메서드 호출이다. 현재 스레드(main)에서 순차 실행된다.start()호출: JVM이 새로운 스레드를 생성하고, 그 스레드에서run()을 실행한다.
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은 명시적으로 대기에 들어간 상태라고 기억하면 된다.
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) {
// 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
스레드의 진짜 어려움은 여기서 시작된다. 같은 변수를 여러 스레드가 동시에 수정하면 예상치 못한 결과가 나온다.
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) {
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.concurrent의BlockingQueue를 쓰는 경우가 훨씬 많다. 원리를 이해하는 차원에서 알아두면 좋다.
데몬 스레드
데몬 스레드(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 연결 해제 등)은 데몬 스레드에 맡기면 안 된다.
정리 테이블
| 주제 | 핵심 요약 |
|---|---|
| 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까지 — 실무에서 쓰는 동시성 도구를 정리한다.