개발놀이터

JPA가 트랜잭션을 관리하는 방법 본문

JPA/JPA

JPA가 트랜잭션을 관리하는 방법

마늘냄새폴폴 2023. 6. 18. 22:48

JPA는 자바 진영에서 사용하는 대표적인 ORM 중 하나입니다. JPA로 인해 자바 개발자들이 데이터베이스 중심에서 객체 중심으로 설계 방식을 전환할 수 있었다고 개인적으로 생각하고 있는데요. 

 

저는 여태껏 JPA를 사용하는데에만 집중을 했습니다. 그래서 JPA가 가진 특징들에 대해서는 등한시 한 느낌이 있습니다. 

 

하지만 제 공부 방식은 항상 써보고 익숙해지면 개념을 공부하는 느낌이었어서 지금이라도 주요 개념들에 대해서 공부해보고자 오랜만에 JPA 카테고리에 글을 썼습니다. 

 

이번 포스팅에선 JPA가 트랜잭션을 관리하는 방법과 JPA에서 제공하는 다양한 기능들에 대해서 소개해드리고자합니다. 

 

JPA가 트랜잭션을 관리하는 방법

JPA는 트랜잭션을 프록시를 이용해서 관리합니다. 여기까지는 보통 알고 있는 내용일겁니다. JPA의 @Transactional이 스프링에서 만든 AOP 중 하나라는 것을 떠올리신다면 금방 알아차릴 내용입니다. 

 

@Transactional이 붙은 모든 메서드와 클래스들을 프록시로 만들어서 AOP 방식을 이용해 트랜잭션 작업을 처리합니다. 이 프록시들은 스프링 프레임워크가 메서드 시작 전후로 트랜잭션 로직을 처리하는데 사용합니다. 

 

그렇기 때문에 트랜잭션을 사용하기 위해서는 스프링 프레임워크의 도움을 받아야 합니다. 즉, 스프링 컨테이너인 Application Context를 이용해야 한다는 의미인데요. 

 

이는 @Async와 같은 방식인데 @Async 어노테이션도 스프링 프레임워크에 도움을 받아 비동기 네트워킹을 원하는 메서드를 프록시로 잡아채서 별도의 스레드를 할당해줍니다. 

 

이와 같이 @Transactional도 트랜잭션을 사용하고 싶은 메서드를 프록시가 가로채서 트랜잭션의 시작과 끝을 관리해줍니다. 

 

스프링 컨테이너의 도움을 받아야 한다는 말은 @Async에서와 마찬가지로 직접 호출을 하거나 new 연산자로 동적할당 하면 아무리 @Transactional 어노테이션이 붙어있더라도 트랜잭션 처리가 되지 않는다는 의미를 뜻합니다. 

 

@Transactional
public void create(Member member) {
	memberRepository.save(member);
}

public void selfInvocationCall() {
	Member member = new Member("홍길동");
    create(member);		// Wrong!!!
}
@RequiredArgsConstructor
@Transactional
public class MemberService {
	
    private final MemberRepository memberRepository;
    
    public void create(Member member) {
    	memberRepository.save(member);
    }
}

@RequiredArgsConstructor
public class AnotherService {

	private final MemberRepository memberRepository;
	
    public void wrongImplement() {
    	MemberService memberService = new MemberService(memberRepository);
        Member member = new Member("홍길동");
        memberService.create(member);
    }
}

 

 

또한, 프록시 객체 또한 자바에서 제공하는 클래승니기 때문에 private이나 protected로 되어있는 메서드는 프록시 객체가 @Transactional 어노테이션이 붙은 메서드를 확인할 수 없습니다. 

 

때문에 public 접근 제어자만 @Transcational의 생명주기에 포함됩니다. 

 

JPA에서 설정할 수 있는 것들

1. 전파단계

트랜잭션의 전파단계는 단어 그대로 트랜잭션을 "전파" 하는 정도입니다. 보통 기준은 기존 트랜잭션이 존재하는지 여부를 가지고 동작합니다. 

 

위의 표는 전파단계를 잘 설명하고 있는 그림입니다. 

 

트랜잭션의 전파단계를 잘만 조절하면 여러개의 상호작용하는 트랜잭션들을 사용하는 복잡한 시스템에서 일어날 수 있는 데이터 부정합 문제를 해결할 수 있습니다. 

 

@Service
public class TransactionService {
	
    @Authwired
    BankService bankService;
    
    @Transactional(propagation = Propagation.REQUIRED)
    public void transferMoney(Long fromAccountId, Long toAccountId, BigDecimal amount) {
        bankService.withdraw(fromAccountId, amount);
        bankService.deposit(toAccountId, amount);
    }
}

