레이어별 테스트 전략은 어떻게 나누어야 하는가?

 

"이 테스트가 지금 뭘 검증하는 거였지?"

이 글을 쓰게 된 계기

제가 작성한 테스트 파일들을 돌아보다 이상한 장면을 발견했습니다.

서비스 테스트인데 @DataJpaTest가 붙어 있고, 실제 DB에 insert가 들어가고 있었습니다.
컨트롤러 테스트에는 비즈니스 분기 조건이 잔뜩 들어가 있었습니다.
통합 테스트를 열어봤더니 verify(repository).save(any()) 같은 Mock 검증이 중간에 섞여 있었습니다.

문제는 테스트가 깨졌을 때였습니다.
서비스 테스트가 깨졌는데 원인이 JPA 매핑 문제였습니다.
컨트롤러 테스트가 깨졌는데 원인이 서비스의 분기 로직이었습니다.
어느 레이어의 문제인지 판정이 안 됐습니다.

그때 깨달았습니다. 테스트가 뭘 검증하는지 모르면, 실패해도 원인을 찾을 수 없었습니다.


처음에는 다 섞여 있었습니다

초기에 제가 쓴 테스트들은 대체로 이런 모습이었습니다.

  • 서비스 테스트가 JPA dirty checking까지 검증합니다.
  • 컨트롤러 테스트가 할인 계산 분기까지 검증합니다.
  • 통합 테스트가 JSON 응답 필드 하나하나까지 확인합니다.

각각은 "더 많이 검증한다"는 이유로 괜찮아 보였습니다.
그런데 테스트가 쌓이니까 이상한 일이 벌어졌습니다.

엔티티 필드 하나를 바꿨는데 컨트롤러 테스트 10개가 깨졌습니다.
서비스의 분기 하나를 수정했는데 통합 테스트 5개가 같이 깨졌습니다.
"이게 진짜 문제인가?"를 판정하는 데 시간이 더 들었습니다.

한 테스트가 모든 걸 검증하려 하면, 그 테스트는 아무것도 제대로 검증하지 못합니다.


테스트 피라미드, 체감으로 다시 읽기

교과서는 보통 단위 → 통합 → E2E 순서로 설명합니다.
근데 저는 그 순서로 피라미드를 이해하지 못했습니다.
제가 체감한 순서는 달랐습니다.

  1. 단위 테스트가 많아야 변경이 빨라집니다. 분기 하나 바꿨을 때 초 단위로 피드백이 옵니다.
  2. 통합 테스트가 핵심만 있어야 CI가 견딥니다. 모든 플로우를 통합으로 돌리면 빌드가 10분을 넘깁니다.
  3. 인수 테스트는 진짜 엔드투엔드만. 유저 시나리오 그대로. 나머지는 위에서 이미 다 걸러졌어야 합니다.

피라미드는 개수 분포가 아닙니다. 책임 분포입니다.


레이어별로 정리한 것들

레이어마다 "답해야 할 질문"이 다르다는 걸 받아들이고 나서, 테스트도 레이어별로 정리됐습니다.

도메인은 가장 먼저 정리되는 레이어입니다

Entity와 Policy는 DB도 Spring도 필요 없습니다.
생성자나 팩토리 메서드로 시작 상태를 만들고, 메서드를 호출하고, 상태를 확인합니다. 그게 전부입니다.

@Test
void 주문_금액이_10만원_이상이면_VIP_등급으로_변경된다() {
    Member member = Member.create("user@test.com", "tester");
    Order order = Order.of(member, 150_000);

    member.applyRankPolicy(order);

    assertThat(member.getRank()).isEqualTo(Rank.VIP);
}

한 가지 조심할 게 있습니다.
테스트용 객체를 만드는 코드가 20줄이 넘어간다면, 도메인 설계가 의심스럽다는 신호입니다.
Fixture를 복잡하게 만드는 방향이 아니라, 생성 자체가 쉬워지도록 도메인을 다듬어야 합니다.

Policy는 (조건 → 결과) 매트릭스가 그대로 테스트가 됩니다.
조건별로 한 케이스씩, 결과를 단문으로 검증합니다.

도메인 테스트가 가벼워야, 그 위에 쌓을 테스트가 가벼워집니다.

리포지토리는 JPA가 의도대로 돌아가는지만 봅니다

제가 사용하는 메타 어노테이션은 이렇게 구성되어 있습니다.

@DataJpaTest
@ActiveProfiles("test")
@Import({QueryDslConfig.class, JpaAuditingTestConfig.class})
public @interface RepositoryJpaTest { }

실제 테스트는 이렇게 생겼습니다.

@RepositoryJpaTest
class PostRepositoryTest {

    @Autowired
    private PostRepository postRepository;

    @Autowired
    private TestEntityManager em;

    @Test
    void 특정_작성자의_게시글을_최신순으로_조회한다() {
        Member author = em.persist(Member.create("user@test.com", "tester"));
        em.persist(Post.of(author, "old title", "body"));
        em.persist(Post.of(author, "new title", "body"));
        em.flush();
        em.clear();

        List<Post> result = postRepository.findByAuthorOrderByCreatedAtDesc(author);

        assertThat(result).extracting(Post::getTitle)
                .containsExactly("new title", "old title");
    }
}

