티스토리 뷰

분산락과 낙관적 락을 사용한 데이터 정합성 지키기

보상 트랜잭션을 사용한 데이터 정합성 유지하기 블로그 글에서 주문 비즈니스 로직에서 분산 트랜잭션을 처리 시스템을 구축했다.

특히 상품 서비스에서 재고를 관리하고 있으며, 다수의 사용자가 동시에 한정 수량의 상품을 구매할 경우 UPDATE inventory SET quantity = quantity - 1와 같은 단순 쿼리만으로는 경쟁 조건(Race Condition)이 발생해 데이터 정합성이 쉽게 깨질 수 있다. 따라서 이커머스에서 재고의 정확성은 곧 서비스 신뢰도와 직결되므로, 정합성을 보장하기 위한 아키텍처 설계와 동시에 제어 전략이 필수적이다.

이를 해결하기 위해 상품 서비스에서 일반적으로 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)을 활용해 정합성을 확보할 수 있다. 재고에 대한 경쟁 조건을 방지하기 위한 작업 시나리오에 대해 알아보자.

 

분산락(Distributed Lock)

분산락은 물리적으로 분산된 여러 서버 인스턴스에서 동시에 동일한 자원에 접근하려는 경쟁 상황이 발생할 때, 이를 서로 배타적으로 제어할 수 있도록 보장해주는 동기화 수단이다. 즉, 비관적 락의 개념을 분산 환경에서 확장된 구현 방식이라고 볼 수 있다.

 

분산락을 위한 Java 레디스 클라이언트

Redis에서 분산락을 구현하는 방법은 주로 SETNX(SET if Not eXists) 명령어와 EXPIRE 명령어를 조합하는 방식으로 Java 환경에서 Redis와 통신할 수 있는 대표적인 클라이언트는 Lettuce와 Redisson이 있다. Lettuce는 스핀락(Spin-Lock) 방식으로 락을 구현하기 때문에 락을 얻기 위해 계속해서 반복적으로 시도하기 때문에 락을 얻기 전까지 CPU를 지속적으로 사용하게 된다.

 

반면, Redisson은 레드락(RedLock) 알고리즘을 공식적으로 지원하고, Redis의 Pub/Sub 메커니즘을 사용해 락을 관리하며 더 효율적이고 자원 소모가 적은 방식으로 락을 제어한다. 위 사진에서 채널을 통해 메시지를 보내는 것처럼 하나의 채널을 만들고 하나의 스레드가 락을 점유하면 해제했을 때 채널에 메시지를 보내줌으로써 구독하고 있는(락을 획득해야 하는) 스레드들이 메시지를 받아 락 획득을 시도하는 방식이라고 할 수 있다.

 

Redisson 구현

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {

    private final RedissonReactiveClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.idolu.product.global.annotation.DistributedLock)")
    public Mono<Object> lock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
        String baseKey = distributedLock.lockName();
        String dynamicKey = RedissonKeyGenerator.generateDynamicKey(
                distributedLock.identifier(),
                joinPoint.getArgs(),
                distributedLock.paramClassType(),
                signature.getParameterNames());
        long threadId = ThreadLocalRandom.current().nextLong();

        return redissonClient.getLock(baseKey + ":" + dynamicKey)
                .tryLock(
                        distributedLock.waitTime(),
                        distributedLock.leaseTime(),
                        distributedLock.timeUnit(),
                        threadId)
                .handle((lockAcquired, sink) -> {
                    log.info("락 획득: {}, threadId: {}", lockAcquired, threadId);
                    if (lockAcquired) sink.next(true);
                    else sink.error(new IllegalArgumentException("락을 획득하지 못했습니다."));
                })
                .flatMap(lockAcquired -> {
                    try {
                        return aopForTransaction.proceed(joinPoint);
                    } catch (Throwable e) {
                        return Mono.error(e);
                    }
                })
                .flatMap(result -> {
                    log.info("락 해제 시도 threadId: {}", threadId);
                    return redissonClient.getLock(baseKey + ":" + dynamicKey)
                            .unlock(threadId)
                            .thenReturn(result);
                })
                .onErrorResume(ex -> {
                    log.info("락 해제 시도 threadId: {}", threadId);
                    return redissonClient.getLock(baseKey + ":" + dynamicKey)
                            .unlock(threadId)
                            .then(Mono.error(ex)); // 예외 다시 전달
                });
    }
}

@Slf4j
@Component
public class AopForTransaction {

    @Transactional
    public Mono<Object> proceed(ProceedingJoinPoint joinPoint) throws Throwable {
        return (Mono<Object>) joinPoint.proceed();
    }
}

위 코드는 @DistributedLock 어노테이션이 붙은 메서드 실행 시 Redisson 기반의 분산락을 획득한 후 락 안에서 트랜잭션을 시작하여 비즈니스 로직을 실행하고, 정상 또는 예외 상황에서도 반드시 락을 해제하도록 보장하는 AOP이다. 전체 흐름은 다음과 같다.

  1. @DistributedLock 어노테이션을 감지하여 AOP가 동작한다.
  2. lockName과 identifier 값을 기반으로 동적 락 키를 생성한다.
  3. RedissonReactiveClient를 이용하여 비동기로 락 획득을 시도한다.
  4. 락을 획득하면 aopForTransaction.proceed()를 실행하여 트랜잭션을 시작한다.
  5. 본래의 메서드를 실행하여 비즈니스 로직을 수행한다.
  6. 트랜잭션을 커밋 또는 롤백한다.
  7. 락을 해제한다.

 

