1. 동시에 같은 데이터를 수정하면 문제가 생긴다

여러 요청이 같은 데이터를 동시에 읽고 수정하면 한 요청의 변경 내용이 사라질 수 있다. 예를 들어 재고가 10개일 때 두 사용자가 동시에 1개씩 주문한다고 생각해 보자.

요청 A: 재고 10 조회 → 1개 차감 → 재고 9 저장
요청 B: 재고 10 조회 → 1개 차감 → 재고 9 저장

두 번의 주문이 처리됐지만 최종 재고는 8이 아니라 9가 된다. 두 요청이 같은 값을 읽은 뒤 각자 계산한 결과를 저장하면서, 나중에 저장한 값이 앞선 변경을 덮어쓴 것이다. 이를 갱신 손실(Lost Update) 이라고 한다.

낙관적 락(Optimistic Lock)비관적 락(Pessimistic Lock) 은 이런 동시성 충돌을 다루는 대표적인 방법이다. 이름에 락이 들어가지만 두 방식이 데이터를 보호하는 시점과 방법은 다르다.


2. 낙관적 락은 충돌이 드물다고 가정한다

낙관적 락은 여러 요청이 동시에 작업하도록 허용하고, 데이터를 저장하는 시점에 다른 요청이 먼저 수정했는지 확인한다. 충돌이 자주 발생하지 않을 것이라고 보는 방식이다.

일반적으로 데이터에 버전 값을 두고 조회한 버전과 수정 시점의 버전을 비교한다.

1. 요청 A와 B가 version = 1인 상품을 조회한다.
2. 요청 A가 version = 1 조건으로 수정하고 version을 2로 올린다.
3. 요청 B도 version = 1 조건으로 수정을 시도한다.
4. 현재 version은 이미 2이므로 요청 B의 수정은 실패한다.

개념적으로는 다음과 같은 SQL이 실행된다.

UPDATE product
SET stock = 9,
    version = version + 1
WHERE id = 1
  AND version = 1;

조건과 일치하는 행이 없다면 다른 트랜잭션이 먼저 데이터를 변경했다는 뜻이다. 애플리케이션은 이 충돌을 사용자에게 알리거나, 최신 데이터를 다시 읽어 작업을 재시도할 수 있다.

JPA의 @Version

JPA에서는 엔티티의 버전 필드에 @Version을 붙여 낙관적 락을 적용할 수 있다.

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private int stock;

    @Version
    private Long version;

    public void decreaseStock(int quantity) {
        if (stock < quantity) {
            throw new IllegalStateException("재고가 부족합니다.");
        }
        stock -= quantity;
    }
}

JPA는 엔티티를 수정할 때 조회 당시의 버전을 조건에 포함하고, 수정에 성공하면 버전을 증가시킨다. 이미 버전이 바뀌었다면 낙관적 락 예외가 발생한다.

@Transactional
public void order(Long productId, int quantity) {
    Product product = productRepository.findById(productId)
        .orElseThrow();

    product.decreaseStock(quantity);
}

충돌 시 재시도할 수 있지만 무조건 반복해서는 안 된다. 최대 재시도 횟수를 정하고, 외부 결제나 메시지 발행처럼 중복 실행되면 안 되는 작업은 재시도 범위와 분리하거나 멱등성을 보장해야 한다.


3. 비관적 락은 충돌이 발생한다고 가정한다

비관적 락은 데이터를 조회할 때부터 다른 트랜잭션의 접근을 제한한다. 충돌 가능성이 높으므로 먼저 데이터베이스 락을 획득한 뒤 작업하는 방식이다.

대표적인 SQL은 SELECT ... FOR UPDATE이다.

SELECT id, stock
FROM product
WHERE id = 1
FOR UPDATE;

요청 A가 해당 행의 락을 획득하면 요청 B는 A의 트랜잭션이 커밋되거나 롤백되어 락이 해제될 때까지 기다린다. 따라서 같은 행을 수정하는 작업이 순서대로 처리된다.

JPA의 PESSIMISTIC_WRITE

Spring Data JPA에서는 조회 메서드에 PESSIMISTIC_WRITE를 지정할 수 있다.

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Product p where p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}

락은 트랜잭션 동안 유지되므로 조회와 변경을 하나의 트랜잭션 안에서 실행해야 한다.

@Transactional
public void order(Long productId, int quantity) {
    Product product = productRepository.findByIdForUpdate(productId)
        .orElseThrow();

    product.decreaseStock(quantity);
}

