본문으로 건너뛰기

Ch 13.2 쓰레드 동기화와 제어 (Synchronization)

멀티 쓰레드 환경에서는 여러 쓰레드가 프로세스의 같은 자원(메모리 등)을 공유하기 때문에 예상치 못한 문제가 발생할 수 있습니다. 이를 해결하기 위해 하나의 쓰레드가 특정 작업을 마칠 때까지 다른 쓰레드가 접근하지 못하도록 막는 것을 동기화(Synchronization) 라고 합니다.

1. 경쟁 조건 (Race Condition) 이란?

두 개 이상의 쓰레드가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 현상을 경쟁 조건(Race Condition) 이라고 합니다.

public class RaceConditionExample {
static int count = 0; // 공유 자원

public static void main(String[] args) throws InterruptedException {
Runnable incrementTask = () -> {
for (int i = 0; i < 10000; i++) {
count++; // 이 연산은 원자적이지 않음! (읽기 → 증가 → 쓰기 3단계)
}
};

Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);

t1.start();
t2.start();

t1.join();
t2.join();

System.out.println("예상 결과: 20000");
System.out.println("실제 결과: " + count); // 20000보다 작은 값이 나올 수 있음!
}
}

count++는 사실 읽기 → 증가 → 쓰기 3단계 연산입니다. 두 쓰레드가 동시에 같은 값을 읽으면 두 번 증가시켜도 한 번만 반영됩니다.

경고

경쟁 조건 버그는 항상 재현되지 않아 디버깅이 매우 어렵습니다. 설계 단계에서 예방하는 것이 중요합니다.

2. synchronized 키워드

메서드 동기화

메서드 전체를 임계 영역(Critical Section)으로 설정합니다.

class BankAccount {
private int balance;

public BankAccount(int initialBalance) {
this.balance = initialBalance;
}

// 인스턴스 락 사용: 같은 객체의 synchronized 메서드는 한 번에 하나의 쓰레드만 접근
public synchronized void deposit(int amount) {
System.out.printf("[입금 시작] 현재 잔액: %d, 입금액: %d%n", balance, amount);
balance += amount;
System.out.printf("[입금 완료] 변경된 잔액: %d%n", balance);
}

public synchronized void withdraw(int amount) {
if (balance >= amount) {
System.out.printf("[출금 시작] 현재 잔액: %d, 출금액: %d%n", balance, amount);
try { Thread.sleep(100); } catch (InterruptedException e) {}
balance -= amount;
System.out.printf("[출금 완료] 변경된 잔액: %d%n", balance);
} else {
System.out.println("[출금 실패] 잔액 부족: " + balance);
}
}

public synchronized int getBalance() {
return balance;
}
}

블록 동기화

특정 객체를 락(Lock)으로 지정하여 그 블록만 동기화합니다. 메서드 전체를 잠그는 것보다 성능에 유리합니다.

class InventoryManager {
private int stock = 100;
private final Object stockLock = new Object(); // 락 전용 객체

public void sellItem(int quantity) {
// 동기화가 필요 없는 로직 (이 부분은 병렬 실행 가능)
System.out.println("판매 요청 처리 준비 중...");

synchronized (stockLock) { // 재고 관련 부분만 동기화
if (stock >= quantity) {
stock -= quantity;
System.out.println("판매 완료. 남은 재고: " + stock);
} else {
System.out.println("재고 부족. 현재 재고: " + stock);
}
}

// 동기화가 필요 없는 후처리 로직
System.out.println("판매 처리 완료");
}
}

인스턴스 락 vs 클래스 락

