BackEnd/Spring

Spring @Sql 애노테이션의 동작 원리와 트러블슈팅 기록

PgmJUN 2024. 6. 22. 23:05

 

 

 

우아한테크코스 레벨2에서 스프링부트를 활용한 미션을 수행하면서, 테스트 코드를 어느때보다 열심히 작성하고 있는 것 같다.

하지만 테스트 코드에서 @Sql 을 사용하는 과정에서 문제를 마주하게 되었고,

이를 @Sql 애노테이션의 동작 원리를 살펴보는 좋은 기회로 여겨 문제 상황과 학습 내용을 기록해보고자 한다.

 

 


 

@Sql 애노테이션이란?

마주한 문제에 대해 알아보기 전에

우선 Sql 애노테이션이 무엇인지부터 알아보자.

 

 

정확한 설명을 위해 공식문서의 말을 빌려보자면,

 

‘@Sql 애노테이션은 통합 테스트 환경에서 주어진 데이터베이스에 실행시키기 위한 SQL의 scripts 또는 statements를 구성하기 위한 목적으로 테스트 클래스 또는 테스트 메서드에 붙여주는 애노테이션’ 이라고 한다.

 

간단히 @Sql 애노테이션의 역할을 살펴보았으니 이제 문제 상황에 접근해보자

 

 


 

마주한 문제 상황

@JdbcTest
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
class ThemeRepositoryTest { 
   
    //...
   
