반복되는 테스트 환경에 메타 어노테이션을 만들기

테스트가 30개를 넘어가면, PR 리뷰에서 같은 질문이 계속 반복된다.

"이거 @SpringBootTest 맞아요? @WebMvcTest 아닌가?" "여기엔 @DataJpaTest가 빠진 것 같은데..." "이 테스트는 unit이에요 integration이에요?"

답이 매번 다르고, 같은 사람이 한 달 전과 다르게 답한다. 한 분기쯤 지나면 어노테이션 조합이 팀원 수만큼 늘어나 있다. 새로 합류한 사람의 첫 PR에서는 다섯 가지 다른 어노테이션이 등장하기도 한다.

이게 단순한 스타일 문제가 아닌 이유는, "이 테스트가 어떤 종류인가"가 사람마다 다르게 해석되면 빠르게 돌릴 테스트와 느리게 돌릴 테스트의 경계가 무너진다는 데 있다. 그러면 결국 다 같이 느린 모드로 돌리게 되고, 시간이 더 지나면 테스트 자체를 덜 돌리게 된다.

이 글은 우리 팀이 테스트 어노테이션을 컨벤션으로 박기로 한 이유, 그리고 메타 어노테이션 6개로 정리한 과정을 적은 글이다.

정한 한 줄

우리가 정한 규칙은 이거 하나다.

이 레이어는 이 메타 어노테이션 하나만 쓴다.

너무 단순해서 부끄러울 정도인데, 의외로 잘 작동한다. 사람이 기억해야 하는 게 "어떤 어노테이션 + 어떤 import + 어떤 Tag"가 아니라 "이 테스트가 어느 레이어인가" 하나로 줄어든다.

메타 어노테이션 6종

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag("unit")
@ExtendWith(MockitoExtension::class)
annotation class UnitTest

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag("repository")
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
annotation class RepositoryJpaTest

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

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag("integration")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
annotation class IntegrationTest

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag("integration-security")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test", "security")
annotation class IntegrationSecurityTest

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag("service-integration")
@SpringBootTest
@ActiveProfiles("test")
annotation class ServiceIntegrationTest

(@JobIntegrationTest도 있는데 위 패턴과 거의 똑같아서 생략한다.)

각각의 의도는 이렇다.

어노테이션 검증 레이어 빠른가

@UnitTest 도메인 / 순수 로직 빠름
@RepositoryJpaTest JPA 매핑 / 쿼리 보통
@ControllerWebMvcTest HTTP 계약 빠름
@ServiceIntegrationTest 서비스 + Repository 협력 느림
@IntegrationTest 전체 흐름 느림
@IntegrationSecurityTest 전체 흐름 + 보안 느림

테스트 클래스에는 한 줄로 끝난다.

@RepositoryJpaTest
class PostRepositoryTest(
    @Autowired private val postRepository: PostRepository,
) {
    @Test
    fun `id로 조회하면 게시글이 반환된다`() { ... }
}

@DataJpaTest도, @AutoConfigureTestDatabase도, @Tag("repository")도 따로 안 쓴다. 메타 어노테이션 안에 다 들어 있다.

JUnit Tag를 메타 안에 두는 이유

메타 어노테이션 안에 @Tag("unit")을 넣은 이유는 테스트를 종류별로 골라서 실행하기 위해서다. JUnit 5는 @Tag로 마킹된 테스트만 골라 돌릴 수 있는데, 메타 어노테이션이 Tag를 자동으로 붙이니까 사용자는 클래스에 @UnitTest만 써도 그 클래스가 자동으로 "unit" 태그를 갖게 된다.

이게 왜 중요했냐면, 한 번은 도메인 로직 한 줄을 고치고 PR을 올렸는데 CI가 12분이 걸렸다. 평소 같으면 1~2분이면 끝날 변경이었다. 추적해 보니 CI가 모든 통합 테스트까지 포함해서 돌고 있었다. 그 시점에는 unit과 integration을 구분할 방법이 없어서, 한 번 돌리면 다 도는 구조였다.

이 사건이 메타 어노테이션 안에 Tag를 묶기로 한 결정적 계기였다. 사람한테 "이 테스트에 @Tag("unit") 붙여주세요" 하고 부탁하면, 절반 정도는 잊는다. 메타 어노테이션 안에 같이 묶여 있으면 잊을 수가 없다. @UnitTest를 붙이는 순간 Tag도 따라온다.

Gradle Task로 한 단계 더

Tag만으로는 부족했다. 개발자마다 IntelliJ Run Configuration을 다르게 만들어 두고 돌렸기 때문에, "지금 빠른 테스트만 돌렸어요"라는 말이 사람마다 다른 걸 의미했다. 그래서 Gradle Task에 명시적으로 박았다.

