Spring

@Transactional

dev-rootable 2023. 8. 8. 15:40

📌 목적

 

서비스 계층이 다른 계층에 의해 변경 영향을 받지 않도록 하기 위함

 

🔎 트랜잭션 추상화

 

✔ 문제점들

 

예를 들어 서비스 계층에서 계좌이체라는 비즈니스 로직을 수행한다면 반드시 필요한 두 메서드는 조회와 변경이다. 두 메서드 모두 DB에 접근을 해야만 결과를 내줄 수 있다.

 

하지만, 서비스 계층에서 트랜잭션 관련 로직인 트랜잭션 획득, 커밋, 롤백 등을 포함하면 순수 Java 로직과 데이터 접근 로직이 섞여 유지보수나 테스트에 불리하다. 그리고 가장 큰 문제는 데이터 접근 기술을 교체할 수 없다는 것이다. 그 이유는 서비스 계층에서 구체적인 접근 기술에 의존하기 때문이다.

 

JDBC에 의존

 

JDBC에서 JPA로 교체

 

✔ 해결책

 

서비스 계층이 인터페이스에 의존하도록 하자

 

스프링은 트랜잭션의 주요 기능을 추상화한 PlatformTransactionManager 인터페이스(extends TransactionManager)를 제공한다.

 

추상화 인터페이스에 의존하는 서비스 계층

 

PlatformTransactionManager 인터페이스

 

🔎 트랜잭션 동기화

 

✔ 문제점들

 

트랜잭션 매니저나 @Transactional 없이 사용할 때는 동일 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘겼다.

 

커넥션 동기화를 위해 메서드 오버로딩

 

데이터베이스에 접근하는 메서드마다 메서드 오버로딩을 거쳐야 하므로 코드 낭비가 많아지고 유지보수성도 떨어진다. 또한, 트랜잭션을 유지하지 않아도 되는 기능이 있다면 다시 한번 분리해야 하는 번거로움이 있다.

 

✔ 해결책

 

트랜잭션 동기화 매니저에 보관된 동일 커넥션을 이용하자

 

트랜잭션 매니저는 내부에서 트랜잭션 동기화 매니저(TransactionSynchronizationManager)를 사용한다. 트랜잭션 동기화 매니저는 ThreadLocal을 통해
커넥션을 동기화
하는데, 쓰레드마다 별도의 저장소가 부여되고 해당 스레드만 데이터에 접근이 가능하다. 또한, 멀티스레드 환경에서도 안전하게 커넥션을 동기화할 수 있다.

 

📌 트랜잭션 매니저

 

🔎 트랜잭션 동기화 동작 방식

 

트랜잭션 매니저 동작

 

1. 데이터 접근을 위해 DataSource 주입

 

void init() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD); //인증 정보
    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource); //JDBC
    memberRepository = new MemberRepositoryV3(dataSource);
    memberService = new MemberServiceV3_1(transactionManager, memberRepository);
}

 

2. TransactionManager는 DataSource를 통해 커넥션을 만들고 트랜잭션을 시작

 - 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환

 - 없으면 새로운 커넥션을 생성해서 반환

private Connection getConnection() throws SQLException {
    //트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
    Connection con = DataSourceUtils.getConnection(dataSource);
    return con;
}

 

3. TransactionManager는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관

 

4. Repository(데이터 접근 계층)는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용

 

    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(?, ?)";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

 

5. 트랜잭션이 종료되면, TransactionManager는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다.

 - 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 바로 닫지 않고 그대로 유지

 - 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫음

 

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        //트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
        DataSourceUtils.releaseConnection(con, dataSource);
    }

 

 

🔎 트랜잭션 추상화 동작 방식

 

1. JDBC용 TransactionManager인 DataSourceTransactionManager를 주입

 

void init() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD); //인증 정보
    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource); //JDBC
    memberRepository = new MemberRepositoryV3(dataSource);
    memberService = new MemberServiceV3_1(transactionManager, memberRepository);
}

 

2. PlatformTransactionManager를 통해 트랜잭션 시작

 

3. 비즈니스 로직 수행 후 TransactionStatus(현재 트랜잭션의 상태 정보)를 통해 커밋 또는 롤백

 

@RequiredArgsConstructor
public class MemberServiceV3_1 {
    
    private final PlatformTransactionManager transactionManager;
    private final MemberRepositoryV3 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        //트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            //비즈니스 로직
            bizLogic(fromId, toId, money);
            transactionManager.commit(status); //성공시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status); //실패시 롤백
            throw new IllegalStateException(e);
        }
    }
    
    ...

 

