[E-commerce] 동시성 문제 해결하기 (비관적 락, 네임드 락, 분산 락)

 

이커머스 서비스에서 발생할 수 있는 동시성 문제들을 정리하고, 다양한 방법으로 동시성 문제를 해결해 보며 겪은 경험들을 공유해 보자 합니다.

 

동시성 문제가 발생할 수 있는 유즈케이스

Case 1 (조회한 상품 재고 수량이 맞지 않는 경우)

1. 사용자 A가 재고가 5개인 상품을 조회하고 재고 1개 차감 요청, 트랜잭션 시작
2. 사용자 B가 동시에 동일 상품의 상품을 조회, A의 트랜잭션이 아직 커밋되지 않았기 때문에 재고가 5개인 것으로 확인
3. 사용자 A의 트랜잭션이 커밋되어 실제 재고는 4개로 업데이트
4. 실제 상품의 재고는 4개이지만 사용자 B가 조회한 상품의 재고는 5개로 재고 정합성 불일치 문제 발생

 

Case 2 (동시에 상품을 주문할 때, 상품의 재고가 부족한 경우)

1. 사용자 A가 재고 3개 차감을 요청, 트랜잭션 시작
2. 사용자 B는 동시에 동일 상품의 재고를 확인하며, A의 트랜잭션이 아직 커밋되지 않았기 때문에 재고가 3개인 것으로 확인
3. 사용자 A의 트랜잭션이 커밋되어 실제 재고는 0으로 업데이트
4. 사용자 B도 재고 1개 차감을 요청하지만 실제 재고는 0이므로 재고 부족 에러가 발생

 

Case 3 (재고보다 많은 주문 접수)

1. 사용자 A와 B가 동시에 재고가 1개 남은 상품에 대해 주문을 요청
2. 사용자 A와 B가 요청한 상품의 재고가 1개로 남았기 때문에 정상적으로 주문 접수 완료 (주문 생성)
3. 사용자 A의 트랜잭션에서 먼저 상품의 재고를 차감
4. 사용자 B의 트랜잭션에서 재고 차감을 하려 하지만 재고가 0이기 때문에 재고 차감 실패 및 주문 취소

 

Case 4 (동시에 여러 사용자가 동일 상품 주문 시 재고 차감 문제)

1. 사용자 A와 B가 재고가 5개인 상품을 동시에 1개씩 주문
2. 사용자 A의 트랜잭션이 시작되어 재고를 4개로 차감
3. 사용자 B의 트랜잭션이 사용자 A의 트랜잭션이 커밋되기 전 상품 재고를 조회(이 시점 재고는 5개)하였기 때문에, 재고를 4개로 차감
4. 사용자 A, B의 트랜잭션을 커밋
5. 상품의 재고는 3개가 되어야 하지만 실제 DB에는 4개로 저장

 

Case 5 (사용자가 동시에 결제를 시도하여 사용자의 포인트 잔액이 맞지 않는 경우)

1. 사용자 A의 포인트 잔액이 100일 때, 100포인트를 결제 요청
2. 동시에 다른 결제에서 50포인트를 결제 요청, 첫 번째 결제 요청의 트랜잭션이 아직 커밋되지 않았기 때문에 포인트가 100으로 확인
3. 첫번째 결제 요청이 커밋되어 100 포인트 차감 (실제 포인트 0)
4. 두번째 결제 요청에서 실제 사용자의 포인트가 0이기 때문에 에러 발생

 

현재 이커머스 서비스에서 동시성 문제가 발생할 수 있는 유즈케이스들을 정리해 보았습니다.

실제로는 더 다양한 동시성 유즈케이스가 존재하겠지만 가장 중요한 상품의 재고 정합성과 사용자의 포인트에 대해 동시성 문제를 해결해 보겠습니다.

 

다양한 동시성 문제 해결 방법

1. 낙관적 락 (Optimistic Lock)

낙관적 락은 동시 요청을 직접적으로 막지 않고, 현재 트랜잭션의 데이터를 커밋하기 전 다른 트랜잭션에서 변경을 했는지 확인(version or timestamp)을 통해 동시에 데이터 수정을 막는 방법입니다.

만일 데이터의 버전이나 타임스탬프(주로 version 필드를 사용)가 다를 경우 충돌했음을 감지하고, 현재 트랜잭션을 롤백하거나 재시도하여 동시성 문제를 처리합니다. 

 

