Spring/동시성 제어

ReentrantLock의 이해와 활용

kyoooooong 2025. 12. 3. 17:13

ReentrantLock 한 줄 정의

ReentrantLock은 synchronized처럼 “한 번에 한 스레드만 임계 구역을 통과하게 하는 락”이지만,락 자체를 객체로 다루면서 훨씬 많은 기능을 제공하는 고급 락이다.

private final ReentrantLock lock = new ReentrantLock();

여기서 lock은 JVM 모니터락이 아니다.

 

synchronized가 사용하는 “모니터(monitor)”와는 완전히 별개의 락 객체다. 이 차이에서부터 ReentrantLock의 모든 추가 기능들이 나온다고 보면 된다.

 


1. Reentrant(재진입 가능)이라는 이름의 진짜 의미

1.1 그냥 “또 들어올 수 있다”가 아님

“현재 락을 가지고 있는 스레드가 다시 같은 락을 요청해도 DeadLock 없이 다시 들어올 수 있다.”

 

 

이게 Reentrant의 정의인데, 이걸 아주 구체적으로 뜯어보면

  • ReentrantLock은 내부적으로 “락을 가진 스레드 + 그 스레드가 몇 번이나 락을 다시 획득했는지(hold count)”를 같이 관리한다.
  • 같은 스레드가 다시 lock()을 호출하면:
    • 소유자(owner)가 같으면 → deadlock이 아니라 hold count만 +1
    • 다른 스레드가 lock()을 호출하면 → 대기 상태로 큐에 줄 서기

 

예시로 보면 다음과 같다.

class Service {
    private final ReentrantLock lock = new ReentrantLock();

    public void outer() {
        lock.lock();
        try {
            // ...
            inner();   // 내부에서 다시 lock() 호출
        } finally {
            lock.unlock();
        }
    }

    private void inner() {
        lock.lock();
        try {
            // ...
        } finally {
            lock.unlock();
        }
    }
}

 

 

동작 흐름:

  1. outer()가 lock.lock() → owner = Thread-1, holdCount = 1
  2. inner()에서도 lock.lock() → Thread-1이 outer() 내부에서 직접 호출해 같은 Thread-1이므로
  3. holdCount = 2만 증가 (대기 X)
  4. inner()의 unlock() → holdCount 2 → 1
  5. outer()의 unlock() → holdCount 1 → 0
  6. → holdCount가 0이 되는 순간 락 완전 해제
  • ReentrantLock은 “같은 스레드가 몇 번 락을 잡았는지”를 기억한다.
  • unlock()은 “한 번 lock()에 정확히 한 번씩” 대응해야 한다.
  • 이 구조 덕분에 재귀 호출, 계층적 메서드 호출에서 같은 락을 여러 번 쓰기 편하다.
  • non-reentrant 락이었다면 위 코드는 자기 자신에게 걸리는 데드락이 되었을 것.

synchronized도 사실 재진입 가능(reentrant)한 모니터 락이지만, ReentrantLock은 그걸 객체로 꺼내서 더 많은 기능을 붙인 버전이라고 보면 된다.

 

 


2. ReentrantLock은 “모니터락”이 아니다

synchronized는 이렇게 생긴 문법을 가진다.

synchronized (obj) {
    // obj의 모니터락을 기반으로 동기화
}
  • 이때 락의 실체는 “obj에 붙어 있는 모니터”.
  • 우리는 그 모니터에 대한 정보를 직접 볼 수도, 컨트롤할 수도 없다.

 

반면 ReentrantLock은

private final ReentrantLock lock = new ReentrantLock();
// ...
lock.lock();
try {
    // critical section
} finally {
    lock.unlock();
}
  • 락이 객체로 독립되어 있다.
  • 그래서 다음이 가능해진다:
    • 공정/비공정 모드 설정
    • 현재 누가 락을 가지고 있는지 확인 (isHeldByCurrentThread()getOwner())
    • 대기 중인 스레드가 있는지 확인 (hasQueuedThreads())
    • 여러 Condition을 락 하나에 묶어 관리 (lock.newCondition())

즉,

synchronized→ “객체에 숨겨진 모니터를 살짝 빌려 쓰는 문법”

ReentrantLock→ “락 자체를 new 해서 노골적으로 들고 사용하는 객체”

 

라고 생각하면 된다.


3. 공정 모드(Fair) vs 비공정 모드(Nonfair)

ReentrantLock의 아주 중요한 특징 중 하나가 공정성(fairness) 옵션이다.

new ReentrantLock(true);   // 공정 락 (Fair)
new ReentrantLock(false);  // 비공정 락 (Nonfair, 기본값)
new ReentrantLock();       // 기본: 비공정

 

3.1 공정 락(Fair Lock)

“먼저 기다린 스레드가 먼저 락을 얻는다.”

정확히 말하면:

  • 락을 얻으려는 스레드를 FIFO 큐에 쌓아둔다.
  • 락이 풀릴 때, 큐에서 가장 오래 기다린 스레드가 우선적으로 락을 얻는다.
  • 그래서
    • 기아(starvation) 가 발생하지 않는다 (이론적으로).
    • 대신 매번 “내 순서인가?” 확인하고 큐를 유지해야 해서 성능이 떨어진다.

 

3.2 비공정 락(Nonfair Lock) – 기본값

“새로 온 스레드도 빈틈만 보이면 바로 락을 가져갈 수 있다.”

  • 락이 풀리는 순간:
    • 대기 중인 스레드가 있어도
    • 지금 막 lock()을 호출한 스레드가 운 좋게 바로 락을 가져갈 수 있음
  • 이걸 “barging(새치기)”라고 부른다.
  • 결과:
    • 스루풋(처리량)은 올라간다.
    • 대신 오래 기다리는 스레드가 계속 미뤄질 수 있어 기아(starvation) 가능성 up.

 

