두 사용자가 동시에 같은 상품의 재고를 차감하면, 재고가 정확히 줄어들까?

동시성 문제는 코드만 봐서는 발견이 어렵습니다. 단일 스레드에서는 완벽하게 동작하던 로직이, 트래픽이 몰리는 순간 데이터가 꼬이기 시작합니다. JPA에서는 이 문제를 낙관적 락(Optimistic Lock) 과 비관적 락(Pessimistic Lock) 두 가지 전략으로 해결합니다.

동시성 제어가 필요한 이유

간단한 재고 차감 시나리오를 보겠습니다.

JAVA
// 재고 차감 로직
public void decreaseStock(Long productId) {
    Product product = productRepository.findById(productId).orElseThrow();
    product.decreaseStock(1); // stock -= 1
}

두 스레드가 동시에 이 메서드를 실행하면 어떻게 될까요?

PLAINTEXT
시간  스레드A                    스레드B
─────────────────────────────────────────────
t1   조회: stock = 10
t2                               조회: stock = 10
t3   stock = 9, UPDATE
t4                               stock = 9, UPDATE  ← 갱신 분실!

두 번 차감했는데 재고는 9가 됩니다. 이것이 ** 갱신 분실(Lost Update)** 문제입니다. 공부하다 보니 단순한 시나리오인데도 실무에서 정말 자주 발생하는 버그더라고요.

낙관적 락 — @Version

동작 원리

낙관적 락은 "대부분의 트랜잭션은 충돌하지 않을 것"이라고 ** 낙관적으로** 가정합니다. 실제 DB 락을 걸지 않고, 엔티티에 ** 버전 필드 **를 추가하여 수정 시점에 충돌을 감지합니다.

JAVA
@Entity
public class Product {

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

    private String name;
    private int stock;

    @Version  // 핵심 — 이 필드가 충돌 감지 역할
    private Long version;

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

@Version을 붙이면 JPA가 UPDATE 시 자동으로 버전 조건을 추가합니다.

SQL
-- JPA가 생성하는 실제 SQL
UPDATE product
SET stock = 9, version = 2
WHERE id = 1 AND version = 1   -- 버전이 달라지면 0 rows affected

핵심은 WHERE 절에 version이 포함된다는 점입니다. 내가 읽은 시점의 버전과 현재 DB의 버전이 다르면 UPDATE가 적용되지 않고, JPA는 OptimisticLockException을 발생시킵니다.

@Version이 지원하는 타입

  • int / Integer
  • long / Long
  • short / Short
  • java.sql.Timestamp

보통 Long 타입을 가장 많이 사용합니다. Timestamp는 밀리초 단위 충돌 가능성이 있어서 권장하지 않습니다.

OptimisticLockException 처리와 재시도

낙관적 락의 핵심은 ** 충돌 후 재시도 **입니다. 예외가 발생하면 다시 조회해서 다시 시도하는 로직이 반드시 필요합니다.

Spring Retry를 활용한 재시도

수동으로 반복문을 작성하는 대신 spring-retry를 사용하면 깔끔합니다.

JAVA
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Retryable(
        retryFor = OptimisticLockingFailureException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 100)  // 100ms 간격으로 재시도
    )
    @Transactional
    public void decreaseStock(Long productId) {
        Product product = productRepository.findById(productId).orElseThrow();
        product.decreaseStock(1);
    }

    @Recover  // 모든 재시도가 실패하면 호출
    public void recover(OptimisticLockingFailureException e, Long productId) {
        throw new RuntimeException("재고 차감에 실패했습니다. productId=" + productId, e);
    }
}

주의할 점이 있습니다. @Retryable@Transactional을 같은 메서드에 붙이면 재시도 시 ** 같은 트랜잭션 **이 재활용될 수 있습니다. 트랜잭션 전파 설정이 REQUIRES_NEW가 아니라면 @Retryable은 트랜잭션 바깥에서 동작하도록 클래스를 분리하는 것이 안전합니다.

비관적 락 — SELECT FOR UPDATE

동작 원리

비관적 락은 "충돌이 발생할 것"이라고 ** 비관적으로** 가정합니다. 데이터를 조회하는 시점에 DB 레벨에서 행 잠금을 걸어 다른 트랜잭션이 해당 행을 수정하지 못하게 합니다.

