[스프링] JPA 트랜잭션은 JDBC도 해주지 (상)

FrogRat·2021년 6월 12일
1

스프링 썸네일

스프링에서 지원하는 트랜잭션

스프링에서는 트랜잭션 처리에 대한 추상화를 아주 유려하게?! 해놓았기 때문에 PlatformTransactionManager 인터페이스 기준으로 여러 형태의 트랜잭션 매니저 클래스를 지원합니다.

지금은 자바의 로우 레벨에서의 DB 처리 작업을 지원하는 JDBC 를 위한 DataSourceTransactionManager, 여러 개의 DB 리소스에 대한 트랜잭션들을 하나의 트랜잭션으로 묶어서 처리하고 싶을때 사용하는 JtaTransactionManager, JPA 의 구현체인 Hibernate 에 대한 트랜잭션을 지원하는 HibernateTransactionManager 를 지원하고 있고 JPA 에 대해서는 JpaTransactionManager 를 지원합니다.

프로젝트에서 현재 JDBC 기반의 SQL Mapper(iBatis, MyBatis 등) 를 사용하고 있는데 JPA(or Hibernate)를 사용하고 싶다면 트랜잭션 매니저 빈 관련 설정을JpaTransactionManager 로 교체해주면 됩니다.

JPA 와 JDBC 트랜잭션

하지만 기존 레거시 코드에서 JPA 로 변경이 한번에 뚝딱뚝딱 되지는 않습니다. 개인 토이 프로젝트가 아닌 이상(사실 그냥 조그마한 토이 프로젝트라도) 코드 수정 작업에 꽤 많은 시간이 필요합니다. 그렇게 될 경우에는 아예 JDBC 버전의 프로젝트와 JPA 버전의 프로젝트로 나눠서 소스 관리를 해야하나 라고 생각할 수 있습니다.

이런 걱정이 무색할 정도로 스프링의 JpaTransactionManager 는 JPA 와 JDBC 가 진정한 하나의 트랜잭션으로 묶여서 동작하도록 지원하고 있습니다.

JPA 와 JDBC 의 짬뽕

JpaTransactionManager 는 동일한 DB 에 대해 JPA 형태의 DB 접근 방식과 JDBC 형태의 DB 접근 방식이 섞여 있는 하나의 기능에 대해 온전한 트랜잭션을 지원하고 있습니다.

우선은 첫번째로 JpaTransactionManager 의 API 명세에서 확인할 수 있습니다.

This transaction manager also supports direct DataSource access within a transaction (i.e. plain JDBC code working with the same DataSource). This allows for mixing services which access JPA and services which use plain JDBC (without being aware of JPA)! Application code needs to stick to the same simple Connection lookup pattern as with DataSourceTransactionManager (i.e. DataSourceUtils.getConnection(javax.sql.DataSource) or going through a TransactionAwareDataSourceProxy). Note that this requires a vendor-specific JpaDialect to be configured.

대충 번역해보자면...(도와줘! 구글 번역기!)

JPA 트랜잭션 매니저는 트랜잭션 내에서 동일한 DataSource로 작동하는 일반 JDBC 코드와 같은 직접적인 DataSource 액세스를 지원합니다. 이를 통해 JPA에 액세스하는 서비스와 JPA를 알지 못하는 일반 JDBC를 사용하는 서비스를 혼합 할 수 있습니다! 애플리케이션 코드는 DataSourceUtils.getConnection (javax.sql.DataSource) 또는 TransactionAwareDataSourceProxy를 통과하는 DataSourceTransactionManager와 동일한 단순 연결 조회 패턴을 고수해야합니다. 이를 위해서는 공급 업체별 JpaDialect를 구성해야합니다.

주의 사항

한가지 주의해야할 점은 JPA 트랜잭션 매니저와 JDBC Connection 을 위한 DataSource는 동일한 DataSource 객체를 바라보고 있어야 하나의 트랜잭션으로 제대로 묶일 수 있습니다.

Note: To be able to register a DataSource's Connection for plain JDBC code, this instance needs to be aware of the DataSource (setDataSource(javax.sql.DataSource)). The given DataSource should obviously match the one used by the given EntityManagerFactory. This transaction manager will autodetect the DataSource used as the connection factory of the EntityManagerFactory, so you usually don't need to explicitly specify the "dataSource" property.

