비관적 락 vs 낙관적 락
비관적 락과 낙관적 락이 뭔지와 어떻게 락을 걸 수 있는지 어떤 차이점이 있는지 알아보도록 하겠습니다.
동시성 문제를 해결하기 위해서 DB 레벨에서 해결하는 경우가 있습니다. 비관적 락과 낙관적 락으로 해결할 수 있는 방법을 설명합니다.
재고가 100개인 상품을 동시에 사용자 1000명이 요청이 왔을때의 시나리오로 진행하겠습니다.
비관적 락
비관적 락을 알려면 배타적 락과 공유 락을 먼저 이해해야합니다.
배타적 락(Exclusive Lock / X-Lock)
락을 획득한 트랜잭션만 해당 데이터에 읽기/쓰기 모두 독점합니다. 다른 트랜잭션은 락이 해제될 때 까지
X-Lock, S-Lock 모두 대기합니다.
1
2
3
4
5
6
7
TX 1 ──────────────────────────────────────────────────────────▶
🔒 X-Lock 획득 📖 재고 확인 & 사용 ✅ COMMIT (락 해제)
(FOR UPDATE)
TX 2 ──────────────────────────────────────────────────────────▶
⏳ 대기중... ⏳ 대기중... 🔒 X-Lock 획득
(읽기도 차단됨) (이미 사용됨 감지)
공유 락(Shared Lock / S-Lock)
여러 트랜잭션이 동시에 읽기는 허용하되, 누군가 읽는 동안 쓰기는 차단합니다. “읽는 동안 데이터가 바뀌지 않음”을 보장합니다. X-Lock 대기, S-Lock 허용합니다.
1
2
3
4
5
6
7
8
9
10
11
TX 1 ──────────────────────────────────────────▶
🔑 S-Lock 획득 📖 재고 읽기...
(FOR SHARE)
TX 2 ──────────────────────────────────────────▶
🔑 S-Lock 획득 ✅ 📖 재고 읽기...
(동시 허용!) (TX1과 동시에 실행됨!)
TX 3 (쓰기 시도) ──────────────────────────────▶
⏳ X-Lock 대기중...
(S-Lock이 모두 해제될 때까지 쓰기 불가)
동시성 문제를 해결하기 위해 비관적 락을 사용하면 배타적 락을 걸어 동시성 문제를 해결 할 수 있습니다.
1
2
3
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM Inventory i WHERE i.product.id = :productId")
Optional<Inventory> findByProductIdWithPessimisticLock(Long productId);
문제점
처리량 병목
1 2 3 4 5 6 7 8 9
사용자 10,000명 동시 요청 ↓ [ X-Lock 대기열 ] TX1 처리중... (50ms) TX2 ⏳ 대기 TX3 ⏳ 대기 TX4 ⏳ 대기 ... TX10000 ⏳ 대기 (최대 50ms × 10,000 = 500초 대기)한번에 딱 1개의 트랜잭션만 재고 row에 접근 가능하기 때문에, 동시에 요청이 많을수록 마지막 트랜잭션은 대기 시간이 증가할 수 밖에 없습니다.
1 2 3 4 5 6 7 8 9
재고 차감 (X-Lock) 재고 조회 (S-Lock) ↓ ↓ 같은 row 접근 시도 TX_구매1: X-Lock 보유 중... TX_조회1: ⏳ S-Lock 대기 (X-Lock 때문에 읽기도 못함) TX_조회2: ⏳ S-Lock 대기 TX_조회3: ⏳ S-Lock 대기 TX_구매2: ⏳ X-Lock 대기재고를 차감하는 row와 조회하는 row가 겹칠 경우에도 처리량에 문제가 될 수 있습니다.
데드락
1 2
TX1: 상품A 락 획득 → 상품B 락 대기 TX2: 상품B 락 획득 → 상품A 락 대기
두 트랜잭션이 묶음으로 서로 다른 상품을 위에 시나리오 처럼 구매하는 경우 데드락이 발생할 수 있습니다.
낙관적 락
동시성문제를 어플리케이션에서 처리하여 데드락과 성능문제를 풀어볼 수 있습니다.
row별로 버전 컬럼을 사용하여 동시성 문제를 해결합니다.
1
🔓 DB 락 없이 조회 → ✏️ @Version으로 체크 → 💥 충돌 시 예외 → 🔄 앱에서 재시도
1 2 3 4 5 6 7 8 9
public class Inventory { ... @Version @Column(nullable = false) @Builder.Default private Long version = 0L; }
1 2 3
@Lock(LockModeType.OPTIMISTIC) @Query("SELECT i FROM Inventory i WHERE i.product.id = :productId") Optional<Inventory> findByProductIdWithOptimisticLock(Long productId);
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 29 30 31 32 33 34 35 36
public class CreateOrderWithOptimisticLockUseCase { private final OrderRepository orderRepository; private final UserRepository userRepository; private final InventoryRepository inventoryRepository; public record Input(@NotNull Long userId, @NotNull Long productId) { } @Retryable( retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 100) ) public void execute(Input input) { User user = userRepository.findById(input.userId()) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); Inventory inventory = inventoryRepository.findByProductIdWithOptimisticLock(input.productId()) .orElseThrow(() -> new IllegalArgumentException("재고를 찾을 수 없습니다")); inventory.getStockQuantity().decreaseStockQuantity(); Order order = Order.builder() .product(inventory.getProduct()) .user(user) .build(); orderRepository.save(order); } @Recover public void recover(ObjectOptimisticLockingFailureException e, Input input) { log.warn("낙관적 락 재시도 모두 실패: productId={}",input.productId()); throw new RuntimeException("잠시 후 다시 시도해주세요."); } }
문제점
충돌이 많은 경우 재시도 증가
낙관적 락은 충돌이 드문 경우 사용이 효율적인데, 충돌이 많은 경우 재시도 횟수와 SELECT + UPDATE가 쿼리가 증가하면서 비관적 락보다 DB 부하가 더 심해질 수 있습니다.
복잡한 로직
어플리케이션내에서 직접 처리해줘야 해서 복잡한 재시도 로직들을 구현해야합니다.