PESSIMISTIC_READ vs PESSIMISTIC_WRITE

락 모드SQL설명
PESSIMISTIC_READSELECT ... FOR SHARE공유 락 — 다른 트랜잭션이 읽을 수는 있지만 수정 불가
PESSIMISTIC_WRITESELECT ... FOR UPDATE배타 락 — 다른 트랜잭션이 읽기/수정 모두 차단
PESSIMISTIC_FORCE_INCREMENTSELECT ... FOR UPDATE + 버전 증가배타 락 + @Version 필드도 함께 증가

실무에서는 거의 대부분 PESSIMISTIC_WRITE를 사용합니다. PESSIMISTIC_READ는 DB마다 지원 여부와 동작이 달라서 주의가 필요합니다.

Spring Data JPA에서 @Lock 사용하기

Spring Data JPA는 @Lock 어노테이션으로 락 모드를 간단하게 지정할 수 있습니다.

JAVA
public interface ProductRepository extends JpaRepository<Product, Long> {

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

    // 낙관적 락 (강제 버전 증가)
    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithOptimisticLock(@Param("id") Long id);
}

비관적 락을 사용하는 서비스 코드는 이렇게 됩니다.

JAVA
@Service
@RequiredArgsConstructor
public class PessimisticStockService {

    private final ProductRepository productRepository;

    @Transactional
    public void decreaseStock(Long productId) {
        // SELECT ... FOR UPDATE 실행 — 다른 트랜잭션은 이 행을 수정할 수 없다
        Product product = productRepository
            .findByIdWithPessimisticLock(productId)
            .orElseThrow();

        product.decreaseStock(1);
        // 트랜잭션 커밋 시 락이 해제된다
    }
}

비관적 락은 트랜잭션이 끝날 때까지 락을 유지합니다. 따라서 트랜잭션 범위를 최대한 짧게 유지하는 것이 성능의 핵심입니다. 트랜잭션 안에서 외부 API 호출 같은 느린 작업을 절대 넣으면 안 됩니다.

락 타임아웃 설정

비관적 락은 다른 트랜잭션이 락을 잡고 있으면 대기합니다. 무한정 대기하면 장애로 이어질 수 있으므로 타임아웃을 반드시 설정해야 합니다.

@QueryHints로 설정

JAVA
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
        @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")  // 3초
    })
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
}

타임아웃을 초과하면 PessimisticLockException이 발생합니다. 단, MySQL의 InnoDB는 lock.timeout 힌트를 무시 하고 innodb_lock_wait_timeout 전역 설정을 따릅니다. DB별로 동작이 다르니 반드시 확인해야 합니다.

데드락 시나리오와 예방 전략

데드락이 발생하는 전형적인 패턴

PLAINTEXT
트랜잭션A                         트랜잭션B
───────────────────────────────────────────
LOCK product(id=1)
                                   LOCK product(id=2)
LOCK product(id=2) ← 대기
                                   LOCK product(id=1) ← 대기
───────────────────────────────────────────
→ 서로 상대방의 락을 기다리며 교착 상태!

예방 전략

1. 자원 접근 순서를 통일한다

가장 기본적이면서도 효과적인 방법입니다. 여러 행에 락을 걸어야 할 때 항상 ID 오름차순으로 접근하면 순환 대기가 발생하지 않습니다.

JAVA
@Transactional
public void transfer(Long fromId, Long toId, int amount) {
    // 항상 작은 ID부터 락을 건다
    Long firstId = Math.min(fromId, toId);
    Long secondId = Math.max(fromId, toId);

    Product first = productRepository.findByIdWithPessimisticLock(firstId).orElseThrow();
    Product second = productRepository.findByIdWithPessimisticLock(secondId).orElseThrow();

    // 비즈니스 로직 수행
    if (fromId.equals(firstId)) {
        first.decreaseStock(amount);
        second.increaseStock(amount);
    } else {
        second.decreaseStock(amount);
        first.increaseStock(amount);
    }
}

2. 트랜잭션을 짧게 유지한다

락을 잡고 있는 시간이 길수록 데드락 확률이 올라갑니다. 트랜잭션 안에서 꼭 필요한 작업만 수행합니다.

