Java 멀티스레딩 실전 가이드 – synchronized·volatile·ReentrantLock 완전 비교


Java 멀티스레딩은 백엔드 개발자라면 반드시 넘어야 할 관문입니다. “synchronized와 ReentrantLock의 차이가 뭔가요?”, “volatile은 언제 쓰나요?” — 이 질문들은 자바 개발자 면접에서 중급 이상을 가르는 기준선입니다. 단순히 키워드를 암기하는 것과, 내부 동작 원리를 이해하고 상황에 맞게 선택할 수 있는 것은 전혀 다른 수준입니다. 이 글에서는 세 가지 동기화 메커니즘의 개념·동작 원리·실전 코드·트레이드오프를 한 번에 정리합니다.


목차

  1. Java 멀티스레딩 기초 – 왜 동기화가 필요한가
  2. synchronized – 가장 기본적인 상호 배제 메커니즘
  3. volatile – 가시성 문제를 해결하는 경량 도구
  4. ReentrantLock – 유연하고 강력한 명시적 잠금
  5. 세 가지 메커니즘 실전 비교 – 언제 무엇을 쓸 것인가
  6. 심화 개념 – happens-before와 java.util.concurrent

1. Java 멀티스레딩 기초 – 왜 동기화가 필요한가

Java 멀티스레딩 동기화 메커니즘을 이해하기 전에, 왜 동기화가 필요한지를 코드 수준에서 먼저 확인해야 합니다. 문제를 정확히 알아야 해결책을 제대로 이해할 수 있습니다.

공유 자원과 경쟁 조건(Race Condition)

아래 코드를 보겠습니다. 얼핏 보면 문제가 없어 보이지만, 멀티스레드 환경에서 실행하면 예상과 전혀 다른 결과가 나옵니다.

java

public class Counter {
    private int count = 0;

    public void increment() {
        count++; // 이 한 줄이 실제로는 세 단계!
    }

    public int getCount() {
        return count;
    }
}

// 테스트 코드
public class RaceConditionDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        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();

        // 기대값: 20000, 실제값: 매 실행마다 다름 (예: 17832, 18451 ...)
        System.out.println("count = " + counter.getCount());
    }
}

count++는 단순해 보이지만 실제로는 세 단계의 CPU 명령으로 구성됩니다.

1. count 값을 레지스터에 읽기  (READ)
2. 레지스터 값에 1 더하기      (ADD)
3. 결과를 count에 쓰기         (WRITE)

두 스레드가 이 세 단계를 뒤섞어 실행하면 결과가 손실됩니다. 이것이 **경쟁 조건(Race Condition)**입니다. 동기화 메커니즘은 이 문제를 해결하기 위해 존재합니다.

두 가지 핵심 문제 – 원자성과 가시성

Java 멀티스레딩에서 발생하는 문제는 크게 두 종류입니다.

원자성(Atomicity) 문제는 위의 count++처럼 여러 단계로 이루어진 연산이 중간에 다른 스레드에 의해 끊기는 현상입니다. 연산 전체가 하나의 단위로(원자적으로) 실행되어야 하는데, 중간에 인터럽트가 발생하는 것입니다.

가시성(Visibility) 문제는 한 스레드가 공유 변수를 수정했는데, 다른 스레드가 그 변경 사항을 즉시 볼 수 없는 현상입니다. 현대 CPU는 성능을 위해 레지스터와 CPU 캐시를 사용합니다. 스레드 A가 캐시에 값을 썼지만 메인 메모리에 반영하기 전에 스레드 B가 메인 메모리의 오래된 값을 읽는 경우가 여기에 해당합니다.

세 가지 동기화 메커니즘은 이 두 가지 문제를 각기 다른 방식으로, 다른 범위에서 해결합니다.


2. synchronized – 가장 기본적인 상호 배제 메커니즘

synchronized는 Java에 내장된 가장 기본적인 동기화 키워드입니다. 특별한 import 없이 바로 사용할 수 있으며, JVM이 모니터(Monitor) 기반으로 관리합니다.

동작 원리 – 모니터 락(Monitor Lock)

Java의 모든 객체는 내부에 **모니터(Monitor)**라는 동기화 장치를 하나씩 갖고 있습니다. synchronized 블록에 진입하는 스레드는 해당 객체의 모니터 락을 획득해야 합니다. 락을 이미 다른 스레드가 점유하고 있으면, 현재 스레드는 BLOCKED 상태로 대기합니다. 락을 보유한 스레드가 블록을 빠져나오면 락이 자동으로 해제되고, 대기 중이던 스레드 중 하나가 락을 획득합니다.