장점

  • 동시 요청에 대해 DB에 락을 걸지 않기 때문에, 비관적 락보다 성능 향상에 이점이 있다.
  • 낙관적 락충돌이 자주 발생하지 않는다고 가정하기 때문에, 많은 사용자가 동시에 데이터에 접근할 수 있도록 한다. 즉, 처리량을 향상할 수 있다.

단점

  • 동시에 요청하여 데이터 충돌이 발생했을 때 이를 해결하기 위한 추가적인 로직이 필요하여 구현 복잡성이 있다.
  • 데이터의 변경 빈도가 높은 시스템에서는 충돌이 자주 발생하기 때문에, 이를 해결하기 위한 추가적인 시간이 필요하다.

 

즉, 낙관적 락은 충돌이 빈번하지 않고 성능이 우선시 되는 경우와 동시 요청에서 하나의 요청만 처리해야 하는 경우 적합합니다.

 

2. 비관적 락 (Pessimistic Lock)

비관적 락은 어느 트랜잭션에서 특정 테이블에 접근 시, 락을 걸어(db 전체, 테이블, 열) 해제될 때까지 다른 트랜잭션에서 해당 테이블에 접근하지 못하도록 잠금 하는 것입니다.

 

트랜잭션 동안 락이 걸리므로 다른 트랜잭션이 락이 걸린 데이터에 대한 접근을 요청하는 경우 대기 시간이 발생할 수 있습니다.

-> 이는 곧 애플리케이션의 성능 저하 문제로 이어집니다.

 

장점

  • 낙관적 락과 다르게 테이블에 락을 걸어 다른 트랜잭션에서의 접근을 하지 못하게 하기 때문에 데이터의 일관성이 보장된다.
  • 트랜잭션에서 데이터를 사용하기 전 락을 걸기 때문에, 데이터를 변경 중에 다른 트랜잭션과 충돌 가능성이 낮다.

단점

  • 데이터 일관성을 보장하지만 동시 접속자가 많은 환경에서는 락 대기 시간으로 인해 성능에 영향을 줄 수 있다. 
  • 다수의 트랜잭션이 서로 다른 순서로 여러 데이터에 락을 요청하면 데드락이 발생할 수 있다.

 

비관적 락은 트랜잭션이 커밋될 때까지 락을 유지하고 있기 때문에, 자원 낭비가 발생할 수 있습니다. (트랜잭션 관리가 중요!)

왜냐하면 락이 필요하지 않은 시간(상품 재고 테이블에 락을 걸고, 결제를 처리하는 시간)에도 락을 걸고 있기 때문에 불필요하게 커넥션을 사용하고 있기 때문

 

즉, 비관적 락은 데이터의 정합성이 중요하고 트랜잭션 간 충돌이 빈번하게 발생할 수 있는 경우에 적합합니다.

 

낙관적 락 VS 비관적 락

그럼 현재 이커머스 서비스에서 낙관적 락과 비관적 락 중 어떤 락을 사용하여 동시성 문제를 해결해야 할까요?

위에서 각 특징을 보았듯이 어떤 락을 선택할지는 트랜잭션 간 충돌 빈도에 따라 결정할 수 있습니다.

 

비관적 락: 충돌 빈도가 빈번하고 데이터의 정합성이 중요시되는 애플리케이션

낙관적 락: 충돌 빈도가 낮고 데이터의 정합성보다는 성능이 중요시되는 애플리케이션

 

그래서 저는 사용자의 포인트 및 상품 재고의 정합성이 더 중요하다고 생각했기 때문에 낙관적 락보다는 비관적 락이 더 적합하다고 생각을 하였습니다.

 

상품 주문 시 동시성 문제 상황

상품 주문 시스템에서 동시성 문제에 대하여 테스트 코드를 작성하고, 비관적 락을 사용하여 동시성 문제를 해결해 보겠습니다.

 

우선 테이블 구조는 아래와 같이 이루어져 있습니다.

 

