프로젝트에 분산 락 적용 (feat. AOP)

유호빈·2024년 4월 30일
1

이전 포스트에서는 모놀리식 방식의 프로젝트에 비관적 락을 도입했습니다.

이번에는 MSA를 공부하며 프로젝트에 MSA 구조를 도입했고, 따라서 멀티 인스턴스 환경이 되었을 때 공통적으로 락을 사용할 수 있는 분산 락을 적용하게 되었습니다.

현재 프로젝트에는 토큰 관리를 Redis로 하기 때문에 Redis를 이용해 구현하기로 했고, Redis의 분산 락 구현방식 중 Redisson을 선택했습니다.

Redisson 선택 이유

Redis를 이용한 분산 락 구현 라이브러리는 두 가지가 존재합니다.

1. Lettuce

Redis의 setnx 명령어를 통해 키, 밸류가 존재하지 않을 때 락을 획득하는 방식입니다.
spin lock 방식이기 때문에 락을 획득하려는 thread가 락이 사용 가능한지 반복적으로 redis에 확인해야 하기 때문에 Redis에 부하를 줄 수 있습니다.
또한, retry 로직을 직접 구현해 주어야 합니다.

2. Redisson

pub/sub 방식을 이용해 락 해제 시 채널에 메시지를 보내 락을 획득해야할 thread에게 알립니다.
이때, 메시지를 받을 thread에서 락 획득을 시도해 Redis의 부하를 줄일 수 있습니다.

각 라이브러리는 위와 같은 특징을 갖고 있기 때문에 Redis를 토큰 관리에도 사용하는 이번 프로젝트에선 Redis의 부하를 줄일 수 있는 Redisson 방식을 선택했습니다.

1. 분산 락 적용

	@Transactional
    public void decreaseProductsStock(OrderProductListRequest request) {
        request.getOrderProducts()
                .forEach(op -> {
                    String lockKey = "lock:" + op.getProductId();
                    RLock lock = redissonClient.getLock(lockKey);
                    try {
                        boolean available = lock.tryLock(10L, 5L, TimeUnit.SECONDS);
                        if (!available) {
                            log.error("lock 획득 실패");
                            throw new BusinessException(LOCK_NOT_AVAILABLE);
                        }

                        Product product = productRepository.findById(op.getProductId())
                                .orElseThrow(() -> new BusinessException(NOT_FOUND_PRODUCT));

                        log.info("current_stock={}", product.getStock());
                        product.decreaseStock(op.getUnitCount());
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    } finally {
                        lock.unlock();
                    }
                });
    }
    

주문 시 여러 product가 포함되어 있기 때문에 처음 분산 락 구현 시에는 각 product마다 락을 잡아줘야 겠다고 생각했습니다.

  • 문제 발생

위와 같이 구현하고 테스트를 돌려봤을 때 재고가 감소가 제대로 되지 않았습니다.

이러한 이유는 락 해제 시 트랜잭션은 커밋되지 않은 상태이기 때문에 다른 thread에서 락을 획득해 커밋되지 않은 상태의 데이터를 조회하기 때문입니다.

2. 트랜잭션 커밋 후 락 해제

애노테이션 기반의 AOP를 사용해 분산 락 컴포넌트를 만드는 과정은 컬리 블로그에 잘 설명되어 있어 이를 기반으로 작성했습니다.
https://helloworld.kurly.com/blog/distributed-redisson-lock/#3-%EB%B6%84%EC%82%B0%EB%9D%BD%EC%9D%84-%EB%B3%B4%EB%8B%A4-%EC%86%90%EC%89%BD%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%A0-%EC%88%98%EB%8A%94-%EC%97%86%EC%9D%84%EA%B9%8C

	@DistributedLock(key = "order")
    public void decreaseProductsStock(OrderProductListRequest request) {
        request.getOrderProducts()
                .forEach(op -> {
                    Product product = productRepository.findById(op.getProductId())
                            .orElseThrow(() -> new BusinessException(NOT_FOUND_PRODUCT));

                    log.info("current_stock={}", product.getStock());
                    product.decreaseStock(op.getUnitCount());

                });
    }
    

