Spring

[인프런/스프링 DB 2편] 5. 스프링 트랜잭션

주니어주니 2023. 7. 20. 13:04

 

 

📌 @Transactional 사용
-> 트랜잭션 프록시 적용
-> 트랜잭션 AOP 처리

 

1. 트랜잭션 적용 확인 

 

1) build.gradle 

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.1.1'
	id 'io.spring.dependency-management' version '1.1.0'
}

group = 'hello'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

        //테스트에서 lombok 사용
        testCompileOnly 'org.projectlombok:lombok'
        testAnnotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

 

2) TxBasicTest - 트랜잭션 적용 확인 

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);
        }
    }
}

 

(1) proxyCheck() 

 

  • AopUtils.isAopProxy() 
    • @Transactional 을 특정 클래스나 메소드에 하나라도 사용 -> 스프링 트랜잭션 AOP 적용의 대상
      -> 실제 BasicService 객체가 아닌, 프록시 객체가 스프링 빈에 등록 + 프록시 객체 주입
      ( 프록시는 BasicService를 상속해서 만들어지기 때문에 다형성 활용 -> BasicService가 부모객체)

 

 

(2) txTest()

 

  • application.properties 에 로그 추가
    • 트랜잭션 시작과 종료를 명확하게 로그로 확인할 수 있음
logging.level.org.springframework.transaction.interceptor=TRACE

 

  • basicService.tx() 호출
    • 프록시의 tx() 호출 -> 프록시는 tx() 메소드가 트랜잭션을 사용할 수 있는지 확인 -> @Transactional 확인 -> 적용 대상 O
    • 실제 basicService의 tx() 호출
    • 호출이 끝나면 프록시는 트랜잭션 로직을 커밋/롤백 해서 트랜잭션 종료

  • basicService.nonTx() 호출
    • 프록시의 nonTx() 호출 -> 프록시는 nonTx() 메소드가 트랜잭션 사용할 수 있는지 확인 -> @Transactional 없음 -> 적용 대상 X
    • 트랜잭션을 시작하지 않고, basicService의 nonTx() 호출하고 종료

  • TransactionSynchronizationManager.isActualTransactionActive()
    • 현재 스레드에 트랜잭션이 적용되어 있는지 확인 

 

 

 


 

2. 트랜잭션 우선순위

 

 

1) TxLevelTest

package hello.springtx.apply;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
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;

@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);
        }
    }
}

 

* 스프링의 @Transactional 규칙 

 

(1) 우선순위 규칙

 

더 구체적이고 자세한 것이 우선순위

  • LevelService 클래스 : @Transactional(readOnly = true)
  • write() 메소드 : @Transactional(readOnly = false) -> 적용

 

(2) 클래스에 적용하면 메소드는 자동 적용

  • read() 메소드 : @Transactional 없음
  • LevelService 클래스 : @Transactional(readOnly = true) -> 클래스의 트랜잭션 옵션 자동 적용

 

 

2) 결과

 

* write() 호출 

readOnly = false 옵션 적용

 

* read() 호출

readOnly = true 옵션 적용

 

 

💡 인터페이스에 @Transactional 적용 

1. 클래스의 메소드 (우선순위 ↑) 
2. 클래스의 타입 
3. 인터페이스의 메소드 
4. 인터페이스의 타입

-> 별로 비추 -> 클래스에 적용할 것! 

 

 


 

3. 🚨 트랜잭션 AOP 주의 사항 

 

3-1. 프록시 내부 호출 

 

프록시 내부 호출

(1) 원래
@Transactional 사용-> 트랜잭션 AOP 적용 -> 대상 객체 대신 프록시를 스프링 빈으로 등록 + 주입 !
-> 프록시 호출 -> 프록시에서 트랜잭션 적용 -> 프록시에서 대상 객체의 메소드 호출 ! => 트랜잭션 적용 보장

