Spring 테스트 (4/4)

이전 편: [Spring 테스트] 3. @WebMvcTest로 API 검증하기

이 문서가 시리즈의 마지막 편입니다.

@DataJpaTest로 쿼리를 검증하고 @WebMvcTest로 API를 검증했다. 하지만 레이어를 관통하는 문제는 슬라이스로 잡을 수 없다. Service가 Repository를 호출하고, 그 결과를 DTO로 변환해서 반환하는 전체 흐름이 맞물려 돌아가는지는 통합 테스트에서 확인해야 한다.

@SpringBootTest가 해주는 일

  • 전체 Spring 컨텍스트를 띄운다. 실제 앱과 동일한 빈 구성이다.
  • Controller, Service, Repository, DB 전부 실제로 동작한다.
  • @MockBean이 필요 없다. 진짜 빈끼리 연동한다.
sequenceDiagram autonumber participant Test as 테스트 코드 participant Svc as MessageService participant Repo as MessageRepository participant DB as 내장 H2 rect rgb(232, 248, 232) Note over Test, DB: 데이터 준비 Test->>DB: userRepository.save() Test->>DB: profileRepository.save() Test->>DB: messageRepository.save() end rect rgb(240, 248, 255) Note over Test, DB: 실제 흐름 실행 Test->>Svc: getByCursor(request) Svc->>Repo: getByCursor(request) Repo->>DB: SELECT 쿼리 DB-->>Repo: 결과 Repo-->>Svc: List Svc->>DB: profileRepository.findByUserId() Note right of Svc: UserSummary 변환 Svc-->>Test: MessageGetResponse end rect rgb(232, 248, 232) Note over Test: 결과 검증 Note right of Test: data 크기, sender 정보,
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이 항상 성공한다

flowchart TD subgraph Test ["테스트 (@Transactional)"] direction TB A["messageRepository.save()"] --> B["messageService.getByCursor()"] B --> C["message.getSender()"] C --> D["Lazy 프록시 초기화 성공"] end subgraph Prod ["실제 운영"] direction TB E["Controller 호출"] --> F["Service (트랜잭션)"] F --> G["트랜잭션 종료"] G --> H["Controller에서
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로 변환을 완료하는 구조를 확인해야 한다.

REQUIRES_NEW 전파

서비스에서 @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 통합, 무엇을 언제

flowchart TD A{뭘 검증하는가?} A -->|"쿼리 자체"| B["@DataJpaTest"] A -->|"HTTP 요청/응답"| C["@WebMvcTest"] A -->|"레이어 간 연동"| D["@SpringBootTest"] B --> E(["빠름 ✓"]) C --> F(["빠름 ✓"]) D --> G(["느림, 필요할 때만"]) style A fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style B fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style C fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style D fill:#E8F4F8,stroke:#2196F3,stroke-width:2px style E fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style F fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style G fill:#FFF3E0,stroke:#FF9800,stroke-width:2px

기본 원칙은 가장 좁은 범위부터다. 쿼리만 검증하면 되면 @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이다.