IT/JAVA

ReentrantLock 락 제어

iamhyeon 2025. 12. 6. 20:41

 

 

Java에서 멀티스레드 환경을 다루다 보면, 가장 먼저 떠올리는 동기화 방식은 synchronized이다.
하지만 실무에서는 synchronized만으로 해결할 수 없는 더 세밀한 락 제어가 필요한 경우가 많고,

이때 선택하게 되는 도구가 바로 ReentrantLock이다.

 

단순한 전역 락이 아니라 자원별 락이 필요하다.

이때 사용할 수 있는 패턴 중 하나가 ConcurrentMap + ReentrantLock 기반의 동적 락 관리 기법이다.

 

1. ReentrantLock이란

ReentrantLock은 Java의 java.util.concurrent.locks 패키지에서 제공하는 명시적 락(Explicit Lock)이다.
synchronized가 자동으로 락을 걸고 해제하는 구조라면, ReentrantLock은 개발자가 직접 제어한다.

주요 특징

기능 설명
재진입(Reentrant) 가능 동일 스레드가 같은 락을 여러 번 획득해도 데드락 없이 동작함
tryLock(), lockInterruptibly() 제공 락 대기 중 인터럽트 대응, 기다림 없이 시도 등 다양한 락 전략 구현 가능
공정 락 지원 락 요청 순서를 보장해 기아 상태를 방지할 수 있음
Condition 지원 wait/notify보다 확장된 조건 대기/깨우기 구현 가능

 

ReentrantLock은 synchronized보다 더 세밀하고 유연한 락 제어가 가능하다.

 

synchronized의 한계

  • 락을 언제 획득하고 언제 해제되는지 통제가 어렵다.
  • timeout 기반의 락 획득 전략을 구현할 수 없다.
  • 공정 락(Fair lock)을 지원하지 않는다.
  • 여러 조건을 가진 wait/notify 확장 구현이 어렵다.

ReentrantLock을 사용하면

  • lock() / unlock() 호출 시점을 직접 제어할 수 있고
  • tryLock()으로 즉시 락 획득 여부만 확인할 수도 있으며
  • 대기 중 인터럽트에도 반응할 수 있다.

 

 

3. 자원별 동시성 제어가 필요한 이유

예시: 

  • userId 100번 → 포인트 충전 요청 3개가 동시에 들어옴
  • accountId A → 동시에 출금 요청 2개
  • orderId 200 → 중복 주문 처리 방지

이런 경우 단순히 전역 하나의 락을 걸어버리면 모든 요청이 순차 처리되어 오히려 시스템 전체가 느려진다.

여기서 필요한 것은 다음 구조이다.

 

같은 자원에 대한 요청은 직렬 처리하고,
서로 다른 자원에 대한 요청은 동시에 병렬 처리한다.

 

이를 구현하려면 자원별로 락을 개별 관리해야 하고, 여기서 등장하는 것이 Per-Key Lock 패턴이다.

 

4. ConcurrentMap을 활용한 ReentrantLock 동적 관리

멀티스레드 환경에서 다음과 같은 구조는 동기화가 전혀 되지 않는다.

public void doSomething(long userId) {
    ReentrantLock lock = new ReentrantLock(); // 매번 새로 생성됨
    lock.lock();
    try {
        // critical section
    } finally {
        lock.unlock();
    }
}

 

자원 ID는 같은데 락 객체가 매번 새로 생성되기 때문이다.
스레드 A와 스레드 B가 같은 userId로 들어와도 서로 다른 Lock 인스턴스를 잡고 있기 때문에 전혀 동기화되지 않는다.

 

private final ConcurrentMap<Long, ReentrantLock> lockMap = new ConcurrentHashMap<>();

 

key에 따라 항상 동일한 Lock 객체를 반환해야 한다.

computeIfAbsent

