각각의 데이터 접근 기술들은 트랜잭션을 처리하는 방식에 차이가 있다.
예를 들어, JDBC기수로가 JPA기술은 트랜잭션을 사용하는 코드자체가 다르다.
따라서, JDBC기술을 사용하다가 JPA 기술로 변경하게 되면 트랜잭션을 사용하는 코드들도 모두 함께 변경해야한다.
스프링은 이런 문제를 해결하기 위해서 트랜잭션 추상화를 제공했다.
참고)
트랜잭션 매니저 정리 포스팅
좀 더 첨언을 하자면 트랜잭션 매니저는 트랜잭션을 사용하는 방법을 추상화한것이고, 데이터소스는 여러 종류의 커넥션 풀에서 데이터 커넥션을 얻기 위한 방법을 추상화한것이다.
스프링은 PlatformTransactionManager 라는 인터페이스를 통해 트랜잭션을 추상화한다.
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
트랜잭션은 트랜잭션 시작, 커밋, 롤백으로 단순하게 추상화할 수 있다.
스프링은 트랜잭션을 추상화해서 제공할 뿐만 아니라 실무에서 주로 사용하는 데이터 접근 기술에 대한 트랜잭션 매니저의 구현체도 제공한다. 우리는 필요한 구현체를 스프링빈으로 등록하고 주입 받아서 사용하기만 하면 된다.
여기에 더해서 스프링 부트는 어떤 데이터 접근 기술을 사용하는지 자동으로 인식해서 적절한 트랜잭션 매니저를 선택해서 스프링빈으로 등록해주기 떄문에 트랜잭션 매니저를 선택하고 등록하는 과정도 생략할 수 있다.
JdbcTemplate, MyBatis를 사용하면 DataSourceTransactionalManager를 스프링빈으로 등록하고, JPA를 사용하면 JpaTransactionManager를 스프링빈으로 등록해준다.
트랜잭션은 기본적으로 선언적 트랜잭션과 프로그래밍 방식 트랜잭션 관리 방식을 사용한다.
일반적으로는 선언적 트랜잭션 관리를 많이 사용한다.
선언전 트랜잭션(@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);
}
트랜잭션을 처리하기 위한 프록시를 적용하면 트랜잭션을 처리하는 객체와 비지니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.
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);
}
}
}
-----
public class Service {
public void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
}
@Transactional을 통해 선언적 트랜잭션 방식을 사용하면 단순히 애노테이션 하나로 트랜잭션을 적용할 수 있다. 그러나 코드가 눈에 보이지않아 AOP를 기반으로 동작하기 때문에 실제 트랜잭션이 적용되고 있는지 확인이 어렵다.
아래의 예시는 트랜잭션 적용 여부 확인 예시이다.
@Slf4j
@SpringBootTest
public class TxBasicTest {
@Autowired BasicService basicService;
@Test
void proxyCheck() {
log.info("aop class={}", basicService.getClass());
assertThat(AopUtils.isAopProxy(basicService)).isTrue();
}
@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();
log.info("tx active={}", txActive);
}
public void nonTx() {
log.info("call nonTx");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
참고
application.properties에 아래의 로그를 추가하면 트랜잭션의 시작과 종료 로그를 명확하게 확인할 수 있다.
logging.level.org.springframework.transaction.interceptor=TRACE
클라이언트가 basicService.tx() 를 호출하면, 프록시의 tx() 가 호출된다. 여기서 프록시는 tx() 메서드가 트랜잭션을 사용할 수 있는지 확인해본다. tx() 메서드에는 @Transactional 이 붙어있으므로 트랜잭션 적용 대상이다.
따라서 트랜잭션을 시작한 다음에 실제 basicService.tx() 를 호출한다.
그리고 실제 basicService.tx() 의 호출이 끝나서 프록시로 제어가(리턴) 돌아오면 프록시는 트랜잭션 로직을 커밋하거나 롤백해서 트랜잭션을 종료한다.
클라이언트가 basicService.nonTx() 를 호출하면, 트랜잭션 프록시의 nonTx() 가 호출된다. 여기서 nonTx() 메서드가 트랜잭션을 사용할 수 있는지 확인해본다. nonTx() 에는 @Transactional 이 없으므로 적용 대상이 아니다. 따라서 트랜잭션을 시작하지 않고, basicService.nonTx() 를 호출하고 종료한다.
현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다. 결과가 true 면 트랜잭션이 적용되어 있는 것이다. 트랜잭션의 적용 여부를 가장 확실하게 확인할 수 있다.
스프링에서 우선순위는 항상 더 구체적이고 자세한것이 높은 우선순위를 가진다.
예를 들어, 메서드와 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 메서드가 더 높은 우선순위를 갖는다.
인터페이스와 해당 인터페이스를 구현한 클래스에 애노테이션일 붙일 수 있다면 더 구체적인 클래스가 더 높은 우선순위를 가진다.
아래는 스프링 트랜잭션 우선순위와 관련된 예시코드이다.
@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)
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은 다음 두 가지 규칙이 있다.
트랜잭션을 사용할때는 다양한 옵션을 사용할 수 있다.
참고
readOnly=false는 기본값이므로 보통 생략해서 사용한다.
TransactionSynchronizationManager.isCurrentTransactionReadOnly는 현재 트랜잭션에 적용된 readOnly 옵션의 값을 반환한다
인터페이스에도 @Transcational 을 적용할 수 있따. 이 경우 다음 순서로 적용된다.
그러나, 인터페이스에 @Transactional을 사용하는것은 스프링 공식 메뉴얼에서 권장하지 않는다. AOP를 적용하는 방식에 따라서 인터페이스에 애노테이션을 두면 AOP가 적용되지 않는 경우도 있기 때문이다. 가급적 구체 클래스에 @Transactional을 사용하자.
@Transactional을 사용하면 스프링의 트랜잭션 AOP가 적용된다.
트랜잭션 AOP는 기본적으로 트랜잭션 방식의 AOP를 사용한다.
앞서 배운것 처럼 @Transactional을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고 실제 객체를 호출해준다.
따라서 트랜잭션을 적용하라면 항상 프록시를 통해서 대상 객체(원래의 서비스 객체)를 호출해야한다.
이렇게 해야 프록시에서 먼저 트랜잭션을 적용하고 이후에 대상 객체를 호출하게 된다.
만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고 트랜잭션도 적용되지 않는다.
AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시에 항살 실제 객체 대신에 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
아래 코드는 해당 문제의 예시 코드이다.
@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);
}
}
}
@Transactional이 하나라도 있으면 트랜잭션 프록시 객체가 만들어진다.
그리고 callService 빈을 주입받으면 트랜잭션 프록시 객체가 대신 주입된다.
위 예시에서 문제는 external() 함수를 호출하는 경우이다.
문제 원인
자바언언에서 메서드 앞에 별도 의 참조가 없으면 this라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal()을 호출하게 되는데 여기서 this는 프록시 객체가 아니다.
결과적으로 내부 호출은 프록시를 거치지 않는다. 따라서 트랜잭션을 적용할 수 없다.
문제 해결안
내부 호출을 피하기 위해서 internal() 메서드를 별도의 클래스로 분리한다.
실무에서도 이렇게 가장 많이 사용된다.
아래의 코드는 별도의 클래스로 분리한 결과이다.
@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);
}
}
}
실제 호출되는 흐름은 아래와 같다.
스프링의 트랜잭션 AOP 기능은 public 메서드에만 트랜잭션을 적용하도록기본 설정이 되어 있다. 그래서 protected, private, package-visible에는 트랜잭션이 적용되지 않는다.
스프링이 public에만 트랜잭션을 적용하는 이유는 아래와 같다.
참고로 public이 아닌곳에 @Transactional이 붙어있으면 예외가 발생하지 않고 트랜잭션 적용만 무시된다.
스프링 초기화 시점에는 트랜잭션 AOP가 적용되지 않을 수 있다.
@SpringBootTest
public class InitTxTest {
@Autowired Hello hello;
@Test
void go() {
//초기화 코드는 스프링이 초기화 시점에 호출한다.
}
@TestConfiguration
static class InitTxTestConfig {
@Bean
Hello hello() {
return new Hello();
}
}
@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);
}
}
}
스르링 트랜잭션은 아래와 같은 옵션을 제공한다.
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 {};
}
트랜잭션을 사용하려면 먼저 스프링 빈에 등록된 어떤 트랜잭션 매니저를 사용할지 알아야 한다.
생각해보면 코드로 직접 트랜잭션을 사용할 때 분명 트랜잭션 매니저를 주입 받아서 사용했다.
@Transactional 에서도 트랜잭션 프록시가 사용할 트랜잭션 매니저를 지정해주어야 한다.
사용할 트랜잭션 매니저를 지정할 때는 value , transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 된다.
이 값을 생략하면 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략한다. 그런데 사용하는 트랜잭션 매니저가 둘 이상이라면 다음과 같이 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.
public class TxService {
@Transactional("memberTxManager")
public void member() {...}
@Transactional("orderTxManager")
public void order() {...}
}
참고
애노테이션에서 속성이 하나인 경우 위 처럼 value를 생략하고 값을 바로 넣을 수 있다.
예외 발생시 스프링 트랜잭션의 기본정책은 다음과 같다.
이 옵션을 사용하면 기본정책에 추가로 어떤 예외가 발생할때 롤백할지 지정할 수 있다.
@Transactional(rollbackFor = Exception.class)
예를 들어 이렇게 지정하면 체크 예외인 Exception이 발생해도 롤백하게 된다.( 하위 예외들도 대상에 포함된다.)
rollbackForClassName도 있는데 rollbackFor은 예외 클래스를 직접 지정하고, rollbackForClassName는 예외 이름을 문자로 넣으면 된다.
rollbackFor와 반대이다. 기본정책에 추가로 어떤 예외가 발생했을때 롤백하면 안되는지 지정할 수 있다.
트랜잭션은 기본적으로 읽기 쓰기가 모두 가능한 트랜잭션이 생성된다.
readOnly=true 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제가 안되고 읽기 기능만 작동한다. (드라이버나 데이터베이스에 따라 정상 동작하지 않는 경우도 있다.) 그리고 readOnly 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있다
트랜잭션 시작 후 예외가 발생하면 아래와 같이 처리된다.
예외 발생시 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백한다.
참고
트랜잭션이 커밋, 롤백되었는지 확인하기 위한 로깅 추가
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
스프링은 체크예외는 커밋하고 언체크예외는 롤백하는 이유가 뭘까?
스프링은 기본적으로 체크 예외는 비지니스 의미가 있을때 사용하고, 런타임 예외는 복구 불가능한 예외로 가정한다.
참고로 이러한 정책을 꼭 따를 필요는 없고, 앞서 배운 rollbackFor라는 옵션으로 체크 예외도 롤백하면 된다.
참고
비지니스 예외라는 것은 예외가 발상했을때의 상황이 시스템에 문제가 있어서 발생한것이 아닌 시스템은 정상적으로 동작했지만, 비지니스 상황에서 문제가 되기 때문에 발생한 상황을 말한다.
따라서, 비지니스 상황에 의해서 발생한 예외는 그에 따른 적절한 후처리가 필요하기 떄문에 체크 예외로 고려할 수 있다.
참고
JPA는 테이블이 존재하지 않으면 테이블도 만든다. 물론 설정을 바꿀수도있다.
application.properteis에 spring.jpa.hibernate.dll-auto 옵션을 조정하면 된다.
- none : 테이블을 생성하지 않는다.
- create : 애플리케이션 시작 시점에 테이블을 생성한다.
아래의 강의를 공부하여 정리한 내용입니다.
김영한님의 스프링 DB2