이번 문서의 목표: 이 파일을 다 읽으면 synchronized 메서드와 synchronized 블록의 차이를 코드로 설명할 수 있고, 자바의 모니터·락 개념으로 그 동작 원리를 말할 수 있으며, 교착 상태가 발생하는 조건을 코드에서 찾아낼 수 있다.
왜 다시 동기화를 파고드는가
18편에서 synchronized를 메서드에 붙이면 한 번에 한 스레드만 그 메서드를 실행할 수 있다는 것을 배웠다. 하지만 실제 시험 문제와 실무 코드에서는 “메서드 전체가 아니라 일부 코드만 보호하고 싶을 때는 어떻게 하는가”, “synchronized가 도대체 무엇을 기준으로 스레드를 막는가”, “동기화를 잘못 쓰면 프로그램이 아예 멈춰버리는 이유는 무엇인가” 같은 더 깊은 질문이 출제된다. 이 편에서는 synchronized의 동작 원리를 모니터(monitor)와 락(lock)이라는 개념으로 정확히 이해하고, 메서드 단위가 아니라 블록 단위로 동기화 범위를 좁히는 방법, 그리고 동기화를 잘못 설계했을 때 생기는 교착 상태(deadlock)를 다룬다.
모니터와 락: synchronized가 실제로 지키는 것
쉽게 말하면: 모든 객체는 눈에 보이지 않는 열쇠(락) 하나를 가지고 있고, synchronized는 “이 열쇠를 손에 쥔 스레드만 이 구역에 들어갈 수 있다”는 규칙이다.
자바의 모든 객체는 내부적으로 모니터(monitor, 하나의 락과 그 락으로 보호되는 코드 영역을 함께 가리키는 개념)를 하나씩 가지고 있다고 생각할 수 있다. synchronized 키워드가 붙은 코드에 들어가려는 스레드는 먼저 그 대상 객체의 락(lock, 자물쇠라는 뜻으로, 한 번에 한 스레드만 쥘 수 있는 표식)을 얻어야 하며, 이미 다른 스레드가 그 락을 쥐고 있다면 락이 반환될 때까지(즉 앞선 스레드가 synchronized 영역을 빠져나갈 때까지) BLOCKED 상태(18편에서 배운 상태)로 대기한다.
class 계좌 {
int 잔액 = 0;
synchronized void 입금() {
잔액++;
}
}이 코드에서 synchronized void 입금()은 “이 메서드를 실행하려면 계좌 객체 자신의 락을 쥐어야 한다”는 뜻이다. 여기서 중요한 점은 락이 메서드 단위가 아니라 객체 단위로 걸린다는 것이다. 만약 계좌 객체가 두 개(acc1, acc2)라면, 각 객체가 서로 다른 락을 가지므로 acc1.입금()을 실행하는 스레드와 acc2.입금()을 실행하는 스레드는 서로를 기다리지 않고 동시에 진행될 수 있다.
시험 함정: “synchronized 메서드가 하나라도 있으면, 그 클래스의 어떤 객체를 대상으로 하든 한 번에 한 스레드만 실행할 수 있다”는 설명은 틀렸다. 락은 객체마다 따로 걸리므로, 서로 다른 객체에 대해서는 동시에 여러 스레드가 각자의 synchronized 메서드를 실행할 수 있다. 같은 객체를 여러 스레드가 공유할 때만 서로 기다리게 된다.
synchronized 메서드 vs synchronized 블록
synchronized를 메서드 전체가 아니라 특정 코드 블록에만 적용할 수도 있다. 이때는 어떤 객체의 락을 사용할지 괄호 안에 직접 지정한다.
class 계좌 {
int 잔액 = 0;
private final Object 락객체 = new Object();
void 입금() {
System.out.println("동기화 밖: 잠금 없이 실행되는 코드");
synchronized (락객체) {
잔액++;
}
System.out.println("동기화 끝난 뒤: 잠금 없이 실행되는 코드");
}
}synchronized (락객체) { ... } 형태로 쓰면, 그 블록 안의 코드만 락으로 보호되고 블록 앞뒤의 코드는 여러 스레드가 자유롭게 동시에 실행할 수 있다.
| 비교 항목 | synchronized 메서드 | synchronized 블록 |
|---|---|---|
| 보호 범위 | 메서드 전체 | 지정한 코드 블록만 |
| 사용하는 락 | 인스턴스 메서드면 그 객체(this) 자신 | 괄호 안에 명시한 임의의 객체 |
| 세밀한 제어 | 메서드 전체를 통째로 막아 성능 손해가 클 수 있음 | 꼭 필요한 부분만 좁혀 막아 나머지 코드는 동시 실행 가능 |
| 정적(static) 메서드에 적용 시 | 그 클래스 자체(클래스이름.class)의 락을 사용 | synchronized (클래스이름.class)로 명시 가능 |
시험 함정: “synchronized (this)로 감싼 블록과 인스턴스 메서드 전체에 붙인 synchronized는 항상 똑같이 동작한다”는 설명은 범위 자체는 다르지만 같은 락(해당 인스턴스)을 사용한다는 점에서는 맞는 설명이다. 다만 메서드 전체를 감싼 것과 메서드 안의 일부만 synchronized (this)로 감싼 것은 보호되는 코드의 범위가 다르므로 성능과 안전성 측면에서 결과가 달라질 수 있다는 점을 구분해야 한다.
교착 상태: 두 스레드가 서로를 기다리다 멈추는 상황
쉽게 말하면: 두 사람이 각각 젓가락 한 짝씩 쥐고 상대방의 젓가락을 기다리면, 둘 다 영원히 밥을 먹지 못한다.
교착 상태(deadlock, 서로 자물쇠를 갖고 상대의 자물쇠를 기다리며 아무도 진행하지 못하는 상황)는 두 개 이상의 스레드가 각각 하나의 락을 쥔 채, 서로 상대방이 쥔 락을 기다릴 때 발생한다.
class 자원 {
String 이름;
자원(String 이름) { this.이름 = 이름; }
}
public class DeadlockDemo {
public static void main(String[] args) {
자원 A = new 자원("A");
자원 B = new 자원("B");
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println("t1: A의 락 획득");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (B) {
System.out.println("t1: B의 락까지 획득");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (B) {
System.out.println("t2: B의 락 획득");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (A) {
System.out.println("t2: A의 락까지 획득");
}
}
});
t1.start();
t2.start();
}
}t1: A의 락 획득
t2: B의 락 획득
(이후 아무 출력도 없이 프로그램이 영원히 멈춘다)한 줄씩 실행 추적
| 시각 | 스레드 t1 | 스레드 t2 |
|---|---|---|
| 1 | synchronized (A) 진입, A의 락 획득 | - |
| 2 | - | synchronized (B) 진입, B의 락 획득 |
| 3 | sleep(100) 동안 대기 | sleep(100) 동안 대기 |
| 4 | synchronized (B) 시도 → B는 t2가 쥐고 있어 BLOCKED | synchronized (A) 시도 → A는 t1이 쥐고 있어 BLOCKED |
| 5 | t2가 B를 놓아주기를 기다림(하지만 t2도 멈춰 있음) | t1이 A를 놓아주기를 기다림(하지만 t1도 멈춰 있음) |
t1은 B의 락을 기다리고, t2는 A의 락을 기다린다. 그런데 t1이 B를 얻으려면 t2가 먼저 B를 반납해야 하고, t2가 A를 얻으려면 t1이 먼저 A를 반납해야 한다. 하지만 둘 다 락을 반납하려면 상대가 먼저 반납해야 하는 상황이라, 서로가 서로를 영원히 기다리며 아무도 진행하지 못하는 교착 상태에 빠진다.
이 예제에서 교착 상태가 발생하는 조건을 정리하면 다음과 같다.
- 두 개 이상의 락이 존재한다.
- 서로 다른 스레드가 그 락들을 서로 다른 순서로 얻으려고 시도한다(t1은 A→B 순서, t2는 B→A 순서).
- 각 스레드가 이미 하나의 락을 쥔 채 다른 락을 기다린다.
시험 함정: 교착 상태를 막는 가장 기본적인 방법은 “여러 스레드가 여러 개의 락을 얻어야 할 때, 항상 같은 순서로 얻도록 코드를 작성하는 것”이다. 위 예제에서 t1과 t2가 모두 A를 먼저, B를 나중에 얻도록 순서를 통일하면 교착 상태가 발생하지 않는다. “락을 아예 쓰지 않는 것”이 정답으로 제시된다면, 그것은 동기화 자체를 포기하는 것이므로 옳은 해결책이 아니다.
동기화 실행 순서 추적 문제 연습
다음 코드에서 두 스레드가 하나의 synchronized 메서드를 공유할 때 최종 출력이 어떻게 되는지 순서를 따라가 보자.
class 카운터 {
int 값 = 0;
synchronized void 증가() {
int 임시 = 값;
try { Thread.sleep(10); } catch (InterruptedException e) {}
값 = 임시 + 1;
}
}| 시나리오 | t1 실행 | t2 실행 | 값 |
|---|---|---|---|
| synchronized 없이 실행 | 임시=0 읽음, sleep, 값=1 저장 | 임시=0 읽음(t1이 아직 저장 전), sleep, 값=1 저장(t1의 결과를 덮어씀) | 최종 1 (기대: 2) |
| synchronized 있을 때 | 락 획득, 임시=0 읽음, sleep, 값=1 저장, 락 반납 | 락 대기 후 획득, 임시=1 읽음, sleep, 값=2 저장 | 최종 2 (기대와 일치) |
이 비교표는 왜 synchronized가 “값을 읽고 계산하고 저장하는” 세 단계 전체를 하나의 덩어리로 묶어 주는지를 보여준다. synchronized가 없으면 sleep(10) 때문에 두 스레드의 읽기 시점이 겹쳐 한쪽의 결과가 사라지지만, synchronized가 있으면 t2는 t1이 메서드 전체(읽기부터 저장까지)를 마칠 때까지 BLOCKED 상태로 기다리므로 항상 최신 값을 기준으로 계산한다.
자주 틀리는 점
- 락이 메서드 단위가 아니라 객체 단위로 걸린다는 점을 놓쳐, 서로 다른 객체를 대상으로 한
synchronized메서드까지 서로 기다린다고 착각한다. synchronized블록의 범위를 필요 이상으로 넓게 잡거나, 반대로 꼭 보호해야 할 코드를 블록 밖에 두어 동기화 효과가 없는 경우가 있다.- 교착 상태가 “락을 아예 안 쓰면 해결된다”고 오해한다 — 올바른 해결책은 락을 얻는 순서를 통일하는 것이다.
- 교착 상태가 발생한 프로그램은 예외를 던지며 종료되는 것이 아니라, 아무 응답 없이 멈춘 채로 계속 실행되고 있다는 점을 놓친다.
핵심 정리
- 자바의 모든 객체는 모니터(락)를 하나씩 가지며,
synchronized는 그 락을 쥔 스레드만 보호 영역에 들어가게 한다. - 락은 메서드가 아니라 객체 단위로 걸리므로, 서로 다른 객체에 대한
synchronized메서드는 서로 기다리지 않는다. synchronized블록을 쓰면 메서드 전체가 아니라 필요한 코드만 좁혀서 보호할 수 있다.- 교착 상태는 두 개 이상의 스레드가 서로 다른 순서로 여러 락을 얻으려 할 때 발생하며, 락을 얻는 순서를 통일하면 예방할 수 있다.
- 실행 순서를 추적할 때는 “값 읽기 → 계산 → 저장”이 여러 단계로 나뉜다는 점과,
synchronized가 그 전체를 하나의 덩어리로 묶어 준다는 점을 기준으로 삼는다.
마무리 복습
임시 = 값;
// ...
값 = 임시 + 1;참고 자료
- Oracle Java Tutorials - Concurrency: https://docs.oracle.com/javase/tutorial/essential/concurrency/index.html