카테고리 없음

Lock- Transaction 사이 race condition 해결방법 2 : AOP

kjw81024 2026. 6. 1. 22:53

요약 

Transaction 시작 -> Lock 획득 -> 비즈니스 로직 -> Lock 반환 -> Transaction Commit 순서로 코드가 작성되어 있을 때 

Lock은 풀렸는데 DB 트랜잭션은 아직 커밋되지 않았기 때문에, 다른 스레드가 Lock을 획득해서 같은 데이터를 읽거나 수정할 수 있는 문제가 발생 

 

지금까지는 이 차이 (RaceCondition) 을 해결하기 위해 Lock Service 를 분리하고, 해당 서비스 내부에서 Transaction 으로 감싼 메서드를 호출함으로써 메서드가 끝나고 commit 된 이후 Lock 을 반환하는 형식으로 해결했다.

(Scheduler + Expire 로직)

 

하지만 이 방법은 리팩토링 범위가 좀 과한 편인데, 수정해야하는 메서드가 생각보다 너무 많음

-> AOP 를 사용하는 방법을 추천받았다

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key();
}
@DistributedLock(key = "'reservation:' + #reservationId")
@Transactional
public void confirm(Long reservationId) {
    ...
}

어노테이션을 생성하여 붙여주고 

@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RedisLockAspect {

    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint,
                       DistributedLock distributedLock) throws Throwable {
                       
        String lockKey = parseKey(
            distributedLock.key(),
            joinPoint
        );
        
        boolean locked = redisLock.tryLock(distributedLock.key());

        if (!locked) {
            throw new LockAcquisitionException("락 획득 실패");
        }

        try {
            return joinPoint.proceed();
        } finally {
            redisLock.unlock(distributedLock.key());
        }
    }
}

분산락 AOP가 @Order 어노테이션을 사용해서 Transaction 보다 먼저 사용되도록 설정 

받아온 Lock key 를 joinpoint 에서 getSignature 로 빼서 파싱해주는 로직을 추가해주면 된다.

 

사용은 그냥 메서드 위에 어노테이션 올려주면 끝


AOP 로 Lock-Transaction race condition 문제가 해결되는가

결론만 말하자면..... 조건만 맞으면 해결은 된다 

같은 메서드에 두 어노테이션이 붙어있으면 원래는 무슨 순서로 도입되느지 알 수 없지만, @Order 로 제어한다면 순서가  보장

또한 @Around 는 바깥에 있을 수록 먼저 진입하고 나중에 종료하므로 우리가 원한 흐름과 동일하다

(Lock 획득 -> Transaction 시작 -> 비즈니스 로직 -> Transaction commit 또는 Rollback -> Release Lock)

 

하지만 이 때

프록시를 경유한 호출이어야한다는 조건이 존재한다.

(동일 클래스 내부에서 부르면 AOP 도 안 타고 Lock 도 안 탐.. 사실 이건 @Transactional 에서 했던 내용과 동일하다)


장단점 비교 

처음 구현한 방식 (Lock Service 를 분리) 과 새로운 방식 (AOP 도입) 의 차이점을 확인해보기

  장점 단점
LockService 분리 호출 구조가 곧 락-트랜잭션 순서
self-invocation 함정 없음 
테스트, 디버깅이 편함 
락이 필요한 메서드마다 rapper 메서드, 두단계 호출
키가 동적이면 매번 키를 만들어 넘김
AOP 메서드 시그니처만 보고 락 의도 확인 가능
비즈니스 코드와 락 인프라가 분리됨
self-invocation 함정 
다른 팀원이 transaction 의 Order 에 손대면 안 됨...
디버깅이 번거로움 (호출 스택에 advice frame 이 추가됨)

 

결론

락이 걸리는 메서드가 적으면 그냥 LockService 방법이 안전하고 명확 

하지만 지금처럼 Lock 이 필요한 메서드가 많다면 AOP 가 유지보수성이 좋음 

+ Self invocation 함정에 안 걸릴 자신이 있다면...~~


도입 계획 정리 

일단 키 Prefix 를 통일 

현재 payment:{reservationId}, reservation:{reseravtionId} 이렇게 두 개가 있으니까 키를 통일해서 

