단순히 Persistence Layer를 테스트하는 방법만을 서술하는 것이 아닌, 영속 계층을 테스트해야 하는 이유에 대해 정리하고 어떤 방식으로 테스트하는 것이 더 좋은 방법인지 고민한 내용을 정리해보려 합니다.
1. Persistence Layer (혹은 Repository Layer)
Persistence Layer의 테스트를 하기 전에 Layered Architecture와 테스트의 분류에 대해 먼저 숙지해두면 각 레이어 별 테스트가 어떤 것을 목적으로 하는지 파악할 수 있고, 그 목적에 맞는 테스트를 만들 수 있다.
1-1. Layered Architecture
Layered Architecture에서 각 계층의 역할은 아래와 같다.
- Presentation Layer: 사용자의 요청과 응답을 처리하는 계층
- Business Layer (Application Layer): 비즈니스 로직을 수행하는 계층
- Persistence Layer (Repository Layer): 데이터베이스로부터 데이터를 조회해 보관 및 사용하고 저장하는 계층
우리는 각 계층에 역할에 맞는 단위테스트를 작성하면 된다. Presentation Layer는 요청과 응답을 잘 처리하는지 테스트 하면 되고, Business Layer는 비즈니스 로직이 잘 실행되는지를 염두에 두고 테스트하면 된다. Persistence Layer에서는 데이터를 잘 조회하고 저장하는지 테스트해보면 될 것이다.
1-2. 테스트의 분류
Persistence Layer Test는 테스트의 분류 중 어디에 속할까?
아래는 구글의 테스트 3분류이다. 보편적으로 테스트의 3분류를 API 테스트, 통합 테스트, 단위 테스트로 나눈다. 하지만, 구글에서는 소중대로 구분한 새로운 분류체계를 사용한다.
각 테스트의 정의는 아래와 같다
- 소형 테스트: 단일 서버, 단일 프로세스, 단일 스레드, 디스크 I/O 허용 X, Blocking Call 허용 X
- 중형 테스트: 단일 서버, 멀티 프로세스, 멀티 스레드, H2와 같은 DB 사용
- 대형 테스트: 멀티 서버, End to End 테스트
Persistence Layer Test는 전통적인 관점에서 봤을 때 Repository 컴포넌트만 테스트하는 단위테스트이다. 구글의 테스트 3분류에 따르면 데이터베이스를 달고 테스트를 진행하는 중형테스트라고 볼 수 있다. Persistence Layer Test는 DB를 달고 테스트하기 때문에 소형테스트보다 시간이 오래 걸린다.
반면, Presentation Layer Test와 Business Layer Test는 Test Double(Fake, Stub, Mock 등)을 만들어 단위테스트를 할 수 있기 때문에 스프링부트를 띄우지 않고 단위테스트를 진행한다면 소형테스트라고 볼 수 있다.
2. 데이터베이스 설정
Persistence Layer를 테스트하려면 데이터베이스 설정이 선행되어야 한다. 운영 환경에서 사용하는 MySQL, Oracle, PostgreSQL과 같은 데이터베이스를 사용해 테스트를 진행하거나 In-memory H2 DB를 사용할 수 있다.
2-1. H2 데이터베이스를 사용해야 하는 이유
H2 In-Memory DB를 사용해 테스트하는 것이 더 빠르다. 아래 블로그를 확인해보면 평균적으로 24.18%가 더 빠르다는 사실을 확인해볼 수 있다.
또한 H2 DB를 사용하면 데이터베이스 구성이 훨씬 쉽다. 테스트를 위한 DB를 따로 구성하지 않아도 된다. 로컬에서야 도커를 사용해 테스트 DB를 띄우면 된다지만, CI/CD 환경에서 테스트를 실행하기 위해 따로 연결해 사용할 수 있는 DB가 필요하다. 하지만 H2 In-Memory DB를 사용하면 따로 DB 환경을 구성할 필요가 없다.
하지만 단점도 존재한다. 실제 운영 환경에서는 MySQL이나 PostgreSQL를 사용하고 테스트에서 H2 DB를 사용하면 운영 환경과 괴리로 인해 생기는 문제가 있다. 예를 들어, MySQL에서 사용하는 모든 함수를 H2에서 지원하지는 않기 때문에 만약 실제 환경에서 H2가 지원하지 않는 SQL Function을 사용하면 해당 메소드에 대해 테스트를 실행할 때 H2에는 존재하지 않는 SQL Function이기 때문에 에러가 발생하고 테스트가 실패할 것이다.
이런 단점에도 불구하고 이를 상쇄할만큼 H2 DB가 빠르고 간편하기 때문에 사용하는 것이 나쁘지 않다고 생각한다.
H2 데이터베이스 설정
Spring의 Profile 기능을 사용해 테스트 환경에서는 운영환경과는 다르게 H2의 In-Memory DB를 사용하게 설정할 수 있다.
H2 DB를 In-Memory로 생성하려면 spring.datasource.url에 jdbc:h2:mem 와 같은 형식으로 값을 넣어주면 된다. ddl-auto를 create로 설정해놓으면 Entity만 만들어도 테이블을 자동으로 생성해주기 때문에 따로 스키마에 대한 SQL문을 작성할 필요가 없다.
- src/test/resources/application.yaml
spring:
profiles:
active: test
datasource:
url: jdbc:h2:mem:~/myApplication
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true
h2:
console:
enabled: true
2-2. Testcontainers
위에서 언급한 바와 같이 H2 In-Memory DB를 사용한 테스트는 실제 운영환경과 테스트 환경의 멱등성을 보장할 수 없다. 이 부분에서 아쉬움을 느끼는 개발자라면 Testcontainers라는 기술을 사용해볼 수 있다. 자세한 내용은 아래 글에 담겨 있다.
3. 테스트 환경 구축
Persistence Layer Test는 다른 레이어의 단위 테스트와는 다르게 애플리케이션 환경이 구축되어야 DB와 연결해 테스트를 실행할 수 있다. 이 때 사용되는 것이 @DataJpaTest와 @SpringBootTest이다.
3-1. @DataJpaTest
Data JPA 컴포넌트들(JPA에 의해 자동 생성되는 Proxy 객체)를 테스트할 수 있는 환경을 만들어준다. Data Jpa 컴포넌트만 불러오기 때문에 다른 객체를 가져오려고 하면 에러가 발생한다.
@DataJpaTest 어노테이션에는 기본적으로 @Transactional이 들어가 있기 때문에 모든 테스트가 롤백된다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {...}
3-2. @SpringBootTest
위에서 설명했다시피 @DataJpaTest는 Data Jpa 컴포넌트만 지원하기 때문에 그 외의 다른 컴포넌트를 테스트하려면 다른 방법을 사용해야 한다. 이때 사용할 수 있는 게 @SpringBootTest 어노테이션이다. 예를 들어, QueryDsl을 사용해 만들어진 Repository 구현체는 Data Jpa 컴포넌트가 아니기 때문에 @DataJpaTest 어노테이션으로는 주입 받을 수 없는 대신 @SpringBootTest를 사용할 수 있다.
@SpringBootTest는 모든 빈을 스캔해 애플리케이션 컨텍스트를 생성하는 등 통합 테스트를 위한 환경을 만들어준다. 모든 빈을 다 가져오기 때문에 @DataJpaTest 보다 느리다. 하지만 @SpringBootTest를 Persistence Layer 테스트에서 쓰면 좋은 이유가 있는데 3-3을 확인해보자.
3-3. 테스트 환경을 통합해서 더 빠른 테스트 만들기
만약 테스트 환경을 통합하지 않았다면 전체 테스트를 돌렸을 때 Test Results에서 Spring Boot가 여러 번 실행된 것을 확인할 수 있다.(전체 테스트를 돌린 후, "Spring Boot"라고 검색해보자.)
서비스 테스트에서 서로 각기 다른 MockBean을 가지고 있거나, Persistence Layer에서 @DataJpa와 @SpringBootTest 또는 커스텀 환경을 섞어 썼다면 새로운 환경의 Spring Boot가 더 많이 실행됐을 것이다. 스프링 테스트에서는 컨테이너 환경(의존성 설정)이 다르다고 판단하면 Spring Boot를 새롭게 실행하기 때문이다.
따라서 테스트 속도를 개선하고 싶다면 Persistence Layer에서도 @SpringBootTest를 사용하고 Service 테스트와 환경을 통합할 필요가 있다. (Presentation Layer의 테스트는 Service만 모킹해서 요청과 응답을 테스트하는 단위테스트 환경을 구축하기 때문에 성격이 약간 다르다. 그렇기 때문에 Controller의 경우는 따로 환경을 구성해주는 것이 좋아 보인다.)
코드예시
아래와 같이 통합 테스트 환경을 abstract class로 생성해준다. Service 통합테스트 환경과 Persistence Layer의 환경을 합칠 것이기 때문에 Service Test 단에 있는 모든 Mock들을 IntegrationTestSupport 추상 클래스로 가지고 온다. 이 때 Mocking 객체들을 protected로 선언해줘야 각 테스트에서 사용할 수 있다.
@ActiveProfiles("test")
@SpringBootTest
public abstract class IntegrationTestSupport {
@MockBean
protected MessageSendClient messageSendClient;
@MockBean
protected MailSendClient mailSendClient;
}
이제 Service 테스트와 Persistence Layer 테스트 클래스에서 추상클래스를 상속받아 테스트를 구현하자. 그리고 전체 테스트를 돌려보면 테스트 환경이 통합되어 스프링 부트가 실행 횟수가 줄어들고, 테스트 속도도 빨라진 것을 확인할 수 있다.
class OrderServiceIntegrationTest extends IntegrationTestSupport {
// Mocking이 IntegrationTestSupport 쪽으로 다 넘어가야 한다.
...
}
class OrderRepository extends IntegrationTestSupport {
...
}
4. 테스트를 위한 데이터 준비
조회 로직과 업데이트 로직을 테스트하려면 DB에 미리 데이터를 넣어야 하는데 이 때 사용할 수 있는 방법 몇 가지와 각각의 장단점에 대해 소개하려고 한다.
4-1. given 절에서 작성, 테스트는 문서다.
각 테스트 케이스를 실행할 때마다 데이터를 넣는 방식이다. 각 테스트 케이스마다 필요한 데이터만 삽입할 수 있지만 중복되는 데이터들이 모든 케이스마다 작성되기 때문에 테스트 코드가 길어질 수 있다는 단점이 있다.
이런 단점에도 불구하고, given 절에서 데이터들이 들어가야 하는 이유가 있다. 문서로서의 역할을 하는 테스트 코드를 작성하기 위해서이다. given에 테스트에 필요한 모든 데이터가 들어있어야 나를 포함해 팀원들이 이해하기 훨씬 편하다. 테스트 케이스의 상황이 한눈에 들어오기 때문이다.
작성자 입장에서는 given에 모든 데이터를 작성하는 게 귀찮고 불편한데다가 시간도 오래 걸린다. 하지만 그 귀찮음 때문에 Fixture들을 파편화하기 시작하고 그것이 계속 이어져 Fixture를 관리되기 힘든 수준까지 이르면 문서로서의 역할을 하는 테스트 코드 작성이 어려워진다.
따라서 문서로서의 테스트를 만들기 위해 아래의 원칙을 따르는 것이 좋다.
- 모든 Fixture는 given에 작성하자.
- 생성 로직을 사용하지 말고 생성자나 빌더를 사용해 객체를 생성하자.
- Fixture 함수를 분리하고 싶다면 한 곳에 모아서 사용하기 보다는 각 테스트 클래스에 필요한 함수를 두자. 각 테스트 케이스마다 전달해야 하는 파라미터가 달라질 수 있기 때문이다.
테스트 코드에 레포지토리로 데이터를 삽입하는 코드가 있다면, @Transactional을 사용해 데이터를 롤백해줄 수 있다.
@SpringBootTest
class OrderRepositoryTest {
@Autowired
OrderRepository orderRepository;
@Test
@Transactional
public void orderTest() {
// given
Order order1 = createOrderFixture(...);
Order order2 = createOrderFixture(...);
Order order3 = createOrderFixture(...);
orderRepository.saveAll(List.of(order1, order2, order3));
// when
...
// then
...
}
...
private Order createOrderFixuture(...) {
...
}
}
4-2. @BeforeEach나 @Before를 사용한 setup
케이스마다 데이터를 넣어주는 것이 현실적으로 불가능할 때 사용할 수 있는 방식이다. 공통적인 데이터를 넣어주고, 특정 데이터가 필요할 때만 3-1의 방식을 사용해 데이터를 넣어주는 방식으로 사용될 수 있다.
하지만 @BeforeEach를 사용해 모든 메소드마다 데이터 삽입을 실행하면 어떤 케이스에서는 필요 없는 데이터가 들어갈 수 있기 때문에 테스트 성능에 영향을 줄 수 있다. 또한 특정 케이스를 위해 setup에 있는 Fixture 변경했을 때 다른 케이스들에 영향을 줄 수 있다는 단점도 존재한다.
@AfterEach나 @After를 통해 데이터를 지워주는 로직을 넣어줘야 다른 테스트에 영향을 주지 않을 수 있다. 이 때 deleteAll()은 findAll()로 찾은 리스트를 순회하며 하나씩 데이터를 삭제하기 때문에 속도가 느릴 수 있다. 한 번의 쿼리로 모든 데이터를 지우는 것이 훨씬 빠르기 때문에 deleteAllInBatch()를 사용하는 것을 추천한다.
@SpringBootTest
class OrderRepositoryTest {
@Autowired
OrderRepository orderRepository;
@BeforeEach()
void setup() {
Order order1 = createOrderFixture(...);
Order order2 = createOrderFixture(...);
Order order3 = createOrderFixture(...);
orderRepository.saveAll(List.of(order1, order2, order3));
}
@AfterEach
void tearDown() {
orderRepository.deleteAllInBatch();
}
@Test
public void orderTest() {
// given
...
// when
...
// then
...
}
...
}
4-3. @Sql이나 @SqlGroup과 SQL 쿼리 파일을 사용한 데이터 삽입
3-2와 마찬가지로 given에 모든 데이터를 작성할 여력이 없을 때 사용할 수 있는 방법이다. value에 원하는 SQL 파일(이 때 루트 디렉토리는 resources)을 넣어주고 executionPhase로 원하는 시점을 지정할 수 있다. 지정할 수 있는 시점은 org.springframework.test.context.jdbc.Sql 어노테이션에 ExecutionPhase Enum으로 정의되어 있는데 아래와 같다.
- BEFORE_TEST_CLASS: 클래스 시작 전 동작. @Before와 같은 역할
- BEFORE_TEST_MTEHOD: 메소드 시작 전 동작. @BeforeEach와 같은 역할
- AFTER_TEST_CLASS: 클래스 종료 후 동작. @After와 같은 역할
- AFTER_TEST_MTEHOD: 메소드 종료 후 동작. @AfterEach와 같은 역할
만약 관리자 페이지와 쇼핑몰 페이지 2개를 각 모듈로 나눠 구현해 운영한다고 가정해보자. 관리자 페이지에서는 상품을 등록해야 하기 때문에 ProductRepostory에 save가 필요하다. 하지만 실제 유저에게 서비스하는 쇼핑몰에서는 상품을 저장할 필요가 없기 때문에 ProductRepository에 save나 saveAll이 필요하지 않다. 이런 경우 테스트만을 위해 repository의 save를 구현해야 하는 것이 싫을 때 사용하면 좋은 방식이 @Sql과 @SqlGroup이다.
다른 연관관계를 같이 테스트하려면 연관된 다른 레포지토리를 주입받아 save를 실행해줬어야 하지만, 이 방식은 데이터를 넣기 위해 레포지토리의 save를 사용하지 않기 때문에 굳이 다른 레포지토리를 주입받지 않아도 된다는 장점이 있다.
하지만 3-2와 마찬가지로 테스트 속도 이슈와 Fixture 수정 시 테스트 케이스에 대한 영향 등의 문제가 있다. 또한, SQL문에서 각 row의 id 값을 지정해서 데이터를 넣어 놓고 테스트 코드 단에서 repository save를 실행하려고 하면 채번을 "1"부터 하기 때문에 Unique index or primary key violation 에러가 발생할 수 있다는 단점이 있다.
@SqlGroup을 사용해 데이터를 삽입하는 sql문과 데이터를 지워주는 sql문을 같이 작성해줘야 다른 테스트에 영향을 주지 않을 수 있다. 예시는 아래와 같다.
@SpringBootTest
@SqlGroup({
@Sql(value = "/sql/order-repository-test.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD),
})
class OrderRepositoryTest {
@Autowired
OrderRepository orderRepository;
@Test
public void orderTest() {
// given
...
// when
...
// then
...
}
...
}
- test/resources/sql/order-repository-test.sql
insert into `member`(...)
values (...), (...), (...);
insert into `order`(...)
values (...), (...), (...);
insert into `order_line`(...)
value (...), (...), (...);
- test/resources/sql/delete-all-data.sql
delete `order_line` where 1;
delete `order` where 1;
delete `member` where 1;
5. Persistence Layer는 어떻게 테스트해야할까..?
5-1. Repository의 의존성 역전
테스트를 할 때 구현(implementation)이 아니라 설계(interface)에 맞춰야 한다
당신의 TDD가 항상 실패하는 이유, 이규원 - 2018OKKYCON
나는 인터페이스를 만들고 인터페이스를 구현한 구현체를 따로 만든다. 그리고 인터페이스를 대상으로 테스트하는 것을 좋아한다.
만약 어떤 JPA와 QueryDSL을 사용하는 프로젝트를 진행하고 있다고 해보자. 미래에 JPA를 뛰어넘을 차세대 기술이 생겨 진행하고 있는 프로젝트의 기술 스택을 다른 ORM이나 QueryMapper로 변경하게 되어 마이그레이션을 하게 되었다. 그러면 구현체가 바뀌게 될 것이다. 만약 구현체를 대상으로 테스트를 작성했다면 테스트를 전체적으로 변경해야하는 불상사가 생길 수 있다.
구현체가 바뀌더라도 사용하고 있는 인터페이스의 기능이 바뀌면 안되기 때문에 구현체가 아닌 인터페이스를 중심으로 기능이 잘 돌아가는지를 확인할 수 있는 게 중요하다고 생각한다. 그래서 테스트를 작성할 때 인터페이스를 대상으로 테스트를 작성하는 것이 맞다고 생각한다.
구조
OrderRepository를 중심으로 관계를 살펴보자.
- OrderRepositoryImpl은 OrderRepository를 구현한 구현체이다. DataJpa 컴포넌트나 QueryDSL, MyBatis, JdbcTemplate 등으로 구현된 구현체를 주입받아 인터페이스를 구현한다.
- OrderService는 OrderRepository에 의존하는데 실제로 OrderService는 OrderRepositoryImpl 구현체를 주입받게 된다.
여기서 Persistence Layer Test를 진행할 때, 테스트하는 대상은 구현체가 아닌 OrderRepository 인터페이스가 되어야 한다.
코드 예시
- 아래와 같이 Repository 인터페이스가 있다.
public interface OrderRepository {
Order save(Order order);
List<Order> search(OrderQueryCondition condition);
}
- 그리고 OrderRepository를 구현한 구현체(OrderRepositoryImpl)는 위의 인터페이스를 구현한다. 이 객체는 QueryDsl, DataJpa, MyBatis, Jdbc 등으로 만든 구현체를 주입받아 로직을 구현한다.
@Repository
@RequiredConstructor
public class OrderRepositoryImpl implements OrderRepository {
private final OrderQueryRepository orderQueryRepository;
private final OrderJpaRepository orderJpaRepository;
@Override
public Order save(Order order) {
return orderJpaRepository.save(order);
}
@Override
public List<Order> search(OrderQueryCondition condition) {
return orderQueryRepository.search(condition);
}
}
@Repository
@RequiredConstructor
public class OrderQueryRepository {
private final JPAQueryFactory queryFactory;
public List<Order> search(OrderQueryCondition condition) {...}
}
public interface OrderJpaRepository extends JpaRepository<Order, OrderNo> {
...
}
- 테스트는 구현체를 타겟으로 만들지 않고 인터페이스를 타겟으로 만들고 실제 테스트에서 레포지토리 인터페이스를 주입받는다. 결국 제일 중요한 것은 인터페이스가 잘 돌아가는 것이기 때문이다.
- 여기서 주의할 점이 있는데 이렇게 만들어진 인터페이스는 Data Jpa의 컴포넌트가 아니기 때문에 @DataJpaTest를 사용하면 인터페이스를 주입받을 수 없다. @SpringBootTest를 사용해야 테스트 환경이 정상적으로 구성된다.
@SpringBootTest
class OrderRepositoryTest {
@AutoWired
OrderRepository orderRepository;
...
}
5-2. 과하지 않게
위에서 설명했다시피 Persistence Layer 테스트는 다른 레이어의 단위테스트보다 무겁다. 따라서 정말 필요한 메소드만 테스트하는 것이 좋다. 예를 들어 복잡한 동적 쿼리가 있는 조회 로직이라면 테스트해보는 것이 좋을 것이다.
하지만, 단순 조회(findAll, findById)와 저장 로직만 있는 Persistence Layer를 굳이 테스트할 필요는 없다고 생각한다. 그 시간에 차라리 비즈니스 로직 통합테스트를 만드는 것이 낫다.
5-3. @Transactional
테스트에서 @Transactional을 사용하는 것에 대해 많은 개발자들의 생각이 갈리는 것으로 알고 있다.
Test에 @Transactional이 있으면 트랜잭션이 Test로 묶여버리기 때문에 발생하는 여러 문제들로 인해 통합테스트에서 @Transactional을 사용하는 것에 대해서는 굉장히 조심스러운 입장이다. 비즈니스 로직은 비동기, 트랜잭션 전파 레벨, 스프링 이벤트 등으로 인해 테스트 단의 @Transactional에 영향을 받을 가능성이 크기 때문이다.
하지만, Persistence Layer에서는 @Transactional을 사용하는 것은 괜찮다고 생각한다. Persistence Layer가 테스트 단의 @Transacional에 의해 트랜잭션이 묶여 발생할 수 있는 문제를 따져보면 비즈니스 로직을 통합테스트를 실행할 때와는 다르게 크게 신경쓰일만한 것이 없다고 생각한다. 트랜잭션 전파 레벨을 레포지토리 단에서 제어하거나 레포지토리 메소드를 비동기로 처리하는 것이 아니라면 큰 문제가 없어보인다. 그래도 @Transactional의 사이드이펙트를 잘 알고 사용하면 좋을 것이다.
만약, @Transactional을 사용하기 꺼려진다면 @AfterEach에서 레포지토리의 deleteAllInBatch() 메소드를 사용하는 것을 권장한다.
6. 결론
사실 Persistence Layer의 테스트보다 더 중요한 것은 비즈니스 로직의 소형 테스트(혹은 단위테스트)라고 생각한다. 중형 테스트가 많아지면 테스트 속도가 느려질 수 있고, 이렇게 느려진 테스트는 CI/CD와 리팩토링 등 프로젝트 전반에 큰 영향을 줄 수 있다. 너무 오랜 시간이 걸리면 개발자들이 테스트를 돌려보기 무서워지는 시점이 올 수도 있다.
하지만 레포지토리의 메소드가 복잡한 동적 쿼리를 가지고 있어 비즈니스 로직에 중대한 영향을 미친다면 충분히 테스트해볼 만한 가치가 있다. 아무리 속도가 느리더라도 필요할 때 테스트가 붙어야 한다.
몇몇 부분은 개인적인 생각일 뿐이니, 이유만 타당하다면 본인이 맞다고 생각하는 방식대로 테스트를 작성하길 바란다.
관련 글
그 외 참고자료
- 스프링부트 테스트를 위한 의존성과 어노테이션 - 망나니개발자님 블로그
- Practical Testing: 실용적인 테스트 가이드 - 인프런 강의
- Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트 - 인프런 강의
- 2018OKKYCON