[Spring] 스프링에서 트랜잭션의 작동 방식을 알아보자.

728x90

 

들어가며


Spring 어플리케이션을 사용해 무언가를 만들다보면 트랜잭션을 당연하게 사용한다.

추가적으로 여러 사람에 코드를 보면 @Transactional 어노테이션이 붙어있는 코드를 보고는 한다. 

 

나 또한 무의식 속에 @Transactional 을 서비스 계층에 있는 비즈니스 로직에 붙이고는 한다.

대충 알기로 트랜잭션, 즉 CUD 작업이 일어나는 곳에 붙이면 된다. 정도만 알고 있었고

 

어떠한 역할을 하고, 왜 붙여야 하는지는 제대로 알지 못했다.

이번 공부를 통해 확실하게 알아본 내용을 적어보려고 한다.

 

본론


데이터를 관리할 때 가장 중요하게 여기는 점은 정확한 데이터를 유지하는 것이다 

우리는 특정 실행 시나리오가 잘못되거나 일관되지 못한 데이터로 끝나 버리기를 원치 않는다 

 

예시를 보자 

1) 내 계좌에서 돈을 출금하여 친구에게 이체한다. 

2) 친구 계좌에 돈이 입금된다. 

 

이 두 단계는 모두 데이터를 변경하는 작업이며, 이체가 올바르게 실행되려면 두 작업이 모두 성공해야 한다 

 

하지만 2번에서 문제가 발생하여 작업을 완료할 수 없다면 어떻게 해야할까?

즉 1단계는 완료되었지만, 2단계가 완료되지 못하면 데이터 불일치가 발생한다 

 

즉, 나는 돈을 보냈는데 상대방은 받지 못하는 대참사가 발생하게 되는 것이다

 

위 문제는 데이터 일관성을 위반한것이다 

위 데이터 일관성을 유지하기 위해서는 두 단계 모두 올바르게 실행되거나, 하나가 실패하면 둘다 실행되지 않도록 진행해야 한다.

 

트랜잭션 이란?

 

트랜잭션에 특징에는 4가지 ACID 라는 것이 있다.

원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 영속성(Durability)

 

위에서 잠깐 일관성을 설명했다.

 

그렇다면 일관성을 지키기 위해서는 어떻게 해야할까? 

 

그래서 바로 원자성 이라는 특징이 있다.

원자성 - 정해진 가변 작업의 집합으로, 작업을 모두 올바르게 실행하거나 전혀 실행하지 않을 수 있다.

 

트랜잭션은 앱에서 이미 데이터를 변경했을 때 사용 사례의 어떤 단계에서 실패하더라도 데이터 일관성 유지를 보장하기 때문에 필수적이다

 

다시 두 단계로 구성된(단순화된) 이체 기능을 생각해보자 

1단계 전에 트랜잭션을 시작하고, 2단계 후에 트랜잭션을 종료할 수 있다

 

위 경우 두 단계가 모두 성공적으로 실행되면 트랜잭션이 종료될 때, 앱은 두 단계에서 수행한 변경 사항을 유지한다 

이를 트랜잭션이 'commit' 되었다고 한다.

 

커밋 작업은 트랜잭션이 종료되고 모든 단계가 성공적으로 끝날때 발생하므로 어플리케이션은 데이터 일관성을 지킬 수 있다.

 

그럼 반대로 2단계에서 트랜잭션이 실패를 하면 commit 을 하지않고 1단계로 다시 돌려준다.

이를 트랜잭션이 'rollback' 되었다고 한다.

즉 DB 데이터 불일치를 피하기 위해 데이터를 트랜잭션 시작 시점의 상태로 복원하는 것이다. 

 

트랜잭션에 대한 간단한 설명을 해둔 포스팅 입니다. 

https://hyeonq.tistory.com/138

 

기초 개념 같은 경우는 위 포스팅을 참고해주세요!

 

Spring 에서 트랜잭션의 동작 방식

