[SpringBoot] 결제 로직(6) - 분산락

포테이토웅·2024년 7월 22일
0

springboot-결제로직

목록 보기
6/6

저는 결제 시 상품 재고량의 동시성을 제어하기 위해 비관적 락(베타락)을 사용했습니다.
이번 글에서는 이 시스템을 분산락으로 전환한 과정에 대해 다루겠습니다.

분산락이란?

분산락(Distributed Lock)이란 분산 시스템에서 리소스나 작업에 대한 접근을 제어하기 위해 사용되는 락 메커니즘입니다.
DB락(비관적 락, 낙관적 락 등)과는 달리, 분산락은 DB를 사용하지 않고 중앙 집중형 락 서비스(Zookeeper, Redis 등)를 통해 락을 관리합니다.

분산락을 구현하는 방법

  • Zookeeper
    • 분산 서버 관리시스템으로 분산 서비스 내 설정 등을 공유해주는 시스템입니다.
    • 추가적인 인프라 구성이 필요하고 성능 튜닝을 위한 러닝커브가 존재합니다.
  • MySQL
    • 추가적인 인프라 구성 없이 문자열로 거는 User Level Lock으로 분산락을 직접 구현할 수 있습니다.
    • 락을 자동으로 반납할 수 없어 명시적으로 락을 release 시켜야합니다.
    • DB에서 락을 관리합니다.
    • 락 획득 시도는 스핀락으로 구현해야하기 때문에 WAS에도 부담이 존재합니다.
  • Redis
    • 인메모리 DB로 속도가 빠릅니다.
    • 싱글스레드 방식으로 동시성 문제가 현저히 적습니다.
    • 캐시 저장소로 활용이 가능합니다.
    • 현재 저의 프로젝트에서는 이미 캐싱을 위해 Redis를 사용중이어서 Redis를 이용해 분산락을 적용하기로 했습니다.

분산락으로 전환한 이유

저는 결제 로직에서 상품 재고량 수정에 대한 기능을 비관적 락을 이용해 동시성을 제어했습니다. 테스트 코드와 실제 운영 테스트 결과, 비관적 락을 통해 충분히 동시성을 제어할 수 있었습니다.

그러나 운영적인 관점에서 보면, 상품이라는 도메인은 결제 외에도 상품 등록, 수정, 삭제, 구매 이력 등 다양한 기능에 연관되어 있습니다. 이러한 기능들이 복잡해짐에 따라 데드락이 발생할 가능성이 높아질 수 있습니다. 데드락이 발생할 경우, 문제를 해결하기 위해 데이터베이스에 직접 접근해 로그를 확인해야 하는 상황이 발생할 수 있습니다.

또한, 현재 캐싱을 위해 Redis를 이미 사용하고 있는 점도 고려했습니다. Redis는 분산락을 구현하는데 유용한 중앙 집중형 락 서비스로, 기존의 Redis 인프라를 활용하여 동시성 문제를 보다 효과적으로 관리할 수 있습니다. 이를 통해 데이터베이스에 대한 의존도를 줄이고, 더 안정적이고 확장 가능한 동시성 제어를 구현할 수 있습니다.


Lettuce VS Redisson

Redis 분산락을 적용하기 위한 라이브러리에는 크게 Lettuce와 Redisson이 있습니다.

Lettuce

  • 방식 : 스핀락(Spin Lock)을 사용합니다. 스핀락은 락을 획득하려는 스레드가 락이 해제될 때까지 반복적으로 시도하는 방식입니다.
  • 장점
    • 간단한 구현 : 스핀락은 구현이 간단하고, Redis의 기본 명령어만으로도 락을 관리할 수 있습니다.
    • 성능 : 락 획득과 해제가 신속하게 이루어지며, 낮은 레이턴시를 제공합니다.
  • 단점
    • CPU 사용량 : 스핀락은 락을 반복적으로 시도하기 때문에 CPU 자원을 많이 사용할 수 있습니다. 대기 시간이 길어질수록 자원 소모가 커질 수 있습니다.
    • 락 충돌 : 락 충돌이 발생할 경우, 여러 프로세스가 동시에 락을 시도하게 되어 성능 저하가 있을 수 있습니다.

Redisson

  • 방식 : Pub/Sub 방식을 사용합니다. Pub/Sub 방식은 메시지를 발행하고 구독하여 리소스의 상태를 관리하는 방식입니다. 특정 채널을 통해 락 상태를 전파하고, 다른 프로세스가 이 채널을 구독하여 락 상태를 확인합니다.
  • 장점
    • 스케일링 : Pub/Sub 방식은 다수의 노드와 클러스터 환경에서 효율적으로 락을 관리할 수 있습니다. Pub/Sub 메시징 패턴을 통해 락 상태를 전파하고 동기화할 수 있습니다.
    • 높은 신뢰성 : 락 상태 변경이 채널을 통해 전파되므로, 락 상태의 일관성과 신뢰성이 높습니다.
  • 단점
    • 복잡성 : Redis의 Pub/Sub 기능을 사용하기 때문에 추가적인 설정과 관리가 필요합니다.
    • 네트워크 지연 : 메시징을 통한 락 관리로 인해 네트워크 지연이 발생할 수 있으며, 대규모 분산 환경에서는 성능에 영향을 줄 수 있습니다.

