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