[Spring Database] 트랜잭션
데이터 접근 기술들은 트랜잭션을 처리하는 방법이 조금씩 다르다.
따라서 데이터 접근 기술을 바꾸게 되면 트랜잭션을 처리하는 코드들도 모두 바꿔야 한다.
스프링은 PlatformTransactionManager 인터페이스를 통해 트랜잭션 추상화를 제공한다.
인터페이스를 통해 트랜잭션을 사용하면 데이터 접근 기술이 달라져도 동일한 방법으로 트랜잭션을 사용할 수 있다.
많이 사용되는 데이터 접근 기술에 대한 구현체도 함께 제공하고, 사용하는 기술을 인식해 구현체를 스프링 빈으로 등록해준다.
스프링에서는 더 구체적이고 자세한 요소가 더 높은 우선순위를 가진다.
트랜잭션에서도 마찬가지인데, 메서드와 클래스에 @Transactional 애너테이션을 붙일 수 있으면 메서드가 더 높은 우선순위를 가진다.
@SpringBootTest
public class TxLevelTest {
@Autowired
LevelService service;
@Test
void orderTest() {
service.write();
service.read();
}
@TestConfiguration
static class TxApplyLevelConfig {
@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);
}
}
}
클래스에 적용한 @Transaction 애너테이션은 클래스 내부의 메서드들에게는 자동으로 적용된다.
클래스에도 붙이고 메서드에도 붙여놓으면 더 구체적인 메서드 쪽이 우선된다.
권장하는 방법은 아니지만, 인터페이스에서도 @Transactional 애너테이션을 사용할 수 있다. 이 때 우선순위는 다음과 같다.
1. 클래스 메서드
2. 클래스
3. 인터페이스 메서드
4. 인터페이스
@Transactional 애너테이션을 사용하면 트랜잭션 AOP가 적용된다.
여기서 AOP는 프록시 방식으로 사용되고, 스프링 빈으로 등록된 프록시 객체가 먼저 요청을 받아 트랜잭션을 처리하고 실제 객체를 호출한다.
프록시를 통해 실체 객체를 호출하는 경우는 아무런 문제 없이 동작한다.
문제는 프록시를 거치지 않고 바로 실체 객체를 호출하는 경우인데..
이 경우가 발생하지 않을 것 같지만 실체 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 실체 객체를 직접 호출하는 경우가 생긴다.
당연히 트랜잭션이 적용되지 않고, 치명적인 오류를 초래할 수 있으니 꼭 잡아야 한다.
@Slf4j
@SpringBootTest
public class InternalCallV1Test {
@Autowired
CallService callService;
@Test
void printProxy(){
log.info("{}", 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();
}
public void printTxInfo(){
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("{}", txActive);
boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
log.info("{}", readOnly);
}
}
}
@Transaction 애너테이션이 하나라도 있으면 프록시 객체가 만들어진다.
CallService 객체를 주입받을 때 프록시 객체가 대신 주입된다.
(printProxy의 결과로 프록시임을 확인할 수 있다)
external 메서드를 실행하면 internal 메서드를 실행할 때 트랜잭션이 적용될 것 같지만, 적용되지 않는다.
callService는 프록시이지만, external 메서드에는 @Transactional 애너테이션이 붙어있지 않아 해당 메서드를 호출 할 때는 트랜잭션을 적용하지 않는다.
external 메서드는 내부에서 internal 메서드를 실행한다. internal 메서드에는 @Transactional 애너테이션이 붙어있어서 트랜잭션이 적용될 것 같지만, 적용되지 않는다.
자바에서는 메서드 앞에 별도의 참조가 없으면 this로 자기 자신의 인스턴스를 가리킨다.
즉, external 내부에서 internal 메서드를 호출할 때는 this.interanal()에서 this가 생략돼있다.
this는 자기 자신을 가리키고, 자신은 프록시를 거치지 않은 실체 객체이다.
프록시를 거치지 않으면 트랜잭션을 적용할 수 없다.
internal 메서드를 별도의 클래스로 분리해서 문제를 해결해보자.
@Slf4j
@SpringBootTest
public class InternalCallV2Test {
@Autowired
CallService callService;
@Test
void printProxy(){
log.info("{}", callService.getClass());
}
@Test
void externalCall(){
callService.external();
}
@TestConfiguration
static class InternalCallV2TestConfig{
@Bean
CallService callService(){
return new CallService(internalService());
}
@Bean
InternalService internalService(){
return new InternalService();
}
}
@RequiredArgsConstructor
static class CallService{
private final InternalService internalService;
public void external(){
log.info("call external");
printTxInfo();
internalService.internal(); // 내부에서 실행함
}
public void printTxInfo(){
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("{}", txActive);
boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
log.info("{}", readOnly);
}
}
static class InternalService{
@Transactional
public void internal(){
log.info("call internal");
printTxInfo();
}
public void printTxInfo(){
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("{}", txActive);
boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
log.info("{}", readOnly);
}
}
}
InternalService 클래스를 새로 만들고 internal 메서드를 옮겼다.
CallService에서는 @Transactional 애너테이션과 연관이 없어져 프록시가 적용되지 않고, InternalService에는 프록시가 적용된다.
callService의 external 메서드를 호출한다. (callService는 실체 객체이다)
callService는 주입받은 internalService의 internal 메서드를 호출한다.
internalService는 트랜잭션 프록시이기 때문에 트랜잭션을 적용한 후 프록시에서 실체 객체의 internal을 호출한다.
문제를 해결하는 방법으로는 여러 가지가 있지만, 이렇게 별도의 클래스를 도입하는 방법을 자주 사용한다.
스프링은 트랜잭션을 관리하기 위해 프록시를 기반으로 AOP를 사용하는데, 프록시는 대상 객체의 public 메서드를 중심으로 동작하니 의도하지 않은 결과를 피하기 위해 @Transactional 애너테이션을 사용할 때는 접근제어자로 public을 사용하자.
가끔 트랜잭션 AOP가 적용되지 않는 경우가 있는데, 스프링 초기화 시점에 트랜잭션을 걸지는 않았는지 확인해보자.
@SpringBootTest
public class InitTxTest {
@Autowired
ForTest forTest;
@Test
void tese1(){
}
@TestConfiguration
static class InitTxTestConfig{
@Bean
ForTest forTest(){
return new ForTest();
}
}
@Slf4j
static class ForTest{
@PostConstruct
@Transactional
public void initV1(){
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("@PostConstruct tx active={}", isActive);
}
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initV2(){
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("ApplicationReady tx active={}", isActive);
}
}
}
@PostConstruct 애너테이션과 @Transactional 애너테이션을 함께 사용하면 트랜잭션이 적용되지 않는다.
스프링 초기화 시점에는 트랜잭션을 적용할 수 없다.
트랜잭션보다 초기화 코드가 먼저 실행된다.
@EventListener(ApplicationReadyEvent.class) 애너테이션을 사용해 스프링이 완전히 초기화 된 후 트랜잭션을 적용하도록 하자.
value / transactionManager
스프링에게 어떤 트랜잭션 매니저를 사용할 지 알려줄 때 사용한다.
value 혹은 transactionManager 둘 중 하나에 스프링 빈으로 등록된 트랜잭션 매니저의 이름을 적어주면 된다.
지금까지는 이 값을 생략해서 기본으로 등록된 트랜잭션 매니저를 사용하도록 했는데, 두 개 이상의 트랜잭션 매니저를 사용하는 경우 메서드별로 구분해서 적어줘야 한다.
public class TxService {
@Transactional("memberTxManager")
public void member() {...}
@Transactional("orderTxManager")
public void order() {...}
}
이런 식으로 사용한다.
이 때 애너테이션 속성이 하나이고, 속성의 이름이 value이면 생략하고 바로 값을 넣을 수 있다.
rollbackFor / noRollbackFor
트랜잭션은 기본적으로 언체크 예외인 RuntimeException / Error 예외가 발생하면 롤백하고 체크 예외인 Exception 예외가 발생하면 커밋되는 방식으로 작동한다. (자손 타입도 포함)
rollbackFor 속성을 사용하면 롤백하는 범위를 늘릴 수 있다.
noRollbackFor 속성은 그 반대로 동작한다.
Isolation
트랜잭션의 격리 수준을 설정할 때 사용한다.
기본 값으로 데이터베이스에서 사용하는 격리 수준을 사용한다
timeout
타임아웃 조건을 설정할 때 사용한다.
readOnly
기본적으로 읽기 쓰기가 모두 가능한 트랜잭션으로 생성된다.
해당 속성을 true로 지정하면 읽기 기능만 작동하고, 다양한 부분에서 성능 최적화가 발생한다. (특히 JPA 부분에서)
rollbackFor 옵션을 지정해서 자유롭게 롤백 / 커밋을 설정할 수 있지만, 체크는 커밋 / 언체크는 롤백이 기본값으로 설정됨에는 다 이유가 있다.
스프링은 체크 예외는 비즈니스 의미가 있다고 간주하고, 언체크 예외는 복구 할 수 없다고 가정한다.
쇼핑몰에서 누군가 결제를 시도했다고 하자.
1. 결제에 성공했다.
2. sql에 오류가 발생했다.
3. 결제하려는데 통장에 돈이 없다.
1번은 그냥 처리되니까 넘겨두자.
2번의 경우, 시스템에 문제가 있다고 판단해 런타임 예외로 변환하고 트랜잭션을 롤백한다.
3번의 경우, 사용자 지정 체크 예외인 NotEnoughMoneyException이 발생한다. 시스템에는 문제가 없고 비즈니스 상황에 문제가 발생했다.
3번은 비즈니스 예외로, 반드시 처리해야 하기 때문에 체크 예외로 던져야 한다.
만약 런타임 에러로 처리하고 롤백한다면? 고객이 주문을 시도한 내역이 사라져버린다.
'Spring > Spring Database' 카테고리의 다른 글
[Spring Database] 트랜잭션 전파 (1) | 2022.09.16 |
---|---|
[Spring Database] QueryDSL (1) | 2022.09.11 |
[Spring Database] Spring Data JPA (0) | 2022.09.11 |
[Spring Database] JPA 적용 (0) | 2022.09.10 |
[Spring Database] JPA (1) | 2022.09.09 |
댓글
이 글 공유하기
다른 글
-
[Spring Database] 트랜잭션 전파
[Spring Database] 트랜잭션 전파
2022.09.16 -
[Spring Database] QueryDSL
[Spring Database] QueryDSL
2022.09.11 -
[Spring Database] Spring Data JPA
[Spring Database] Spring Data JPA
2022.09.11 -
[Spring Database] JPA 적용
[Spring Database] JPA 적용
2022.09.10