상품(products) 테이블과 상품 재고(product_stocks) 테이블을 분리한 이유는 상품 테이블에 비관적 락을 걸게 될 경우 단순히 상품을 조회하는 요청에서도 락을 기다려야 하는 상황이 발생하기 때문에 상품 주문 시 재고 차감은 product_stocks 테이블에 락을 걸고 상품 조회는 prodcuts 테이블을 바라보고 조회하도록 하기 위함입니다.

 

(만일 상품 재고 테이블이 존재하지 않고 상품 테이블이 재고를 관리하도록 테이블을 설계했다면 분산락 또는 MySQL 네임드 락을 사용하여 동시성 문제를 효율적으로 처리 가능)

 

상품 주문 결제 처리하는 코드는 아래와 같이 한 트랜잭션 단위로 묶어 처리하고 있습니다.

@Transactional
public OrderPaidResult order(Long userId, OrderRequest request) {
	// 사용자 조회
	User user = userService.getUser(userId);
	
  	// 상품 조회
    	List<Product> products = productService.getProductsByIds(request.products().stream()
		.map(OrderRequest.ProductOrderRequest::id)
		.toList()
	);

	// 상품 재고 조회
	List<Stock> stocks = stockService.getStocksByProductIds(products);

	// 상품 재고 차감
	stockService.decreaseProductStock(stocks, request);

	// 주문 생성
	Order order = orderService.order(user, products, request);

	// 결제
	Payment payment = paymentService.pay(user, order, request);

	applicationEventPublisher.publishEvent(new OrderCreatedEvent(products, request.products(), order, payment));
	return OrderPaidResult.of(order, payment);
}

 

이 상품 주문 로직에 대한 동시성 테스트 코드는 아래와 같이 구성하였습니다

 

@Test
@DisplayName("주문 동시성 테스트")
void concurrency_order_test() throws InterruptedException {
	// Given
	int numThreads = 100;
	ExecutorService executor = Executors.newFixedThreadPool(numThreads);
	CountDownLatch latch = new CountDownLatch(numThreads);

	Long userId = 1L;
	Long productId = 2L;
	OrderRequest request = new OrderRequest(
			...
	);

	// When
	for (int i = 0; i < numThreads; i += 1) {
		executor.submit(() -> {
			try {
				orderUseCase.order(userId, request);
			} finally {
				latch.countDown();
			}
		});
	}

	latch.await();
	executor.shutdown();

	// Then
    	Product product = productService.getProductDetail(productId);
	List<Stock> stocks = stockService.getStocksByProductIds(List.of(product));
	assertThat(stocks.getFirst().stockQuantity()).isEqualTo(0);
}

 

100개의 Thread에서 동시에 상품의 재고가 총 100개인 상품을 주문하면 정상적으로 동시성 처리가 되었을 경우 상품의 재고는 0개가 되는 것을 기대하는 테스트 코드입니다.

상품 재고 100개 확인

비관적 락 사용 전

비관적 락을 사용하지 않고 (동시성 처리를 하지 않고) 테스트를 실행해 보면 상품의 재고가 100개가 차감된 게 아닌 10개만 차감된 결과를 얻을 수 있습니다.

실제 db 상품 재고 수

위와 같은 상황이 이전에 유즈케이스에서 정리했던 case 4번인 동시에 여러 사용자가 동일 상품 주문 시 재고 차감문제의 경우에 해당합니다.

 

비관적 락 사용 후

그럼 상품 재고를 조회하는 쿼리에 비관적 락을 걸고 다시 테스트를 실행해 보겠습니다.

JPA를 사용하는 경우 @Lock 어노테이션을 통해 조회 메서드에 어노테이션을 추가해 주면 비관적 락이 사용됩니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)

 

비관적 락 적용 후 테스트 실행 결과

 

 

정확히 100개의 재고가 차감되어 테스트가 통과하는 것을 볼 수 있습니다.

 

네임드 락

이전에 상품의 재고를 상품 테이블이 관리하게 될 경우 분산락이나 네임드 락을 사용하여 동시성 문제를 효율적으로 처리할 수 있다고 언급했었습니다.

그래서 상품 테이블이 재고를 관리하는 상황으로 가정해 봅시다.

 

만일 상품을 주문하는데, 재고 차감의 동시성 문제를 방지하기 위해 상품 테이블에 비관적 락을 걸 경우 단순히 상품을 조회하기 위해서도 상품 테이블에 락이 걸려있기 때문에 대기를 해야 하는 문제가 발생합니다. (상품 조회뿐 아니라 상품 테이블이 필요한 다른 비즈니스 로직이 전부 대기 상태에 빠집니다.)

 

