우아한테크코스 기술블로그 '테코블'의 선착순 티켓 예매의 동시성 문제: 잠금으로 안전하게 처리하기 포스트에서 낙관적 락 재시도에 대해 다루었는데, 해당 내용을 별개의 포스트로 공유하고자 글을 작성한다.
위 포스트에서는 선착순 티켓 예매 상황에서 발생하는 동시성 문제를 해결하기 위한 다양한 시도 과정을 설명하니 관심이 있으면 읽어보길 추천한다.
낙관적 락이란?
락(잠금, Locking)은 데이터가 읽힌 후 사용될 때까지 데이터가 변경되는 것을 방지하기 위한 조치이다.
잠금 전략으로는 여러 트랜잭션 간 충돌이 일어나지 않을 것이라 가정하는 낙관적 락(Optimistic Lock) , 여러 트랜잭션 간 충돌이 일어날 것이라 가정하는 비관적 락(Pessimistic Lock) 이 있다.
낙관적 락은 실제로 DB에 락을 걸지 않고, 동시 문제 상황을 어플리케이션 레벨에서 잡아주는 잠금 기법이다.
JPA에서는 버전을 통해 데이터의 정합성을 맞춘다.
낙관적 락 재시도
낙관적 잠금은 버전 불일치 시 처리를 어플리케이션 레벨에서 담당하게 된다.
이는 버전 충돌로 인해 요청 처리에 실패할 경우, 직접 예외를 처리하여 재시도하는 로직을 구현해야 함을 뜻한다.
이러한 재시도 로직은 핵심 비즈니스 로직이 아니다.
또한, 여러 낙관적 락 메서드에서 공통적으로 적용되는 로직 즉, 횡단 관심사이다.
Spring에서는 이러한 횡단 관심사를 주로 AOP를 활용해 구현한다.
AOP를 활용한 재시도
먼저 @Retry 어노테이션을 정의한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
int maxRetries() default 1000;
int retryDelay() default 100;
}
여기서 maxRetries는 최대 재시도 횟수이고, retryDelay는 재시도 간격이다.
기본 값으로 최대 1000번까지 0.1초 간격으로 재시도하도록 설정했다.
다음은 낙관적 락 재시도 로직을 구현한 Aspect 코드이다.
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class OptimisticLockRetryAspect {
@Around("@annotation(retryAnnotation)")
public Object retryOptimisticLock(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
Exception exceptionHolder = null;
for (int attempt = 0; attempt < retry.maxRetries(); attempt++) {
try {
return joinPoint.proceed();
} catch (OptimisticLockException | ObjectOptimisticLockingFailureException | StaleObjectStateException e) {
exceptionHolder = e;
Thread.sleep(retry.retryDelay());
}
}
throw exceptionHolder;
}
}
여기서 @Order(Ordered.LOWEST_PRECEDENCE -1) 부분을 주목하자.
@Retry은 일반적으로 @Transactional과 함께 사용된다.
이때 Advice 적용 순서가 결과에 영향을 미친다.
@Order는 Advice가 적용될 순서를 결정한다.
@Transactional의 기본 Order값은 LOWEST_PRECEDENCE (Integer.MAX_VALUE)이다.
Order 값이 작을수록 해당 어노테이션이 먼저 적용됨이 보장된다.
@Retry는 @Transactional 직전에 실행되도록 하기 위해 Order 값을 LOWEST_PRECEDENCE - 1로 설정했다.
@Retry 어노테이션은 아래와 같이 적용할 수 있다.
@Transactional
@Retry
public void ticketing(long ticketId) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new IllegalArgumentException("Ticket Not Found."));
ticket.increaseReservedAmount();
int sequence = ticket.getReservedAmount();
reservationRepository.save(new Reservation(ticket, sequence));
}
spring-retry @Retrayble를 활용한 재시도
위의 방법은 직접 AOP 코드를 작성해줘야 한다는 번거로움이 존재한다.
Spring에는 해당 기능을 제공하는 라이브러리 'spring-retry'가 존재한다.
(나는 바퀴를 재발명하고 있었다..!)
해당 라이브러리의 @Retryable 어노테이션을 활용하면 위와 동일한 기능을 손쉽게 수행할 수 있다.
재시도 간격 설정을 위한 @Backoff, retry 후 최종적으로 Exception이 발생했을 때 처리를 돕는 @Recover 어노테이션도 제공한다.
해당 라이브러리를 활용해 위와 같은 기능을 하는 코드를 작성해 보자.
의존성 등록
spring-retry 어노테이션을 사용하기 위해서는 추가적인 의존성 등록이 필요하다.
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
첫 번째 의존성(org.springframework.retry:spring-retry)는 spring-retry를 활용하기 위한 의존성이다.
두 번째 의존성(org.springframework:spring-aspects)은 선언적 방식의 Retry를 하기 위해 필요한 의존성이다.
Enable Retry
@Retryable 어노테이션을 활용하기 위해서는 설정 파일에 @EnableRetry 어노테이션을 추가해야 한다.
SpringBoot EntryPoint 클래스에 작성하는 방법과, 별도의 클래스 파일에서 작성하는 방법이 있다.
@EnableRetry
@SpringBootApplication
public class LockApplication {
public static void main(String[] args) {
SpringApplication.run(LockApplication.class, args);
}
}
이때, @EnableRetry의 order값을 지정할 수 있다.
기본 order값은 LOWEST_PRECEDENCE -1이므로 항상 @Transactional 직전에 적용됨이 보장되어 별도로 설정해주지 않았다.
@Retryable
낙관적 락의 버전 충돌 시 발생하는 예외는 아래와 같이 세 가지이다.
- (JPA) javax.persistence.OptimisticLockException
- (Hibernate) org.hibernate.StaleObjectStateException in Hibernate
- (Spring) org.springframework.orm.ObjectOptimisticLockingFailureException
Spring Data JPA에서 낙관적락을 사용하게 되면 버전 충돌 시 Hibernate는 StaleStateException을 발생시킨다.
Spring은 이를 OptimisticLockingFailureException으로 래핑하여 처리한다.
본 예시 코드에서는 Spring Data JPA를 사용했으므로 ObjectOptimisticLockingFailureException 발생 시 재시도하도록 작성했다.
@Transactional
@Retryable(
retryFor = {ObjectOptimisticLockingFailureException.class},
maxAttempts = 1000,
backoff = @Backoff(100)
)
public void ticketing(long ticketId) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new IllegalArgumentException("Ticket Not Found."));
ticket.increaseReservedAmount();
int sequence = ticket.getReservedAmount();
reservationRepository.save(new Reservation(ticket, sequence));
}
주의점
낙관적 락 재시도 시 요청이 들어온 순서대로 처리되는 것이 아니라, 재시도의 타이밍에 따라 다음 처리될 요청이 결정된다.
이는 선착순 상황(ex. 요청의 순서에 따라 티켓 번호를 할당해야 하는 상황)에서는 적절하지 않다.
'프로그래밍' 카테고리의 다른 글
CATS: MySQL은 락 점유 순서를 어떻게 결정할까? (3) | 2023.11.17 |
---|---|
[Spring] REQUIRES_NEW와 데드락 위험성 (6) | 2023.10.13 |
[Spring] Mockito 테스트의 중복 given절 줄이기: lenient를 활용한 유틸리티 클래스 (1) | 2023.10.08 |
[Spring] TaskScheduler를 활용해 런타임에 동적으로 작업 예약하기 (7) | 2023.10.08 |
Spring Event란? (0) | 2023.09.17 |