SQL Count & Assertion With 디버깅

2025. 11. 25. 23:12·개발
반응형

저번 포스팅과 이어지는 부분이 있습니다.

 

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개가 모두 성공적으로 통과했다.

테스트 성공

오랜만에 소스 코드를 열심히 들여다 본 것 같다. 역시 라이브러리는 똑바로 알고 쓰는 것이 중요하다.

좋은 경험이었다..!

 

 

이상으로 포스팅을 마칩니다. 감사합니다.

반응형
'개발' 카테고리의 다른 글
  • CORS, CSRF, XSS, CSP, SameSite 정리
  • 기본으로 돌아가기
  • Junit5 구조와 아키텍처 (with Junit이 main 없이 동작하는 이유)
  • Kotest 주의할 점 (with Spring)
Debin
Debin
공부 기록을 남기며 게시글 리팩토링을 진행하는 블로그입니다.
  • Debin
    리팩토링하는 블로그
    Debin
  • 전체
    오늘
    어제
    • 분류 전체보기
      • DB
        • DB 기초
        • MySQL
        • SQL 튜닝
      • OS
      • Network
      • Git
      • 디지털콘텐츠기획
      • 소프트웨어공학
      • 코딩테스트
        • 프로그래머스
        • 백준
        • 인프런
      • 공부 일지
      • 독서
        • 클린코드
        • 일상 속 사물이 알려주는 웹 API 디자인
        • 토비의 스프링
        • 객체지향의 사실과 오해
        • 자바 잘 읽는 법
      • 기록 및 회고
      • Cloud
        • AWS
      • 개발
        • Java
        • Spring Core
        • Spring MVC
        • Spring DB
        • Spring Boot
        • Spring Security
        • Spring Batch
        • JPA
        • Test
        • Android
      • 대외활동
        • UMC SERVER
        • 카엔프 SW 아카데미
      • 프로젝트
      • Docker
      • Gradle
      • ELK
      • 실무 이야기
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃허브
  • 공지사항

    • 본인 깃허브입니다!
  • 인기 글

  • 태그

    리눅스
    객체지향
    innodb
    docker
    redis
    mysql
    객체
    자바
    container
    spring
    컨테이너
    데이터베이스
    운영체제
    ORM
    spring mvc
    인덱스
    스프링
    도커
    토비의 스프링
    AOP
    test
    spring boot
    AWS
    트랜잭션
    Java
    프록시
    스프링 부트
    코딩 #개발자 #노마드북클럽 #노개북
    SQL
    JPA
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
Debin
SQL Count & Assertion With 디버깅
상단으로

티스토리툴바