락이 필요하지 않은 상황에서도 주문 로직 때문에 락이 걸려 애플리케이션의 성능 저하가 일어나게 되는데, 이를 MySQL의 네임드 락을 이용해 해결할 수 있습니다.

 

네임드 락은 비관적 락처럼 테이블이나 레코드 자체에 락을 거는 게 아니라 임의의 문자열에 대해 락을 걸 수 있습니다.

사용자가 지정한 문자열에 대해 락을 얻고, 해제하는 형태가 네임드 락입니다. 

 

만일 한 세션에서 "custom_lock"라는 문자열로 락을 획득했다면 다른 세션에서는 동일한 문자열("custom_lock")에 대해서는 락을 획득할 수 없습니다. 하지만 "custom_lock"이 아닌 다른 문자열에 대한 락은 획득이 가능합니다.

 

Named Lock 쿼리

// "custom_lock" 이라는 문자열에 대해 락을 획득
SELECT GET_LOCK("custom_lock" 2); // 이미 잠금이 걸려있으면 2초 동안 대기

// "custom_lock" 이라는 문자열에 대해 락이 걸려 있는지 확인
SELECT IS_FREE_LOCK("custom_lock");

// "custom_lock" 이라는 문자열에 대해 획득했던 락을 반납(해제)
SELECT RELEASE_LOCK("custom_lock");

 

3개 쿼리 모두 정상적으로 락을 획득하거나 해제한 경우에는 1을 반환하고 아니면 NULL, 0을 반환합니다.

 

네임드 락 적용

네임드 락을 사용하여 이전 상품 주문 동시성 상황을  동시성 테스트 코드를 실행해 보겠습니다.

 

우선 상품 테이블에서 재고를 관리하기 때문에 상품 테이블에서 재고를 차감하도록 비즈니스 로직을 수정하였습니다.

그리고 상품 재고를 차감하기 전 네임드 락을 획득하기 위해 GET_LOCK 메서드와 락을 해제하는 RELEASE_LOCK 메서드를 구현하여 비즈니스 로직을 수정하였습니다.

 

수정된 order 메서드

@Transactional
public OrderPaidResult order(Long userId, OrderRequest request) {
	User user = userService.getUser(userId);
	List<Product> products = new ArrayList<>();
	for (OrderRequest.ProductOrderRequest product : request.products()) {
    	String key = String.valueOf(product.id());
		try {
			lockHandler.lock(key);
			Product decreased = productService.decreaseStock(product.id(), product.quantity());
			products.add(decreased);
		} finally {
			lockHandler.unlock(key);
		}
	}

	Order order = orderService.order(user, products, request);

	Payment payment = paymentService.pay(user, order, request);
	return OrderPaidResult.of(order, payment);
}

 

네임드 락을 위한 lock의 key값을 주문할 상품의 id 값으로 설정해 주었습니다.

그래야 해당 상품에 대해 락을 걸고 동일한 id 값의 상품을 조회하면 동일한 문자열로 락이 걸리기 때문입니다.

 

LockHandler 코드

@Component
@RequiredArgsConstructor
@Slf4j
public class LockHandler {
	private static final String LOCK_KEY_PREFIX = "LOCK_";
	private final ProductRepository productRepository;

	public void lock(String key) {
		Long available = productRepository.getLock(LOCK_KEY_PREFIX + key);
		if (available == 0) {
			throw new RuntimeException("LOCK_ACQUISITION_FAILED");
		}
		log.info("Lock acquired for key: {}", LOCK_KEY_PREFIX + key);
	}

	public void unlock(String key) {
		productRepository.releaseLock(LOCK_KEY_PREFIX + key);
		log.info("Lock released for key: {}", LOCK_KEY_PREFIX + key);
	}
}

 

 

네임드 락을 적용한 상태로 상품 주문 동시성 테스트를 실행해 보겠습니다.

상품 주문 동시성 테스트는 상품 2개를 각각 100개의 스레드에서 1개씩 주문하는 상황으로 가정하고 테스트 코드를 작성했습니다.

