관련 내용
<JDBC, Spring - Transaction 처리에 대한 완벽한 이해>
- 순수 JDBC만을 사용한 Transaction 사용 방법 알아보기
- 순수 JDBC-Transaction 문제 Spring TransactionManger로 해결하기 - 트랜잭션 템플릿, 트랜잭션 AOP 사용
JDBC Trasaction문제 Spring으로 해결하기
- (현재 글)Spring Transaction AOP 동작 과정과 주의 사항 알아보기
스프링 Trasaction AOP 동작 과정과 주의 사항
- Spring Transaction 전파 원리 이해하기
- Spring을 사용한 JDBC 예외 누수 문제 해결하기
Spring을 사용한 JDBC 예외 누수 문제 해결하기
- 자바 예외에 대한 이해
개요 목적
전 글에서는 Spring 트랜잭션 매니저를 활용하여, JDBC 트랜잭션 처리의 문제를 해결하는 방법에 대해서 알아보았다.
이번 시간에는, Spring @Transaction AOP를 사용한 TransactionManager에 대한 이해를 해보려고 한다.
@Trasaction을 붙이는 것으로 어떻게 트랜잭션이 작동을 하는 지, 그리고 사용할 때 주의 사항은 무엇인지 알아보자.
@Transaction 적용 확인하기
@Transactional을 사용했을 때 실제 트랜잭션이 작동하는 지 그리고 프록시 객체가 어떻게 동작하는 지 코드를 통해서 알아보자.
KIMHWANG\springtx\src\test\java\hello\springtx\apply\TxBasicTest.java
@Slf4j
@SpringBootTest
public class TxBasicTest {
@Autowired
BasicService basicService;
@Test
void proxyCheck() {
//BasicService$$EnhancerBySpringCGLIB...
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");
//현재 트랜잭션이 작동하는 지 확인 - true
boolean txActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
public void nonTx() {
log.info("call nonTx");
//현재 트랜잭션이 작동하는 지 확인 - false
boolean txActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
proxyCheck - 실제 객체가 아니라, 프록시 객체를 주입 했는지 확인
@Test
void proxyCheck() {
//실제 BasciService가 아니라 BasicService$$EnhancerBySpringCGLIB가 주입되었다.
log.info("aop class={}", basicService.getClass());
assertThat(AopUtils.isAopProxy(basicService)).isTrue();
}
@Transactional 애노테이션이 특정 클래스나 메서드에 하나라도 있으면 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다. 실제 basicService 객체 대신에 프록시인 basicService$$CGLIB 를 스프링 빈에 등록한다.
그리고 프록시 객체는 는 내부에 실제 basicService를 참조하게 된다.(내부에 넣어 메소드를 뽑아 사용한다)
여기서 핵심은 실제 객체 대신에 프록시가 스프링 컨테이너에 등록되었다는 점이다. txBasicTest 는 스프링 컨테이너에 @Autowired BasicService basicService로 의존관계 주입을 요청하면, 스프링 컨테이너에는 실제 객체 대신에 프록시가 스프링 빈으로 등록되어 있기 때문에 프록시를 주입한다.
프록시는 BasicService 를 상속해서 만들어지기 때문에 다형성을 활용할 수 있다. 따라서 BasicService 대신에 프록시인 BasicService$$CGLIB 를 주입할 수 있다.
트랜잭션 프록시 실제 메소드 어떻게 실행되는 지 확인
실제 BasicService안에 있는 tx() 메소드와 nonTx() 메소드가 프록시 객체에서 어떻게 실행 되는 지 확인해보자.
@Test
void txTest() {
basicService.tx();
basicService.nonTx();
}
트랜잭션 로그를 확인하려면 트랜잭션 로깅 레벨을 변경해야 한다.
//application.properties
logging.level.org.springframework.transaction.interceptor=TRACE
basicService.tx() 호출
- 프록시는 tx() 메서드가 트랜잭션을 사용할 수 있는지 확인해본다. @Transactional 이 붙어있으므로 트랜잭션 적용 대상임을 확인한다.
- 그래서 트랜잭션을 시작한 후에 basicService.tx() 를 호출한다.
- basicService.tx() 의 호출이 끝나서 프록시로 제어가 돌아오면 프록시는 트랜잭션 로직을 커밋하거나 롤백해서 트랜잭션을 종료한다.
#tx() 호출
TransactionInterceptor : Getting transaction for [..BasicService.tx]
y.TxBasicTest$BasicService : call tx
y.TxBasicTest$BasicService : tx active=true
TransactionInterceptor : Completing transaction for
basicService.nonTx() 호출
- nonTx() 에는@Transactional 이 없으므로 트랜잭션 적용 대상이 아님을 확인한다.
- 따라서 트랜잭션을 시작하지 않고, basicService.nonTx() 를 호출하고 종료한다
#nonTx() 호출
y.TxBasicTest$BasicService : call nonTx
y.TxBasicTest$BasicService : tx active=false
@Transactional 적용 위치에 따른 우선 순위
메서드와 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 메서드가 더 높은 우선순위를 가진다.
KIMHWANG\springtx\src\test\java\hello\springtx\apply\TxLevelTest.java
@Transactional(readOnly = true)
static class LevelService {
//메소드에 붙었기 때문에 readOnly = false라는 트랜잭션 규칙이 적용된다.
@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);
}
}
트랜잭션 AOP 주의 사항1 -프록시 내부 호출
@Transactional을 선언하는 것만으로 트랜잭션을 적용하고, 다른 트랜잭션과 합쳐지는 편리한 기능을 사용할 수 있다. 하지만 프록시 객체를 생성해서 트랜잭션 처리를 할 때 발생할 수 있는 문제점에 대해서 반드시 알고 넘어가야 한다.
프록시 내부 호출 문제
@Transactional을 사용하면 스프링의 트랜잭션 AOP를 사용한다.
트랜잭션 AOP는 프록시 방식의 AOP를 사용한다.
즉 @Transactional을 적용하면 스프링 콘테이너에 프록시 객체가 생성되어 먼저 요청을 받아서 트랜잭션을 처리하고, 실제 객체를 호출한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다. 이렇게 되면 @Transactional 이 있어도 트랜잭션이 적용되지 않는다. 실무에서 반드시 한번은 만나서 고생하는 문제이기 때문에 꼭 이해하고 넘어가자.
프록시 내부 호출 문제 코드로 정확히 알아보기.
callService라는 객체가 있다. 두 개의 메소드가 있는데, 하나는 트랜잭션이 적용되지 않은 external() 다른 하나는 트랜잭션이 적용되어 있는 internal()이다. 문제가 발생하는 상황은, external() 안에 internal() 메소드가 포함되어 있는데, external()을 실행하면, external()이 트랜잭션 적용이 안되는 것은 당연하지만, 내부에 있는 internal()도 트랜잭션 처리가 안되는 것이다. 그 이유를 코드를 보면서 알아보자.
KIMHWANG\springtx\src\test\java\hello\springtx\apply\InternalCallV1Test.java
@Slf4j
@SpringBootTest
public class InternalCallV1Test {
@Autowired
CallService callService;
@Test
void externalCall() {
//문제 상황 발생
callService.external();
}
@TestConfiguration
static class InternalCallV1Config {
@Bean
CallService callService() {
return new CallService();
}
}
@Slf4j
static class CallService {
public void external() {
log.info("call external");
printTxInfo();
//내부에 트랜잭션 처리되는 internal() 메소드 호출
//this.internal()과 같은 의미이다.
internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
//실행 로그
CallService : call external
CallService : tx active=false
CallService : call internal
CallService : tx active=false
문제가 발생한 이유
- callService.external() 을 호출한다. 여기서 callService 는트랜잭션 프록시이다.
- callService 의 트랜잭션 프록시가 호출된다.
- external() 메서드에는 @Transactional 이 없다. 따라서 트랜잭션 프록시는 트랜잭션을 적용하지 않는다.
- 트랜잭션 적용하지 않고, 실제 callService 객체 인스턴스의 external() 을 호출한다.
- external() 은 내부에서 internal() 메서드를 호출한다. 그런데 여기서 문제가 발생한다.
- 자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다. internal()를 호출할 때, this.internal()을 실행하게 되는 것이다. 그래서 프록시 객체가 아닌 target객체의 메소드를 실행하기 때문에, @Transactional이 붙어 있어도 트랜잭션이 적용이 되지 않는 것이다.
트랜잭션 AOP 주의 사항2 - 프록시 내부 호출 해결
프록시 내부 호출 해결 - 별도 클래스로 분리
메서드 내부 호출 때문에 트랜잭션 프록시가 적용되지 않는 문제를 해결하기 위해 internal() 메서드를 별도의 클래스로 분리하자.
KIMHWANG\springtx\src\test\java\hello\springtx\apply\InternalCallV2Test.java
@SpringBootTest
public class InternalCallV2Test {
@Autowired
CallService callService;
@Test
void externalCallV2() {
callService.external();
}
@TestConfiguration
static class InternalCallV2Config {
@Bean
CallService callService() {
return new CallService(innerService());
}
@Bean
InternalService innerService() {
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);
}
}
//완전히 외부로 클래스를 분리하였다.
@Slf4j
static class InternalService {
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
//실행 로그
InternalCallV2Test$CallService : call external
InternalCallV2Test$CallService : tx active=false
//internal()에는 트랜잭션이 적용된 것을 확인할 수 있다.
TransactionInterceptor : Getting transaction for
[hello.springtx.apply.InternalCallV2Test$InternalService.internal]
InternalCallV2Test$InternalService : call internal
InternalCallV2Test$InternalService : tx active=true
TransactionInterceptor : Completing transaction for
[hello.springtx.apply.InternalCallV2Test$InternalService.internal]
트랜잭션 AOP 주의 사항3 - 초기화 시점
트랜잭션이 적용되지 않는 또 다른 경우는 스프링 초기화 시점이다.
아래 코드를 통해서 확인해보자.
KIMHWANG\springtx\src\test\java\hello\springtx\apply\InitTxTest.java
@PostConstruct
@Transactional
public void initV1() {
boolean isActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init @PostConstruct tx active={}", isActive);
}
트랜잭션 AOP는 초기화 시점 이후에 적용되기 때문에, 초기화 시점에 생성된 이 메소드는 트랜잭션 적용이 되지 않는다.
해결 방안으로는
@EventListener(value = ApplicationReadyEvent.class)
@Transactional
public void init2() {
boolean isActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init ApplicationReadyEvent tx active={}",
isActive);
}
@EventListener(value = 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;
}
1. value, transactionManager
트랜잭션을 사용하려면 먼저 스프링 빈에 등록된 어떤 트랜잭션 매니저를 사용할지 알아야 한다.
생각해보면 코드로 직접 트랜잭션을 사용할 때 분명 트랜잭션 매니저를 주입 받아서 사용했다.
@Transactional 에서도 트랜잭션 프록시가 사용할 트랜잭션 매니저를 지정해주어야 한다.
사용할 트랜잭션 매니저를 지정할 때는 value , transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 된다.
이 값을 생략하면 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략한다. 그런데 사용하는 트랜잭션 매니저가 둘 이상이라면 다음과 같이 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.
public class TxService {
@Transactional("memberTxManager")
public void member() {...}
@Transactional("orderTxManager")
public void order() {...}
}
2. rollbackFor
예외 발생시 스프링 트랜잭션의 기본 정책은 다음과 같다.
언체크 예외인 RuntimeException , Error 와 그 하위 예외가 발생하면 롤백한다.
체크 예외인 Exception 과 그 하위 예외들은 커밋한다.
이 옵션을 사용하면 기본 정책에 추가로 어떤 예외가 발생할 때 롤백할 지 지정할 수 있다
@Transactional(rollbackFor = Exception.class
//예를 들어서 이렇게 지정하면 체크 예외인 Exception 이 발생해도 롤백하게 된다.
//(하위 예외들도 대상에 포함된다.)
rollbackForClassName 도 있는데, rollbackFor 는 예외 클래스를 직접 지정하고,
rollbackForClassName 는 예외 이름을 문자로 넣으면 된다.
3. noRollbackFor
앞서 설명한 rollbackFor 와 반대이다. 기본 정책에 추가로 어떤 예외가 발생했을 때 롤백하면 안되는지 지정할 수 있다.
예외 이름을 문자로 넣을 수 있는 noRollbackForClassName 도 있다.
롤백 관련 옵션에 대한 더 자세한 내용은 뒤에서 더 자세히 설명한다.
4.propagation
트랜잭션 전파에 대한 옵션이다. 아래 블로그 글에서 확인할 수 있다.
5. isolation
트랜잭션 격리 수준을 지정할 수 있다. 기본 값은 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용하는 DEFAULT 이다.
대부분 데이터베이스에서 설정한 기준을 따른다. 애플리케이션 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드물다.
DEFAULT : 데이터베이스에서 설정한 격리 수준을 따른다.
READ_UNCOMMITTED : 커밋되지 않은 읽기
READ_COMMITTED : 커밋된 읽기
REPEATABLE_READ : 반복 가능한 읽기
SERIALIZABLE : 직렬화 가능
6. timeout
트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정한다. 기본 값은 트랜잭션 시스템의 타임아웃을 사용한다.
운영 환경에 따라 동작하는 경우도 있고 그렇지 않은 경우도 있기 때문에 꼭 확인하고 사용해야 한다.
timeoutString 도 있는데, 숫자 대신 문자 값으로 지정할 수 있다.
7. readOnly
트랜잭션은 기본적으로 읽기 쓰기가 모두 가능한 트랜잭션이 생성된다. readOnly=true 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제가 안되고 읽기 기능만 작동한다. (드라이버나 데이터베이스에 따라 정상 동작하지 않는 경우도 있다.)
그리고 readOnly 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있다.
트랜잭션이 예외를 만났을 때 하는 행동
예외 발생시 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백한다.
(예를 들어 리포지토리에서 올라온 예외가 트랜잭션까지 전달 되었을 때)
- 언체크 예외인 RuntimeException , Error 와 그 하위 예외가 발생하면(잡지 않고 프록시 객체에서도 던지면) 트랜잭션을 롤백한다.
- 체크 예외인 Exception 과 그 하위 예외가 발생하면 트랜잭션을 커밋한다.
- 물론 정상 응답(리턴)하면 트랜잭션을 커밋한다.(안에서 처리하면,)
코드를 통해서 해당 이론이 잘 동작하는 지 확인하자
KIMHWANG\springtx\src\test\java\hello\springtx\exception\RollbackTest.java
//로그를 잘 확인하기 위해 로깅 레벨 설정이 필요하다
//application.properties
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
//런타임 예외 발생: 롤백
@Transactional
public void runtimeException() {
log.info("call runtimeException");
throw new RuntimeException();
}
//실행결과
Getting transaction for [...RollbackService.runtimeException]
call runtimeException
Completing transaction for [...RollbackService.runtimeException] after
exception: RuntimeException
//롤백 되는 것을 확인할 수 있다.
Initiating transaction rollback
Rolling back JPA transaction on EntityManager
//체크 예외 발생: 커밋
@Transactional
public void checkedException() throws MyException {
log.info("call checkedException");
throw new MyException();
}
//실행 결과
Getting transaction for [...RollbackService.checkedException]
call checkedException
Completing transaction for [...RollbackService.checkedException] after
exception: MyException
//커밋된 것을 확인할 수 있다.
Initiating transaction commit
Committing JPA transaction on EntityManager
//체크 예외 rollbackFor 지정: 롤백
@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException {
log.info("call rollbackFor");
throw new MyException();
}
//실핼결과
****Getting transaction for [...RollbackService.rollbackFor]
call rollbackFor
Completing transaction for [...RollbackService.rollbackFor] after
exception: MyException
Initiating transaction rollback
Rolling back JPA transaction on EntityManager
//런타임 예외 noRollbackFor 지정: 커밋
@Transactional(noRollbackFor = RuntimeException.class)
public void noRollbackFor() {
log.info("call runtimeException");
throw new RuntimeException();
}
//실핼결과
****Getting transaction for [...RollbackService.noRollbackFor]
call runtimeException
Completing transaction for [...RollbackService.noRollbackFor] after
exception: RuntimeException
Initiating transaction commit
Committing JPA transaction on EntityManager
언체크 예외는 롤백하고, 체크 예외는 커밋하는 이유 알아보기
스프링은 기본적으로 체크 예외는 비지니스 의미가 있을 때 사용하고, 런타임 예외는 복구 불가능한 예외로 여기기 때문이다.
체크 예외를 비지니스 예외라고 여겨서 커밋하는 예시를 들어 알아보자.
주문시 결제 잔고가 부족하면 NotEnoughMonetException이라는 체크 예외가 발생한다고 가정하면, 이 예외는 시스템은 정상 작동 했지만, 비지니스 상황에서 발생한 예외이다.(복구의 가능성이 있다) 시스템은 정상 작동하기 때문에, 데이터를 일단 저장(구매 내역은 일단 커밋하고) 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내할 수 있다.
'Web Sever 개발과 CS 기초 > 스프링' 카테고리의 다른 글
Logback 설정 파일, 콘솔과 파일에 원하는 형식 로그 출력하기 (0) | 2023.04.26 |
---|---|
Spring Transaction 전파 이해하기 (0) | 2023.04.26 |
JDBC Trasaction문제 Spring으로 해결하기 (0) | 2023.04.26 |
JDBC를 사용한 Trasaction 처리와 이해 (0) | 2023.04.25 |
ConnectionPool에 대한 이해와 DataSource 인터페이스로 커넥션을 획득하는 방법 통일하기 (0) | 2023.04.13 |