어느 순간 테스트가 5분 — @MockBean이 컨텍스트 캐시를 깨고 있었다

평소 1분 30초쯤 걸리던 테스트 빌드가 있었다. 슬라이스 테스트가 300개를 넘어가던 어느 시점부터 5분이 됐다. 정확히 언제부터 그랬는지는 기억이 안 나는데, PR 올리고 화장실 다녀와서 커피 받아도 CI가 안 끝나 있는 게 어느 순간 일상이 됐다.

처음엔 그냥 "테스트가 많아졌으니까 그렇겠지" 하고 넘겼다. 근데 단순 산수가 안 맞았다. 그 사이 새로 추가된 슬라이스 테스트는 10개 남짓이었는데, 빌드 시간은 5배 가까이 늘어 있었다. 한 테스트당 1초씩 늘었다 해도 10초인데, 6분이 추가된 셈이었다.

그래서 들여다봤다.

원인

Spring의 테스트 컨텍스트 캐시는 ApplicationContext를 한 번 만들어두고 다음 테스트가 같은 설정을 요구하면 재사용한다. 이게 빠른 테스트의 핵심이다. 보통은 @WebMvcTest, @DataJpaTest 같은 슬라이스 어노테이션 단위로 캐시 키가 결정된다.

근데 @MockBean을 쓰면, 그 클래스의 컨텍스트는 다른 어떤 컨텍스트와도 공유 안 되는 새 캐시 키가 된다. @MockBean(UserService::class)가 들어간 슬라이스 테스트와 안 들어간 슬라이스 테스트는 Spring 입장에서 다른 컨텍스트로 취급된다.

@WebMvcTest(UserController::class)
class UserControllerTest {
    @MockBean private lateinit var userService: UserService  // 새 캐시 키
}

@WebMvcTest(PostController::class)
class PostControllerTest {
    @MockBean private lateinit var postService: PostService  // 또 새 캐시 키
}

@WebMvcTest(CommentController::class)
class CommentControllerTest {
    @MockBean private lateinit var commentService: CommentService
    @MockBean private lateinit var userService: UserService    // 위 UserControllerTest와도 안 공유됨
}

@MockBean이 들어간 빈 조합이 다르면 Spring은 그것들을 독립적인 ApplicationContext로 본다. 캐시 미스 → 컨텍스트 새로 부팅 → 빌드 시간 누적.

대략 그림으로 보면 이런 분기가 일어난다.

ApplicationContext 하나 만드는 데 우리 프로젝트에서는 7~8초 정도 걸렸다 (시간 측정 안 해본 사람이 많을 텐데, 한 번 켜보면 놀란다). 슬라이스 클래스 30개에서 각자 다른 @MockBean 조합을 쓰면, 캐시 미스가 30번 가까이 나서 30 × 7초 = 약 3분 30초가 그냥 컨텍스트 부팅에만 쓰인다. 6분 늘어난 시간 중 절반쯤이 여기서 나오고 있었다.

이걸 확인한 방법은 단순했다. Spring Test의 org.springframework.test.context.cache 로거를 DEBUG로 켜면 cacheHits / cacheMisses 카운트가 찍힌다. 도입 전엔 misses가 hits의 두 배가 넘었다. 정상이라면 hits가 압도적으로 많아야 한다.

가설들

원인이 컨텍스트 캐시인 건 알았는데, 어떻게 풀어야 할지는 또 다른 문제였다. 후보가 몇 개 있었다.

후보 1. 슬라이스 테스트를 줄이고 다 통합 테스트로. 컨텍스트가 1개로 줄어드니까 캐시 미스는 사라진다. 근데 통합 테스트 하나가 5초 걸리고 슬라이스 테스트는 200ms 걸리니까, 결국 다른 쪽에서 시간이 폭발한다. 그리고 슬라이스가 주는 "이 레이어만 검증한다"는 명확함도 잃는다.

후보 2. Standalone MockMvc로 전환. Spring 컨텍스트를 안 띄우고 MockMvcBuilders.standaloneSetup(controller)로 직접 만드는 방식이다. 빠른데, Validation·ExceptionHandler·ArgumentResolver 같은 게 자동으로 안 묶인다. 그걸 다 수동으로 등록하기 시작하면 테스트 코드가 부풀어 오른다. 일부 케이스에만 쓸 만했다.

후보 3. @TestConfiguration + @Primary로 Mock 빈을 명시 등록. Mock 빈 설정을 별도 @TestConfiguration 클래스로 빼고, 같은 설정을 쓰는 테스트들끼리 컨텍스트를 공유하게 한다. 카카오페이 기술 블로그에서 본 패턴이었다.

후보 1은 트레이드오프가 안 맞았고, 후보 2는 일부 케이스에만 쓸 수 있었다. 후보 3을 메인으로 가기로 했다.

