Notice
Recent Posts
Recent Comments
Link
«   2025/03   »
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
Archives
Today
Total
관리 메뉴

csct3434

Redis의 낙관적 락(Optimistic Lock)과 분산 락(Distributed Lock) 비교 본문

개발 일지

Redis의 낙관적 락(Optimistic Lock)과 분산 락(Distributed Lock) 비교

csct3434 2025. 3. 10. 08:37

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));
    }
);

결론

분산 락이 더 적합한 경우:

  1. 높은 경합 환경에서의 작업
  2. 여러 리소스에 걸친 원자적 업데이트가 필요한 경우
  3. 작업 순서가 중요한 경우
  4. 장시간 실행되는 복잡한 작업
  5. 높은 일관성과 예측 가능성이 필요한 경우

낙관적 락이 더 적합한 경우:

  1. 읽기 작업이 많고 쓰기 충돌이 적은 환경
  2. 단일 리소스 업데이트
  3. 짧은 시간에 완료되는 단순한 작업
  4. 가용성과 성능이 일관성보다 중요한 경우

적절한 락 메커니즘은 애플리케이션의 특성, 요구사항, 그리고 트래픽 패턴에 따라 선택해야 합니다. 때로는 두 방식을 함께 사용하는 하이브리드 접근법이 최적일 수도 있습니다.