(2) 내부 호출 문제 
대상 객체 내부에서 자신의 메소드 호출 -> 프록시를 거치지 않고 대상 객체 직접 호출 -> 트랜잭션 적용  X 

 

 

1) 트랜잭션 내부 호출 예제 

 

package hello.springtx.apply;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
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;

@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();
            internal();
        }

        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }
}

 

(1) CallService 프록시 객체 생성됐는지 확인 

  • @Transactional 이 하나라도 있으면 트랜잭션 프록시 객체 생성됨 ! 
    (external()은 없지만, internal()은 있음)
  • 주입받은 CallService = 프록시 객체 

 

callService 프록시 객체 확인

 

 

(2) internal() 실행 - 트랜잭션 O 

 

트랜잭션 적용

 

  1. callService.internal() 호출 ( = 프록시의 internal() )
  2. 트랜잭션 프록시 호출
  3. @Transactional 확인 -> 트랜잭션 적용 O
  4. 트랜잭션 적용 후 실제 CallService 객체의 internal() 호출 
  5. 실제 CallService 객체의 처리 완료 후 트랜잭션 프록시가 트랜잭션 완료

 

 

 

(3) external() 실행 - 트랜잭션 없이, 트랜잭션이 있는 internal() 호출

 

트랜잭션 적용 X

 

  1. callService.external() 호출 ( = 프록시의 external() )
  2. 트랜잭션 프록시 호출
  3. @Transactional 없음 -> 트랜잭션 적용 X
  4. 트랜잭션 적용 없이 실제 CallService 객체의 external() 호출
  5. external() 은 내부에서 자신의 internal() 호출 (this.internal()) -> 프록시 거치지 않음 -> 트랜잭션 적용 X )

 

=> @Tansactional을 사용하는 트랜잭션 AOP는 메소드 내부 호출프록시를 적용할 수 없음 !

 

 

 

2) 프록시 내부 호출 해결 방법 - 별도의 클래스로 분리 

 

package hello.springtx.apply;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
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;

@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);
        }
    }

    @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);
        }
    }
}
  • InternalService 클래스로 분리 -> 메소드 내부 호출을 외부 호출로 변경
  • CallService에는 트랜잭션 관련 코드 X -> 트랜잭션 프록시 적용 X
  • InternalService에는 트랜잭션 코드 O -> 트랜잭션 프록시 적용 O

 

  1. callService.external() 호출 (실제 callService 객체)
  2. callService는 주입받은 internalService.internal() 호출 
  3. @Transactional 확인 -> 트랜잭션 프록시 적용 -> 트랜잭션 적용
  4. 실제 internalService 객체의 internal() 호출

 

 

 

💡 public 메소드만 트랜잭션 적용 

스프링 트랜잭션 AOP는 public 메소드에만 트랜잭션 적용하도록 기본 설정

public이 아닌 private, protected, package-visible 등에 @Transactional 붙어있으면 -> 트랜잭션 적용 무시

 

 

 

3-2. 초기화 시점과 트랜잭션 AOP 적용 

 

스프링의 초기화 시점에는 트랜잭션 AOP가 적용되지 않을 수 있음 

 

package hello.springtx.apply;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@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(value = ApplicationReadyEvent.class)
        @Transactional
        public void initV2() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("Hello init ApplicationReadyEvent tx active={}", isActive);
        }

    }
}

 

(1) @PostConstruct + @Transactional => 트랜잭션 적용 X

초기화 코드 먼저 호출 -> 그 다음에 트랜잭션 AOP 적용 

-> 초기화 시점에는 트랜잭션을 획득할 수 X

 

 

(2) @EvnetListener(value = ApplicationReadyEvent.class) 사용

스프링이 컨테이너를 완전히 생성한 후 -> 이벤트가 붙은 메소드 호출 

-> 트랜잭션 적용 O

 

 

 


 

4. 예외와 트랜잭션 커밋, 롤백

 

4-1. 예외와 트랜잭션 커밋, 롤백

 

