Post

비관적 락 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. 처리량 병목

    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가 겹칠 경우에도 처리량에 문제가 될 수 있습니다.

  2. 데드락

    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("잠시 후 다시 시도해주세요.");
    	}
    }
    

문제점

  1. 충돌이 많은 경우 재시도 증가

    낙관적 락은 충돌이 드문 경우 사용이 효율적인데, 충돌이 많은 경우 재시도 횟수와 SELECT + UPDATE가 쿼리가 증가하면서 비관적 락보다 DB 부하가 더 심해질 수 있습니다.

  2. 복잡한 로직

    어플리케이션내에서 직접 처리해줘야 해서 복잡한 재시도 로직들을 구현해야합니다.

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