반응형

어플리케이션을 개발하다 보면 낙관적 락, 비관적 락에 대해 들어본 적 있을 것이다

동시성 제어를 다루다 보면 한 번쯤은 마주치는 개념이다

 

락은 언제 필요한가?

여러 사용자가 동시에 같은 데이터를 수정하려고 할 때 문제가 생길 수 있다

- 재고 마이너스: 실제보다 많은 주문이 들어가서 재고가 음수로 떨어짐

- 중복 결제: 같은 주문에 대해 여러 번 결제 처리됨

- 데이터 덮어쓰기: 두 사람이 동시에 수정해서 한쪽 수정 내용이 사라짐

 

[상황]
- 상품 재고: 10개
- 사용자 A: 5개 주문
- 사용자 B: 7개 주문 (거의 동시에)

[문제]
1. A가 재고 확인: 10개 → OK
2. B가 재고 확인: 10개 → OK
3. A가 재고 차감: 10 - 5 = 5개 저장
4. B가 재고 차감: 10 - 7 = 3개 저장 (A의 차감 무시됨)

결과: 실제로는 12개가 팔렸지만 재고는 3개

 

이 처럼 동시성 문제가 발생할 수 있고, 락은 이런 상황을 방지하기 위해 사용한다

 

비관적 락 (Pessimistic Lock)

개념

"충돌이 일어날 거야. 미리 막자"

데이터를 읽는 순간 락을 건다

트랜잭션이 끝날 때까지 다른 사람은 변경을 할 수 없다

 

동작 방식

-- 1. 락을 걸면서 조회 (SELECT ... FOR UPDATE)
SELECT * FROM product WHERE id = 1 FOR UPDATE;

-- 이 시점에 다른 트랜잭션에서:
-- 일반 조회 (SELECT)? → 가능! 락 안 걸린 조회는 문제없음
-- 락을 건 조회 (SELECT ... FOR UPDATE)? → 대기
-- 수정 (UPDATE)? → 대기
-- 삭제 (DELETE)? → 대기

-- 2. 안전하게 재고 차감
UPDATE product SET stock = stock - 1 WHERE id = 1;

-- 3. 커밋하면 락 자동 해제
COMMIT;

 

이 키워드가 붙으면 해당 행에 배타적 락(Exclusive Lock)이 걸린다

 

FOR UPDATE로 비관락을 사용하게 되면 다른 트랜잭션의 동일 로직이 수행될 때 락이 해제될 때 까지 대기하게 된다

 

// 비관적 쓰기 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);

 

JPA에서는 Lock 어노테이션에 Type을 PESSIMISTIC_WRITE로 지정하면 비관적 락을 적용할 수 있다

 

비관적 락이 안전한가?

비관적 락에 대한 내용을 보면 FOR UPDATE가 모든 것을 막아줄 것 같고, 해결해줄 것 같아보인다

 

FOR UPDATE는 조회 제한하지 않는다

 

조회를 제한하지 않기 때문에 FOR UPDATE로 락을 건 이후에 다른 로직에서 해당 데이터를 조회하여 업데이트를 시도한다면 락이 해제되는 순간 데이터가 변경되게 되어 Lost Update 문제가 발생하게 된다

 

// 로직 A와 B는 별개의 비즈니스 로직이라고 가정

// 트랜잭션 A
// 로직 A
@Transactional
fun processOrder(productId: Long, quantity: Int) {
    val product = productRepository.findByIdForUpdate(productId)
    log.info("트랜잭션 A - 조회한 재고: {}", product.stock) // 100
    
    product.decreaseStock(quantity) // 100 - 50 = 50
    productRepository.save(product)
    log.info("트랜잭션 A - 저장할 재고: {}", product.stock) // 50
    // 커밋 시점에 재고 = 50으로 저장
}