4. TransactionManager는 autoCommit(true)와 close를 알아서 처리

 

🔎 트랜잭션 전체 동작 정리

 

1. 의존 관계 주입

 

void init() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD); //인증 정보
    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource); //JDBC
    memberRepository = new MemberRepositoryV3(dataSource);
    memberService = new MemberServiceV3_1(transactionManager, memberRepository);
}

 

2. 서비스 계층에서 트랜잭션 시작

 

//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

 

3. 내부에서 커넥션 생성과 수동 커밋 모드가 진행됨

 

4. 트랜잭션을 시작한 커넥션을 트랜잭션 동기화 매니저에 보관

 

1 ~ 4 과정

 

5. findById, update 등 데이터 접근 메서드 호출

 

6. 동기화된 커넥션을 조회하여 SQL 실행

 

Connection con = DataSourceUtils.getConnection(dataSource);

 

7. 서비스 계층에서 트랜잭션을 종료해야 하므로 커넥션을 바로 닫지 않고 유지

 

DataSourceUtils.releaseConnection(con, dataSource);

 

5 ~ 7 과정

 

8. 비즈니스 로직이 끝나면 트랜잭션 매니저로 커밋 또는 롤백

 

try {
    ... //비즈니스 로직
    transactionManager.commit(status); //성공시 커밋
    } catch (Exception e) {
    transactionManager.rollback(status); //실패시 롤백
    ...
}

 

9. 자동 커밋으로 되돌리고 리소스 정리 (트랜잭션 매니저가 자체적으로 수행)

 

8 ~ 9 과정

 

📌 @Transactional 도입

 

스프링은 AOP 프록시를 사용하여 트랜잭션 처리 부분을 대신 처리하여 서비스 계층으로부터 트랜잭션 로직을 완전히 분리하기 위해 도입한 애노테이션

 

프록시 도입 후 전체 동작

 

🔎 서비스 계층 - 도입 후

 

@Service
@RequiredArgsConstructor
public class MemberServiceV3_3 {

    private final MemberRepositoryV3 memberRepository;

    @Transactional
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        bizLogic(fromId, toId, money); //비즈니스 로직
    }

}

 

🔎 테스트 코드

 

의존 관계 주입을 스프링 컨테이너에게 맡기기 위해 @SpringBootTest를 사용한다. 또한, 스프링은 TransactionManager와 DataSource를 자동 Bean으로 등록한다.

 

@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {

    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";

    @Autowired
    private MemberRepositoryV3 memberRepository;
    @Autowired
    private MemberServiceV3_3 memberService;

    @AfterEach
    void after() throws SQLException {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    void AopCheck() {
        log.info("memberService class={}", memberService.getClass());
        log.info("memberRepository class={}", memberRepository.getClass());
        assertThat(AopUtils.isAopProxy(memberService)).isTrue();
        assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);

        //when
        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);

        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberB.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }

    @Test
    @DisplayName("이체중 예외 발생")
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        //when
        assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());

        //memberA의 돈이 롤백 되어야함
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }

}

 

스프링은 @Transactional을 보고 AOP proxy를 생성한다. 아래 로그를 보면 @Transactional을 사용하는 MemberService만 CGLIB 바이트 코드 조작 라이브러리를 통해 생성된 프락시 객체가 사용되고 있다.

 

memberService는 CGLIB가 붙은 프록시 객체

 

@TestConfiguration를 사용하여 수동 빈 등록을 하거나 다음 방법으로 자동 빈 등록을 할 수 있다.

먼저. DI를 위해 MemberRepository와 MemberService에 @RequiredArgsConstructor를 넣고, Component Scan을 위해 MemberService에 @Service를 추가했다.

MemberRepository에는 @Component를 넣었다. @Repository를 넣으면 AopCheck() 결과에서 true가 나온다.
즉, AOP 스캔 대상이 되는 것이다.

 

✔ 수동 빈 등록

 

    @TestConfiguration
    static class TestConfig {
        @Bean
        DataSource dataSource() {
            return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        }

        @Bean
        PlatformTransactionManager transactionManager() {
            return new DataSourceTransactionManager(dataSource());
        }

        @Bean
        MemberRepositoryV3 memberRepositoryV3() {
            return new MemberRepositoryV3(dataSource());
        }

        @Bean
        MemberServiceV3_3 memberServiceV3_3() {
            return new MemberServiceV3_3(memberRepositoryV3());
        }
    }

 

Reference:

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard

 

스프링 DB 1편 - 데이터 접근 핵심 원리 - 인프런 | 강의

백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., 백엔

www.inflearn.com