@Test
@DisplayName("주문 동시성 테스트")
void concurrency_order_test() throws InterruptedException {
	// Given
	int numThreads = 100;
	ExecutorService executor = Executors.newFixedThreadPool(numThreads);
	CountDownLatch latch = new CountDownLatch(numThreads);

	Long userId = 1L;
	Long productId = 2L;
	Long productId2 = 1L;
	OrderRequest request = new OrderRequest(
		...
		List.of(productId, productId2)
	);

	// When
	for (int i = 0; i < numThreads; i += 1) {
		executor.submit(() -> {
			try {
				orderUseCase.order(userId, request);
			} finally {
				latch.countDown();
			}
		});
	}

	latch.await();
	executor.shutdown();

	// Then

	Product product = productService.getProductDetail(productId);
	assertThat(product.stockQuantity()).isEqualTo(0);

	Product product2 = productService.getProductDetail(productId2);
	assertThat(product2.stockQuantity()).isEqualTo(0);
}

 

 

초기 DB에 product id가 1, 2인 상품에 대해 재고는 100개로 세팅되어 있습니다.

 

위 테스트를 실행하여 실행 결과와 db 재고 값을 확인해 보겠습니다.

 

테스트 결과를 보면 100개가 전부 감소된 게 아닌 9개만 감소된 것을 확인할 수 있습니다. (동시성 처리 x)

 

테스트 실패에 대한 에러 로그를 확인해 보면 10번 스레드에서 커넥션을 사용할 수 없다는 문제가 발생했습니다.

 

이는 100개의 스레드에서 "LOCK_{product_id}"에 대하여 잠금을 얻기 위해 필요한 커넥션이 부족해서 발생한 문제였습니다.

HikariCP의 기본 커넥션 풀은 10개로 100개의 스레드에서 동시에 네임드 락을 얻으려고 할 때 10개의 커넥션이 모두 사용되었기 때문에 나머지 90개의 스레드에서는 커넥션을 얻지 못해 재고 차감이 되지 않았습니다.

그런데 그럼 재고는 총 10개가 차감되어 90개가 남아야 하는 게 아닌가? 하는 의문이 있을 수 있는데, 이는 네임드 락을 얻고 해제하기 위한 별도의 트랜잭션을 설정해 주었기 때문에 1개의 트랜잭션(네임드 락을 얻고 해제하는 트랜잭션)이 별도로 추가되어 총 9개가 감소된 것을 알 수 있습니다.

 

즉 락을 얻는 트랜잭션과 메인 로직을 실행하는 트랜잭션을 분리시켜 주어야 동시성 문제가 처리됩니다.

 

네임드락 사용 시 트랜잭션 분리

현재 주문 로직의 트랜잭션은 메인 로직에 트랜잭션과 상품을 얻고 재고를 감소하는 메서드에 별도의 트랜잭션을 생성하도록 적용하여 분리하였습니다.

 

order 메서드 트랜잭션

@Transactional
public OrderPaidResult order(Long userId, OrderRequest request) {
    User user = userService.getUser(userId);
    List<Product> products = new ArrayList<>();
    for (OrderRequest.ProductOrderRequest product : request.products()) {
       String key = String.valueOf(product.id());
       try {
          lockHandler.lock(key);
          Product decreased = productService.decreaseStock(product.id(), product.quantity());
          products.add(decreased);
       } finally {
          lockHandler.unlock(key);
       }
    }

    Order order = orderService.order(user, products, request);

    Payment payment = paymentService.pay(user, order, request);
    return OrderPaidResult.of(order, payment);
}

 

 

상품 재고를 감소시키는 decreaseStock() 메서드의 트랜잭션

@Transactional(propagation = Propagation.REQUIRES_NEW)
public Product decreaseStock(Long productId, Long quantity) {
    Product product = productReader.readById(productId);
    productUpdator.updateStock(product, quantity);
    return product;
}

 

부모 트랜잭션(order메서드의 트랜잭션)과 분리하기 위해 트랜잭션의 propagation 옵션을 REQUIRES_NEW를 이용해 새로운 트랜잭션을 생성하도록 설정해 주었습니다.

 

락을 얻고 (lockHandler.lock(key)) 상품 재고를 차감시킨 뒤 트랜잭션을 커밋(재고 차감 반영)하여 락을 해제해 주었습니다.

 

트랜잭션을 분리하지 않을 경우

