1. ReentrantLock 클래스: 유연한 잠금
키워드 synchronized는 기본적인 경우에 매우 적합합니다: 메서드나 코드 블록을 빠르고 간단하게 보호할 수 있습니다. 하지만 때로는 더 많은 것이 필요합니다:
- 잠금을 명시적으로 제어하기(예: 잠금 시도를 하고, 실패하면 기다리지 않기).
- 리소스에 대한 “읽기”와 “쓰기” 권한을 분리하기.
- 잠금 대기를 중단시키기.
- 누가 언제 잠금을 획득하거나 해제했는지 진단하기.
이런 과제를 위해 ReentrantLock과 ReadWriteLock 클래스가 고안되었습니다. 이들은 익숙한 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으로 카운터 증가
콘솔 애플리케이션을 확장해 봅시다(예: 여러 스레드에서 주문 처리를 모사). synchronized와 ReentrantLock을 사용할 때의 동작을 비교합니다.
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)
ReentrantLock과 ReentrantReadWriteLock에는 “공정” 모드(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으로 교체
모든 synchronized를 Lock으로 무작정 바꾸지 마세요 — 항상 성능이 빨라지는 것도 아니고 코드가 덜 읽기 쉬워질 수 있습니다.
오류 №5: 재진입성을 간과
동일한 스레드가 lock()을 여러 번 연속으로 호출하는 것은 ReentrantLock에서는 정상이지만, unlock()도 같은 횟수만큼 호출해야 합니다!
GO TO FULL VERSION