HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
✍🏻
Learnary (learn - diary)
/
🌪️
Soloving_Issue_Log
/
MySql, Redis 혼용시 @Transactional 주의사항

MySql, Redis 혼용시 @Transactional 주의사항

생성
May 15, 2023 05:43 AM
kindOf
Transaction
Status
Done
 
Build-UP요약 : AOP 사용함을 인지해야 한다.Spring @Transactional 애노테이션 동작Redis와 Mysql 혼용시 주의사항

Build-UP

[AOP] - 트랜잭션 전파속성
 
 

요약 : AOP 사용함을 인지해야 한다.

notion image
 
notion image
 

Spring @Transactional 애노테이션 동작

Spring @Transactional 은 AOP로 실행시점에 트랜잭션 열고 닫고 하는 이러한 반복적인 행위를 모듈화하여 제공하고
 
애노테이션 사용 이전 코드
private final UserRepository userRepository; public void addUsers(List<User> userList) { TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition()); try { for (User user: userList) { if(isEmailNotDuplicated(user.getEmail())){ userRepository.save(user); } } this.transactionManager.commit(status); } catch (Exception e) { this.transactionManager.rollback(status); throw e } }
 
애노테이션 사용 후
private final UserRepository userRepository; @Transactional public void addUsers(List<User> userList) { for (User user : userList) { if (isEmailNotDuplicated(user.getEmail())) { userRepository.save(user); } } }
 
결과적으로 try, catch 구문이 사라지고 핵심 비즈니스 로직만을 수행하도록 작성하여 간결해지면서 가독성이 증가하였다.
스프링부트에서는 @Transactional은 여러 비즈니스로직을 그룹화 하는 장점이 있다.
 

Redis와 Mysql 혼용시 주의사항

분산환경의 동시성 이슈를 해결하기 위해 레디스로 lock을 걸고 mysql의 데이터 상태를 변경할 때 트랜잭션 전파속성에 주의해야 한다.
 
@Transactional public void decrease(Long key, Long quantity)throwsInterruptedException { while(!redisLockRepository.lock(key)) { Thread.sleep(100); } try{ stockService.decrease(key, quantity); }finally{ redisLockRepository.unlock(key); } } ============================== StockService .java ===================================== @Transactional public void decrease(Long id, Long quantity) { Stock stock = stockRepository.findById(id).orElseThrow(); stock.decrease(quantity); stockRepository.saveAndFlush(stock); }
 
[문제가 되는 이유]
하나의 락을 거는 트랜잭션(RedisService)과 비즈니스 로직의 트랜잭션(StockService)을 같은 트랜잭션으로 사용할 경우 commit이 되기전에 unlock을 수행할 수 있고, unlock 과 commit 사이의 시점에 또 다른 요청이 들어올 때 커밋이 되기 전이므로 동시성 문제가 발생할 수 있게 된다
 
[자세히 들여다보기]
  • 락의 해제 시점이 트랜젹션 커밋 시점보다 빠른 경우 := [트랜잭션 전파 레벨 조절하기 전]
    • notion image
      1. Client1, Client2 두 사용자가 재고 차감을 위해 메서드에 동시에 접근한다.
      1. Client1이 간발의 차이로 락을 먼저 선점하고 재고를 조회하여 현재 재고가 10인 것을 확인한다.
      1. Client1는 재고를 하나 차감하고 락을 해제한다(재고는 10-1=9개), 이때 트랜잭션은 커밋 되지 않은 상태이다.
      1. Client2는 락이 해제되었다는 신호를 받고 락을 획득하고 재고를 조회한다.
      1. Client1에서 재고를 차감했지만 아직 트랜잭션 커밋이 되지 않은 상태이기에 Client2는 재고 조회 시 10으로 조회한다.
      1. Client2는 동일하게 재고를 하나 차감하고 락을 해제하고 커밋 한다. (db에는 10-1=9 로 재고가 반영된다)
      결국 두 사용자가 동시에 접근하여 재고를 차감했지만 실제 DB에 차감된 재고는 2개가 아닌 1개이다.
      렇듯 락의 해제가 트랜잭션 커밋보다 먼저 이뤄지면 데이터 정합성이 깨질 수 있는 구멍이 존재한다.
       
       
  1. Client1, Client2 두 사용자가 재고 차감을 위해 메서드에 동시에 접근한다.
  1. Client1이 간발의 차이로 락을 먼저 선점하고 재고를 조회하여 현재 재고가 10인 것을 확인한다.
  1. Client1는 재고를 하나 차감하고 락을 해제한다(재고는 10-1=9개), 이때 트랜잭션은 커밋 되지 않은 상태이다.
  1. Client2는 락이 해제되었다는 신호를 받고 락을 획득하고 재고를 조회한다.
  1. Client1에서 재고를 차감했지만 아직 트랜잭션 커밋이 되지 않은 상태이기에 Client2는 재고 조회 시 10으로 조회한다.
  1. Client2는 동일하게 재고를 하나 차감하고 락을 해제하고 커밋 한다. (db에는 10-1=9 로 재고가 반영된다)
