[DB] 비관적 락은 정확히 언제 시작하고 언제 끝날까?

조시현·2025년 4월 19일
0

스프링(Spring)과 JPA를 활용하여 금융 서비스의 송금 기능을 구현하면서, 수많은 트랜잭션 충돌과 예외 상황을 경험했다. 특히, 비관적 락을 사용했을 때 기대했던 동작과 실제 동작 사이에 큰 차이가 있었다. 이 글에서는 실제 내가 겪었던 문제 상황을 바탕으로 비관적 락이 걸리는 시점과 풀리는 시점에 대해 정확히 정리해보려 한다.


🔒 비관적 락이 정확히 걸리는 시점은?

흔히 JPA에서 비관적 락을 사용할 때 다음과 같은 형태로 작성한다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Optional<Account> findByIdWithPessimisticLock(@Param("id") Long id);

이 메서드를 호출하면 언제 비관적 락이 걸릴까?

"바로 이 쿼리가 DB에서 실행될 때, 즉 SELECT ... FOR UPDATE가 수행되는 순간이다."

JPA가 위의 코드를 만나면 DB에 다음 SQL을 날리게 된다.

SELECT * FROM account WHERE id = ? FOR UPDATE;

이 시점에 DB는 해당 row를 즉시 잠그고 다른 트랜잭션이 접근하지 못하게 막아놓는다.


🔑 그렇다면 비관적 락이 풀리는 시점은 언제일까?

이 질문에 답하기 위해선, 트랜잭션의 개념을 다시 정확히 짚어봐야 한다. 락을 해제하는 조건은 딱 두 가지뿐이다.

  1. 트랜잭션이 커밋(Commit)될 때
  2. 트랜잭션이 롤백(Rollback)될 때

즉, 비관적 락의 범위는 절대로 "메서드 단위"가 아니라, 반드시 "트랜잭션 범위"에 귀속된다.

예를 들어 아래와 같은 코드가 있다고 하자.

@Transactional
public void outerTransfer(Long fromId, Long toId, Long amount) {
    innerTransfer(fromId, toId, amount);
    performComplexBusinessLogic();
}

@Transactional
public void innerTransfer(Long fromId, Long toId, Long amount) {
    Account from = repository.findByIdWithPessimisticLock(fromId).orElseThrow();
    Account to = repository.findByIdWithPessimisticLock(toId).orElseThrow();

    from.withdraw(amount);
    to.deposit(amount);
}

이때 비관적 락은 언제 해제될까?

정답:

innerTransfer() 메서드가 종료되어도, 실제 락은 상위의 outerTransfer() 메서드의 트랜잭션이 최종적으로 커밋되거나 롤백될 때까지 유지된다.

즉, performComplexBusinessLogic()가 오랜 시간이 걸린다면, 그동안 락이 계속 잡혀있고, 다른 트랜잭션은 대기하거나 타임아웃이 날 가능성이 매우 높아진다.

더 정확히 말하면, 하위 메서드에서 비관적 락이 걸리면, 이후 같은 트랜잭션 범위 내에서 실행되는 상위 메서드의 로직도 전부 락이 걸린 상태에서 진행된다. 예를 들어:

@Transactional
public void outerMethod() {
    lockAccount(); // 비관적 락 시작
    executeAdditionalLogic(); // 이 로직도 락이 걸린 상태에서 수행됨
}

@Transactional
public void lockAccount() {
    repository.findByIdWithPessimisticLock(accountId);
}

위 코드에서 lockAccount()가 실행되면, SELECT FOR UPDATE로 락이 걸리고, 같은 트랜잭션 안에 있는 executeAdditionalLogic()락이 유지된 채로 실행된다. 다시 말해, 락이 걸린 시점부터 트랜잭션이 끝날 때까지, 트랜잭션 범위 안의 모든 코드가 락의 영향을 받는다.


🚨 실제 문제 상황

나는 아래와 같은 실수를 경험했다.

  • 비관적 락을 걸고 데이터를 수정했지만, 트랜잭션 범위를 넓게 잡는 바람에 락이 너무 오래 유지됐다.
  • 그 결과, 동시 요청이 몰렸을 때 빈번한 락 대기와 타임아웃(PessimisticLockingFailureException) 예외가 발생했다.

이러한 문제를 해결하기 위해 다음과 같은 원칙을 세웠다.

  • 비관적 락을 거는 트랜잭션은 항상 최소한으로 유지한다.
  • 비관적 락을 사용한 작업은 다른 로직과 섞지 않고 명확한 트랜잭션으로 구분한다.
// 권장되는 코드 구조
@Transactional
public void transferWithMinimalLock(Long fromId, Long toId, Long amount) {
    lockAndTransfer(fromId, toId, amount);

    // 나머지 비즈니스 로직은 다른 트랜잭션이나 비동기로 처리
}

@Transactional
public void lockAndTransfer(Long fromId, Long toId, Long amount) {
    Account from = repository.findByIdWithPessimisticLock(fromId).orElseThrow();
    Account to = repository.findByIdWithPessimisticLock(toId).orElseThrow();

    from.withdraw(amount);
    to.deposit(amount);
    // 바로 커밋되어 락이 빠르게 해제됨
}

🎯 결론 및 핵심 정리

항목시점특징
비관적 락 시작쿼리 실행 순간 (SELECT FOR UPDATE)즉시 잠김
비관적 락 해제상위 메서드의 모든 트랜잭션이 커밋 또는 롤백될 때트랜잭션 범위로 유지

결국 비관적 락을 제대로 이해하고 활용하기 위해서는 "락 범위 = 트랜잭션 범위"라는 사실을 정확히 기억해야 한다.

이 글이 비관적 락을 쓰면서 혼란을 겪고 있는 많은 개발자들에게 도움이 되었으면 좋겠다!

profile
Luck favors the prepared. Chance favors the prepared mind

0개의 댓글