@TestConfiguration + @Primary 적용

같은 컨트롤러 그룹의 테스트들이 자주 mocking하는 빈들을 한 곳에 모은다.

@TestConfiguration
class WebMvcMockConfig {

    @Bean
    @Primary
    fun userService(): UserService = mockk()

    @Bean
    @Primary
    fun postService(): PostService = mockk()

    @Bean
    @Primary
    fun authService(): AuthService = mockk()
}

테스트에서는 @Import로 가져다 쓴다.

@WebMvcTest(UserController::class)
@Import(WebMvcMockConfig::class)
class UserControllerTest(
    @Autowired private val mockMvc: MockMvc,
    @Autowired private val userService: UserService,  // mockk 인스턴스
) {
    @Test
    fun `사용자 조회`() {
        every { userService.find(1L) } returns User(...)
        // ...
    }
}

핵심은 @MockBean이 사라졌다는 것이다. Spring 입장에서는 이게 그냥 일반 @Bean이라서 컨텍스트 캐시 키에 영향을 주지 않는다. WebMvcMockConfig를 @Import하는 모든 테스트는 같은 컨텍스트를 공유한다.

여기서 한 단계 더 가면, 메타 어노테이션과 묶을 수 있다. 메타 어노테이션 컨벤션은 별도 글에서 정리했으니, 이 글에서는 결과만.

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag("controller-webmvc")
@WebMvcTest
@Import(WebMvcMockConfig::class)
annotation class ControllerWebMvcTest

이렇게 하면 테스트 클래스에서는 @ControllerWebMvcTest만 쓰면 된다. @Import도, @MockBean도 안 쓴다.

Before / After

빌드 시간 측정값은 정확히 기억이 안 난다. 측정 그래프를 보관 안 해뒀다. 대략 이런 순서였다.

  • 도입 전: 8분
  • @TestConfiguration + @Primary 전환: 4분 후반대
  • 메타 어노테이션과 묶고 캐시 일관성 확보: 3분대

PR CI가 4~5분 빨라졌다. 절반 가까이 줄어든 셈이라, 머지 빈도가 자연스럽게 늘었다.

ApplicationContext 캐시 hit ratio도 같이 봤는데 이쪽이 더 극적이었다. 도입 전엔 misses가 hits의 두 배가 넘었던 게, 도입 후엔 misses가 한 자릿수로 떨어졌다. 컨텍스트 부팅이 거의 시작 한 번으로 끝나는 흐름으로 바뀐 것이다.

한계

이 패턴이 모든 경우에 좋다는 건 아니다.

가장 자주 부딪힌 건, 새 빈을 추가할 때마다 WebMvcMockConfig도 손봐야 한다는 점이다. 이게 깜빡할 때가 많다. 한 번은 새로 만든 컨트롤러가 의존하는 서비스를 WebMvcMockConfig에 안 넣어서, 30분 정도 "왜 NPE가 나지" 하고 헤맸다. @MockBean은 클래스 안에서 자동으로 처리되는 편의가 있는데, @TestConfiguration으로 빼면 그 편의가 사라진다.

또 한두 테스트만 다른 mocking이 필요할 때, @TestConfiguration을 두 개로 쪼개거나 그 케이스만 별도 @MockBean을 쓰게 된다. 그러면 또 컨텍스트가 새로 생긴다. 이런 케이스를 의식해서 격리해야 한다.

마지막으로, Spring Boot 3.4부터 @MockBean이 deprecated됐다. 권고는 @MockitoBean으로 옮기라는 건데, 이 어노테이션도 동작이 비슷해서 같은 문제(컨텍스트 캐시 키 분리)를 가진다. 결국 우리는 @MockitoBean도 같은 이유로 피하고 있다.

마지막으로

처음 빌드가 느려졌을 때, 직관적으로는 "테스트가 너무 많아서 그런 거 아닐까" 싶었다. 근데 측정해 보니 테스트 개수보다 컨텍스트 개수가 진짜 변수였다. Spring 테스트 빌드에서는 "테스트 N개"보다 "컨텍스트 K개 × 부팅 시간 T초"가 훨씬 큰 영향을 준다.

@MockBean 자체가 나쁜 도구라기보다는, 그게 컨텍스트 캐시에 미치는 영향을 의식하지 않으면 비용이 누적된다는 게 결론이었다. 한 번 한 번의 @MockBean은 0.1초밖에 안 보일 수 있는데, 30개 모이면 3분이 된다.

남은 과제는 "@TestConfiguration을 어디까지 분리할 것인가"다. 지금은 webmvc / repository / service-integration 단위로 하나씩 두고 있는데, 도메인이 더 커지면 이 설정 클래스 자체가 비대해진다. 그건 다음에 정리해볼 생각이다.


참고