사용 방법 3가지

방법 1 – 메서드 전체에 적용 (인스턴스 락)

java

public class SynchronizedCounter {
    private int count = 0;

    // this 객체의 모니터 락을 사용
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

방법 2 – 블록 단위로 적용 (더 세밀한 제어)

java

public class SynchronizedCounter {
    private int count = 0;
    private final Object lock = new Object(); // 전용 락 객체

    public void increment() {
        // 꼭 필요한 부분만 동기화 → 성능 향상
        synchronized (lock) {
            count++;
        }
        // 이 부분은 여러 스레드가 동시 접근 가능
        System.out.println("increment called");
    }
}

방법 3 – 정적 메서드에 적용 (클래스 락)

java

public class StaticCounter {
    private static int count = 0;

    // Class 객체(StaticCounter.class)의 모니터 락 사용
    public static synchronized void increment() {
        count++;
    }
}

synchronized의 특성과 한계

synchronized원자성가시성을 모두 보장합니다. 락을 획득한 스레드만 임계 구역에 진입할 수 있어 원자성이 보장되고, 락 해제 시 수정된 값이 메인 메모리에 플러시되어 가시성도 확보됩니다.

그러나 한계도 명확합니다. 첫째, 타임아웃 불가입니다. 락 획득을 기다리다 무한 대기에 빠져도 중단할 방법이 없습니다. 둘째, 인터럽트 불가입니다. 대기 중인 스레드에 인터럽트를 보내도 락 대기를 멈출 수 없습니다. 셋째, 공정성 보장 없음입니다. 오래 기다린 스레드가 먼저 락을 얻는다는 보장이 없습니다.

java

// synchronized의 한계 시연 – 타임아웃 불가
public void someMethod() {
    synchronized (lock) {
        // 락을 얻지 못하면 영원히 기다림
        // 1초만 기다리고 포기하는 것이 불가능
    }
}

3. volatile – 가시성 문제를 해결하는 경량 도구

volatilesynchronized보다 훨씬 가볍지만, 해결하는 문제의 범위도 더 좁습니다. 가시성 문제만 해결하며, 원자성은 보장하지 않습니다. 이 차이를 정확히 이해하는 것이 volatile을 올바르게 사용하는 핵심입니다.

동작 원리 – 메인 메모리 직접 읽기·쓰기

일반 변수 접근:
Thread 1: [CPU Cache] → (나중에) → [Main Memory]
Thread 2: [CPU Cache] ← (오래된 값) ← [Main Memory]

volatile 변수 접근:
Thread 1: [Main Memory] ← 즉시 쓰기
Thread 2: [Main Memory] → 항상 직접 읽기

volatile로 선언된 변수는 CPU 캐시를 거치지 않고 항상 메인 메모리에서 직접 읽고 씁니다. 또한 Java 메모리 모델(JMM)의 happens-before 관계를 수립하여, volatile 변수 쓰기 전에 발생한 모든 작업이 이후의 읽기 스레드에서 보이도록 보장합니다.

volatile 올바른 사용 예제

java

// 올바른 사용 ① – 플래그 변수
public class StopThread {
    // volatile 없으면 스레드가 캐시된 값을 읽어 무한 루프 가능
    private volatile boolean stopRequested = false;

    public void start() {
        Thread worker = new Thread(() -> {
            while (!stopRequested) { // 항상 최신 값을 메인 메모리에서 읽음
                doWork();
            }
            System.out.println("스레드 정상 종료");
        });
        worker.start();
    }

    public void stop() {
        stopRequested = true; // 즉시 메인 메모리에 반영
    }

    private void doWork() { /* 작업 수행 */ }
}

java

// 올바른 사용 ② – 싱글톤 패턴 (Double-Checked Locking)
public class Singleton {
    // volatile 없으면 객체 초기화 전 참조가 노출될 수 있음 (명령어 재배열 문제)
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                    // 1차 체크 (락 없음)
            synchronized (Singleton.class) {
                if (instance == null) {            // 2차 체크 (락 있음)
                    instance = new Singleton();    // volatile로 재배열 방지
                }
            }
        }
        return instance;
    }
}

volatile이 원자성을 보장하지 못하는 이유

java

public class WrongVolatileUsage {
    private volatile int count = 0;

    public void increment() {
        count++; // READ → ADD → WRITE 세 단계 → 여전히 Race Condition 발생!
    }
}

