지난 포스트에서 Redisson을 이용하여 동시성 문제를 해결하는 코드를 구현했다.
프로젝트 리팩토링이 거의 끝나가 조회 API를 구체화하여 몇 개 추가하던 도중, 스레드 100개의 동시 요청을 직접적으로 받는 과정을 확인하고 싶어졌다.
그래서 실험해봤다.
결과는??
처참하다..
무엇이 문제였을까
트랜잭션 처리가 씹혔다.
@GetMapping("/test")
public void test() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(100);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
int finalI = i;
executorService.submit(() -> {
try {
log.info(finalI +"번째 일꾼 일한다.");
kakaopayStrategyApplication.test("lala");
} finally {
latch.countDown();
}
});
}
latch.await();
}
이 테스트를 이용해서 100개의 요청을 동시에 요청하면,
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void test(String id) {
log.info("일해라!");
Product product = productRepository.findById(1L).orElseThrow(() -> new RuntimeException("에헤이"));
log.info("상품 전 개수:" + product.getQuantity());
product.decreaseItemQuantity(1);
log.info("상품 후 개수:" + product.getQuantity());
}
원래 상품 200개에서 100개가 되어야 정상이다.
하지만, 계속 이상한 재고량이 남았다..
@Transactional(propagation = Requires_New)를 하면 트랜잭션의 새로운 시작으로 인해, AOP 이용할 경우 문제가 없을 거라고 생각했다.
문제 인지
이 분의 글을 읽고, 깨달았다..
temp method에 @Transactional을 사용하면 위험합니다.
내부적으로 AOP를 사용하기 때문에 unlock이 된 후 commit이 되므로 동기화가 제대로 되지 않을 수도 있습니다.
그래서 redis에 lock을 잡고 db와 redis의 transaction 처리를 따로 해줘야 합니다.
흡사 신의 목소리..
스프링에서 제공하는 @Transactional 애노테이션은 트랜잭션 로직을 AOP를 활용해서 타겟 메서드를 프록시로 감싼다.
그렇게 하면 비즈니스 로직 처리가 깔끔해지는 이점이 있다.
내가 Redisson lock을 이용하기 위해 이 또한 비즈니스 로직이 조금 섞여 들어가므로, 마찬가지로 AOP로 감쌌다.
@Transactional과 @Redislocked(내가 구현한 redisson lock 애노테이션 - AOP 활용) 의 동시 사용으로 인해 AOP가 제대로 순차적으로 동작하지 않아 트랜잭션이 씹힌것이다.
아 맞다.. 스프링 AOP는 프록시로 감싸는 방식으로 동작하는데, 내가 이걸 왜 놓쳤을까..
순서가 중요한 법이다. 이번 기회에 다시 한번 개념을 잡고 간다.
해결 방법
트랜잭션 로직을 또 다른 Aspect로 분리하기로 결정했다.
/**
* 트랜잭션 처리 aop
*/
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
@Order(value = 2)
public class TransactionAspect {
private final RedisFunctionProvider redisFunctionProvider;
@Around("@annotation(com.daniel.mychickenbreastshop.global.aspect.annotation.RedisLocked)")
public Object executeWithTransaction(ProceedingJoinPoint joinPoint) {
if (!isTransactional(joinPoint)) {
try {
return joinPoint.proceed();
} catch (Throwable e) {
throw new InternalErrorException(e);
}
}
RTransaction transaction = redisFunctionProvider.startRedisTransacton();
TransactionStatus status = redisFunctionProvider.startDBTransacton();
Object result;
try {
result = joinPoint.proceed();
redisFunctionProvider.commitRedis(transaction);
redisFunctionProvider.commitDB(status);
} catch (Throwable e) {
redisFunctionProvider.rollbackRedis(transaction);
redisFunctionProvider.rollbackDB(status);
throw new InternalErrorException(e);
}
return result;
}
private boolean isTransactional(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
return method.getAnnotation(RedisLocked.class).transactional();
}
}
트랜잭션 로직으로 감싸는 AOP 추가
@Order로 AOP의 감싸지는 순서를 지정하였다.
(숫자는 상대적 크기 비교, 숫자가 클수록 핵심 관심 모듈에 먼저 감싸짐)
실행
=> 이렇게 동시 요청 건에 대한 Redisson 분산락을 정확하게 적용하게 되었다. 트랜잭션의 처리도 무척이나 중요함을 깨달았고, AOP를 적용하면서 프록시 원리를 다시 한번 깨닫는 계기가 되었다.
'📕 Spring Framework > Spring Project' 카테고리의 다른 글
[이슈] Pageable test 관련 에러 (0) | 2022.11.23 |
---|---|
[Refactor] 패키지 구조와 의존성 (2) | 2022.10.14 |
[Redisson]을 이용한 분산 Lock 구현 & 동시성 문제 해결 (2) | 2022.09.27 |
결제 API 리팩토링 - [2] (feat. WebClient) (7) | 2022.09.22 |
결제 API 리팩토링 - [1] (feat. 전략 패턴) (2) | 2022.09.20 |