"이거 Mock으로 감싸면 되지 않나요?"

이 글을 쓰게 된 계기
이전 글에서 테스트 코드의 필요성과 좋은 테스트의 기준에 대해 정리했습니다. 그 이후로 실제로 테스트를 붙이기 시작했는데, 새로운 종류의 고민이 생겼습니다.
서비스 테스트를 하나 작성하려고 클래스를 열었습니다. @Mock을 붙여야 할 필드가 네 개. PostRepository, MemberRepository, PostLikeRepository, 거기에 외부 알림용 클라이언트까지. given().willReturn() 체인을 하나씩 설정하다 보니, 테스트 코드가 프로덕션 코드보다 길어졌습니다.
그래도 테스트가 통과하니까 괜찮다고 생각했습니다. 문제는 그 다음이었습니다. 서비스 로직을 리팩터링했는데, 기능은 정상 동작하는데 테스트가 깨졌습니다. 원인을 보니 verify(repository).save(any())가 실패한 것이었습니다. 저장 로직의 호출 방식이 바뀌었을 뿐, 결과는 똑같았는데 테스트가 그걸 허용하지 않았습니다.
'테스트가 리팩터링을 막고 있다.' 이전 글에서 테스트는 변경을 두려워하지 않게 만들어주는 장치라고 썼는데, 정반대의 일이 벌어지고 있었습니다.
테스트 더블을 쓸 줄 아는 것과, 잘 쓸 줄 아는 것은 다른 문제였습니다.
테스트 더블, 간단히 정리하면
처음 Mockito를 배웠을 때는 모든 의존성에 @Mock을 붙이는 게 당연하다고 생각했습니다. 근데 테스트 더블에는 Mock 말고도 여러 종류가 있고, 각각 목적이 다릅니다.
| 종류 | 한 줄 설명 |
|---|---|
| Dummy | 쓰이지 않는 파라미터를 채우기 위한 자리 채우기 객체 |
| Stub | 미리 정해둔 값을 반환하는 "대답 전용" 객체. 호출 횟수·순서에 관심 없음 |
| Fake | 실제 동작을 흉내 내는 간이 구현. 예: InMemoryRepository |
| Mock | 어떻게 호출되었는지(횟수, 인자, 순서)를 검증하는 객체 |
| Spy | 실제 객체를 감싸서 호출을 기록. 일부만 스텁 가능 |
// Stub: 항상 20%를 반환하는 할인율 클라이언트
RateClient stubClient = () -> 20;
// Fake: HashMap 기반 인메모리 저장소
class InMemoryMemberRepository implements MemberRepository {
private final Map<Long, Member> store = new HashMap<>();
// save(), findById() 등을 메모리에서 처리
}
// Mock: Mockito로 행위 검증
verify(authValidator).validateSignup(request);
대부분의 개발자가 Mock부터 배웁니다. Mockito가 워낙 편하니까 @Mock과 given().willReturn()만으로 모든 걸 해결하려 합니다. 그게 문제의 시작이었습니다.
Mock은 테스트 더블의 한 종류일 뿐입니다. 그런데 대부분 Mock부터 배웁니다.
언제 써야 하는가 — 체감 순서대로
실제 객체로 충분한 곳에 Mock을 쓰고 있었습니다
도메인 엔티티나 단순한 정책 객체를 테스트할 때도 @Mock을 붙이고 있었습니다. OwnershipPolicy를 Mock으로 감싸고, given(policy.check(...)).willReturn(true)를 설정한 뒤, 서비스 로직을 테스트했습니다.
근데 이 테스트가 실제로 검증하는 게 뭔지 생각해보면, Policy의 로직이 아니라 "제가 짠 Mock 설정"이 맞는지를 확인하는 것이었습니다. Policy에 버그가 있어도 이 테스트는 통과합니다. Mock이 진짜 로직을 대신하고 있으니까.
그때 알게 된 우선순위가 있습니다.
실제 객체 → Fake/Stub → Mock
도메인 엔티티, 값 객체, 단순 Policy는 그냥 new로 만들면 됩니다. DB나 네트워크가 필요 없는 객체를 굳이 Mock으로 감쌀 이유가 없습니다.
실제 객체로 검증할 수 있는데 Mock을 쓰면, 테스트가 검증하는 건 Mock 설정뿐입니다.
verify()는 비즈니스 규칙일 때만 씁니다
Mock의 핵심 기능은 verify()입니다. "이 메서드가 호출되었는가"를 검증합니다. 근데 모든 호출을 verify할 필요는 없습니다. 기준은 하나입니다. 그 호출이 비즈니스 규칙인가?
// 좋은 verify: "댓글이 생성되면 게시글의 댓글 카운트가 증가해야 한다"는 비즈니스 규칙
verify(post).incrementCommentCount();
// 나쁜 verify: repository.save()가 호출되었는지는 구현 세부사항
verify(repository).save(any());
첫 번째는 "댓글 생성 시 카운트가 올라간다"는 비즈니스 요구사항을 표현합니다. 이건 내부 구현이 바뀌더라도 유지되어야 할 규칙입니다.
두 번째는 그냥 구현을 따라 적은 것입니다. 저장 방식이 save() 한 번에서 saveAll()로 바뀌면 테스트가 깨집니다. 기능은 동일한데. 이런 테스트가 리팩터링을 방해합니다.
verify가 비즈니스 요구사항을 말하고 있는지, 구현을 따라 적은 건지 구분해야 합니다.
Stub과 Fake는 과소평가되어 있습니다
Mock만 알면 모든 것을 Mock으로 해결하려 합니다. 근데 많은 경우 Stub이나 Fake가 더 나은 선택입니다.
// Stub: 인터페이스 하나만 구현하면 끝
RateClient stubClient = () -> 20;
DiscountService service = new DiscountService(stubClient);
// Fake: InMemoryRepository로 DB 없이 서비스 로직 전체를 검증
MemberRepository fakeRepo = new InMemoryMemberRepository();
SignupService service = new SignupService(fakeRepo);
Stub은 외부 API 응답을 고정할 때 유용합니다. 인터페이스를 람다 한 줄로 구현하면 됩니다. given().willReturn()을 쓸 필요도 없고, verify()로 뭘 검증할 일도 없습니다. 입력에 대한 출력만 정해주면 됩니다.
Fake는 한 단계 더 갑니다. InMemoryMemberRepository처럼 HashMap으로 구현하면, 실제 DB 없이도 저장/조회 흐름을 검증할 수 있습니다. 통합 테스트까지 가지 않아도 서비스 로직의 흐름을 빠르게 확인할 수 있습니다.
Mock 없이도 테스트할 수 있는 방법은 생각보다 많습니다.
Mock이 힘들면 설계를 의심해야 합니다
이 글에서 가장 하고 싶은 이야기입니다.
서비스 테스트를 작성하는데 Given 절이 30줄을 넘어갔습니다. Elasticsearch에서 상품 정보를 가져오고, Redis에서 환율을 조회하고, MySQL에서 쿠폰과 가맹점 정보를 읽어오는 서비스였습니다. 테스트 하나를 쓰려면 네 개의 Mock에 대해 각각 given().willReturn()을 설정해야 했습니다.
그때 든 생각은 'Mock을 더 잘 써야 하나?'였습니다. 근데 정답은 그게 아니었습니다. 이 클래스가 너무 많은 일을 하고 있었습니다.
카카오페이 기술 블로그에서 본 비유가 인상적이었습니다. 컨베이어 벨트의 작업자가 전부 열심히 일하고 있는데도 라인이 멈추면, 문제는 작업자의 능력이 아니라 한 사람에게 너무 많은 공정이 몰려 있는 것입니다. 새로운 작업자를 추가해서 일을 나눠야 합니다.
코드에서도 마찬가지입니다. OrderService 하나가 상품 검증, 환율 계산, 쿠폰 정책, 가맹점 조회를 전부 담당하고 있으면, 테스트가 복잡해지는 건 당연합니다. ProductPolicy, CouponPolicy, PricingService 같은 협력 객체로 책임을 나누면, 각 테스트는 자기 범위 안에서만 집중할 수 있습니다. Given 절도 짧아집니다.
이전 글에서 "테스트를 붙이기 어려운 코드는 대개 설계도 좋지 않다"고 썼습니다. 테스트 더블 관점에서 같은 이야기를 다시 하면 이렇게 됩니다. Mock이 너무 많이 필요하다는 건, 그 클래스의 의존성이 너무 많다는 뜻이고, 의존성이 많다는 건 책임이 과하다는 뜻입니다.
Mock을 더 잘 쓰는 방법을 찾지 말고, Mock이 왜 이렇게 많이 필요한지를 물어야 합니다.
@MockBean은 편리하지만 비용이 있습니다
컨트롤러 테스트에서 @MockBean은 거의 필수적으로 사용됩니다. @WebMvcTest에서 Service를 대체하려면 @MockBean을 쓰는 게 자연스럽습니다.
@WebMvcTest(SignupController.class)
class SignupControllerTest {
@MockBean
SignupService signupService;
// ...
}
근데 이게 통합 테스트까지 퍼지면 문제가 생깁니다.
테스트를 30개쯤 돌릴 때까지는 몰랐습니다. 60개가 넘어가면서 빌드 시간이 눈에 띄게 느려졌습니다. 원인을 추적해보니 Spring ApplicationContext가 반복적으로 재초기화되고 있었습니다. @MockBean 조합이 테스트 클래스마다 다르면, Spring은 기존 컨텍스트를 재사용하지 못하고 새로 띄웁니다. 테스트 클래스가 늘어날수록 컨텍스트 초기화 횟수도 비례해서 늘어납니다.
대안은 @TestConfiguration과 @Primary를 사용하는 것입니다.
@TestConfiguration
class ClientTestConfiguration {
@Bean
@Primary
PartnerClient mockPartnerClient() {
return mock(PartnerClient.class);
}
}
이렇게 하면 Mock Bean이 테스트 전용 설정으로 한 번만 등록되고, 모든 테스트 클래스가 같은 컨텍스트를 재사용합니다. @MockBean처럼 각 테스트마다 컨텍스트를 새로 만들 필요가 없습니다.
@MockBean은 편리하지만, 통합 테스트에서 남발하면 테스트 전체를 느리게 만듭니다.
그래서 저는 이렇게 정리했습니다
여기까지의 경험을 레이어별로 정리하면 이렇게 됩니다.
| 레이어 | 전략 |
|---|---|
| Domain (Entity/Policy) | 실제 객체만. 테스트 더블 금지 |
| Repository | 실제 JPA + 테스트 DB |
| Service 단위 테스트 | @Mock (Repository/외부 클라이언트) |
| Controller | @MockitoBean (Service) |
| 통합 테스트 | 실제 빈 유지, 외부 시스템만 Fake/Stub (@TestConfiguration) |
이 표는 처음부터 있었던 게 아닙니다. 도메인에 Mock을 쓰고, 통합 테스트에서 @MockBean을 남발하고, 하나씩 고쳐가면서 만들어진 것입니다.
핵심은 레이어마다 적합한 테스트 더블이 다르다는 것입니다. 도메인은 실제 객체가 맞고, 서비스 단위 테스트에서는 Mock이 합리적이고, 통합 테스트에서는 Fake가 나은 경우가 많습니다. 한 가지 방식으로 모든 곳을 커버하려는 게 남용의 시작입니다.
마무리하면서
이전 글에서 "테스트는 변경을 두려워하지 않게 만들어주는 장치입니다"라고 썼습니다. 여전히 맞는 말이라고 생각합니다. 근데 테스트 자체가 구현에 강하게 결합되어 있으면, 오히려 테스트가 변경을 두렵게 만드는 장치가 됩니다. Mock을 남용한 테스트가 정확히 그 상태입니다.
테스트 더블은 실제 객체를 쓸 수 없는 곳에서 테스트를 가능하게 만드는 도구입니다. 그 이상도 이하도 아닙니다. 실제 객체로 검증할 수 있는 곳에서는 실제 객체를 쓰고, 외부 의존성을 격리해야 할 때만 목적에 맞는 테스트 더블을 선택하면 됩니다.
테스트 더블은 테스트를 가능하게 만드는 도구이지, 테스트를 대신해주는 도구가 아닙니다.
다음 글에서는 이어서 이런 주제를 다뤄볼 예정입니다.
- 레이어별 테스트 전략은 어떻게 나누어야 하는가
- Fixture와 실행 전략은 왜 유지보수성에 직접 영향을 주는가
참고 자료
'[ 인사이트 ]' 카테고리의 다른 글
| 테스트 코드란 왜 필요한가? 좋은 테스트 코드란 무엇인가? (0) | 2026.01.15 |
|---|---|
| [Spring JPA] JPA One-to-Many 관계, 양방향은 최후의 수단이다 (0) | 2025.11.26 |