// 위 코드는 volatile을 써도 여전히 잘못된 결과를 냅니다.
// count++는 단일 연산이 아니기 때문입니다.
// 이 경우 synchronized 또는 AtomicInteger를 사용해야 합니다.

volatile을 쓸 수 있는 조건은 명확합니다. 단 하나의 스레드만 변수를 쓰고, 나머지 스레드는 읽기만 하는 경우입니다. 여러 스레드가 동시에 쓰는 변수에는 절대 volatile 단독으로는 충분하지 않습니다.


4. ReentrantLock – 유연하고 강력한 명시적 잠금

ReentrantLockjava.util.concurrent.locks 패키지에서 제공하는 명시적 락 구현체입니다. synchronized가 제공하지 못하는 고급 기능들을 제공하며, 복잡한 동시성 시나리오에서 훨씬 정밀한 제어를 가능하게 합니다.

기본 사용법 – try-finally 패턴 필수

java

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockCounter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock(); // 명시적으로 락 획득
        try {
            count++; // 임계 구역
        } finally {
            lock.unlock(); // 반드시 finally에서 해제 (예외 발생 시에도 보장)
        }
    }

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

⚠️ 핵심 주의사항: ReentrantLock은 반드시 try-finally 블록으로 감싸야 합니다. 예외 발생 시에도 finallyunlock()이 실행되어 락이 영원히 잠기는(데드락) 상황을 방지합니다.

ReentrantLock의 고급 기능들

기능 1 – tryLock(): 타임아웃 기반 락 시도

java

public void incrementWithTimeout() {
    try {
        // 최대 1초만 기다리고, 못 얻으면 포기
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            try {
                count++;
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println("락 획득 실패 – 다른 작업 수행");
            // synchronized로는 불가능한 처리!
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

기능 2 – lockInterruptibly(): 인터럽트 가능한 락 대기

java

public void incrementInterruptibly() throws InterruptedException {
    // 대기 중에 interrupt() 호출 시 InterruptedException 발생 → 대기 중단 가능
    lock.lockInterruptibly();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

기능 3 – 공정 락(Fair Lock): 대기 순서 보장

java

// true 전달 시 공정 락 → 오래 기다린 스레드 우선 획득
// false(기본값) 시 비공정 락 → 처리량(Throughput) 우선
private final ReentrantLock fairLock = new ReentrantLock(true);

기능 4 – Condition: 세밀한 대기·알림 제어

java

// 생산자-소비자 패턴 구현
public class BoundedBuffer<T> {
    private final Queue<T> buffer = new LinkedList<>();
    private final int capacity;
    private final ReentrantLock lock = new ReentrantLock();
    // synchronized의 wait/notify와 달리 조건을 분리 가능
    private final Condition notFull  = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public BoundedBuffer(int capacity) {
        this.capacity = capacity;
    }

    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (buffer.size() == capacity) {
                notFull.await(); // 버퍼가 꽉 찼으면 생산자 대기
            }
            buffer.offer(item);
            notEmpty.signal(); // 소비자에게만 신호 (notifyAll과 달리 선택적)
        } finally {
            lock.unlock();
        }
    }

    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (buffer.isEmpty()) {
                notEmpty.await(); // 버퍼가 비었으면 소비자 대기
            }
            T item = buffer.poll();
            notFull.signal(); // 생산자에게만 신호
            return item;
        } finally {
            lock.unlock();
        }
    }
}

ReentrantLock이 ‘재진입 가능’한 이유

‘Reentrant’란 같은 스레드가 이미 보유한 락을 다시 획득할 수 있다는 의미입니다. synchronized도 재진입을 지원하지만, 명시적 락에서는 이를 직접 확인할 수 있습니다.

java

public void outer() {
    lock.lock();
    try {
        inner(); // 같은 스레드가 inner()에서 lock.lock()을 또 호출해도 데드락 없음
    } finally {
        lock.unlock();
    }
}

public void inner() {
    lock.lock(); // 같은 스레드 → 재진입 허용, 락 카운트만 증가
    try {
        // 작업
    } finally {
        lock.unlock(); // 락 카운트 감소, 0이 되면 실제 해제
    }
}

5. 세 가지 메커니즘 실전 비교 – 언제 무엇을 쓸 것인가

각 메커니즘의 특성을 이해했으니, 이제 어떤 상황에서 무엇을 선택해야 하는지 명확한 기준을 정립합니다. 이 부분이 면접에서 가장 자주 등장하는 핵심 질문입니다.

핵심 특성 비교표

