개발/Java

Java 멀티스레드 동기화

hanks 2026. 2. 22. 18:10

Java 멀티스레드 환경에서 발생하는 데이터 경쟁 조건과 동기화 문제를 해결하기 위한 방법들을 알아봅니다. synchronized, volatile, Lock 인터페이스 등을 사용하여 안전하게 공유 자원에 접근하는 방법을 살펴봅니다.

왜 필요한가?

멀티스레드 프로그래밍은 여러 스레드가 동시에 실행되어 애플리케이션의 성능을 향상시키는 데 유용하지만, 동시에 여러 스레드가 공유 자원에 접근할 때 데이터 불일치, 경쟁 조건, 데드락과 같은 심각한 문제가 발생할 수 있습니다. 이러한 문제를 해결하기 위해 Java는 다양한 동기화 메커니즘을 제공합니다.

① 데이터 경쟁 (Race Condition)

두 개 이상의 스레드가 동시에 동일한 공유 자원에 접근하여 하나 이상의 스레드가 데이터를 변경하려고 할 때 데이터 경쟁이 발생합니다. 이로 인해 예상치 못한 결과가 발생할 수 있습니다. 예를 들어, 은행 계좌 잔액을 업데이트하는 상황을 생각해 봅시다.

java class Account { private int balance = 1000;

public int getBalance() {
    return balance;
}

public void withdraw(int amount) {
    balance -= amount;
}

}

public class RaceConditionExample { public static void main(String[] args) throws InterruptedException { Account account = new Account();

    Runnable task = () -> {
        for (int i = 0; i < 1000; i++) {
            int currentBalance = account.getBalance();
            account.withdraw(1);
            int newBalance = account.getBalance();
            if (currentBalance - 1 != newBalance) {
                System.out.println("Race condition detected!");
            }
        }
    };

    Thread thread1 = new Thread(task);
    Thread thread2 = new Thread(task);

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("Final balance: " + account.getBalance());
}

}

위 코드에서 withdraw 메서드가 동기화되지 않았기 때문에 두 스레드가 동시에 잔액을 변경하려고 시도하여 데이터 경쟁이 발생할 수 있습니다.


② 데드락 (Deadlock)

두 개 이상의 스레드가 서로가 점유하고 있는 자원을 기다리면서 무한정 멈춰있는 상태를 데드락이라고 합니다. 데드락은 멀티스레드 환경에서 발생할 수 있는 심각한 문제 중 하나입니다.

java public class DeadlockExample { private static final Object lock1 = new Object(); private static final Object lock2 = new Object();

public static void main(String[] args) {
    Thread thread1 = new Thread(() -> {
        synchronized (lock1) {
            System.out.println("Thread 1: Holding lock1...");
            try { Thread.sleep(10); } catch (InterruptedException e) {}
            System.out.println("Thread 1: Waiting for lock2...");
            synchronized (lock2) {
                System.out.println("Thread 1: Acquired lock2.");
            }
        }
    });

    Thread thread2 = new Thread(() -> {
        synchronized (lock2) {
            System.out.println("Thread 2: Holding lock2...");
            try { Thread.sleep(10); } catch (InterruptedException e) {}
            System.out.println("Thread 2: Waiting for lock1...");
            synchronized (lock1) {
                System.out.println("Thread 2: Acquired lock1.");
            }
        }
    });

    thread1.start();
    thread2.start();
}

}

위 코드에서 thread1lock1을 획득하고 lock2를 기다리고, thread2lock2를 획득하고 lock1을 기다리기 때문에 데드락이 발생할 수 있습니다.


핵심 개념

개념 설명 사용법
synchronized 메서드 또는 코드 블록을 동기화하여 한 번에 하나의 스레드만 접근하도록 합니다. synchronized (object) { // 코드 }
volatile 변수의 값을 항상 메인 메모리에서 읽고 쓰도록 강제하여 가시성을 확보합니다. private volatile int count;
Lock 인터페이스 ReentrantLock 등의 구현체를 통해 명시적인 잠금 및 해제를 제공합니다. Lock lock = new ReentrantLock(); lock.lock(); try { // 코드 } finally { lock.unlock(); }

[Thread 1] ── synchronized ──→ [Shared Resource] ←── synchronized ────┘ [Thread 2] ── Waiting ──────→ [Shared Resource]


주요 동기화 방법

synchronized 키워드

synchronized 키워드는 Java에서 가장 기본적인 동기화 방법입니다. 메서드 또는 코드 블록에 적용하여 한 번에 하나의 스레드만 해당 영역에 접근할 수 있도록 합니다.

java public class SynchronizedExample { private int count = 0;

public synchronized void increment() {
    count++;
}

public int getCount() {
    return count;
}

public static void main(String[] args) throws InterruptedException {
    SynchronizedExample example = new SynchronizedExample();

    Runnable task = () -> {
        for (int i = 0; i < 1000; i++) {
            example.increment();
        }
    };

    Thread thread1 = new Thread(task);
    Thread thread2 = new Thread(task);

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("Count: " + example.getCount());
}

}

increment 메서드에 synchronized 키워드를 사용하여 스레드 안전성을 보장합니다.


volatile 키워드

volatile 키워드는 변수의 가시성을 보장하는 데 사용됩니다. volatile로 선언된 변수는 항상 메인 메모리에서 읽고 쓰기 때문에 스레드 간에 최신 값을 공유할 수 있습니다.

java public class VolatileExample { private volatile boolean running = true;

public void stop() {
    running = false;
}

public void run() {
    while (running) {
        System.out.println("Running...");
        try { Thread.sleep(100); } catch (InterruptedException e) {}
    }
    System.out.println("Stopped.");
}

public static void main(String[] args) throws InterruptedException {
    VolatileExample example = new VolatileExample();
    Thread thread = new Thread(example::run);
    thread.start();

    Thread.sleep(1000);
    example.stop();
}

}

running 변수를 volatile로 선언하여 메인 스레드에서 stop() 메서드를 호출했을 때 작업 스레드가 변경된 값을 즉시 확인할 수 있도록 합니다.


Lock 인터페이스

Lock 인터페이스는 synchronized 키워드보다 더 유연하고 강력한 동기화 메커니즘을 제공합니다. ReentrantLockLock 인터페이스의 대표적인 구현체입니다.

java import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock;

public class LockExample { private int count = 0; private Lock lock = new ReentrantLock();

public void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

public int getCount() {
    return count;
}

public static void main(String[] args) throws InterruptedException {
    LockExample example = new LockExample();

    Runnable task = () -> {
        for (int i = 0; i < 1000; i++) {
            example.increment();
        }
    };

    Thread thread1 = new Thread(task);
    Thread thread2 = new Thread(task);

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("Count: " + example.getCount());
}

}

ReentrantLock을 사용하여 increment 메서드를 동기화하고, try-finally 블록에서 unlock() 메서드를 호출하여 잠금을 해제합니다.


마무리

Java 멀티스레드 환경에서 동기화는 데이터의 일관성과 안정성을 유지하는 데 필수적입니다. synchronized, volatile, Lock 인터페이스 등 다양한 동기화 메커니즘을 이해하고 적절하게 활용하여 안전하고 효율적인 멀티스레드 애플리케이션을 개발할 수 있습니다. 개인적으로 멀티스레드 프로그래밍은 어렵지만, 꼼꼼하게 동기화 처리를 하면 성능 향상에 큰 도움이 되는 것 같아요. 앞으로 더 다양한 동기화 기법을 공부해야겠어요.