만일 두 트랜잭션을 분리하지 않으면 아래와 같은 상황이 발생할 수 있습니다.

Thread 1에서 상품의 재고를 차감할 때 네임드 락을 걸고(GET_LOCK) 재고를 1개 차감한 뒤 락을 해제(RELEASE_LOCK) 하였습니다.

이때 트랜잭션을 커밋하지 않았기 때문에 차감된 재고(99개)는 반영된 상태가 아닙니다.

Thread 1에서 락을 해제했기 때문에 Thread 2에서는 GET_LOCK을 통해 "LOCK_1" 문자열에 대한 락을 획득할 수 있습니다.

이때 상품의 재고는 차감된 개수가 반영되기 전 100개인 상태를 읽기 때문에 동시성 처리가 되지 않습니다.

 

그래서 네임드 락을 사용할 때는 메인 로직 트랜잭션과 락을 얻고 해제하는 트랜잭션이 분리되어야 합니다.

 

 

그래서 기존 문제였던 커넥션 풀 사이즈를 101개로 설정(100 + 락 획득 및 해제를 위한 트랜잭션 1개)하고 테스트를 다시 실행해 주면 정확히 100개가 차감되는 것을 확인할 수 있습니다.

테스트 통과
실제 DB 재고 개수

 

정확히 100개의 스레드에서 1개씩 상품 재고를 차감한 것을 확인할 수 있습니다.

 

네임드락의 한계

분산 환경에서 락 공유 불가

MySQL의 네임드 락은 기본적으로 각각의 MySQL 인스턴스에서 독립적으로 작동하기 때문에 해당 MySQL 서버 내에서만 유효하다는 단점이 있습니다.

만일 여러 서버에서 하나의 MySQL 서버를 사용한다면 네임드 락을 사용해 다른 클라이언트 간 동시성 처리를 할 수 있지만, 다수의 데이터베이스 서버가 사용되는 분산 환경에서는 각 서버 간 락을 공유할 수 없다는 한계가 존재합니다.

 

즉, 각 서버는 독립적으로 락을 관리하기 때문에 다른 MySQL 서버와는 락 상태를 공유하거나 동기화할 수 없다는 것을 의미합니다. 

예를 들어 분산 환경에서 다수의 데이터 베이스를 사용하는 경우 A 서버에서 획득한 락이 B 서버에서는 영향을 주지 않아 동시성 처리를 할 수가 없습니다.

 

결국 네임드 락은 분산 환경에서 동시성 문제를 해결하는데 한계가 있습니다.

각 데이터베이스 인스턴스는 독립적으로 락을 관리하므로, 서로 다른 인스턴스에 걸린 락에 대해 서로 알지 못하며, 이로 인해 동일 자원에 대한 접근을 제어하는 데 문제가 발생할 수 있습니다.

 

따라서, 분산 환경에서의 동시성 처리를 위해서는 분산 락을 이용해 해결할 수 있습니다.

분산 락이란?

여러 서버에서(분산된 환경) 공유된 자원에 접근하여 수정하는 것을 방지하여 데이터를 동기화하기 위해 사용되는 방법 중 하나입니다.

Redis, Zookeeper와 같은 독립적인 분산 락을 사용하면 여러 서버와 서비스가 서로 락을 공유하고 관리할 수 있습니다.

 

애플리케이션 내에서 공통으로 사용되는 독립적인 저장소를 이용하여 접근하려는 자원이 사용 중인지 확인하여 동시에 여러 스레드에서 동일한 자원에 접근하지 못하도록 합니다.

분산 락을 활용하면 비관적 락처럼 db에 직접적으로 락을 거는 게 아니기 때문에 db의 부하를 줄일 수 있다는 장점이 존재합니다. 

Redis 분산 락

"key-value" 구조의 Redis를 사용하여 분산 락을 구현하여 동시성 제어를 해보았습니다.

redis를 활용하여 분산 락을 구현하는 방법에는 spin lock, pub/sub 방법이 존재합니다.

 

Spin Lock

스핀 락은 lock을 획득 시도하고 얻지 못했을 경우 일정 횟수 또는 시간 동안 락 획득을 재시도하여 락을 얻는 방법입니다. (lettuce 클라이언트 이용하여 구현)

