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 사이의 시점에 또 다른 요청이 들어올 때 커밋이 되기 전이므로 동시성 문제가 발생할 수 있게 된다
[자세히 들여다보기]
락의 해제 시점이 트랜젹션 커밋 시점보다 빠른 경우 := [트랜잭션 전파 레벨 조절하기 전]
Client1, Client2 두 사용자가 재고 차감을 위해 메서드에 동시에 접근한다.
Client1이 간발의 차이로 락을 먼저 선점하고 재고를 조회하여 현재 재고가 10인 것을 확인한다.
Client1는 재고를 하나 차감하고 락을 해제한다(재고는 10-1=9개), 이때 트랜잭션은 커밋 되지 않은 상태이다.
Client2는 락이 해제되었다는 신호를 받고 락을 획득하고 재고를 조회한다.
Client1에서 재고를 차감했지만 아직 트랜잭션 커밋이 되지 않은 상태이기에 Client2는 재고 조회 시 10으로 조회한다.
Client2는 동일하게 재고를 하나 차감하고 락을 해제하고 커밋 한다. (db에는 10-1=9 로 재고가 반영된다)
결국 두 사용자가 동시에 접근하여 재고를 차감했지만 실제 DB에 차감된 재고는 2개가 아닌 1개이다.
렇듯 락의 해제가 트랜잭션 커밋보다 먼저 이뤄지면 데이터 정합성이 깨질 수 있는 구멍이 존재한다.
Client1, Client2 두 사용자가 재고 차감을 위해 메서드에 동시에 접근한다.
Client1이 간발의 차이로 락을 먼저 선점하고 재고를 조회하여 현재 재고가 10인 것을 확인한다.
Client1는 재고를 하나 차감하고 락을 해제한다(재고는 10-1=9개), 이때 트랜잭션은 커밋 되지 않은 상태이다.
Client2는 락이 해제되었다는 신호를 받고 락을 획득하고 재고를 조회한다.
Client1에서 재고를 차감했지만 아직 트랜잭션 커밋이 되지 않은 상태이기에 Client2는 재고 조회 시 10으로 조회한다.
Client2는 동일하게 재고를 하나 차감하고 락을 해제하고 커밋 한다. (db에는 10-1=9 로 재고가 반영된다)
결국 두 사용자가 동시에 접근하여 재고를 차감했지만 실제 DB에 차감된 재고는 2개가 아닌 1개입니다. 이렇듯 락의 해제가 트랜잭션 커밋보다 먼저 이뤄지면 데이터 정합성이 깨질 수 있습니다.
트랜잭션 커밋 이후 락을 해제하는 경우 := [트랜잭션 전파 레벨 조절 후]
1) Client1, Client2 두 사용자가 재고 차감을 위해 메서드에 동시에 접근한다.
2) Client1이 간발의 차이로 락을 먼저 선점하고 재고를 조회하여 현재 재고가 10인 것을 확인한다.
3) Client1는 재고를 하나 차감하고 트랜잭션을 커밋 한다.(재고 = 9)
4) Client1는 락을 해제하고 Client2는 락이 해제되었다는 신호를 받고 락을 획득한다.
5) Client2는 락 획득 후 재고를 조회한다, 이때 재고는 9개이다.
6) Client2는 재고를 하나 차감하고(재고 9-1=8) 트랜잭션 커밋 후 락을 해제한다.
두 사용자가 동시에 접근해도 모두 정상적으로 재고가 자캄된다.
이로써 동시성 이슈 구멍 또한 확실히 방어하여 데이터 정합성을 보장할 수 있게 됬다.
[code level로 살펴보기]
현재 트랜잭션은 RedisService → StockService 로 트랜잭션 물리 트랜잭션 경계를 나누지 않고 이어갈 것이다.
왜냐하면 Default 속성으로 Required 이기 때문이다.
@Transactional 을 풀어서 코드로 반영해보자
============================== 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
}
}
RedisService 에서 락획득을 반복적으로 수행후 락을 얻어낸다.
StockService의 decrease가 모두 끝이 나면 논리적인 경계에서 1번의 커밋이 최초 발생된다.
하지만 실질적으로 commit이 날라기지는 않는다
다시 RedisService 로 돌아와 해당 메소드가 끝이 났으니 redisLockRepository.unlock(key) 을 하게 될 것이다.
그리고 마침내 논리적인 경계에서 또 1번의 커밋이 발생하게 된다.
최종 커밋이 이루어지게 되며 실제로 재고 감소 로직이 쿼리가 날아간다.
이미 3번 과정에서 락 잠금 해제를 했는데 호출자인RedisService 최종 커밋이 일어나야 실제 제고 감소 쿼리가 날아가게 된다.
최종 커밋은 JPA 관해서만 이루어며 락 잠금은 해제는 상관 없다.
그러므로 잠금을 해제하고 다른 요청이 수행되어 더빨리 전달되어 동시성 이슈가 발생할 수 있는 여지는 충분히 있다.
[해결 방법]
스프링 트랜잭션 전파속성은 이전의 트랜잭션을 재사용 혹은 새롭게 생성 할 것인지 결정하는 설정 방식이다.
RedisService에서 lock을 해제하기전에 재고 감소 쿼리가 바로 발생된다면 문제가 되지 않는다.
트랜잭션 전파속성 없이 바로 update 쿼리를 날린다.
트랜잭션 전파 속성을 조정한다. := 스프링부트에서의 물리적 트랜잭션 경계를 나눈다
물리적인 트랜잭션 경계를 나누기 위한 방법은 Required_NEW 속성이다
REQUIRED_NEW 속성
외부 트랜잭션과 내부 트랜잭션을 완전히 분리하는 속성이다.
총 2개의 물리 트랜잭션이 사용되며 각각의 트랜잭션 별로 커밋과 롤백이 수행된다.
변경 후 코드
@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);
}