TL;DR
- 조회가 많고 트랜잭션이 긴 이커머스 주문 시스템에 낙관적 락을 도입했다.
- 재시도 로직과 비즈니스 트랜잭션을 분리해 충돌 시 안전하게 처리한다.
들어가며
주문 생성 로직은 이커머스 서비스의 핵심이자 가장 민감한 구간이다.
재고, 포인트, 쿠폰 등 여러 자원이 동시에 변경되어야 하기 때문에 동시성 제어가 필수이다. 사용자가 몰리는 상황에서 재고가 1개 남은 상품을 2명이 동시에 주문한다면? 내가 보유한 포인트를 동시에 두 건의 주문에서 사용하려고 한다면?
이번 포스팅에서는 주문 시스템에서 데이터의 정합성을 지키기 위해 고민했던 과정과, 낙관적 락을 통해 이를 해결한 경험을 공유한다.
주문 프로세스와 동시성 위험 구간
주문 생성 프로세스는 아래와 같다.
1. 주문 요청
2. 비즈니스 검증 및 차감
2-1. 상품 재고 차감 : 상품의 남은 수량 확인 및 감소
2-2. 쿠폰 사용 : 유저의 보유 쿠폰 유효성 검증 및 사용 처리
2-3. 포인트 차감 : 유저의 보유 포인트 확인 및 차감
3. 주문 엔티티 생성 및 저장
이 과정에서 재고, 포인트, 쿠폰은 모두 공유 자원이다. 여러 트랜잭션이 동시에 접근하여 재고, 쿠폰, 포인트 값을 수정하려고 할 때, 갱신 손실 문제가 발생할 수 있다.
비관적 락 vs 낙관적 락
동시성 이슈를 해결하기 위해 JPA가 제공하는 두 가지 대표적인 락을 고민해 봤다.
왜 비관적 락을 선택하지 않았는가
비관적 락은 데이터에 접근하는 순간 락을 걸어버리는 방식이다. (SELECT ... FOR UPDATE)
그래서 데이터의 정합성은 완벽하게 보장할 수 있겠지만, 다음과 같은 단점이 있어 선택하지 않았다.
1. 긴 트랜잭션과 성능 저하
주문 생성 로직은 단순히 INSERT 한 번으로 끝나지 않는다.
상품 조회 -> 재고 확인 -> 쿠폰 유효성 검증 -> 포인트 잔액 확인 -> 결제 금액 계산 -> 주문 저장 등 복잡한 과정을 거친다.
비관적 락을 사용하면 이 모든 과정이 끝날 때까지 락을 유지해야하고, 앞선 사용자의 트랜잭션이 끝날 때까지 뒤에 있는 모든 사용자는 대기 상태에 빠진다.
2. 데드락 위험
기능 요구사항에 따라 주문할 때 여러 상품을 주문할 수 있다.
이때 자원 접근 순서가 꼬이면 데드락이 발생할 위험이 크다.
예를 들어 사용자A는 상품1 -> 상품 2순서로 재고 차감을 시도하고,
사용자B는 상품2 -> 상품1 순서로 재고 차감을 시도한다고 했을 때
두 트랜잭션이 서로 다른 순서로 락을 획득하면 다음 상황이 만들어진다.
- 트랜잭션A는 상품1의 락을 잡고 상품2 락을 기다리고
- 트랜잭션B는 상품2의 락을 잡고 상품1 락을 기다린다.
이렇게 서로가 가진 락을 기다리는 구조가 생기면서 데드락이 발생할 수 있다.
왜 낙관적 락을 선택했는가
낙관적 락은 DB에 물리적인 락을 걸지 않고, 버전을 이용해서 정합성을 체크한다.
1. 상품 '조회' 요청도 많다.
이커머스 서비스 특성상 '주문'보다 상품 '조회' 요청이 훨씬 많을 것이라고 생각했다.
낙관적 락은 데이터를 읽을 때 아무런 제약이 없다. 트랜잭션이 길어지더라도 다른 사용자의 조회를 막지 않기 때문에, 단순 상품 조회 요청도 많은 이커머스 환경에서 낙관적 락이 적합하다고 생각했다.
2. 대부분은 성공한다.
기술 선택을 결국 트레이드오프라고 생각한다.
현재 주문 서비스의 특징을 고려했을 때, 충돌이 발생했을 때만 재시도를 수행하는 전략이 더 효율적이라고 판단했고,
최종적으로 Product, Point, Coupon 엔티티에 Version을 추가하여 낙관적 락을 적용했다.
낙관적 락 구현
엔티티에 Version 추가
@Entity
public class Product {
// ...
@Version
@Column(nullable = false)
private Long version;
}
JPA는 @Version 어노테이션이 붙은 필드로 버전을 관리한다. 엔티티가 수정될 때마다 JPA가 자동으로 이 값을 1씩 증가시킨다.
Repository에 낙관적 락 조회 메서드 구현
public interface ProductJpaRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithLock(@Param("id") Long id);
}
조회 시점에 락 모드를 명시하여, 이 트랜잭션이 끝날 때까지 버전 체크를 수행하도록 설정한다.
낙관적 락 동작 원리
두 명의 사용자(A, B)가 버전 5인 상품을 동시에 주문한다고 가정하자.
1. 조회 : A와 B 모두 version=5인 상태를 조회한다.
2. 수정 : 둘 다 재고를 차감한다.
3. A 커밋 : A가 먼저 커밋에 성공한다. 이때 DB 쿼리는 다음과 같다.
UPDATE product SET stock = ..., version = 6 WHERE id = 1 AND version = 5
-> version=5라는 조건이 일치하므로 업데이트에 성공하고, 버전은 6이 된다.
4. B 커밋 : B가 커밋을 시도한다.
UPDATE product SET stock = ..., verion = 6 WHERE id = 1 AND version = 5
-> DB의 버전은 이미 6이므로, 실패한다!
-> 수정된 행이 없으므로, JPA는 OptimisticLockingFailureException을 발생시킨다.
재시도 로직 구현 : 트랜잭션 분리
낙관적 락을 도입하면 충돌 시 OptimisticLockingFailureException이 발생한다. 사용자에게 에러 화면을 보여주는 것보다, 실패하면 다시 시도한다 는 로직이 필요하다.
하지만 이 재시도 로직을 어디에 넣느냐가 중요하다.
[시도 1] "Facade 안에 그냥 catch 해서 다시 돌리면 되지 않을까?"
처음에는 기존 OrderFacade 안에서 try-catch로 감싸고 반복문을 돌렸다.
// 실패한 코드
public class OrderFacade {
@Transactional // 트랜잭션 시작
public OrderInfo createOrder(OrderCommand command) {
int retry = 0;
while (retry < 3) {
try {
// ... 주문 로직 ...
return orderInfo; // 성공 시 리턴
} catch (OptimisticLockingFailureException e) {
retry++; // 실패 시 재시도
Thread.sleep(50);
}
}
throw new CoreException(ErrorType.CONFLICT, "주문 실패");
}
}
"단순히 예외를 잡아서 다시 실행하면 되겠지?"라고 생각하고 통합 테스트를 돌려봤다.
테스트 결과는 실패였고, 다음과 같은 에러가 발생하며 테스트가 중단됐다.
Transaction silently rolled back because it has been marked as rollback-only
원인은 다음과 같다.
- 첫 번째 시도에서 버전 충돌 발생 -> 예외 발생
- 해당 트랜잭션은 이미 롤백 마크가 찍히게 됨
- catch 블록에서 잡아서 다시 save를 시도하더라도, 커밋 시점에 "이미 롤백으로 마킹된 트랜잭션"이라는 오류와 함께 전체가 실패하게 됨
- 그리고, 재시도 시에는 DB에서 변경된 최신 버전의 데이터를 새로 읽어와야 하는데, 같은 트랜잭션 내에서는 영속성 컨텍스트가 유지되므로 이전 데이터를 그대로 읽어올 수 있음
이 문제를 해결하기 위해 비즈니스 로직(트랜잭션 O)과 재시도 로직(트랜잭션 X)을 분리하는 패턴을 적용했다.
1. 재시도 로직 담당 (OrderLockFacade)
@RequiredArgsConstructor
@Component
public class OrderLockFacade { // 트랜잭션 없음
private static final int MAX_RETRY_ATTEMPTS = 5;
private final OrderFacade orderFacade;
public OrderInfo createOrder(OrderCommand.CreateOrderCommand createOrderCommand) {
for (int retryCount = 0; retryCount < MAX_RETRY_ATTEMPTS; retryCount++) {
try {
// 여기서 OrderFacade를 호출할 때마다 새로운 트랜잭션이 시작됨
return orderFacade.createOrder(createOrderCommand);
} catch (OptimisticLockingFailureException e) {
if (retryCount == MAX_RETRY_ATTEMPTS - 1) {
throw new CoreException(ErrorType.CONFLICT, "주문량이 많아 실패했습니다.");
}
sleep(retryCount);
}
}
throw new CoreException(ErrorType.INTERNAL_ERROR, "주문 생성 실패");
}
// ... sleep 메서드 생략
}
이 클래스는 트랜잭션이 없다.
오직 충돌이 나면 잡아서 잠깐 대기했다가 다시 요청하느 역할만 수행한다.
2. 비즈니스 로직 담당 (OrderFacade)
@RequiredArgsConstructor
@Component
public class OrderFacade {
// ... 의존성 주입
@Transactional // 재시도 시마다 새로 시작되는 트랜잭션
public OrderInfo createOrder(OrderCommand.CreateOrderCommand command) {
// 1. 조회
User user = userService.findUserByLoginId(command.loginId())...
// 2. 로직 수행 (재고 차감 등)
// ... 여기서 충돌 발생 시 예외가 던져지고, 트랜잭션은 롤백됨 ...
return OrderInfo.from(order, ...);
}
}
여기서는 오직 하나의 주문을 성공적으로 처리하는 것에만 집중한다. @Transactional을 통해 원자성을 보장한다.
결과
OrderLockFacade는 "재시도 전략"에만 집중하고, OrderFacade는 "주문 비즈니스 로직의 정합성"에만 집중한다. 그래서 명확하게 책임 분리를 할 수 있다.
그리고 매 재시도마다 OrderFacade.createOrder()가 호출되면서 새로운 트랜잭션이 시작된다. 그래서 재시도마다 DB의 최신 상태(변경된 버전)를 조회해서 수행할 수 있다.
마지막으로, 스프링에서 제공하는 @Retryable 어노테이션을 활용해서 재시도 로직을 자동화할 수도 있다. OrderLockFacade로 구현한 것처럼 상황에 맞는 세밀한 제어가 가능하진 않지만, 중복되는 재시도 로직이 많다면 이걸 통해 자동화하는 것도 좋은 방법일 것 같다.