비관적 락은 충돌을 미리 차단하므로 이해하기 쉽지만, 대기하는 요청이 늘면 처리량이 떨어질 수 있다. 여러 트랜잭션이 서로 필요한 락을 잡고 기다리면 데드락이 발생할 수도 있으므로 트랜잭션을 짧게 유지하고 락 획득 순서를 일관되게 설계해야 한다.


4. 두 방식은 충돌을 처리하는 시점이 다르다

구분 낙관적 락 비관적 락
기본 가정 충돌이 드물다 충돌이 자주 발생한다
처리 시점 수정할 때 충돌 감지 조회할 때부터 충돌 차단
주요 방법 버전 값 비교 데이터베이스 행 락
다른 요청의 동작 동시에 작업한 뒤 한쪽이 실패할 수 있음 락이 해제될 때까지 대기
장점 락 대기가 적고 조회 성능에 유리 충돌이 잦아도 순차 처리가 명확함
단점 충돌 처리와 재시도 로직이 필요 대기 시간, 타임아웃, 데드락 가능성
적합한 상황 읽기가 많고 충돌이 드문 데이터 같은 데이터에 수정이 집중되는 작업

낙관적 락은 충돌을 없애는 것이 아니라 충돌을 발견하고 실패시키는 방식이다. 반대로 비관적 락은 모든 동시 요청을 막는 것이 아니라 데이터베이스가 정한 범위의 락을 사용해 특정 작업을 기다리게 한다.


5. 어떤 락을 선택해야 할까

한 가지 방식이 항상 더 좋은 것은 아니다. 실제 충돌 빈도와 실패 처리 방법을 기준으로 선택해야 한다.

낙관적 락이 어울리는 경우

  • 게시글이나 회원 정보처럼 동시에 같은 행을 수정할 가능성이 낮다.
  • 읽기 요청이 많고 수정 요청이 상대적으로 적다.
  • 충돌한 요청을 재시도하거나 사용자에게 다시 시도하도록 안내할 수 있다.
  • 긴 시간 데이터베이스 락을 유지하고 싶지 않다.

비관적 락이 어울리는 경우

  • 한정 수량 재고처럼 같은 데이터에 수정 요청이 집중된다.
  • 충돌 후 실패시키는 것보다 처음부터 순서대로 처리하는 편이 단순하다.
  • 처리 시간이 짧고 트랜잭션 범위를 명확하게 제한할 수 있다.
  • 락 대기 시간과 데드락을 모니터링할 수 있다.

처음부터 감으로 결정하기보다는 실제 충돌률, 평균 대기 시간, 재시도 횟수를 측정해야 한다. 충돌이 거의 없다면 비관적 락의 대기 비용이 불필요하고, 충돌이 매우 잦다면 낙관적 락의 반복적인 실패와 재시도가 더 큰 비용이 될 수 있다.


6. 락을 사용하지 않는 해결책도 있다

재고 차감처럼 연산을 SQL 한 문장으로 표현할 수 있다면 조건부 갱신이 더 단순할 수 있다.

UPDATE product
SET stock = stock - 1
WHERE id = 1
  AND stock > 0;

영향받은 행의 수가 1이면 차감에 성공했고, 0이면 재고가 없거나 대상이 없다는 뜻이다. 읽기와 쓰기를 분리하지 않으므로 애플리케이션에서 조회한 값이 중간에 바뀌는 문제를 줄일 수 있다.

또한 트랜잭션 격리 수준과 낙관적·비관적 락은 같은 개념이 아니다. 격리 수준은 트랜잭션끼리 변경 내용을 어느 정도 볼 수 있는지를 정하고, 락 전략은 같은 데이터를 수정하려는 경쟁을 어떻게 처리할지 정한다. 사용하는 데이터베이스와 격리 수준에 따라 실제 락의 범위와 대기 동작이 달라질 수 있으므로 운영 환경의 동작을 확인해야 한다.


7. 정리

낙관적 락은 여러 요청의 작업을 허용한 뒤 버전 값으로 충돌을 감지한다. 충돌이 드문 환경에서 락 대기를 줄일 수 있지만, 실패한 요청의 재시도와 중복 실행을 안전하게 처리해야 한다.

비관적 락은 데이터를 읽을 때 데이터베이스 락을 획득해 다른 요청을 기다리게 한다. 충돌이 잦은 작업을 순서대로 처리하기 쉽지만, 트랜잭션이 길어지면 대기 시간과 데드락 위험이 커진다.

결국 선택 기준은 이름이 아니라 충돌이 얼마나 자주 발생하는지, 실패와 대기를 시스템이 어떻게 감당할 수 있는지이다. 단순한 조건부 갱신으로 해결할 수 있는지도 함께 검토한 뒤 실제 지표를 바탕으로 결정하자.