class LockExample {
// 인스턴스 락: 객체(this)를 기준으로 잠금 (서로 다른 인스턴스는 별도 락)
public synchronized void instanceMethod() {
System.out.println("인스턴스 락 획득: " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
}

// 클래스 락: LockExample.class를 기준으로 잠금 (모든 인스턴스 공유)
public static synchronized void classMethod() {
System.out.println("클래스 락 획득: " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
}

public static void main(String[] args) {
LockExample obj1 = new LockExample();
LockExample obj2 = new LockExample();

// obj1과 obj2는 서로 다른 인스턴스이므로 인스턴스 락은 공유되지 않음
new Thread(obj1::instanceMethod).start(); // obj1 락
new Thread(obj2::instanceMethod).start(); // obj2 락 (동시 실행 가능)

// static 메서드는 클래스 락을 공유하므로 순차 실행
new Thread(LockExample::classMethod).start();
new Thread(LockExample::classMethod).start(); // 앞 쓰레드가 끝날 때까지 대기
}
}

3. wait()와 notify() - 쓰레드 간 통신

동기화를 통해 경쟁 조건을 해결했지만, 한 쓰레드가 락을 오랫동안 독점하면 다른 쓰레드는 기아(Starvation) 상태가 됩니다. wait()notify()는 쓰레드들이 협력하며 작업을 처리할 수 있게 해줍니다.

  • wait(): 락을 반납하고 대기 상태로 전환
  • notify(): 대기 중인 쓰레드 중 하나를 깨움
  • notifyAll(): 대기 중인 모든 쓰레드를 깨움
노트

wait(), notify(), notifyAll()은 반드시 synchronized 블록 안에서 호출해야 합니다.

4. 생산자-소비자 패턴 (Producer-Consumer)

wait()/notify()의 가장 대표적인 활용 예시입니다.

import java.util.LinkedList;
import java.util.Queue;

class SharedBuffer {
private final Queue<Integer> buffer = new LinkedList<>();
private final int MAX_SIZE = 5;

// 생산자: 데이터 추가
public synchronized void produce(int item) throws InterruptedException {
while (buffer.size() == MAX_SIZE) {
System.out.println("[생산자] 버퍼 가득 참. 대기...");
wait(); // 락 반납 + 대기
}
buffer.offer(item);
System.out.println("[생산자] " + item + " 생산. 버퍼 크기: " + buffer.size());
notifyAll(); // 대기 중인 소비자 깨우기
}

// 소비자: 데이터 소비
public synchronized int consume() throws InterruptedException {
while (buffer.isEmpty()) {
System.out.println("[소비자] 버퍼 비어있음. 대기...");
wait(); // 락 반납 + 대기
}
int item = buffer.poll();
System.out.println("[소비자] " + item + " 소비. 버퍼 크기: " + buffer.size());
notifyAll(); // 대기 중인 생산자 깨우기
return item;
}
}

public class ProducerConsumerExample {
public static void main(String[] args) {
SharedBuffer buffer = new SharedBuffer();

// 생산자 쓰레드
Thread producer = new Thread(() -> {
for (int i = 1; i <= 10; i++) {
try {
buffer.produce(i);
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});

// 소비자 쓰레드
Thread consumer = new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
buffer.consume();
Thread.sleep(500); // 생산자보다 느리게 소비
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});

producer.start();
consumer.start();
}
}

5. 교착 상태 (Deadlock)

두 개 이상의 쓰레드가 서로의 락을 기다리며 영원히 멈추는 상태입니다.

교착 상태 발생 4가지 조건(모두 충족해야 발생):

  1. 상호 배제 (Mutual Exclusion): 자원을 한 번에 하나의 쓰레드만 사용 가능
  2. 점유 대기 (Hold and Wait): 자원을 가진 채로 다른 자원을 기다림
  3. 비선점 (No Preemption): 다른 쓰레드의 자원을 강제로 빼앗을 수 없음
  4. 순환 대기 (Circular Wait): 쓰레드들이 원형으로 서로의 자원을 기다림
public class DeadlockExample {
private static final Object lockA = new Object();
private static final Object lockB = new Object();

public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("T1: lockA 획득");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("T1: lockB 기다리는 중...");
synchronized (lockB) { // t2가 lockB를 가지고 있어 대기
System.out.println("T1: lockB 획득"); // 절대 실행 안 됨
}
}
});

Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("T2: lockB 획득");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("T2: lockA 기다리는 중...");
synchronized (lockA) { // t1이 lockA를 가지고 있어 대기
System.out.println("T2: lockA 획득"); // 절대 실행 안 됨
}
}
});

t1.start();
t2.start();
// 이 프로그램은 영원히 종료되지 않음!
}
}

교착 상태 예방 방법:

// 항상 같은 순서로 락을 획득하면 순환 대기 조건 제거
public class DeadlockSolution {
private static final Object lockA = new Object();
private static final Object lockB = new Object();

// 두 쓰레드 모두 lockA → lockB 순서로 획득
static void safeMethod1() {
synchronized (lockA) {
synchronized (lockB) {
System.out.println("안전하게 두 락 획득: " + Thread.currentThread().getName());
}
}
}

static void safeMethod2() {
synchronized (lockA) { // 동일한 순서!
synchronized (lockB) {
System.out.println("안전하게 두 락 획득: " + Thread.currentThread().getName());
}
}
}
}