여기서 검증하는 건 다음 네 가지뿐입니다.

  • 엔티티 매핑이 의도대로 붙는가 (컬럼 타입, nullable, 연관관계)
  • 커스텀 쿼리가 예상한 결과를 돌려주는가 (QueryDSL, @Query)
  • 카운트/집계 쿼리의 결과가 맞는가
  • cascade / orphanRemoval이 기대한 대로 동작하는가

반대로 여기서 검증하지 않는 것들이 중요합니다.

  • 서비스의 if/else 분기는 검증하지 않습니다.
  • 컨트롤러의 응답 포맷은 검증하지 않습니다.
  • 트랜잭션 전파 동작은 여기서 보지 않습니다.

리포지토리 테스트는 JPA가 한 일만 검증합니다. 서비스 분기를 집어넣는 순간 책임이 섞입니다.

서비스는 분기와 플로우만 봅니다

서비스 단위 테스트는 가장 빠르고 가장 많아야 하는 영역입니다.

@ExtendWith(MockitoExtension.class)
@Tag("unit")
public @interface UnitTest { }

DB도, HTTP도, Security도 없습니다.
협력자는 Mock 또는 Stub으로 대체합니다.

@UnitTest
class SignupServiceTest {

    @Mock
    private MemberRepository memberRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private SignupService signupService;

    @Test
    void 이미_가입된_이메일이면_예외를_던진다() {
        given(memberRepository.existsByEmail("user@test.com")).willReturn(true);

        assertThatThrownBy(() -> signupService.signup(
                new SignupRequest("user@test.com", "password", "tester")))
                .isInstanceOf(DuplicatedEmailException.class);

        verify(memberRepository, never()).save(any());
    }

    @Test
    void 신규_이메일이면_비밀번호를_인코딩해_저장한다() {
        given(memberRepository.existsByEmail("user@test.com")).willReturn(false);
        given(passwordEncoder.encode("password")).willReturn("encoded");
        given(memberRepository.save(any(Member.class)))
                .willAnswer(inv -> inv.getArgument(0));

        signupService.signup(new SignupRequest("user@test.com", "password", "tester"));

        ArgumentCaptor<Member> captor = ArgumentCaptor.forClass(Member.class);
        verify(memberRepository).save(captor.capture());
        assertThat(captor.getValue().getPassword()).isEqualTo("encoded");
    }
}

여기서 검증하는 건 세 가지입니다.

  • if/else 분기가 맞게 갈라지는가
  • 예외가 의도한 조건에서 의도한 타입으로 던져지는가
  • 협력 순서가 비즈니스 요구사항과 맞는가

여기서 검증하지 않는 것은 더 중요합니다.

  • JPA 영속성 (그건 리포지토리 테스트에서 확인합니다)
  • HTTP 응답 형식 (그건 컨트롤러 테스트에서 확인합니다)
  • Spring Security 필터 체인 (그건 통합 테스트에서 확인합니다)

서비스 테스트에 DB가 들어오는 순간, 그건 통합 테스트입니다. 분리해야 합니다.

컨트롤러는 HTTP 계약만 봅니다

@WebMvcTest
@AutoConfigureMockMvc(addFilters = false)
@Import(TestSecurityConfig.class)
public @interface ControllerWebMvcTest { }

서비스는 @MockitoBean으로 고정하고, 응답 스텁만 제공합니다.

@ControllerWebMvcTest(SignupController.class)
class SignupControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private SignupService signupService;

    @Test
    void 정상_요청이면_201과_생성된_유저ID를_돌려준다() throws Exception {
        given(signupService.signup(any(SignupRequest.class)))
                .willReturn(new SignupResponse(1L));

        mockMvc.perform(post("/auth/signup")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(
                                new SignupRequest("user@test.com", "password", "tester"))))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.data.userId").value(1L));
    }

    @Test
    void 이메일_형식이_아니면_400을_돌려준다() throws Exception {
        mockMvc.perform(post("/auth/signup")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(
                                new SignupRequest("not-an-email", "password", "tester"))))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("email"));
    }
}

여기서 검증하는 건 이런 것들입니다.

  • URL 매핑이 맞게 걸려 있는가
  • 요청 스키마가 맞게 deserialize 되는가
  • 응답 스키마가 맞게 serialize 되는가
  • @Valid가 제대로 걸리는가
  • 에러 응답 포맷이 규약에 맞는가

여기서 비즈니스 규칙은 검증하지 않습니다.
"할인율 계산이 맞는가"는 컨트롤러 테스트의 관심사가 아닙니다.
서비스가 돌려준 값을 HTTP 응답으로 잘 내보내는지만 봅니다.

컨트롤러 테스트는 "HTTP 껍데기가 잘 붙어있는지"만 확인합니다.

통합 테스트는 처음부터 끝까지 붙어 있는지만 확인합니다

