본문 바로가기

자바

[spring] @Sql 어노테이션

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/jdbc/Sql.html

 

Sql (Spring Framework 6.1.11 API)

@Sql is used to annotate a test class or test method to configure SQL scripts() and statements() to be executed against a given database during integration tests. Method-level declarations override class-level declarations by default, but this behavior can

docs.spring.io

설명

@Sql 어노테이션은 파일로부터 쿼리를 읽어 테스트 실행 전 / 후에 쿼리를 실행해주는 역할을 한다.

 테스트할 때 DB에 접근해야 하는 repository 영역의 의존성은 가짜 객체(mock)로 만들어 테스트하는 것이 가장 이상적일 수 있지만, 경우에 따라 데이터가 정말 잘 저장되었는지, 쿼리가 정상적으로 동작하는지를 검사하는 것이 테스트의 주 목적일 수 있다. 이럴 때는 @DataJpaTest / @DataJdbcTest 어노테이션을 붙여 실제 데이터베이스(또는 H2)와 통신하며 테스트를 진행하게 된다.

 각 테스트에서 JDBC Template / JPA로 직접 쿼리를 실행할 수도 있겠지만, 매 테스트마다 관련된 코드를 작성하는 것도 귀찮은 일인데다 JPA로 데이터를 삽입하는 순간 테스트와 직접적인 연관이 없는 수많은 entity와 repository 관리 코드를 작성할 각오를 해야 한다. 만약 동일한 초기 데이터를 여러 테스트에서 공유하고 싶다면? 동일한 짓을 반복해야 한다.

 @Sql 어노테이션을 이용하면 각 테스트에서 단순히 데이터를 삽입하기 위한 entity / repository에 대한 의존 및 보일러 플레이트 코드를 없앨 수 있고, sql 쿼리를 한 곳에 저장해두고 여러 테스트에서 공유하여 사용할 수 있다는 장점이 있다. 개인적으로는 테스트 코드가 깨끗해진다는 것이 장점인 것 같다.

사용법

  1. sql 파일을 만들어 저장한다. 파일 위치는 Spring이 지원하는 경로로 커버할 수 있는 어디든 가능하다. 나는 resources 폴더 아래에 저장해뒀다.
  2. @Sql 어노테이션을 테스트 클래스 또는 메서드 수준에 붙이고, value로 sql 파일의 위치를 입력한다.

진행하고 있는 프로젝트에는 사람들이 작성한 댓글 개수만큼 추첨 가산점이 붙는 로직이 있다. 이를 지원하기 위해 다음과 같은 쿼리를 작성했다.

public interface CommentRepository extends JpaRepository<Comment, Long> {
    // 이름 매핑 필요. 필드 이름과 직접 매핑.
    @Query(value = "SELECT c.event_user_id as eventUserId, COUNT(c.event_user_id) as count " +
            "FROM comment c " +
            "JOIN event_frame ef ON c.event_frame_id = ef.id " +
            "JOIN event_metadata e ON ef.id = e.event_frame_id " +
            "WHERE e.id = :eventRawId " +
            "GROUP BY c.event_user_id " , nativeQuery = true)
    List<WriteCommentCountDto> countPerEventUserByEventId(@Param("eventRawId") Long eventRawId);
}

위 작성한 SQL문과 projection이 제대로 동작하는지 확인하기 위해 @DataJpaTest를 붙여 db 테스트를 진행했다.

