구독 시스템을 관리하며 비관적 락을 사용하게 되었다.
@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 |