[JPA] Optimistic Lock과 Pessimistic Lock
정의
동시 수정으로 데이터가 깨지는 상황을 막는 두 가지 전략:
- Optimistic (낙관적): 충돌이 드물다고 가정, 커밋 직전에 version 비교로 검증. 충돌 시 예외.
- Pessimistic (비관적): 충돌이 잦다고 가정, 읽는 순간 DB row 에 잠금. 다른 트랜잭션은 대기.
spring-data-jpa 의 ## 자주 보는 패턴 절에 간단한 예가 있다면, 이 페이지는 LockModeType 옵션과 트레이드오프를 깊이 다룬다.
Optimistic Lock: @Version
@Entity
class Product {
@Id Long id;
int stock;
@Version
Long version; // Hibernate 가 자동 관리
}
매 UPDATE 마다 WHERE id = ? AND version = ? 조건 추가, 동시에 version = version + 1:
-- 트랜잭션 A: stock 차감
UPDATE product SET stock = 9, version = 2
WHERE id = 1 AND version = 1;
-- 같은 시점 트랜잭션 B: 다른 stock 차감
UPDATE product SET stock = 8, version = 2
WHERE id = 1 AND version = 1;
-- 매칭되는 row 0개 → 0 rows affected
-- Hibernate 가 OptimisticLockException 던짐A: 1 rows affected, OK
B: 0 rows affected, throws ObjectOptimisticLockingFailureExceptionDB 락을 잡지 않음 → throughput 높음. 충돌 시 재시도 로직 필요.
@Version 필드 타입
@Version Long version; // 권장 (음수 안 됨)
@Version Integer version; // 권장
@Version Timestamp updatedAt; // 가능하지만 정밀도 / 클럭 동기화 문제
권장은 Long 또는 Integer. timestamp 는 ms 단위 동시 update 에서 깨짐.
Retry 패턴
@Retryable(
value = ObjectOptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2)
)
@Transactional
public void decreaseStock(Long productId, int qty) {
Product p = repo.findById(productId).orElseThrow();
p.setStock(p.getStock() - qty);
// commit 시 version 검증
}
spring-retry 라이브러리로 자동 재시도. 무한 retry 금지 (요청 폭주 시 폭증).
LockModeType 8가지
| Mode | 의미 |
|---|---|
NONE | lock 없음 (기본) |
OPTIMISTIC | 트랜잭션 종료 시 version 검증 |
OPTIMISTIC_FORCE_INCREMENT | 변경 없어도 version++ (강제 충돌 감지) |
READ | = OPTIMISTIC (legacy 이름) |
WRITE | = OPTIMISTIC_FORCE_INCREMENT (legacy) |
PESSIMISTIC_READ | SELECT ... FOR SHARE (공유 lock) |
PESSIMISTIC_WRITE | SELECT ... FOR UPDATE (배타 lock) |
PESSIMISTIC_FORCE_INCREMENT | pessimistic + version++ |
대부분 OPTIMISTIC, OPTIMISTIC_FORCE_INCREMENT, PESSIMISTIC_WRITE 만 실무에서 쓰임.
OPTIMISTIC_FORCE_INCREMENT
연관 entity 의 변경이 부모의 version 도 올려야 할 때:
@Entity
class Order {
@Version Long version;
@OneToMany(mappedBy = "order")
List<OrderItem> items;
}
@Transactional
public void addItem(Long orderId, Product p) {
Order o = em.find(Order.class, orderId, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
o.getItems().add(new OrderItem(o, p));
// OrderItem INSERT 만 일어나도 Order.version 증가
}
addItem 두 트랜잭션이 동시 실행되면 둘 다 version 증가 시도 → 한 쪽이 실패. 이게 없으면 둘 다 통과해 race 가능.
Pessimistic Lock
DB 에서 직접 row 잠금:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
생성되는 SQL:
-- PostgreSQL / MySQL InnoDB
SELECT * FROM product WHERE id = ? FOR UPDATE;
-- Oracle
SELECT * FROM product WHERE id = ? FOR UPDATE;
다른 트랜잭션이 같은 row 에 FOR UPDATE / UPDATE / DELETE 시도하면 대기. lock 보유자가 commit/rollback 하면 해제.
Timeout
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
@QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000") // 5초
})
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
대기 시간 초과 시 LockTimeoutException. timeout 0 은 즉시 실패 (NOWAIT).
DB 별 지원:
- PostgreSQL:
FOR UPDATE NOWAIT,FOR UPDATE SKIP LOCKED - MySQL 8+:
FOR UPDATE NOWAIT,FOR UPDATE SKIP LOCKED - Oracle:
FOR UPDATE NOWAIT,FOR UPDATE SKIP LOCKED
SKIP LOCKED 는 큐 패턴에 유용 (lock 잡힌 row 는 건너뛰고 다음 가져옴).
Optimistic vs Pessimistic 선택
| 항목 | Optimistic | Pessimistic |
|---|---|---|
| 충돌 빈도 | 드물 때 | 잦을 때 |
| DB 락 | 없음 | 있음 |
| Throughput | 높음 | 낮음 |
| Deadlock 위험 | 없음 | 있음 |
| 재시도 로직 | 필요 | 불필요 |
| 사용자 경험 | 다시 시도 요청 가능 | 대기 |
| 코드 복잡도 | retry 필요 | 단순 |
규칙:
- 읽기 중심 + 충돌 드뭄: Optimistic
- 재고 차감, 좌석 예약 같은 hot row: Pessimistic
- 분산 환경 + 외부 시스템 호출: 두 가지 다 부족, distributed lock (Redis, Zookeeper) 고려
흔한 실수: 같은 트랜잭션 안 재조회
@Transactional
public void wrong(Long id) {
Product p1 = repo.findById(id).orElseThrow();
// ... 시간 걸리는 작업 ...
Product p2 = repo.findById(id).orElseThrow(); // 1차 캐시에서 가져옴, p1 == p2
p2.setStock(...);
}
1차 캐시 (spring-jpa-persistence-context) 때문에 두 번째 find 는 SQL 안 나감 = stale 데이터. 트랜잭션 시작 시점의 stock 으로 계산되는 함정. 트랜잭션을 짧게 유지하거나 em.refresh() 호출.
Optimistic Lock 충돌 흐름
sequenceDiagram
autonumber
participant A as 트랜잭션 A
participant DB as PostgreSQL
participant B as 트랜잭션 B
A->>DB: SELECT stock, version=1
B->>DB: SELECT stock, version=1
A->>DB: UPDATE SET stock=9, version=2 WHERE version=1
DB-->>A: 1 row updated
B->>DB: UPDATE SET stock=8, version=2 WHERE version=1
DB-->>B: 0 rows updated
B-->>B: OptimisticLockException 발생
Note over B: Retryable 재시도
B->>DB: SELECT stock, version=2
B->>DB: UPDATE SET stock=8, version=3 WHERE version=2
DB-->>B: 1 row updated
분산 환경 고려
JPA Locking 은 단일 DB instance 전제. 서비스 규모가 커지면:
| 상황 | 한계 | 해결책 |
|---|---|---|
| DB 샤딩 / 분산 | row-level lock 범위 초과 | Redis SETNX 또는 ZooKeeper |
| 마이크로서비스 | 트랜잭션 경계 없음 | Saga 패턴 + 보상 트랜잭션 |
| 캐시 불일치 | version 이 캐시에도 존재 | Cache-aside + TTL 단축 |
| 수평 확장 Pod | DB 연결 수 증가 | Connection Pool 최적화 |
CAUTION
분산 환경에서 @Version 만으로는 크로스 서비스 동시성 문제를 막을 수 없다. 외부 분산 락 또는 Saga 패턴을 병행 검토.
Redis 분산 락 (Redisson 예시)
@Autowired RedissonClient redisson;
public void decreaseStockWithLock(Long productId, int qty) {
RLock lock = redisson.getLock("product:lock:" + productId);
try {
if (lock.tryLock(3, 5, TimeUnit.SECONDS)) {
decreaseStock(productId, qty); // 내부에서 JPA 트랜잭션
} else {
throw new RuntimeException("Lock 획득 실패");
}
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}
Redis SET NX PX 기반. 단일 Redis 노드 장애 시 lock 소실 위험 → Redlock (N 개 Redis 과반 동의).
Version 관리 주의사항
읽기 전용 API 에서 version 노출 금지
// DTO 에 version 포함 (위험)
record ProductResponse(Long id, int stock, Long version) {}
// version 없이 (안전)
record ProductResponse(Long id, int stock) {}
클라이언트가 stale version 을 기반으로 수정 요청을 보내면 예상치 못한 OptimisticLockException. API 설계 시 version 은 내부 구현 상세로 취급.
벌크 업데이트와 version
// 벌크 UPDATE 는 @Version 우회 (주의!)
@Modifying
@Query("UPDATE Product p SET p.stock = p.stock - :qty WHERE p.id = :id")
int decreaseStockBulk(@Param("id") Long id, @Param("qty") int qty);
// version 검증 없이 직접 SQL → Optimistic Lock 의미 없음
JPQL 벌크 업데이트는 영속성 컨텍스트를 우회. 의도적 최적화가 아니면 개별 엔티티 업데이트 사용.
함정과 베스트 프랙티스
- 재고 차감 / 카운터 같은 lost update 회피에는 둘 다 가능
- read-heavy: optimistic + retry
- write-heavy: pessimistic
- 재시도는 idempotent 작업에만: 결제 API 호출 후 retry 면 중복 결제
OptimisticLockException은 클라이언트에게 적절히 메시징: “다른 사람이 먼저 수정했습니다, 새로 고침 후 다시 시도”- pessimistic lock + long transaction = deadlock 폭탄: 트랜잭션 짧게
@Lock은 query method 에 적용, 메서드 호출은 트랜잭션 안에서FOR UPDATE SKIP LOCKED+ LIMIT 1 가 큐 패턴 (spring-batch도 비슷)- 2차 캐시 + Optimistic: 2차 캐시는 version 정보도 캐싱 (Hibernate). 분산 환경에선 신중
관련 위키
이 글의 용어 (5개)
- [Java] volatilejava
- 정의 은 Java 의 키워드. 필드에 붙이면 두 가지를 보장한다. 1. 가시성 (visibility): 한 스레드의 쓰기가 다른 모든 스레드에 즉시 보인다. CPU 캐시에 머무르…
- [JPA] FetchType: LAZY vs EAGER, N+1, fetch joinspring
- 정의 FetchType 은 JPA 가 연관 entity 를 언제 로드할지 결정하는 hint. - : entity 로딩 시 즉시 함께 SELECT - : 실제 사용 시점 (prox…
- [JPA] PersistenceContext: 1차 캐시, dirty checking, flushspring
- 정의 PersistenceContext 는 가 관리하는 entity 의 메모리 저장소 + 변경 추적기. 트랜잭션 안에서만 살아 있다 ( 진입 시 생성, commit/rollbac…
- [Spring] Spring Data JPA: Repository, Query Methodsspring
- 정의 Spring Data JPA는 JPA(Hibernate) 위에 Repository 추상화를 얹어 boilerplate를 제거. 인터페이스만 정의하면 Spring이 구현체 자…
- [Spring] Transaction: @Transactional, propagation, isolationspring
- 정의 Spring 트랜잭션은 AOP 기반 선언형 트랜잭션 관리. 한 줄로 메서드 전체를 트랜잭션 범위로. JDBC, JPA, MongoDB 등 backend에 동일 API. 기본…
💬 댓글