Skip to content

Mutex 구현: ReentrantLock vs Synchronized

김동철 edited this page Mar 1, 2025 · 1 revision

Mutex 구현: ReentrantLock vs Synchronized 차이점

1. 개요

자바에서 멀티스레드 환경의 공유 자원 접근을 제어하는 상호 배제(Mutual Exclusion, Mutex) 메커니즘은 크게 두 가지 방식으로 구현할 수 있다: synchronized 키워드와 ReentrantLock 클래스. 이 두 방식은 모두 동일한 기본 목적(임계 영역 보호)을 서비스하지만, 구현 방식과 제공하는 기능에 있어 중요한 차이점이 있다.

2. synchronized 키워드

synchronized는 Java 언어 자체에 내장된 기본적인 락 메커니즘이다.

기본 문법

// 메서드 전체에 락 적용
public synchronized void synchronizedMethod() {
    // 임계 영역 코드
}

// 블록에 락 적용
public void blockSynchronized() {
    synchronized (this) {
        // 임계 영역 코드
    }
}

특징

  1. 편리성: 문법이 간단하고 사용하기 쉬움
  2. 암시적 락 해제: 블록을 벗어나거나 예외가 발생해도 자동으로 락 해제
  3. 재진입성(Reentrancy): 이미 락을 획득한 스레드가 다시 동일한 락을 획득하는 것이 가능
  4. 가시성 보장: 락 획득/해제 시 메모리 배리어 효과로 변수의 가시성 보장

제약 사항

  1. 세밀한 제어 부족: 락 획득 시도를 타임아웃하거나 인터럽트할 수 없음
  2. 락 확인 불가: 특정 락이 현재 보유 중인지 프로그래밍 방식으로 확인 불가
  3. 읽기/쓰기 락 구분 없음: 읽기 작업과 쓰기 작업에 대한 구분된 락을 제공하지 않음
  4. 공정성 제어 불가: 대기 중인 스레드 간의 우선순위 또는 공정성 조절 불가

3. ReentrantLock 클래스

ReentrantLock은 Java 5에서 도입된 java.util.concurrent.locks 패키지의 일부로, 고급 락 기능을 제공한다.

기본 사용법

import java.util.concurrent.locks.ReentrantLock;

public class Example {
    private final ReentrantLock lock = new ReentrantLock();
    
    public void criticalMethod() {
        lock.lock();  // 락 획득
        try {
            // 임계 영역 코드
        } finally {
            lock.unlock();  // 락 해제
        }
    }
}

주요 기능

  1. 명시적 락 제어: 락 획득과 해제를 명시적으로 제어
  2. 타임아웃 지원: 락 획득 시도에 시간 제한 설정 가능
    if (lock.tryLock(1, TimeUnit.SECONDS)) {
        try {
            // 임계 영역 코드
        } finally {
            lock.unlock();
        }
    } else {
        // 락 획득 실패 처리
    }
  3. 인터럽트 지원: 락 획득 대기 중 인터럽트 처리 가능
    try {
        lock.lockInterruptibly();
        try {
            // 임계 영역 코드
        } finally {
            lock.unlock();
        }
    } catch (InterruptedException e) {
        // 인터럽트 처리
    }
  4. 공정성 설정: 가장 오래 대기한 스레드에게 우선권 부여 가능
    // 공정성 모드로 락 생성
    private final ReentrantLock fairLock = new ReentrantLock(true);
  5. 락 정보 조회: 현재 락 상태에 대한 정보 접근 가능
    int waitingThreads = lock.getQueueLength();
    boolean isLocked = lock.isLocked();
    boolean isHeldByCurrentThread = lock.isHeldByCurrentThread();
  6. 조건 변수(Condition): 다수의 대기/통지 조건 생성 가능
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    
    // 생산자 코드
    lock.lock();
    try {
        while (isFull()) {
            notFull.await();
        }
        // 아이템 추가
        notEmpty.signal();
    } finally {
        lock.unlock();
    }

4. 주요 차이점 비교

특성 synchronized ReentrantLock
구현 수준 언어 자체 내장 라이브러리 클래스
락 획득/해제 암시적(블록 단위) 명시적(lock/unlock 호출)
타임아웃 지원 불가능 가능(tryLock 메서드)
인터럽트 지원 불가능 가능(lockInterruptibly 메서드)
락 공정성 비공정 전용 공정/비공정 모드 선택 가능
조건 변수 객체당 하나(wait/notify) 다수 생성 가능(Condition)
사용 편의성 간단함 복잡함(try-finally 필요)
성능(JDK 6 이후) 최적화됨 유사하거나 약간 우수
안전성 락 해제 보장 명시적 unlock 필요(finally 블록 권장)
확장성 제한적 다양한 API를 통해 높은 확장성

5. 성능 고려사항

JDK 1.6 이전에는 ReentrantLocksynchronized보다 성능이 뛰어났으나, JDK 1.6부터 synchronized에 대한 성능 최적화(락 경량화, 적응적 스피닝, 락 비대화 등)가 도입되어 성능 차이가 크게 줄었다.

