GitHub

https://github.com/Choidongjun0830

Spring

[Spring DB 1편 듣고 복습, 토이 프로젝트 수정] 4. 스프링과 문제 해결 - 트랜잭션 - 2

gogi masidda 2024. 2. 29. 17:54

MemberRepository의 코드 수정 (V3)

파라미터로 커넥션을 넘기는 부분을 모두 지우고

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

    private Connection getConnection() throws SQLException {
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection = {}, class = {}", con, con.getClass());
        return con;
    }

이렇게 수정했다.

  • DataSourceUtils.getConnection()
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 그 커넥션을 반환한다.
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없으면 새로운 커넥션을 생성해서 반환한다.
  • DataSourceUtils.releaseConnection()
    • 커넥션을 con.close()로 직접 닫으면 커넥션이 유지되지 않는다. 커넥션은 트랜잭션을 종료할 때까지 유지되어야 한다.
    • DataSourceUtils.releaseConnection()은 커넥션을 바로 닫는 것이 아니라, 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다. 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.

MemberService 코드 수정(V3_1)

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
    //transactionManager를 주입 받기
    private final PlatformTransactionManager transactionManager;
    private final MemberRepositoryWithDBV3 memberRepository;

    //커뮤니티 포인트 전송
    public void pointTransfer(String fromNickname, String toNickname, int point) throws SQLException {
        //트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            bizLogic(fromNickname, toNickname, point);
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            throw new IllegalStateException(e);
        }
    }

    private void bizLogic(String fromNickname, String toNickname, int point) throws SQLException {
        Member fromMember = memberRepository.findByNickname(fromNickname);
        Member toMember = memberRepository.findByNickname(toNickname);
        validation(toMember);
        memberRepository.updatePoint(fromNickname, fromMember.getPoint() - point);
        memberRepository.updatePoint(toNickname, toMember.getPoint() + point);
    }

    private void validation(Member toMember) {
        if(toMember.getNickname().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

transactionManager를 주입 받아 getTransaction()으로 트랜잭션을 시작한다. 

DefaultTransactionDefinition은 트랜잭션과 관련된 옵션을 지정하는 것이다.

 

테스트 코드

테스트 코드는 이전과 같고 달라진 점은 

@BeforeEach
    void before() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL,
                USERNAME, PASSWORD);
        //JDBC용 트랜잭션 매니저인 DataSourceTransactionManager를 서비스에 주입한다.
        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        memberRepository = new MemberRepositoryWithDBV2(dataSource);
        memberService = new MemberServiceV2(memberRepository, dataSource);
    }

PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource); 

이 코드를 통해 서비스에 JDBC용 트랜잭션 매니저인 DataSourceTransactionManager를 주입하는 것이다.


트랜잭션 매니저의 동작 흐름

  1. 서비스 계층에서 transactionManager.getTransaction()으로 트랜잭션 시작
  2. 트랜잭션을 시작하려면 데이터베이스 커넥션이 필요하므로, 트랜잭션 매니저는 내부에서 데이터 소스를 사용해서 커넥션 생성
  3. 수동 커밋 모드로 변경
  4. 커넥션을 트랜잭션 동기화 매니저에 보관
  5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관하여 멀티 쓰레드 환경에서 안전하게 커넥션 보관 가능
  6. 서비스는 비즈니스 로직 수행
  7. Repository의 메서드들은 트랜잭션이 시작된 커넥션이 필요한데 이것을 DataSourceUtils.getConnection()으로 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이렇게 같은 커넥션을 사용하게 되어 트랜잭션이 유지된다.
  8. 획득한 커넥션으로 SQL을 데이터베이스에 전달해서 실행한다.
  9. 비즈니스 로직이 끝나고 .commit()이나 .rollback()을 호출한다.
  10. 트랜잭션을 종료하려면 동기화된 커넥션이 필요한데, 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
  11. 획득한 커넥션을 통해 데이터 베이스에 트랜잭션을 커밋하거나 롤백한다.
  12. 트랜잭션 동기화 매니저를 정리하고, 자동 커밋 모드로 되돌리고, con.close()로 커넥션을 종료하거나 반환한다.

트랜잭션 try, catch, finally가 반복되는 문제 해결

스프링의 TransactionTemplate으로 문제를 해결할 수 있다.

 

MemberService 수정 (V3_2)