통합 테스트는 세 갈래로 나눴습니다.

  • @IntegrationTest — HTTP 포함 전 구간 플로우
  • @ServiceIntegrationTest — HTTP 없이 서비스 + DB 연동
  • @JobIntegrationTest — 배치/스케줄러 실행

공통점은 "진짜로 붙어 있는지"를 보는 것입니다.

@IntegrationTest
class SignupIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private ObjectMapper objectMapper;

    @TestConfiguration
    static class StubbedExternalConfig {
        @Bean
        @Primary
        MailClient mailClient() {
            return new NoOpMailClient();
        }
    }

    @Test
    void 회원가입_요청이_DB와_메일_전송까지_이어진다() throws Exception {
        mockMvc.perform(post("/auth/signup")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(
                                new SignupRequest("user@test.com", "password", "tester"))))
                .andExpect(status().isCreated());

        assertThat(memberRepository.findByEmail("user@test.com")).isPresent();
    }
}

외부 메일 시스템은 @TestConfiguration + @Primary로 Fake로 대체합니다.
DB는 실제로 붙여서, 회원가입 플로우가 끝까지 이어지는지만 확인합니다.

  • 컴포넌트가 실제로 조립되어 동작하는가
  • 트랜잭션 경계가 의도대로 잡히는가
  • 외부 시스템과의 연동이 끊기지 않았는가 (외부는 Fake로 대체)

여기서 검증하지 말아야 할 것이 핵심입니다.

  • 단위 테스트에서 이미 커버된 분기를 반복 검증하지 않습니다.
  • JSON 필드 하나하나를 여기서 검증하지 않습니다 (그건 컨트롤러 테스트 몫).
  • 리포지토리 쿼리의 세부 결과를 여기서 다시 검증하지 않습니다.

통합 테스트는 "다 붙어서 돌아가는가"만 봅니다. 분기 검증을 여기서 반복하면 CI가 느려집니다.


레이어 경계를 지킨다는 것은

레이어 경계는 책임 경계입니다.
그리고 책임 경계는 곧 테스트 범위의 경계입니다.

테스트가 경계를 넘어가면 벌어지는 일은 매번 비슷했습니다.

  • 실패했을 때 원인 레이어가 불명확해집니다.
  • 한 번의 변경에 여러 레이어 테스트가 같이 깨집니다.
  • CI가 느려지고, 느려지면 안 돌리게 됩니다.

전편에서 "Mock이 힘들면 설계를 의심해야 합니다"라고 했습니다.
이번 글의 주장은 그 연장선입니다.

테스트 범위가 자꾸 넓어진다면, 레이어 분리를 의심해야 합니다.

테스트의 경계를 지키면, 설계의 경계도 같이 지켜집니다.


저도 한때 이렇게 생각했습니다

"@SpringBootTest 하나면 다 되는 거 아닌가?" — 아닙니다.
모든 걸 로딩하면 모든 걸 놓칩니다. 실패 원인이 어디인지 판정이 안 되고, 테스트 한 개가 2초씩 걸립니다.

"레이어별로 나누면 테스트가 더 많아지는 거 아닌가?" — 더 많아집니다.
근데 각각이 더 빠르고 더 명확합니다. 100개의 단위 테스트가 10개의 통합 테스트보다 빠르게 돌아갑니다.

"통합 테스트만 있으면 단위 테스트는 중복 아닌가?" — 아닙니다.
통합 테스트로 분기를 전부 덮으려 하면 테스트가 폭발합니다. 분기는 단위에서 덮고, 통합은 "붙어 있는가"만 보는 게 맞습니다.


마무리하면서

전전편에서 좋은 테스트는 빠르고 독립적이고 반복 가능해야 한다고 썼습니다.
전편에서는 Mock을 더 잘 쓰는 방법이 아니라 Mock이 왜 필요한지를 물어야 한다고 썼습니다.
이번 글에서 제가 정리한 건, 레이어마다 테스트가 답해야 할 질문이 다르다는 것이었습니다.

  • 도메인: "이 규칙이 맞나?"
  • 리포지토리: "JPA가 이렇게 돌아가나?"
  • 서비스: "분기가 맞나?"
  • 컨트롤러: "HTTP 계약이 맞나?"
  • 통합: "다 붙어서 돌아가나?"

테스트는 레이어마다 다른 질문에 답해야 합니다. 같은 질문을 반복하는 순간, 테스트는 중복이 됩니다.

다음 글에서는 이어서 이런 주제를 다뤄볼 예정입니다.

  • Fixture와 실행 전략은 왜 유지보수성에 직접 영향을 주는가

참고 자료

  1. 테스트 코드에서 내부 구현 검증 피하기 — 조졸두 블로그
  2. Stub을 이용한 Service 계층 단위 테스트 하기 — 조졸두 블로그
  3. 실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 1: Mock — 카카오페이 기술 블로그
  4. 실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 2: 테스트 코드로부터 피드백 받기 — 카카오페이 기술 블로그
  5. 테스트 코드를 왜 그리고 어떻게 작성해야 할까? — 인프랩 기술 블로그