대부분의 일반 서비스에서는

  • 요청이 아주 많고
  • 살짝의 불공정성을 허용할 수 있을 때

→ 비공정 락이 기본적으로 더 빠르고 실용적이라서 default가 Nonfair다.

 

3.3 synchronized의 공정성은?

  • synchronized는 공정성 옵션이 없다.
  • 즉, 내부 구현에 따라 실질적으로 비공정(nonfair)에 가깝게 동작한다고 보면 된다.
  • “먼저 기다린 스레드가 먼저 락을 얻는다”는 보장이 전혀 없음.

 


4. Condition

synchronized에서는 조건 대기를 이렇게 한다.

synchronized (obj) {
    while (!조건만족) {
        obj.wait();
    }
    // ...
    obj.notify();
}

 

문제는

  • obj마다 대기 큐가 딱 하나뿐. → 서로 다른 조건을 모두 한 큐에 얹어 써야 함.
  • notify()를 하면 “어떤 스레드가 깨어날지”를 알 수 없다.
  • “읽는 스레드 / 쓰는 스레드 / 검사하는 스레드” 등 여러 부류가 있어도, 같은 wait() / notify() 큐를 공유해야 해서 코드가 지저분해진다.

 

4.1 ReentrantLock + Condition

ReentrantLock에서는 이렇게 한다.

private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull  = lock.newCondition();

 

 

핵심 차이

항목 wait/notify(Object) Condition (Lock 기반)

대기 큐 개수 객체당 1개 락 하나에 Condition 여러 개 가능
API wait(), notify() await(), signal(), signalAll()
제어력 누가 깨어날지 모름 논리적으로 역할별 Condition 분리
사용법 synchronized 필요 반드시 lock.lock() 안에서만 사용

 

 

생산자/소비자 패턴 예시:

class BoundedQueue<E> {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notEmpty = lock.newCondition();
    private final Condition notFull  = lock.newCondition();

    private final Queue<E> queue = new ArrayDeque<>();
    private final int capacity;

    BoundedQueue(int capacity) {
        this.capacity = capacity;
    }

    public void put(E e) throws InterruptedException {
        lock.lock();
        try {
            while (queue.size() == capacity) {
                notFull.await();       // 꽉 차면 notFull 큐에서 대기
            }
            queue.add(e);
            notEmpty.signal();         // 소비자 쪽 깨우기
        } finally {
            lock.unlock();
        }
    }

    public E take() throws InterruptedException {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                notEmpty.await();      // 비었으면 notEmpty 큐에서 대기
            }
            E e = queue.remove();
            notFull.signal();          // 생산자 쪽 깨우기
            return e;
        } finally {
            lock.unlock();
        }
    }
}

 

 

여기서

  • notEmpty에는 “소비자(꺼내는 쪽)” 스레드만 대기
  • notFull에는 “생산자(넣는 쪽)” 스레드만 대기

이렇게 역할별로 Condition 큐를 쪼개서 관리할 수 있다.

 

 

이때,

  • await() / signal() / signalAll()은 반드시 해당 Condition을 만든 Lock을 잡은 상태에서만 호출해야 한다.
  • await()는 내부적으로 lock.unlock() + 대기 + 재획득 과정을 한 번에 처리한다.
  • signal()은 해당 Condition 큐에서 “하나의 스레드”만 깨운다.
  • signalAll()은 Condition 큐에 대기 중인 “모든 스레드”를 깨운다.

 


5. tryLock() 공정성 주의점

여기가 약간 헷갈리는 포인트라, 정확히 짚고 넘어가야 한다.

 

공식 문서에서 말하는 핵심

공정 모드(fair=true)로 생성했어도 tryLock()은 공정성을 깨고 “새치기(barging)”할 수 있다.

 

5.1 문제 상황

Lock lock = new ReentrantLock(true); // 공정 락

// 스레드 A, B는 이미 lock()을 호출하고 대기 중
// 이때 스레드 C가 등장해서:

if (lock.tryLock()) {
    // 바로 락 획득 가능 (대기 큐 무시)
}

 

공정 락인데도, tryLock()은 “지금 당장 락이 비어 있으면 그냥 잡는다”는 성격이라

  • 이미 공정하게 줄 서 있는 스레드들(A, B)이 있어도 tryLock() 호출한 스레드(C)가 운 좋게 끼어들어 락을 가져갈 수 있다.

 

즉, 공정 모드여도

  • lock() → 공정성 준수 (FIFO)
  • tryLock() → 공정성 무시 가능 (barging)

 

5.2 그럼 공정성을 지키면서 “즉시 시도”는 못하나?

이때 쓰는 트릭이 바로 이거

lock.tryLock(0, TimeUnit.SECONDS);

 

왜 이게 다르냐면

  • tryLock(long time, TimeUnit unit)은 공정 모드 설정을 존중하는 구현을 따른다.
  • 공정 락에서 tryLock(0, SECONDS)를 호출하면:
    • 락이 비어 있고,
    • 나보다 먼저 기다리는 스레드가 없다면 락 획득
    • 이미 큐에 선행 대기 스레드가 있다면 → 바로 false 반환 (새치기 안 함)

 

그래서 정리하면

 

timeout 버전의 tryLock은 내부적으로 공정 모드의 큐에 들어간 뒤, 시간이 0이면 바로 한 번 체크만 하고 빠지는 느낌으로 동작한다, 라고 이해하면 된다! (빠진다는 게 바로 false 되어 탈락된다는 것. 대기 X)