카테고리 없음

Lock- Transaction 사이 race condition 해결방법 1 : 서비스 분리

kjw81024 2026. 5. 30. 19:32

왜 Lock 이랑 Transaction 을 분리하는건 해도 해도 모르겠지. . .. ?

우우유ㅏ누유ㅠㅠㅠㅠㅠ


문제 상황 

스케줄러를 도입하며 스케줄러 내부의 메서드에 Lock 을 걸어야함의 필요성을 느꼈다.

 

현재 스케줄러는 일정 시간이 지난 PENDING 상태의 예약을 자동으로 만료 처리 하고, 한쪽이 결제된 상태의 PENDING 예약은 만료처리와 동시에 환불이 이루어지는 형태


발생할 수 있는 문제점을 생각해본건 

Reservation 을 Expire 처리하는데 결제가 이루어진다거나 확정이 된다거나 해서 

Expired 는 됐는데 결제가 동시에 일어나서 환불이 되지 않는다거나, 확정이 되어 취소를 하려면 위약금을 내야한다거나 하는 문제

 

말이 너무 횡설수설 해서 정리해보자면


1. Expire 스케줄러 vs 결제 Lock 충돌 

현재 PaymentLockService 는 재시도 처리 없이 즉시 실패 처리가 된다. 

public <T> T executeWithReservationLock(Long reservationId, Supplier<T> task) {
	Boolean acquired = stringRedisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, LOCK_TTL);

	// 여기서 Lock 을 획득 못하면 바로 PAYMENT_LOCK_FAILED 오류 발생
    if (!Boolean.TRUE.equals(acquired)) {
        throw new PaymentException(PaymentErrorCode.PAYMENT_LOCK_FAILED);
    }
    
    // 획득 시 task 실행
}

- 사용자가 결제 CONFIRM 중일 때 Expire 가 같은 ReservationID 로 Lock 획득을 시도하면 즉시 실패 -> 스케줄러 비정상 종료 

이 때 스케줄러가 비정상 종료 되면 expirePendingReservations method 가 ForEach 로 돌고 있기 때문에 하나라도 잘못 되면 전체 트랜잭션이 롤백되어 만료가 아예 안 됨 

 

- 스케줄러가 결제 EXPIRE 중일 때 사용자가 결제를 위해 ReservationID 로 Lock 획득을 시도할 수 없음 


2. 스케줄러의 트랜잭션 범위 

@Scheduled(fixedDelayString = "${reservation.expire.fixed-delay:60000}")
public void expirePendingReservations() {
    try{
    	// expirePendingReservation 이 한 트랜잭션
        int expiredCount = reservationService.expirePendingReservations(LocalDateTime.now());
    } catch (Exception e){
        log.error("[ReservationExpireScheduler] 예약 만료 처리 중 에러 발생", e);
    }
}
@Transactional
public int expirePendingReservations(LocalDateTime now) {
	// expire 할 Reservation List 가져오기 
    
    expiredTargets.forEach(this::expireReservation);
    // 후략
}

expirePendingReservation 을 호출하면 

(1) Expire 할 ReservationList 를 Repository 에서 조회 

(2) Reservation 을 하나씩 돌면서 expireReservation method 호출

 

(expireReservation Method 내부) ... 개길어

   1) Reservation Status 를 EXPIRED 로 변경

   2) 한 쪽의 결제가 이루어진 예약이라면 Lock 을 걸어서 << Payment 를 찾고, ReservationPayment 도 찾고, refund 처리 >>

   3) 실제로 refund 처리 내부에는 외부 PG 사 호출하여 금액 환불 처리 로직 수행 , Payment Status REFUND 처리, ReservationPayment false 처리, 쿠폰 restore 로직이 수행 됨

   4) 아무도 결제하지 않은 에약이라면 역시 Payment 를 찾고, EXPIRED 처리 

   5) Proposal 로 생긴 예약이었다면 Accept 되었던 Proposal 을 다시 PENDING 상태로 변경 

   6) CareRequest 로 생긴 예약이었다면 Accept 되었던 CareRequest 를 다시 PENDING 상태로 변경

 

