[Spring Data] Spring Transaction을 통한 문제해결
Spring TransactionJDBC만으로 트랜잭션을 사용하면 몇가지 문제가 발생한다. 1. 서비스 계층에서 트랜잭션 코드로 인해 JDBC 코드가 작성된다.JDBC 코드에 의존하다보니 이후 JPA 등과 같이 다른 데이터
jaehee1007.tistory.com
해당 포스트에서는 Spring Transaction이 여러가지 문제를 해결하는 방식에 대해 알아봤다.
이번 포스트에서는 Spring Transaction의 내부구조에 대해 더 자세히 알아보고 제공하는 여러가지 기능에 대해 알아보고자한다.
Spring Transaction 사용방식
Spring Transaction은 PlatformTransactionManager라는 인터페이스를 통해 다양한 데이터 접근 기술에서 트랜잭션을 편리하게 사용할 수 있게 해준다.
PlatformTransactionManager을 사용하는 방식에는 두 가지가 있다.
1. 프로그래밍 방식
직접 PlatformTransactionManager을 사용하거나 TransactionTemplate을 사용하여 직접 프로그래밍 코드를 작성하는 방식이다.
2. 선언적 방식
@Transactional 어노테이션을 붙여 사용하는 방식이다.
해당 포스트에선 @Transactional 위주로 설명하겠다.
@Transactional
@Transactional을 사용하면 해당 클래스에 대한 프록시 객체가 생성된다.
이를 통해 서비스 단에 순수한 비즈니스 로직을 남길 수 있게 돼 더욱 깔끔하게 코드를 작성할 수 있다.
@Transaction은 메서드에 붙을 수도 있고 클래스에 붙을 수도 있다.
1) 메서드에 붙은 경우
@Slf4j
static class BasicService {
@Transactional
public void tx() {
log.info("call tx");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active = {}", txActive);
}
public void nonTx() {
log.info("call nonTx");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active = {}", txActive);
}
}
- 이 경우 해당 메서드에만 트랜잭션이 적용된다.
2) 클래스에 붙은 경우
@Slf4j
@Transactional
static class BasicService {
public void tx() {
log.info("call tx");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active = {}", txActive);
}
public void nonTx() {
log.info("call nonTx");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active = {}", txActive);
}
}
- 이 경우 클래스의 모든 메서드에 트랜잭션이 적용된다.
하나의 메서드에만 @Transactional이 붙어도 스프링은 자동으로 해당 클래스에 대한 프록시 객체를 생성한다.
- 생성된 프록시 객체를 스프링 빈으로 등록한다.
- 해당 프록시 객체는 기존 클래스와 상속 관계이다.
물론 트랜잭션은 @Transactional이 붙은 메서드에 한해서만 적용된다.
- 해당 객체의 특정 메서드를 호출하게 되면 실제 객체가 아닌 프록시 객체의 메서드를 호출하게 된다.
- 트랜잭션을 사용하는 메서드의 경우 트랜잭션을 적용한 뒤 실제 메서드를 호출한다.
- 트랜잭션을 사용하지 않는 메서드의 경우 바로 실제 메서드를 호출한다.
@Transactional - 우선순위
앞서 설명했듯 @Transactional은 클래스에 붙일 수도 있고 특정 메서드에만 붙일 수도 있다.
그렇다면 @Transactional이 둘 다 붙은 경우 어느 것이 우선순위를 가질까?
결론부터 말하자면 메서드에 붙은 @Transactional이 우선순위를 가진다.
스프링에선 항상 구체적인 것이 우선순위를 가진다.
예시를 보도록하자.
@Slf4j
@Transactional(readOnly = true)
static class LevelService {
@Transactional(readOnly = false)
public void write() {
log.info("call write");
printTxInfo();
}
public void read() {
log.info("call read");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = isActualTransactionActive();
log.info("tx active={}", txActive);
boolean readOnly = isCurrentTransactionReadOnly();
log.info("tx readOnly={}", readOnly);
}
}
- 클래스의 @Transactional에는 readOnly 특성이 true이고 write() 메서드의 @Transactional은 readOnly 특성이 false로 설정되어 있다.
해당 코드에서 write()를 호출하면 readOnly 특성은 어떤 것으로 설정될까?
바로 메서드에 설정된 false로 설정된다.
반면 read()의 경우엔 클래스에 설정된 true로 설정된다.
@Transactional - 내부 호출 문제
@Transactional을 사용할 때 가장 주의를 해야하는 부분이 바로 내부 호출 문제이다.
앞서 말했듯 @Transactional을 사용하면 해당 클래스에 대한 프록시 객체가 생성되고 메서드 호출시 프록시 객체의 메서드를 호출한다.
이후 프록시 객체는 트랜잭션 처리를 한뒤 실제 메서드를 호출한다.
내부 호출 문제란?
내부 호출 문제는 클래스 차원에서 @Transactional이 붙은 것이 아닌 특정 메서드에만 @Transactional을 붙인 상황에서 발생한다.
위와 같이 프록시의 트랜잭션이 적용되지 않는 메서드가 내부의 다른 트랜잭션이 적용되는 메서드를 호출할 때 해당 메서드에 트랜잭션이 적용되지 않는 것을 내부 호출 문제라고 한다.
코드로 보자.
static class CallService {
public void external() {
log.info("call external");
printTxInfo();
internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
- 클래스에는 @Transactional이 붙지 않고 internal()메서드에만 붙어 있다.
- external()에서는 internal()을 호출한다.
이때 external()을 호출하면 내부에서 호출된 internal()은 트랜잭션이 적용되지 않는다.
callService.external()
왜 이러한 현상이 발생할까?
external()에서 호출하는 internal()은 프록시 메서드가 아닌 실제 메서드를 호출하는 것이기 때문이다.
이러한 문제를 해결하려면 두 메서드를 별도의 클래스로 분리해야한다.
문제해결 코드
@RequiredArgsConstructor
static class CallService {
private final InternalService internalService;
public void external() {
log.info("call external");
printTxInfo();
internalService.internal();
}
private void printTxInfo() {
boolean txActive = isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
static class InternalService {
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
이렇게 분리 한뒤 external()을 호출하면 정상적으로 internal()에 트랜잭션이 적용된다.
callService.external();
구조는 다음과 같다.
@Transactional - 접근 제어자
@Transactional은 private 메서드에서는 작동하지 않는다.
코드로 보자
@Transactional
static class BasicService {
public void public_tx() {
log.info("call public_tx");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active = {}", txActive);
}
private void private_tx() {
log.info("call private_tx");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active = {}", txActive);
}
}
각각을 호출한 결과는 다음과 같다
1) public_tx()
2) private_tx()
@Transactional - 초기화 시점
스프링 초기화 시점에는 @Transactional이 적용되지 않을 수도 있다.
코드로 보자.
@Slf4j
static class Hello {
@PostConstruct
@Transactional
public void initV1() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init @PostConstruct tx active={}", isActive);
}
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initV2() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init ApplicationReadyEvent tx active={}", isActive);
}
}
해당 클래스를 스프링 빈으로 등록해줬다.
static class InitTxTestConfig {
@Bean
Hello hello() {
return new Hello();
}
}
이렇게 한 뒤 실행을 해보면 결과는 다음과 같다.
1) initV1() - @PostConstuct
@PostConstruct는 스프링 컨테이너 생성이 완료된 직후에 호출된다.
해당 시점에는 트랜잭션이 적용되지 않은 것을 알 수 있다.
2) initV2() - @EventListener(ApplicationReadyEvent.class)
@EventListener(ApplicationReadyEvent.class)는 스프링 어플리케이션이 완전히 초기화가 끝난 뒤에 호출된다.
해당 시점에는 트랜잭션이 적용된다.
@Transactional - 예외와 커밋, 롤백
트랜잭션이 실행하는 도중 예외가 발생하면 해당 트랜잭션은 어떻게 될까?
트랜잭션 실행 중 예외가 발생하면 해당 트랜잭션은 커밋되거나 롤백된다.
- Exception같은 체크예외가 발생하면 트랜잭션은 커밋된다.
- RuntimeException 같은 언체크 예외가 발생하면 트랜잭션은 롤백된다.
스프링이 이러한 선택을 한 이유가 뭘까?
체크 예외는 비즈니스 예외이다. 비즈니스 예외는 시스템 상에 문제는 있어서 발생한 것이 아니다.. 비즈니스 로직상에 문제가 발생한 것이다. 개발자가 해결할 수 있다. 따라서 커밋한다.
언체크 예외는 복구 불가능한 예외이다. 시스템 상에 문제가 있는 것이기 때문에 개발자가 별도로 해결하기 힘들다. 따라서 롤백한다.
예시를 들어보자.
비즈니스 요구사항
주문을 하는데 상황에 따라 다음과 같이 조치한다.
1. 정상: 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 완료 로 처리한다.
2. 시스템 예외: 주문시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다.
--> 시스템 예외가 발생한 경우 복구 불가능하기 때문에 데이터가 커밋되면 안된다. 따라서 롤백돼야 한다.
3. 비즈니스 예외: 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를 대기 로 처리한다. 이 경우 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다.
--> 잔고부족과 같은 비즈니스 예외의 경우 개발자가 어느정도 사용자에게 메시지를 보여주는 등 해결할 수 있는 여지가 있다. 이 경우 주문을 롤백하면 안된다. 사용자 입장에서 잔고가 부족하다고 해서 주문을 아예 삭제해버리면 처음부터 다시 주문해야하는 불편함이 발생하기 때문에 커밋해야 한다.
물론 요구사항에 따라 비즈니스 예외도 롤백해야하는 경우가 생길수도 있고 시스템 예외도 커밋해야하는 경우가 생길 수도 있다.
그런 경우에는 @Transactional의 다음과 같은 특성을 사용하면 된다.
1. rollbackFor() - 해당 예외를 롤백
@Transactional(rollbackFor = RuntimeException.class)
2) noRollbackFor() - 해당 예외를 커밋
@Transactional(noRollbackFor = Exception.class)
정리
1. PlatformTransactionManager
- Spring은 PlatformTransactionManager 인터페이스를 통해 다양한 데이터 접근 기술에서 편리하게 트랜잭션을 사용할 수 있게 해줌
2. 선언적 트랜잭션 - @Transactional
- 클래스나 메서드에 붙이면 해당 클래스에 대해 프록시 객체를 만듦
3. @Transactional의 기능
- 우선순위 - 구체적인게 우선(메서드가 클래스보다 우선)
- 내부 호출 문제 - 프록시의 트랜잭션이 적용되지 않는 메서드가 내부의 다른 트랜잭션이 적용되는 메서드를 호출할 때 해당 메서드에 트랜잭션이 적용되지 않는 것.
- 접근제어자 - private에선 작동 X
- 초기화 시점 - 스프링 어플리케이션이 모두 초기화된 이후
(@PostConstruct X, @EventListener(ApplicationReadyEvent.class) O) - 예외와 커밋,롤백
체크 예외 - 커밋
언체크 예외 - 롤백
해당 포스트는 김영한 님의 스프링 DB 2편 - 데이터 접근 활용 기술 강의를 기반으로 작성되었습니다.
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-2
'Spring > Spring Data' 카테고리의 다른 글
[Spring Data] Spring Transaction의 트랜잭션 전파 (0) | 2024.06.09 |
---|---|
[Spring Data] QueryDSL (0) | 2024.06.07 |
[Spring Data] Spring Data JPA (0) | 2024.06.07 |
[Spring Data] JPA (0) | 2024.06.07 |
[Spring Data] MyBatis (0) | 2024.06.06 |