Springboot + Mybatis를 사용하는 프로젝트에서
@Mapper
에 대한 단위 테스트를 수행하는 방법과 주의사항에 대해서 알아본다.
내가 진행하는 프로젝트는 읽기 전용과 쓰기 전용 DB가 구분되어 있으며, 이런 이유로 DataSource
를 두 개로 나누어 설정하여 개발을 진행 중에 있다.
이렇게 개발을 진행하지 않더라도 지금부터 알아보는 @Mapper
에 대한 테스트는 적용하는데 문제가 없을 것이다.
DataSource 설정 참고
구분 | read | write |
---|---|---|
클래스 | ReaderDataSourceConfig.class | WriterDataSourceConfig.class |
드라이버 | Trino Driver | PostgreSQL Driver |
트랜잭션 지원여부 | X | O |
테스트를 수행하기 앞서 DataSource
의 연결 정보를 설정하는 작업이 필요하다. 나는 복수 개의 DataSource
를 사용했기 때문에 여기서 설정 방법을 확인하면 된다.
@Mapper
public interface SomeReaderMapper {
List<SomeResultDTO> selectSomeResult(String code);
}
먼저 Mybatis
의 @Mapper
를 정의해주자. 해당 매퍼의 경로는 아래 설정 중 편한 방식으로 지정한다.
application.yaml
mybatis:
...생략
mapper-locations: classpath:/sqlmap/mapper/*.xml
Java Config
// ReaderDataSourceConfig.java 일부
@Bean(name = "readerSqlSessionFactory")
public SqlSessionFactory readerSqlSessionFactory(@Qualifier("readerDataSource") DataSource dataSource)
throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSource);
sqlSessionFactoryBean.setTypeAliasesPackage("...생략"); // DTO
sqlSessionFactoryBean.setTypeHandlersPackage("...생략"); // 타입 핸들러
sqlSessionFactoryBean.setMapperLocations(
new PathMatchingResourcePatternResolver().getResources("classpath:/sqlmap/mapper/reader/**/*.xml"));
return sqlSessionFactoryBean.getObject();
}
build.gradle 파일에 아래와 같이 mybatis의 테스트 의존성을 추가하자.
...생략
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.2'
정의한 매퍼를 테스트하기 위한 클래스를 생성하고, 정상 호출과 의도된 불량 호출을 정의한다.
@MybatisTest
@Import({
DataSourceBeanConfig.class, // DataSource 설정
ReaderDataSourceConfig.class, // ReaderDataSource 설정
JasyptConfigAES.class // DB 접속 정보 복호화
})
// 선택 사항
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SomeReaderMapperTest {
@Autowired
private SomeReaderMapper someReaderMapper;
@Test
@DisplayName("결과 목록 조회 정상 테스트")
void someResult_returnsResults() {
String code = "valid code";
List<SomeResultDTO> results = someReaderMapper.selectSomeResult(
code);
Assertions.assertFalse(results.isEmpty());
}
@Test
@DisplayName("결과 목록 조회 비정상 테스트")
void someResult_returnsEmptyList() {
String code = "invalid code";
List<SomeResultDTO> results = someReaderMapper.selectSomeResult(
code);
Assertions.assertTrue(results.isEmpty());
}
}
위 코드대로 실행하면 아래와 같은 결과가 나온다.
테스트 코드에서 중요하게 봐야하는 부분은 클래스명 상단에 위치한 어노테이션들이다.
@MybatisTest
Mybatis의 테스트 의존성을 추가하면 사용할 수 있는 어노테이션으로 mybatis와 관련된 테스트에 필요한 어노테이션을 이용해 구성 요소를 자동으로 설정해준다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(MybatisTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(MybatisTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureMybatis
@AutoConfigureTestDatabase
@ImportAutoConfiguration
public @interface MybatisTest {
내부에 선언된 어노테이션은 위와 같다. 기본적으로 @AutoConfiguration
을 오버라이드하여 false
로 설정되어 있고, @Transactional
어노테이션으로 커밋 또는 롤백을 자동 지원한다. 이 외에도 캐시, 테스트 데이터베이스 연결 등에 대한 정보를 제공하고, 구성한다.
여기서 중요한 부분은 @Transactional
이다. 스프링을 이용하다보면 다양한 DB Driver를 연결하는 경우들이 발생한다. 대부분의 DB 플랫폼은 트랜잭션을 지원하지만 필자가 사용하는 Trino
의 경우에는 트랜잭션 지원을 하지 않는다.
이러한 이유로 @MybatisTest
내부에 정의된 @Trasactional
이 제대로 동작하지 않으면서 다음과 같은 오류를 뱉어낸다.
java.lang.IllegalStateException: Failed to retrieve PlatformTransactionManager for @Transactional test:
그럼 이런 상황을 어떻게 해결할까?
@Transactinal(propagation = Propagation.NOT_SUPPORTED)
바로 트랜잭션의 전파 속성에서 트랜잭션 지원을 수동으로 꺼주면된다. 스프링에서 지원하는 전파 속성 중 NOT_SUPPORTED
는 트랜잭선을 완전히 비활성화
한다.
그렇기 때문에 @MybatisTest
내부에 정의된 트랜잭션 또한 동작하지 않게되어 정상적으로 수행이 가능한 것이다.
다만, 주의해야할 사항은 트랜잭션이 지원하지 않는 DB Driver일 경우에 NOT_SUPPORTED
를 사용해야한다.
여기까지 해결이 됐다면 설정 정보에 대해서는 어떻게 가져오는지 알아보자.
@Import
@Import
를 통해 명시적으로 설정 관련 클래스를 추가하면 테스트 환경에서도 실제 데이터베이스 연결 정보를 토대로 구성할 수 있다. 위 코드에서 사용한 클래스들에 대한 정보는 다음과 같다.
대부분이 프로젝트와 연결된 데이터베이스 정보로 테스트를 수행하기 때문에 @Import
를 사용하여 명시적으로 설정 정보를 넘겨주는 것을 추천한다.
만약, @Import
로 정보를 전달하지 않을 경우 다음과 같은 예외가 발생할 수 있다.
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'sqlSessionFactory' defined in class path resource
어쩌면 간단할지 모르는 @Mapper
에 대한 테스트가 나에게는 꽤나 오랜 시간을 소모하게 만들었다. 이렇게 무언가를 해결하면서 매번 느끼는 점은 내가 사용하는 메서드, 어노테이션의 내부 동작을 확인하는 것이 가장 빠르고 명확한 해결 방법이 될 수 있다는 점을 인식한다.
AI를 이용하여 해결하는 것도 빠를 수 있지만 버전 정보, 환경 구성 등에 따라 제대로 유추하지 못하는 답변을 주는 경우도 있기 때문에 최근에는 공식 문서, 내부 코드 구성 등을 확인하며 에러를 해결하려고 노력하고 있다.
마지막으로 테스트에서 사용할 수 있는 트랜잭션 전파 속성에 대해 다시 한번 정리하고 마무리하겠다.
전파속성 | 설명 |
---|---|
REQUIRED(기본) | 현재 트랜잭션이 있으면 그대로 사용, 없으면 생성 |
REQUIRED_NEW | 항상 새로운 트랜잭션 생성, 기존은 일시 정지 |
NESTED | 중첩된 트랜잭션 생성, 내부적으로 중첩될 경우가 예상되면 사용 |
MANDATORY | 반드시 기존 트랜잭션이 존재해야하고, 없을 경우 예외 발생 |
SUPPORTS | 트랜잭선이 있으면 트랜잭션 내에서 실행, 없으면 트랜잭션 없이 실행 |
NOT_SUPPORTED | 트랜잭션 없이 실행하고, 기존에 있는 트랜잭션은 일시 정지 |
NEVER | 이미 트랜잭션이 존재하면 예외 발생, 트랜잭선 없이 실행 |