https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-2#reviews
김영한님의 위 강의를 기반으로 작성하였습니다.
스프링 트랜잭션 추상화
- JPA, JDBC 둘은 트랜잭션 코드가 완전히 다르다.
스프링에서는 이처럼 기술에 따른 트랜잭션 코드 차이 해결하고자, 트랜잭션 추상화를 제공한다.
스프링은 트랜잭션을 추상화해서 제공할 뿐만 아니라, 실무에서 주로 사용하는 데이터 접근 기술에 대한 트랜잭션 매니저의 구현체도 제공한다. 우리는 필요한 구현체를 스프링 빈으로 등록하고 주입 받아서 사용하기만 하면 된다.
여기에 더해서 스프링 부트는 어떤 데이터 접근 기술을 사용하는지를 자동으로 인식해서 적절한 트랜잭션 매니저를 선택해서 스프링 빈으로 등록해주기 때문에 트랜잭션 매니저를 선택하고 등록하는 과정도 생략할 수 있다. 예를 들어서 JdbcTemplate , MyBatis 를 사용하면 DataSourceTransactionManager(JdbcTransactionManager) 를 스프링 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager 를 스프링 빈으로 등록해준다.
선언적 트랜잭션 관리 vs 프로그래밍 방식 트랜잭션 관리
선언적 트랜잭션 관리(Declarative Transaction Management)
@Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적
트랜잭션 관리라 한다.
선언적 트랜잭션 관리는 과거 XML에 설정하기도 했다.
이름 그대로 해당 로직에 트랜잭션을 적용하겠다 라고 어딘가에 선언하기만 하면 트랜잭션이 적용되는
방식이다.
프로그래밍 방식의 트랜잭션 관리(programmatic transaction management)
트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라 한다.
프로그래밍 방식의 트랜잭션 관리를 사용하게 되면, 애플리케이션 코드가 트랜잭션이라는 기술 코드와 강하게 결합된다.
선언적 트랜잭션 관리가 프로그래밍 방식에 비해서 훨씬 간편하고 실용적이기 때문에 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다.
선언적 트랜잭션과 AOP
@Transactional 을 통한 선언적 트랜잭션 관리 방식을 사용하게 되면 기본적으로 프록시 방식의 AOP가 적용된다.
프록시 적용 전
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money); transactionManager.commit(status); //성공시 커밋
} catch (Exception e) { transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
서비스 코드에서 위와 같이 트랜잭션 적용 코드를 모두 수행해야한다 -> 비즈니스 로직이 트랜잭션 코드로 인해 불분명해짐.
프록시 도입 후
서비스 로직에 트랜잭션 코드를 넣을 필요 없이, AOP를 통해 트랜잭션 프록시를 만들고, 이 트랜잭션 프록시가 서비스로직의 트랜잭션 처리를 수행해준다.
트랜잭션 프록시 코드 예시
public class TransactionProxy {
private MemberService target;
public void logic() { //트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
//실제 대상 호출 target.logic();
transactionManager.commit(status); //성공시 커밋 } catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
} }
프록시 도입 후 전체 과정
스프링이 제공하는 트랜잭션 AOP
스프링의 트랜잭션은 매우 중요한 기능이고, 전세계 누구나 다 사용하는 기능이다. 스프링은 트랜잭션 AOP 를 처리하기 위한 모든 기능을 제공한다. 스프링 부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해준다.
개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션을 처리하는 프록시를 적용해준다.
트랜잭션 적용 확인
우선 트랜잭션이 @Transactional을 통해 올바로 적용되었는지 테스트 코드로 확인하는 방법을 알아보자.
@Slf4j
@SpringBootTest
public class TxBasicTest {
@Autowired BasicService basicService;
@Test
void proxyCheck() {
log.info("aop class={}", basicService.getClass());
assertThat(AopUtils.isAopProxy(basicService)).isTrue();
//AopUtils.isAopProxy(): 스프링 AOP프록시로 적용되었는지 확인해준다
}
@Test
void txTest() {
basicService.tx();
basicService.nonTx();
}
@TestConfiguration
static class TxApplyBasicConfig {
@Bean
BasicService basicService() {
return new BasicService();
}
}
@Slf4j
static class BasicService {
@Transactional
public void tx() {
log.info("call tx");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
//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 애노테이션이 특정 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다.(클래스 자체에 프록시가 생겨서, 각 메소드가 @Transactional을 사용하는지, 안하는지 체크하고 적절히 로직을 적용)
그리고 실제 basicService 객체 대신에 프록시인 basicService$$CGLIB 를 스프링 빈에 등록한다. 그리고 프록시는 내부에 실제 basicService 를 참조하게 된다. 여기서 핵심은 실제 객체 대신에 프록시가 스프링 컨테이너에 등록되었다는 점이다. 클라이언트인 txBasicTest 는 스프링 컨테이너에 @Autowired BasicService basicService 로 의존관계 주입을 요청한다. 스프링 컨테이너에는 실제 객체 대신에 프록시가 스프링 빈으로 등록되어 있기 때문에 프록시를 주입한다.
프록시는 BasicService 를 상속해서 만들어지기 때문에 다형성을 활용할 수 있다. 따라서 BasicService 대신에 프록시인 BasicService$$CGLIB 를 주입할 수 있다.
트랜잭션 프록시 동작 방식
클라이언트가 주입 받은 basicService$$CGLIB 는 트랜잭션을 적용하는 프록시이다.
**
application.properties
logging.level.org.springframework.transaction.interceptor=TRACE
이 로그를 추가하면 트랜잭션 프록시가 호출하는 트랜잭션의 시작과 종료를 명확하게 로그로 확인할 수 있다.
Getting transaction for [hello.springtx.apply.TxBasicTest$BasicService.tx]
작업......
Completing transaction for [hello.springtx.apply.TxBasicTest$BasicService.tx]
다음과 같이 시작, 종료를 명확히 로그로 확인 가능하다.
위에 적었던 txTest() 의 2가지 메소드를 통해 어떻게 스프링 AOP를 통한 트랜잭션 프록시가 동작하는지 알아보자.
basicService.tx() 호출 (@Transactional 적용 메소드)
클라이언트가 basicService.tx() 를 호출하면, 프록시의 tx() 가 호출된다.
여기서 프록시는 tx() 메서드가트랜잭션을사용할수있는지확인해본다.
tx()메서드에는@Transactional이 붙어있으므로 트랜잭션 적용 대상이다.
따라서 트랜잭션을 시작한 다음에 실제 basicService.tx() 를 호출한다.
그리고 실제 basicService.tx() 의 호출이 끝나서 프록시로 제어가(리턴) 돌아오면 프록시는
트랜잭션 로직을 커밋하거나 롤백해서 트랜잭션을 종료한다.
basicService.nonTx() 호출 (@Transactional 적용 x 메소드)
클라이언트가 basicService.nonTx() 를 호출하면, 트랜잭션 프록시의 nonTx() 가 호출된다.
여기서nonTx() 메서드가 트랜잭션을사용할수있는지확인해본다.nonTx()에는
@Transactional 이 없으므로 적용 대상이 아니다.
따라서 트랜잭션을 시작하지 않고, basicService.nonTx() 를 호출하고 종료한다.( 진짜를 호출 후 종료)
**TransactionSynchronizationManager.isActualTransactionActive()
현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다. 결과가 true 면 트랜잭션이 적용되어 있는 것이다. 트랜잭션의 적용 여부를 가장 확실하게 확인할 수 있다.
트랜잭션 적용 위치
이번엔
코드를 통해 @Transactional 의 적용 위치에 따른 우선순위를 확인해보자.
스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다. 이것만 기억하면 스프링에서 발생하는 대부분의 우선순위를 쉽게 기억할 수 있다. 그리고 더 구체적인 것이 더 높은 우선순위를 가지는 것은 상식적으로 자연스럽다.
예를 들어서 메서드와 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 메서드가 더 높은 우선순위를 가진다.
인터페이스와 해당 인터페이스를 구현한 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 클래스가 더 높은 우선순위를 가진다.
이제 테스트 코드를 보며 실제 적용을 확인해보자.
@SpringBootTest
public class TxLevelTest {
@Autowired LevelService service;
@Test
void orderTest() {
service.write();
service.read();
}
@TestConfiguration
static class TxLevelTestConfig {
@Bean
LevelService levelService() {
return new LevelService();
}
}
@Slf4j
@Transactional(readOnly = true)
static class LevelService {
@Transactional(readOnly = false) //메소드가 더 높은 우선순위를 가지기에, write()의 Transaction 속성은 새롭게 적용된다.
public void write() {
log.info("call write");
printTxInfo();
}
public void read() {
log.info("call read");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
log.info("tx readOnly={}", readOnly);
}
}
}
스프링의 @Transactional 은 다음 두 가지 규칙이 있다.
1. 우선순위 규칙
2. 클래스에 적용하면 메서드는 자동 적용
우선순위
트랜잭션을 사용할 때는 다양한 옵션을 사용할 수 있다. 그런데 어떤 경우에는 옵션을 주고, 어떤 경우에는 옵션을 주지 않으면 어떤 것이 선택될까? 예를 들어서 읽기 전용 트랜잭션 옵션을 사용하는 경우와 아닌 경우를 비교해보자. (읽기 전용 옵션에 대한 자세한 내용은 뒤에서 다룬다. 여기서는 적용 순서에 집중하자.)
LevelService 의 타입에 @Transactional(readOnly = true) 이 붙어있다. write() : 해당 메서드에 @Transactional(readOnly = false) 이 붙어있다.
이렇게 되면 타입에 있는 @Transactional(readOnly = true) 와 해당 메서드에 있는 @Transactional(readOnly = false) 둘 중 하나를 적용해야 한다.
클래스 보다는 메서드가 더 구체적이므로 메서드에 있는 @Transactional(readOnly = false) 옵션을 사용한 트랜잭션이 적용된다.
클래스에 적용하면 메서드는 자동 적용
read() : 해당 메서드에 @Transactional 이 없다. 이 경우 더 상위인 클래스를 확인한다.
상위 클래스에 @Transactional(readOnly = true) 이 적용되어 있다. 따라서 트랜잭션이 적용되고
readOnly = true 옵션을 사용하게 된다.
참고로 readOnly=false 는 기본 옵션이기 때문에 보통 생략한다. 여기서는 이해를 돕기 위해 기본 옵션을 적어주었다.
@Transactional == @Transactional(readOnly=false) 와 같다
**TransactionSynchronizationManager.isCurrentTransactionReadOnly
현재 트랜잭션에 적용된 readOnly 옵션의 값을 반환한다.
인터페이스에 @Transactional 적용
인터페이스에도 @Transactional 을 적용할 수 있다(하지만 공식 메뉴얼에서 권장하지 않는 방법).
이 경우 다음 순서로 적용된다. 구체적인 것이 더 높은 우선순위를 가진다고 생각하면 바로 이해가 될 것이다.
1. 클래스의 메서드 (우선순위가 가장 높다.)
2. 클래스의 타입
3. 인터페이스의 메서드
4. 인터페이스의 타입 (우선순위가 가장 낮다.)
클래스의 메서드를 찾고, 만약 없으면 클래스의 타입을 찾고 만약 없으면 인터페이스의 메서드를 찾고 그래도 없으면 인터페이스의 타입을 찾는다.
그런데 인터페이스에 @Transactional 사용하는 것은 스프링 공식 메뉴얼에서 권장하지 않는 방법이다. AOP를 적용하는 방식에 따라서 인터페이스에 애노테이션을 두면 AOP가 적용이 되지 않는 경우도 있기 때문이다. 가급적 구체 클래스에 @Transactional 을 사용하자.
트랜잭션 AOP 주의 사항- 프록시 내부 호출
여기서는 트랜잭션 AOP에 관점에서 설명한다.
(실무에서 자주 만나는 문제- 분명 트랜잭션을 적용했지만, 적용되지 않는 등의 문제가 자주 생긴다)
@Transactional 을 사용하면 스프링의 트랜잭션 AOP가 적용된다.
트랜잭션 AOP는 기본적으로 프록시 방식의 AOP를 사용한다.
앞서 배운 것 처럼 @Transactional 을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고, 실제 객체를 호출해준다.
따라서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다.
이렇게 해야 프록시에서 먼저 트랜잭션을 적용하고, 이후에 대상 객체를 호출하게 된다.
만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는다.
(프록시에 트랜잭션 적용 코드가 들어있다.)
AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시에 항상 실제 객체 대신에 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다. 이렇게 되면 @Transactional 이 있어도 트랜잭션이 적용되지 않는다. 실무에서 반드시 한번은 만나서 고생하는 문제이기 때문에 꼭 이해하고 넘어가자.
예제를 통해서 내부 호출이 발생할 때 어떤 문제가 발생하는지 알아보자. 먼저 내부 호출이 발생하는 예제를 만들어보자.
@Slf4j
@SpringBootTest
public class InternalCallV1Test {
@Autowired
CallService callService;
@Test
void printProxy() {
log.info("callService class={}", callService.getClass());
}
@Test
void internalCall() {
callService.internal();
}
@Test
void externalCall() {
callService.external();
}
@TestConfiguration
static class InternalCallV1TestConfig {
@Bean
CallService callService() {
return new CallService();
}
}
@Slf4j
static class CallService {
public void external() {
log.info("call external");
printTxInfo();
this.internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
결과:
분명 internal() 에는 @Transactional이 걸려있지만,
결과에는 Transaction이 적용되지 않았다.
이는 external 함수에서 내부 호출을 통해 internal을 호출하기 때문인데,
@Autowired
CallService callService;
....
callService.external();
이런식으로 의존관계를 주입 받아 사용 시, 트랜잭션 프록시를 주입받아서 사용하고, 트랜잭션 적용코드를 사용한 뒤 실제 메소드가 호출되고, 종료시에 프록시로 리턴되어 트랜잭션을 rollback(), 혹은 commit()으로 종료한다.
하지만 아래와 같이 메소드 내부에서 호출할경우, 트랜잭션이 적용되지 않는다.
public void external() {
log.info("call external");
printTxInfo();
this.internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
그 이유는, 위와 같이 내부 호출 수행 시, 프록시가 호출되지 않고, 실제 메소드가 호출되기 때문이다.
그렇기에 트랜잭션 프록시를 거치지 않고, 트랜잭션도 적용되지 않는다.
CallService
external() 은 트랜잭션이 없다.
internal() 은 @Transactional 을 통해 트랜잭션을 적용한다.
@Transactional 이 하나라도 있으면 트랜잭션 프록시 객체가 만들어진다. 그리고 callService 빈을 주입 받으면 트랜잭션 프록시 객체가 대신 주입된다.
internalCall() 실행 시
1. 클라이언트인 테스트 코드는 callService.internal() 을 호출한다. 여기서 callService 는 트랜잭션 프록시이다.
2. callService 의 트랜잭션 프록시가 호출된다.
3. internal() 메서드에 @Transactional 이 붙어 있으므로 트랜잭션 프록시는 트랜잭션을 적용한다. 4. 트랜잭션 적용 후 실제 callService 객체 인스턴스의 internal() 을 호출한다.
실제 callService 가 처리를 완료하면 응답이 트랜잭션 프록시로 돌아오고, 트랜잭션 프록시는 트랜잭션을 완료한다.
TransactionInterceptor
[..CallService.internal]
..rnalCallV1Test$CallService
..rnalCallV1Test$CallService
TransactionInterceptor
[..CallService.internal]
: Getting transaction for
: call internal
: tx active=true
: Completing transaction for
올바르게 적용된 모습
ExternalCall() 을 실행해보자.
결과
CallService
CallService
CallService
CallService
: call external
: tx active=false
: call internal
: tx active=false
internalCall의 트랜잭션이 적용되어있지 않다.
왜 그런지에 대해 분석해보자.
프록시와 내부호출
실제 호출되는 흐름을 천천히 분석해보자.
1. 클라이언트인 테스트 코드는 callService.external() 을 호출한다. 여기서 callService 는 트랜잭션 프록시이다.
2. callService 의 트랜잭션 프록시가 호출된다.
3. external() 메서드에는 @Transactional 이 없다. 따라서 트랜잭션 프록시는 트랜잭션을 적용하지 않는다.
4. 트랜잭션 적용하지 않고, 실제 callService 객체 인스턴스의 external() 을 호출한다.
5. external() 은 내부에서 internal() 메서드를 호출한다. 그런데 여기서 문제가 발생한다.
문제 원인
자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal() 이 되는데, 여기서 this 는 자기 자신을 가리키므로, 실제 대상 객체( target )의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 트랜잭션을 적용할 수 없다. 결과적으로 target 에 있는 internal() 을 직접 호출하게 된 것이다.
프록시 방식의 AOP 한계
@Transactional 를 사용하는 트랜잭션 AOP는 프록시를 사용한다. 프록시를 사용하면 메서드 내부 호출에 프록시를 적용할 수 없다.
그렇다면 이 문제를 어떻게 해결할 수 있을까?
가장 단순하고, 자주쓰는 방법은 내부 호출을 피하기 위해 internal() 메서드를 별도의 클래스로 분리하는 것이다.
어려운 해결 방법: AOP를 아예 자바 코드에 적용하는 방법.
컴파일 시점에 서비스 로직에 AOP가
tx.시작()
서비스 로직
tx.종료()
처럼 tx 코드를 컴파일 타임에 넣는 방법도 있다(보통 복잡해서 잘 사용 x).
이번 글에선 inernal() 클래스를 별도의 클래스로 분리하여 내부 호출 문제를 해결해보겠다.
@Slf4j
@SpringBootTest
public class InternalCallV2Test {
@Autowired
CallService callService;
@Test
void printProxy() {
log.info("callService class={}", callService.getClass());
}
@Test
void externalCallV2() {
callService.external();
}
@TestConfiguration
static class InternalCallV1TestConfig {
@Bean
CallService callService() {
return new CallService(internalService());
}
@Bean
InternalService internalService() {
return new InternalService();
}
}
@Slf4j
@RequiredArgsConstructor
static class CallService {
private final InternalService internalService;
public void external() {
log.info("call external");
printTxInfo();
internalService.internal();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
static class InternalService {
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
InternalService 클래스를 만들고 internal() 메서드를 여기로 옮겼다. 이렇게 메서드 내부 호출을 외부 호출로 변경했다.
CallService 에는 트랜잭션 관련 코드가 전혀 없으므로 트랜잭션 프록시가 적용되지 않는다. InternalService 에는 트랜잭션 관련 코드가 있으므로 트랜잭션 프록시가 적용된다.
위와 같이, 아예 다른 클래스에서 사용하는 것으로, 문제를 해결할 수 있다.
실제 호출되는 흐름을 분석해보자.
1. 클라이언트인 테스트 코드는 callService.external() 을 호출한다.
2. callService 는 실제 callService 객체 인스턴스이다.
3. callService 는 주입 받은 internalService.internal() 을 호출한다.
4. internalService 는 트랜잭션 프록시이다. internal() 메서드에 @Transactional 이 붙어 있으므로 트랜잭션 프록시는 트랜잭션을 적용한다.
5. 트랜잭션 적용 후 실제 internalService 객체 인스턴스의 internal() 을 호출한다.
public 메서드만 트랜잭션 적용
스프링의 트랜잭션 AOP 기능은 public 메서드에만 트랜잭션을 적용하도록 기본 설정이 되어있다.
그래서 protected , private , package-visible 에는 트랜잭션이 적용되지 않는다. 생각해보면 protected , package-visible 도 외부에서 호출이 가능하다. 따라서 이 부분은 앞서 설명한 프록시의 내부 호출과는 무관하고, 스프링이 막아둔 것이다.
트랜잭션은 주로 비즈니스 로직의 시작점에서 건다(컨트롤러에서 메소드 호출 -> 바로 트랜잭션).
트랜잭션 AOP 주의사항- 초기화 시점
스프링 초기화 시점에는 Transaction AOP가 적용되지 않을 수 있다.
ex) @PostConstruct는 스프링 초기화시점에 수행된다, @Transactional 적용 x )
대안:
@EventListener(ApplicationReadyEvent.class)//스프링 컨테이너가 완전히 떴을 때 호출
트랜잭션 옵션 소개
트랜잭션엔 다양한 옵션이 존재한다.
우선 디폴트 옵션부터 알아보자.
public @interface Transactional {
String value() default "";
String transactionManager() default "";
Class<? extends Throwable>[] rollbackFor() default {};
Class<? extends Throwable>[] noRollbackFor() default {};
Propagation propagation() default Propagation.REQUIRED;
Isolation isolation() default Isolation.DEFAULT;
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
boolean readOnly() default false;
String[] label() default {};
}
트랜잭션 설정
Value, transactionManager: 어떤 트랜잭션 매니저를 사용할지지정. 따로 설정하지 않으면 자동으로 적절한 트랜잭션 매니저 사용.
(보통 트랜잭션 매니저가 둘 이상인 경우 구분하기 위해 사용)
rollbackFor:예외 발생시 스프링 트랜잭션의 기본 정책은 언체크 예외-> 롤백. 체크 예외 -> 커밋.
기본 정책에 추가로 어떤 예외가 발생할 때 롤백할 지 지정.
noRollbackFor:롤백하지 않을 예외 지정.
isolation: 트랜잭션 isolation level 지정( 보통 DB꺼 따라서 지정 따라서 사용할일 x
timeout: 트랜잭션 동작시간에 대한 타임아웃을 초단위 지정( 환경따라 안되는 경우 존재)
label: 트랜잭션 어노테이션을 직접 읽어서 처리할 때 라벨링하여 사용 (거의 사용 x)
readOnly: readOnly=true 시, 읽기 전용 트랜잭션이 된다.
readOnly 가 어떻게 영향을 미칠까?
우선 기본적으로, readOnly로 설정된 트랜잭션에서 write발생시, 예외가 발생한다. 이를 통해 의도치 않은 write를 체크할 수 있다.
1) 프레임워크(JPA):
- JPA는 읽기 전용 트랜잭션에서 커밋시점에 플러시를 호출하지 않는다.(JPA는 Delay Write로, 커밋시점에 한번에 Write하고 Flush 수행하여 Write를 반영, 하지만 read만수행된다면, flush해서 DB에 적용시킬 필요가 없기에 쓸데없는 flush 호출을 하지 않는다.)
- 또한 추가로 변경또한 필요가 없기에, 변경감지를 위한 스냅샷 객체도 생성하지 않는다.
2) JDBC 드라이버
- 읽기(읽기 전용), 쓰기(읽기도 가능) DB를 구분해서 요청한다 따라서 각각 다른 DB 커넥션을 획득해서 사용한다.
이때의 장점은?
- 분산된 부하: 읽기 작업이 쓰기 작업에 영향을 주지 않고 병렬로 처리될 수 있다.
Read 끼리는 순서상관없이 수행해도 아무런 문제가 없지만, Write, Read가 혼재되어있다면 올바른 결과를 도출하기 위해 많은 처리가 필요하다(실제 transaction은 Serializable하지 않기에)
즉 읽기와 쓰기 작업이 동시에 수행될 때 데이터베이스 서버의 부하를 분산시킬 수 있다.
읽기 성능 향상: 별도의 읽기용 데이터베이스를 사용하면 읽기 작업이 주로 수행되는 경우에 대한 성능을 향상시킬 수 있습니다. 읽기용 데이터베이스는 복제된 데이터를 가지고 있으므로, 복제된 데이터베이스를 통해 읽기 작업을 처리함으로써 쓰기 작업에 영향을 주지 않고 응답 시간을 단축시킬 수 있습니다.
데이터 일관성 유지: 별도의 읽기용 데이터베이스는 주로 복제된 데이터를 가지고 있으므로, 쓰기 작업이 수행되는 동안에도 읽기 작업은 이전에 쓰기 작업이 완료된 시점의 일관된 데이터를 제공합니다. 이를 통해 읽기 작업 중에 데이터 일관성을 유지할 수 있습니다.
3) 데이터베이스
-읽기 전용 트랜잭션으로 선언된 트랜잭션은 다양한 이점이 생긴다.
이유: Read Lock끼리는 리소스 공유가 가능하다. read만 사용되기에 큰 제한사항을 걸게되고, 예상범위가 좁아진다.
리소스 활용 최적화: 읽기 전용 트랜잭션은 쓰기 작업이 없기 때문에 데이터베이스 리소스를 쓰기 작업에 비해 더 효율적으로 활용할 수 있습니다. 쓰기 작업에 비해 더 적은 록 충돌이 발생(Read 락 끼리는 같이 사용 가능)하고, 트랜잭션 로그를 기록하는 등의 부담도 적기 때문에 처리 성능이 향상될 수 있습니다.
응답 시간 단축: 읽기 전용 트랜잭션은 쓰기 작업에 영향을 받지 않고 데이터를 조회하기 때문에 응답 시간을 단축시킬 수 있습니다. 읽기 작업은 디스크에 대한 접근이 적고, 데이터를 캐시에 저장하는 등의 최적화를 통해 빠른 응답을 제공할 수 있습니다.
동시성 제어 감소: 읽기 전용 트랜잭션은 동시에 여러 개의 트랜잭션이 수행되더라도 데이터의 일관성을 유지할 수 있습니다. 따라서, 동시성 제어를 위한 록 충돌 등의 오버헤드를 줄일 수 있으며, 동시에 많은 수의 읽기 요청을 처리할 수 있습니다.
예외와 트랜잭션 커밋, 롤백 - 기본
예외가발생했는데,내부에서예외를처리하지못하고,트랜잭션범위(@Transactional가 적용된AOP) 밖으로 예외를 던지면 어떻게 될까?
예외 발생시 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백한다.
언체크 예외인 RuntimeException , Error 와 그 하위 예외가 발생하면 트랜잭션을 롤백한다.
체크 예외인 Exception 과 그 하위 예외가 발생하면 트랜잭션을 커밋한다.
물론 정상 응답(리턴)하면 트랜잭션을 커밋한다.
@Transactional의 rollbackFor 옵션:예외 발생시 스프링 트랜잭션의 기본 정책은 언체크 예외-> 롤백. 체크 예외 -> 커밋.
기본 정책에 추가로 어떤 예외가 발생할 때 롤백할 지 지정(특정 checked 예외를 롤백하고 싶을 때.)
실제 테스트로 알아보자.
** 유용한 로그 설정
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
#JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG
rollback, commit 둘모두 Transaction 종료 조건이고, 로그로 rollback이 수행되었는지, commit이 되엇는지 보기 어렵다.( 데이터를 직접 확인해봐야함) 위와 같이 설정하면 commit, rollback이 보인다(JPA 트랜잭션 로그 확인 가능).
@SpringBootTest
public class RollbackTest {
@Autowired RollbackService service;
@Test
void runtimeException() {
Assertions.assertThatThrownBy(() -> service.runtimeException())
.isInstanceOf(RuntimeException.class);
}
@Test
void checkedException() {
Assertions.assertThatThrownBy(() -> service.checkedException())
.isInstanceOf(MyException.class);
}
@Test
void rollbackFor() {
Assertions.assertThatThrownBy(() -> service.rollbackFor())
.isInstanceOf(MyException.class);
}
@TestConfiguration
static class RollbackTestConfig {
@Bean
RollbackService rollbackService() {
return new RollbackService();
}
}
@Slf4j
static class RollbackService {
//런타임 예외 발생: 롤백
@Transactional
public void runtimeException() {
log.info("call runtimeException");
throw new RuntimeException();
}
//체크 예외 발생: 커밋
@Transactional
public void checkedException() throws MyException {
log.info("call checkedException");
throw new MyException();
}
//체크 예외 rollbackFor 지정: 롤백
@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException {
log.info("call rollbackFor");
throw new MyException();
}
}
static class MyException extends Exception {
}
}
runtimeException() 수행 시, 아래와 같이 로그를 확인 가능
: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@4e4af370]
2023-05-31 18:03:59.414 TRACE 80177 --- [ Test worker] o.s.t.i.TransactionInterceptor : Getting transaction for [hello.springtx.exception.RollbackTest$RollbackService.runtimeException]
2023-05-31 18:03:59.418 INFO 80177 --- [ Test worker] h.s.e.RollbackTest$RollbackService : call runtimeException
2023-05-31 18:03:59.419 TRACE 80177 --- [ Test worker] o.s.t.i.TransactionInterceptor : Completing transaction for [hello.springtx.exception.RollbackTest$RollbackService.runtimeException] after exception: java.lang.RuntimeException
2023-05-31 18:03:59.419 DEBUG 80177 --- [ Test worker] o.s.orm.jpa.JpaTransactionManager : Initiating transaction rollback
2023-05-31 18:03:59.419 DEBUG 80177 --- [ Test worker] o.s.orm.jpa.JpaTransactionManager : Rolling back JPA transaction on EntityManager [SessionImpl(666625907<open>)]
스프링은 왜 체크 예외는 커밋하고, 언체크(런타임) 예외는 롤백할까?
스프링 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임(언체크) 예외는 복구 불가능한 예외로 가정한다.
체크 예외: 비즈니스 의미가 있을 때 사용 언체크 예외: 복구 불가능한 예외
따라서 체크 예외 -> catch 후 예외 처리 로직.
언체크 예외 -> 상위에 에러를 올려준다. 에러 response
참고로 꼭 이런 정책을 따를 필요는 없다. 그때는 앞서 배운 rollbackFor 라는 옵션을 사용해서 체크 예외도 롤백하면 된다.
그런데 비즈니스 의미가 있는 비즈니스 예외라는 것이 무슨 뜻일까? 간단한 예제로 알아보자.
비즈니스 요구사항
주문을 하는데 상황에 따라 다음과 같이 조치한다.
1. 정상: 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 완료 로 처리한다.
2. 시스템 예외: 주문시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다.
3. 비즈니스 예외: 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를 대기 로 처리한다.
이 경우 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다.
이때 결제 잔고가 부족하면 NotEnoughMoneyException 이라는 체크 예외가 발생한다고 가정하겠다. 이 예외는 시스템에 문제가 있어서 발생하는 시스템 예외가 아니다. 시스템은 정상 동작했지만, 비즈니스 상황에서 문제가 되기 때문에 발생한 예외이다. 더 자세히 설명하자면, 고객의 잔고가 부족한 것은 시스템에 문제가 있는 것이 아니다. 오히려 시스템은 문제 없이 동작한 것이고, 비즈니스 상황이 예외인 것이다. 이런 예외를 비즈니스 예외라 한다. 그리고 비즈니스 예외는 매우 중요하고, 반드시 처리해야 하는 경우가 많으므로 체크 예외를 고려할 수 있다.
즉, 매우 중요한 로직이 의도적으로 exception을 throw하여 어떠한 로직이 수행되도록할 때, 이 예외를 check 예외로 두는 것으로
안전하게 사용하면서도, rollback로직을 적용할 수 있다.
[ 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를 대기 로 처리한다.
이 경우 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다. ]
위 에러는 요청받은 결제 정보가 rollback으로 사라져서는 안되고, 별도의 처리로직이 나오도록
처리해야한다. 즉, 체크 예외로 던져줘야한다.
NotEnoughMoneyException
public class NotEnoughMoneyException extends Exception {
public NotEnoughMoneyException(String message) {
super(message);
} }
Order 엔티티
@Table(name = "orders")
@Getter
@Setter
public class Order {
@Id
@GeneratedValue
private Long id;
private String username; //정상, 예외, 잔고부족
private String payStatus; //대기, 완료 }
OrderRepository
public interface OrderRepository extends JpaRepository<Order, Long> {
}
OrderService
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
//JPA는 트랜잭션 커밋 시점에 Order 데이터를 DB에 반영한다.
@Transactional
public void order(Order order) throws NotEnoughMoneyException {
log.info("order 호출");
orderRepository.save(order);
log.info("결제 프로제스 진입");
if (order.getUsername().equals("예외")) {//유저이름이 "예외" 라면 언체크 예외 발생
log.info("시스템 예외 발생");
throw new RuntimeException("시스템 예외");
} else if (order.getUsername().equals("잔고부족")) {//유저이름이 "잔고부족" 라면 체크 예외 발생
log.info("잔고 부족 비즈니스 예외 발생");
order.setPayStatus("대기");
throw new NotEnoughMoneyException("잔고가 부족합니다");
} else {
//정상 승인
log.info("정상 승인");
order.setPayStatus("완료");
}
log.info("결제 프로세스 완료");
}
}
여러 상황을 만들기 위해서 사용자 이름( username )에 따라서 처리 프로세스를 다르게 했다. 기본 : payStatus 를 완료 상태로 처리하고 정상 처리된다.
예외 : RuntimeException("시스템 예외") 런타임 예외가 발생한다.
잔고부족 :
payStatus를 대기 상태로처리한다.
NotEnoughMoneyException("잔고가 부족합니다") 체크 예외가 발생한다.
잔고 부족은 payStatus 를 대기 상태로 두고, 체크 예외가 발생하지만, order 데이터는 커밋되기를 기대한다.
이제 테스트 해보자
@Slf4j
@SpringBootTest
class OrderServiceTest {
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
void complete() throws NotEnoughMoneyException {
//given
Order order = new Order();
order.setUsername("정상");
//when
orderService.order(order);
//then
Order findOrder = orderRepository.findById(order.getId()).get();
assertThat(findOrder.getPayStatus()).isEqualTo("완료");
}
@Test
void runtimeException() throws NotEnoughMoneyException {
//given
Order order = new Order();
order.setUsername("예외");
//when
Assertions.assertThatThrownBy(() -> orderService.order(order))
.isInstanceOf(RuntimeException.class);
//then
Optional<Order> orderOptional = orderRepository.findById(order.getId());
assertThat(orderOptional.isEmpty()).isTrue();
}
@Test
void bizException() {
//given
Order order = new Order();
order.setUsername("잔고부족");
//when
try {
orderService.order(order);
} catch (NotEnoughMoneyException e) {
log.info("고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내");
}
//then
Order findOrder = orderRepository.findById(order.getId()).get();
assertThat(findOrder.getPayStatus()).isEqualTo("대기");
}
}
complete()
사용자 이름을 정상 으로 설정했다. 모든 프로세스가 정상 수행된다.
다음을 통해서 데이터가 완료 상태로 저장 되었는지 검증한다. assertThat(findOrder.getPayStatus()).isEqualTo("완료");
runtimeException()
사용자 이름을 예외 로 설정했다.
RuntimeException("시스템 예외")이발생한다.
런타임 예외로 롤백이 수행되었기 때문에 Order 데이터가 비어 있는 것을 확인할 수 있다.
bizException()
사용자 이름을 잔고부족 으로 설정했다. NotEnoughMoneyException("잔고가 부족합니다")이발생한다.
체크 예외로 커밋이 수행되었기 때문에 Order 데이터가 저장된다. 다음을 통해서 데이터가 대기 상태로 잘 저장 되었는지 검증한다. assertThat(findOrder.getPayStatus()).isEqualTo("대기");
정리
NotEnoughMoneyException 은 시스템에 문제가 발생한 것이 아니라, 비즈니스 문제 상황을 예외를 통해 알려준다. 마치 예외가 리턴 값 처럼 사용된다. 따라서 이 경우에는 트랜잭션을 커밋하는 것이 맞다. 이 경우 롤백하면 생성한 Order 자체가 사라진다. 그러면 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내해도 주문( Order ) 자체가 사라지기 때문에 문제가 된다.
+ 아예 예외가 아닌, 약속된 리턴값(보통 enum)이 리턴되면 처리 로직을 수행하는 것도 가능
ex). enum.잔고부족 리턴 시 -> 잔고 부족 로직 수행 등등
그런데 비즈니스 상황에 따라 체크 예외의 경우에도 트랜잭션을 커밋하지 않고, 롤백하고 싶을 수 있다. 이때는 rollbackFor 옵션을 사용하면 된다.
런타임 예외는 항상 롤백된다. 체크 예외의 경우 rollbackFor 옵션을 사용해서 비즈니스 상황에 따라서 커밋과 롤백을 선택하면 된다.
그럼 실제로는 어떻게 예외를 사용하면 좋을까?
체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임(언체크) 예외는 복구 불가능한 예외로 가정
체크 예외는 정상적으로 서버가 동작 시에도, 의도적으로 비즈니스 로직을 위해 발생시키기 위해 사용한다.
언체크 예외는 정상동작시 발생하지 않고, 실제 장애가 생기면 발생하는 시스템 에러에 사용한다.
스프링도 체크 예외 == 비즈니스 의도로 발생 시킨 예외로 판단하여 rollback하지 않고,
언체크 예외 == 복구 불가 예외 로 판단하고 rollback하기에 아래와 같이 적절히 사용하면 좋다.
복구 불가 예외(시스템 에러): 상위에 unchecked예외로 올려준다( rollback 수행됨).
비즈니스 예외: 체크 예외로 두고, rollback하고 싶지 않다면 rollbackFor 옵션 사용( 체크예외들이 올라오는 경우가 존재한다, 이런건 rollbackFor로 처리해야한다...(레거시 프로젝트에서 흔하다.)
하지만, 스프링의 기본 설정에는 다 의도가 있기에, 최대한 기본 설정을 통해 사용하는 것이 좋은 방법이라고 한다.
스프링 트랜잭션 전파
트랜잭션이 둘 이상 있을 때 어떻게 동작하는지 자세히 알아보고, 스프링이 제공하는 트랜잭션 전파 (propagation)라는 개념도 알아보자.
우선 예제 코드를 작성해보자
@Slf4j
@SpringBootTest
public class BasicTxTest {
@Autowired
PlatformTransactionManager txManager;
@TestConfiguration
static class Config {
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);//트랜잭션 매니저, 데이터 소스를 지정해서 주입( 디폴트로 자동 주입해준다. )
}
}
@Test
void commit() {
log.info("트랜잭션 시작");
TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션 커밋 시작");
txManager.commit(status);
log.info("트랜잭션 커밋 완료");
}
@Test
void rollback() {
log.info("트랜잭션 시작");
TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션 롤백 시작");
txManager.rollback(status);
log.info("트랜잭션 롤백 완료");
}
@Test
void double_commit() {
log.info("트랜잭션1 시작");
TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션1 커밋");
txManager.commit(tx1);
log.info("트랜잭션2 시작");
TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션2 커밋");
txManager.commit(tx2);
}
@Test
void double_commit_rollback() {
log.info("트랜잭션1 시작");
TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션1 커밋");
txManager.commit(tx1);
log.info("트랜잭션2 시작");
TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션2 롤백");
txManager.rollback(tx2);
}
@Test
void inner_commit() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction()={}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("inner.isNewTransaction()={}", inner.isNewTransaction());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
@Test
void outer_rollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 롤백");
txManager.rollback(outer);
}
@Test
void inner_rollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner); //rollback-only 표시
log.info("외부 트랜잭션 커밋");
assertThatThrownBy(() -> txManager.commit(outer))
.isInstanceOf(UnexpectedRollbackException.class);
}
@Test
void inner_rollback_requires_new() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction()={}", outer.isNewTransaction()); //true
log.info("내부 트랜잭션 시작");
DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus inner = txManager.getTransaction(definition);
log.info("inner.isNewTransaction()={}", inner.isNewTransaction()); //true
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner); //롤백
log.info("외부 트랜잭션 커밋");
txManager.commit(outer); //커밋
}
}
@TestConfiguration : 해당 테스트에서 필요한 스프링 설정을 추가로 할 수 있다.
DataSourceTransactionManager 를 스프링 빈으로 등록했다. 이후 트랜잭션 매니저인 PlatformTransactionManager 를 주입 받으면 방금 등록한 DataSourceTransactionManager 가 주입된다.
기존의 코드에서, 몇가지 예제가 추가되었다.
2개의 트랜잭션이 각각 따로 수행되는 상황을 구현하였다.
@Test
void double_commit() {
log.info("트랜잭션1 시작");
TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션1 커밋");
txManager.commit(tx1);
log.info("트랜잭션2 시작");
TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션2 커밋");
txManager.commit(tx2);
}
실행 로그
트랜잭션1 시작
Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@1064414847 wrapping conn0] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0] to manual commit
트랜잭션1 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@1064414847 wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0] after transaction
트랜잭션2 시작
Creating new transaction with name [null]:
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@778350106 wrapping conn0] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@778350106 wrapping conn0] to manual commit
트랜잭션2 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@778350106 wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@778350106 wrapping conn0] after transaction
트랜잭션1
Acquired Connection [HikariProxyConnection@1064414847 wrapping conn0] for JDBC transaction
트랜잭션1을 시작하고, 커넥션 풀에서 conn0 커넥션을 획득했다.
Releasing JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0] after transaction
트랜잭션1을 커밋하고, 커넥션 풀에 conn0 커넥션을 반납했다.
트랜잭션2
Acquired Connection [HikariProxyConnection@ 778350106 wrapping conn0] for JDBC transaction
트랜잭션2을 시작하고, 커넥션 풀에서 conn0 커넥션을 획득했다.
Releasing JDBC Connection [HikariProxyConnection@ 778350106 wrapping conn0] after transaction
트랜잭션2을 커밋하고, 커넥션 풀에 conn0 커넥션을 반납했다.
주의!
로그를 보면 트랜잭션1과 트랜잭션2가 같은 conn0 커넥션을 사용중이다. 이것은 중간에 커넥션 풀 때문에 그런 것이다. 트랜잭션1은 conn0 커넥션을 모두 사용하고 커넥션 풀에 반납까지 완료했다. 이후에 트랜잭션2가 conn0 를 커넥션 풀에서 획득한 것이다. 따라서 둘은 완전히 다른 커넥션으로 인지하는 것이 맞다.
그렇다면 둘을 구분할 수 있는 다른 방법은 없을까?
히카리 커넥션 풀에서 커넥션을 획득하면 실제 커넥션을 그대로 반환하는 것이 아니라 내부 관리를 위해 히카리 프록시 커넥션이라는 객체를 생성해서 반환한다. 물론 내부에는 실제 커넥션이 포함되어 있다. 이 객체의 주소를 확인하면 커넥션 풀에서 획득한 커넥션을 구분할 수 있다.
트랜잭션1:
Acquired Connection [HikariProxyConnection@1000000 wrapping conn0]
트랜잭션2:
Acquired Connection [HikariProxyConnection@2000000 wrapping conn0]
히카리 커넥션풀이 반환해주는 커넥션을 다루는 프록시 객체의 주소가 트랜잭션1은 HikariProxyConnection@1000000 이고, 트랜잭션2는 HikariProxyConnection@2000000 으로 서로 다른 것을 확인할 수 있다.
결과적으로 conn0 을 통해 커넥션이 재사용 된 것을 확인할 수 있고, HikariProxyConnection@1000000 , HikariProxyConnection@2000000 을 통해 각각 커넥션 풀에서 커넥션을 조회한 것을 확인할 수 있다.
-> 커넥션 풀에서 같은 커넥션을 가져올 수 있지만, 커넥션 풀이 반환하는, 커넥션 풀을 다루는 프록시 객체는 주소가 다르기에 구별가능하다.
트랜잭션이 각각 수행되면서 사용되는 DB 커넥션도 각각 다르다.
이 경우 트랜잭션을 각자 관리하기 때문에 전체 트랜잭션을 묶을 수 없다. 예를 들어서 트랜잭션1이 커밋하고, 트랜잭션2가 롤백하는 경우 트랜잭션1에서 저장한 데이터는 커밋되고, 트랜잭션2에서 저장한 데이터는 롤백된다. 다음 예제를 확인해보자.
@Test
void double_commit_rollback() {
log.info("트랜잭션1 시작");
TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션1 커밋");
txManager.commit(tx1);
log.info("트랜잭션2 시작");
TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션2 롤백");
txManager.rollback(tx2);
}
실행 로그
트랜잭션1 시작
Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@1943867171 wrapping conn0] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@1943867171 wrapping conn0] to manual commit
트랜잭션1 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@1943867171 wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@1943867171 wrapping conn0] after transaction
트랜잭션2 시작
Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@239290560 wrapping conn0] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@239290560 wrapping conn0] to manual commit
트랜잭션2 롤백
Initiating transaction rollback
Rolling back JDBC transaction on Connection [HikariProxyConnection@239290560 wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@239290560 wrapping conn0] after transaction
로그를 보면 트랜잭션1은 커밋되지만, 트랜잭션2는 롤백되는 것을 확인할 수 있다.
이제 트랜잭션 전파에 대해 알아보자.
트랜잭션 전파
트랜잭션을 각각 사용하는 것이 아니라, 트랜잭션이 이미 진행중인데, 여기에 추가로 트랜잭션을 수행하면 어떻게 될까?
기존 트랜잭션과 별도의 트랜잭션을 진행해야 할까? 아니면 기존 트랜잭션을 그대로 이어 받아서 트랜잭션을 수행해야 할까?
이런 경우 어떻게 동작할지 결정하는 것을 트랜잭션 전파(propagation)라 한다. 참고로 스프링은 다양한 트랜잭션 전파 옵션을 제공한다.
**참고
- 지금부터 설명하는 내용은 트랜잭션 전파의 기본 옵션인 REQUIRED 를 기준으로 설명할 것이다.
- 실무에서는 대부분 REQUIRED 옵션을 사용한다. 그리고 아주 가끔 REQUIRES_NEW 을 사용하고, 나머지는 거의 사용하지 않는다. 이번 글에선 Required 옵션을 기반으로 트랜잭션 전파에 대해 살펴보고, Required_new의 내부 동작까지 살펴보겠다.
- 외부 트랜잭션이 수행중이고, 아직 끝나지 않았는데, 내부 트랜잭션이 수행된다.
- 외부 트랜잭션이라고 이름 붙인 것은 둘 중 상대적으로 밖에 있기 때문에 외부 트랜잭션이라 한다. 처음 시작된 트랜잭션으로 이해하면 된다.
- 내부 트랜잭션은 외부에 트랜잭션이 수행되고 있는 도중에 호출되기 때문에 마치 내부에 있는 것 처럼 보여서 내부 트랜잭션이라 한다.
스프링 이 경우 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 트랜잭션을 만들어준다. 내부 트랜잭션이 외부 트랜잭션에 참여하는 것이다. 이것이 기본 동작이고, 옵션을 통해 다른 동작방식도 선택할 수 있다.
- 스프링은 이해를 돕기 위해 논리 트랜잭션과 물리 트랜잭션이라는 개념을 나눈다.
- 논리 트랜잭션들은 하나의 물리 트랜잭션으로 묶인다.
- 물리 트랜잭션은 우리가 이해하는 실제 데이터베이스에 적용되는 트랜잭션을 뜻한다. 실제 커넥션을 통해서 트랜잭션을 시작( -setAutoCommit(false)) 하고, 실제 커넥션을 통해서 커밋, 롤백하는 단위이다.
- 논리 트랜잭션은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위이다.
- 이러한 논리 트랜잭션 개념은 트랜잭션이 진행되는 중에 내부에 추가로 트랜잭션을 사용하는 경우에 나타난다. 단순히 트랜잭션이 하나인 경우 둘을 구분하지는 않는다. (더 정확히는 REQUIRED 전파 옵션을 사용하는 경우에 나타나고, 이 옵션은 뒤에서 설명한다.)
실제에서는 신규 트랜잭션-> 물리 트랜잭션 생성
이미 트랜잭션 존재 -> 논리 트랜잭션 생성.
그럼 왜 이렇게 논리 트랜잭션과 물리 트랜잭션을 나누어 설명하는 것일까?
트랜잭션이 사용중일 때 또 다른 트랜잭션이 내부에 사용되면 여러가지 복잡한 상황이 발생한다. 이때 논리 트랜잭션 개념을 도입하면 다음과 같은 단순한 원칙을 만들 수 있다.
원칙
- 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
- 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.
풀어서 설명하면 이렇게 된다. 모든 트랜잭션 매니저를 커밋해야 물리 트랜잭션이 커밋된다. 하나의 트랜잭션 매니저라도 롤백하면 물리 트랜잭션은 롤백된다.
즉, 내부, 외부 트랜잭션 모두 트랜잭션 매니저를 통해 트랜잭션을 적용하게 되는데, 이는 실제 DB에 바로 적용되는 각각의 트랜잭션이 아니다. ( 외부 트랜잭션에 내부 트랜잭션 추가 등등... ) 실제 DB에 반영되는 트랜잭션 내에서, 하나의 논리 트랜잭션이라도 실패하면
전체 덩어리가 rollback된다.
@Test
void inner_commit() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction()={}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("inner.isNewTransaction()={}", inner.isNewTransaction());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
- 외부 트랜잭션이 수행중인데, 내부 트랜잭션을 추가로 수행했다.
- 외부 트랜잭션은 처음 수행된 트랜잭션이다. 이 경우 신규 트랜잭션( isNewTransaction=true )이 된다.
- 내부 트랜잭션을 시작하는 시점에는 이미 외부 트랜잭션이 진행중인 상태이다. 이 경우 내부 트랜잭션은 외부 트랜잭션에 참여한다.
- 트랜잭션 참여
- 내부 트랜잭션이 외부 트랜잭션에 참여한다는 뜻은 내부 트랜잭션이 외부 트랜잭션을 그대로 이어 받아서 따른다는 뜻이다.
- 다른 관점으로 보면 외부 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다는 뜻이다.
- 외부에서 시작된 물리적인 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다는 뜻이다.
- 정리하면 외부 트랜잭션과 내부 트랜잭션이 하나의 물리 트랜잭션으로 묶이는 것이다.
*하나의 커넥션은 커밋하면 반환된다. 위의 예제에서는, 분명 내부 트랜잭션이 외부 트랜잭션에 참여하여,
하나의 트랜잭션이 되었기에, 커넥션을 1개만 사용할 텐데, 위에는 commit을 내부, 외부 트랜잭션 각각이 수행됬다.
하지만, 아무 문제 없이 테스트가 수행된다.
스프링이 어떻게 어떻게 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 물리 트랜잭션으로 묶어서 동작하게 하는지 실행 결과룰 통해 자세히 알아보자.
실행 결과
외부 트랜잭션 시작
Creating new transaction with name [null]:
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@1943867171 wrapping conn0] for JDBC
transaction
Switching JDBC Connection [HikariProxyConnection@1943867171 wrapping conn0] to
manual commit
outer.isNewTransaction()=true
내부 트랜잭션 시작
Participating in existing transaction inner.isNewTransaction()=false
내부 트랜잭션 커밋// -> 아무 로그도 없다. 실제 커밋이 수행되지 않았다는 뜻.
외부 트랜잭션 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@1943867171
wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@1943867171 wrapping conn0]
after transaction
- 내부 트랜잭션을 시작할 때 Participating in existing transaction 이라는 메시지를 확인할 수 있다. 이 메시지는 내부 트랜잭션이 기존에 존재하는 외부 트랜잭션에 참여한다는 뜻이다.
- 실행 결과를 보면 외부 트랜잭션을 시작하거나 커밋할 때는 DB 커넥션을 통한 물리 트랜잭션을 시작
( manual commit )하고, DB 커넥션을 통해 커밋 하는 것을 확인할 수 있다. 그런데 내부 트랜잭션을 시작하거나 커밋할 때는 DB 커넥션을 통해 커밋하는 로그를 전혀 확인할 수 없다.
- 정리하면 외부 트랜잭션만 물리 트랜잭션을 시작하고, 커밋한다.
- 만약 내부 트랜잭션이 실제 물리 트랜잭션을 커밋하면 트랜잭션이 끝나버리기 때문에, 트랜잭션을 처음 시작한 외부 트랜잭션까지 이어갈 수 없다. 따라서 내부 트랜잭션은 DB 커넥션을 통한 물리 트랜잭션을 커밋하면 안된다.
- 스프링은 이렇게 여러 트랜잭션이 함께 사용되는 경우, 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다. 이를 통해 트랜잭션 중복 커밋 문제를 해결한다.
요청 흐름 - 외부 트랜잭션
1. txManager.getTransaction() 를 호출해서 외부 트랜잭션을 시작한다.
2. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성한다.
3. 생성한 커넥션을 수동 커밋 모드( setAutoCommit(false) )로 설정한다. - 물리 트랜잭션 시작
4. 트랜잭션 매니저는 트랜잭션 동기화 매니저에 커넥션을 보관한다.
5. 트랜잭션 매니저는 트랜잭션을 생성한 결과를 TransactionStatus 에 담아서 반환하는데, 여기에 신규 트랜잭션의 여부가 담겨 있다. isNewTransaction 를 통해 신규 트랜잭션 여부를 확인할 수 있다. 트랜잭션을 처음 시작했으므로 신규 트랜잭션이다.( true )
6. 로직1(트랜잭션 처리 로직)이 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 커넥션을 획득해서 사용한다.
요청 흐름 - 내부 트랜잭션
7. txManager.getTransaction() 를 호출해서 내부 트랜잭션을 시작한다.
8. 트랜잭션 매니저는 트랜잭션 동기화 매니저를 통해서 기존 트랜잭션이 존재하는지 확인한다.
9. 기존 트랜잭션이 존재하므로 기존 트랜잭션에 참여한다. 기존 트랜잭션에 참여한다는 뜻은 사실 아무것도 하지 않는다는 뜻이다.
이미 기존 트랜잭션인 외부 트랜잭션에서 물리 트랜잭션을 시작했다. 그리고 물리 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 담아두었다.
따라서 이미 물리 트랜잭션이 진행중이므로 그냥 두면 이후 로직이 기존에 시작된 트랜잭션을 자연스럽게 사용하게 되는 것이다.
이후 로직은 자연스럽게 트랜잭션 동기화 매니저에 보관된 기존 커넥션을 사용하게 된다.
10. 트랜잭션 매니저는 트랜잭션을 생성한 결과를 TransactionStatus 에 담아서 반환하는데, 여기에서 isNewTransaction 를 통해 신규 트랜잭션 여부를 확인할 수 있다. 여기서는 기존 트랜잭션에 참여했기 때문에 신규 트랜잭션이 아니다. ( false )
11. 로직2가 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 외부 트랜잭션이 보관한 커넥션을 획득해서 사용한다
(이를 통해 트랜잭션이 하나로 묶일 수 있는 것.)
커밋 이후, 응답 흐름
응답 흐름 - 내부 트랜잭션
12. 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션을 커밋한다.
13. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 이 경우 신규 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않는다. 이 부분이 중요한데, 실제 커넥션에 커밋이나 롤백을 호출하면 물리 트랜잭션이 끝나버린다. 아직 트랜잭션이 끝난 것이 아니기 때문에 실제 커밋을 호출하면 안된다. 물리 트랜잭션은 외부 트랜잭션을 종료할 때 까지 이어져야한다.
응답 흐름 - 외부 트랜잭션
14. 로직1이 끝나고 트랜잭션 매니저를 통해 외부 트랜잭션을 커밋한다.
15. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 외부 트랜잭션은 신규 트랜잭션이다. 따라서 DB 커넥션에 실제 커밋을 호출한다.
16. 트랜잭션 매니저에 커밋하는 것이 논리적인 커밋이라면, 실제 커넥션에 커밋하는 것을 물리 커밋이라 할 수 있다. 실제 데이터베이스에 커밋이 반영되고, 물리 트랜잭션도 끝난다.
핵심 정리
- 여기서 핵심은 트랜잭션 매니저에 커밋을 호출한다고해서 항상 실제 커넥션에 물리 커밋이 발생하지는 않는다는 점이다.
- 신규 트랜잭션인 경우에만 실제 커넥션을 사용해서 물리 커밋과 롤백을 수행한다. 신규 트랜잭션이 아니면 실제 물리 커넥션을 사용하지 않는다.
- 이렇게 트랜잭션이 내부에서 추가로 사용되면 트랜잭션 매니저에 커밋하는 것이 항상 물리 커밋으로 이어지지 않는다. 그래서 이 경우 논리 트랜잭션과 물리 트랜잭션을 나누게 된다. 또는 외부 트랜잭션과 내부 트랜잭션으로 나누어 설명하기도 한다.
- 트랜잭션이 내부에서 추가로 사용되면, 트랜잭션 매니저를 통해 논리 트랜잭션을 관리하고, 모든 논리 트랜잭션이 커밋되면 물리 트랜잭션이 커밋된다고 이해하면 된다.
이번엔 만약 외부 롤백이 발생한다면, 어떻게 수행할지 알아보자.
스프링 트랜잭션 전파- 외부 롤백
논리 트랜잭션이 하나라도 롤백되면 전체 물리 트랜잭션은 롤백된다.
따라서 이 경우 내부 트랜잭션이 커밋했어도, 내부 트랜잭션 안에서 저장한 데이터도 모두 함께 롤백된다.
내부 트랜잭션은 앞서 배운대로 직접 물리 트랜잭션에 관여하지 않는다.
결과적으로 외부 트랜잭션에서 시작한 물리 트랜잭션의 범위가 내부 트랜잭션까지 사용된다. 이후 외부 트랜잭션이 롤백되면서 전체 내용은 모두 롤백된다.
요청 흐름은 앞선 흐름과 같으니, 응답 흐름을 확인해보자.
외부 트랜잭션 롤백시, 응답 흐름
응답 흐름 - 내부 트랜잭션
1. 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션을 커밋한다.
2. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 이 경우 신규 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않는다. 이 부분이 중요한데, 실제 커넥션에 커밋이나 롤백을 호출하면 물리 트랜잭션이 끝나버린다. 아직 트랜잭션이 끝난 것이 아니기 때문에 실제 커밋을 호출하면 안된다. 물리 트랜잭션은 외부 트랜잭션을 종료할 때 까지 이어져야한다.
응답 흐름 - 외부 트랜잭션
3. 로직1이 끝나고 트랜잭션 매니저를 통해 외부 트랜잭션을 롤백한다.
4. 트랜잭션 매니저는 롤백 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 외부 트랜잭션은 신규 트랜잭션이다. 따라서 DB 커넥션에 실제 롤백을 호출한다.
5. 트랜잭션 매니저에 롤백하는 것이 논리적인 롤백이라면, 실제 커넥션에 롤백하는 것을 물리 롤백이라 할 수 있다. 실제 데이터베이스에 롤백이 반영되고, 물리 트랜잭션도 끝난다.
앞선 커밋 예시에서, 롤백으로 바뀐 것 뿐이다.
스프링 트랜잭션 전파 - 내부 롤백
이번에는 내부 트랜잭션은 롤백되는데, 외부 트랜잭션이 커밋되는 상황을 알아보자.
이 상황은 겉으로 보기에는 단순하지만, 실제로는 단순하지 않다. 내부 트랜잭션이 롤백을 했지만, 내부 트랜잭션은 물리 트랜잭션에 영향을 주지 않는다. 그런데 외부 트랜잭션은 커밋을 해버린다. 지금까지 학습한 내용을 돌아보면 외부 트랜잭션만 물리 트랜잭션에 영향을 주기 때문에 물리 트랜잭션이 커밋될 것 같다.
전체를 롤백해야 하는데, 스프링은 이 문제를 어떻게 해결할까? 지금부터 함께 살펴보자.
테스트 코드
@Test
void inner_rollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner); //rollback-only 표시
log.info("외부 트랜잭션 커밋");
assertThatThrownBy(() -> txManager.commit(outer))
.isInstanceOf(UnexpectedRollbackException.class);
}
실행 결과
실행 결과를 보면 마지막에 외부 트랜잭션을 커밋할 때 UnexpectedRollbackException.class 이 발생하는 것을 확인할 수 있다. 이 부분은 바로 뒤에 설명한다.
외부 트랜잭션 시작
Creating new transaction with name [null]:
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@220038608 wrapping conn0] for JDBC
transaction
Switching JDBC Connection [HikariProxyConnection@220038608 wrapping conn0] to
manual commit
내부 트랜잭션 시작
Participating in existing transaction
내부 트랜잭션 롤백
Participating transaction failed - marking existing transaction as rollback- //내부 트랜잭션 실패 로그!
only
Setting JDBC transaction [HikariProxyConnection@220038608 wrapping conn0]
rollback-only
외부 트랜잭션 커밋
Global transaction is marked as rollback-only but transactional code requested
commit
Initiating transaction rollback
Rolling back JDBC transaction on Connection [HikariProxyConnection@220038608
wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@220038608 wrapping conn0]
after transaction
외부 트랜잭션 시작
물리 트랜잭션을 시작한다.
- 내부 트랜잭션 시작 기존 트랜잭션에 참여한다.
- Participating in existing transaction
- 내부 트랜잭션 롤백
Participating transaction failed - marking existing transaction as rollback-only
내부 트랜잭션을 롤백하면 실제 물리 트랜잭션은 롤백하지 않는다. 대신에 기존 트랜잭션을 롤백 전용으로 표시한다.
트랜잭션 커밋
- 외부 트랜잭션을 커밋한다.
- Global transaction is marked as rollback-only
- 커밋을 호출했지만, 전체 트랜잭션이 롤백 전용으로 표시되어 있다. 따라서 물리 트랜잭션을 롤백한다.
응답 흐름 - 내부 트랜잭션
1. 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션을 롤백한다. (로직2에 문제가 있어서 롤백한다고 가정한다.)
2. 트랜잭션 매니저는 롤백 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 이 경우 신규 트랜잭션이 아니기 때문에 실제 롤백을 호출하지 않는다. 이 부분이 중요한데, 실제 커넥션에 커밋이나 롤백을 호출하면 물리 트랜잭션이 끝나버린다. 아직 트랜잭션이 끝난 것이 아니기 때문에 실제 롤백을 호출하면 안된다. 물리 트랜잭션은 외부 트랜잭션을 종료할 때 까지 이어져야한다.
3. 내부 트랜잭션은 물리 트랜잭션을 롤백하지 않는 대신에 트랜잭션 동기화 매니저에 rollbackOnly=true 라는 표시를 해둔다.
응답 흐름 - 외부 트랜잭션
4. 로직1이 끝나고 트랜잭션 매니저를 통해 외부 트랜잭션을 커밋한다.
5. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 외부 트랜잭션은 신규 트랜잭션이다. 따라서 DB 커넥션에 실제 커밋을 호출해야 한다. 이때 먼저 트랜잭션 동기화 매니저에 롤백 전용( rollbackOnly=true ) 표시가 있는지 확인한다. 롤백 전용 표시가 있으면 물리 트랜잭션을 커밋하는 것이 아니라 롤백한다.
6. 실제 데이터베이스에 롤백이 반영되고, 물리 트랜잭션도 끝난다.
7. 트랜잭션 매니저에 커밋을 호출한 개발자 입장에서는 분명 커밋을 기대했는데 롤백 전용 표시로 인해 실제로는 롤백이 되어버렸다.
이것은 조용히 넘어갈 수 있는 문제가 아니다. 시스템 입장에서는 커밋을 호출했지만 롤백이 되었다는 것은 분명하게 알려주어야 한다.
예를 들어서 고객은 주문이 성공했다고 생각했는데, 실제로는 롤백이 되어서 주문이 생성되지 않은 것이다.
스프링은 이 경우 UnexpectedRollbackException 런타임 예외를 던진다. 그래서 커밋을 시도했지만, 기대하지 않은 롤백이 발생했다는 것을 명확하게 알려준다.
정리
논리 트랜잭션이 하나라도 롤백되면 물리 트랜잭션은 롤백된다.
내부 논리 트랜잭션이 롤백되면 트랜잭션 동기화 매니저에 롤백 전용 마크를 표시한다.
외부 트랜잭션을 커밋할 때 롤백 전용 마크를 확인한다. 롤백 전용 마크가 표시되어 있으면 물리 트랜잭션을 롤백하고, UnexpectedRollbackException 예외를 던진다.
*참고
애플리케이션 개발에서 중요한 기본 원칙은 모호함을 제거하는 것이다. 개발은 명확해야 한다. 이렇게 커밋을 호출했는데, 내부에서 롤백이 발생한 경우 모호하게 두면 아주 심각한 문제가 발생한다. 이렇게 기대한 결과가 다른 경우 예외를 발생시켜서 명확하게 문제를 알려주는 것이 좋은 설계이다.
여태까지는, 트랜잭션 propagation 기본 설정인 REQUIRED에 대해 알아 봤다. 이제 다른 옵션도 알아보자.
스프링 트랜잭션 전파 - REQUIRES_NEW
이번에는 외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 사용하는 방법에 대해서 알아보자.
외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 각각 별도의 물리 트랜잭션을 사용하는 방법이다. 그래서 커밋과 롤백도 각각 별도로 이루어지게 된다.
이 방법은 내부 트랜잭션에 문제가 발생해서 롤백해도, 외부 트랜잭션에는 영향을 주지 않는다. 반대로 외부 트랜잭션에 문제가 발생해도 내부 트랜잭션에 영향을 주지 않는다. 이 방법을 사용하는 구체적인 예는 이후에 알아보고 지금은 작동 원리를 이해해보자.
- 이렇게 물리 트랜잭션을 분리하려면 내부 트랜잭션을 시작할 때 REQUIRES_NEW 옵션을 사용하면 된다.
- 외부 트랜잭션과 내부 트랜잭션이 각각 별도의 물리 트랜잭션을 가진다.
- 별도의 물리 트랜잭션을 가진다는 뜻은 DB 커넥션을 따로 사용한다는 뜻이다.
- 이 경우 내부 트랜잭션이 롤백되면서 로직 2가 롤백되어도 로직 1에서 저장한 데이터에는 영향을 주지 않는다.
- 최종적으로 로직2는 롤백되고, 로직1은 커밋된다.
@Test
void inner_rollback_requires_new() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction()={}", outer.isNewTransaction()); //true
log.info("내부 트랜잭션 시작");
DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus inner = txManager.getTransaction(definition);
log.info("inner.isNewTransaction()={}", inner.isNewTransaction()); //true
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner); //롤백
log.info("외부 트랜잭션 커밋");
txManager.commit(outer); //커밋
}
외부 트랜잭션 시작
Creating new transaction with name [null]:
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@1064414847 wrapping conn0] for JDBC
transaction
Switching JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0] to
manual commit
outer.isNewTransaction()=true //outer 트랜잭션 만들고, 잠시 미뤄누고 inner 트랜잭션 새로 생성
내부 트랜잭션 시작
Suspending current transaction, creating new transaction with name [null]
Acquired Connection [HikariProxyConnection@778350106 wrapping conn1] for JDBC
transaction
Switching JDBC Connection [HikariProxyConnection@778350106 wrapping conn1] to
manual commit
inner.isNewTransaction()=true
내부 트랜잭션 롤백
Initiating transaction rollback
Rolling back JDBC transaction on Connection [HikariProxyConnection@778350106
wrapping conn1]
Releasing JDBC Connection [HikariProxyConnection@778350106 wrapping conn1]
after transaction
Resuming suspended transaction after completion of inner transaction
외부 트랜잭션 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@1064414847
wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0]
after transaction
외부 트랜잭션 시작
외부 트랜잭션을 시작하면서 conn0 를 획득하고 manual commit 으로 변경해서 물리 트랜잭션을 시작한다.
외부 트랜잭션은 신규 트랜잭션이다.( outer.isNewTransaction()=true )
내부 트랜잭션 시작
내부 트랜잭션을 시작하면서 conn1 를 획득하고 manual commit 으로 변경해서 물리 트랜잭션을 시작한다.
내부 트랜잭션은 외부 트랜잭션에 참여하는 것이 아니라, PROPAGATION_REQUIRES_NEW 옵션을 사용했기 때문에 완전히 새로운 신규 트랜잭션으로 생성된다.( inner.isNewTransaction()=true )
내부 트랜잭션 롤백
내부 트랜잭션을 롤백한다.
내부 트랜잭션은 신규 트랜잭션이기 때문에 실제 물리 트랜잭션을 롤백한다. 내부 트랜잭션은 conn1 을 사용하므로 conn1 에 물리 롤백을 수행한다.
외부 트랜잭션 커밋
외부 트랜잭션을 커밋한다.
외부 트랜잭션은 신규 트랜잭션이기 때문에 실제 물리 트랜잭션을 커밋한다. 외부 트랜잭션은 conn0 를 사용하므로 conn0 에 물리 커밋을 수행한다.
요청 흐름 - 외부 트랜잭션
1. txManager.getTransaction() 를 호출해서 외부 트랜잭션을 시작한다.
2. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성한다.
3. 생성한 커넥션을 수동 커밋 모드( setAutoCommit(false) )로 설정한다. - 물리 트랜잭션 시작
4. 트랜잭션 매니저는 트랜잭션 동기화 매니저에 커넥션을 보관한다.
5. 트랜잭션 매니저는 트랜잭션을 생성한 결과를 TransactionStatus 에 담아서 반환하는데, 여기에 신규 트랜잭션의 여부가 담겨 있다. isNewTransaction 를 통해 신규 트랜잭션 여부를 확인할 수 있다. 트랜잭션을 처음 시작했으므로 신규 트랜잭션이다.( true )
6. 로직1이 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 커넥션을 획득해서 사용한다.
요청 흐름 - 내부 트랜잭션
7. REQUIRES_NEW 옵션과 함께 txManager.getTransaction() 를 호출해서 내부 트랜잭션을 시작한다.
트랜잭션 매니저는 REQUIRES_NEW 옵션을 확인하고, 기존 트랜잭션에 참여하는 것이 아니라 새로운
트랜잭션을 시작한다.
8. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성한다.
9. 생성한 커넥션을 수동 커밋 모드( setAutoCommit(false) )로 설정한다. - 물리 트랜잭션 시작 10. 트랜잭션 매니저는 트랜잭션 동기화 매니저에 커넥션을 보관한다.
이때 con1 은 잠시 보류되고, 지금부터는 con2 가 사용된다. (내부 트랜잭션을 완료할 때 까지 con2
가 사용된다.)
11. 트랜잭션 매니저는 신규 트랜잭션의 생성한 결과를 반환한다. isNewTransaction == true
12. 로직2가 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저에 있는 con2 커넥션을 획득해서 사용한다.
응답 흐름 - 내부 트랜잭션
1. 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션을 롤백한다. (로직2에 문제가 있어서 롤백한다고 가정한다.)
2. 트랜잭션 매니저는 롤백 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 현재 내부 트랜잭션은 신규 트랜잭션이다. 따라서 실제 롤백을 호출한다.
3. 내부 트랜잭션이 con2 물리 트랜잭션을 롤백한다.
트랜잭션이 종료되고, con2 는 종료되거나, 커넥션 풀에 반납된다. 이후에 con1 의 보류가 끝나고, 다시 con1 을 사용한다.
응답 흐름 - 외부 트랜잭션
4. 외부 트랜잭션에 커밋을 요청한다.
5. 외부 트랜잭션은 신규 트랜잭션이기 때문에 물리 트랜잭션을 커밋한다.
6. 이때 rollbackOnly 설정을 체크한다. rollbackOnly 설정이 없으므로 커밋한다. 7. 본인이 만든 con1 커넥션을 통해 물리 트랜잭션을 커밋한다.
트랜잭션이 종료되고, con1 은 종료되거나, 커넥션 풀에 반납된다.
정리
REQUIRES_NEW 옵션을 사용하면 물리 트랜잭션이 명확하게 분리된다.
REQUIRES_NEW 를 사용하면 데이터베이스 커넥션이 동시에 2개 사용된다는 점을 주의해야 한다( 커넥션 수는 커넥션 풀에서 한정되어 있다.).
스프링 트랜잭션 전파 - 다양한 전파 옵션
스프링은 다양한 트랜잭션 전파 옵션을 제공한다. 전파 옵션에 별도의 설정을 하지 않으면 REQUIRED 가 기본으로 사용된다.
참고로 실무에서는 대부분 REQUIRED 옵션을 사용한다. 그리고 아주 가끔 REQUIRES_NEW 을 사용하고, 나머지는 거의 사용하지 않는다. 그래서 나머지 옵션은 이런 것이 있다는 정도로만 알아두고 필요할 때 찾아보자.
REQUIRED
가장 많이 사용하는 기본 설정이다. 기존 트랜잭션이 없으면 생성하고, 있으면 참여한다. 트랜잭션이 필수라는 의미로 이해하면 된다. (필수이기 때문에 없으면 만들고, 있으면 참여한다.) 기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다.
기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.
REQUIRES_NEW
항상 새로운 트랜잭션을 생성한다.
기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다.
기존 트랜잭션 있음: 새로운 트랜잭션을 생성한다.
SUPPORT
트랜잭션을 지원한다는 뜻이다. 기존 트랜잭션이 없으면, 없는대로 진행하고, 있으면 참여한다.
기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.
NOT_SUPPORT
트랜잭션을 지원하지 않는다는 의미이다.
기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
기존 트랜잭션 있음: 트랜잭션 없이 진행한다. (기존 트랜잭션은 보류한다)
MANDATORY
의무사항이다. 트랜잭션이 반드시 있어야 한다. 기존 트랜잭션이 없으면 예외가 발생한다.
기존 트랜잭션 없음: IllegalTransactionStateException 예외 발생
기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.
NEVER
트랜잭션을 사용하지 않는다는 의미이다. 기존 트랜잭션이 있으면 예외가 발생한다. 기존 트랜잭션도 허용하지 않는 강한 부정의 의미로 이해하면 된다.
기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
기존 트랜잭션 있음: IllegalTransactionStateException 예외 발생
NESTED
Required와 유사하지만, 기존 트랜잭션이 존재한다면, "중첩 트랜잭션" 을 만든다.
- 기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다.
- 기존 트랜잭션 있음: 중첩 트랜잭션을 만든다. - 중첩 트랜잭션은 외부 트랜잭션의 영향을 받지만, 중첩 트랜잭션은 외부에 영향을 주지 않는다.
- 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋할 수 있다.
- 외부 트랜잭션이 롤백 되면 중첩 트랜잭션도 함께 롤백된다.
- * 참고
- JDBC savepoint 기능을 사용한다. DB 드라이버에서 해당 기능을 지원하는지 확인이 필요하다.
- 중첩 트랜잭션은 JPA에서는 사용할 수 없다.
트랜잭션 전파와 옵션
isolation , timeout , readOnly 는 트랜잭션이 처음 시작될 때만 적용된다.
트랜잭션에 참여하는 경우에는 적용되지 않는다.
예를 들어서 REQUIRED 를 통한 트랜잭션 시작, REQUIRES_NEW 를 통한 트랜잭션 시작 시점에만 적용된다.
'Spring boot' 카테고리의 다른 글
김영한 스프링 핵심 원리 - 기본편 정리 글 (0) | 2023.06.09 |
---|---|
[스프링 데이터 접근 활용 기술] 스프링 트랜잭션 propagation 활용 (0) | 2023.06.01 |
[스프링 데이터 접근 활용 기술 1] (0) | 2023.05.31 |
[스프링 데이터 접근 활용 기술] JPA, Spring Data JPA, QueryDSL의 활용 (0) | 2023.05.30 |
[spring 데이터 접근 핵심 원리 7] Spring 예외 처리 (0) | 2023.05.30 |