synchronized와 ReentrantLock은 내부에서 무엇이 다른가

seonwoo_jung·2026년 6월 6일

1. 도입

멀티스레드 코드를 처음 짤 때는 synchronized 하나면 충분했다. 그런데 라이브러리 코드나 동시성 잘 짠 코드를 읽다 보면 synchronized 대신 ReentrantLock을 꺼내 쓰는 경우가 자주 보인다. "둘 다 상호배제(mutual exclusion)인데 왜 굳이?"라는 의문이 들어서, 두 락이 어디서 동작하고, 무엇으로 happens-before를 보장하며, 기능적으로 무엇이 다른지를 따라가 봤다.

기준 문서는 두 갈래다. synchronized의 의미와 메모리 모델은 Java Language Specification(JLS) §17(Threads and Locks)에 정의돼 있고, ReentrantLock의 동작은 java.util.concurrent.locks 패키지 문서와 AbstractQueuedSynchronizer(AQS) 쪽에서 확인했다. 버전마다 미묘하게 다른 JVM 락 최적화 동작은 단정하기보다 "대체로 이런 흐름"으로 적는다.

2. 핵심 개념

한 문장으로 줄이면 이렇게 이해했다.

synchronizedJVM(언어/런타임)에 내장된 모니터 락이고, ReentrantLock라이브러리(java.util.concurrent)로 구현된 락이다. 둘 다 재진입 가능하고 상호배제를 보장하지만, 후자는 그 위에 타임아웃·인터럽트·공정성·다중 Condition 같은 제어 기능을 얹은 것이다.

  • synchronized: 모든 Java 객체가 가진 모니터(monitor) 를 잠근다. JLS §17.1에 따르면 각 객체는 하나의 모니터를 가지며, 한 번에 하나의 스레드만 그 모니터를 소유할 수 있다. 같은 스레드가 다시 들어오면 카운트가 올라가는 재진입(reentrant) 구조다.
  • ReentrantLock: Lock 인터페이스의 구현체로, 명시적으로 lock() / unlock()을 호출한다. 이름 그대로 재진입이 가능하고, 내부는 AQS라는 동기화 프레임워크 위에 올라가 있다.

두 락 모두 "재진입"이라는 이름값을 하지만, 결정적 차이는 락을 거는 주체가 누구냐다. synchronized는 JVM이 락의 획득/해제를 대신 관리해 주고, ReentrantLock은 개발자가 직접 해제를 책임진다.

3. 내부 동작

synchronized — 모니터와 mark word

synchronized 블록은 컴파일하면 바이트코드 monitorenter / monitorexit 한 쌍으로 바뀐다. 동기화 메서드는 별도 바이트코드 대신 메서드 플래그 ACC_SYNCHRONIZED가 붙고, 진입/이탈 시 JVM이 같은 모니터 동작을 수행하는 것으로 알려져 있다.

public void inc() {
    synchronized (this) {   // monitorenter
        count++;
    }                       // monitorexit (정상/예외 경로 모두)
}

HotSpot에서는 이 모니터 락을 한 번에 무거운 OS 뮤텍스로 잡지 않고, 경합 정도에 따라 단계적으로 승격(inflation)시키는 것으로 이해했다. 객체 헤더의 mark word 를 이용한 흐름은 대략 이렇다.

단계상황동작(대략)
Biased Lock단일 스레드가 반복 진입mark word에 스레드 ID 기록, CAS 없이 통과
Lightweight Lock짧은 경합mark word에 CAS로 락 레코드 포인터 교체(스핀)
Heavyweight Lock경합 심함OS 모니터(뮤텍스)로 승격, 스레드 블로킹

다만 biased locking은 JDK 15(JEP 374)에서 기본 비활성화·폐기 예정으로 바뀐 것으로 알려져 있다. 그래서 최신 JDK에서는 위 표의 첫 단계가 사라졌다고 보는 게 안전하다. 이런 부분은 버전에 따라 동작이 달라지므로 "옛날 글의 biased lock 설명"을 그대로 믿지 않는 편이 좋다.

ReentrantLock — AQS와 state

ReentrantLock은 내부에 Sync extends AbstractQueuedSynchronizer를 두고, 모든 락 동작을 AQS에 위임한다. AQS의 핵심은 두 가지다.

  • volatile int state: 락 보유 횟수. 0이면 free, 1 이상이면 점유 중(재진입하면 +1).
  • FIFO 대기 큐: 락을 못 얻은 스레드를 노드로 묶어 줄 세우는 CLH 기반 큐.

획득의 뼈대는 state에 대한 CAS(compare-and-set)다.