/**
 * 트랜잭션 - 트랜잭션 템플릿
 */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_2 {
    //transactionManager를 주입 받기
    private final TransactionTemplate txTemplate;
    private final MemberRepositoryWithDBV3 memberRepository;
    public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryWithDBV3 memberRepository) {
        this.txTemplate = new TransactionTemplate(transactionManager);
        this.memberRepository = memberRepository;
    }

    //커뮤니티 포인트 전송
    public void pointTransfer(String fromNickname, String toNickname, int point) throws SQLException {
        txTemplate.executeWithoutResult((staus) -> {
            try {
                bizLogic(fromNickname, toNickname, point);
            } catch (Exception e) {
                throw new IllegalStateException(e);
            }
        });
    }

    private void bizLogic(String fromNickname, String toNickname, int point) throws SQLException {
        Member fromMember = memberRepository.findByNickname(fromNickname);
        Member toMember = memberRepository.findByNickname(toNickname);
        memberRepository.updatePoint(fromNickname, fromMember.getPoint() - point);
        validation(toMember);
        memberRepository.updatePoint(toNickname, toMember.getPoint() + point);
    }

    private void validation(Member toMember) {
        if(toMember.getNickname().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

TransactionTemplate를 사용하려면 transactionManager가 필요해서 생성자에서 transactionManager를 주입받으면서 TransactionTemplate를 생성했다.

 

트랜잭션 템플릿 사용 로직

public void pointTransfer(String fromNickname, String toNickname, int point) throws SQLException {
        txTemplate.executeWithoutResult((staus) -> {
            try {
                bizLogic(fromNickname, toNickname, point);
            } catch (Exception e) {
                throw new IllegalStateException(e);
            }
        });
    }
  • 트랜잭션을 시작하고, 커밋하거나 롤백하는 코드가 사라졌다.
  • 기본 동작
    • 비즈니스 로직이 정상 수행되면 커밋
    • 언체크 예외가 발생하면 롤백, 그 외는 커밋 (체크 예외도 커밋)
  • bizLogic()에서 SQLException을 던져서 처리해주기 위해 try~catch로 체크 예외를 언체크 예외로 바꿔서 던지게 함.

트랜잭션 문제 해결 - 트랜잭션 AOP

아직 서비스 계층에 순수한 비즈니스 로직만 남겨두지는 못했다. @Transactional을 사용하면 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해준다.

 

AOP: 관점 지향 프로그래밍

어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나누어서 보고 그 관점을 기준으로 각각을 모듈화하는 것. 코드들을 부분적으로 나누어서 모듈화한다는 것이다. 스프링 AOP는 프록시를 이용한다.

 

트랜잭션이 필요한 곳이 @Transactional 애노테이션만 붙이면 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.

 

MemberService 수정 (V3_3)

/**
 * 트랜잭션 - @Transactional AOP
 */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_3 {
    private final MemberRepositoryWithDBV3 memberRepository;

    //커뮤니티 포인트 전송
    @Transactional
    public void pointTransfer(String fromNickname, String toNickname, int point) throws SQLException {
        bizLogic(fromNickname,toNickname, point);
    }

    private void bizLogic(String fromNickname, String toNickname, int point) throws SQLException {
        Member fromMember = memberRepository.findByNickname(fromNickname);
        Member toMember = memberRepository.findByNickname(toNickname);
        memberRepository.updatePoint(fromNickname, fromMember.getPoint() - point);
        validation(toMember);
        memberRepository.updatePoint(toNickname, toMember.getPoint() + point);
    }

    private void validation(Member toMember) {
        if(toMember.getNickname().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

@Transactional 애노테이션만 붙이고, 트랜잭션 코드는 모두 지워 순수한 비즈니스 로직만 남게 되었다. @Transactional 애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다.

 

테스트 코드

@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 MemberRepositoryWithDBV3 memberRepository;
    @Autowired
    private MemberServiceV3_3 memberService;

    @AfterEach
    void after() throws SQLException {
        memberRepository.deleteByNickname("memberA");
        memberRepository.deleteByNickname("memberB");
        memberRepository.deleteByNickname("ex");
    }
    @TestConfiguration
    static class TestConfig {
        @Bean
        DataSource dataSource() {
            return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        }

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

        @Bean
        MemberRepositoryWithDBV3 memberRepositoryWithDBV3() {
            return new MemberRepositoryWithDBV3(dataSource());
        }

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

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

나머지 비즈니스 로직 테스트 코드는 앞과 동일하다.

스프링 AOP를 적용하려면 스프링 컨테이너가 필요해서 이전과 다르게 설정이 필요하다.

728x90