같은 예약에 대해 다른 키로 락을 잡아서 상호 배제가 되지 않는 문제 해결 

 

A 메서드가 B 메서드를 호출하는 형식의 로직에서 

A 메서드에서는 reservation:{id} 로 Lock 을 획득하고

B 메서드에서 payment:{id} 로 잡고 있었다면 키가 다르기 때문에 락을 정상적으로 획득할 수 있지만 

ex)

cancel (reservation lock)
 └─ refundPaidPayments (payment lock)  <- 키가 달라서 살아남음

 

만약에 B 메서드도 reservation:{id} 로 키를 잡으면 진입에 실패함 

-> 진입점인 A 메서드에만 락을 걸고 내부 헬퍼 메서드에는 락을 없앰 

 

즉 어노테이션으로 처리할 메서드

ReservationService complete
cancel
PaymentService create

 

락을 걸지 않는 헬퍼 메서드 

PaymentRefundService refundPaidPayments
refundWithPenalty
refundSitterDepositAfterCompletion

 

서비스 코드로 넘길 메서드

ReservationService confirmAfterPayment (시터 락, 이미 완료)
ReservationExpireService expireOne (스케줄러용, 이미 완료)
PaymentService confirm
fail
confirmByWebhook
failByWebhook
PaymentRefundService syncRefundedByWebhook

 

confirm, fail, confirmByWebhook, failByWebhook, syncRefundedByWebhook 를 서비스 코드로 넘기는 이유는 

파라미터로 merchantUid만 받고, reservationId는 DB 조회 후에야 알 수 있기 때문에 그냥 서비스 코드로 넣기 

다만 PaymentLockService 에서 가져오진 않고 ReservationLockService 로 통일 

 

리팩토링 순서 

1. PaymentRefundService 에 헬퍼메서드 3개에 락이 걸려있다면 삭제 

 

2. PaymentLockService 삭제 + confirm, confirmByWEbhook, fail, failByWebhook, syncRefundedByWEbhook 을 Reservation Service Lock 으로 처리

3. complete, cancel, create 의 기존 락 삭제 + 어노테이션 위에 붙여주기


1번 과정

PaymentRefundService 에 헬퍼메서드 3개에 락이 걸려있다면 삭제 

 

귀찮아서 못적었지만 refundWithPenalty 도 삭제했다 

이제 헬퍼메서드는 호출자 (cancel, expireOne 메서드)의 reservation Lock 에 의존하게 됨 

 

이제 PaymentLockService 를 사용하는 로직이 존재하지 않기 때문에 제거

 


2번 과정 

 

confirm, confirmByWEbhook, failByWebhook, syncRefundedByWEbhook 은 Service code 를 분리하면서 추가했었고

이번에 fail 에도 Lokc 을 추가

 

@Transactional
public PaymentResponseDto fail(Long memberId, FailPaymentRequest request) {
    Payment payment = paymentRepository.findByMerchantUid(request.merchantUid())
            .orElseThrow(() -> new PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND));

    return reservationLockService.executeWithReservationLock(payment.getReservationId(), () -> {
        validatePaymentOwner(memberId, payment);
        validateFailable(payment);

        payment.fail(request.failedReason());
        restoreCouponIfApplied(payment);

        log.info("[PaymentService] 결제 실패 처리 paymentId={}, merchantUid={}, reason={}",
                payment.getId(), payment.getMerchantUid(), request.failedReason());

        return PaymentResponseDto.from(payment);
    });
}

미리 Fail 은 reservationLock 을 걸어주기 

 

payment 부분은 메서드 파라미터에 Reservation ID 가 존재하지 않고 (FailPaymentRequest 에도 reservationId 가 X)

Version 으로 한 번 더 관리 

 

Version 사용하는 곳이 많아져서 GlobalExceptionHandler 에도 추가해줬다

@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ApiResponse<Void>> handleObjectOptimisticLockingFailureException(
        ObjectOptimisticLockingFailureException exception,
        HttpServletRequest request)
{
    log.warn("[ObjectOptimisticLockingFailureException] path={}", request.getRequestURI(), exception);

    return ResponseEntity
            .status(CommonErrorCode.CONFLICT.getStatus())
            .body(ApiResponse.fail(ErrorResponse.of(
                    CommonErrorCode.CONFLICT,
                    request.getRequestURI()
            )));
}

 