@Service
public class BankService {

    @Autowired
    private AccountRepository accountRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void withdraw(Long accountId, BigDecimal amount) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new RuntimeException("Account not found"));

        BigDecimal newBalance = account.getBalance().subtract(amount);
        if (newBalance.compareTo(BigDecimal.ZERO) < 0) {
            throw new RuntimeException("Insufficient balance");
        }

        account.setBalance(newBalance);
        accountRepository.save(account);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deposit(Long accountId, BigDecimal amount) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new RuntimeException("Account not found"));

        BigDecimal newBalance = account.getBalance().add(amount);
        account.setBalance(newBalance);
        accountRepository.save(account);
    }
}

 

위의 예제는 OO은행에서 사용하는 비즈니스 로직입니다. TransactionService 클래스에선 BankService 클래스에 있는 withdraw() 메서드와 deposit() 메서드를 호출하고 있습니다. 

 

TransactionService에 있는 메서드는 전파 단계가 디폴트 값인 REQUIRED로 설정되어 부모 트랜잭션이 있으면 합병되고 부모 트랜잭션이 없으면 새로운 트랜잭션을 만들어 로직을 수행하게 됩니다. 

 

BankService에 있는 두 개의 메서드는 전파 단계가 REQUIRES_NEW로 설정되어 부모 트랜잭션이 끝날 때까지 기다리고 있다가 새로운 트랜잭션을 만들어 로직을 수행합니다. 

 

그렇기 때문에 부모 트랜잭션에서 실행되는 transferMoney() 메서드와 withdraw() 메서드와 deposit() 메서드가 모두 다른 트랜잭션에서 수행된다는 사실을 알 수 있습니다. 

 

이렇게 처리하면 서로 격리된 상태에서 로직이 수행되어 데이터의 일관성을 유지할 수 있게 해줍니다. 

 

 

2. 격리수준

데이터베이스에서 격리수준을 트랜잭션에서 그대로 적용할 수 있습니다. 이 얘기는 예를 들어 데이터베이스의 격리수준은 READ COMMITTED인데 @Transactional을 이용해 격리수준을 Serializable로 격상시킬 수 있다는 얘기입니다. 

 

그에 따라 각각의 격리수준에서 해당하는 부정합 문제나 특징들을 그대로 따릅니다. 

 

 

3. read-only 플래그

read-only 플래그를 true로 설정하면 트랜잭션을 read-only로 사용할 수 있을... 것처럼 생각이 듭니다. 하지만 이는 오해하기 쉬운 설정인데, 사실상 read-only 플래그를 설정하더라도 INSERT 연산이나 UPDATE 연산이 일어나지 않는다고 장담할 수 없습니다. 

 

read-only 플래그를 이해하는데 중요한 점은 오직 트랜잭션 내부에서만 관련있다는 것입니다. 만약 트랜잭션 밖에서 read-only 플래그가 적용된다면 간단하게 무시됩니다. 

 

가벼운 예시로 트랜잭션의 전파 단계를 SUPPORTS로 설정하면 부모 트랜잭션이 존재하면 부모 트랜잭션을 이용하면서 read-only가 작동하지만 부모 트랜잭션이 없는 상황에선 트랜잭션의 연산이 안걸리고 해당 로직이 실행되고 read-only 플래그가 정상적으로 작동하지 않는다. 

 

 

4. rollback

우리는 보통 @Transactional을 선언하여 트랜잭션을 관리합니다. 이를 선언적 트랜잭션 관리라고 하는데요. 선언적 트랜잭션 처리에서 롤백은 rollbackFor 혹은 rollbackForClassName 속성을 이용해서 롤백을 진행할 수 있습니다. 

 

예를 들어서 rollbackFor = SQLException.class 라고 적으면 SQLException이 발생하면 해당 트랜잭션에서의 연산을 롤백하겠다는 의미입니다. 

 

@Transactional(rollbackFor = SQLException.class)
public void create(Member member) {
	memberRepository.save(member);
    throw new SQLException("rollbach test");
}

 

 

마치며

여기까지 JPA에서 트랜잭션을 관리하는 방법에 대해서 알아봤습니다. JPA가 AOP라는 사실은 알고 있었지만 프록시를 이용하기 때문에 스프링 컨테이너의 도움을 받아야 한다는 사실은 오늘 처음 알았네요. 

 

이론 공부도 나름 프로젝트 진행하는 것 못지않게 재밌는 점이 많은 것 같습니다. 앞으로 JPA에 대해서 다뤄야할 내용들이 꽤 있는데요. 한번 차근차근 진행해보도록 하겠습니다. 

 

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

 

 

출처

https://www.baeldung.com/transaction-configuration-with-jpa-and-spring