CodeGym /행동 /JAVA 25 SELF /ReentrantLock 및 ReadWriteLock: 차이점과 예제

ReentrantLock 및 ReadWriteLock: 차이점과 예제

JAVA 25 SELF
레벨 52 , 레슨 2
사용 가능

1. ReentrantLock 클래스: 유연한 잠금

키워드 synchronized는 기본적인 경우에 매우 적합합니다: 메서드나 코드 블록을 빠르고 간단하게 보호할 수 있습니다. 하지만 때로는 더 많은 것이 필요합니다:

  • 잠금을 명시적으로 제어하기(예: 잠금 시도를 하고, 실패하면 기다리지 않기).
  • 리소스에 대한 “읽기”와 “쓰기” 권한을 분리하기.
  • 잠금 대기를 중단시키기.
  • 누가 언제 잠금을 획득하거나 해제했는지 진단하기.

이런 과제를 위해 ReentrantLockReadWriteLock 클래스가 고안되었습니다. 이들은 익숙한 synchronized보다 더 많은 제어와 기능을 제공합니다.

정확히 무엇인가요?

ReentrantLock은 인터페이스 Lock을 구현하는 클래스입니다. synchronized와 비슷하게 동작하지만 추가 기능이 있습니다. 가장 큰 차이는 잠금 제어가 명시적이라는 점입니다: 직접 lock()unlock() 메서드를 호출합니다.

흥미로운 점은 reentrant라는 단어가 스레드가 동일한 잠금을 여러 번 연속으로 획득해도 교착상태를 만들지 않는다는 뜻이라는 것입니다. 이는 메서드가 재귀적으로 자신을 호출하거나 호출 체인에서 공용 잠금을 사용하는 경우에 유용합니다.

사용 구문

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private int value = 0;
    private final Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock(); // 잠금 획득
        try {
            value++;
        } finally {
            lock.unlock(); // 반드시 잠금을 해제합니다!
        }
    }

    public int getValue() {
        lock.lock();
        try {
            return value;
        } finally {
            lock.unlock();
        }
    }
}

주의:
lock()unlock() 호출은 항상 try...finally와 함께 사용해야 합니다. unlock() 호출을 잊으면 다른 어떤 스레드도 보호된 블록에 들어갈 수 없어 ‘영원한 정체’가 발생합니다.

ReentrantLock의 기능

잠금 시도:
잠금을 시도하되, 무한정 기다리지 않을 수 있습니다:

if (lock.tryLock()) {
    try {
        // 작업 수행
    } finally {
        lock.unlock();
    }
} else {
    // 잠금 획득 실패 — 다른 일을 함
}

타임아웃을 두고 대기:

if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
    // 100ms 안에 잠금 획득
}

잠금이 이미 획득되었는지 확인:

if (lock.isLocked()) { ... }

대기 큐 진단, “공정성” 잠금 등 다양한 부가 기능.

3. 예제: ReentrantLock으로 카운터 증가

콘솔 애플리케이션을 확장해 봅시다(예: 여러 스레드에서 주문 처리를 모사). synchronizedReentrantLock을 사용할 때의 동작을 비교합니다.

synchronized 예제

public class OrderCounter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