// build.gradle.kts
tasks.register<Test>("unitTest") {
    useJUnitPlatform {
        includeTags("unit")
    }
}

tasks.register<Test>("repositoryTest") {
    useJUnitPlatform {
        includeTags("repository")
    }
}

tasks.register<Test>("controllerTest") {
    useJUnitPlatform {
        includeTags("controller-webmvc")
    }
}

tasks.register<Test>("integrationTest") {
    useJUnitPlatform {
        includeTags("integration", "integration-security", "service-integration")
    }
}

빠른 루프는 이렇게 돈다.

./gradlew :app-api:unitTest         # 1초대
./gradlew :app-api:repositoryTest   # 10초대
./gradlew :app-api:integrationTest  # 분 단위

CI도 같은 분리를 따라간다. PR에는 unitTest + repositoryTest + controllerTest만 돌리고, main 머지에는 integration까지 돌린다. 12분 걸리던 CI가 PR 단계에서는 2~3분으로 떨어졌다 (정확히 몇 분 줄었는지는 기록 안 해뒀다).

전체 체인을 그림으로 보면 이렇다.

flowchart LR
    A["테스트 클래스<br/>@UnitTest"] --> B["메타 어노테이션 내부<br/>@Tag('unit')"]
    B --> C["JUnit Platform<br/>includeTags('unit')"]
    C --> D["Gradle Task<br/>:unitTest"]
    D --> E["CI / 로컬<br/>./gradlew :unitTest"]

핵심은 이 체인이 한 곳에서 시작해서 한 곳으로 흐른다는 것이다. 어느 한 단계에서 사람이 의식해서 맞춰야 하는 부분이 있으면, 결국 어긋난다.

도입 후 변화

도입한 지 얼마나 됐는지는 정확히 기억이 안 난다. 한 분기 정도였던 것 같은데, 두 가지가 분명히 바뀌었다.

먼저 "이거 @WebMvcTest 맞아요?" 같은 PR 코멘트가 사라졌다. 어노테이션이 한 가지로 줄어들면 고를 게 없어지니까, 자연스럽게 메뉴가 단순해진다.

다음으로, PR 단계에서 unit + repository + controller만 돌리는 게 자연스러워졌다. 더 자주 PR을 올리고, 더 자주 머지하게 됐다. 이건 측정 가능한 결과가 아니라 분위기 변화에 가까운데, 분위기 변화 쪽이 사실 더 큰 효과였다고 생각한다.

한계

이 컨벤션을 다 좋다고는 못 한다.

우선 메타 어노테이션은 추상화라서, 처음 보는 사람은 "이 어노테이션이 진짜로 뭘 하는지" 한 번 까봐야 한다. IDE에서 @RepositoryJpaTest를 Cmd+클릭해서 정의를 보지 않으면 무엇이 들어 있는지 모른다. 도구가 자기를 가린다.

또 어떤 테스트에는 메타 어노테이션을 안 만드는 게 낫다. 예를 들어 @JsonTest는 한 모듈에서 두세 번만 쓰는데, 이걸 @JsonSliceTest 같은 메타로 또 만들면 메뉴만 늘어난다. 우리는 메타 어노테이션을 7개, 8개로 늘리지 않으려고 의식한다. 일회성에 가까운 슬라이스는 그냥 직접 붙인다.

마지막 함정은 메타 어노테이션 안의 import 구성이다. 한 번은 @Import(SecurityTestConfig::class)를 @ControllerWebMvcTest 안에 넣었다가, 그게 필요 없는 컨트롤러 테스트도 SecurityTestConfig를 다 로드해서 30초쯤 빌드 시간이 늘어난 적이 있다. 메타 어노테이션은 영향 범위가 넓어서, 변경할 때 PR 리뷰에서 평소보다 한 번 더 본다.

마지막으로

이 컨벤션의 핵심은 "어노테이션을 줄인 것"보다 **"어노테이션 분류 자체를 한 곳에서 결정한 것"**에 가깝다. 사람마다 다르게 답하던 질문이 코드베이스에 명시적으로 박혀 있게 됐다.

사실 처음에는 이 정도까지 컨벤션으로 박을 필요가 있을까 의심했다. 가이드 문서로 두면 될 거라고 생각했는데, 가이드 문서는 본 사람만 따른다. 메타 어노테이션은 안 따르면 컴파일이 안 되거나 Tag가 빠진다. 그 차이가 생각보다 컸다.

남은 과제는 "메타 어노테이션을 몇 개까지 늘릴 것인가"다. 지금은 6개인데, 새로운 슬라이스를 검증하고 싶을 때마다 메타로 만들어야 할지 그냥 직접 붙일지 기준이 아직 모호하다. 그건 다음에 정리해볼 생각이다.


참고