// 비공정(nonfair) 획득의 개념 흐름 (실제 구현 요약)
final boolean nonfairTryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {                          // 비어 있으면
        if (compareAndSetState(0, acquires)) {  // CAS로 선점 시도
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        setState(c + acquires);            // 재진입: 카운트만 증가
        return true;
    }
    return false;                          // 실패 → 큐에 들어가 park
}

CAS에 실패한 스레드는 큐에 노드로 들어가 LockSupport.park()로 블로킹되고, 앞 스레드가 unlock()(state를 0으로 release)하면서 다음 노드를 깨운다. 공정(fair) 모드는 여기서 한 줄이 다르다 — 큐에 먼저 기다리는 스레드가 있으면 새 스레드가 끼어들지(barging) 못하게 막는다. 비공정 모드(기본값)는 끼어들기를 허용해서 처리량이 더 높은 대신 굶주림(starvation) 가능성이 있다고 정리했다.

둘 다 보장하는 것 — happens-before

기능 차이와 별개로, 두 락 모두 메모리 가시성을 보장한다는 점은 같다. JLS §17.4의 happens-before 규칙에 따르면 모니터의 unlock은 그 모니터의 이후 lock보다 happens-before 다. ReentrantLockjava.util.concurrent.locks 패키지 문서가 명시하듯, unlock 이전의 모든 동작은 이후의 성공한 lock 다음 동작보다 happens-before 다. 즉 한쪽이 락 안에서 쓴 값은 다른 쪽이 락을 잡은 뒤 반드시 보이는데, 그 토대는 AQS의 statevolatile이라는 점이다.

4. 비교와 예시

기능 차이를 한 표로 정리하면 이렇다.

항목synchronizedReentrantLock
수준JVM 내장(키워드)라이브러리(클래스)
해제JVM이 자동(블록 이탈/예외)개발자가 unlock() 직접
타임아웃 획득불가tryLock(timeout)
인터럽트 응답대기 중 불가lockInterruptibly()
공정성 선택불가(비공정)생성자 인자로 fair 선택
조건 변수wait/notify 1개여러 개의 Condition

ReentrantLock이 가져다주는 실질적 이점은 "락을 무한정 기다리지 않을 수 있다"는 점이다. synchronized는 한 번 대기에 들어가면 인터럽트로도 빠져나올 수 없지만, tryLock은 포기가 가능하다.

private final ReentrantLock lock = new ReentrantLock();

void transfer() throws InterruptedException {
    // 1초 안에 못 얻으면 포기 → 데드락/무한대기 회피
    if (lock.tryLock(1, TimeUnit.SECONDS)) {
        try {
            // 임계 구역
        } finally {
            lock.unlock();   // 반드시 finally에서 해제
        }
    } else {
        // 락 획득 실패 시 대안 경로
    }
}

여기서 자주 하는 실수 하나: unlock()finally에 넣지 않는 것이다. synchronized는 예외가 나도 JVM이 모니터를 풀어 주지만, ReentrantLock은 그 책임이 전적으로 개발자에게 있어서 finally 누락 시 락이 영영 풀리지 않는다.

5. 정리

  • 한 줄 요약: synchronized는 JVM이 관리하는 모니터 락, ReentrantLock은 AQS의 volatile state + FIFO 큐로 구현한 라이브러리 락이다. 메모리 가시성(happens-before)은 둘 다 보장하지만, 타임아웃·인터럽트·공정성·다중 Condition은 후자만 제공한다.
  • 단순 상호배제면 가독성과 자동 해제 때문에 synchronized가, 세밀한 제어가 필요하면 ReentrantLock이 맞다고 정리했다.
  • biased locking처럼 버전에 따라 사라진 최적화가 있으니, JVM 락 동작 설명은 JDK 버전을 함께 확인하는 게 안전하다.

더 파고들 만한 주제:

  • AQS의 공유 모드: ReentrantLock은 배타 모드지만, Semaphore·CountDownLatch는 같은 AQS의 공유(shared) 모드를 쓴다.
  • ReentrantReadWriteLock: 하나의 state(32비트)를 읽기/쓰기 카운트로 쪼개 쓰는 방식.

참고 자료

  • Java Language Specification §17 (Threads and Locks) — 모니터, happens-before, lock/unlock 규칙
  • java.util.concurrent.locks 패키지 문서 — Lock/ReentrantLock의 메모리 일관성 보장
  • AbstractQueuedSynchronizer API 문서 — state와 FIFO 대기 큐
  • JEP 374: Disable and Deprecate Biased Locking — biased lock 기본 비활성화 변경

0개의 댓글