그러니까 사실상... expire 할 예약이 10개라면 1)~6) 까지를 10번씩 확인하는데 하나라도 Lock 충돌이 일어나면 모든 Transaction 이 Rollback 되는 문제가 생긴다

 

추가로 Lock 을 Reservation ID 단위로 거는데 Transaction 이 끝나기 전에 2) 에서 refund 처리 이후 Lock key 를 풀어버리기 때문에 Lock 해제 이후 커밋이 안 끝나서 결제 측에서 Lock 을 새로 잡고 들어와도 DB 는 얘가 refund 되었는지 paid 상태인지 알 수 없다 ...

 

Expire (스케줄러) 가 먼저 Lock 획득 사용자의 Payment 가 승인 실패됨 
근데 PortOne 쪽에서는 이미 결제 완료인 상황 
webhook 으로 복구되긴 하지만 그 사이 에러 노출됨 
Payment 가 먼저 Lock 획득  스케줄러 전체 롤백 (PAYMENT_LOCK_FAILED)

 


3. 멱등성 보장 

 

오늘 중간발표 했는데... (갑자기 TMI 쏟는거 아님 , , ,) 멀티 인스턴스 (클러스터 환경) 를 도입할 예정인 것 같다 

근데 지금 스케줄러가 app 내부에서 돌고 있기 때문에 

서버 A 에서도 스케줄러를 실행시키고, 서버 B에서도 스케줄러를 실행시키게 되면 환불이나 쿠폰 복구가 2번씩 실행될 수 있다 

 

즉 같은 작업을 두 번 실행해도 결과는 1번이어야 하는데 그게 안 됨 


4. 정리 

... 적고보니 레전드 걍 문제 덩어리 스케줄러였다 ....... 우울하군아 

일단 문제들을 정리해보면 아래와 같음 

 

참고로 Confirm 은 개별 Payment 에 대한 Confirm 이다 . . Reservation 을 CONFIRMED 상태로 변환하는 거 말고!

  조합 1 조합 2 문제
1 Exprie Expire 클러스터 중복 -> 멱등성 보장 X
동시에 예약 만료 처리 할 수 있음 
2 Exprie Cancel 한 예약이 만료처리 됨과 동시에 취소 처리 됨
(사실상 PENDING 이니까 기록상의 문제를 제외하고는 상관 없을 듯)
3 Expire Payment 만료되는 순간에 결제가 생성되면 Lock 경합이 일어남 (1번 문제)
결제 없이 생성된 예약인줄 알고 바로 expire 처리 하는데 결제창을 동시에 띄우고, 결제가 일어나고, 만료 처리 되면 이미 EXPIRED 상태라 정합성 깨짐 
4 Expire PaymentConfirm 만료되는 예약을 확정하려는 상황 
5 Expire ReservationConfirm 사용자 양쪽이 결제완료 직전일 때 (PaymentConfirm 진행중) Expire 이 돈다
-> PaymentConfirm 트랜잭션이 커밋 (Reservation = CONFIRMED)
-> Expire 트랜잭션 진행 reservation.expire() 호출 (이미 ReservationList 에 담겨버림)
-> EXPIRED 로 덮어씌워짐 : 결제: PAID, 사용자: 확정이라고 인식, 예약: EXPIRED...
6 Cancel PaymentConfirm 취소되는 예약을 확정하려는 상황 

 

가장 큰 문제는 Exprie 과 두 Confirm 사이 (4번, 5번) 인 듯 하다........... 


리팩토링 방향성

 

일단 스케줄러의 Lock 을 트랜잭션 밖으로 옮겼다

 

Transactional 안에서 Lock 을 잡음녀 Lock 해제 후 커밋까지 빈 구간이 생겨서 Lock method 랑 Transaction method 분리 필요

 

추가로 2번 문제처럼 지금 스케줄러가 너무 너무 많은 내용을 Transaction 으로 한 번에 처리하고 있기 때문에 

Reservation 하나 처리하는걸 Transaction 으로 둘 수 있도록 하기 

즉 예약 1건당 독립적인 Transaction 을 실행시키거나 메서드를 분리시켜야함 