// 트랜잭션 B
// 로직 B
@Transactional
fun updatePrice(productId: Long, quantity: Int) {
    val product = productRepository.findById(productId).orElseThrow() // 일반 SELECT!
    log.info("트랜잭션 B - 조회한 재고: {}", product.stock) // 100 (락 없이 조회)
    
    product.decreaseStock(quantity) // 100 - 30 = 70
    productRepository.save(product)
    log.info("트랜잭션 B - 저장할 재고: {}", product.stock) // 70
    // ⚠️ 커밋 시점에 재고 = 70으로 덮어씀!
}

 

위와 같은 Lost Update 문제가 발생하여 정합성에 문제가 생길 수 있다

비관적 락을 사용한다면 위와 같은 문제를 고민해보고 사용해야 한다

 

낙관적 락 (Optimistic Lock)

개념

"충돌은 잘 안 일어나. 일단 믿고 진행하자"

데이터를 읽을 때는 락을 걸지 않는다

수정할 때 버전을 확인해서 변경되었으면 실패 처리한다

 

동작 방식

-- 1. 버전과 함께 조회 (락 없음)
SELECT id, stock, version FROM product WHERE id = 1;
-- 결과: id=1, stock=100, version=1

-- 2. 업데이트 시 버전 확인
UPDATE product 
SET stock = 99, version = 2 
WHERE id = 1 AND version = 1;

-- 만약 다른 트랜잭션이 처리되며 version을 2로 바꿨다면?
-- → 영향받은 행 수 = 0 (업데이트 실패)
-- → OptimisticLockException 발생

 

버전 필드를 추가하여 데이터 변경 시마다 버전을 증가시킨다

업데이트 시 조회했던 버전과 현재 버전이 같은지 확인하고, 다르면 누군가 먼저 수정한 것이므로 실패 처리한다

 

@Entity
class Product(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    
    var stock: Int,
    
    @Version
    var version: Long = 0
) {
    fun decreaseStock(quantity: Int) {
        require(stock >= quantity) { "재고 부족" }
        stock -= quantity
    }
}

interface ProductRepository : JpaRepository<Product, Long>

// Service
@Transactional
fun processOrder(productId: Long, quantity: Int) {
    val product = productRepository.findById(productId).orElseThrow()
    product.decreaseStock(quantity)
    productRepository.save(product)
    // version이 변경되었다면 OptimisticLockException 발생
}

 

JPA에서는 엔티티에 @Version 어노테이션만 추가하면 낙관적 락이 자동으로 적용된다

 

낙관적 락의 재시도 처리

낙관적 락은 충돌이 발생하면 예외가 발생하므로, 고객 경험이나 비즈니스적으로 필요한 경우 재시도 로직이 필요하다

 

비관적 락 vs 낙관적 락

비관적 락은 언제 쓸까?

  • 충돌이 자주 발생하는 경우: 동시 수정이 잦은 데이터
  • 충돌 비용이 큰 경우: 재시도하기 어려운 중요한 작업 (결제, 송금)
  • 트랜잭션이 짧은 경우: 락을 오래 잡고 있지 않아도 되는 경우

장점: 데이터 정합성 보장이 확실함
단점: 락 대기로 인한 성능 저하, 데드락 위험

낙관적 락은 언제 쓸까?

  • 충돌이 드문 경우: 동시 수정 확률이 낮은 데이터
  • 읽기가 많은 경우: 조회는 많지만 수정은 적은 경우
  • 응답 속도가 중요한 경우: 락 대기 없이 빠른 응답이 필요한 경우

장점: 락을 걸지 않아 성능이 좋음, 데드락 없음
단점: 충돌 시 재시도 필요, 재시도 로직 구현 필요

 

마무리

동시성 제어에 정답은 없다

 

비관적 락은 확실하지만 자원을 계속 점유한다

낙관적 락은 빠르지만 재시도가 필요하다

각자 trade-off가 있을 뿐이다

 

그렇다면 어떻게 선택할까?

  • 충돌이 얼마나 자주 일어나는가
  • 충돌이 일어났을 때 비용이 얼마나 큰가

처음부터 완벽하게 선택할 필요는 없다

낙관적 락으로 시작해서 충돌률을 보고, 필요하면 비관적 락으로 바꾸거나 분산락이나 다른 락들을 고민해보면 된다

반응형

+ Recent posts