@DataJpaTest로 쿼리를 검증하고 @WebMvcTest로 API를 검증했다. 하지만 레이어를 관통하는 문제는 슬라이스로 잡을 수 없다. Service가 Repository를 호출하고, 그 결과를 DTO로 변환해서 반환하는 전체 흐름이 맞물려 돌아가는지는 통합 테스트에서 확인해야 한다.
@SpringBootTest가 해주는 일
- 전체 Spring 컨텍스트를 띄운다. 실제 앱과 동일한 빈 구성이다.
- Controller, Service, Repository, DB 전부 실제로 동작한다.
@MockBean이 필요 없다. 진짜 빈끼리 연동한다.
hasNext 등 end
초록 영역에서 실제 DB에 데이터를 넣고, 파란 영역에서 Service → Repository → DB → Profile 조회까지 진짜 레이어 간 흐름이 실행된다. 슬라이스에서는 각 레이어를 분리해서 테스트했지만, 여기서는 합쳐서 동작하는 것을 본다.
기본 셋업
@SpringBootTest
@Transactional
class MessageIntegrationTest {
@Autowired
private MessageService messageService;
@Autowired
private UserRepository userRepository;
@Autowired
private ProfileRepository profileRepository;
@Autowired
private MessageRepository messageRepository;
}
@Transactional이 핵심이다. 이게 테스트 메서드를 하나의 트랜잭션으로 감싸서 끝나면 자동 롤백한다.
@Transactional 없이 테스트를 돌리면 데이터가 DB에 남는다. 다른 테스트가 이 데이터를 만나서 예상치 못한 실패가 발생한다. 특히 CI에서 테스트 순서가 랜덤이면 "로컬에서 되는데 CI에서 안 된다"는 상황이 생긴다.
전체 흐름 테스트
데이터 준비
통합 테스트에서는 TestEntityManager 대신 실제 Repository로 데이터를 넣는다. 실제 앱이 데이터를 생성하는 방식과 동일하게 가야 진짜 흐름을 테스트하는 것이다.
private User createAndSaveUser(String name) {
User user = User.create(name + "@test.com", "password123", name);
User saved = userRepository.save(user);
profileRepository.save(Profile.create(saved));
return saved;
}
User와 Profile을 함께 만드는 헬퍼를 두면 테스트마다 반복 코드가 줄어든다.
DM 조회 전체 흐름
@Test
void DM_조회_전체_흐름을_검증한다() {
// given
User sender = createAndSaveUser("sender");
User receiver = createAndSaveUser("receiver");
for (int i = 0; i < 5; i++) {
messageRepository.save(
Message.create(sender, receiver, "msg" + i));
}
MessageGetRequest request = new MessageGetRequest(
sender.getId(), null, null, 3);
// when
MessageGetResponse response = messageService.getByCursor(request);
// then
assertThat(response.data()).hasSize(3);
assertThat(response.hasNext()).isTrue();
assertThat(response.data().get(0).sender().userId())
.isEqualTo(sender.getId());
assertThat(response.data().get(0).sender().name())
.isEqualTo("sender");
}
이 테스트가 검증하는 것들을 풀어보면 이렇다.
- Repository의 커서 쿼리가 올바른 데이터를 반환하는지
- Service의
limit + 1트릭과hasNext판정이 맞는지 UserSummary변환에서 Profile 조회가 정상 동작하는지- 최종 DTO의 필드가 올바르게 채워지는지
슬라이스 테스트 하나로는 이 전체를 볼 수 없다.
@Transactional의 함정
@Transactional이 테스트를 편리하게 만들어주지만, 실제 동작과 다른 결과를 줄 수 있는 케이스가 있다.
Lazy Loading이 항상 성공한다
message.getSender()"] H --> I["LazyInitializationException"] end style D fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style I fill:#FFE6E6,stroke:#F44336,stroke-width:2px style Test fill:#f0f8f0,stroke:#4CAF50 style Prod fill:#fff0f0,stroke:#F44336
왼쪽(테스트)에서는 @Transactional이 테스트 전체를 감싸니까 Lazy Loading이 어디서든 성공한다. 하지만 오른쪽(운영)에서는 Service의 트랜잭션이 끝난 뒤 Controller에서 Lazy 접근하면 LazyInitializationException이 터진다.
이런 문제를 잡고 싶으면 @Transactional 없이 테스트하거나, Service 레이어에서 DTO로 변환을 완료하는 구조를 확인해야 한다.
서비스에서 @Transactional(propagation = REQUIRES_NEW)를 쓰는 경우, 테스트의 @Transactional과 별개의 트랜잭션이 생긴다. 이 트랜잭션은 테스트가 끝나도 롤백되지 않는다. 데이터가 DB에 남을 수 있다.
N+1 쿼리 검증
통합 테스트에서만 잡을 수 있는 대표적인 문제가 N+1 쿼리다. 쿼리 로그를 켜고 카운트하면 된다.
application-test.yml 설정
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
쿼리 수 검증
로그를 눈으로 확인하는 것도 방법이지만, 정량적으로 검증하고 싶으면 CountQueryInterceptor 같은 유틸을 만들 수 있다. 가장 간단한 방법은 콘솔 로그에서 SELECT 수를 세는 것이다.
@Test
void 메시지_조회_시_N_plus_1이_발생하지_않는다() {
// given
User sender = createAndSaveUser("sender");
User receiver = createAndSaveUser("receiver");
for (int i = 0; i < 10; i++) {
messageRepository.save(
Message.create(sender, receiver, "msg" + i));
}
MessageGetRequest request = new MessageGetRequest(
sender.getId(), null, null, 10);
// when
MessageGetResponse response = messageService.getByCursor(request);
// then
assertThat(response.data()).hasSize(10);
// 콘솔에서 SELECT 쿼리 수를 확인한다.
// 메시지 1회 + 프로필 2회(sender, receiver) = 총 3회여야 한다.
// 메시지 10개마다 프로필을 조회하면 N+1이다.
}
눈으로 확인이 불편하면 hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS 설정이나 서드파티 라이브러리(spring-data-jpa-query-counter 등)를 활용할 수 있다.
슬라이스 vs 통합, 무엇을 언제
기본 원칙은 가장 좁은 범위부터다. 쿼리만 검증하면 되면 @DataJpaTest, HTTP 레이어만 보면 @WebMvcTest를 쓴다. @SpringBootTest는 전체 흐름이 맞물려야만 잡히는 문제가 있을 때 쓴다.
통합 테스트가 많아지면 CI가 느려진다. 핵심 시나리오만 통합으로 커버하고, 나머지는 슬라이스와 유닛으로 충분하다.
자주 하는 실수
@Transactional 의존으로 LazyInitializationException 놓치기테스트에서는 트랜잭션이 끝까지 살아있으니 Lazy Loading이 항상 성공한다. 운영에서 Controller나 비동기 콜백에서 Lazy 접근하면 터진다. Service에서 DTO 변환을 완료하는 습관이 중요하다.
[!DANGER] 통합 테스트만으로 모든 것을 커버하려는 시도
@SpringBootTest 하나에 모든 케이스를 넣으면 테스트가 느리고 실패 원인을 특정하기 어렵다. 쿼리 조건 분기 5가지를 전부 통합 테스트로 검증할 필요 없다. 그건 @DataJpaTest가 빠르게 해준다.
[!DANGER] 테스트 데이터 간섭
@Transactional을 빼먹거나 REQUIRES_NEW 전파가 섞이면 테스트 데이터가 롤백되지 않는다. 다음 테스트가 이전 데이터를 만나서 깨진다. @AfterEach에서 직접 정리하거나, @Transactional이 제대로 걸려있는지 확인한다.
면접 Q&A
@SpringBootTest와 @DataJpaTest의 차이는?@DataJpaTest는 JPA 관련 빈만 로드하는 슬라이스 테스트다. 빠르고 Repository 쿼리 검증에 적합하다. @SpringBootTest는 전체 컨텍스트를 올리는 통합 테스트로, 레이어 간 연동을 검증한다. 느리지만 실제 동작에 가장 가깝다.
[!QUESTION] @SpringBootTest에 @Transactional을 붙이면 어떤 문제가 생길 수 있는가?
함정 질문이다. @Transactional이 테스트 전체를 감싸기 때문에 Lazy Loading이 항상 성공한다. 운영에서는 트랜잭션 경계 밖에서 Lazy 접근하면 LazyInitializationException이 터지는데, 테스트에서는 이 버그를 잡지 못한다. 또한 REQUIRES_NEW 전파를 쓰는 서비스의 커밋은 테스트 롤백 대상이 아니다.
[!QUESTION] 슬라이스 테스트에서 @MockBean을 쓰는 이유는?
@WebMvcTest는 컨트롤러 빈만 올린다. 컨트롤러가 의존하는 서비스 빈이 컨텍스트에 없기 때문에 @MockBean으로 가짜 빈을 등록해야 한다. @Mock은 Spring 컨텍스트와 무관하므로 컨트롤러에 주입되지 않는다.
[!QUESTION] N+1 문제를 테스트로 어떻게 잡는가?
슬라이스 테스트에서는 잡기 어렵다. @SpringBootTest에서 show-sql: true로 쿼리 로그를 켜고, 예상 쿼리 수와 실제 실행된 쿼리 수를 비교한다. 메시지 10건에 프로필 쿼리가 20번 나가면 N+1이다.