컬리 블로그를 기반으로 락을 획득한 다음 트랜잭션을 시작하고 트랜잭션이 종료되면 락을 반환하는 방식으로 코드를 변경했습니다.

  • 문제 발생

이처럼 구현하게 되면 주문 생성 시 서로 연관이 없는 product끼리도 주문이라는 락을 획득할 때까지 기다려야 합니다.

3. 분산 락 서비스 분리 (트랜잭션 내부 호출 문제)

AOP 관련 내용은 인프런의 강의를 기반으로 작성되었습니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard

ProductService

	public void decreaseProductsStock(OrderProductListRequest request) {
        request.getOrderProducts().forEach(productStockService::decreaseStock);
    }

ProductStockService

	@DistributedLock(key = "#request.getProductId()")
    public void decreaseStock(AddOrderProductRequest request) {
        Product product = productRepository.findById(request.getProductId())
                .orElseThrow(() -> new BusinessException(ExceptionCode.NOT_FOUND_PRODUCT));

        product.decreaseStock(request.getUnitCount());
    }

ProductService 에서는 각 상품의 재고를 감소시키는 다른 Service 메소드를 호출합니다.

ProductStockService에서 각 상품에 대해 락을 획득하고 재고를 감소시킨 뒤 락을 해제합니다.

이와 같이 Service를 따로 만들어서 호출한 이유는 프록시의 내부 호출 문제와 관련이 있습니다.

프록시 내부 호출 문제

AOP를 적용하려면 항상 프록시를 통해서 대상 객체(타겟)을 호출해야 합니다.
만약 대상 객체 내부에서 메소드 호출이 발생하면 프록시를 거치지 않고 바로 대상 객체를 직접 호출하기 때문에 AOP가 적용되지 않는 문제가 발생합니다.

그림과 같이 내부 메소드 호출 시 target 내부 메소드를 바로 호출하기 때문에 프록시를 거치지 않아 어드바이스가 적용되지 않습니다.

이러한 해결방법으로 몇 가지 방법이 있는데 그 중 내부 호출하는 부분을 별도의 클래스로 분리해 사용했습니다.

그림과 같이 기존 내부 호출 메소드를 외부의 별도 클래스로 분리하게 되면 프록시를 거쳐 어드바이스 적용됩니다.

따라서 위와 같은 방식으로 분산 락을 프로젝트에 적용했습니다.

	@DisplayName("재고 감소 분산락 테스트")
    @Test
    public void decreaseProductsStock() throws InterruptedException {
        // given
        int numThreads = 10;
        CountDownLatch doneSignal = new CountDownLatch(numThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numThreads);

        Product product = new Product(1L, "신발", "신발입니다.", 10000L, 100L, "hobin");
        productRepository.save(product);

        List<AddOrderProductRequest> list = new ArrayList<>();
        list.add(new AddOrderProductRequest(product.getId(), 2L));

        OrderProductListRequest orderProductListRequest = new OrderProductListRequest(list);

        // when
        for (int i = 0; i < numThreads; i++) {
            executorService.submit(() -> {
                try {
                    productService.decreaseProductsStock(orderProductListRequest);
                } finally {
                    doneSignal.countDown();
                }
            });
        }

        doneSignal.await();
        executorService.shutdown();

        Product savedProduct =
                productRepository.findById(1L)
                        .orElseThrow(() -> new BusinessException(ExceptionCode.NOT_FOUND_PRODUCT));
        Assertions.assertThat(savedProduct.getStock()).isEqualTo(80);
    }

테스트 역시 잘 통과한 모습을 볼 수 있습니다.

profile
시작하자

0개의 댓글