항목synchronizedvolatileReentrantLock
원자성 보장
가시성 보장
타임아웃해당 없음✅ (tryLock)
인터럽트해당 없음✅ (lockInterruptibly)
공정성 설정해당 없음✅ (생성자 파라미터)
Condition 분리❌ (wait/notify 하나)해당 없음✅ (다수 Condition)
락 자동 해제해당 없음❌ (수동 unlock 필요)
코드 복잡도낮음매우 낮음높음
성능중간가장 빠름중간~높음
Java 버전초기부터초기부터Java 5+

상황별 선택 가이드

volatile을 선택해야 할 때

java

// ✅ 상황 1: 단일 스레드 쓰기, 다수 스레드 읽기
private volatile boolean isRunning = true;

// ✅ 상황 2: 싱글톤 Double-Checked Locking
private static volatile MyService instance;

// ✅ 상황 3: 상태 플래그 (단순 boolean/참조 변수)
private volatile ConnectionStatus status;

// ❌ 잘못된 사용: 복합 연산
private volatile int counter;
counter++; // 여전히 Race Condition 발생!

synchronized를 선택해야 할 때

java

// ✅ 상황 1: 간단한 임계 구역, 짧은 락 보유 시간
public synchronized void simpleUpdate() {
    this.value = compute();
}

// ✅ 상황 2: 코드 가독성·유지보수가 중요한 경우
public void transfer(Account target, int amount) {
    synchronized (this) {
        this.balance -= amount;
        target.balance += amount;
    }
}

// ✅ 상황 3: wait/notify 패턴으로 충분한 경우
synchronized (lock) {
    while (!condition) lock.wait();
    // 작업
    lock.notifyAll();
}

ReentrantLock을 선택해야 할 때

java

// ✅ 상황 1: 타임아웃이 필요한 경우
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { ... }

// ✅ 상황 2: 인터럽트 가능한 락 대기
lock.lockInterruptibly();

// ✅ 상황 3: 공정 락이 필요한 경우 (대기 순서 보장)
new ReentrantLock(true);

// ✅ 상황 4: 여러 조건에 따른 분리된 대기 큐 필요
Condition full = lock.newCondition();
Condition empty = lock.newCondition();

// ✅ 상황 5: 락 획득 여부를 런타임에 확인해야 하는 경우
if (lock.isHeldByCurrentThread()) { ... }
lock.getQueueLength(); // 대기 스레드 수 확인

면접 모범 답변 – “세 가지의 차이를 설명해주세요”

“세 메커니즘은 해결하는 문제의 범위와 제공하는 기능이 다릅니다. volatile은 가시성 문제만 해결하며 원자성은 보장하지 않아, 단일 스레드 쓰기·다수 스레드 읽기 상황의 플래그 변수에 적합합니다. synchronized는 원자성과 가시성을 모두 보장하는 기본 동기화 도구로, 간단한 임계 구역 보호에 사용합니다. 다만 타임아웃·인터럽트·공정성 제어가 불가합니다. ReentrantLocksynchronized의 기능에 더해 타임아웃, 인터럽트 가능한 락 대기, 공정 락, 복수 Condition 등 고급 기능을 제공하지만, 직접 unlock()을 호출해야 하므로 코드 복잡도가 올라갑니다. 단순한 경우엔 synchronized를, 고급 제어가 필요할 때 ReentrantLock을 선택합니다.”


6. 심화 개념 – happens-before와 java.util.concurrent

면접관을 감탄시키고 실무 역량을 증명하는 심화 개념들입니다.

happens-before 관계

Java 메모리 모델(JMM)의 핵심 개념인 happens-before는 한 연산의 결과가 다른 연산에서 반드시 보인다는 순서 보장입니다. 이 규칙을 이해하면 synchronizedvolatile이 왜 가시성을 보장하는지 근본 원리를 설명할 수 있습니다.

주요 happens-before 규칙은 다음과 같습니다.

1. 프로그램 순서 규칙:
   같은 스레드 내에서 앞에 오는 코드 happens-before 뒤에 오는 코드

2. 모니터 락 규칙 (synchronized):
   락 해제(unlock) happens-before 다음 락 획득(lock)
   → 이전 스레드의 변경 사항이 다음 스레드에 보임

3. volatile 변수 규칙:
   volatile 쓰기 happens-before 이후의 volatile 읽기
   → 쓰기 전 모든 작업이 읽기 스레드에 보임

4. 스레드 시작 규칙:
   Thread.start() happens-before 해당 스레드의 모든 동작

