
Redisson은 분산 환경에서 동시성 문제를 효율적으로 해결하기 위한 다양한 도구를 제공합니다. 그중 분산 ReadWriteLock은 "읽기 작업이 쓰기 작업보다 훨씬 많은" 시나리오에서 시스템의 효율성과 확장성을 극대화하는 데 핵심적인 역할을 합니다. 이 글에서는 Redisson의 분산 ReadWriteLock이 어떻게 동작하며, 어떤 상황에서 유용하게 활용될 수 있는지 자세히 살펴보겠습니다.
실제 비즈니스 시나리오에서는 동일한 핵심 리소스에 대해 여러 스레드가 "읽기" 또는 "쓰기" 작업을 수행해야 하는 경우가 많습니다.
이때, 읽기 작업은 동시에 여러 스레드가 수행해도 문제가 없지만, 쓰기 작업은 데이터의 일관성을 위해 단 하나의 스레드만 수행해야 합니다.
Redisson의 분산 ReadWriteLock은 이러한 요구사항을 충족시키면서도 분산 환경의 복잡성을 투명하게 처리합니다.
즉, 읽기 작업은 병렬로 수행하여 효율성을 높이고, 쓰기 작업은 상호 배타적으로 수행하여 데이터 일관성을 유지할 수 있도록 돕습니다.
분산 ReadWriteLock은 특히 읽기 작업이 쓰기 작업보다 훨씬 많은 시나리오에 적합합니다.
예를 들어, 웹 서비스에서 캐시된 설정 데이터나 제품 정보처럼 빈번하게 조회되지만 업데이트는 드물게 발생하는 데이터에 유용하게 적용할 수 있습니다.
분산 캐시 설정 데이터: 캐시된 설정은 여러 서버에서 동시에 읽혀야 하지만, 설정 업데이트 시에는 모든 읽기 작업을 차단하여 데이터 불일치를 방지해야 합니다.
특정 공유 리소스에 대한 높은 동시성 접근 시나리오: 로그 및 통계 정보처럼 읽기 작업은 병렬로 수행 가능하지만, 쓰기(추가/수정) 작업과는 상호 배타적이어야 하는 경우에 적합합니다.
이제 Redisson의 RReadWriteLock이 어떻게 읽기-쓰기 상호 배제를 구현하는지 소스 코드를 통해 자세히 알아보겠습니다.
JDK의 java.util.concurrent.locks.ReadWriteLock과 유사하게, Redisson의 ReadWriteLock 또한 두 가지 유형의 잠금을 제공합니다.
읽기 잠금 (Read Lock): 여러 스레드가 동시에 획득 가능합니다.
쓰기 잠금 (Write Lock): 단 하나의 스레드만 획득 가능하며, 다른 읽기 또는 쓰기 잠금을 모두 배제합니다.
Redisson은 이러한 동기화 규칙을 Redis와 상호 작용하여 분산 환경에서 일관성과 정확성을 보장합니다.
Redisson에서는 RReadWriteLock 인터페이스를 통해 읽기 잠금 또는 쓰기 잠금을 얻을 수 있습니다.
RReadWriteLock rwLock = redissonClient.getReadWriteLock("mySharedResource");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();
RedissonReadWriteLock의 주요 특징은 다음과 같습니다.
Redis Lua 스크립트 활용: 읽기 잠금의 재진입성과 쓰기 잠금과의 상호 배제 관계를 보장합니다.
Lua 스크립트는 Redis 서버에서 원자적으로 실행되어 분산 환경에서의 동시성 문제를 방지합니다.
읽기 잠금 카운트 유지: 읽기 잠금이 획득될 때마다 해당 읽기 잠금을 보유한 스레드 수를 Redis에 유지합니다.
쓰기 잠금 확인 로직: 쓰기 잠금을 획득할 때, Redis에 읽기 잠금이나 다른 쓰기 잠금이 이미 사용 중인지 확인합니다.
잠금이 사용 중인 경우, 해당 스레드는 대기하거나 잠금 획득에 실패합니다.
Redisson의 RedissonReadWriteLock 클래스를 통해 내부 동작을 살펴보겠습니다.
public class RedissonReadWriteLock implements RReadWriteLock {
private final CommandAsyncExecutor commandExecutor;
private final String writeLockKey; // 쓰기 잠금에 사용될 Redis 키
private final String readLockKey; // 읽기 잠금에 사용될 Redis 키
public RedissonReadWriteLock(CommandAsyncExecutor commandExecutor, String name) {
this.commandExecutor = commandExecutor;
// 동일한 비즈니스 이름을 기반으로 읽기/쓰기 잠금에 대한 고유한 Redis 키 생성
this.writeLockKey = "redisson_rwlock{" + name + "}:write";
this.readLockKey = "redisson_rwlock{" + name + "}:read";
}
@Override
public RLock readLock() {
// 읽기 잠금 객체 반환
return new RedissonReadLock(commandExecutor, readLockKey, writeLockKey);
}
@Override
public RLock writeLock() {
// 쓰기 잠금 객체 반환
return new RedissonWriteLock(commandExecutor, writeLockKey, readLockKey);
}
}
RedissonReadWriteLock은 동일한 비즈니스 이름을 기반으로 쓰기 잠금과 읽기 잠금에 대한 별도의 Redis 키를 생성합니다.
그리고 각 잠금 유형에 맞는 RedissonReadLock과 RedissonWriteLock 인스턴스를 반환하여 실제 잠금/잠금 해제 로직을 처리합니다.
RedissonReadLock)RedissonReadLock은 내부적으로 Lua 스크립트를 사용하여 잠금 및 관련 작업을 구현합니다.
간소화된 잠금 로직은 다음과 같습니다.
public class RedissonReadLock extends RedissonBaseLock {
private final String writeLockKey; // 쓰기 잠금 키 참조
public RedissonReadLock(CommandAsyncExecutor commandExecutor, String lockName, String writeLockName) {
super(commandExecutor, lockName, LockType.READ);
this.writeLockKey = writeLockName;
}
@Override
public void lock() {
try {
acquireLock(-1, null); // 무한정 대기하며 잠금 획득 시도
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return acquireLock(time, unit); // 지정된 시간 동안 잠금 획득 시도
}
private boolean acquireLock(long waitTime, TimeUnit unit) throws InterruptedException {
// 핵심: Redis에 Lua 스크립트를 실행하여 쓰기 잠금 여부 및 읽기 잠금 카운트 확인
// 만약 읽기 잠금 획득이 가능하다면 읽기 잠금 카운트를 1 증가
// 그렇지 않다면 대기하거나 false 반환
return get(tryAcquireAsync(waitTime, unit));
}
// ... (생략된 기타 메서드)
acquireLock 메서드에서는 현재 쓰기 잠금이 사용 중인지 (심지어 현재 스레드 자신이 쓰기 잠금을 보유하고 있는지도 포함하여) 확인합니다.
쓰기 잠금이 없다면, 읽기 잠금 카운트를 직접 1 증가시키고 성공을 반환합니다. 그렇지 않은 경우, 스레드는 대기하거나 실패로 종료됩니다.
RedissonWriteLock )RedissonWriteLock의 설계는 읽기 잠금과 유사하지만, 잠금 확인 로직이 다릅니다.
public class RedissonWriteLock extends RedissonBaseLock {
private final String readLockKey; // 읽기 잠금 키 참조
public RedissonWriteLock(CommandAsyncExecutor commandExecutor, String lockName, String readLockName) {
super(commandExecutor, lockName, LockType.WRITE);
this.readLockKey = readLockName;
}
@Override
public void lock() {
try {
acquireLock(-1, null); // 무한정 대기하며 잠금 획득 시도
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return acquireLock(time, unit); // 지정된 시간 동안 잠금 획득 시도
}
private boolean acquireLock(long waitTime, TimeUnit unit) throws InterruptedException {
// 핵심: Lua 스크립트를 통해 읽기 잠금 카운트가 0이 아니거나
// 다른 쓰기 잠금 스레드가 이미 잠금을 점유하고 있는지 확인
// 만약 잠금 획득이 가능하다면 쓰기 잠금 키(값)를 현재 스레드 식별자로 설정하고 카운트 증가
return get(tryAcquireAsync(waitTime, unit));
}
// ... (생략된 기타 메서드)
}
쓰기 잠금은 읽기 잠금 수가 0이 아니거나, 다른 쓰기 잠금 스레드가 이미 해당 잠금을 점유하고 있는 경우 획득할 수 없습니다.
이러한 모든 검증 및 잠금 획득 작업은 Redis로 전송되는 Lua 스크립트를 통해 원자적으로 처리됩니다.
unlock())읽기 잠금이든 쓰기 잠금이든 unlock() 호출은 내부적으로 Lua 스크립트를 실행하여 잠금 카운트를 감소시킵니다.
읽기 잠금 해제: 읽기 잠금 카운트가 0으로 떨어지면 더 이상 아무도 읽기 잠금을 보유하고 있지 않음을 의미하며, 해당 Redis 키를 지우고 잠금 리소스를 완전히 해제합니다.
쓰기 잠금 해제: 쓰기 잠금 카운트가 0으로 돌아가면 마찬가지로 잠금이 완전히 해제됩니다.
Redisson은 분산 ReadWriteLock의 일관성을 위해 다음과 같은 메커니즘을 사용합니다.
Redisson은 잠금, 잠금 해제, 카운트 증가/감소와 같은 모든 중요한 동작을 Lua 스크립트로 패키징하여 Redis 서버에서 원자적으로 실행합니다.
이는 네트워크 전송 중에 단계별 실행으로 발생할 수 있는 동시성 문제를 근본적으로 방지합니다.
Redis에서 읽기 잠금과 쓰기 잠금은 서로 다른 키에 매핑됩니다. 이는 잠금 간의 충돌을 방지하고 읽기 잠금 카운트 관리를 용이하게 합니다.
하지만 잠금 Lua 스크립트 내부에서는 올바른 관계를 유지하기 위해 쓰기 잠금 키를 먼저 확인한 후 읽기 잠금 키를 확인하는 등 상호 의존적인 검사를 수행합니다.
동일한 스레드가 이미 획득한 읽기 잠금이나 쓰기 잠금을 다시 획득하면 Redis의 관련 카운트가 증가합니다.
이는 해당 스레드가 리소스를 계속 보유할 수 있도록 보장하며, 분산 시나리오에서 재진입이 필수적인 요구사항임을 반영합니다.
다른 Redisson 잠금과 마찬가지로 ReadWriteLock도 leaseTime과 같은 메커니즘을 제공합니다. 이는 지정된 시간이 지나면 잠금이 자동으로 해제되도록 하여, 예기치 않은 오류로 인한 영구적인 교착 상태를 방지하는 중요한 안전장치입니다.
Redisson의 RReadWriteLock은 분산 환경에서 로컬 ReadWriteLock의 동작을 완벽하게 재현합니다. 즉, 읽기 잠금은 병렬로 사용할 수 있고, 쓰기 잠금은 상호 배타적이며, 읽기-쓰기 또한 상호 배타적인 규칙을 따릅니다.
Redisson은 Lua 스크립트와 Redis를 결합하여 잠금 및 잠금 해제 프로세스의 원자성과 정확성을 보장합니다.
또한, 재진입 및 자동 갱신(만료) 기능을 고려하여 견고한 분산 잠금 솔루션을 제공합니다.
읽기 횟수가 많고 쓰기 횟수가 적은 분산 비즈니스 시나리오에서 Redisson 분산 ReadWriteLock은 동시성 성능과 리소스 상호 배타성 사이에서 최적의 균형을 찾아 시스템 처리량을 크게 향상시킬 수 있는 핵심 도구입니다.