자바 백엔드 입문 보강편. @Transactional을 붙였는데도 동시 요청에서 재고가 마이너스로 떨어지는 lost update 문제를 풀어요. 낙관적 락(@Version)과 비관적 락(@Lock)의 동작 차이, 충돌 시 재시도, 그리고 언제 무엇을 쓰는지 기준까지 정리한 학습 노트.
이 글은 자바 백엔드 입문 시리즈의 보강편이에요. 43편 @Transactional에서 트랜잭션으로 "한 덩어리를 묶는" 법을, 51편 영속성 컨텍스트에서 JPA의 동작 원리를 다뤘죠. 그런데 실무에 나가면 "분명 @Transactional을 붙였는데 동시 요청에서 재고가 마이너스로 떨어진다" 는 사고를 꼭 한 번은 만나요. 트랜잭션만으로는 안 막히는 영역, 동시성 제어예요. 해결 도구가 낙관적 락과 비관적 락 두 가지인데, 이 글에서 둘을 풀어 가요.
@Transactional을 붙였는데 왜 또 충돌이 날까
이게 처음엔 정말 억울하게 느껴져요. 트랜잭션을 걸면 다 안전한 줄 알았거든요. 그런데 트랜잭션이 보장하는 건 "내 작업 묶음이 전부 성공하거나 전부 실패한다" 지, "남이 동시에 같은 데이터를 못 건드린다" 가 아니에요.
구체적인 사고를 볼게요. 재고가 1개 남은 상품에 주문 두 건이 거의 동시에 들어와요.
A: 재고 조회 → "1개 남음"
B: 재고 조회 → "1개 남음" (A가 아직 저장 전)
A: 1 - 1 = 0 저장
B: 1 - 1 = 0 저장 ← A의 차감이 사라짐
둘 다 "1개 남았네" 를 읽고 각자 차감했어요. 결과는 재고 0이지만 실제로는 2개가 팔렸죠. A가 쓴 값을 B가 덮어쓴 거예요. 이걸 lost update(갱신 손실)라고 불러요. 일반적인 트랜잭션 격리 수준(보통 READ COMMITTED)으로는 이게 안 막혀요.
비유 한 줄 — 공유 문서를 두 사람이 동시에 편집하는 상황이에요. 둘 다 같은 버전을 열어 각자 고치고 저장하면, 늦게 저장한 사람이 먼저 저장한 사람의 수정을 통째로 덮어써요. 이걸 막는 두 가지 방식이 바로 락이에요.
두 가지 락 전략 — 낙관적 락과 비관적 락
| 낙관적 락 (Optimistic) | 비관적 락 (Pessimistic) | |
|---|---|---|
| 전제 | "충돌은 드물 거야" (낙관) | "충돌은 자주 날 거야" (비관) |
| 방식 | 버전 번호로 나중에 충돌 감지 | 읽을 때부터 미리 DB 락 |
| DB 락 | 안 잡음 | 잡음 (SELECT ... FOR UPDATE) |
| 충돌 시 | 예외 → 재시도 필요 | 뒤 트랜잭션이 대기 |
| 성능 | 빠름 (락 대기 없음) | 느림 (대기·데드락 위험) |
이름이 직관적이에요. 낙관적은 "어차피 부딪힐 일 별로 없으니 일단 진행하고 끝에서 확인하자", 비관적은 "부딪힐 게 뻔하니 처음부터 문을 잠그자". 같은 문제를 정반대 태도로 푸는 거죠.
낙관적 락 — @Version
낙관적 락은 엔티티에 버전 번호 하나를 둬요. JPA가 @Version 필드를 보면 자동으로 관리해 줘요.
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private int stock;
@Version
private Long version; // JPA가 자동 관리 — 직접 건드리지 않음
}
동작은 이래요. 엔티티를 읽을 때 version도 함께 읽고(예: version = 5), 수정 후 커밋할 때 JPA가 이런 UPDATE를 날려요.
UPDATE product SET stock = ?, version = 6
WHERE id = ? AND version = 5 -- 읽을 때의 version과 같을 때만
WHERE version = 5 조건이 핵심이에요. 그 사이 누가 먼저 수정해서 version이 6으로 올라가 있으면, 이 UPDATE는 0건 갱신이 돼요. JPA는 이걸 감지해 OptimisticLockException(Spring에서는 ObjectOptimisticLockingFailureException)을 던져요. "네가 읽은 사이에 누가 바꿨어, 다시 해" 라는 신호죠.
장점은 분명해요 — 읽는 동안 DB 락을 안 잡으니 빠르고 처리량이 좋아요. 대신 단점도 명확해요. 충돌이 나면 예외가 터지니까, 그걸 잡아서 재시도하는 코드가 따로 필요해요.
충돌이 나면 — 재시도
낙관적 락은 "충돌 = 예외"라서, 예외를 잡아 다시 시도하는 게 한 세트예요. 수동으로 짜면 이런 모양이에요.
@Service
public class StockService {
private final ProductRepository productRepository;
public void decrease(Long productId, int qty) {
int retry = 0;
while (true) {
try {
decreaseOnce(productId, qty);
return;
} catch (ObjectOptimisticLockingFailureException e) {
if (++retry >= 3) throw e; // 3번까지만 재시도
}
}
}
@Transactional
public void decreaseOnce(Long productId, int qty) {
Product p = productRepository.findById(productId).orElseThrow();
p.decreaseStock(qty); // 재고 부족이면 내부에서 예외
}
}
Spring Retry를 쓰면 @Retryable로 더 깔끔하게 줄일 수도 있어요. 여기서 정말 중요한 함정 하나 — 재시도 단위는 트랜잭션 바깥이어야 해요. 위 코드처럼 decreaseOnce(트랜잭션)를 통째로 다시 부르는 구조라야, 새 트랜잭션이 최신 version을 다시 읽어 와요. 트랜잭션 안에서 루프만 돌면 같은 낡은 데이터를 계속 읽어 영원히 실패해요.
비관적 락 — @Lock
비관적 락은 태도가 정반대예요. 읽는 순간부터 DB에 행 잠금(row 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> findByIdForUpdate(@Param("id") Long id);
}
PESSIMISTIC_WRITE는 DB에 SELECT ... FOR UPDATE로 번역돼요. A 트랜잭션이 이 행을 잠그고 있는 동안, B는 같은 행을 읽으려다 A가 끝날 때까지 대기해요. A가 차감하고 커밋하면, 그제야 B가 차감된 최신 재고를 읽죠. lost update가 원천 차단돼요.
확실한 대신 비용이 있어요. 대기가 생기니 처리량이 떨어지고, 두 트랜잭션이 서로의 락을 기다리면 데드락도 날 수 있어요. 그래서 보통 락 타임아웃을 같이 걸어 둬요.
락은 트랜잭션 안에서만 의미가 있어요. @Lock 메서드를 트랜잭션 없이 부르면 락이 바로 풀려 효과가 없어요. 그래서 락을 쓰는 조회는 항상 @Transactional 서비스 메서드 안에서 호출해야 합니다.
언제 무엇을 쓰나
둘 중 뭘 고를지는 충돌이 얼마나 자주 나느냐로 갈려요.
- 낙관적 락 — 충돌이 드문 경우. 게시글 수정, 사용자 프로필 변경처럼 같은 데이터를 동시에 건드릴 일이 거의 없을 때. 평소엔 락 비용이 0이라 가벼워요.
- 비관적 락 — 충돌이 잦고 절대 틀리면 안 되는 경우. 재고 차감, 잔액·포인트 차감, 좌석 예매처럼 돈·수량이 걸린 곳. 확실함이 성능보다 중요할 때.
여기까지 따라오셨다면 한 가지 더 짚을 게 있어요. 모든 동시성 문제를 락으로 풀어야 하는 건 아니에요. "이메일 중복 가입"처럼 유일성만 보장하면 되는 경우는 락보다 DB의 unique 제약(unique index)이 훨씬 싸고 확실해요. 락은 "기존 값을 읽고 → 계산해서 → 다시 쓰는" 패턴에서 진짜 필요해집니다.
자주 막히는 함정
기존 테이블에 @Version을 추가했더니 에러가 나요. 이미 쌓인 행의 version이 null이라 그래요. 마이그레이션으로 기존 행의 version을 0으로 채워 주면 됩니다.
비관적 락을 걸었는데 응답이 멈춰요. 락 대기가 길어진 거예요. 타임아웃(jakarta.persistence.lock.timeout 힌트)을 걸어 무한 대기를 막으세요.
재시도를 넣었는데 무한 루프예요. 재시도가 트랜잭션 안에서 돌고 있을 가능성이 커요. 앞서 말한 대로 재시도는 트랜잭션 바깥에서, 트랜잭션 메서드를 통째로 다시 부르는 구조라야 해요.
시험·면접 직전 압축 노트 — 낙관적 락·비관적 락
@Transactional은 "내 작업 묶음의 원자성"만 보장 — 동시 수정은 안 막음- 동시에 같은 값을 읽고 각자 쓰면 = lost update(갱신 손실)
- 해결 = 낙관적 락 또는 비관적 락
- 낙관적 락 = "충돌 드물다" 가정,
@Version으로 커밋 시점에 충돌 감지 @Version은 JPA가 자동 관리 — UPDATE에WHERE version = ?조건이 붙음- 충돌 시 =
OptimisticLockException(ObjectOptimisticLockingFailureException) - 낙관적 락 장점 = DB 락 안 잡아 빠름 / 단점 = 재시도 코드 필요
- 재시도는 반드시 트랜잭션 바깥에서 (트랜잭션 통째로 재호출해야 최신 version 읽음)
- 비관적 락 = "충돌 잦다" 가정, 읽을 때부터 DB 행 잠금
@Lock(LockModeType.PESSIMISTIC_WRITE)→SELECT ... FOR UPDATE- 비관적 락 장점 = 확실 / 단점 = 대기·데드락·처리량 저하
- 락은 트랜잭션 안에서만 유효 —
@Transactional서비스 안에서 호출 - 선택 기준 = 충돌 드묾·가벼움 → 낙관적 / 충돌 잦음·돈·수량 → 비관적
- 유일성(중복 가입)만 필요하면 락 말고 unique 제약이 더 쌈
- 락이 진짜 필요한 패턴 = "읽고 → 계산 → 다시 쓰기"
@Version추가 시 기존 행 versionnull처리 주의
공식 문서: Spring Data JPA — Locking에서 @Lock과 LockModeType의 종류를 확인할 수 있어요.
시리즈 다른 편
같이 읽으면 좋은 글: