csct3434
Redis의 낙관적 락(Optimistic Lock)과 분산 락(Distributed Lock) 비교 본문
Redis를 사용하는 분산 환경에서 동시성 제어는 데이터 일관성을 유지하는 데 매우 중요합니다. 두 가지 주요 접근 방식인 낙관적 락과 분산 락의 차이점과 각각의 사용 사례를 자세히 살펴보겠습니다.
1. 개념 및 기본 원리
낙관적 락(Optimistic Lock)
- 기본 원리: 충돌이 드물게 발생한다고 가정하고, 실제 업데이트 시점에만 충돌을 검사
- 구현 방식: Redis의 WATCH, MULTI, EXEC 명령어 조합 사용
- 작동 방식: 키를 감시(watch)하고 변경 감지 시 트랜잭션 실패 처리
분산 락(Distributed Lock)
- 기본 원리: 자원에 접근하기 전에 명시적으로 락을 획득하고 작업 후 해제
- 구현 방식: Redis의 SET NX PX 또는 Redisson 라이브러리 사용
- 작동 방식: 락을 성공적으로 획득한 클라이언트만 작업 수행 가능, 다른 클라이언트는 대기
2. 구현 방법 및 코드 예시
낙관적 락 구현 예시
public boolean updateWithOptimisticLock(String key, String newValue, String expectedValue) {
return redisTemplate.execute(new SessionCallback<Boolean>() {
@Override
public Boolean execute(RedisOperations operations) throws DataAccessException {
operations.watch(key); // 키 변경 감시 시작
String currentValue = (String) operations.opsForValue().get(key);
// 기대값과 현재값 비교
if (expectedValue.equals(currentValue)) {
operations.multi(); // 트랜잭션 시작
operations.opsForValue().set(key, newValue);
List<Object> result = operations.exec(); // 트랜잭션 실행
// 트랜잭션이 성공적으로 실행되었는지 확인
return result != null && !result.isEmpty();
}
operations.unwatch(); // watch 해제
return false;
}
});
}
// 재시도 로직 구현
public boolean updateWithRetry(String key, String newValue, int maxRetries) {
int retries = 0;
boolean success = false;
while (!success && retries < maxRetries) {
String currentValue = redisTemplate.opsForValue().get(key);
success = updateWithOptimisticLock(key, newValue, currentValue);
if (!success) {
retries++;
try {
// 지수 백오프 적용
Thread.sleep((long) (Math.random() * Math.pow(2, retries) * 100));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
return success;
}
분산 락 구현 예시 (Redisson 사용)
public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime, Supplier<T> task) {
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
// 락 획득 시도
locked = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
if (locked) {
// 락을 획득했으므로 작업 실행
return task.get();
} else {
throw new LockAcquisitionException("Failed to acquire lock: " + lockKey);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquisitionException("Lock acquisition interrupted", e);
} finally {
// 락이 현재 스레드에 의해 보유되고 있다면 해제
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
// 여러 리소스에 대한 락 획득 (예: 계좌 이체)
public <T> T executeWithMultiLock(List<String> lockKeys, Supplier<T> task) {
List<RLock> locks = lockKeys.stream()
.map(redissonClient::getLock)
.collect(Collectors.toList());
RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));
boolean locked = false;
try {
// 모든 락을 획득하려고 시도
locked = multiLock.tryLock(10, 30, TimeUnit.SECONDS);
if (locked) {
return task.get();
} else {
throw new LockAcquisitionException("Failed to acquire all locks");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquisitionException("Lock acquisition interrupted", e);
} finally {
if (locked) {
multiLock.unlock();
}
}
}
3. 두 방식의 주요 차이점
특성 낙관적 락 분산 락
동시성 제어 방식 | 충돌 감지 (Detect & Retry) | 상호 배제 (Mutual Exclusion) |
락 획득 시점 | 락을 획득하지 않음 | 작업 전 명시적 락 획득 |
충돌 처리 | 충돌 발생 시 트랜잭션 실패, 재시도 필요 | 락 획득 실패 시 대기 또는 포기 |
성능 오버헤드 | 충돌이 적을 때 낮음 | 항상 락 획득/해제 오버헤드 발생 |
일관성 수준 | 재시도 구현 시 최종 일관성 | 강한 일관성 |
데드락 위험 | 낮음 (명시적 락 없음) | 존재 (타임아웃으로 완화) |
실패 복구 | 자동 롤백 (트랜잭션 실패) | 수동 롤백 필요, 락 해제 보장 필요 |
4. 낙관적 락과 분산 락(배타적 락)의 선택 기준
재시도 로직을 구현한다면 낙관적 락(Optimistic Lock)으로도 많은 상황에서 충분할 수 있습니다. 그럼에도 분산 락(배타적 락)을 사용해야 하는 몇 가지 중요한 이유가 있습니다:
분산 락(배타적 락)이 필요한 상황
1. 높은 경합(Contention) 환경에서의 성능 이슈
재시도가 많이 발생하는 높은 경합 환경에서는 낙관적 락이 오히려 성능 저하를 일으킬 수 있습니다:
- 많은 트랜잭션이 실패하고 재시도되면서 시스템 리소스 낭비
- 반복적인 충돌과 재시도로 인한 레이턴시 증가
- 극단적인 경우 "라이브락(Livelock)" 상황 발생 가능 (모든 클라이언트가 계속 충돌하며 진행 불가)
2. 작업 실행 순서가 중요한 경우
- 낙관적 락은 충돌 시점에서만 문제를 감지하지만, 작업 순서를 보장하지 않음
- 특정 작업들이 반드시 순차적으로 실행되어야 하는 경우 배타적 락이 필요
3. 복잡한 트랜잭션이나 장시간 실행 작업
- 여러 단계의 복잡한 작업이나 시간이 오래 걸리는 작업의 경우, 낙관적 락으로 구현 시 충돌 가능성이 높아짐
- 작업 중간에 충돌이 발생하면 처음부터 다시 시작해야 하므로 비효율적
4. 원자성이 보장되어야 하는 연관된 여러 작업
- 여러 키/리소스에 대해 모두 원자적으로 변경되어야 하는 경우
- 낙관적 락은 개별 키 단위로 적용되므로 여러 키에 걸친 작업의 원자성 보장이 어려움
5. 시스템 안정성과 예측 가능성
- 높은 부하 상황에서 재시도 로직의 복잡성과 불확실성 증가
- 분산 락은 더 예측 가능한 동작과 안정적인 시스템 운영 제공
실제 예시
금융 거래 시나리오: 두 계좌 간 송금 작업 시:
낙관적 락 접근법:
boolean success = false;
int retries = 0;
while (!success && retries < MAX_RETRIES) {
// 출금 계좌 잔액 읽기
Account fromAccount = accountRepository.findById(fromId);
String fromVersion = fromAccount.getVersion();
// 입금 계좌 잔액 읽기
Account toAccount = accountRepository.findById(toId);
String toVersion = toAccount.getVersion();
// 잔액 확인
if (fromAccount.getBalance() < amount) {
throw new InsufficientFundsException();
}
// 새로운 잔액 계산
long newFromBalance = fromAccount.getBalance() - amount;
long newToBalance = toAccount.getBalance() + amount;
// 두 계좌 업데이트 시도 (둘 다 성공해야 함)
boolean fromUpdated = optimisticUpdate(fromId, newFromBalance, fromVersion);
boolean toUpdated = optimisticUpdate(toId, newToBalance, toVersion);
success = fromUpdated && toUpdated;
if (!success) {
// 둘 중 하나라도 실패하면 롤백(취소) 처리 필요
if (fromUpdated) {
// from 계좌 업데이트 롤백 필요
optimisticUpdate(fromId, fromAccount.getBalance(), fromAccount.getNewVersion());
}
retries++;
Thread.sleep(BACKOFF_TIME * retries);
}
}
if (!success) {
throw new TransactionFailedException("Too many conflicts");
}
분산 락 접근법:
distributedLockService.executeWithMultiLock(
Arrays.asList("account:" + fromId, "account:" + toId),
() -> {
// 두 계좌의 락을 모두 획득한 상태
Account fromAccount = accountRepository.findById(fromId);
Account toAccount = accountRepository.findById(toId);
if (fromAccount.getBalance() < amount) {
throw new InsufficientFundsException();
}
// 두 계좌 업데이트 (원자적으로 수행됨)
accountRepository.updateBalance(fromId, fromAccount.getBalance() - amount);
accountRepository.updateBalance(toId, toAccount.getBalance() + amount);
// 추가 작업: 거래 기록 저장 등
transactionRepository.save(new Transaction(fromId, toId, amount));
}
);
결론
분산 락이 더 적합한 경우:
- 높은 경합 환경에서의 작업
- 여러 리소스에 걸친 원자적 업데이트가 필요한 경우
- 작업 순서가 중요한 경우
- 장시간 실행되는 복잡한 작업
- 높은 일관성과 예측 가능성이 필요한 경우
낙관적 락이 더 적합한 경우:
- 읽기 작업이 많고 쓰기 충돌이 적은 환경
- 단일 리소스 업데이트
- 짧은 시간에 완료되는 단순한 작업
- 가용성과 성능이 일관성보다 중요한 경우
적절한 락 메커니즘은 애플리케이션의 특성, 요구사항, 그리고 트래픽 패턴에 따라 선택해야 합니다. 때로는 두 방식을 함께 사용하는 하이브리드 접근법이 최적일 수도 있습니다.
'개발 일지' 카테고리의 다른 글
자바 네트워크 프로그래밍: ServerSocket vs ServerSocketChannel (0) | 2025.03.10 |
---|---|
Fork Join Framework: CPU bounded vs I/O bounded 작업 효율성 비교 (0) | 2025.03.10 |
CAS(Compare-And-Swap): 논블로킹 동시성 제어의 핵심 (0) | 2025.03.02 |
메모리 가시성 문제로 인한 Java의 동시성 버그 (0) | 2025.03.02 |
메시지 유실을 고려한 채팅 시스템 클러스터 개발기 (0) | 2025.02.25 |