티스토리 뷰

Java & Kotlin

[Java] synchronized의 한계를 극복하기 위한 Lock 인터페이스와 ReentrantLock 클래스

망나니개발자 2026. 1. 13. 10:00
반응형

 

 

1. synchronized의 한계를 극복하기 위한 Lock 인터페이스와 ReentrantLock 클래스


이전 포스팅에서 설명하였듯, synchronized 키워드를 활용한 동시성 제어는 다음의 2가지 치명적인 문제가 있었다.

  • 공정성 문제: 락의 획득 순서가 보장되지 않음
  • 무한 대기 문제: 대기하는 스레드를 깨우는 등의 제어를 할 수 없음

 

 

 이런 문제를 해결하기 위해 자바 1.5부터 java.util.concurrent 라는 동시성 문제 해결을 위한 라이브러리 패키지가 추가되었다.

 

 

[ 무한 대기 문제를 해결하기 위한 LockSupport 클래스 ]

먼저 무한 대기 문제를 해결하기 위해 LockSupport 클래스가 추가되었다. LockSupport는 스레드를 블로킹/언블로킹하기 위한 가장 기본적인 도구로, 먼저 스레드를 대기 상태로 변경시킬 수 있다. 이때 스레드는 WAITING 또는 TIMED_WAITING상태가 되며, 누가 깨워주기 전까지는 계속 대기하며 CPU 실행 스케줄링에 들어가지 않는다. 스레드를 깨울 수도 있는데, 그러면 대기중인 스레드가 RUNNABLE로 바뀌며 다시 작업을 수행하게 된다.

이러한 LockSupport 클래스가 제공하는 대표적인 기능은 다음과 같다.

  • park() : 스레드를 대기 상태로 두며, 이때 스레드 상태는 WAITING 상태가 된다.
  • parkNanos(nanos) : 스레드를 특정 나노초 동안만 대기 상태로 두며, 이때 스레드 상태는 TIMED_WAITING 상태가 된다. 지정한 시간이 지나면 TIMED_WAITING 상태에서 빠져나오고 RUNNABLE 상태로 변경된다.
  • unpark(thread) : WAITING 상태의 대상 스레드를 RUNNABLE 상태로 변경한다.

 

 

LockSupport를 활용하는 예제 코드는 다음과 같다.

public class LockSupportMainV1 {
     public static void main(String[] args) {
          Thread thread1 = new Thread(new ParkTask(), "Thread-1");
          thread1.start();

          // 잠시 대기하여 Thread-1이 park 상태에 빠질 시간을 준다. 
          sleep(100);
          log("Thread-1 state: " + thread1.getState());
          log("main -> unpark(Thread-1)"); 
          LockSupport.unpark(thread1); // 1. unpark 사용 
          //thread1.interrupt(); // 2. interrupt() 사용
     }

     static class ParkTask implements Runnable {
          @Override
          public void run() { 
               log("park 시작");
               LockSupport.park();
               log("park 종료, state: " + Thread.currentThread().getState()); 
               log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
          } 
     }
}

 

 