기존 PessimisticLockingFailureException 이 있길래 형식을 맞춰줌 


3번 과정

락 Value UUID (락의 주인이 누구인지) 를 검증한 후 release 하는 로직 

 

Transactional 어노테이션보다 Lock 어노테이션이 먼저 실행되어야하기 때문에 

Order 을 HIGHEST_PRECEDENCE 로 설정 

@Slf4j
@RequiredArgsConstructor
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RedisLockAspect {

    private static final String LOCK_PREFIX = "lock:";
    private static final Duration LOCK_TTL = Duration.ofSeconds(10);

    private final StringRedisTemplate stringRedisTemplate;
    private final SpelExpressionParser parser = new SpelExpressionParser();

    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint,
                       DistributedLock distributedLock) throws Throwable {

        // distributed Lock annotation 을 사용할 때
        // @DistributedLock(key = "'reservation:' + #reservationId") 요런식으로 사용하게 되면
        // Lock Key 가 자동으로 lock:reservation:1 이런식으로 됨
        String lockKey = LOCK_PREFIX+ parseKey(distributedLock.key(), joinPoint);

        // TTL 만료 후 다른 스레드의 락을 잘못 지우는 것을 막는 안전장치
        String lockValue = UUID.randomUUID().toString();

        Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, LOCK_TTL);

        if (!Boolean.TRUE.equals(acquired)) {
            log.warn("[RedisLockAspect] 락 획득 실패 key={}", lockKey);
            throw new ReservationException(ReservationErrorCode.RESERVATION_LOCK_FAILED);
        }

        try {
            return joinPoint.proceed();
        } finally {
            releaseLock(lockKey, lockValue);
        }

    }

    // 락 해제 시 value 값 (UUID) 가 옳게 되어있는지 확인
    private void releaseLock(String lockKey, String lockValue) {
        String saved = stringRedisTemplate.opsForValue().get(lockKey);
        if (lockValue.equals(saved)) {
            stringRedisTemplate.delete(lockKey);
        }
    }

여긴 빠져있지만 parseKey 에서 메서드 파라미터를 기준으로 값을 가져온다 

 

Service 코드를 분리해서 작동시켰던 Lock 과 동일한 공간을 사용할 수 있도록 함 (Lock key 를 똑같이 만들어서)

 

기존에 complete 와 cancel 메서드는 내부에 LockService 를 호출하고 있었지만

Annotation 을 추가하면 같은 Redis 키를 한 스레드가 두 번 잡으려고 시도

-> RESERVATION_LOCK_FAILED 예외 처리 

-> Finally 에서 Lock 해제 

 

즉 작동시키려는 메서드가 항상 실패하기 때문에 어노테이션을 추가하고 기존 Service 에서 호출하던 Lock 은 없애준다 


회고

 

한 거 요약...

기존 Service 코드로 분리된 락(LockService.executeWithLock(...))을 @DistributedLock 어노테이션 + AOP 방식으로 옮김

어노테이션을 추가해주며 재진입 방지를 위해 기존  Lock 은 제거해주기 (deadlock 방지)

Key 통일, Payment/ReservationPayment 에 @Version을 추가해 stale write 방어선 추가 

 

  Service Annotation+AOP
명시성  흐름이 코드에서 보임 선언적
보일러 플레이트 메서드마다 래퍼 필요 어노테이션 한 줄
Self-invocation 위험 없음 있음 (프록시 경유 필요)
Key 처리 호출부에서 직접 SpEL 로 파라미터에서 추출 

(* 보일러 플레이트: 새로운 기능(비즈니스 로직)을 구현하기 위해 매번 반복적으로 작성해야 하는 필수 코드)

 

장단점이 있으므로 상황에 따라 혼용 

webhook 처럼 merchantUid → reservationId 룩업이 필요한 케이스는 service 코드

reservationId가 파라미터에 깔끔하게 있는 경우는 어노테이션

 

@Version 역할 (Optimistic Locking)

 

Lock 으로 직렬화 1차 방어 이후

commit 된 다음에 @Version 이 마지막 방어선이 됨 

락 획득 전 stale read 후의 write 를 잡음