5. 스레드 종료 규칙:
   스레드의 모든 동작 happens-before Thread.join() 반환

java.util.concurrent – 실무에서 더 많이 쓰는 도구들

실제 프로덕션 코드에서는 직접 synchronizedReentrantLock을 쓰는 것보다 java.util.concurrent 패키지의 고수준 도구를 활용하는 경우가 많습니다.

java

// 1. AtomicInteger – volatile + CAS 연산으로 락 없이 원자성 보장
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // 락 없이 원자적 증가 (CAS 기반)
    }

    public int getCount() {
        return count.get();
    }
}

java

// 2. ConcurrentHashMap – 세그먼트 락으로 높은 동시성 지원
import java.util.concurrent.ConcurrentHashMap;

// HashMap은 스레드 안전하지 않음
// Collections.synchronizedMap()은 전체 락 → 성능 저하
// ConcurrentHashMap은 버킷 단위 락 → 성능과 안전성 동시 확보
Map<String, Integer> safeMap = new ConcurrentHashMap<>();

java

// 3. CountDownLatch – 여러 스레드의 완료를 기다리는 도구
import java.util.concurrent.CountDownLatch;

public class ParallelTaskDemo {
    public static void main(String[] args) throws InterruptedException {
        int taskCount = 5;
        CountDownLatch latch = new CountDownLatch(taskCount);

        for (int i = 0; i < taskCount; i++) {
            final int taskId = i;
            new Thread(() -> {
                try {
                    System.out.println("Task " + taskId + " 완료");
                } finally {
                    latch.countDown(); // 카운트 1 감소
                }
            }).start();
        }

        latch.await(); // 카운트가 0이 될 때까지 대기
        System.out.println("모든 태스크 완료");
    }
}

java

// 4. ReadWriteLock – 읽기는 동시에, 쓰기는 독점으로
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteCache {
    private final Map<String, String> cache = new HashMap<>();
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

    public String get(String key) {
        rwLock.readLock().lock(); // 읽기 락: 여러 스레드 동시 획득 가능
        try {
            return cache.get(key);
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public void put(String key, String value) {
        rwLock.writeLock().lock(); // 쓰기 락: 단독 획득만 가능
        try {
            cache.put(key, value);
        } finally {
            rwLock.writeLock().unlock();
        }
    }
}

데드락 방지 실전 패턴

java

// 데드락 발생 시나리오
public void transfer(Account from, Account to, int amount) {
    synchronized (from) {           // 스레드 A: from 락 획득
        synchronized (to) {         // 스레드 A: to 락 대기
            from.balance -= amount; // 스레드 B는 반대 순서로 락 시도 → 데드락!
            to.balance += amount;
        }
    }
}

// 해결책 1 – 락 획득 순서 고정 (System.identityHashCode 활용)
public void safeTransfer(Account from, Account to, int amount) {
    Account first  = System.identityHashCode(from) < System.identityHashCode(to)
                     ? from : to;
    Account second = first == from ? to : from;

    synchronized (first) {
        synchronized (second) {
            from.balance -= amount;
            to.balance   += amount;
        }
    }
}

// 해결책 2 – tryLock으로 데드락 회피
public boolean safeTransferWithTryLock(
        Account from, Account to, int amount) throws InterruptedException {
    while (true) {
        if (from.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
            try {
                if (to.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
                    try {
                        from.balance -= amount;
                        to.balance   += amount;
                        return true;
                    } finally {
                        to.lock.unlock();
                    }
                }
            } finally {
                from.lock.unlock();
            }
        }
        // 둘 다 못 얻었으면 잠시 후 재시도
        Thread.sleep(10);
    }
}

결론

Java 멀티스레딩 동기화 메커니즘의 선택은 ‘가장 강력한 것’이 아니라 ‘상황에 맞는 것’을 고르는 문제입니다. 가시성만 필요하다면 volatile, 단순한 임계 구역 보호라면 synchronized, 타임아웃·인터럽트·공정성·다중 조건이 필요하다면 ReentrantLock을 선택합니다. 실무에서는 세 가지보다 AtomicInteger, ConcurrentHashMap, ReadWriteLock 같은 고수준 도구를 더 자주 활용하므로 java.util.concurrent 패키지에도 익숙해지는 것이 중요합니다. 오늘 정리한 코드를 직접 IDE에서 실행해보고, 경쟁 조건이 발생하는 순간을 눈으로 확인해보세요. 이론과 코드가 연결되는 그 순간이 진짜 이해의 시작입니다.


게시됨

카테고리

작성자

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다