@Sql(value = "classpath:sql/CommentRepositoryTest.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
@DataJpaTest(showSql = false)
@TestPropertySource(locations = "classpath:application-test.yml")
class CommentRepositoryTest {
    @Autowired
    CommentRepository commentRepository;


    @DisplayName("존재하는 대상 이벤트에 대해 작성된 댓글이 있다면 유저 별로 개수를 구해 반환")
    @Test
    void getCountOfCommentPerUserIfCommentExist() {
        List<WriteCommentCountDto> counts = commentRepository.countPerEventUserByEventId(1L);

        assertThat(counts).hasSize(3);
        assertThat(counts.get(0).getCount()).isEqualTo(3);
        assertThat(counts.get(1).getCount()).isEqualTo(6);
        assertThat(counts.get(2).getCount()).isEqualTo(2);
    }

    @DisplayName("존재하지 않는 대상 이벤트는 빈 배열 반환")
    @Test
    void getCountOfCommentPerUserIfEventNotExist() {
        List<WriteCommentCountDto> counts = commentRepository.countPerEventUserByEventId(3L);
        assertThat(counts).hasSize(0);
    }

    @DisplayName("존재해도 댓글 없으면 빈 배열 반환")
    @Test
    void getCountOfCommentPerUserIfEventExistButNoComment() {
        List<WriteCommentCountDto> counts = commentRepository.countPerEventUserByEventId(2L);
        assertThat(counts).hasSize(0);
    }
}
  • value: 실행할 SQL 파일의 위치를 나타낸다.
  • executionPhase: 해당 SQL문을 실행할 타이밍을 지정한다. JUnit이 지원하는 4개 타이밍과 비슷하다.
    • BEFORE_TEST_CLASS: 전체 테스트 시작 전
    • AFTER_TEST_CLASS: 전체 테스트 실행 후
    • BEFORE_TEST_METHOD: 개별 테스트 메서드 시작 전
    • AFTER_TEST_METHOD: 개별 테스트 메서드 시작 후

주의점

@Sql 쿼리는 테스트 클래스 내에서 공유된다. 만약 특정 테스트에 특정 행을 갱신하는 로직이 있다면 다른 테스트에 영향을 주지 않도록 db 상태를 어떻게든 초기화해야 한다.

@DataJpaTest의 경우 @Transactional 어노테이션이 붙어 있어 매 테스트에서 실행된 내용이 롤백되어 각 테스트 간 동일한 DB 상태를 보장한다. 하지만 @Sql은 단순히 SQL문을 읽어 실행할 뿐 롤백 로직이 없다.

이를 해결하는 방법을 찾아보니 크게 2가지 정도가 있는 것 같다.

  1. delete / truncate 등 명령어를 이용하여 매 테스트 이후에 DB 데이터를 비운다.
    1. @Sql(executionPhase=AFTER_TEST_METHOD) 로 초기화 코드를 분리해둘 수 있다.
  2. @DirtiesContext를 활용하여 각 테스트마다 컨텍스트를 생성해 사용하게 만든다.

2번의 경우 별도 코드를 작성하지 않아도 되지만 테스트마다 새로운 스프링 컨텍스트를 만들어 사용하게 되므로 테스트 성능이 낮아지는 문제가 있다. 스프링 컨텍스트를 껴서 테스트해본 적이 있다면 알겠지만, POJO로 테스트하는 것에 비해 상당히 초기화 시간이 오래 걸린다. CI / CD에서 걸리는 시간 = 돈이라는 것을 고려하면 개인적으로 별로 끌리는 방법은 아닌 것 같다.

1번 방식은 모든 테이블에서 데이터를 delete하는 SQL을 작성해둬야 한다는 불편함이 있고, 테이블이 추가될 때마다 한줄씩 갱신해야 하는 귀찮음이 있다. 하지만, 보통 DB는 한번 잘 만들어두면 변경되는 일이 적기 때문에 성능을 위해 충분히 도입할 수 있다고 생각한다.

1번 방식을 이용한다면 코드는 대략 아래처럼 되겠다.

@Sql(value = "classpath:sql/CommentRepositoryTest.sql", 
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "classpath:sql/reset.sql", 
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
@DataJpaTest(showSql = false)
@TestPropertySource(locations = "classpath:application-test.yml")
class CommentRepositoryTest {
   // 테스트 코드를 작성...
}

매 테스트 전에 데이터를 삽입 / 테스트 이후에는 reset.sql에 정의된 delete / truncate 명령으로 테이블 데이터를 비운다.