6. volatile 키워드 - 메모리 가시성

멀티 쓰레드 환경에서 각 쓰레드는 성능을 위해 CPU 캐시에 변수를 복사하여 사용합니다. 이 때 한 쓰레드가 값을 수정해도 다른 쓰레드의 캐시에는 반영되지 않을 수 있습니다. volatile은 변수를 항상 메인 메모리에서 읽고 쓰도록 강제합니다.

public class VolatileExample {
// volatile 없이 → 일부 JVM/JIT 환경에서 루프가 끝나지 않을 수 있음
// private static boolean running = true;

// volatile 추가 → 메인 메모리에서 항상 읽음 (가시성 보장)
private static volatile boolean running = true;

public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("작업 쓰레드: 시작");
while (running) { // volatile 덕분에 최신 값 읽음
// 작업 수행
}
System.out.println("작업 쓰레드: 종료");
});

worker.start();
Thread.sleep(1000);
running = false; // 메인 쓰레드에서 변경
System.out.println("메인 쓰레드: running = false 설정");
}
}

synchronized vs volatile 비교

구분synchronizedvolatile
원자성(Atomicity)보장 (블록 전체)읽기/쓰기 각각만
가시성(Visibility)보장보장
성능상대적으로 느림 (락)빠름 (락 없음)
사용 시나리오복합 연산, 상태 변경플래그 변수, 단순 읽기/쓰기

7. 실전 예제: 은행 계좌 멀티쓰레드 입출금

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

class SynchronizedBankAccount {
private final String accountNumber;
private int balance;
private final List<String> transactionHistory = new ArrayList<>();
private final AtomicInteger transactionCount = new AtomicInteger(0);

public SynchronizedBankAccount(String accountNumber, int initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}

public synchronized boolean deposit(int amount, String depositor) {
if (amount <= 0) return false;
balance += amount;
String record = String.format("[%d] 입금: +%,d원 (from: %s) | 잔액: %,d원",
transactionCount.incrementAndGet(), amount, depositor, balance);
transactionHistory.add(record);
System.out.println(record);
notifyAll(); // 잔액 부족으로 대기 중인 출금 쓰레드 깨우기
return true;
}

public synchronized boolean withdraw(int amount, String requester) throws InterruptedException {
if (amount <= 0) return false;

// 잔액이 부족하면 최대 3초 대기
long deadline = System.currentTimeMillis() + 3000;
while (balance < amount) {
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
System.out.printf("[실패] 출금 시간 초과 (요청자: %s, 금액: %,d원)%n",
requester, amount);
return false;
}
System.out.printf("[대기] 잔액 부족 (현재: %,d원, 요청: %,d원, 요청자: %s)%n",
balance, amount, requester);
wait(remaining);
}

balance -= amount;
String record = String.format("[%d] 출금: -%,d원 (by: %s) | 잔액: %,d원",
transactionCount.incrementAndGet(), amount, requester, balance);
transactionHistory.add(record);
System.out.println(record);
return true;
}

public synchronized int getBalance() { return balance; }

public void printHistory() {
System.out.println("\n=== 거래 내역 (" + accountNumber + ") ===");
transactionHistory.forEach(System.out::println);
System.out.printf("최종 잔액: %,d원%n", balance);
}
}

public class BankSimulation {
public static void main(String[] args) throws InterruptedException {
SynchronizedBankAccount account = new SynchronizedBankAccount("KB-001", 100_000);

List<Thread> threads = new ArrayList<>();

// 입금 쓰레드들
threads.add(new Thread(() -> {
try {
Thread.sleep(500);
account.deposit(50_000, "급여");
Thread.sleep(1000);
account.deposit(30_000, "이자");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "입금-쓰레드"));

// 출금 쓰레드들 (동시에 출금 시도)
for (int i = 1; i <= 3; i++) {
final int amount = i * 30_000;
final String name = "사용자-" + i;
threads.add(new Thread(() -> {
try {
account.withdraw(amount, name);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "출금-" + name));
}

// 모든 쓰레드 시작
for (Thread t : threads) {
t.start();
}

// 모든 쓰레드 완료 대기
for (Thread t : threads) {
t.join();
}

account.printHistory();
}
}

고수 팁: wait()은 스퓨리어스 웨이크업(Spurious Wakeup - 신호 없이 깨어나는 현상)이 발생할 수 있으므로, 항상 if 대신 while문으로 조건을 재확인해야 합니다.