서론
앞서 트랜잭션과 격리 수준, DB가 보장하는 동시성 제어를 정리해 보았다. 이번에는 실제 주문 로직에 어떻게 적용했는지를 정리해보고자 한다. 이번 주차 구현에서는 DB 레벨의 락 전략 학습이 목표였기 때문에 Redis와 같은 분산락은 고려하지 않고 낙관적 락과 비관적 락을 먼저 도입해 보기로 했다.
왜 락 전략이 필요하지?
InnoDB의 MVCC와 Next-Key Lock 덕분에 기본적인 읽기 일관성이나 Phantom Read 방지는 잘 되지만 도메인 규칙까지 완전히 보장하기에는 부족하다고 느꼈다. 따라서 락 전략을 추가해 보기로 했다.
낙관적 락 vs 비관적 락
두 방식을 간단하게 비교하면 아래와 같다.
| 구분 | 낙관적 락 (Optimistic Lock) | 비관적 락 (Pessimistic Lock) |
| 방식 | 버전 필드를 이용해 충돌 감지 | 조회 시점부터 DB에 락 설정 |
| 충돌 발생 시 | 예외 후 재시도 or 실패 처리 | 다른 트랜잭션 대기 or 실패 |
| 성능 | 충돌이 적으면 성능 우수 | 락 경쟁 시 성능 저하 가능 |
| 적합한 상황 | 하나만 성공하면 되는 구조 | 절대 중복되면 안 되는 자원 |
각각 두 방식이 어떤 상황에서 더 유리한지 좀 더 살펴보기로 했다.
왜 낙관적 락은 충돌이 적을 때 유리할까?
먼저 트랜잭션 롤백이 DB에서 어떤 역할을 하는지 짚고 넘어가야 한다.
롤백은 말 그대로 트랜잭션 중 변경된 내역을 모두 취소하고 이전 상태로 되돌리는 동작이다. DB 입장에서는 커밋 전까지는 변경이 확정된 게 아니고 롤백이 발생하면 그동안의 작업은 싹 무시되는 것이다.
그렇다면 롤백이 발생하기까지 이전 데이터는 어디에 보관될까?
MySQL InnoDB에서는 이전 글에서 정리했듯이 변경 전 데이터를 Undo Log에 저장해 둔다. 롤백 시 Undo Log를 이용해 데이터를 복원하는데, 여기서 중요한 건 데이터를 어딘가에 보관한다는 것은 I/O와 메모리를 사용한다는 점이다. 따라서 롤백이 자주 발생하면 할수록 Undo Log는 계속 쌓이고 그만큼 부하가 늘어 성능이 떨어질 것이다.
낙관적 락은 DB에 실제 락을 거는 대신 '@Version' 필드 같은 버전 정보를 이용해 충돌 여부를 감지하는 논리 락이다. 트랜잭션 간 간섭 없이 각자 로직을 수행하고, 커밋 시점에 충돌 여부를 버전으로 판단한다. 충돌이 자주 발생한다면 불리해진다. 커밋 직전까지 로직을 모두 수행한 다음, 버전이 다르면 롤백을 유도하므로 충돌이 잦다는 것은 그만큼 롤백이 자주 발생하고 Undo Log도 많이 쌓인다는 의미다. 하지만 충돌이 적다면 락 대기 시간도 없고 병렬 처리 효율이 높아 성능이 좋다.
비관적 락은 어떨까?
비관적 락은 애초에 조회 시점부터 DB 레코드에 락을 걸어버린다. 'SELECT ... FOR UPDATE' 같은 DB의 레코드 락을 이용해 다른 트랜잭션의 접근을 막는다. 이 방식은 충돌 자체를 사전에 차단할 수 있기에 재고차감, 쿠폰 발급처럼 정합성이 매우 중요한 자원에 적합하다. 하지만 락을 너무 오래 잡고 있거나 트랜잭션이 길어지면 데드락 위험도 있고, 경합 상황에서는 대기가 길어져 성능이 저하될 수 있다.
주문 로직 흐름
현재 OrderFacade#create 내 주문 처리 흐름은 다음과 같다.
1. 주문 생성
2. (조건) 쿠폰 사용 → 할인 금액 계산 및 사용 처리
3. 재고 차감
4. 포인트 차감
5. 결제 저장
6. 외부 주문 시스템 전달
7. 주문 상태 SUCCESS 처리
위 모든 로직은 하나의 트랜잭션으로 묶여 있어야 하고 중간에 하나라도 실패 시 롤백되어야 한다.
여기서 특히 동시성 문제가 생길 수 있는 지점은 세 군데였다.
- 쿠폰: 정확히 한 번만 사용되어야 함
- 포인트: 한 주문에는 한 번만 차감되지만, 여러 주문이 동시에 올 경우 잔액 내에서 정확히 처리되어야 함
- 재고: 정해진 수량 내에서 정확히 차감되어야 함
테스트 전략
락 적용 결과를 검증하기 위해 다음과 같은 방식으로 동시성 테스트를 진행했다.
- 모든 테스트는 다수 스레드를 동시에 실행해 실제 경합 상황을 재현
- CountDownLatch + ExecutorService를 이용해 다수 스레드를 동시에 시작
- 테스트 대상 서비스 메서드에 @Transactional 어노테이션과 선택한 락 전략을 적용한 상태로 실행해 실제 코드와 동일한 조건으로 검증
- 검증 항목은 성공 요청 수와 실패 요청 수, DB에서 조회 한 최종 데이터의 정합성을 검증
테스트 코드 예시는 다음과 같다.
@DisplayName("낙관적 락 적용 시, 동시에 쿠폰을 사용해도 한 번만 사용된다.")
@Test
void useCouponWithOptimisticLock() throws InterruptedException {
// arrange
int threadCount = 10;
Coupon coupon = couponJpaRepository.save(new Coupon("3천원 할인", 100, 0, PRICE, 3000, null, 10000));
UserCoupon userCoupon = userCouponJpaRepository.save(UserCoupon.create(coupon.getId(), 1L));
// act: 10 개의 스레드 동시 호출
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
couponUseService.use(userCoupon.getId(), userCoupon.getUserId(), 10000);
successCount.incrementAndGet();
} catch (Exception e) {
System.out.println("[FAIL]: "+e.getMessage());
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
// assert
System.out.println("[SUCCESS COUNT]: "+successCount.get());
System.out.println("[FAIL COUNT]: "+failCount.get());
assertThat(successCount.get()).isEqualTo(1);
UserCoupon result = userCouponJpaRepository.findById(userCoupon.getId()).orElseThrow();
assertThat(result.getStatus()).isEqualTo(UserCouponStatus.USED);
List<CouponUsageHistory> histories = couponUsageHistoryJpaRepository.findHistoriesByUserCouponId(userCoupon.getId());
assertThat(histories).hasSize(1);
}
쿠폰 사용
문제 정의
- 쿠폰은 한 번만 사용 가능함
- 같은 사용자가 동시에 여러 요청을 보내면 중복 사용 가능성 있음
AS-IS
@Transactional
public int use(Long userCouponId, Long userId, int orderAmount) {
UserCoupon userCoupon = userCouponService.getDetail(userCouponId, userId);
Coupon coupon = couponService.getDetail(userCoupon.getCouponId());
couponService.validateAmount(coupon, orderAmount);
int discountAmount = couponService.calculateDiscountAmount(coupon, orderAmount);
userCouponService.use(userCouponId, userId);
return discountAmount;
}
- 단순 조회 후 사용 처리
- 동시 요청 시 여러 번 사용될 가능성이 있음
TO-BE: 낙관적 락
public class UserCoupon {
...
@Version
private Long version;
- @Version 필드를 사용해 버전 충돌 감지
- 한 요청이 먼저 커밋되면 나머지는 OptimisticLockException 예외 발생
- 쿠폰은 한 번만 성공하면 되므로 나머지 요청에 대해서 재시도 처리를 추가하지는 않음
결과 및 선정 이유
- 쿠폰 사용 시나리오의 경우 하나의 사용자가 하나의 쿠폰을 사용하는 경우이므로 경합 가능성이 낮다고 생각함
- 한 번만 성공하면 되는 구조이므로 실패요청에 대해 단순 예외처리가 가능함
- 비관적 락처럼 대기가 없어 성능 부담이 적고 빠르게 결과 반환 가능
- 총 10건의 요청 중 1건에 대해서만 쿠폰 사용 요청이 성공하고 나머지는 실패한 것을 볼 수 있었음
- 경합이 거의 없고 한 번만 성공하면 되는 구조라 낙관적 락이 잘 맞는 것처럼 보임
포인트 사용
문제 정의
- 여러 주문 요청이 동시에 들어올 경우 포인트 잔액을 초과해 차감되면 안 됨
AS-IS
@Transactional
public void use(Long userId, int amount) {
pointService.use(userId, amount);
}
- 단순 조회 후 차감
- 동시에 차감 요청이 오면 기존 잔액에서 조회 후 각자 커밋되어 사용 내역이 덮어씌워져 정합성이 맞지 않을 수 있음
TO-BE
포인트에 대해서는 낙관적 락과 비관적 락 중 어떤 것이 더 좋을지 고민하다가 두 개 다 적용해 보기로 했다.
1) 비관적 락 적용
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(value = {
@QueryHint(name = "javax.persistence.lock.timeout", value = "5000")
})
@Query("select p from Point p where p.userId = :userId")
Optional<Point> findByUserIdWithLock(Long userId);
- SELECT ... FOR UPDATE 적용해 레코드 락 설정
- 다른 트랜잭션은 락 해제 전까지 대기
2) 낙관적 락 적용
public class Point {
...
@Version
private Long version;
@Retryable(
retryFor = {OptimisticLockException.class, StaleObjectStateException.class, ObjectOptimisticLockingFailureException.class},
maxAttempts = 5,
backoff = @Backoff(delay = 50)
)
@Transactional
public void use(Long userId, int amount) {
pointService.use(userId, amount);
}
- @Version 필드를 사용해 버전 충돌 감지
- @Retryable 사용해 최대 재시도 횟수 결정 가능
결과 및 선정 이유
| 구분 | 낙관적 락 | 비관적 락 |
| 포인트 충분 → 모든 요청 성공 | 21147ms | 11338ms |
| 포인트 부족 → 가능한 만큼만 성공 | 1652ms | 1927ms |
| 0 포인트 → 모두 실패 | 682ms | 2821ms |
- 테스트 결과 낙관적 락을 선정함
- 1000 건 테스트 시 모든 요청이 성공해야 하는 시나리오에서는 비관적 락이 더 빠름
- 하지만 나머지 시나리오에서는 낙관적 락이 더 빠름
- 포인트 사용은 경합이 적을 것이고, 잔액 부족 시 빠른 실패 응답이 중요하다고 생각해 낙관적 락을 선정
- 또한 소규모(10건) 테스트에서는 두 방식 사이 소요 시간 차이가 유의미한 차이가 없었음
재고 차감
문제 정의
- 상품의 남은 재고 수량을 정확히 차감해야 함
- 여러 주문 요청이 동시에 들어올 경우 보유 재고를 초과해 차감되면 안 됨
AS-IS
@Transactional
public void decreaseStock(Long productId, int quantity) {
Stock stock = getStockByProductId(productId);
stock.decrease(quantity);
}
- 단순 조회 후 차감
TO-BE: 비관적 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(value = {
@QueryHint(name = "javax.persistence.lock.timeout", value = "5000")
})
@Query("select s from Stock s where s.product.id = :productId")
Optional<Stock> findByProductIdWithLock(Long productId);
- SELECT ... FOR UPDATE 적용해 레코드 락 설정
- 다른 트랜잭션은 락 해제 전까지 대기해 초과주문 차단
- @QueryHint timeout 사용해 락 대기 시간 설정
결과 및 선정 이유
- 재고는 여러 사용자가 동시에 접근할 가능성이 높음
- 하나라도 초과 주문이 발생하면 전체 데이터에 미치는 영향이 큼
- 순차처리 보장이 필수이므로 비관적 락 선택
마무리
이번 주는 DB의 기본 동시성 제어 만으로 문제 방지가 가능한지에 대해 검증하는 주차였다. 락 적용 및 테스트에 앞서, 활용되는 DB의 락 메커니즘과 트랜잭션, 격리 수준에 대해 정리하고 들어가니 각 락 전략의 장단점을 더 잘 이해할 수 있었다.
실제 동시성 테스트에서도 기대한 결과를 얻었고 락 전략만 잘 골라도 단일 인스턴스 환경에서는 안정적으로 정합성을 보장할 수 있음을 확인했다. 다만 아직은 멀티 인스턴스를 가정하지 않았기 때문에 이후에 Redis 기반 분산락이나 메시지 큐 등을 추가로 검토해서 적용해 볼 계획이다.
추가로 낙관적 락에서 재시도 횟수나, 대기 시간 그리고 비관적 락에서 대기 시간 설정값에 대한 기준을 어떻게 가져가야 할 지도 더 고민해 볼 지점인 것 같다. 특히나 @QueryHint 에 적용한 timeout 의 경우에는 DBMS 종류에 따라 적용되지 않을 수 있어서 주의해서 사용해야할 것 같다. 아직은 명확한 기준이 없어서 일단은 예제값을 따랐지만 어떤 기준으로 설정하는지 좀 더 공부해보고 변경해 적용해 볼 계획이다.
'Study > Database' 카테고리의 다른 글
| MySQL InnoDB와 트랜잭션 (3) | 2025.08.08 |
|---|---|
| 트랜잭션과 격리 수준 (2) | 2025.08.08 |