방송대 Java프로그래밍 13강: 스레드 상태 제어와 동기화
증가 10만 번과 감소 10만 번을 실행했는데 결과가 0이 아닐 수 있다. 멀티 스레드에서는 실행 순서가 고정되지 않을 뿐 아니라, 공유 값을 읽고 바꾸는 동작이 겹치면 계산 자체가 사라질 수 있기 때문이다. 이 글은 스레드 생성부터 상태 제어, 실행 완료 대기, 공유 객체의 동기화까지를 하나의 문제 해결 흐름으로 연결한다.
문제의 출발점은 동시 실행과 정확한 결과를 구분하는 것이다
프로그램을 실행하면 자원과 실행 환경을 가진 프로세스(process)가 만들어지고, Java 프로그램은 main()을 수행하는 하나의 스레드(thread)에서 시작한다. 스레드는 프로세스 안에서 코드를 따라가는 실행 흐름이다. 하나의 프로세스가 여러 스레드를 가지면 작업을 나누어 진행할 수 있지만, 각 흐름의 실제 실행 순서는 운영 환경의 스케줄링에 따라 달라진다.
| 구분 | 프로세스 | 스레드 |
|---|---|---|
| 역할 | 실행 중인 독립 프로그램과 그 자원 | 프로세스 안에서 코드를 수행하는 실행 흐름 |
| Java 시작 시점 | 프로그램 실행과 함께 생성 | main()을 수행하는 메인 스레드로 시작 |
| 다중 실행 | 각 프로세스가 독립적으로 실행 | 한 프로세스 안에서 여러 스레드가 병행 가능 |
| 종료 판단 | 메인 스레드만 끝났다고 즉시 종료되는 것이 아니라 실행 중인 모든 스레드가 끝나야 프로그램이 종료 | |
여기서 두 질문을 분리해야 한다. “두 작업을 함께 진행할 수 있는가?”는 동시 실행의 문제이고, “공유 값이 언제나 올바른가?”는 데이터 무결성의 문제다. 스레드를 만들기만 해서는 두 번째 문제가 해결되지 않는다.
핵심 관점: 출력 순서가 매번 달라지는 현상은 스케줄링의 결과일 수 있지만, 최종 계산값이 틀리는 현상은 공유 상태 간섭을 의심해야 한다.
스레드 생성은 Thread 상속과 Runnable 구현으로 나뉜다
실행할 코드는 run()에 작성하고, 새 실행 흐름을 요청할 때는 start()를 호출한다. 강의에서는 두 가지 작성 방법을 제시한다.
| 방법 | 작성 순서 | 특징 |
|---|---|---|
Thread 상속 | 하위 클래스에서 run() 재정의 → 객체 생성 → start() | 스레드와 작업 코드가 한 클래스에 결합 |
Runnable 구현 | run() 구현 → 객체를 Thread 생성자에 전달 → start() | 실행할 작업과 스레드 객체의 역할을 분리 |
class PrintThread extends Thread {
@Override
public void run() {
System.out.println(getName() + ": Thread 상속");
}
}
class PrintTask implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()
+ ": Runnable 구현");
}
}
Thread first = new PrintThread();
Thread second = new Thread(new PrintTask(), "worker-2");
first.start();
second.start();
Thread()는 자동 이름을 부여하고, Thread(String name)은 이름을 직접 받는다. 작업 객체를 사용할 때는 Thread(Runnable target) 또는 Thread(Runnable target, String name)을 쓴다. 현재 실행 중인 스레드는 Thread.currentThread()로 확인하고, 이름은 getName()과 setName()으로 다룬다.
run을 직접 호출하면 새 스레드가 시작되지 않는다
start()는 새 스레드가 실행 가능한 상태가 되도록 요청하고, 그 새 실행 흐름이 준비되면 run()을 수행한다. 반면 run()을 일반 메서드처럼 직접 호출하면 현재 스레드의 호출 스택에서 실행될 뿐이다.
class NameTask implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
Thread worker = new Thread(new NameTask(), "worker");
worker.run(); // main 출력: 새 스레드가 아님
worker.start(); // worker 출력: 새 실행 흐름에서 수행
자주 생기는 오개념: run() 안에 같은 코드가 있다는 이유로 run()과 start()를 바꾸어 써도 되는 것은 아니다. 멀티 스레드 실행의 경계는 start()다.
출력 순서는 예측 대상이 아니라 가능한 결과의 집합이다
메인 스레드가 두 자식 스레드의 start()를 순서대로 호출해도 첫 번째 작업이 반드시 먼저 끝나지는 않는다. start()의 호출 순서는 실행 완료 순서를 예약하는 명령이 아니기 때문이다.
Thread t1 = new Thread(new PrintTask(), "alpha");
Thread t2 = new Thread(new PrintTask(), "beta");
t1.start();
t2.start();
System.out.println("main continues");
alpha, beta, main continues의 출력 순서는 실행할 때마다 달라질 수 있다. 이는 곧바로 오류를 뜻하지 않는다. 순서 요구가 없는 독립 작업이라면 정상이다. 그러나 “두 작업이 끝난 뒤 집계 결과를 출력한다”처럼 선후 관계가 필요하면 별도의 대기 수단을 사용해야 한다.
강의의 상태 모형은 실행 기회를 중심으로 읽는다
강의는 스레드의 흐름을 Startable, Runnable, Running, Not Running, Dead라는 학습용 모형으로 설명한다. 이 모형은 Java API의 세부 상태명을 그대로 나열하기보다 “생성됨, 실행 기회를 기다림, 실행 중, 잠시 실행할 수 없음, 종료됨”의 이동을 이해하는 데 초점을 둔다.
| 강의의 상태 | 뜻 | 대표 전이 |
|---|---|---|
| Startable | 객체는 생성됐지만 아직 시작 전 | start() → Runnable |
| Runnable | 실행할 수 있으며 CPU 기회를 기다림 | 스케줄 선택 → Running |
| Running | 실제로 코드를 수행 중 | CPU 양보·상실 → Runnable, 대기 호출 → Not Running |
| Not Running | 블록·대기·시간 제한 대기 등으로 당장 실행 불가 | 시간 만료·알림·인터럽트 → Runnable |
| Dead | run() 수행이 끝난 종료 상태 | 다시 시작할 수 없음 |
한 줄로 외우기보다 사건을 따라가면 쉽다. 객체 생성 후 start()를 호출하면 실행 가능해지고, CPU를 얻으면 수행하며, sleep()이나 join(), wait()로 대기했다가 조건이 충족되면 다시 실행 기회를 기다린다. 마지막으로 run()이 끝나면 종료된다.
제어 메서드는 누구를 멈추고 무엇을 기다리는지로 고른다
| 메서드 | 핵심 효과 | 해석할 때의 질문 |
|---|---|---|
setPriority(int) | 스케줄링 우선순위를 설정 | 실행 순서를 보장하는가? 아니며 기회에 영향을 줄 뿐이다. |
sleep(millis) | 현재 실행 중인 스레드를 정해진 시간 동안 재움 | 누가 쉬는가? 호출문을 수행한 현재 스레드다. |
yield() | 현재 스레드가 실행 기회를 양보하도록 요청 | 다른 특정 스레드의 즉시 실행을 보장하는가? 아니다. |
join() | 대상 스레드가 끝날 때까지 현재 스레드가 기다림 | 누구의 종료를 기다리는가? 메서드를 호출한 대상 객체다. |
interrupt() | 대기 중인 스레드에 인터럽트를 전달 | sleep·join·wait 중이면 예외 처리가 준비됐는가? |
wait() / notify() | 같은 객체 조건을 기다리거나 대기 스레드 하나를 깨움 | 같은 객체 모니터를 보유한 synchronized 영역인가? |
sleep()과 join()은 대기 도중 인터럽트를 받으면 InterruptedException을 처리해야 한다. 시간 제한형 join(millis)은 그 시간까지만 기다리며, 시간이 지났다고 대상 스레드가 반드시 종료된 것은 아니다.
join은 완료 순서를 만들지만 공유 계산을 보호하지는 않는다
다음 코드는 두 작업을 먼저 시작한 뒤 메인 스레드가 각각의 종료를 기다린다. 따라서 마지막 문장은 두 자식 스레드가 모두 끝난 뒤에만 출력된다.
Thread t1 = new Thread(new PrintTask(), "alpha");
Thread t2 = new Thread(new PrintTask(), "beta");
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("all workers finished");
join()이 보장하는 것은 “기다리는 코드가 대상의 종료 뒤에 진행한다”는 관계다. alpha와 beta 가운데 누가 먼저 끝날지는 여전히 정해지지 않는다. 또한 두 작업이 같은 변수를 동시에 수정한다면 join()만으로 수정 과정의 충돌을 막을 수 없다.
wait와 notify는 같은 객체를 매개로 조건 대기를 연결한다
wait()는 현재 스레드가 같은 객체에 대한 알림을 기다리게 하고, notify()는 그 객체에서 기다리는 스레드 중 하나를 깨운다. 두 메서드는 대상 객체의 모니터를 보유한 synchronized 메서드 또는 블록 안에서 호출해야 한다.
class Signal {
private boolean ready;
public synchronized void awaitReady() throws InterruptedException {
while (!ready) {
wait();
}
}
public synchronized void makeReady() {
ready = true;
notify();
}
}
대기 조건은 if 한 번이 아니라 while로 다시 확인하는 형태가 안전하다. 알림은 “조건이 반드시 참이다”라는 값을 대신하지 않으므로, 깨어난 뒤 공유 조건을 재검사해야 한다. 강의의 핵심은 wait()와 notify()가 동일 객체의 동기화 문맥에서 짝을 이룬다는 점이다.
공유 카운터의 증감은 한 번의 원자적 동작이 아니다
count++와 count--는 한 줄이지만 내부적으로는 값을 읽고, 계산하고, 다시 저장하는 단계로 이해해야 한다. 두 스레드가 이 단계 사이에 끼어들면 한쪽 계산이 덮어써질 수 있다.
| 순간 | 증가 스레드 | 감소 스레드 | 공유 count |
|---|---|---|---|
| 1 | 0을 읽음 | - | 0 |
| 2 | 로컬에서 1 계산 | 0을 읽음 | 0 |
| 3 | 1을 저장 | 로컬에서 -1 계산 | 1 |
| 4 | - | -1을 저장해 앞선 결과를 덮음 | -1 |
class Counter {
private int count;
public void increment() { count++; }
public void decrement() { count--; }
public int value() { return count; }
}
증가 10만 번과 감소 10만 번을 각각 수행한 뒤 두 스레드에 join()을 호출하면 모든 반복은 끝났음을 알 수 있다. 그러나 각 반복의 읽기-수정-쓰기가 서로 간섭할 수 있으므로 최종값 0은 보장되지 않는다. 이것이 실행 완료 대기와 임계 구역 보호를 구분해야 하는 이유다.
synchronized는 같은 잠금으로 임계 구역 진입을 직렬화한다
synchronized 메서드는 호출 대상 객체의 잠금을 얻은 스레드 하나만 실행할 수 있게 한다. 다른 스레드는 잠금이 풀릴 때까지 기다린다. 증가와 감소가 같은 Counter 객체의 같은 잠금을 사용하면 읽기-수정-쓰기 전체가 보호된다.
class SafeCounter {
private int count;
public synchronized void increment() {
count++;
}
public synchronized void decrement() {
count--;
}
public synchronized int value() {
return count;
}
}
메서드 전체가 아니라 일부 코드만 보호할 때는 동기화 블록을 사용한다.
public void increment() {
synchronized (this) {
count++;
}
}
위 두 방식은 모두 같은 객체인 this의 잠금을 사용한다. 중요한 것은 키워드의 개수가 아니라 충돌할 수 있는 모든 접근이 동일한 잠금 규칙에 참여하는가다. 증가만 동기화하고 감소는 보호하지 않으면 상호 배제가 완성되지 않는다.
세 질문으로 스레드 문제의 해결 도구를 정한다
- 새 실행 흐름이 필요한가? 작업을
run()에 정의하고start()로 시작한다. 단순히run()을 직접 부르면 현재 흐름에서 실행된다. - 특정 사건까지 기다려야 하는가? 대상 스레드의 종료라면
join(), 시간 경과라면sleep(), 같은 객체의 조건 변화라면wait()와notify()를 검토한다. - 공유 값을 여러 스레드가 수정하는가? 읽기-수정-쓰기 과정이 겹칠 수 있다면 같은 잠금의
synchronized임계 구역으로 보호한다.
도구를 섞지 않는 법: priority와 yield()는 원하는 순서를 보장하지 않고, join()은 작업 내부의 공유 데이터 경쟁을 막지 않으며, synchronized는 어떤 스레드가 먼저 잠금을 얻는지까지 고정하지 않는다.
핵심 개념 정리
- 프로세스는 실행 중인 프로그램과 자원의 단위이고, 스레드는 프로세스 안의 실행 흐름이다.
- Java는 메인 스레드에서 시작하며
Thread상속 또는Runnable구현으로 작업을 정의할 수 있다. start()는 새 실행 흐름을 시작하지만run()직접 호출은 현재 스레드에서 일반 메서드처럼 수행된다.- 강의의 상태 모형은 생성, 실행 가능, 실행, 대기, 종료 사이의 이동을 보여 준다.
join()은 대상 스레드 종료까지 기다리게 하지만 공유 값의 수정 과정을 보호하지 않는다.wait()와notify()는 같은 객체의 동기화 문맥에서 조건 대기를 연결한다.synchronized는 같은 잠금의 임계 구역에 한 번에 한 스레드만 진입시켜 공유 객체의 무결성을 지킨다.
멀티 스레드 문제는 실행 흐름 생성 → 필요한 선후 관계 확인 → 공유 상태 간섭 추적 → 동일 잠금으로 임계 구역 보호 순서로 해결한다. 실행 순서가 달라질 수 있다는 사실과 결과의 정확성이 깨지는 현상을 분리하면, start()·join()·wait()·synchronized의 역할을 정확히 고를 수 있다.
예상문제 10선
1. 프로세스와 스레드의 관계를 가장 정확히 설명한 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ③
- ① 오답: 스레드는 저장 구조가 아니라 코드를 수행하는 실행 흐름이다.
- ② 오답: 하나의 프로세스 안에 여러 스레드가 존재할 수 있다.
- ③ 정답: 프로세스의 자원 안에서 하나 이상의 스레드가 실행 흐름을 이룬다.
- ④ 오답: Java 프로그램은
main()을 수행하는 메인 스레드에서 시작한다.
2. worker.run()과 worker.start()의 차이로 옳은 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ①
- ① 정답: 멀티 스레드 실행은
start()에서 시작하며 직접 부른run()은 일반 메서드 호출이다. - ② 오답: 새 스레드를 시작하는 메서드와 작업 내용을 뒤바꾼 설명이다.
- ③ 오답: 코드 내용은 같아도 실행 주체와 호출 스택이 다르다.
- ④ 오답:
start()는 대상 스레드를 실행 가능하게 할 뿐 현재 스레드를 종료하지 않는다.
3. 작업 클래스가 이미 다른 클래스를 상속하고 있을 때 스레드 작업을 분리하는 방법으로 가장 알맞은 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ④
- ① 오답: Java 클래스는 둘 이상의 클래스를 동시에 상속할 수 없다.
- ② 오답:
Thread생성자는 실행 작업으로Runnable객체를 받을 수 있다. - ③ 오답:
run()직접 호출은 현재 스레드에서 수행된다. - ④ 정답:
Runnable구현은 작업을 별도 객체로 만들고Thread와 결합한다.
4. 강의의 상태 모형에서 실행 중인 스레드가 sleep()을 호출한 직후의 이동은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ②
- ① 오답: 종료된 스레드는 새로 생성된 상태로 되돌아가지 않는다.
- ② 정답: 실행 중인 현재 스레드는 지정 시간 동안 실행할 수 없는 대기 상태로 간다.
- ③ 오답:
sleep()은 시작 전 객체를 종료시키는 메서드가 아니다. - ④ 오답: 스레드 상태 전이는 새로운 프로세스를 만드는 동작이 아니다.
5. 메인 스레드가 작업 스레드의 종료 뒤에 결과를 출력하게 할 때 가장 직접적인 메서드는?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ②
- ① 오답: 이름 변경은 실행 완료를 기다리게 하지 않는다.
- ② 정답: 대상 스레드 객체의
join()은 그 종료까지 현재 스레드를 기다리게 한다. - ③ 오답: 우선순위 조회는 종료 선후 관계를 만들지 않는다.
- ④ 오답: 현재 스레드 객체를 얻는 기능일 뿐 다른 작업의 종료를 기다리지 않는다.
6. 스레드 우선순위와 yield()에 관한 설명 중 잘못된 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ④
- ① 오답 아님: 우선순위는 스케줄 선택 가능성에 영향을 줄 수 있다는 설명이다.
- ② 오답 아님:
yield()의 핵심은 현재 실행 기회의 양보 요청이다. - ③ 오답 아님: 스레드 간 출력 순서는 고정되지 않을 수 있다.
- ④ 정답: 두 기능 모두 특정 스레드의 다음 실행이나 완료 순서를 보장하지 않는다.
7. 공유값 0을 증가 스레드와 감소 스레드가 모두 읽은 뒤 각각 1과 -1을 계산하고, 증가가 1을 저장한 다음 감소가 -1을 저장했다. 최종값은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ①
- ① 정답: 마지막 저장인 -1이 앞서 저장된 1을 덮어써 최종 공유값이 된다.
- ② 오답: 증감의 수학적 합만 보고 읽기-수정-쓰기의 교차를 무시했다.
- ③ 오답: 증가 저장 뒤에 감소 저장이 수행됐으므로 1은 최종값이 아니다.
- ④ 오답: 데이터 경쟁은 예외 없이 잘못된 값으로 나타날 수 있다.
8. wait()와 notify() 사용 조건으로 가장 적절한 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ③
- ① 오답: 기다림과 알림은 같은 객체를 매개로 연결되어야 한다.
- ② 오답: 호출 위치는 메인 메서드 순서가 아니라 모니터 보유 조건으로 결정된다.
- ③ 정답: 대상 객체의 잠금을 가진 동기화 문맥에서 대기와 알림을 수행한다.
- ④ 오답: 스레드 이름은 객체 모니터 소유 여부와 관계없다.
9. 같은 Counter 객체를 두 스레드가 증감할 때 무결성을 지키는 수정으로 가장 적절한 것은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ④
- ① 오답: 실행 기회 양보는 공유 연산의 상호 배제를 보장하지 않는다.
- ② 오답: 이름은 식별용이며 잠금이나 실행 순서를 만들지 않는다.
- ③ 오답:
join()은 종료를 기다릴 뿐 공유 연산 자체를 임계 구역으로 보호하지 않는다. - ④ 정답: 충돌하는 두 연산이 같은 잠금에 참여해야 한 번에 한 스레드만 값을 수정한다.
10. 두 작업이 공유 카운터를 수정하고, 메인 스레드는 두 작업이 끝난 뒤 정확한 값을 출력해야 한다. 필요한 조합은?
정답입니다.
오답입니다. 답안을 다시 선택해 보세요.
정답 및 해설 보기
정답: ②
- ① 오답: 우선순위는 공유 수정의 원자성도 작업 완료 시점도 보장하지 않는다.
- ② 정답: 동기화는 계산 무결성을 지키고
join()은 출력 전에 두 작업의 완료를 보장한다. - ③ 오답: 직접
run()호출은 멀티 스레드 실행이 아니며 요구한 구조와 다르다. - ④ 오답: 이름과 실행 기회 양보로는 임계 구역 보호나 완료 대기를 만들 수 없다.
참고 자료와 작성 기준
이 글은 해당 차시 강의자료를 바탕으로 학습 목적에 맞게 재구성한 비공식 학습자료입니다. 상태 용어는 강의의 개념 모형임을 밝혔고, 코드의 실행 주체·대기 관계·공유 상태 간섭을 단계별로 검토했습니다.
- 작성·편집: 올에이클래스 학습연구팀
- 주요 근거: 한국방송통신대학교 Java프로그래밍 13강 「멀티 스레드 프로그래밍」 강의록 :codex-file-citation{path="K1. 컴퓨터과학과/1. 재검토필요/2-1 Java프로그래밍/1. 강의록/java프로그래밍 강의록 13강.pdf" purpose="source"}
- 편집 원칙: 올에이클래스 편집 정책
- 최종 내용 검토: 2026-09-02
댓글
댓글 쓰기