스핀 락은 sentnx 명령어를 활용해서 지속적으로 Redis에게 락 획득을 시도하기 때문에 요청이 많아질수록 해당 스레드를 사용하고 있는 리소스와 Redis가 받는 부하가 커지게 되는 단점이 존재합니다.

 

Pub/Sub

Redisson 클라이언트를 이용해 pub/sub 방식으로 분산 락을 구현할 수 있습니다.

pub/sub 방식은 스핀 락과 다르게 락을 얻기 위해 반복적으로 redis에 락 획득 요청을 보내는 게 아닌, 락을 얻기 위해 특정 시간 동안 구독을 하고 있다가, 락을 획득하고 있던 스레드에서 락을 해제하게 되면 이를 구독하고 있는 subscriber 들에게 락이 해제되었다는 신호를 주면 이를 구독하고 있는 subscriber들은 락 획득을 시도하게 됩니다.

즉, 스핀 락의 단점이었던 락 획득을 위해 주기적으로 락 획득 요청 시도를 하지 않기 때문에 Redis에게 가하는 부하가 적어진다는 장점이 있습니다.

 

Redis를 활용하여 분산 락 적용

Spin lock 방법이 아닌 pub/sub 방법을 활용하여 분산 락을 구현해 보았습니다.

 

기존 LockHandler를 RedissonClient를 활용하여 수정하였습니다. 

@Component
@RequiredArgsConstructor
@Slf4j
public class LockHandler {
    private static final String LOCK_KEY_PREFIX = "LOCK_";
    private final RedissonClient redissonClient;

    public void lock(String key, long waitTime, long leaseTime) {
       RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + key);
       try {
          boolean available = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
          if (!available) {
             throw new InterruptedException("LOCK_ACQUISITION_FAILED");
          }
          log.info("Lock acquired for key: {}", LOCK_KEY_PREFIX + key);
       } catch (InterruptedException e) {
          throw new RuntimeException(e.getMessage());
       }
    }

    public void unlock(String key) {
       RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + key);
       lock.unlock();
       log.info("Lock released for key: {}", LOCK_KEY_PREFIX + key);
    }
}

 

RedissonClient의 getLock() 메서드롤 통해 RLock 객체를 얻어 이 객체를 통해 락을 관리할 수 있습니다.

 

RLock 객체의 tryLock() 메서드를 통해서 락을 획득하는데, waitTime은 락을 획득하기 위해 대기하는 시간을 지정하고, leaseTime은 락을 갖고 있는 시간(만료 시간)을 설정할 수 있습니다.

 

이후 락을 적용하는 코드는 이전에 봤던 네임드 락의 구조와 비슷합니다.

@Transactional
public OrderPaidResult order(Long userId, OrderRequest request) {
	...

      String key = String.valueOf(request.productId());
      lockHandler.lock(key, 2, 1);
      try {
         stockService.decreaseProductStock(products, request);
      } finally {
         lockHandler.unlock(key);
      }

	...
}

 

주문하려는 상품의 id값을 이용해 key값을 만들어 락을 얻고(대기 시간 2초, 락 만료 시간 1초), 상품 재고 차감한 뒤 락을 해제하는 로직입니다.

 

이후 기존 주문 동시성 테스트를 실행해 보면 

 

정상적으로 테스트가 통과하는 것을 확인할 수 있습니다.

 

 

정리

길고 길었던 이커머스 서비스에서 발생할 수 있는 동시성 문제를 여러 가지 방법(낙관적 락, 비관적 락, 네임드 락, 분산 락)을 알아보고 적용해 보면서 정리해 보았습니다.

처음부터 "동시성 문제를 처리하기 위해서는 레디스의 분산 락을 사용해야 해!"라는 무지성 논리로 분산 락을 적용했다면 분산 락을 사용해야 하는 이유와 비관적 락이나 네임드 락을 이용해서 동시성 문제를 해결할 수 있을 텐데 왜 분산 락을 사용했는지에 대한 질문에 답을 하지 못했을 텐데 직접 구현해 보면서 단점을 몸소 느끼며 다른 동시성 제어 방법의 필요성을 느낄 수 있었습니다.

 

 

Reference

https://helloworld.kurly.com/blog/distributed-redisson-lock/#5-%EB%A7%88%EC%B9%98%EB%A9%B0

https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html