@Transaction쓰면 프록시를 만들어서 서비스 코드 순수성을 유지하는 방식이다. 프록시 클래스라서 AOP(Aspect Oriented Programming)이라고 하는데 코드 앞뒤로 계속 반복해서 사용할 수 밖에 없는걸 줄이자는 의미임. 이 강의는 좀 더 자세히 알아보자는 개념인듯.
실제로 프록시를 사용하는지 테스트코드 작성
package hello.springtx.apply;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import static org.assertj.core.api.Assertions.*;
@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);
}
}
}
만약 클래스 안에 @Transaction이 하나라도 있다면 그 클래스를 프록시 클래스로 바꿔서 참조한다.
@Transaction 같은 스프링용 에노테이션은 구체적인것이 우선순위가 먼저라고 보면 된다. 대부분 이렇고 상식적으로도 이게 맞음.
@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);
}
}
}
가장 중요하고 일하다보면 한번은 만드시 하는 실수라서 정말 중요한 문제가 있음. 프록시 때문에 생기는 문제다.
@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();
}
}
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 = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
클래스를 프린트하면 클래스의 메소드에 @Transactional이 하나라도 들어갔기 때문에 이 클래스는 프록시 클래스로 만들어져 실행됨을 볼 수 있다. 그래서 @Transactional이 있는 메소드는 실행하면 transaction이 활성화 되어있는걸 볼 수 있는데, @Transactional이 없는 메소드를 실행하고 그 안에서 @Transactional이 있는 메소드를 실행하면 이건 적용이 안된 걸 볼 수 있다. 이유는 프록시 클래스의 한계 때문이다.
밖에서 처음 클래스의 메소드를 실행할 때 만약 해당 메소드가 @Transactional 에노테이션이 있으면 프록시 클래스로 메소드를 호출하고 안의 실제 내용을 호출한다. 근데 @Transactional이 없는 메소드로 실행하면 프록시는 그냥 건너뛰고 실제 클래스의 메소드를 호출한다. 여기서 실제 클래스로 실행하기 때문에 이 클래스의 다른 메소드(this.가 생략되어 있으므로)를 실행하면 프록시가 아닌 원래 클래스의 메소드를 실행하는 것. 그래서 @Transactional이 적용되지 않는다. 실무에서 꼭 한번씩 나오는 실수이며 정말 주의해야 한다.
해결 방법으로는 @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 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();
}
// @Transactional
// public void internal() {
// log.info("call internal");
// printTxInfo();
// }
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 @Transactional은 public 함수에만 적용된다. protected, private까지 다 적용하면 너무 무작위로 많이 하는 것도 있고 보통 public으로써 다른 클래스에서도 실행할 수 있게 하는 곳에 쓸테니까..
또 다른 주의사항은 초기화 시킬때 @Transactional을 하고 싶다고 해도 안된다는 거임. 이유는 초기화 코드가 먼저 실행 된 뒤에 트랜잭션 AOP가 적용되기 때문. 그래서 Eventclass로 해당 이벤트가 끝났을 때 실행하는 방식으로 우회해야 한다.
@Transactional의 옵션들
주목할만한건 value, rollbackFor, propagation, readOnly 정도인듯.
value와 transactionManager는 매니저 2개 이상 쓸 때 이름 따로 부여하는거.
rollbackFor는 이제 런타임 예외(언체크 예외)는 롤백, 컴파일 예외(체크 예외)는 커밋을 하는데 체크 에러 중에서도 이게 뜨면 롤백시키고 싶다 하는 것들 지정해줄 수 있음.
propagation은 엄청 방대해서 나중에 설명하겠다 함.
readOnly는 수정하는게 아닌 읽기만 한다고 알리기 때문에 프레임워크, JDBC 드라이버, 데이터베이스에서 읽기만 한다면 내용은 바뀌지 않는다는 것을 이용해 이것저것 최적화를 좀 한다. 변경점을 찾아내어 db에 반영하는 flush는 실행조차 하지 않는다던지, db는 db마다 내부 따로 읽기만 한다면 최적화 하는게 따로 있고.. 한다.
예외 발생하는거 볼건데 왜 체크 예외는 커밋하고 언체크 예외는 롤백하는가 의문을 가지는게 중요함.
일단 실제로 그러는지 테스트해서 보자.
@SpringBootTest
public class RollbackTest {
@Autowired
RollbackService service;
@Test
void runtimeException() {
// Assertions.assertThatThrownBy((() -> service.runtimeException()))
// .isInstanceOf(MyException.class);
service.runtimeException();
}
@Test
void checkedException() throws MyException {
// Assertions.assertThatThrownBy((() -> service.checkedException()))
// .isInstanceOf(MyException.class);
service.checkedException();
}
@Test
void rollbackFor() throws MyException {
// Assertions.assertThatThrownBy((() -> service.rollbackFor()))
// .isInstanceOf(MyException.class);
service.rollbackFor();
}
@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 checkedException");
throw new MyException();
}
}
static class MyException extends Exception {
}
}
이유는 스프링의 생각? 때문임. 런타임 예외는 db가 잘못되었다던가 문법 오류라던가 같은 문제라 어차피 개발자가 와서 해결해야 할 문제라 롤백을 하는거고, 체크 예외는 비즈니스 의미가 있는 예외, 예를들어 잔고부족 같은 예외일 거라고 생각해서 커밋하는거다. 잔고 부족일 경우 그냥 되돌리는게 아니라 결제 대기중 같은걸로 커밋해서 db에 바꿔야 할 수도 있으니 이런것까지 고려한 것. 그래서 보통 정상, 시스템 예외, 비즈니스 예외로 나눠서 생각한다.
이걸 실제로 해보면 더 감이 잘 온다.
테이블 이름을 orders로 정한건 db의 order 문법도 있고 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() {
// given
Order order = new Order();
order.setUsername("예외");
// when
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("대기");
}
}
만약 실행하면 table 없이 정의만 해도 알아서 만들어지고 하는걸 볼 수 있는데 spring.jpa.hibernate.ddl-auto 옵션으로 설정할 수 있다.
만약 런타임 예외인 경우 주문한 order를 롤백했기 때문에 order 자체가 없어서 없는게 정상이고, 잔고 부족 같은 체크 예외면 커밋이 되어 주문 대기 같은 상태로 저장되는게 정상으로써 다루어진 모습이다. 물론 다르게 해서 예외 대신 정상 내놓고 if문으로 처리해도 되고 하지만 기본적으로 스프링이 만들어준 로직을 따라가면서 하는게 좋다.
'CS > 김영한 스프링 강의' 카테고리의 다른 글
스프링 DB 2편 - 데이터 접근 핵심 원리 - 섹션11. 스프링 트랜잭션 전파2 - 활용 (0) | 2023.09.02 |
---|---|
스프링 DB 2편 - 데이터 접근 핵심 원리 - 섹션10. 스프링 트랜잭션 전파1 - 기본 (0) | 2023.09.02 |
스프링 DB 2편 - 데이터 접근 핵심 원리 - 섹션8. 데이터 접근 기술 - 활용 방안 (0) | 2023.08.28 |
스프링 DB 2편 - 데이터 접근 핵심 원리 - 섹션7. 데이터 접근 기술 - Querydsl (0) | 2023.08.25 |
스프링 DB 2편 - 데이터 접근 핵심 원리 - 섹션6. 데이터 접근 기술 - 스프링 데이터 JPA (0) | 2023.08.23 |