참고 : 일반 JDBC 코드에 대해 DataSource의 연결을 등록하려면 Jpa 트랜잭션 매니저 인스턴스가 DataSource (setDataSource (javax.sql.DataSource))를 인식해야합니다. 주어진 DataSource는 JPA 트랜잭션 매니저의 EntityManagerFactory에서 사용하는 것과 분명히 일치해야합니다. JPA 트랜잭션 매니저는 EntityManagerFactory의 연결 팩토리로 사용되는 DataSource를 자동 감지하므로 일반적으로 "dataSource"속성을 명시적으로 지정할 필요가 없습니다.

스프링에서 여러 DB 리소스에 접근해서 사용하는 경우에 위와 같은 주의 사항을 고려해서 트랜잭션 빈 설정을 해줘야 합니다.

테스트 해보자

Spring API 문서를 믿지만 그래도 다시 한번 돌다리를 두드려봅시다. 아래는 테스트 하려는 코드를 보여줍니다.

DataSource 프로퍼티 설정만 해주면 별다른 DataSource 빈 설정이 없어도 Spring Boot 에서 알아서 DB 관련 Bean 들을 설정해줍니다.

테스트할 코드

Spring Boot 는 자동으로 DataSourceJpaTransactionManager 빈을 등록해둔 상태 입니다.

@RequiredArgsConstructor
@Service
public class JpaTransactionManagerTestService {
    private final JdbcTemplate postJdbcTemplate;
    private final PostsRepository postsRepository;

    @Transactional // Spring Boot 에서 JpaTransactionManager 를 사용하도록 자동 설정
    public void addPost(Long userId, Posts post) {
        postsRepository.save(post); // (1) JPA 재질

        postJdbcTemplate.update(
                "UPDATE user " +
                        "SET total_post_count = total_post_count + 1 " +
                        "WHERE id = " + userId
        ); // (2) JDBC 재질

        throwException(post); // 인위적인 예외 발생을 위한 메서드
    }

   /**
    * 게시글 타이틀이 "invalid title" 일 경우 
    * 예외 발생!
    */
    private void throwException(Posts post) {
        if ("invalid title".equals(post.getTitle())) {
            throw new RuntimeException(post.getTitle());
        }
    }
}

게시글을 저장하는 코드(1)는 JPA 로 처리하고, 유저 정보를 업데이트하는 쿼리(2)는 JdbcTemplate 으로 처리합니다. 그리고 트랜잭션이 잘 돌아가는지 테스트 케이스를 만듭니다.

테스트 코드

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class JpaTransactionManagerTestServiceTest {
    private User fixtureUser;

    @Autowired
    private JpaTransactionManagerTestService sut;

    @Autowired
    private PostsRepository postsRepository;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    public void setUp() {
        User user = User.builder()
                .name("bottle taste")
                .email("bottletaste@bottle.com")
                .role(Role.USER)
                .build();

        fixtureUser = userRepository.save(user);
    }

    @AfterEach
    public void tearDown() {
        userRepository.deleteAll();
        postsRepository.deleteAll();
    }

    @Test
    public void JPA_와_JDBC_템플릿이_하나의_트랜잭션으로_묶일_경우__트랜잭션_동작이_성공한다() {
        // given
        long expectedTotalPostCount = fixtureUser.getTotalPostCount() + 1L;
        Posts fixturePost = fixturePost("트랜잭션 테스트");

        // when
        sut.addPost(fixtureUser.getId(), fixturePost);

        // then
        fixtureUser = userRepository.findById(fixtureUser.getId()).get();

        assertThat(fixtureUser.getTotalPostCount())
            .isEqualTo(expectedTotalPostCount);
       
        assertThat(postsRepository.findByAuthor(fixturePost.getAuthor()))
            .isNotNull();
    }

    @Test
    public void JPA_와_JDBC_템플릿이_하나의_트랜잭션으로_묶였으나_예외가_발생할_경우__JPA와_JDBC_작업은_모두_롤백된다() {
        // given
        long expectedTotalPostCount = fixtureUser.getTotalPostCount();
        Posts fixturePost = fixturePost("invalid title"); // 예외 발생을 일으키는 게시글 타이틀

        // when
        assertThrows(
          RuntimeException.class, 
          () -> sut.addPost(fixtureUser.getId(), fixturePost)
        );

        // then
        fixtureUser = userRepository.findById(fixtureUser.getId()).get();

        assertThat(fixtureUser.getTotalPostCount())
        	.isEqualTo(expectedTotalPostCount);
            
        assertThat(postsRepository.findByAuthor(fixturePost.getAuthor()))
        	.isNull();
    }

    private Posts fixturePost(String title) {
        return Posts.builder()
                .title(title)
                .author("bottle taste")
                .content("트랜잭션 테스트 본문")
                .build();
    }
}