스프링 앱에서 트랜잭션 사용 방법을 보여 주기 전에 스프링에서 트랜잭션 작동 방식과 트랜잭션 코드를 구현하고자 프레임워크가
제공하는 기능을 설명한다. 사실 트랜잭션 이면에는 스프링 AOP 애스펙트가 있다 

 

오늘날 대부분의 경우 어노테이션으로 애스펙트가 실행을 가로채고 변경해야 할 메소드를 표시한다.

스프링 트랜잭션에서도 비슷한 느낌이다.

 

AOP를 기반으로 데이터베이스 커넥션으로 부터 트랜잭션 관련 기능을 지원하도록 도와준다

 

스프링이 트랜잭션에서 감싸길 원하는 메소드를 표시하려고 @Transactional 이라는 어노테이션을 사용한다.

그 내부에서는 스프링이 애스펙트를 구성 설정(사용자가 직접 구현하는 대신 스프링이 제공) 을 하고

메소드가 실행하는 작업에 트랜잭션 로직을 적용한다.

 

🌟 즉 @Transactional 메소드가 있으면 스프링에서 구성한 애스펙트가 메소드 호출을 가로 챈다. 🌟

그리고 해당 호출에 대한 트랜잭션 로직을 적용한다. 

결과적으로 메소드가 런타임 예외를 발생시키면 어플리케이션은 메소드가 변경한 내용을 적용하지 않는다.

 

간단하게 코드로 보자

@Service
public class MoneyService {

	@Transactional
	public void sendMoney() {
		// 내 계좌에서 돈을 출금한다.
		// 대상 계좌에 돈이 입금된다.
	}
    
}

 

위 로직을 짜고 Controller 에서 호출을 한다.

private final MoneyService moneyService;

@PostMapping()
public void sendMoney() {
	moneyService.sendMoney();
}

 

컨트롤러에서 위 메소드가 호출이 되면 내부적으로 아래 과정을 거친다.

 

1)  컨트롤러 -> 서비스 로직으로 가면서 @Transactionl 어노테이션이 붙어있는 것을 확인했다.

     -  @Transactionl 은 스프링 트랜잭션 에스펙트에 메소드를 가로채도록 지시한다.

2) 스프링은 호출을 가로채는 애스펙트를 구성한다

3) 예를 들면 아래와 같은 애스펙트 로직을 실행한다.

try {
	//트랜잭션 시작
    
    	//가로챌 메소드 호출

	//트랜잭션 commit
} catch (RuntimeException e) {
	//트랜잭션 rollback
}

 

기본적으로 가로챈 메소드가 런타임 예외를 발생시키면 애스펙트는 트랜잭션을 rollback 한다.

가로챈 메소드가 정상적으로 실행이되면 트랜잭션을 commit 한다.

 

만약 위 로직 기준 내 계좌에서 돈을 출금은 했지만, 상대방 계좌에 입금이 안된 경우

트랜잭션을 롤백이 된다. 위에서 말한 일관성이 깨지게 되어 원자성에 의해 지켜지는 것이다.

 

스프링은 메소드가 런타임 예외를 발생시키면 트랜잭션을 롤백하는 것을 알고 있다.

 

스프링에서 예외에 대한 구현을 Custom 할 수 있지만,

그냥 애플리케이션을 항상 단순하게 유지하고 꼭 필요한 경우가 아니라면 프레임워크의 기본 동작에 의존하자.

 

Spring 에서 트랜잭션 사용

 위 상황에 대한 코드를 보면서 이해를 해보자.

 

간단하게 Entity 를 작성했다.

@Data
@Entity
public class Account {
	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;
	private String name;
	private BigDecimal amount;
}

 

실제로 프로젝트를 할 때는 @Data 어노테이션은 최대한 지양할 필요가 있다.

지금은 편의를 위해서 그냥 사용한다.

 

레포지토리를 작성했다.

public interface AccountRepository extends JpaRepository<Account, Long> {

