Spring/동시성 제어

Spring 비관적 락 (Pessimistic Lock)

kyoooooong 2025. 9. 8. 23:52

웹 서비스를 만들다 보면 이런 상황 경험해보셨나요?

“재고가 마이너스로 떨어졌네?”, “예약이 중복으로 잡혔어!” 🤯

이건 여러 사용자가 동시에 같은 데이터를 수정하면서 생기는 동시성 문제입니다.

 

낙관적 락(Optimistic Lock) 은 “동시에 수정될 일이 많진 않겠지” 하고 버전으로 관리하는 방식이라면

이번에는 반대로, “언제든 충돌 날 수 있어!”라고 비관적으로 가정하고 아예 미리 락을 걸어버리는 방식,

 

즉 비관적 락(Pessimistic Lock) 을 알아보겠습니다.

 


비관적 락이란?

비관적 락은 데이터를 읽는 순간부터 데이터베이스(DB) 차원에서 락을 걸어버립니다.

즉, 내가 이 데이터 보고 있는 동안 다른 트랜잭션은 건드리지 마! 라는 거예요.

  • 읽기(Read): 다른 트랜잭션이 수정하지 못하게 막음
  • 쓰기(Write): 다른 트랜잭션이 읽거나 수정도 못 하게 막음

👉 위처럼, 비관적 락은 충돌을 아예 원천 차단하는 강력한 방식입니다.


비관적 락의 장단점

장점

  • 정합성 보장: 재고 감소, 은행 이체, 예약 시스템처럼 틀리면 안 되는 작업에 적합

단점

  • 데드락 위험: 여러 트랜잭션이 서로 락을 기다리면 무한 대기(Deadlock) 발생 가능

데드락(Deadlock)이란?

데드락은 둘 이상의 트랜잭션이 서로가 가진 자원을 기다리며 무한 대기 상태에 빠지는 현상입니다.

조건은 4가지:

  1. 상호 배제: 자원은 한 번에 하나의 트랜잭션만 사용 가능
  2. 점유 대기: 자원을 점유한 채로 다른 자원 대기
  3. 비선점: 다른 트랜잭션이 자원을 강제로 뺏을 수 없음
  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을 꼭 거는 이유는 비관적 락을 제대로 “잡고-변경하고-커밋할 때까지” 유지하려면 트랜잭션 경계가 필요하기 때문입니다.

 

왜 필요한가?

  1. 락 유지 범위
  • @Lock(PESSIMISTIC_WRITE)는 DB에서 SELECT … FOR UPDATE 류의 행 잠금을 겁니다.
  • 이 락은 트랜잭션이 열려있는 동안만 유지되고 커밋/롤백 시 해제돼요.
  • 트랜잭션이 없으면(오토커밋) 쿼리 끝나자마자 락이 풀려서, 바로 뒤의 decreaseStockQuantity/UPDATE 구간을 보호하지 못합니다.

 

오토커밋(auto-commit)이란?

  • DB 연결(Connection)의 기본 모드
  • BEGIN / COMMIT 같은 트랜잭션 제어 구문을 사용하지 않아도각 SQL 문이 끝나자마자 자동으로 커밋되는 모드예요.
  • 즉, 한 줄의 SQL = 하나의 트랜잭션.

👉 MySQL, PostgreSQL 등 대부분의 DB 클라이언트/드라이버는 기본값이 오토커밋 = true입니다.

 

→ 정리 (흐름)

  1. 사용자가 상품 구매 요청을 보냄.
  2. 서비스 계층에서 findByIdWithPessimisticLock() 호출 → 상품 row에 DB 레벨 락.
  3. 재고 차감 로직 실행 (decreaseStockQuantity).
  4. 트랜잭션 커밋 → DB에 반영 → 락 해제.
  5. 동시에 다른 사용자가 같은 상품을 구매하려고 하면, 첫 번째 트랜잭션이 끝날 때까지 대기(blocking).

정리

  • 비관적 락은 DB 차원에서 충돌 자체를 원천 차단
  • 장점: 정합성 보장, 충돌 처리 로직 단순
  • 단점: 성능 저하, 데드락 위험
  • 스프링에서는 @Lock(PESSIMISTIC_WRITE) 등으로 손쉽게 적용 가능

👉 “데이터 정합성이 절대 깨지면 안 되는 상황”이라면 비관적 락이 최고의 선택입니다.

다만, 성능·데드락 리스크까지 고려해 상황에 맞게 쓰는 게 핵심입니다.