낙관적 락 vs 비관적 락 — `@Version`과 `FOR UPDATE`를 고르는 실무 기준
낙관적 락과 비관적 락, 왜 알아야 하나요?
데이터베이스 락 글에서는 락의 종류와 기본 동작을, SELECT ... FOR UPDATE 글에서는 비관적 락을 언제 써야 하는지를 따로 정리했습니다. 그런데 실제 서비스 코드에서는 여전히 이런 판단이 남습니다.
- 사용자 프로필 수정은
@Version이면 충분해 보이는데, 재고 차감도 같은 방식으로 처리해도 될까요? FOR UPDATE를 걸면 안전해 보이지만, 모든 쓰기 경쟁 구간에 붙여도 괜찮을까요?- 조건부
UPDATE나UNIQUE제약으로 끝낼 수 있는 문제에 락 전략을 과하게 쓰고 있지는 않을까요?
핵심은 "무슨 락 문법을 쓸까?"가 아닙니다. 충돌을 언제 감지하고, 실패를 어떤 형태로 드러낼 것인가를 먼저 정해야 합니다.
이 글은 MySQL InnoDB + Spring Boot + JPA를 기준으로, 기본 개념 설명보다 선택 기준에 집중합니다.
먼저 선택 기준부터 보면
실무에서는 보통 아래 순서로 판단하면 덜 헷갈립니다.
- SQL 한 문장으로 규칙을 표현할 수 있나? 가능하면 락 전략보다 먼저 검토합니다
- 같은 행에 요청이 얼마나 자주 몰리나? 충돌이 잦으면 낙관적 락은 실패와 재시도가 급격히 늘 수 있습니다
- 충돌 시 다시 시도해도 안전한가? 안전하면 낙관적 락이 잘 맞고, 아니면 비관적 락이나 제약 조건이 더 낫습니다
- 트랜잭션을 짧게 유지할 수 있나? 길다면 비관적 락은 대기와 커넥션 점유 비용이 커집니다
가장 짧게 줄이면 이렇습니다.
- 충돌이 드물고 재시도가 자연스럽다 → 낙관적 락
- 충돌이 잦고 한 번에 한 명만 처리해야 한다 → 비관적 락
- 조건을 SQL로 바로 표현할 수 있다 → 조건부
UPDATE나UNIQUE제약을 먼저 검토
Phase 1. 핵심 차이는 "기다리게 할지, 실패하게 할지"입니다
두 전략의 가장 큰 차이는 구현 문법이 아니라 충돌을 처리하는 시점입니다.
- 비관적 락 — 충돌이 날 것이라 보고 먼저 잠급니다
- 낙관적 락 — 충돌이 드물 것이라 보고 끝에서 검증합니다
그래서 같은 쓰기 경쟁도 사용자에게는 전혀 다르게 보입니다.
- 비관적 락은 보통 대기,
lock wait timeout,deadlock으로 나타납니다 - 낙관적 락은 보통 충돌 감지, 재시도, "다른 사용자가 먼저 수정했습니다" 같은 실패로 나타납니다
즉, 질문은 "어느 쪽이 더 안전한가?"가 아니라 "이 구간의 실패를 대기로 보여 줄 것인가, 충돌 에러로 보여 줄 것인가?" 입니다.
Phase 2. 낙관적 락이 잘 맞는 경우
낙관적 락은 대부분의 요청이 서로 부딪히지 않는 상황에서 강합니다.
1. 같은 레코드를 동시에 수정할 확률이 낮다
사용자 프로필 수정, 관리자 설정 변경, 게시글 편집처럼 같은 행을 동시에 건드릴 가능성이 낮은 기능은 매번 DB 락을 잡는 편이 더 과할 수 있습니다.
이럴 때는:
- 대부분의 요청은 대기 없이 바로 처리되고
- 드물게 충돌한 요청만 예외 처리하면 되므로
전체 처리량과 응답성이 더 좋아지기 쉽습니다.
2. 읽기와 쓰기 사이 간격이 길다
웹 화면에서는 사용자가 수정 화면을 열고 저장 버튼을 누르기까지 수 초에서 수 분이 걸릴 수 있습니다.
1. 사용자 A가 수정 화면 열기
2. 내용을 5분 동안 수정
3. 저장 버튼 클릭
이 구간을 DB 락으로 보호하는 것은 현실적으로 어렵습니다. HTTP 요청 사이에 락을 계속 들고 있을 수 없기 때문입니다. 이런 종류의 편집 화면은 저장 시점에 버전을 비교하는 모델이 자연스럽습니다.
3. 충돌 시 재시도나 사용자 안내가 가능하다
낙관적 락은 충돌이 나면 애플리케이션이 후속 행동을 결정해야 합니다.
- 최신 데이터를 다시 읽고 자동 재시도
- 충돌 사실을 알려주고 다시 편집 유도
- 변경 내용을 병합
즉, 낙관적 락은 충돌 이후의 UX까지 설계할 수 있을 때 잘 맞습니다.
JPA에서는 보통 @Version으로 구현합니다
@Entity
class Article(
@Id
val id: Long,
var title: String,
var content: String,
@Version
var version: Long = 0,
)
@Version이 붙어 있으면 JPA provider는 보통 flush/commit 과정에서 실행되는 UPDATE에 버전 조건을 포함합니다.
UPDATE article
SET title = ?, content = ?, version = version + 1
WHERE id = ?
AND version = ?;
영향받은 행 수가 0이 되면 충돌로 보고 OptimisticLockException 계열 예외를 던집니다.
참고: 낙관적 락은 "오래 잠가 두는 락"이 아닙니다. 일반 조회로 읽고, 저장 시점에 내가 봤던 버전이 아직 유효한지만 확인합니다.
Phase 3. 비관적 락이 잘 맞는 경우
비관적 락은 부딪힐 가능성이 높은 구간을 아예 줄 세우는 전략입니다.
1. 같은 행에 요청이 자주 몰린다
인기 상품 재고 차감, 좌석 선점, 쿠폰 사용 처리처럼 같은 행이 반복해서 갱신되는 구간은 낙관적 락으로 돌리면 실패 요청이 빠르게 늘 수 있습니다.
예를 들어 재고 1개짜리 상품에 요청이 동시에 몰리면:
- 낙관적 락은 읽기까지는 여러 요청이 통과한 뒤 마지막에 대부분 실패하고
- 비관적 락은 처음부터 한 명씩 들어가도록 대기시킵니다
이런 구간에서는 "빠른 실패"보다 예측 가능한 직렬화가 더 중요할 수 있습니다.
2. 읽고 판단한 뒤 쓰는 로직을 통째로 보호해야 한다
다음과 같은 로직은 단순 증가/감소보다 현재 상태에 대한 비즈니스 판단이 핵심입니다.
- 재고가 충분한지 확인한 뒤 차감
- 쿠폰이 아직 사용되지 않았는지 확인한 뒤 사용 처리
- 주문 상태가
READY인지 확인한 뒤PAID로 전이
이런 경우에는 SELECT ... FOR UPDATE에서 정리한 것처럼 읽기-판단-쓰기 구간 자체를 보호해야 할 수 있습니다.
START TRANSACTION;
SELECT stock
FROM products
WHERE id = 1
FOR UPDATE;
-- stock > 0 인지 확인
UPDATE products
SET stock = stock - 1
WHERE id = 1;
COMMIT;
3. 충돌 후 재시도 비용이 크다
비관적 락은 "충돌이 잦다"는 이유만으로 선택하는 것이 아닙니다. 실패 후 다시 시도하기 어려운 구간에서도 자주 고려합니다.
예를 들어:
- 결제 승인 요청과 얽힌 상태 변경
- 외부 시스템 차감과 함께 가는 처리
- 중복 발송이 위험한 알림/메시지 처리
이런 구간은 충돌 후 재시도를 무심코 붙이면 부작용이 커질 수 있습니다. 물론 그렇다고 트랜잭션 안에서 외부 API를 호출하라는 뜻은 아닙니다. 비관적 락을 쓰더라도 트랜잭션은 짧게 유지해야 합니다.
Phase 4. 둘 중 하나를 고르기 전에 더 단순한 해결책부터 봐야 합니다
실무에서 자주 놓치는 지점입니다. 많은 문제는 낙관적 락과 비관적 락 중 하나를 고르기 전에 SQL 한 문장이나 제약 조건으로 끝낼 수 있습니다.
1. 조건부 UPDATE로 끝낼 수 있다
재고 차감은 자주 이렇게 표현할 수 있습니다.
UPDATE products
SET stock = stock - 1
WHERE id = 1
AND stock > 0;
이 방식은:
- 재고가 있을 때만 차감되고
- 품절이면 아무 행도 수정하지 않으며
- 영향받은 행 수만 보면 성공/실패를 판단할 수 있습니다
즉, 규칙을 SQL 한 문장으로 안전하게 표현할 수 있다면 FOR UPDATE나 @Version보다 더 직접적일 수 있습니다.
2. 상태 전이도 조건부 UPDATE로 표현할 수 있다
UPDATE orders
SET status = 'PAID'
WHERE id = 1
AND status = 'READY';
이 경우도 영향받은 행 수가:
1이면 상태 변경 성공0이면 이미 다른 상태이거나 선행 조건 불만족
즉, "현재 값이 이럴 때만 바꾼다"는 규칙은 읽기-판단-쓰기로 풀지 않아도 되는 경우가 많습니다.
3. 중복 방지는 UNIQUE 제약이 더 강력할 수 있다
같은 사용자가 같은 쿠폰을 두 번 발급받으면 안 된다면, 고유 제약 조건이 락 전략보다 더 강한 해결책일 수 있습니다.
ALTER TABLE issued_coupon
ADD CONSTRAINT uk_coupon_user UNIQUE (coupon_id, user_id);
그 후 그냥 INSERT하고 중복 키 예외를 처리하면 됩니다. 이 문제의 핵심은 "잠그는 것"이 아니라 중복을 물리적으로 허용하지 않는 것이기 때문입니다.
Phase 5. JPA와 Spring에서 선택을 망치는 함정
실무에서는 전략 자체보다 구현 디테일 때문에 의도와 다른 동작이 더 자주 나옵니다.
1. @Version을 붙였는데 벌크 UPDATE를 사용한다
JPA의 벌크 UPDATE는 영속성 컨텍스트를 우회합니다. 그래서 기본적으로 @Version 기반 낙관적 락 검사가 자동 적용되지 않습니다.
@Modifying
@Query("""
update Product p
set p.stock = p.stock - 1
where p.id = :id
""")
fun decreaseStock(id: Long): Int
이런 쿼리는 빠를 수 있지만, version 조건이 없으면 낙관적 락 보호를 받지 못합니다.
2. 낙관적 락 예외 시점을 잘못 기대한다
낙관적 락 예외는 보통 엔티티를 읽을 때가 아니라 flush 또는 commit 시점에 자주 발생합니다. 서비스 메서드 앞부분에서 외부 로직을 많이 수행한 뒤 마지막에 예외가 터지면 흐름이 꼬일 수 있습니다.
3. 비관적 락을 잡고 트랜잭션 안에서 오래 머문다
@Transactional
fun processOrder(orderId: Long) {
val order = orderRepository.findByIdForUpdate(orderId)
val paymentResult = externalApi.charge(order.amount)
order.complete(paymentResult)
}
이 패턴은 락을 잡은 채 외부 API를 기다립니다. 응답 지연이 생기면 락 보유 시간, 커넥션 점유 시간, 대기 요청 수가 함께 늘어납니다.
4. 인덱스 없이 FOR UPDATE를 사용한다
인덱스가 부실하면 InnoDB는 더 넓은 범위를 스캔하고 잠글 수 있습니다. 그러면 원래 의도보다 많은 행이 잠기고, 충돌률과 deadlock 가능성도 함께 올라갑니다.
한눈에 보는 선택 기준
실무 판단을 표로 줄이면 아래와 가깝습니다.
| 질문 | 낙관적 락이 잘 맞는 경우 | 비관적 락이 잘 맞는 경우 | 더 먼저 볼 대안 |
|---|---|---|---|
| 충돌 빈도는 어떤가? | 같은 행 수정이 드뭅니다 | 같은 행에 요청이 자주 몰립니다 | - |
| 요청 사이 간격은 어떤가? | 읽기와 저장 사이 간격이 깁니다 | 짧은 트랜잭션 안에서 끝납니다 | - |
| 실패 후 재시도가 안전한가? | 재시도나 사용자 안내가 자연스럽습니다 | 재시도 비용이 큽니다 | UNIQUE, 조건부 UPDATE |
| 로직이 읽기-판단-쓰기인가? | 꼭 그렇지 않습니다 | 현재 상태를 읽고 판단해야 합니다 | 조건을 SQL로 밀어 넣기 |
| 부하가 몰리면 어떤 형태가 나은가? | 일부 충돌 실패를 감수할 수 있습니다 | 대기시키더라도 직렬화가 낫습니다 | 큐/직렬 처리 구조 검토 |
정리
- 낙관적 락과 비관적 락의 차이는 충돌을 언제 처리하느냐입니다 — 하나는 나중에 검증하고, 다른 하나는 먼저 막습니다
- 충돌이 드물고 재시도가 자연스러우면 낙관적 락이 잘 맞습니다 — 편집 화면, 설정 변경, 낮은 충돌률의 관리 기능이 대표적입니다
- 충돌이 잦고 읽기-판단-쓰기를 통째로 보호해야 하면 비관적 락이 잘 맞습니다 — 재고, 쿠폰, 좌석, 상태 전이 같은 구간이 대표적입니다
- 많은 경우 더 좋은 해답은 조건부
UPDATE나UNIQUE제약입니다 — 락 전략은 항상 첫 선택지가 아닙니다 - JPA 구현 디테일이 선택을 망칠 수 있습니다 — 벌크
UPDATE, 늦은 예외 시점, 긴 트랜잭션, 인덱스 없는FOR UPDATE는 대표적인 함정입니다
핵심을 한 문장으로 줄이면 이렇습니다.
어떤 락을 쓸지보다, 충돌을 대기로 처리할지 실패로 처리할지부터 정하는 편이 실무에서는 더 중요합니다