카테고리 없음

[DB] @Transactional 이란?

작은별._. 2024. 8. 4. 12:33
728x90

해당 포스팅에서 트랜잭션 매니저를 이용해서 아래처럼 트랜잭션을 직접 시작할 수 있었습니다.


 public void transferMoney(String fromId, String toId, int money) {
 		// 트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        
        try {
            // 비즈니스 로직
            businessLogic(fromId, toId, money);
            transactionManager.commit(status); //성공시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status); // 실패 시 롤백
            throw new IllegalStateException(e);
        }
    }

 

 

즉, 아래처럼 비즈니스 로직 직전과 직후에 트랜잭션을 시작하고 종료하였습니다.


 

 

하지만 스프링의 @Transactional 을 사용하면 아래처럼 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해 줄 수 있습니다.


 


public class TransactionProxy {
    private Service target;
    public void logic(){
        // 트랜잭션 시작
        TransactionStatus status = trnsactionManager.getTransaction();
        try{
            // 실제 대상 호출 (비즈니스 로직 호출)
            target.logic();
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status); // 실패시 롤백
            throw new IllegalStateException(e);
        }
    }
}   


public class Service {
    public void logic() {
        //트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
        bizLogic(fromId, toId, money);
    }
}

 

  • 프록시 도입 전 : 서비스에 비즈니스 로직과 트랜잭션 처리 로직이 함께 섞여 있습니다.
  • 프록시 도입 후 : 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가지고 가고, 트랜잭션을 시작한 후에 실제 서비스를 대신 호출합니다. 따라서 서비스 계층에는 순수한 비즈니스 로직만 남길 수 있습니다.

 

이제 @Transactional 을 사용해서 서비스 로직과 트랜잭션 처리 부분을 분리하도록 하겠습니다.

기존 AccountRepository 클래스는 아래처럼 그대로 사용하고, AccountService 클래스만 수정합니다.

 

AccountRepository 클래스


package hello.jdbc.repository.Account;

import hello.jdbc.domain.Account;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;

import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;

@Slf4j
@AllArgsConstructor
public class AccountRepositoryV2 {

    private final DataSource dataSource;

    public Account save(Account account) throws SQLException {
        String sql = "insert into account(account_id, money) values (?, ?)";

        Connection con = null;
        PreparedStatement pstmt = null;

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

    }


    public Account findById(String id) throws SQLException {
        String sql = "select * from account where account_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, id);

            rs = pstmt.executeQuery();

            if (rs.next()) {
                int money = rs.getInt("money");
                return new Account(id, money);
            } else {
                throw new NoSuchElementException("account not found account_id = " + id);
            }
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    public void update(String id, int money) throws SQLException {
        String sql = "update account set money=? where account_id=?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, id);
            pstmt.executeUpdate();

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    public void delete(String accountId) throws SQLException {
        String sql = "delete from account where account_id=?";

        Connection con = null;
        PreparedStatement pstmt = null;

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

    }

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

    private Connection getConnection() throws SQLException {
        //주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection={}, class={}", con, con.getClass());
        return con;
    }


}

AccountService 클래스

transferMoney 메서드 위에 @Transactional 추가함으로써 Service 클래스에 필요 없는 부분은 주석 처리 하였습니다. 즉, 순수한 비즈니스 로직만 남기고 트랜잭션 관련 코드는 모두 제거할 수 있게 되었습니다.


package hello.jdbc.service;

import hello.jdbc.domain.Account;
import hello.jdbc.repository.Account.AccountRepositoryV1;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;

import java.sql.SQLException;

import static hello.jdbc.common.Constants.TEST_ID_FOR_EXCEPTION;

@Slf4j
@AllArgsConstructor
public class AccountServiceV2 {
   // private final PlatformTransactionManager transactionManager;
    private final AccountRepositoryV1 repository;

    @Transactional
    public void transferMoney(String fromId, String toId, int money) {
     //   TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            businessLogic(fromId, toId, money);
    //        transactionManager.commit(status);
        } catch (Exception e) {
     //       transactionManager.rollback(status); // 실패 시 롤백
            throw new IllegalStateException(e);
        }
    }

    private void businessLogic(String fromId, String toId, int money) throws SQLException {
        Account fromAccount = repository.findById(fromId);
        Account toAccount = repository.findById(toId);

        repository.update(fromId, fromAccount.getMoney() - money);
        validation(toAccount);
        repository.update(toId, toAccount.getMoney() + money);
    }

    private void validation(Account account) {
        if (account.getAccountId().equals(TEST_ID_FOR_EXCEPTION)) {
            throw new IllegalStateException("Exception while transferring!!");
        }
    }
}

 

@Transactional 애노테이션을 메서드에 붙여도 되고 클래스에 붙여도 됩니다. 클래스에 붙이면 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 됩니다.


테스트 코드


package hello.jdbc.service;

import hello.jdbc.domain.Account;
import hello.jdbc.repository.Account.AccountRepositoryV2;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
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.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.sql.SQLException;

import static hello.jdbc.common.Constants.*;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
@SpringBootTest // Integration test
class AccountServiceV2Test {
    @Autowired
    private AccountRepositoryV2 repository;
    @Autowired
    private AccountServiceV2 service;

    @AfterEach
    void after() throws SQLException {
        repository.delete(ACCOUNT_A);
        repository.delete(ACCOUNT_B);
        repository.delete(TEST_ID_FOR_EXCEPTION);
    }

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

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

        @Bean
        AccountRepositoryV2 accountRepositoryV2(DataSource dataSource) { // DI
            return new AccountRepositoryV2(dataSource);
        }