저는 상품 재고량과 같은 높은 신뢰성을 요구하는 작업을 다루기 위해 Redisson을 선택했습니다. 상품 재고량은 정확성과 일관성이 매우 중요한 데이터이기 때문에, 분산락의 신뢰성을 보다 더 보장할 수 있는 클라이언트가 필요했습니다.


분산락 구현 방법

Kurly 분산락 글을 참고하였습니다.

build.gradle

implementation 'org.redisson:redisson-spring-boot-starter:3.33.0'

Redisson 라이브러리를 사용하기 위한 의존성을 추가합니다.

RedisConfig

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    /**
     * Redisson 설정
     */
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + host + ":" + port);

        return Redisson.create(config);
    }
}

RedissonClient를 사용하기 위해 설정입니다.

DistributedLock

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {

    /**
     * 락의 이름
     */
    String key();

    /**
     * 락의 시간 단위
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 락을 기다리는 시간
     */
    long waitTime() default 5L;

    /**
     * 락 임대 시간
     */
    long leaseTime() default 3L;
}

락의 이름인 key는 필수, 나머지 값들은 기본값을 이용하거나 커스텀하게 설정할 수 있도록 합니다.

CustomSpringELParser

/**
 * Spring Expression Language Parser Util
 */
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class CustomSpringELParser {

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }
}

전달받은 Lock의 이름을 Spring Expression Language로 파싱하여 읽어올 수 있도록 하는 유틸입니다.

AopForTransaction

/**
 * AOP에서 트랜잭션 분리를 위한 클래스
 */
@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

분산락을 사용하는 메소드는 Propagation.REQUIRED_NEW를 통해 별도의 트랜잭션으로 동작하도록 설정합니다. 반드시 트랜잭션이 커밋된 후 락이 해제되어야 합니다. 락의 해제가 먼저 이루어진다면 다른 사용자는 커밋되기 이전의 데이터를 가지고 수정을 하기 때문에 데이터의 정합성이 깨질 수 있습니다.

DistributedLockAop

/**
 * @DistributionLock Annotation을 사용한 메소드에 대한 AOP
 */
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockAop {

    private final RedissonClient redissonClient;

    private final AopForTransaction aopForTransaction;

    @Around("@annotation(potatowoong.potatomallback.global.config.redis.DistributeLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);

        final String key = CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributeLock.key()).toString();
        RLock rLock = redissonClient.getLock(key);

        try {
            boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit());
            if (!available) { // 락을 획득하지 못한 경우
                return false;
            }
            return aopForTransaction.proceed(joinPoint); // 락을 획득한 경우
        } catch (InterruptedException e) {
            // 락을 획득하는 도중에 Interrupted Exception이 발생한 경우
            log.error("Interrupted Exception", e);
            throw new InterruptedException();
        } finally {
            // 락을 해제
            rLock.unlock();
        }
    }
}

@DistributedLock 어노테이션 선언 시 수행되는 AOP 클래스입니다.

  1. @DistributedLock 어노테이션의 Key값(락의 이름)을 파싱해 RLock 인스턴스를 가져옵니다.
  2. waitTime까지 락의 획득을 시도하며, leaseTime이 지나면 잠금을 해제합니다.
  3. @DistributedLock 어노테이션이 선언된 메소드를 별도의 트랜잭션으로 실행합니다.
  4. 종료 시 무조건 락을 해제합니다.

UserProductService

    /**
     * 상품 재고량 감소 + 분산락
     */
    @DistributeLock(key = "T(java.lang.String).format('Product%d', #productId)")
    public void decreaseProductQuantityWithLock(final long productId, final int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));

        // 재고량 검증
        if (quantity > product.getStockQuantity()) {
            throw new CustomException(ErrorCode.PRODUCT_STOCK_NOT_ENOUGH);
        }

        product.decreaseStockQuantity(quantity);
        productRepository.save(product);
    }

상품 ID를 락의 이름으로 설정해 분산락을 적용합니다.


참고 자료

https://velog.io/@a01021039107/%EB%B6%84%EC%82%B0%EB%9D%BD%EC%9C%BC%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EC%9D%B4%EB%A1%A0%ED%8E%B8

profile
주경야독

0개의 댓글