ReentrantLock lock = lockMap.computeIfAbsent(userId, id -> new ReentrantLock(true));
  • key가 존재하지 않으면 새로운 락을 생성하여 저장
  • 존재하면 기존 락을 반환
  • ConcurrentHashMap이기 때문에 멀티스레드 환경에서도 안전하게 동작

 

  • 같은 userId → 같은 Lock 공유 → 동시성 제어 O
  • 다른 userId → 다른 Lock → 서로 병렬 처리 가능

JVM 한 프로세스 내에서 Key 기반으로 락을 관리

 

⬇️ HashMap VS ConcurrentHashMap 

더보기

HashMap은 스레드 안전하지 않다.
여러 쓰레드가 동시에 호출하면 내부 버킷 구조가 깨지거나, null이 뜬금없이 나오는 등의 문제 가능.

멀티 스레드에서 안전하게 쓰려면 ConcurrentHashMap을 써야 한다.

 

5. 예시: UserId 기반 Lock 유틸리티

@Component
public class LockManager {

    private final ConcurrentMap<Long, ReentrantLock> lockMap = new ConcurrentHashMap<>();

    public void lock(long key) {
        ReentrantLock lock = lockMap.computeIfAbsent(key, id -> new ReentrantLock(true));
        lock.lock();
    }

    public void unlock(long key) {
        ReentrantLock lock = lockMap.get(key);
        if (lock != null && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

 

⬇️ ReentrantLock(true) vs ReentrantLock(false)

더보기

✅ true (공정 락, fair)
먼저 기다린 스레드가 먼저 Lock을 얻는다
줄 서기 방식(FIFO)
대신 속도가 조금 느리다

✅ false (비공정 락, non-fair)
순서 상관없이, 운 좋으면 새로 온 스레드가 바로 Lock을 얻을 수 있다
대신 속도가 빠르다

 

✔ 언제 true(공정 락)를 써야 할까?
- 요청 순서( fairness )가 중요한 상황
- 먼저 들어온 요청이 먼저 처리되어야 할 때
- 특정 스레드가 계속 기다리기만 하는 걸 방지하고 싶을 때 (기아 방지)

예시:
유저별 포인트 충전·사용 요청 처리
결제 같은 순서가 중요한 작업

✔ 언제 false(비공정 락)를 써야 할까?
- 성능이 가장 중요할 때
- 순서(공정성)보다 전체 처리량을 높이는 게 중요한 경우
- 락을 자주 잡고 빠르게 풀어야 하는 곳
- CPU가 바쁘고 스레드 경쟁이 많은 곳

예시: 
- 고성능 서버에서 공정성이 크게 중요하지 않은 내부 처리
- 캐시 보호 용도
- 빠른 Lock 획득을 통해 처리량을 높여야 하는 상황

 

장점

  • key 단위로 동시성 제어 가능
  • 같은 key에 대해선 직렬 처리
  • 다른 key는 병렬 처리 → 전체 처리량 증가
  • Lock 객체를 재사용하므로 메모리·처리 비용 감소

주의점

  • unlock은 반드시 락을 Held한 스레드만 수행해야 한다.
  • 맵에 사용되지 않는 Lock이 무한히 쌓일 수 있으므로
    필요 시 clean-up 전략이 필요하다.
  • 여러 서버(분산 시스템)이면 이 방식은 동작하지 않는다 → Redis Lock 등 필요

  • ReentrantLock은 synchronized보다 훨씬 유연한 제어가 가능한 명시적 락이다.
  • 자원별 동시성 제어가 필요할 때는 key 기반으로 Lock을 관리해야 한다.
  • ConcurrentHashMap + ReentrantLock 조합은 자주 사용되는 per-key lock 패턴이다.
  • 목적에 따라 DB Lock, Redis Lock, 전역 Lock, synchronized 등 다양한 선택지가 존재한다.

단일 JVM 환경에서 발생하는 동시성 문제를 해결할 수 있다.

반응형