현대 JVM에서의 성능 특성:

  • 비경합 상황: 대부분의 경우 두 구현 모두 유사한 성능
  • 높은 경합 상황: ReentrantLock이 공정 모드에서 더 나은 성능을 보일 수 있음
  • 특수 시나리오: 고급 기능이 필요한 경우 ReentrantLock이 유리

6. 사용 지침

synchronized 선택 시나리오

  1. 간단한 락 요구사항이 있을 때
  2. 락 획득/해제 로직의 실수를 피하고 싶을 때
  3. 기존 코드 베이스와의 호환성이 중요할 때
  4. 특별한 락 기능이 필요하지 않을 때
public class SimpleCounter {
    private int count = 0;
    
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}

ReentrantLock 선택 시나리오

  1. 락 획득 시도에 시간 제한이 필요할 때
  2. 락 획득 대기 중 인터럽트 처리가 필요할 때
  3. 공정한 락 정책이 필요할 때
  4. 락 상태에 대한 정보가 필요할 때
  5. 동일한 락에 다수의 조건 변수가 필요할 때
public class AdvancedBuffer<T> {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    private final T[] items;
    private int count, putIndex, takeIndex;
    
    public AdvancedBuffer(int capacity) {
        items = (T[]) new Object[capacity];
    }
    
    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) {
                notFull.await();
            }
            items[putIndex] = item;
            putIndex = (putIndex + 1) % items.length;
            count++;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }
    
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();
            }
            T item = items[takeIndex];
            items[takeIndex] = null;
            takeIndex = (takeIndex + 1) % items.length;
            count--;
            notFull.signal();
            return item;
        } finally {
            lock.unlock();
        }
    }
}

7. 실제 사용 패턴 및 코드 예제

synchronized를 활용한 생산자-소비자 패턴

public class SynchronizedQueue<T> {
    private final Queue<T> queue = new LinkedList<>();
    private final int capacity;
    
    public SynchronizedQueue(int capacity) {
        this.capacity = capacity;
    }
    
    public synchronized void put(T item) throws InterruptedException {
        while (queue.size() >= capacity) {
            wait();  // 큐가 가득 찼으면 대기
        }
        queue.add(item);
        notifyAll();  // 대기 중인 소비자에게 알림
    }
    
    public synchronized T take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();  // 큐가 비었으면 대기
        }
        T item = queue.remove();
        notifyAll();  // 대기 중인 생산자에게 알림
        return item;
    }
}

ReentrantLock을 활용한 읽기-쓰기 패턴

public class ConcurrentCache<K, V> {
    private final Map<K, V> map = new HashMap<>();
    private final ReentrantLock lock = new ReentrantLock();
    
    public V get(K key, long timeout, TimeUnit unit) throws InterruptedException {
        // 제한 시간 내에 락 획득 시도
        if (lock.tryLock(timeout, unit)) {
            try {
                return map.get(key);
            } finally {
                lock.unlock();
            }
        }
        throw new TimeoutException("락 획득 시간 초과");
    }
    
    public void put(K key, V value) {
        lock.lock();
        try {
            map.put(key, value);
        } finally {
            lock.unlock();
        }
    }
    
    public boolean remove(K key, long timeout, TimeUnit unit) throws InterruptedException {
        if (lock.tryLock(timeout, unit)) {
            try {
                return map.remove(key) != null;
            } finally {
                lock.unlock();
            }
        }
        return false;
    }
}

8. 권장 사항 및 모범 사례

  1. 기본 원칙: 특별한 요구사항이 없다면 synchronized를 우선 사용
  2. 락 범위 최소화: 락이 필요한 코드 부분만 보호하여 성능 최적화
  3. 락 분리: 서로 다른 데이터에 대해 분리된 락을 사용하여 경합 감소
  4. 데드락 방지:
    • 일관된 락 획득 순서 유지
    • tryLock을 활용한 데드락 회피 기법 구현
  5. 락 해제 보장: ReentrantLock 사용 시 항상 finally 블록에서 unlock 호출
  6. 과도한 동기화 피하기: 불필요한 동기화는 성능 저하의 원인
  7. 최신 동시성 도구 활용: java.util.concurrent 패키지의 고수준 도구 활용 고려

9. 결론

synchronizedReentrantLock 모두 자바에서 상호 배제를 구현하는 유효한 방법이지만, 각각의 장단점이 있다. 단순한 상호 배제 요구사항에는 synchronized가 간결하고 안전한 선택이지만, 타임아웃, 인터럽트 처리, 공정성, 다중 조건 변수 등의 고급 기능이 필요한 경우에는 ReentrantLock이 더 적합하다.

현대 JVM에서는 두 방식 모두 성능 최적화가 잘 되어 있으므로, 기능적 요구사항과 코드 명확성을 기준으로 선택하는 것이 바람직하다. 특히 보다 복잡한 동시성 시나리오에서는 Lock 인터페이스와 이를 구현한 클래스들이 제공하는 유연성이 큰 이점이 될 수 있다.