ReentrantLock으로 동일한 예제

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class OrderCounter {
    private int count = 0;
    private final Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

무엇이 이점인가요?

  • 잠금 시도를 하고 무한정 기다리지 않을 수 있습니다 (tryLock()).
  • 복잡한 로직을 구현할 수 있습니다: 예를 들어 여러 잠금을 특정 순서로 획득하기(복잡한 자료구조에서 유용).
  • 다른 위치에서 “해제”할 수도 있습니다(그러나 매우 주의해야 합니다 — 항상 unlock()을 잊지 마세요!).

4. ReadWriteLock: 읽기/쓰기 잠금

무엇인가요?

ReadWriteLock은 단순한 잠금이 아니라 스마트한 접근 배분기입니다. 대표 구현은 ReentrantReadWriteLock이며, 읽기용과 쓰기용 두 가지 잠금으로 나눕니다.

스레드들이 데이터만 읽고 아무도 변경하지 않는다면 서로 간섭 없이 함께 동작할 수 있습니다. 그러나 누군가가 수정하려는 순간, 다른 모든 스레드는 대기해야 합니다. 쓰기는 한 명만 허용되며 조용한 환경이 필요합니다.

이 접근 방식은 읽기가 많고 쓰기는 드문 곳에서 특히 유용합니다 — 예를 들어 사용자는 상품 카탈로그를 계속 조회하지만 업데이트는 가끔만 일어나는 경우입니다.

사용 구문

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ProductCatalog {
    private final Map<String, String> products = new HashMap<>();
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

    public void addProduct(String id, String name) {
        rwLock.writeLock().lock();
        try {
            products.put(id, name);
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    public String getProduct(String id) {
        rwLock.readLock().lock();
        try {
            return products.get(id);
        } finally {
            rwLock.readLock().unlock();
        }
    }
}

우리 애플리케이션에서의 사용 예

여러 스레드가 조회하는 주문 데이터베이스가 있고(예: 주문 검색), 가끔 새로운 주문이 들어오는(쓰기) 상황을 가정해 봅시다.

import java.util.*;
import java.util.concurrent.locks.*;

public class OrderDatabase {
    private final List<String> orders = new ArrayList<>();
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

    // 주문 추가(writeLock 필요)
    public void addOrder(String order) {
        rwLock.writeLock().lock();
        try {
            orders.add(order);
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    // 모든 주문의 복사본 가져오기(병렬 읽기 가능)
    public List<String> getOrders() {
        rwLock.readLock().lock();
        try {
            // 경합을 피하기 위해 복사본을 반환
            return new ArrayList<>(orders);
        } finally {
            rwLock.readLock().unlock();
        }
    }
}

무엇이 일어나나요?

  • 아무도 쓰지 않는 동안에는 수천 개의 스레드도 동시에 주문 목록을 읽을 수 있습니다.
  • 한 스레드가 주문 추가를 시작하는 순간, 일관성 없는 데이터를 막기 위해 읽기는 차단됩니다.

5. 비교: 언제 무엇을 사용할까?

시나리오 synchronized ReentrantLock ReadWriteLock
단순 동기화 ✖ (과도함)
타임아웃/잠금 시도가 필요
읽기 많고 쓰기 적음 ✔ (상당한 향상)
진단/통계가 필요
재귀적 잠금 ✔ (재진입성)

결론:

  • 단순한 경우 — synchronized를 사용하세요.
  • 유연성이 필요 — ReentrantLock.
  • ‘자주 읽고, 드물게 쓰는’ 시나리오 — ReadWriteLock.

6. 시각화: ReadWriteLock 동작 흐름

flowchart LR
    subgraph 읽기
      T1[스레드 1] -- 읽기 --> Orders
      T2[스레드 2] -- 읽기 --> Orders
      T3[스레드 3] -- 읽기 --> Orders
    end
    subgraph 쓰기
      T4[스레드 4] -- 쓰기 (addOrder) --> Orders
    end
    Orders[주문 목록]
    style Orders fill:#f9f,stroke:#333,stroke-width:2px

어느 스레드도 쓰지 않는 동안에는 모두가 동시에 읽을 수 있습니다. 쓰기가 발생하면 나머지 스레드는 writeLock이 해제될 때까지 대기합니다.

7. 구현상의 특징과 유의점

“공정성”(fairness)

ReentrantLockReentrantReadWriteLock에는 “공정” 모드(fair mode)를 활성화할 수 있습니다: 스레드는 대기열 순서대로 처리되어 ‘기아’ 현상을 방지하지만 성능이 저하될 수 있습니다.

Lock fairLock = new ReentrantLock(true); // true — 공정 모드
ReadWriteLock fairRWLock = new ReentrantReadWriteLock(true);

잠재적 함정

  • unlock을 잊음: unlock()을 호출하지 않으면 영구 잠금이 발생합니다. 항상 try...finally를 사용하세요.
  • 잠금 내부에서의 예외: 코드 블록에서 예외가 발생하더라도 잠금은 반드시 해제되어야 합니다!
  • ReadWriteLock의 과도한 사용: 작은 컬렉션이거나 거의 항상 쓰기만 하는 경우에는 ReadWriteLock의 이점이 거의 없고 코드만 복잡해집니다.

8. 흔한 실수

오류 №1: unlock() 호출을 잊음
가장 흔하고 교활한 실수는 잠금을 획득한 뒤 unlock() 호출을 잊는 것입니다. 그 결과 영구 잠금이 발생하고 스레드가 ‘멈춥니다’. 아무 일도 없을 것 같아도 항상 try...finally를 사용하세요.

오류 №2: 필요하지 않은 곳에 ReadWriteLock 사용
동시 읽기가 거의 없고 쓰기가 잦다면, ReadWriteLock은 코드만 복잡하게 하고 성능을 낮춥니다. 실제로 다수의 동시 읽기가 있는 경우에만 사용하세요.

오류 №3: 여러 잠금을 서로 다른 순서로 획득
코드가 여러 Lock을 획득한다면(예: 여러 객체용), 모든 스레드에서 항상 동일한 순서로 하세요. 그렇지 않으면 deadlock이 발생하여 스레드가 서로를 영원히 기다리게 됩니다.

오류 №4: 특별한 이유 없이 synchronized를 ReentrantLock으로 교체
모든 synchronizedLock으로 무작정 바꾸지 마세요 — 항상 성능이 빨라지는 것도 아니고 코드가 덜 읽기 쉬워질 수 있습니다.

오류 №5: 재진입성을 간과
동일한 스레드가 lock()을 여러 번 연속으로 호출하는 것은 ReentrantLock에서는 정상이지만, unlock()도 같은 횟수만큼 호출해야 합니다!

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION