JPA 락 전략 — 낙관적 락과 비관적 락으로 동시성을 제어하는 방법
두 사용자가 동시에 같은 상품의 재고를 차감하면, 재고가 정확히 줄어들까?
동시성 문제는 코드만 봐서는 발견이 어렵습니다. 단일 스레드에서는 완벽하게 동작하던 로직이, 트래픽이 몰리는 순간 데이터가 꼬이기 시작합니다. JPA에서는 이 문제를 낙관적 락(Optimistic Lock) 과 비관적 락(Pessimistic Lock) 두 가지 전략으로 해결합니다.
동시성 제어가 필요한 이유
간단한 재고 차감 시나리오를 보겠습니다.
// 재고 차감 로직
public void decreaseStock(Long productId) {
Product product = productRepository.findById(productId).orElseThrow();
product.decreaseStock(1); // stock -= 1
}
두 스레드가 동시에 이 메서드를 실행하면 어떻게 될까요?
시간 스레드A 스레드B
─────────────────────────────────────────────
t1 조회: stock = 10
t2 조회: stock = 10
t3 stock = 9, UPDATE
t4 stock = 9, UPDATE ← 갱신 분실!
두 번 차감했는데 재고는 9가 됩니다. 이것이 ** 갱신 분실(Lost Update)** 문제입니다. 공부하다 보니 단순한 시나리오인데도 실무에서 정말 자주 발생하는 버그더라고요.
낙관적 락 — @Version
동작 원리
낙관적 락은 "대부분의 트랜잭션은 충돌하지 않을 것"이라고 ** 낙관적으로** 가정합니다. 실제 DB 락을 걸지 않고, 엔티티에 ** 버전 필드 **를 추가하여 수정 시점에 충돌을 감지합니다.
@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 시 자동으로 버전 조건을 추가합니다.
-- 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/Integerlong/Longshort/Shortjava.sql.Timestamp
보통 Long 타입을 가장 많이 사용합니다. Timestamp는 밀리초 단위 충돌 가능성이 있어서 권장하지 않습니다.
OptimisticLockException 처리와 재시도
낙관적 락의 핵심은 ** 충돌 후 재시도 **입니다. 예외가 발생하면 다시 조회해서 다시 시도하는 로직이 반드시 필요합니다.
Spring Retry를 활용한 재시도
수동으로 반복문을 작성하는 대신 spring-retry를 사용하면 깔끔합니다.
@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_READ | SELECT ... FOR SHARE | 공유 락 — 다른 트랜잭션이 읽을 수는 있지만 수정 불가 |
PESSIMISTIC_WRITE | SELECT ... FOR UPDATE | 배타 락 — 다른 트랜잭션이 읽기/수정 모두 차단 |
PESSIMISTIC_FORCE_INCREMENT | SELECT ... FOR UPDATE + 버전 증가 | 배타 락 + @Version 필드도 함께 증가 |
실무에서는 거의 대부분 PESSIMISTIC_WRITE를 사용합니다. PESSIMISTIC_READ는 DB마다 지원 여부와 동작이 달라서 주의가 필요합니다.
Spring Data JPA에서 @Lock 사용하기
Spring Data JPA는 @Lock 어노테이션으로 락 모드를 간단하게 지정할 수 있습니다.
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);
}
비관적 락을 사용하는 서비스 코드는 이렇게 됩니다.
@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로 설정
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별로 동작이 다르니 반드시 확인해야 합니다.
데드락 시나리오와 예방 전략
데드락이 발생하는 전형적인 패턴
트랜잭션A 트랜잭션B
───────────────────────────────────────────
LOCK product(id=1)
LOCK product(id=2)
LOCK product(id=2) ← 대기
LOCK product(id=1) ← 대기
───────────────────────────────────────────
→ 서로 상대방의 락을 기다리며 교착 상태!
예방 전략
1. 자원 접근 순서를 통일한다
가장 기본적이면서도 효과적인 방법입니다. 여러 행에 락을 걸어야 할 때 항상 ID 오름차순으로 접근하면 순환 대기가 발생하지 않습니다.
@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 함수를 활용하면 행 단위가 아닌 ** 이름 기반의 락 **을 구현할 수 있습니다.
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);
}
@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: 행 단위가 아닌 이름 기반 락. 유연하지만 커넥션 관리에 주의가 필요합니다.
결국 "어떤 락이 더 좋다"는 없습니다. 충돌 빈도, 데이터 중요도, 시스템 특성을 종합적으로 판단해서 선택해야 합니다. 공부하면서 느낀 건, 락 전략보다 ** 트랜잭션 범위를 최소화하는 것 **이 모든 전략에서 공통적으로 중요하다는 점이었습니다.