위의 코드를 실행하면 다음과 같은 흐름을 확인할 수 있다.

  • main 스레드가 Thread-1 을 start() 하면 Thread-1 은 RUNNABLE 상태가 됨
  • Thread-1 은 Thread.park() 를 호출하고, RUNNABLE 상태에서 WAITING 상태가 되면서 대기함
  • main 스레드가 Thread-1 을 unpark() 로 깨우고, `Thread-1 은 RUNNABLE 상태로 변함

 

 

이처럼 LockSupport 는 특정 스레드를 WAITING 또는 TIMED_WAITING 상태와 RUNNABLE 상태로 변경시킬 수 있다. 참고로 여기서 대기중인 스레드에 인터럽트가 발생하면 WAITING 또는 TIMED_WAITING 상태에서 RUNNABLE 상태로 변하면서 깨어난다.

 

 

[ Lock 인터페이스와 ReentrantLock 클래스의 사용법 ]

Lock 인터페이스

Lock 인터페이스와 ReentrantLock 클래스는 더 강력하고 유연한 동시성 제어를 위해 등장한 도구들이다. Lock 인터페이스는 synchronized 키워드를 통한 암묵적인 락과 다르게 명시적인 락 획득과 해제를 지원한다. 조건 없는 락, 폴링 락, 타임아웃이 있는 락, 락 확보 대기 상태에 인터럽트를 걸 수 있는 방법 등이 포함돼 있으며, 락을 확보하고 해제하는 모든 작업이 명시적이다. 즉, Lock 인터페이스는 동시성 프로그래밍에서 쓰이는 안전한 임계 영역을 위해 락을 추상화한 인터페이스이다.

package java.util.concurrent.locks;

 public interface Lock {
     void lock();
     void lockInterruptibly() throws InterruptedException;
     boolean tryLock();
     boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
     void unlock();
     Condition newCondition();
}

 

 

ReentrantLock 클래스

Lock 인터페이스는 대표적으로 다음의 2가지 구현을 제공한다.

  • ReentrantLock: 이는 기존 자바 동기화 블록에서 사용되는 잠금과 거의 동일하지만 더 유연하다.
  • ReentrantReadWriteLock: 많은 Reader와 적은 Writer가 있는 경우 성능을 개선할 수 있다.

 

 

ReentrantLock은 읽기와 쓰기 작업의 구분이 없기 때문에, 여러 동시 읽기 요청이 와도 하나의 작업만 처리 가능하다. 이러한 문제를 극복하기 위해 읽기 작업은 여러 요청을 허용하지만 쓰기 작업은 하나만 허용하도록 고도화하여 성능 향상을 꾀한 것이 바로 ReentrantReadWriteLock 이다. 참고로 ReentrantLock은 재진입 락이라는 의미인데, 이는 synchronized 키워드와 마찬가지로 동일한 스레드가 이미 획득한 락을 다시 획득할 수 있음을 의미한다.

 

ReentrantLock는 구현을 통해 다음과 같은 기능을 제공하며, 내부적으로 스레드의 대기와 깨우기를 위해 LockSupport가 사용된다.

  • void lock()
    • 락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때까지 현재 스레드는 대기(WAITING)한다. 이 메서드는 인터럽트에 응답하지 않는다.
    • 여기서 사용하는 락은 객체 내부의 모니터 락이 아니고, Lock 인터페이스와 ReentrantLock 이 제공하는 기능이다. 모니터 락과 BLOCKED 상태는 synchronized 에서만 사용된다.
  • void lockInterruptibly()
    • 락 획득을 시도하되, 다른 스레드가 인터럽트 할 수 있다.
    • 만약 다른 스레드가 이미 락을 획득했다면 현재 스레드는 락을 획득할 때까지 대기하고, 대기 중에 인터럽트가 발생하면 InterruptedException이 발생하며 락 획득을 포기한다.
  • boolean tryLock()
    • 락 획득을 시도하고, 즉시 성공 여부를 반환한다.
    • 락 획득에 성공하면 true를 반환하고, 다른 스레드에 의해 락을 선점당해 락 획득에 실패했다면 false를 반환한다.
  • boolean tryLock(long time, TimeUnit unit)
    • 주어진 시간 동안 락 획득을 시도하는데, 락을 획득하면 true를 반환하고 실패하면 false 를 반환한다.
    • 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException 이 발생하며 락 획득을 포기한다.
  • void unlock()
    • 락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있게 된다.
    • 락을 획득한 스레드가 호출해야 하며, 그렇지 않으면 IllegalMonitorStateException 이 발생할 수 있다.
  • Condition newCondition()
    • Condition 객체를 생성하여 반환한다.
    • Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 한다. 이는 Object 클래스의 wait , notify , notifyAll 메서드와 유사한 역할을 한다.

 

 

참고로 대기(WAITING) 상태의 스레드에 인터럽트가 발생하면 대기 상태를 빠져 나오는 것이 맞다. lock() 메서드를 사용하면, WAITING 상태가 되므로 인터럽트에 반응하여 아주 짧은 순간 대기 상태를 빠져 나와 RUNNABLE 이 된다. 그런데 lock() 메서드 안에서 해당 스레드를 다시 WAITING 상태로 강제로 변경하여 인터럽트를 무시한다. 대신 인터럽트가 필요하면 사용할 수 있는 lockInterruptibly() 를 제공하여, 개발자에게 보다 유연하고 다양한 선택권을 주었다.

명시적인 락을 사용할 때 주의할 점은 반드시 finally 블록에서 락을 해제해야 한다는 것이다. 락을 finally 블록에서 해제하지 않으면 try 구문 내부에서 예외가 발생했을 때 락이 해제되지 않는 경우가 발생한다. 따라서 이러한 부분을 놓치게 되면 시스템에 심각한 문제를 초래할 수 있다.

public class BankAccount {
    private int balance = 0;
    private final Lock lock = new ReentrantLock();

    public void deposit(int amount) {
        lock.lock(); // 락 획득
        try {
            balance += amount;
        } finally {
            lock.unlock(); // 반드시 finally 블록에서 락 해제
        }
    }

    public void withdraw(int amount) {
        lock.lock(); // 락 획득
        try {
            if (balance >= amount) {
                balance -= amount;
            }
        } finally {
            lock.unlock(); // 반드시 finally 블록에서 락 해제
        }
    }
}

 

 

이제 무한 대기 문제가 해결되었으므로, 다음 문제인 공정성 문제를 해결할 차례이다. ReentrantLock 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공한다. 기본적으로 ReentrantLock은 비공정 모드이며, 해당 값은 생성자를 통해 설정할 수 있다.

public class ReentrantLockFairMode {

    private final Lock nonFairLock = new ReentrantLock();  // 비공정 모드 락
    private final Lock fairLock = new ReentrantLock(true); //공정 모드 락

    public void nonFairLockTest() {
        nonFairLock.lock();
        try {
            // 임계 영역 
        } finally {
            nonFairLock.unlock();
        }
    }
     
    public void fairLockTest() {
        fairLock.lock();
        try {
            // 임계 영역
        } finally {
            fairLock.unlock();
        }
    }
}

 

 

참고로 불공정한 ReentrantLock이라고 하더라도 일부러 순서를 뛰어넘는 것은 아니다. 락이 해제되는 정확한 타이밍에 락 획득을 시도하는 스레드가 있다면 락 획득을 허용할 뿐이다. 반대로 공정한 락을 사용하면 항상 락을 획득하려는 스레드가 항상 큐의 대기자 목록 맨 뒤에 쌓일 뿐이다.

즉, 불공정 락도 대부분의 경우에는 공정성이 지켜지지만, 간혹 예외 케이스가 있을 뿐이다. 공정 모드를 사용하면 불필요하게 성능이 저하될 수 있어서, 순서가 매우 크리티컬한 케이스가 아니라면 가급적 비공정 모드의 사용을 권장한다.

이렇듯 Lock 인터페이스와 ReentrantLock 구현체를 사용하면 synchronized 단점인 무한 대기 문제와 공정성 문제를 모두 해결할 수 있다. 또한 훨씬 유연한 기능을 제공하여 오늘날 많이 사용되고 있다.

 

 

[ Condition 인터페이스 ]

동시성 프로그래밍을 개발하다 보면 특정 조건이 만족할 때까지 작업을 멈추고 대기시키고(await), 조건이 충족되면 해당 스레드들을 깨우고(signal) 작업을 진행시킬 필요가 있다. Condition 인터페이스는 Lock과 함께 스레드들을 대기시키고 깨워주는 메커니즘으로, synchronized의 wait(), notify(), notifyAll()에 상응하는 기능이라고 볼 수 있다.

해당 기능은 특정 조건에 따라 스레드를 먼저 대기시켜야 하므로, 반드시 특정한 Lock에 종속되어 Condition 객체가 만들어져야 한다. 다음의 코드는 Condition 객체를 활용하여 계좌에서 출금을 시도할 때 잔액이 부족하면 스레드를 대기시키고, 잔액이 충전되면 모든 스레드를 깨워 처리를 진행하게 한다.

public class BankAccount {
    private long balance = 0;

    private final Lock lock = new ReentrantLock();
    private final Condition sufficientFunds = lock.newCondition();

    public BankAccount() {}

    public void deposit(long amount) {
        lock.lock();

        try {
            balance += amount;
            // 잔액이 늘었으니, 기다리던 출금 스레드들을 모두 깨워 진행시킴
            sufficientFunds.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public void withdraw(long amount) throws InterruptedException {
        lock.lockInterruptibly();

        try {
            while (balance < amount) {
                // 출금할 때 잔액이 부족하면, 잔액이 충분해질 때까지 대기시킴
                sufficientFunds.await();
            }
            balance -= amount;
        } finally {
            lock.unlock();
        }
    }
}

 

 

Condition 객체는 Lock 하나를 대상으로 필요한 만큼 몇 개라도 만들 수 있다. Condition 객체는 자신을 생성해준 Lock 객체의 공정성을 그대로 물려받는데, 공정한 Lock에서 생성된 Condition 객체의 경우에는 Condition.await 메소드에서 리턴될 때 정확하게 FIFO 순서를 따른다.

 

 

[ Lock와 ReentrantLock의 특징 요약 및 정리 ]

이렇듯 Lock 인터페이스와 ReentrantLock 클래스는 synchronized 키워드와 코드는 조금 다르지만 기본적인 사용법은 매우 비슷하다. 하지만 대기 중 인터럽트, 페어 락(fair lock), 둘 이상의 조건 지정 등 보다 유연한 기능을 제공한다.

  • 대기 중 인터럽트: 락을 소유한 스레드가 오랜 시간 락을 해제하지 않을 때, 같은 락을 얻기 위해 대기 중인 다른 스레드들은 락을 포기하고 다른 일을 할 수 있다.
  • 공적 락: 같은 락을 얻기 위해 대기하는 스레드가 많을 때 락 획득을 시도한 시간 순서대로 락을 얻는다.
  • 둘 이상의 조건 지정: ReentrantLock은 동시에 여러 개의 Condition 객체와 연결 지을 수 있다.

 

 

참고로 성능 상의 문제로 synchronized 키워드 대신 ReentrantLock을 선택해야 한다는 얘기도 많이 있다. 하지만 JDK 6 이후 버전을 이용중이라면, synchronized도 최적화 기법들이 많이 도입되어, synchronized냐 ReentrantLock이냐를 선택할 때 성능은 더 이상 고려 대상이 아니다.

 

 

 

 

 

관련 포스팅

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함