    @Test
    @DisplayName("예약 수 상위 10개 테마를 조회했을 때 내림차순으로 정렬된다. 만약 예약 수가 같다면, id 순으로 오름차순 정렬된다.")
    @Sql(scripts = "/reservationData.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
    void readTop10ThemesDescOrder() {
 

나는 RepositoryTest 에서 클래스 레벨에 @Sql 애노테이션을 선언하여 truncate.sql 을 실행시켜 DB를 초기화하고

메서드 레벨에 선언한 reservationData.sql 로 초기 테이터를 삽입하고자 하였다.

 

하지만 예상한 바와는 다르게

reservationData.sql은 수행되지만 truncate.sql 제대로 실행되지 않아 문제가 발생한 것이다.

 

@JdbcTest
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
class ThemeRepositoryTest { 
   
    //...
   
    @Test
    @DisplayName("예약 수 상위 10개 테마를 조회했을 때 내림차순으로 정렬된다. 만약 예약 수가 같다면, id 순으로 오름차순 정렬된다.")
    @Sql(scripts = "/reservationData.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
    void readTop10ThemesDescOrder() {

이때 truncate.sql을 ExecutionPhase.AFTER_TEST_METHOD 로 실행시키면 정상 작동했고,

그 이유가 궁금해 기록해보기로 하였다.

 

 


 

원인 분석

스프링 공식문서를 통해 알게된 원인은 다음과 같았다.

 

 

@Sql 애노테이션은 클래스 레벨 + 메서드 레벨 모두 선언되어 있다면

'메서드 레벨 설정'이 '클래스 레벨 설정'을 오버라이드하여, 메서드 레벨에 명시된 sql 파일만 수행된다고 한다.

 

때문에 truncate.sql과 reservationData.sql 실행을 모두 BEFORE_TEST_METHOD로 설정해두면

메서드에 선언된 @Sql 애노테이션이 오버라이드되면서 reservationData.sql 만 실행시킨 것이었다.


그리고 이때 truncate.sql을 AFTER_TEST_METHOD로 설정하면

서로 다른 시점(AFTER_TEST, BEFORE_TEST) 의 설정이기 때문에 오버라이드가 되지 않아서 문제가 해결되었던 것이다.

 

하지만 DB 초기화는 테스트 실행 전에 수행시켜주는 것이 훨씬 안정성있기 때문에
truncate.sql과 reservationData.sql을 BEFORE_TEST 시점에 한 번에 실행시킬 방법을 찾기로 했다.

 

 

Executing SQL Scripts :: Spring Framework

In addition to the aforementioned mechanisms for running SQL scripts programmatically, you can declaratively configure SQL scripts in the Spring TestContext Framework. Specifically, you can declare the @Sql annotation on a test class or test method to conf

docs.spring.io

 

 


 

 

해결 방안

 

1. Mulitple scripts 사용

@JdbcTest
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
class ThemeRepositoryTest { 
    
    //...
    
    @Test
    @DisplayName("예약 수 상위 10개 테마를 조회했을 때 내림차순으로 정렬된다. 만약 예약 수가 같다면, id 순으로 오름차순 정렬된다.")
    @Sql(scripts = {"/truncate.sql", "/reservationData.sql"}, executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
    void readTop10ThemesDescOrder() {​

같은 시점에 truncate.sql 외에 다른 쿼리를 가지는 메서드에는

메서드의 @Sql 애노테이션 내에 truncate.sql을 함께 추가해주면 오버라이드로 인해 reservationData.sql 만 실행되는 문제가 해결된다.

 

 

2. @SqlGroup 애노테이션 사용

@JdbcTest
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
class ThemeRepositoryTest { 
    
    //...
        
    @Test
    @DisplayName("예약 수 상위 10개 테마를 조회했을 때 내림차순으로 정렬된다. 만약 예약 수가 같다면, id 순으로 오름차순 정렬된다.")
    @SqlGroup({
            @Sql(scripts = {"/truncate.sql"}, executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
            @Sql(scripts = {"/reservationData.sql"}, executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
    })
    void readTop10ThemesDescOrder() {​

또다른 방법으로는 @SqlGroup 을 사용하는 방법이 있다.

@SqlGroup 을 사용하면 2개 이상의 쿼리를 각각 명시하여 실행시켜줌으로써 문제를 해결할 수 있다.

 

 


 

@Sql 애노테이션에 선언한 scripts 실행 순서

이제 truncate.sql과 reservationData.sql 을 같은 시점인 BEFORE_TEST에 실행시킬 수 있게 되었다.

 

하지만 여기서 더 중요한 포인트는

truncate.sql을 통해 DB가 truncate된 후에 reservationData.sql를 실행시켜서 데이터를 초기화시켜주어야 하는데,

그 순서를 어떻게 보장할 수 있는가이다.

 

때문에 @Sql 애노테이션에 선언한 스크립트의 실행 순서를 알아보기로 하였다.

 

우선위 @Sql 애노테이션의 'scripts' 속성에 선언된 스크립트는

스크립트가 선언된 순서대로 실행될 것이라는 믿음 아래서 작성된 코드이다.

하지만 과연 실제로도 그럴까?

 

직접 코드를 타고 들어가면서 알아보자.

 

 

ResourceDatabasePopulator

 

@Sql 애노테이션은 org.springframework.jdbc 에 선언이 되어있다.

 

해당 패키지에 들어가보니 @Sql 애노테이션과 더불어

@Sql 애노테이션의 스크립트 실행 리스너인 SqlScriptsTestExecutionListener 를 마주할 수 있었다.

 

이 SqlScriptsTestExecutionListener에 존재하는 메서드들을 파고 하위로 들어가다보면

org.springframework.jdbc.datasource.init 에 존재하는 ResourceDatabasePopulator 라는 객체를 확인할 수 있다.

 

스크립트의 실행은 정확히 이 객체의 execute() 메서드에서 시작된다.

 

이제 타점을 찾았으니 자세히 살펴보자.

 

 

ResourceDatabasePopulator의 execute() 메서드

 

해당 객체는 execute() 라는 메서드를 통해 자기 자신(ResourceDatabasePopulator)과 dataSource를 넘겨

DatabasePopulatorUtils의 메서드 execute()를 호출한다.

 

 

DatabasePopulatorUtils의 populate(), execute() 메서드

 

DatabasePopulatorUtils 의 execute() 메서드는, try/catch 구문 내에서 매개변수로 전달받은 dataSource를 사용하여 DataSourceUtils 로부터 커넥션을 얻어온다.

 

그리고 그 connection을 매개변수로 담아

execute() 메서드의 호출자인 ResourceDatabasePopulator의 populate() 메서드를 호출한다.

바로 아래에서 살펴볼 예정이지만 살짝 설명하자면 populate()는 script를 실행시켜 DB에 쿼리를 날리는 메서드이다.

 

이때 populate() 메서드가 예외 발생없이 정상적으로 수행되는 경우

만약 connection이 autoCommit 상태가 아니고, 트랜잭션을 타고있지 않다면

여기서 connection을 직접 commit한다.

 

 

ResourceDatabasePopulator의 populate() 메서드

 

DatabasePopulatorUtils 의 execute() 에서 호출한 ResourceDatabasePopulator의 populate() 메서드이다.

코드를 살펴보면 String[] scripts의 Iterator를 얻어 순차적으로 쿼리를 실행시키는 것을 확인할 수 있었다.

 

이 scripts는 우리가 @Sql 애노테이션의 scripts 속성에 정의한 바로 그 스크립트이며,
입력 순서대로 scripts String배열에 담겨있다가 이 시점에 하나씩 순서대로 실행되는 것이다.

 

ScriptUtils의 executeSqlScript() 메서드

 

 

executeSqlScript() 메서드는 내부적으로 Statement를 생성하여, script에 담긴 쿼리를 DB에 날리는 역할한다.

 

 


 

결론

  •  테스트에서 Multiple Scripts 또는 @SqlGroup 애노테이션을 통해 여러 sql을 같은 시점에 실행시킬 수 있다.
  • 실행하고자 입력한 Sql 스크립트는 입력해준 순서대로 실행된다.