Spring 비관적 락 (Pessimistic Lock)
웹 서비스를 만들다 보면 이런 상황 경험해보셨나요?
“재고가 마이너스로 떨어졌네?”, “예약이 중복으로 잡혔어!” 🤯
이건 여러 사용자가 동시에 같은 데이터를 수정하면서 생기는 동시성 문제입니다.
낙관적 락(Optimistic Lock) 은 “동시에 수정될 일이 많진 않겠지” 하고 버전으로 관리하는 방식이라면
이번에는 반대로, “언제든 충돌 날 수 있어!”라고 비관적으로 가정하고 아예 미리 락을 걸어버리는 방식,
즉 비관적 락(Pessimistic Lock) 을 알아보겠습니다.
비관적 락이란?
비관적 락은 데이터를 읽는 순간부터 데이터베이스(DB) 차원에서 락을 걸어버립니다.
즉, 내가 이 데이터 보고 있는 동안 다른 트랜잭션은 건드리지 마! 라는 거예요.
- 읽기(Read): 다른 트랜잭션이 수정하지 못하게 막음
- 쓰기(Write): 다른 트랜잭션이 읽거나 수정도 못 하게 막음
👉 위처럼, 비관적 락은 충돌을 아예 원천 차단하는 강력한 방식입니다.
비관적 락의 장단점
장점
- 정합성 보장: 재고 감소, 은행 이체, 예약 시스템처럼 틀리면 안 되는 작업에 적합
단점
- 데드락 위험: 여러 트랜잭션이 서로 락을 기다리면 무한 대기(Deadlock) 발생 가능
데드락(Deadlock)이란?
데드락은 둘 이상의 트랜잭션이 서로가 가진 자원을 기다리며 무한 대기 상태에 빠지는 현상입니다.
조건은 4가지:
- 상호 배제: 자원은 한 번에 하나의 트랜잭션만 사용 가능
- 점유 대기: 자원을 점유한 채로 다른 자원 대기
- 비선점: 다른 트랜잭션이 자원을 강제로 뺏을 수 없음
- 환형 대기: 원형 구조로 서로 자원을 기다림
흔한 케이스로는,
- A 사용자가 상품1 락 → 상품2 대기
- B 사용자가 상품2 락 → 상품1 대기
- → 둘 다 무한 대기 😨 🍀
읽기 락 vs 쓰기 락
비관적 락은 크게 두 가지로 나뉩니다.
🔒 읽기 락 (PESSIMISTIC_READ)
- 공유락 방식. 여러 트랜잭션이 동시에 데이터를 읽을 수 있음
- 단, 다른 트랜잭션의 데이터 수정은 불가능
- 읽기 락이 걸려있을 때 쓰기락이 들어오면 쓰기 락은 배타적(exclusive)이어야 하므로, 읽기 락이 걸려 있는 동안에는 쓰기 락이 대기해야 합니다.
SELECT * FROM product WHERE id = 1 LOCK IN SHARE MODE;
🔒 쓰기 락 (PESSIMISTIC_WRITE)
- 배타적 락 방식. 한 트랜잭션만 데이터 읽기 + 쓰기 모두 독점. 다른 트랜잭션은 읽기 쓰기 모두 불가 🍀
SELECT * FROM product WHERE id = 1 FOR UPDATE;
코드 예제
1. 엔티티 정의
@Entity
@Table(name = "pessimistic_product")
class Product(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(nullable = false)
val name: String,
) {
var stockQuantity: Int = 0
protected set
fun decreaseStockQuantity(quantity: Int) {
if (stockQuantity < quantity || quantity < 0) {
throw IllegalArgumentException("재고가 없습니다.")
}
this.stockQuantity -= quantity
}
}
2. 레포지토리에서 락 걸기
interface ProductRepository : JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
fun findByIdWithPessimisticLock(id: Long): Product?
}
- findByIdWithPessimisticLock(id: Long):
- @Lock(LockModeType.PESSIMISTIC_WRITE) → DB 레벨에서 쓰기 락 (exclusive lock) 을 검.
- JPQL로 상품을 조회할 때 해당 row를 잠가서 다른 트랜잭션이 동시에 수정 못 하도록 보장.
즉, 재고 차감 시 동시성 문제(동시에 여러 명이 주문해서 음수 재고 발생)를 방지합니다.
3. 서비스에서 사용하기
@Service
class ProductService(
private val productRepository: ProductRepository
) {
@Transactional
fun decreaseStockQuantity(productId: Long, quantity: Int) {
val product = productRepository.findByIdWithPessimisticLock(productId)
?: throw IllegalArgumentException("상품이 없습니다. id=$productId")
product.decreaseStockQuantity(quantity)
}
}
- @Transactional: 트랜잭션 안에서 실행됨.
1. findByIdWithPessimisticLock 호출:
- 조회하면서 DB에서 해당 상품 row에 배타적 락 획득.
- 트랜잭션이 끝나기 전까지 다른 트랜잭션은 이 상품을 수정할 수 없음.
2. decreaseStockQuantity(quantity) 호출:
- 재고 유효성 검사 → 차감.
3. 트랜잭션 종료 시점에 JPA가 UPDATE SQL 실행 → DB 반영.
👉 이때, @Transactional 은 필수!
@Transactional을 꼭 거는 이유는 비관적 락을 제대로 “잡고-변경하고-커밋할 때까지” 유지하려면 트랜잭션 경계가 필요하기 때문입니다.
왜 필요한가?
- 락 유지 범위
- @Lock(PESSIMISTIC_WRITE)는 DB에서 SELECT … FOR UPDATE 류의 행 잠금을 겁니다.
- 이 락은 트랜잭션이 열려있는 동안만 유지되고 커밋/롤백 시 해제돼요.
- 트랜잭션이 없으면(오토커밋) 쿼리 끝나자마자 락이 풀려서, 바로 뒤의 decreaseStockQuantity/UPDATE 구간을 보호하지 못합니다.
오토커밋(auto-commit)이란?
- DB 연결(Connection)의 기본 모드
- BEGIN / COMMIT 같은 트랜잭션 제어 구문을 사용하지 않아도, 각 SQL 문이 끝나자마자 자동으로 커밋되는 모드예요.
- 즉, 한 줄의 SQL = 하나의 트랜잭션.
👉 MySQL, PostgreSQL 등 대부분의 DB 클라이언트/드라이버는 기본값이 오토커밋 = true입니다.
→ 정리 (흐름)
- 사용자가 상품 구매 요청을 보냄.
- 서비스 계층에서 findByIdWithPessimisticLock() 호출 → 상품 row에 DB 레벨 락.
- 재고 차감 로직 실행 (decreaseStockQuantity).
- 트랜잭션 커밋 → DB에 반영 → 락 해제.
- 동시에 다른 사용자가 같은 상품을 구매하려고 하면, 첫 번째 트랜잭션이 끝날 때까지 대기(blocking).
정리
- 비관적 락은 DB 차원에서 충돌 자체를 원천 차단
- 장점: 정합성 보장, 충돌 처리 로직 단순
- 단점: 성능 저하, 데드락 위험
- 스프링에서는 @Lock(PESSIMISTIC_WRITE) 등으로 손쉽게 적용 가능
👉 “데이터 정합성이 절대 깨지면 안 되는 상황”이라면 비관적 락이 최고의 선택입니다.
다만, 성능·데드락 리스크까지 고려해 상황에 맞게 쓰는 게 핵심입니다.