요약
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 를 잡음