본문 바로가기
데이터베이스 & ORM

비관적 락(Pessimistic Lock)

by DoRightting 2024. 11. 22.

구독 시스템을 관리하며 비관적 락을 사용하게 되었다. 

 

@Repository
public interface SubscriptionRepository {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Subscription s WHERE s.subscriptionId = :subscriptionId")
    Optional<Subscription> findBySubscriptionIdWithLock(@Param("subscriptionId") String subscriptionId);
}

 

 이 코드가 실행될 때 실제로 SQL은 이렇게 동작한다. 

 

SELECT * FROM subscriptions s WHERE s.subscription_id = ? FOR UPDATE

 

비관적 락을 구체적으로 사용한 예시이다. 

@Transactional
public void processPaymentRetry(String subscriptionId) {
    // 비관적 락과 함께 구독 정보 조회
    Subscription subscription = subscriptionRepository
        .findBySubscriptionIdWithLock(subscriptionId)
        .orElseThrow(() -> new PaymentException("구독 정보를 찾을 수 없습니다."));

    // ... 결제 처리 로직
}




@Transactional
public SubscriptionResponse renewSubscription(String subscriptionId) {
    // 비관적 락과 함께 구독 정보 조회
    Subscription subscription = subscriptionRepository
        .findBySubscriptionIdWithLock(subscriptionId)
        .orElseThrow(() -> new SubscriptionException("구독 정보를 찾을 수 없습니다."));

    // ... 갱신 처리 로직
}

 

이렇게 구현한 이유는 나는 사용자의 편의를 위하여 일시적인 이유로 결제가 실패했을 때 자동으로 3번까지 결제 재시도 로직을 만들어 놨었다. 그런데 동시에 두 개의 트랜잭션이 같은 구독에 접근하는 경우가 발생할 수 있다.

 

// 트랜잭션 1
void processPayment(String subscriptionId) {
    Subscription subscription = findSubscription(subscriptionId); // 락이 없다면
    if (subscription.getStatus() == PAYMENT_PENDING) {
        // 결제 처리
        subscription.setStatus(ACTIVE);
        save(subscription);
    }
}

// 트랜잭션 2 (동시 실행)
void cancelSubscription(String subscriptionId) {
    Subscription subscription = findSubscription(subscriptionId); // 락이 없다면
    if (subscription.getStatus() == PAYMENT_PENDING) {
        subscription.setStatus(CANCELLED);
        save(subscription);
    }
}

 

이런 상황에서는 

 

  • 두 트랜잭션이 동시에 PAYMENT_PENDING 상태를 읽음
  • 트랜잭션 1이 ACTIVE로 변경
  • 트랜잭션 2가 CANCELLED로 변경
  • 결과적으로 결제는 성공했는데 구독은 취소됨

그래서 비관적 락을 사용했는데 비관적 락은 어떻게 동작하느냐고 하면 

 

@Transactional
public void processPaymentRetry(String subscriptionId) {
    // 1. SELECT FOR UPDATE로 해당 row에 대한 배타적 락 획득
    Subscription subscription = subscriptionRepository
        .findBySubscriptionIdWithLock(subscriptionId)
        .orElseThrow();

    // 2. 이 시점에서 다른 트랜잭션은 이 구독 정보에 접근 불가
    PaymentRetryContext retryContext = new PaymentRetryContext(subscription);

    // 3. 안전하게 상태 변경 및 결제 처리
    if (!shouldRetryPayment(retryContext)) {
        handleMaxRetriesExceeded(subscription);
        return;
    }

    try {
        executePaymentRetry(retryContext);
    } catch (Exception e) {
        handleRetryFailure(retryContext, e);
    }
    // 4. 트랜잭션 종료 시 락 해제
}

 

 

이렇게 구현해 놓으면 아까와 같이 결제 재시도와 구독 취소가 동시에 요청되었을 때 동작 순서는 이렇게 된다. 

 

Time    Transaction 1 (Payment)           Transaction 2 (Cancel)
----    --------------------             -------------------
t1      락 획득 시도                      락 획득 시도
t2      락 획득 성공                      대기
t3      결제 처리 시작                    계속 대기
t4      결제 완료                        계속 대기
t5      상태 업데이트                     계속 대기
t6      트랜잭션 종료(락 해제)            락 획득 성공
t7                                      이미 ACTIVE 상태임을 확인
t8                                      예외 발생(취소 불가)

 

비관적 락을 사용할 시 주의할 점이 있다. 

 

1. 항상 @Transactionsal과 함께 사용해야 한다. 왜냐하면 어떤 메서드가 실행되었을 때 트랜잭션이 없다면 조회하고 수정하고 저장되는 일련의 과정동안 락이 유지가 되어야 하는데, 조회할 때 락을 획득한다고 해도 수정 시 트랜잭션이 없어서 락을 바로 반환하게 된다. 그럼 다른 로직이 동시에 접근 가능한 상태가 되어 사용하는 의미를 잃어버리게 된다. 실제 예시는 다음과 같다.

@Service
public class SubscriptionService {
    // 잘못된 예시 - @Transactional 없음
    public void wrongExample(String subscriptionId) {
        // 락을 획득하지만, 트랜잭션이 없어서 바로 반환됨
        Subscription subscription = repository.findBySubscriptionIdWithLock(subscriptionId);
        // 이 시점에서 락이 유지되지 않음
        processPayment(subscription); // 위험: 다른 트랜잭션이 동시 접근 가능
    }
    
public void withoutTransaction() {
    // 1. DB 연결 획득
    // 2. SELECT FOR UPDATE 실행 (락 획득)
    // 3. DB 연결 반환 - 락도 함께 해제됨
    // 4. 비즈니스 로직 실행 (락 없이)
}

 

2. 락 획득 순서를 잘못 짜놓게 되면 데드락이 발생하게 된다. 예를 들면 

// 트랜잭션 1
@Transactional
public void updateSubscription(String subscriptionId, String paymentId) {
    // 1. 구독 정보에 대한 락 획득
    Subscription subscription = subscriptionRepository
        .findBySubscriptionIdWithLock(subscriptionId);
    
    // 2. 결제 정보에 대한 락 획득 시도
    Payment payment = paymentRepository
        .findByPaymentIdWithLock(paymentId);
    
    // 업데이트 로직
}

// 트랜잭션 2
@Transactional
public void updatePayment(String paymentId, String subscriptionId) {
    // 1. 결제 정보에 대한 락 획득
    Payment payment = paymentRepository
        .findByPaymentIdWithLock(paymentId);
    
    // 2. 구독 정보에 대한 락 획득 시도
    Subscription subscription = subscriptionRepository
        .findBySubscriptionIdWithLock(subscriptionId);
    
    // 업데이트 로직
}

 

이렇게 구현되었을 때 동작 방식은 다음과 같다. 

 

시간  트랜잭션 1                      트랜잭션 2
----  -------------                 -------------
t1    구독 A의 락 획득                결제 B의 락 획득
t2    결제 B의 락 획득 시도(대기)       구독 A의 락 획득 시도(대기)
t3    계속 대기...                   계속 대기...

 

그래서 데드락 방지를 위해서 로깅을 강화하고 타임아웃 값을 설정해 놓도록 하고, 락 범위를 최소화하여 구현할 필요가 있다. 

'데이터베이스 & ORM' 카테고리의 다른 글

PostgreSQL & pgvector  (0) 2024.11.16
REPEATABLE READ  (0) 2024.11.15
Row-Level Lock  (1) 2024.11.12
VACUUM ANALYZE  (1) 2024.11.11
PostgreSQL GIN  (5) 2024.11.10