-> 한 건 실패해도 다음 건 진행, Lock 획득 실패 시 쿨하게... skip 하고 1분 있다가 다시 시도해보기 

 

3번 문제 해결을 위해 Reservation Entity 의 expire() method 내부에 아래같은 메서드 추가해주기 

if (status != PENDING) return;

또는... 낙관락 넣어서 Version 관리 해주기 

 

가능할 지 모르겠지만... PortOne 환불은 Transaction 밖으로 빼거나 비동기 처리 할 수 있음 좋을 것 같은데 이 부분에 대해서 한 번 논의 해봐야할것같다 


해결 과정

 

일단 Lock 을 트랜잭션 바깥으로 옮겼다 (expire만...)

 

이전 코드 

Scheduler : reservationService 의 expirePendingReservations 호출
Reservation Service 가 @Transaction 을 획득 한 후 타겟 리스트를 찾아서 전체 반복문 시작 (전체 transactional)
반복문 내부에서 Lock 을 잡고 expire 처리 
모두 expire -> Lock 해제 -> commit 

 

이후 코드 

Scheduler 가 타겟 리스트 찾고 하나씩 Lock 을 잡음
ReservationExpireService 의 @Transactional expireOne 호출 (건별 transactional)
reservation 재조회, expire 처리 -> commit -> Lock 해제 

 

 

추가로 모든 진입점ㅇㄹ Reservation Lock 으로 통일해줬음 


코드를 작성하면서 확인 해봐야 할 부분을 메모장에 얼레벌레 정리해둔거 풀기 

 

1

PaymentService 에서도 Lock 을 획득해서 expire 처리할 때결제 confirm 이랑 부딪히면 만료 실패되는데 상관없는지...

-> 정상 동작일듯... 한번 실패하면 -> 자동 복구 + 결제에 우선권 주기 

Redis Lock 에서 부딪혔을 때 우선권을 주는 방법이 없으니까 일단 .... 만료 되고 있는 상태면 결제는 실패 

결제되고 있는 상태면 당연히 만료는 실패 

 

2

만료처리 (target 파악 -> resrvation status 업데이트 -> 실제 환불 (외부 API)) 가 생각보다 시간이 오래걸려서 

한 건 만료에 1초정도 걸린다고 치면 60건만 만료된다고 해도 scheduler 가 다 실행시키기도 전에 다음 스케줄러가 실행되진 않을까  하는 고민.....

-> 지금 코드가 fixedRate 가 아니라 fixedDelay 라서 상관 없음 -> 이전 실행이 끝난 뒤 60초 대기 

같은 인스턴스에선 중복 실행 x

PortOne이 한 번 5초씩 걸리면 60건 = 5분..이 소요됨 -> 그러는 동안 다른 만료 건은 계속 쌓임 

 

-> 비동기 처리 해볼까 라는 생각

 

3

비동기 처리 한다고 하면 .... 근거가 있어야 할 것 같음 

-> grafana 로 실제 스케줄러 실행 + 만료처리 + 환불처리 에 시간이 얼마나 걸리는지 측정해보고 
한 건 당 만료처리에 평균적으로 얼마의 시간이 소요되는지도 확인 해보고 ... 생각보다 시간이 오래걸리면 분리하는게 나을 것 같다

 

4

PaymentConfirm, Cancel, Webhook 도 도 ReservationLock 잡도록 하기

  • 기존: PaymentService.confirm → PaymentLockService (lock:payment:{reservationId})
  • 변경: PaymentService.confirm → ReservationLockService (lock:reservation:{reservationId})
  • 대상: confirm, confirmByWebhook, failByWebhook → ReservationLockService 로 교체
    ReservationService.cancel → Reservation Lock 으로 감쌈
    PaymentRefundService.syncRefundedByWebhook → ReservationLockService 로 교체
  • 결과: 같은 reservationId에 대한 모든 진입점이 동일 Lock으로 직렬화됨

 

5

흐름이 지금 PaymentService.confirm -> confirmPayment -> reservationService.confirmAfterPayment 흐름

동일 Lock 키 잡고있는 거 없는지 확인해보기 reservationId 두 번 잡는 일 없는지 확인 해보기 ....