예외 발생 시, 내부에서 예외 처리 못하고, 트랜잭션 범위 밖으로 예외를 던질 때

스프링 트랜잭션 AOP 예외의 종류에 따라 트랜잭션 커밋 or 롤백

  • 런타임 예외 -> 트랜잭션 롤백
  • 체크 예외 -> 트랜잭션 커밋
  • 정상 -> 트랜잭션 커밋

 

 

1) 예외에 따른 트랜잭션 커밋, 롤백 테스트

package hello.springtx.exception;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
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 static org.assertj.core.api.Assertions.*;

@SpringBootTest
public class RollbackTest {

    @Autowired
    RollbackService service;

    @Test
    void runtimeException() {
        assertThatThrownBy(() -> service.runtimeException())
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void checkedException() {
        assertThatThrownBy(() -> service.checkedException())
                .isInstanceOf(MyException.class);
    }

    @Test
    void rollbackFor() {
        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 {
    }
}

 

 

(1) 트랜잭션 확인 로그 추가

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

 

 

(2) 런타임 예외 -> 시스템 예외 -> 롤백

  • 예) 복구할 수 없는 시스템 예외 -> 롤백

 

 

(3) 체크 예외 -> 비즈니스 예외 -> 커밋

  • 예) 비즈니스 의미가 있는 비즈니스 예외 -> 주문 시 잔고 부족 -> 일단 데이터 저장 후 커밋

 

 

(4) 체크 예외 rollbackFor 지정 -> 롤백

  • 원래 체크 예외는 커밋인데, 롤백 하고 싶으면 지정 가능

 

 

4-2. 활용 

 

* 비즈니스 요구사항 

  • 정상 : 데이터 저장, 결제 상태 = "완료" -> 커밋
  • 시스템 예외 : 복구 불가능한 시스템 예외 -> 런타임 예외 -> 롤백
  • 비즈니스 예외 : 데이터 저장, 결제 상태 = "대기" -> 체크 예외 -> 커밋

 

1) NotEnoughMoneyException - 잔고 부족 비즈니스 예외 (체크 예외)

package hello.springtx.order;

public class NotEnoughMoneyException extends Exception {

    public NotEnoughMoneyException(String message) {
        super(message);
    }
}

 

2) Order

package hello.springtx.order;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    private String username;    // 정상, 예외, 잔고부족
    private String payStatus;   // 대기, 완료
}
  • JPA를 사용하는 Order 엔티티
  • ( * 실무에서 엔티티에 @Setter 사용 비추 )
  • 테이블 이름 지정하지 않으면 클래스 이름인 order가 되지만, 데이터베이스 예약어(order by) 때문에 사용할 수 X -> orders라고 따로 지정

 

3) OrderRepository - 스프링 데이터 JPA 사용 (기본 CRUD)

package hello.springtx.order;

import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, Long> {
}

4) OrderService 

package hello.springtx.order;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@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("결제 프로세스 완료");
    }
}
  • 사용자 이름 = "예외"
    • 시스템 예외 -> 런타임 예외 발생 -> 롤백 

  • 사용자 이름 = "잔고부족"
    • 비즈니스 예외 -> 체크 예외 발생 -> 커밋

  • 사용자 이름 = 그 외
    • 정상 -> 커밋

 

 

5) OrderServiceTest

package hello.springtx.order;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

@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
        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("대기");
    }

}

 

(1) JPA SQL 확인 로그 추가

#JPA SQL
logging.level.org.hibernate.SQL=DEBUG

 

(2) complete() -> 정상 -> 커밋

  • 커밋 시점에 데이터 insert, update 

 

 

(3) runtimeException() -> 시스템 예외 -> 롤백

  •  롤백이니까 insert 수행할 필요도 X

 

 

(4) bixException() -> 비즈니스 예외 -> 커밋 

  • 체크 예외 -> 커밋 수행 -> 데이터 insert, update 완
  • 체크 예외인데 롤백하고 싶으면 -> rollbackFor 옵션 사용