	@Modifying
	@Query("update Account a SET a.amount = :amount where a.id = :id")
	void changeAmount(Long id, BigDecimal amount);
}

 

이제 위 레포지토리 를 사용하여 서비스를 작성해볼 것이다.

@Transactional(readOnly = true) // 기본 동작은 읽기 전용으로 한다.
@RequiredArgsConstructor
@Service
public class AccountService {
	private final AccountRepository accountRepository;

	// 메소드 호출이 트랜잭션에 포함되도록 지시한다.
	@Transactional
	public void sendMoney(Long idSender, Long idReceiver, BigDecimal amount) {
		Account sender = accountRepository.findById(idSender).orElse(null);
		Account receiver = accountRepository.findById(idReceiver).orElse(null);

		BigDecimal senderNewAmount = sender.getAmount().subtract(amount);
		BigDecimal receiverNewAmount = receiver.getAmount().subtract(amount);

		accountRepository.changeAmount(idSender, senderNewAmount);
		accountRepository.changeAmount(idReceiver, receiverNewAmount);
	}
	
}

 

이제 최종적으로 호출이 될 컨트롤러 이다. 

@RequiredArgsConstructor
@RestController
public class AccountController {
	private final AccountService accountService;
	
	@GetMapping("/account")
	public void sendMoney(Long senderId, Long receiverId, BigDecimal amount) {
		accountService.sendMoney(senderId,receiverId,amount);
	}
}

 

 

사용자가 /account api 를 호출을 했다고 치자. 그러면 동작은 아래와 같다.

 

1) AccountController 가 sendMoney() 메소드를 호출한 직후 스프링 트랜잭션 애스펙트가 호출을 가로채고 트랜잭션을 시작한다.

2) 메소드가 런타임 예외가 없다면 스프링 트랜잭션 애스펙트는 메소드 실행이 종료된 후 트랜잭션을 커밋한다.

3) 만약 예외가 있다면 모든 트랜잭션을 rollback 하고 처음 있던 상태로 돌아간다.

 

지금 저 코드를 그대로 사용하면 에러없이 잘 동작한다. 

 

하지만 에러가 생겼다면?

직접 에러를 만들어 보자.

@Transactional(readOnly = true) // 기본 동작은 읽기 전용으로 한다.
@RequiredArgsConstructor
@Service
public class AccountService {
	private final AccountRepository accountRepository;

	// 메소드 호출이 트랜잭션에 포함되도록 지시한다.
	@Transactional
	public void sendMoney(Long idSender, Long idReceiver, BigDecimal amount) {
		Account sender = accountRepository.findById(idSender).orElse(null);
		Account receiver = accountRepository.findById(idReceiver).orElse(null);

		BigDecimal senderNewAmount = sender.getAmount().subtract(amount);
		BigDecimal receiverNewAmount = receiver.getAmount().subtract(amount);

		accountRepository.changeAmount(idSender, senderNewAmount);
		accountRepository.changeAmount(idReceiver, receiverNewAmount);
        
        throw new RuntimeException("Error 발생");
	}
	
}

 

위 처럼 throw 를 날리고 api 를 날리면 에러가 발생한 것을 볼수 있다.

 

위 경우는 직접 에러를 만든거지만, 실제로 비즈니스 로직에 따라 개발하다보면 여러 에러를 만날 수 있을 것이다.

내 코드에서 어떠한 에러가 발생하면, 

 

스프링 트랜잭션 애스펙트가 런타임 예외를 받아트랜잭션으 롤백시켜주기 때문에

@Transactional 어노테이션은 어지간하면 사용하는게 좋다.

 

만약 사용하지 않는다면 일관성이 중요한 DB 에서, 1번 트랜잭션은 save 되고 2번 트랜잭션은 실패하여

1개는 저장되고, 1개는 저장이 안되어 데이터 정합성, 즉 일관성이 깨지는 issue 를 겪게 될 수도 있다.

 

 

결론


@Transactional 어노테이션은 알고 사용해야 효율을 극대화 할 수 있다..

 

 

728x90