Lock key  클래스  메서드 
lock:reservation:{reservationId} ReservationExpireScheduler expirePendingReservations
ReservationService complete
cancel
PaymentService confirm
confirmByWebhook
failByWebhook
PaymnetRefundService syncRefundByWebhook
lock:sitter:{sitterProfileId} ReservationService ConfirmAfterPayment
lock:payment:{reservationtId} PaymentRefundService refundPaidPayments
refundWithPenalty

prefix 가 다르기 때문에 reservationId 를 둘 다 잡아도 서로 못 봄 : 다른 Lock 키가 됨

 

refundPaidPayments랑 refundWithPenalty는 호출자 (cancel, expire 등)가 이미 lock:reservation을 잡고 들어오니까, 내부 lock:payment는 중복이긴 한데 키가 달라서 충돌 XXX

나중에 lock:payment 싹다 없앨 때 같이 없애주기

 

충돌 예약 자동 취소 (reservationService.refundIfPaid) 에도 Lock 을 걸어야할거같음 

-> 아니다 그냥 isPending() 으로 1번, @Version 으로 막기 

 

6

다른 메서드들도 Lock 을 트랜잭션 밖으로 빼줘야하나?

 

실제로 지금 아래 메서드들이 ... 모두 Transaction 진입 -> Lock 획득 -> 비즈니스로직 -> Lock 해제 -> Transaction 종료 흐름 

PaymentService confirm, confirmByWebhook, failByWebhook, fail
ReservationService cancel, complete, confirmAfterPayment
PaymentRefundService syncRefundedByWebhook, refundPaidPayments, refundWithPenalty

너무 많아서 다른 방법을 찾아야할듯 

 

-> @Version 추가해서 race 를 마지막에라도 잡기 

/*
UPDATE 시 JPA 가 자동으로 version 을 +1 하고, WHERE 절에 기존 version 을 추가 
동시에 두 트랜잭션이 같은 reservation 을 수정하면 한쪽은 OptimisticLockException 으로 실패
Reservation Lock 으로 1차 직렬화 + @Version 으로 마지막 방어선
 */
@Version
@Column(nullable = false)
private Long version;

 

@Version 만 쓰면 안 되는 이유 

1. version 은 DB UPDATE 시점에만 작동하기 때문에 트랜잭션이 끝까지 실행되고 나서 Commit 단계에서 실패하게 됨 

 

그래서 만약에 스레드 A, B가 동시에 Lock 을 잡고 둘 다 외부 API 를 호출해서 환불같은 로직을 실행하게 되면 A, B 둘다 환불

-> 나중에 실행한 스레드가 version 때문에 Rollback 이 되어야하지만 외부 API 는 안 됨 

-> Lock 이 있으면 B 는 PortOne API 호출 자체를 못 함 (코드 자체를 직렬화)

 

만약에 version 만 쓰면 두 트랜잭션 모두 끝까지 실행되어서 한쪽은 모든 작업 (DB 업뎃, 쿠폰 복구, 외부 API 등.등...)

Lock 이 있으면 어차피 B 는 시작도 못 하니까 


일단 결과...

보호 계층 역할
Reservation Lock (key 통일) 같은 reservation에 대한 모든 진입점 직렬화
상태 가드 (isPending() 체크) 이미 다른 상태면 조용히 skip
@Version Lock이 못 잡은 미세 race도 마지막 방어선에서 차단
건별 트랜잭션 (Expire) 1건 실패해도 다른 건 진행
Lock 양보형 (Expire) 결제 confirm 등 우선 처리에 양보

 


추후 과제

 

cancel, confirm, fail Lock 이 Transaction 안에 있는데 그냥 Version 으로 막아뒀음 

-> 이후에 리팩토링 해야함

webhook 복구가 있어서 당장 장애는 안 나겠지만 ....... 그래도 해야함

lock -> tx -> business -> commit -> release 순서로 이루어지도록 

 

refundIfPaid Lock 고민 

 

refundPaidPayments, refundWithPenalty 에서 쓰는 payment:id 락도 없애도록 리팩토링