JPA hibernate는 편리하지만 때로 치명적이다. 의도하지 않은 여러 쿼리가 실행돼 db에 부하가 걸리거나, 영속성 컨텍스트와 실제 db 데이터가 다를 수 있는 등의 문제가 발생할 수 있기 때문이다. 여기서는 첫 번째 문제를 보완할 수 있는 방법을 소개한다.
Hibernate가 어떤 쿼리를 생성하는지 보통은 눈으로 확인한다. 아래와 같은 로그를 보고 말이다.
[ Test worker] org.hibernate.SQL : select book0_.id as id1_0_0_ from book book0_ where book0_.id=?
[ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [0]
로그를 눈으로 보면 select
쿼리가 한 번 발생하는 것을 확인할 수 있다.
이게 최선일까? 절대 아니다.
Unit test에는 F.I.R.S.T. 원칙이 있는데, 여기서 S는 self-validating으로, 개발자가 로그를 확인해서 테스트 통과 여부를 체크하는 게 아니라, 프로그램 스스로 테스트 통과 여부를 체크하는 것을 말한다. 위의 로그 확인 방법은 이 원칙에 위배된다 (알아 안다고. 이거 unit test 아니야. 그래도 귀찮은 건 싫잖아?).
SELECT, INSERT, UPDATE, DELETE 쿼리 중 무엇이 몇 번 발생하는지 테스트 코드로 확인
이 문제의 실마리는 datasource proxy다. Datasource proxy를 이용해 self-validating test를 만들어보자. 아래는 프로젝트 환경 설정이다.
build.gradle.kts
plugins {
id("org.springframework.boot") version "2.6.11"
id("io.spring.dependency-management") version "1.0.13.RELEASE"
val kotlinVersion = "1.6.21"
kotlin("jvm") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion
kotlin("plugin.jpa") version kotlinVersion
}
// 생략
dependencies {
// 생략: data jpa, web, kotlin, database, test
// self-validating query count 라이브러리.
// datasource proxy를 정의하진 않지만, datasource-proxy 라이브러리에 의존한다.
implementation("com.vladmihalcea:db-util:1.0.7")
}
// 생략
엔티티
@Entity
class Book(
@Id
@GeneratedValue(strategy = IDENTITY)
val id: Long = 0L,
)
레포지토리
interface BookRepository : JpaRepository<Book, Long>
서비스
@Service
class DemoService(
private val bookRepository: BookRepository,
) {
@Transactional
fun findBook() {
bookRepository.findById(0L)
}
}
테스트
@SpringBootTest
@TestConstructor(autowireMode = ALL)
internal class DemoServiceTest(
private val demoService: DemoService,
) {
@Test
fun a() {
// 쿼리 기록 삭제
SQLStatementCountValidator.reset()
demoService.findBook()
// select 쿼리 개수 체크
SQLStatementCountValidator.assertSelectCount(1)
}
}
테스트 코드가 매우 직관적이다. 하지만 테스트는 아래와 같이 실패한다. 떡하니 쿼리도 로그에 나와서 약오른다.
2022-08-30 23:59:12.180 DEBUG 17105 --- [ Test worker] org.hibernate.SQL : select book0_.id as id1_0_0_ from book book0_ where book0_.id=?
2022-08-30 23:59:12.183 TRACE 17105 --- [ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [0]
Expected 1 statements but recorded 0 instead!
com.vladmihalcea.sql.exception.SQLSelectCountMismatchException: Expected 1 statements but recorded 0 instead!
해결 방법은 서두에서 언급한 것처럼 datasource-proxy다. 아래와 같이 component bean을 설정하자.
@Component
class DatasourceProxyBeanPostProcessor : BeanPostProcessor {
override fun postProcessBeforeInitialization(bean: Any, beanName: String): Any {
return bean
}
override fun postProcessAfterInitialization(bean: Any, beanName: String): Any {
return if (bean is DataSource) {
ProxyDataSourceBuilder.create(bean)
.logQueryBySlf4j(INFO)
.multiline()
.countQuery()
.build()
} else bean
}
}
이제 테스트를 실행하면 성공한다.
위 방법은 N+1 같은 문제를 감지하는 데 좋다. 하지만 가끔은 쿼리의 종류뿐만 아니라 쿼리문 전체를 확인하고 싶을 때도 있다. 이 때는 어덯게 해야 할까? 눈으로 확인해야 할까?
쿼리문 자체를 String 비교를 통해 테스트 코드로 확인
Hibernate가 만들어내는 쿼리를 string으로 변환하고, 이를 테스트 코드로 확인할 수 있다. 여기서도 datasource proxy를 사용한다. 아래는 프로젝트 설정이다.
build.gradle.kts
plugins {
id("org.springframework.boot") version "2.6.11"
id("io.spring.dependency-management") version "1.0.13.RELEASE"
val kotlinVersion = "1.6.21"
kotlin("jvm") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion
kotlin("plugin.jpa") version kotlinVersion
}
// 생략
dependencies {
// 생략: data jpa, web, kotlin, database, test
// datasource proxy를 정의.
implementation("net.ttddyy:datasource-assert:1.0")
}
엔티티와 레포지토리는 위와 동일하다.
서비스
@Service
class DemoService(
private val bookRepository: BookRepository,
) {
@Transactional
fun findBook() {
bookRepository.findById(0L)
}
@Transactional
fun findAllBook(){
bookRepository.findAll()
}
}
Proxy datasource를 사용하기 위해 테스트 패키지에 다음과 같이 테스트 환경설정을 한다. ProxyDataSourceConfig
@TestConfiguration
class ProxyDataSourceConfig : BeanPostProcessor {
override fun postProcessAfterInitialization(bean: Any, beanName: String): Any {
return if (bean is DataSource) {
return ProxyTestDataSource(bean)
} else bean
}
}
테스트
@SpringBootTest
@Import(ProxyDataSourceConfig::class)
@TestConstructor(autowireMode = ALL)
internal class DemoServiceTest(
private val dataSource: ProxyTestDataSource,
private val demoService: DemoService,
) {
@AfterEach
fun tearDown() {
dataSource.reset()
}
@Test
fun findAllBook() {
demoService.findAllBook()
val actual = dataSource.queryExecutions
.map {
it as PreparedExecution
it.query
}[0]
val expected =
"""
select
book0_.id as id1_0_,
book0_.body as body2_0_
from book book0_
"""
.trimIndent()
.replaceWithSingleWhiteSpace()
assertTrue(actual == expected)
}
private fun String.replaceWithSingleWhiteSpace(): String {
return this.replace("\\s+".toRegex(), " ")
}
@Test
fun findBook() {
demoService.findBook()
val actual = dataSource.queryExecutions
.map {
it as PreparedExecution
it.query
}[0]
val expected =
"""
select
book0_.id as id1_0_0_,
book0_.body as body2_0_0_
from book book0_
where book0_.id=?
"""
.trimIndent()
.replaceWithSingleWhiteSpace()
assertTrue(actual == expected)
}
}
위 테스트는 hibernate가 생성하는 쿼리를 string으로 놓고 비교한다. 그렇기 때문에 서비스 코드가 변경돼 쿼리도 달라진다면 이를 테스트로 알 수 있다.
References
- Anghel Leonard. (2020) Spring Boot Persistence Best Practices (1st ed.). Apress
- Configuring A Datasource-Proxy In Spring Boot, Arnold Galovics - ARNOLD GALOVICS
- datasource-assert, Tadaya Tsuyukubo - github
- Testing with Spring Boot’s @TestConfiguration Annotation