        @Bean
        AccountServiceV2 accountServiceV2(AccountRepositoryV2 accountRepositoryV2) { // DI
            return new AccountServiceV2(accountRepositoryV2);
        }


    }

    @Test
    // AOP 프록시 적용 확인
    void AopCheck() {
        log.info("accountService class={}", service.getClass());
        log.info("accountRepository class={}", repository.getClass());
        assertThat(AopUtils.isAopProxy(service)).isTrue();
        assertThat(AopUtils.isAopProxy(repository)).isFalse();
    }
    /*
    service 로그에 EnhancerBySpringCGLIB.. 라는 부분을 통해 프록시(CGLIB)가 적용된 것을 확인할 수 있다. 
	repository 에는 AOP를 적용하지 않았기 때문에 프록시가 적용되지 않는다.
    
    */


    @Test
    @DisplayName("정상 이체")
    void transferMoney() throws SQLException {
        Account accountA = new Account(ACCOUNT_A, 50000);
        Account accountB = new Account(ACCOUNT_B, 10000);
        repository.save(accountA);
        repository.save(accountB);

        service.transferMoney(accountA.getAccountId(), accountB.getAccountId(), 10000);

        Account findAccountA = repository.findById(accountA.getAccountId());
        Account findAccountB = repository.findById(accountB.getAccountId());
        assertThat(findAccountA.getMoney()).isEqualTo(40000);
        assertThat(findAccountB.getMoney()).isEqualTo(20000);
    }


    @Test
    @DisplayName("이체중 예외 발생")
    void transferException() throws SQLException {
        Account accountA = new Account(ACCOUNT_A, 50000);
        Account accountEx = new Account(TEST_ID_FOR_EXCEPTION, 10000);
        repository.save(accountA);
        repository.save(accountEx);

        assertThatThrownBy(() -> service.transferMoney(accountA.getAccountId(), accountEx.getAccountId(), 10000)).isInstanceOf(IllegalStateException.class);

        Account findAccountA = repository.findById(accountA.getAccountId());
        Account findAccountEx = repository.findById(accountEx.getAccountId());
        assertThat(findAccountA.getMoney()).isEqualTo(50000);
        assertThat(findAccountEx.getMoney()).isEqualTo(10000);
    }
}

 

  • @SpringBootTest : 스프링 AOP 를 적용하려면 스프링 컨테이너가 필요합니다. 이 애노테이션으로 테스트 시 스프링 부트를 통해 스플이 컨테이너를 생성합니다. 그 후 @Autowired 를 통해 스프링 컨테이너가 관리하는 빈들을 주입하여 사용합니다.
  • @TestConfiguration :  테스트 안에서 내부 설정 클래스를 만들어 이 애노테이션을 붙이면, 스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있습니다.
  • TestConfig
    • DataSource 스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록합니다.
    • 트랜잭션 매니저 (DataSourceTransactionManager)를 스프링 빈으로 등록합니다.
      • 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해야 합니다.

@Transactional 동작 방식을 아래 그림으로 정리할 수 있습니다.


 


번외 (스프링 부트의 자동 리소스 등록)

위 코드에서는 코드에 직접 데이터 소스와 트랜잭션 매니저를 스프링 빈으로 등록해서 사용하였습니다.


@Bean
DataSource dataSource() {
 return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
 return new DataSourceTransactionManager(dataSource());
}

 

 

하지만 스프링 부트의 자동 리소스 등록 기능을 사용하면 직접 등록할 필요가 없습니다. 아래와 같이 application.properties 에 DataSource 속성을 지정하면 스프링부트에서 자동으로 DataSource 를 생성하고 스프링 빈에 등록합니다. 

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=

 

 

스프링 부트가 기본으로 생성하는 DataSource는 커넥션 풀을 제공하는 HikariDataSource 입니다. 커넥션 풀과 관련된 설정도 application.properties 를 통해 지정할 수 있습니다. 

또한, spring.datasource.url 속성이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도합니다.

 

이제 위에서 지정한 DataSource 를 이용해 Bean 이 등록되는지 확인해 보겠습니다.


import hello.jdbc.repository.Account.AccountRepositoryV2;
import hello.jdbc.service.AccountServiceV2;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;

@TestConfiguration
@RequiredArgsConstructor
static class TestConfig {
    private final DataSource dataSource;

    /* @Bean
     DataSource dataSource() {
         return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
     }

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

    @Bean
    AccountRepositoryV2 accountRepositoryV2(DataSource dataSource) {
        return new AccountRepositoryV2(dataSource);
    }

    @Bean
    AccountServiceV2 accountServiceV2(AccountRepositoryV2 accountRepositoryV2) {
        return new AccountServiceV2(accountRepositoryV2);
    }


}

 

위와 같이 생성자에 자동으로 DataSource 빈이 등록되고 테스트가 잘 수행되는 것을 확인할 수 있습니다. 그리고 스프링 부트는 적절한 트랜잭션 매니저를 자동으로 스프링 빈으로 등록합니다. 이 때 transactionManager 라는 이름으로 등록합니다. 참고로 개발자가 직접 트랜잭션 매니저를 빈으로 등록하면 스프링 부트는 트랜잭션 매니저를 자동으로 등록하지 않습니다.

 

어떤 트랜잭션 매니저를 선택할지는 현재 등록된 라이브러리를 보고 판단하는데, JDBC를 기술을 사용하면 DataSourceTransactionManager 를 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager 를 빈으로 등록합니다. 둘다 사용하는 경우 JpaTransactionManager 를 등록합니다. 참고로 JpaTransactionManager 는 DataSourceTransactionManager 가 제공하는 기능도 대부분 지원합니다.

728x90
반응형