위 테스트 코드는 2개의 케이스가 있습니다. 기본적으로 트랜잭션이 정상동작하는 케이스에 대한 확인이 필요합니다.

그 외에 관건은 트랜잭션을 처리하는 중간에 에러가 발생했을 때 입니다. 예외가 발생할 수 있는 게시글 타이틀을 설정하고 인위적으로 예외가 발생하게 만듭니다. 해당 코드는 예외를 발생될 거고 트랜잭션은 정상적으로 커밋되지 않으면서 JPA 재질과 JDBC 재질 모두 롤백이 되어야 합니다.

아래는 테스트 결과 입니다.

Gradle 테스트 결과 Report

두가지 테스트 케이스 모두 성공합니다. 짜잔!

JPA 트랜잭션 안쓰면?

굳이 말 안 듣는 청개구리가 되어봅시다. JpaTransactionManager 가 아닌 DataSourceTransactionManager 를 사용하도록 강제하면 어떻게 될까요?
강제 설정을 위해 별도의 트랜잭션 설정을 해줍니다.

트랜잭션 설정(Java Config)

@Configuration
@EnableJpaAuditing
@EnableJpaRepositories(
        basePackages = "com.bottletaste.blog.domain",
        transactionManagerRef = "jpaTransactionManager"
)
@EnableTransactionManagement
public class DatabaseConfig {
    @Bean
    public PlatformTransactionManager datasourceTransactionManager(DataSource dataSource){
        DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager();
        dataSourceTransactionManager.setDataSource(dataSource);
        return dataSourceTransactionManager;
    }

    @Bean
    public PlatformTransactionManager jpaTransactionManager(EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager jpaTransactionManager = new JpaTransactionManager();
        jpaTransactionManager.setEntityManagerFactory(entityManagerFactory);
        return jpaTransactionManager;
    }
    
    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
        emf.setJpaVendorAdapter(jpaVendorAdapter());
        emf.setDataSource(dataSource);
        emf.setPersistenceUnitName("persistenceJpa");
        emf.setPackagesToScan("com.bottletaste.blog.domain.*");
        emf.setJpaProperties(additionalProperties());

        return emf;
    }
    
    // 그외 코드 생략
}

테스트할 코드 - 살짝 수정

@RequiredArgsConstructor
@Service
public class JpaTransactionManagerTestService {
    private final JdbcTemplate postJdbcTemplate;
    private final PostsRepository postsRepository;

    @Transactional(transactionManager = "datasourceTransactionManager") // DatasourceTransactionManager 를 사용하도록 설정
    public void addPost(Long userId, Posts post) {
    // 그 외 코드 동일, 생략
    }
    
    // 그 외 코드 동일, 생략
}

테스트 결과!

테스트 코드도 모두 동일합니다. 동일하게 테스트를 실행했을 때의 결과는 아까와 동일하지 않게 예외가 발생하면서 테스트가 실패합니다.

Caused by: java.lang.IllegalStateException: 
    Already value [org.springframework.jdbc.datasource.ConnectionHolder@19f0c22e] 
    for key [HikariDataSource (HikariPool-1)] bound to thread [Test worker]
	at org.springframework.transaction.support.TransactionSynchronizationManager.bindResource(TransactionSynchronizationManager.java:193)
	at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:421)

참조

링크

profile
병 안에 쥐개구리

1개의 댓글

comment-user-thumbnail
2023년 10월 9일

좋은 글 잘 봤습니다!

PlatformTransactionManager의 여러 구현체를 보던 중 DataSource~와 JPA~가 따로 있는 것을 봤어요.
기본은 JpaTransactionManager가 빈인데 어떻게 JdbcTemplate의 트랜잭션도 지원해주지?? 가 궁금했는데 완벽하게 해소됐습니다😃

답글 달기