결국 두 사용자가 동시에 접근하여 재고를 차감했지만 실제 DB에 차감된 재고는 2개가 아닌 1개입니다. 이렇듯 락의 해제가 트랜잭션 커밋보다 먼저 이뤄지면 데이터 정합성이 깨질 수 있습니다.
  • 트랜잭션 커밋 이후 락을 해제하는 경우 := [트랜잭션 전파 레벨 조절 후]
    • notion image
      1) Client1, Client2 두 사용자가 재고 차감을 위해 메서드에 동시에 접근한다.
      2) Client1이 간발의 차이로 락을 먼저 선점하고 재고를 조회하여 현재 재고가 10인 것을 확인한다.
      3) Client1는 재고를 하나 차감하고 트랜잭션을 커밋 한다.(재고 = 9)
      4) Client1는 락을 해제하고 Client2는 락이 해제되었다는 신호를 받고 락을 획득한다.
      5) Client2는 락 획득 후 재고를 조회한다, 이때 재고는 9개이다.
      6) Client2는 재고를 하나 차감하고(재고 9-1=8) 트랜잭션 커밋 후 락을 해제한다.
       
      두 사용자가 동시에 접근해도 모두 정상적으로 재고가 자캄된다.
      이로써 동시성 이슈 구멍 또한 확실히 방어하여 데이터 정합성을 보장할 수 있게 됬다.
 
[code level로 살펴보기]
  1. 현재 트랜잭션은 RedisService → StockService 로 트랜잭션 물리 트랜잭션 경계를 나누지 않고 이어갈 것이다.
    1. 왜냐하면 Default 속성으로 Required 이기 때문이다.
  1. @Transactional 을 풀어서 코드로 반영해보자
    1. ============================== RedisService.java ===================================== public void decrease(Long key, Long quantity)throws InterruptedException { TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // 핵심 코드 while(!redisLockRepository.lock(key)) { Thread.sleep(100); } try{ stockService.decrease(key, quantity); }finally{ redisLockRepository.unlock(key); } } this.transactionManager.commit(status); } catch (Exception e) { this.transactionManager.rollback(status); throw e } } ============================== StockService .java ===================================== public void decrease(Long id, Long quantity) { TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // 핵심 코드 Stock stock = stockRepository.findById(id).orElseThrow(); stock.decrease(quantity); stockRepository.saveAndFlush(stock); this.transactionManager.commit(status); } catch (Exception e) { this.transactionManager.rollback(status); throw e } }
    2. RedisService 에서 락획득을 반복적으로 수행후 락을 얻어낸다.
    3. StockService의 decrease가 모두 끝이 나면 논리적인 경계에서 1번의 커밋이 최초 발생된다.
      1. 하지만 실질적으로 commit이 날라기지는 않는다
    4. 다시 RedisService 로 돌아와 해당 메소드가 끝이 났으니 redisLockRepository.unlock(key) 을 하게 될 것이다.
    5. 그리고 마침내 논리적인 경계에서 또 1번의 커밋이 발생하게 된다.
    6. 최종 커밋이 이루어지게 되며 실제로 재고 감소 로직이 쿼리가 날아간다.
 
이미 3번 과정에서 락 잠금 해제를 했는데 호출자인RedisService 최종 커밋이 일어나야 실제 제고 감소 쿼리가 날아가게 된다.
최종 커밋은 JPA 관해서만 이루어며 락 잠금은 해제는 상관 없다.
그러므로 잠금을 해제하고 다른 요청이 수행되어 더빨리 전달되어 동시성 이슈가 발생할 수 있는 여지는 충분히 있다.
 
[해결 방법]
스프링 트랜잭션 전파속성은 이전의 트랜잭션을 재사용 혹은 새롭게 생성 할 것인지 결정하는 설정 방식이다.
RedisService에서 lock을 해제하기전에 재고 감소 쿼리가 바로 발생된다면 문제가 되지 않는다.
  1. 트랜잭션 전파속성 없이 바로 update 쿼리를 날린다.
  1. 트랜잭션 전파 속성을 조정한다. := 스프링부트에서의 물리적 트랜잭션 경계를 나눈다
    1. 물리적인 트랜잭션 경계를 나누기 위한 방법은 Required_NEW 속성이다
      REQUIRED_NEW 속성
      외부 트랜잭션과 내부 트랜잭션을 완전히 분리하는 속성이다.
      총 2개의 물리 트랜잭션이 사용되며 각각의 트랜잭션 별로 커밋과 롤백이 수행된다.
      notion image
변경 후 코드
@Transactional public void decrease(Long key, Long quantity)throwsInterruptedException { while(!redisLockRepository.lock(key)) { Thread.sleep(100); } try{ stockService.decrease(key, quantity); }finally{ redisLockRepository.unlock(key); } } ============================== StockService .java ===================================== @Transactional(propagation = Propagation.REQUIRES_NEW) public void decrease(Long id, Long quantity) { Stock stock = stockRepository.findById(id).orElseThrow(); stock.decrease(quantity); stockRepository.saveAndFlush(stock); }