Post

도메인별 동시성 제어 최적화 전략

도메인별 동시성 제어 최적화 전략

주문 기능에서 동시성 문제

주문 기능을 구현하다 보면 세 가지 도메인에서 동시성 이슈가 발생합니다.

  • 재고 (Product): 동시에 10명이 마지막 1개를 주문하면?
  • 포인트 (Point): 동시에 여러 결제가 같은 포인트를 차감하면?
  • 쿠폰 (Coupon): 동시에 여러 사람이 같은 쿠폰을 사용하면?

처음에는 세 도메인 모두 비관적 락(Pessimistic Lock)을 적용했습니다. 일단 안전하게 가자는 생각이었습니다.

비관적 락과 낙관적 락 복습

비관적 락 (Pessimistic Lock)

  • “충돌이 발생할 것이다”라고 가정하고 미리 잠급니다
  • SELECT ... FOR UPDATE
  • 충돌이 많을 때 유리합니다
  • 데드락 위험이 있습니다

낙관적 락 (Optimistic Lock)

  • “충돌이 없을 것이다”라고 가정하고 커밋 시점에 충돌을 감지합니다
  • @Version 컬럼을 이용한 버전 충돌 감지
  • 충돌이 적을 때 유리합니다
  • 충돌 시 재시도 로직이 필요합니다

도메인별 특성 분석

락 전략을 결정하기 전에 각 도메인의 특성을 분석했습니다.

도메인충돌 확률예외 격리 가능성선택
Product (재고)높음낮음비관적 락
Point (포인트)높음낮음비관적 락
Coupon (쿠폰)낮음높음낙관적 락

재고와 포인트는 충돌 확률이 높습니다. 인기 상품은 동시에 수십 명이 주문할 수 있고, 포인트도 한 사용자가 여러 탭에서 동시에 결제 시도할 수 있습니다. 비관적 락이 맞습니다.

쿠폰은 다릅니다. 보통 쿠폰은 1인 1매 제한이 있고, 같은 쿠폰으로 동시 요청이 들어오는 경우가 드뭅니다. 낙관적 락으로 충돌을 감지하고 예외를 던지는 게 더 가볍습니다.

구현

재고: 비관적 락

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithLock(@Param("id") Long id);
}

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional
    public void deductStock(Long productId, int quantity) {
        Product product = productRepository.findByIdWithLock(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));
        product.deductStock(quantity); // 재고 부족 시 도메인 예외
    }
}

포인트: 비관적 락

1
2
3
4
5
6
public interface PointRepository extends JpaRepository<Point, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Point p WHERE p.userId = :userId")
    Optional<Point> findByUserIdWithLock(@Param("userId") Long userId);
}

쿠폰: 낙관적 락

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
@Entity
public class Coupon {

    @Id
    private Long id;

    @Version
    private Long version;

    private boolean used;

    public void use() {
        if (this.used) {
            throw new CouponAlreadyUsedException(this.id);
        }
        this.used = true;
    }
}

public interface CouponRepository extends JpaRepository<Coupon, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("SELECT c FROM Coupon c WHERE c.id = :id")
    Optional<Coupon> findByIdWithOptimisticLock(@Param("id") Long id);
}

낙관적 락은 @Version 필드를 통해 동시 수정을 감지합니다. 두 트랜잭션이 같은 version으로 업데이트하려 하면 OptimisticLockException이 발생합니다.

OrderFacade에서 조합

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
@Service
@RequiredArgsConstructor
public class OrderFacade {

    private final ProductService productService;
    private final CouponService couponService;
    private final PointService pointService;
    private final OrderService orderService;

    @Transactional
    public OrderResult order(OrderCommand command) {
        // 비관적 락: 재고 차감
        productService.deductStock(command.getProductId(), command.getQuantity());

        // 낙관적 락: 쿠폰 사용 (충돌 시 OptimisticLockException)
        int discountAmount = 0;
        if (command.getCouponId() != null) {
            discountAmount = couponService.apply(command.getCouponId());
        }

        // 비관적 락: 포인트 차감
        if (command.getPointAmount() > 0) {
            pointService.deduct(command.getUserId(), command.getPointAmount());
        }

        return orderService.create(command, discountAmount);
    }
}

낙관적 락 예외 처리

낙관적 락은 예외가 발생했을 때 처리 전략이 필요합니다.

쿠폰의 경우, 두 트랜잭션이 동시에 같은 쿠폰을 사용하려 하면 하나는 버전 충돌(OptimisticLockingFailureException)로 실패합니다. 재시도하더라도 쿠폰은 이미 사용된 상태이므로 재시도가 아닌 명확한 에러 응답을 주는 게 맞습니다.

1
2
3
4
5
6
@ExceptionHandler(OptimisticLockingFailureException.class)
public ResponseEntity<ErrorResponse> handleOptimisticLockException(
        OptimisticLockingFailureException e) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
        .body(new ErrorResponse("이미 처리된 요청입니다. 다시 시도해주세요."));
}

재고나 포인트처럼 재시도가 의미 있는 경우에는 @Retryable을 활용할 수 있지만, 이 경우는 비관적 락을 쓰는 게 더 직관적입니다.

정리

모든 락을 동일하게 처리하는 건 잘못된 접근입니다. 도메인의 특성을 이해하고 적절한 전략을 선택해야 합니다.

  • 충돌 확률이 높고, 실패가 치명적: 비관적 락
  • 충돌 확률이 낮고, 실패를 예외로 처리 가능: 낙관적 락

비관적 락은 안전하지만 성능 비용이 있습니다. 낙관적 락은 가볍지만 충돌 처리 로직이 필요합니다. 도메인을 이해한 설계가 과잉 동기화를 막습니다.

This post is licensed under CC BY 4.0 by the author.