분산 락 사용 시 상위 트랜잭션이 있으면 안되는 이유 블로그 글에서 설정한 적이 있듯이 트랜잭션이 먼저 시작한 상태에서 락을 획득할 경우 MySQL의 REPEATABLE READ 격리 수준에 따라 각 트랜잭션의 변경사항을 인지하지 못한 채 데이터를 읽고 수정하게 되어 Lost Update와 같은 정합성 문제가 발생할 수 있기 때문이다.

따라서 안정적인 동시성 제어를 위해서는 반드시 분산 락을 먼저 획득한 후 트랜잭션을 시작해야 하며, 로그에서도 이러한 순서 보장하고 있는 것을 확인할 수 있다.

 

낙관적 락 적용

분산 락은 강력한 동시성 제어 수단이지만, 네트워크 지연이나 클라이언트의 비정상 동작 등 예기치 못한 상황에서는 완벽하게 동작하지 않을 수 있다. 대부분의 경우에는 문제없이 운영되지만, 이러한 극히 드문 예외 상황에서도 데이터 정합성을 보장하기 위해 낙관적 락을 보조적으로 함께 사용하여 재고 처리를 안정적으로 진행할 수 있다.

 

@Slf4j
@Configuration
public class RetryConfig {

    // 재시도 전략 설정
    @Bean
    public Retry optimisticLockingBackoffRetry() {
        return Retry.backoff(3, Duration.ofSeconds(2)).jitter(0.1)
                .filter(ex -> ex instanceof OptimisticLockingFailureException)
                .doBeforeRetry(retrySignal -> log.error("retryCount: {}, errorCode: {}", retrySignal.totalRetries(), retrySignal.failure().getMessage()))
                .onRetryExhaustedThrow((spec, retrySignal) -> retrySignal.failure());
    }
}

// 재시도 전략 적용
public Mono<Boolean> updateProductStock(ProductStockUpdateCommand command) {
    return productAdapter.updateProductStock(command)
            .retryWhen(optimisticLockingBackoffRetry);
}

// 분산락 및 낙관적 락 적용
@DistributedLock(lockName = "productStock", identifier = "productId", paramClassType = ProductStockUpdateCommand.class)
public Mono<Boolean> updateProductStock(ProductStockUpdateCommand command) {
    return inventoryUpdateLogRepository.findInventoryUpdateLogByOrderNoAndType(command.getOrderNo(), command.getStockType()) // 이미 처리된 주문 재고 처리건인지 확인
            .handle((__, sink) -> sink.error(new ProductBadRequestException(ALREADY_STOCK_UPDATE))) // 이미 처리된 주문 재고 변경건
            .switchIfEmpty(Mono.just(true))
            .flatMap(__ -> inventoryUpdateLogRepository.save(InventoryUpdateLog.builder()
                    .productId(command.getProductId())
                    .orderNo(command.getOrderNo())
                    .quantity(command.getStock())
                    .type(command.getStockType())
                    .build()))
            .flatMap(__ -> productRepository.findById(command.getProductId()))
            .flatMap(product -> productRepository.save(product.updateStock(command.getStock(), command.getStockType()))) // 재고 처리
            .thenReturn(true);
}

위 코드는 재고 변경 요청을 처리할 때 데이터 정합성을 보장하기 위해 Redisson 기반의 분산 락과 낙관적 락을 함께 사용했다. 락을 획득한 이후에는 해당 주문 번호와 재고 타입에 대한 중복 처리를 방지하기 위해 InventoryUpdateLog 테이블을 먼저 조회하고, 기존에 동일한 요청이 처리된 적이 있다면 작업을 중단한다. 중복된 작업이 아니라면, 처리 로그를 저장하고 해당 상품을 조회한 뒤 재고를 변경한다. 이때, 재고 변경 메서드인 updateStock()은 내부적으로 @Version 필드를 활용해 낙관적 락을 적용하고 있으며, 이로 인해 동시성 충돌이 발생할 경우 OptimisticLockingFailureException 예외가 발생한다. 이를 보완하기 위해 RetryConfig 클래스에서 OptimisticLockingFailureException 예외에 대한 재시도 전략을 정의하고, 예외가 발생할 때마다 재시도 횟수와 실패 원인을 로그로 기록할 수 있게 했다.

이를 통해 Redisson의 분산 락으로 서비스 간 동시성 문제를 제어하고, 낙관적 락과 재시도 로직으로 데이터베이스 수준의 충돌을 제어하여 재고 정합성을 지킬 수 있도록 로직을 구성했다.

 

 

출처

무진장 힘들었지만 무진장 성장한 개발 이야기

[Redis] 레디스가 제공하는 분산락(RedLock)의 특징과 한계

Redis 분산 락(Lettuce, Redisson)

토스 | SLASH 22 - 애플 한 주가 고객에게 전달 되기까지

 

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/01   »
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
글 보관함