3. 락 타임아웃을 설정한다

데드락이 발생하더라도 타임아웃으로 빠르게 실패시킨 뒤 재시도하는 것이 무한 대기보다 낫습니다.

4. 인덱스를 확인한다

비관적 락은 인덱스를 타지 않으면 ** 테이블 전체에 락 **이 걸릴 수 있습니다. WHERE 조건에 사용하는 컬럼에 인덱스가 있는지 반드시 확인해야 합니다.

어떤 락을 선택해야 할까

기준낙관적 락비관적 락
** 충돌 빈도**낮을 때 적합높을 때 적합
** 성능**DB 락 없음 → 높음DB 락 대기 → 상대적으로 낮음
** 구현 복잡도**재시도 로직 필요비교적 단순
** 데드락 위험**없음있음
** 사용 예시**게시글 수정, 프로필 업데이트재고 차감, 포인트 차감, 좌석 예약
** 실패 방식**예외 발생 후 재시도대기 후 처리

판단 기준을 정리하면 이렇습니다.

  • "충돌이 거의 없다" → 낙관적 락. 성능 이점이 크고 DB 부하가 적습니다.
  • "충돌이 빈번하다" → 비관적 락. 재시도 비용보다 한 번에 처리하는 것이 효율적입니다.
  • "데이터 정합성이 절대적으로 중요하다" → 비관적 락. 금융, 결제 같은 도메인에서 주로 사용합니다.
  • "높은 동시성 + 정합성 둘 다 필요하다" → 비관적 락 + 짧은 트랜잭션 + 타임아웃 조합을 고려합니다.

Named Lock — 애플리케이션 레벨 락

MySQL에서 제공하는 GET_LOCK 함수를 활용하면 행 단위가 아닌 ** 이름 기반의 락 **을 구현할 수 있습니다.

JAVA
public interface LockRepository extends JpaRepository<Product, Long> {

    @Query(value = "SELECT GET_LOCK(:key, :timeout)", nativeQuery = true)
    Integer getLock(@Param("key") String key, @Param("timeout") int timeout);

    @Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
    Integer releaseLock(@Param("key") String key);
}
JAVA
@Component
@RequiredArgsConstructor
public class NamedLockFacade {

    private final LockRepository lockRepository;
    private final ProductService productService;

    @Transactional
    public void decreaseStock(Long productId) {
        try {
            // 락 획득 (타임아웃 3초)
            lockRepository.getLock("STOCK_" + productId, 3);
            // 실제 비즈니스 로직은 별도 트랜잭션에서 수행
            productService.decreaseStock(productId);
        } finally {
            // 반드시 락을 해제한다
            lockRepository.releaseLock("STOCK_" + productId);
        }
    }
}

Named Lock은 분산 환경에서도 동작하지만, DB 커넥션을 하나 더 점유 한다는 단점이 있습니다. 커넥션 풀이 고갈될 수 있으므로 락 전용 DataSource를 분리하는 것이 일반적입니다. 분산 환경이라면 Redis 기반의 Redisson 분산 락도 함께 고려해 보세요.

정리

  • **낙관적 락 **: @Version 필드를 통한 충돌 감지. 실제 DB 락을 걸지 않아 성능이 좋지만 충돌 시 재시도 로직이 필수입니다.
  • ** 비관적 락 **: SELECT FOR UPDATE로 행을 잠금. 충돌을 원천 차단하지만 데드락 위험과 성능 저하를 감수해야 합니다.
  • ** 데드락 예방 **: 자원 접근 순서 통일, 짧은 트랜잭션, 타임아웃 설정이 기본입니다.
  • Named Lock: 행 단위가 아닌 이름 기반 락. 유연하지만 커넥션 관리에 주의가 필요합니다.

결국 "어떤 락이 더 좋다"는 없습니다. 충돌 빈도, 데이터 중요도, 시스템 특성을 종합적으로 판단해서 선택해야 합니다. 공부하면서 느낀 건, 락 전략보다 ** 트랜잭션 범위를 최소화하는 것 **이 모든 전략에서 공통적으로 중요하다는 점이었습니다.

댓글 로딩 중...