저번 포스팅과 이어지는 부분이 있습니다.
https://devdebin.tistory.com/366
JPA 거짓 연관 관계 맺기(프록시)
동일한 테이블의 2개의 엔티티회사 프로젝트에 아래 예시 코드와 유사한 코드가 존재한다.@Table(name = "prod")@Entityclass Prod( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0, @Column(name = "name")
devdebin.tistory.com
SQL Count & Assertion
저번 포스팅에서는 getReferenceById를 사용해 실제 데이터를 조회하지 않고 프록시로 연관관계를 세팅했다.
이 과정에서 데이터 조회하지 않는 내용을 눈으로 확인해봤는데, 이번에는 눈으로 검증하지 않는 자동화를 진행해보려고 한다.
즉 발생한 SQL 구문 횟수를 검증(Assertion)하는 것이다.
사용한 라이브러리
먼저 아래와 같은 의존성을 추가해준다. build.gradle.kts 기준 코드다.
implementation("io.hypersistence:hypersistence-utils-hibernate-63:3.12.0")
implementation("com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.12.1")
hypersistence-utils-hibernate 라이브러리의 SQLStatementCountValidator를 사용해 검증을 진행할 예정이다.
그리고 DataSource-Proxy 라이브러리를 사용해 데이터 소스를 프록시해 실제 파라미터 값과 SQL문 실행 수 같은 정보를 얻을 수 있다. 깃허브에서 DataSource-Proxy를 스프링 부트 스타터로 편하게 제공하는 라이브러리가 있어 이를 채택했다.
hypersistence-utils-hibernate 라이브러리가 내부적으로 DataSource-Proxy 라이브러리를 의존하고 있어,
SQL 실행 수 같은 정보를 편하게 검증 가능하게 해준다.
그럼 본격적으로 코드를 작성해보자.
먼저 DataSource-Proxy 스프링 부트 스타터이니 자동 구성을 먼저 확인해보면 어떤 스프링 빈이 등록되어,
DataSource-Proxy 라이브러리가 데이터 소스를 프록시해 파라미터 값과 SQL 실행 수 같은 각종 정보를 얻을 수 있는지 확인이 가능할 것이다.
바로 DataSourceDecoratorAutoConfiguration 라는 스프링 부트 자동 구성 핵심 클래스를 찾을 수 있었다.
이 클래스에는 아래 코드와 같이 프로퍼티를 읽어오고 있다.
@EnableConfigurationProperties({DataSourceDecoratorProperties.class})
이제 DataSourceDecoratorProperties 클래스를 확인해보자.

우리는 DataSource-Proxy 라이브러리를 사용하고 있으니, DataSourceProxyProperties를 확인하면 된다.

바로 countQuery가 보인다.
그럼 이제 우리의 application.yml에 아래와 같은 내용을 작성하자.
decorator:
datasource:
datasource-proxy:
multiline: true
format-sql: true
json-format: true
count-query: true
이제 데이터 소스를 감싼 프록시에서 수행된 query를 카운팅하는 세팅을 완료했다.
자세히 코드를 살펴보면 QueryCountStrategy의 구현체인 SingQueryCountHolder가 스프링 빈으로 등록된 것이다.

SingQueryCountHolder는 ProxyDataSourceBuilderConfigurer에서 Data Source 프록시를 생성할 때 DataSourceQueryCountListener에 멤버 변수로 바인딩 된다.
이제 흐름은 파악했다!
이제 아래와 같이 테스트 코드를 작성하자.
package com.dblab
import com.dblab.jpa.proxy.Prod
import com.dblab.jpa.proxy.ProdForExternalSystem
import com.dblab.jpa.proxy.ProdForExternalSystemRepository
import com.dblab.jpa.proxy.ProdRepository
import com.dblab.jpa.proxy.Vendor
import com.dblab.jpa.proxy.VendorRepository
import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator
import net.ttddyy.dsproxy.QueryCountHolder
import net.ttddyy.dsproxy.listener.QueryCountStrategy
import net.ttddyy.dsproxy.listener.SingleQueryCountHolder
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.annotation.DirtiesContext
@SpringBootTest
class DbLabApplicationTests {
@Autowired
lateinit var prodRepository: ProdRepository
@Autowired
lateinit var prodForExternalSystemRepository: ProdForExternalSystemRepository
@Autowired
lateinit var vendorRepository: VendorRepository
@BeforeEach
fun setUp() {
SQLStatementCountValidator.reset()
}
@Test
fun `연관관계를 조회한 상품 저장`() {
val vendor = vendorRepository.save(createVendor())
val prod = Prod(name = "상품", vendor = vendorRepository.findById(vendor.id).orElseThrow())
prodRepository.save(prod)
SQLStatementCountValidator.assertTotalCount(3)
SQLStatementCountValidator.assertInsertCount(2)
SQLStatementCountValidator.assertSelectCount(1)
}
@Test
fun `연관관계 없이 상품 저장`() {
val vendor = vendorRepository.save(createVendor())
val prod = ProdForExternalSystem(name = "상품", vendorId = vendor.id)
prodForExternalSystemRepository.save(prod)
SQLStatementCountValidator.assertTotalCount(2)
SQLStatementCountValidator.assertInsertCount(2)
SQLStatementCountValidator.assertSelectCount(0)
}
@Test
fun `프록시를 사용한 상품 저장`() {
val vendor = vendorRepository.save(createVendor())
val prod = Prod(name = "상품", vendor = vendorRepository.getReferenceById(vendor.id))
prodRepository.save(prod)
SQLStatementCountValidator.assertTotalCount(2)
SQLStatementCountValidator.assertInsertCount(2)
SQLStatementCountValidator.assertSelectCount(0)
}
private fun createVendor() = Vendor(
name = "test_vendor",
code = "code"
)
}
잘 짠 것 같지만 예외가 뻥뻥터졌다.
테스트 3개를 전체 수행시켰더니 마지막 테스트에서 다음과 같은 에러가 발생했다.

reset() 메서드가 필자가 생각한대로 테스트마다 SQL 카운트를 초기화시켜주고 있는 것이 아니었다.
그래서 바로 SQLStatementCountValidator를 살펴보았다.
내부에 있는 QueryCountHolder가 바로 DataSource-Proxy 라이브러리에 있는 클래스다.
public class SQLStatementCountValidator {
private SQLStatementCountValidator() {
}
public static void reset() {
QueryCountHolder.clear();
}
public static void assertSelectCount(int expectedCount) {
StatementType.SELECT.validate((long)expectedCount);
}
public static void assertInsertCount(int expectedCount) {
StatementType.INSERT.validate((long)expectedCount);
}
public static void assertUpdateCount(int expectedCount) {
StatementType.UPDATE.validate((long)expectedCount);
}
public static void assertDeleteCount(int expectedCount) {
StatementType.DELETE.validate((long)expectedCount);
}
public static void assertTotalCount(int expectedCount) {
StatementType.TOTAL.validate((long)expectedCount);
}
}
다음은 QueryCountHolder의 코드다.
public class QueryCountHolder {
private static ThreadLocal<ConcurrentMap<String, QueryCount>> queryCountMapHolder = new ThreadLocal<ConcurrentMap<String, QueryCount>>() {
@Override
protected ConcurrentMap<String, QueryCount> initialValue() {
return new ConcurrentHashMap<String, QueryCount>();
}
};
public static QueryCount get(String dataSourceName) {
final Map<String, QueryCount> map = queryCountMapHolder.get();
return map.get(dataSourceName);
}
public static QueryCount getGrandTotal() {
final QueryCount totalCount = new QueryCount();
final Map<String, QueryCount> map = queryCountMapHolder.get();
for (QueryCount queryCount : map.values()) {
totalCount.setSelect(totalCount.getSelect() + queryCount.getSelect());
totalCount.setInsert(totalCount.getInsert() + queryCount.getInsert());
totalCount.setUpdate(totalCount.getUpdate() + queryCount.getUpdate());
totalCount.setDelete(totalCount.getDelete() + queryCount.getDelete());
totalCount.setOther(totalCount.getOther() + queryCount.getOther());
totalCount.setTotal(totalCount.getTotal() + queryCount.getTotal());
totalCount.setSuccess(totalCount.getSuccess() + queryCount.getSuccess());
totalCount.setFailure(totalCount.getFailure() + queryCount.getFailure());
totalCount.setTime(totalCount.getTime() + queryCount.getTime());
}
return totalCount;
}
public static void put(String dataSourceName, QueryCount count) {
queryCountMapHolder.get().put(dataSourceName, count);
}
public static List<String> getDataSourceNamesAsList() {
return new ArrayList<String>(getDataSourceNames());
}
public static Set<String> getDataSourceNames() {
return queryCountMapHolder.get().keySet();
}
public static void clear() {
queryCountMapHolder.get().clear();
}
}
QueryCountHolder 클래스의 clear() 메서드는 단순히 스레드 로컬을 초기화하는 것이다.
여기서 잘못됨을 느꼈다.
SQL 카운트를 세기 위해 싱글톤으로 등록된 SingQueryCountHolder 코드는 없고 스레드 로컬만 초기화하는 것이다.
이런 코드라면 초기화가 될 리 없지..!!!
근데 이상한 점은 SingQueryCountHolder 에 의해서 어떻게 QueryCountHolder의 QueryCount 값이 올라간거지?!!
코드로 보면 바로 답이 나온다.

바로 저기에서 QueryCountHolder에 값을 넣어버리는 것이다.
결론은 QueryCountHolder와 SingQueryCountHolder 가 다른 저장소를 보고 있었고,
count가 증가하는 로직은 SingQueryCountHolder에 의해 QueryCountHolder도 반영되지만,
SingQueryCountHolder의 clear() 메서드에는 QueryCountHolder에 대한 어떤 로직도 없어서 반영이 안됐다.
그래서 코드를 아래와 같이 수정했다.
package com.dblab
import com.dblab.jpa.proxy.Prod
import com.dblab.jpa.proxy.ProdForExternalSystem
import com.dblab.jpa.proxy.ProdForExternalSystemRepository
import com.dblab.jpa.proxy.ProdRepository
import com.dblab.jpa.proxy.Vendor
import com.dblab.jpa.proxy.VendorRepository
import io.hypersistence.utils.jdbc.validator.SQLStatementCountValidator
import net.ttddyy.dsproxy.QueryCountHolder
import net.ttddyy.dsproxy.listener.QueryCountStrategy
import net.ttddyy.dsproxy.listener.SingleQueryCountHolder
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.annotation.DirtiesContext
@SpringBootTest
class DbLabApplicationTests {
@Autowired
lateinit var prodRepository: ProdRepository
@Autowired
lateinit var prodForExternalSystemRepository: ProdForExternalSystemRepository
@Autowired
lateinit var vendorRepository: VendorRepository
@Autowired
lateinit var queryCountStrategy: QueryCountStrategy
@BeforeEach
fun setUp() {
println("=== Before Test ===")
// clear 전
println("Before clear - Grand Total: ${QueryCountHolder.getGrandTotal().insert}")
println("Before clear - DataSources: ${QueryCountHolder.getDataSourceNames()}")
if (::queryCountStrategy.isInitialized && queryCountStrategy is SingleQueryCountHolder) {
(queryCountStrategy as SingleQueryCountHolder).clear()
}
// clear 후
println("After clear - Grand Total: ${QueryCountHolder.getGrandTotal().insert}")
println("After clear - DataSources: ${QueryCountHolder.getDataSourceNames()}")
}
@Test
fun `연관관계를 조회한 상품 저장`() {
val vendor = vendorRepository.save(createVendor())
val prod = Prod(name = "상품", vendor = vendorRepository.findById(vendor.id).orElseThrow())
prodRepository.save(prod)
SQLStatementCountValidator.assertTotalCount(3)
SQLStatementCountValidator.assertInsertCount(2)
SQLStatementCountValidator.assertSelectCount(1)
}
@Test
fun `연관관계 없이 상품 저장`() {
val vendor = vendorRepository.save(createVendor())
val prod = ProdForExternalSystem(name = "상품", vendorId = vendor.id)
prodForExternalSystemRepository.save(prod)
SQLStatementCountValidator.assertTotalCount(2)
SQLStatementCountValidator.assertInsertCount(2)
SQLStatementCountValidator.assertSelectCount(0)
}
@Test
fun `프록시를 사용한 상품 저장`() {
val vendor = vendorRepository.save(createVendor())
val prod = Prod(name = "상품", vendor = vendorRepository.getReferenceById(vendor.id))
prodRepository.save(prod)
SQLStatementCountValidator.assertTotalCount(2)
SQLStatementCountValidator.assertInsertCount(2)
SQLStatementCountValidator.assertSelectCount(0)
}
private fun createVendor() = Vendor(
name = "test_vendor",
code = "code"
)
}
테스트 3개가 모두 성공적으로 통과했다.

오랜만에 소스 코드를 열심히 들여다 본 것 같다. 역시 라이브러리는 똑바로 알고 쓰는 것이 중요하다.
좋은 경험이었다..!
이상으로 포스팅을 마칩니다. 감사합니다.