ClaudeCode가 작성한 테스트 코드와 함께 학습 - 2

드코딩·2026년 5월 4일

ClaudeCode가 작성한 테스트 코드와 함께 학습 - 1에서 내용이 너무 길어져서 남은 부분을 추가 정리해보자
이번 글에서는 실제 테스트 코드 내부 구현부분을 다루면서 1편과 동일하게 나의 생각을 정리해보자.

해당 인라인 블럭으로 작성한 내용을 직접 고민하고 생각한 내용을 작성하는 부분입니다.
클로드 코드가 작성해준 내용과 구분하기 위함.

5. 테스트 파일 구조 해부 ( ClaudeCode의 답변)

NotificationBatchServiceHolidayTest.kt를 블록 단위로 뜯어봅니다.

5-1. Fake를 위한 자료구조

private val holidayCalendar = mutableSetOf<LocalDate>()
private val workPolicies = mutableListOf<WorkPolicyVersion>()
private val notificationLogs = mutableListOf<NotificationLog>()
private val terms = mutableListOf<Term>()
private val agreements = mutableListOf<TermAgreement>()
private val settings = mutableListOf<NotificationSetting>()
private val fcmTokens = mutableListOf<FcmToken>()
private val dailySchedules = mutableListOf<DailyWorkSchedule>()

이게 인메모리 DB입니다. 프로덕션의 MySQL 테이블과 1:1 대응:

테스트 자료구조프로덕션 테이블
holidayCalendarpublic_holiday
workPolicieswork_policy_version
notificationLogsnotification_log
termsterm
agreementsterm_agreement
settingsnotification_setting
fcmTokensfcm_token
dailySchedulesdaily_work_schedule

네이밍 선택publicHolidayReponotificationLogRepo 같은 infrastructure 용어 대신 holidayCalendarnotificationLogs 같은 도메인 용어로 지었습니다. 테스트를 읽을 때 "공휴일 달력에 신정을 추가하고 나면…"처럼 자연어로 읽히게 하려는 의도.

여기서 나는 너무 자료구조들이 존재하고 이는 즉 너무 많은 책임을 가지고 있는 것은 아닐까?
하는 의문이 생겼고 바로 클로드 코드에게 코드리뷰를 부탁했다.

1차 분해 — Fake 8개는 동급이 아니다

SUT(System Under Test)는 NotificationBatchService인데, 그 자체가 의존하는 협력자는 8개가 아니다.

NotificationBatchService (SUT)  
├─ WorkPolicyVersionRepository      ← SUT 직접 의존  
├─ NotificationLogRepository        ← SUT 직접 의존  
├─ NotificationEligibilityService   ← 실물(in-process)로 함께 검증  
│   ├─ TermRepository  
│   ├─ TermAgreementRepository  
│   ├─ NotificationSettingRepository  
│   ├─ FcmTokenRepository  
│   └─ DailyWorkScheduleRepository  
└─ PublicHolidayService             ← 실물  
    └─ PublicHolidayRepository
  • SUT가 직접 들고 있는 의존: 4개 (WorkPolicyVersionRepository, NotificationLogRepository, NotificationEligibilityService, PublicHolidayService).
  • SUT 자체로는 4개, 그 중 2개가 in-process 도메인 서비스라 같이 검증 범위에 끌어들였고, 그 결과 간접 의존 6개의 데이터 경계까지 합쳐 fake가 8개가 됐다.

NotificationBatchService.kt 한 파일만 보면 의존성이 비대하지 않다. 8개라는 숫자는 세 서비스가 함께 참여하는 통합된 도메인의 데이터 경계 합산이다.

PublicHolidayService, NotificationEligibilityService실물을 사용한다.

"Fake = 결합도" 명제의 조건부 진실

이 명제는 mockist 스타일에서는 거의 진실이다. mock이 많다 = SUT가 협력자에게 많은 명령을 내린다 = 결합도가 높다.

Classicist 스타일에선 다르게 읽어야 한다. classicist의 fake는 "협력자의 동작 흉내"가 아니라 "외부 시스템(DB)의 얇은 어댑터"이다. 그래서 fake 수는 결합도가 아니라 외부로 노출된 데이터 경계의 수에 비례한다.

이 프로젝트의 도메인 데이터 경계를 세보면:

  • 회원 자격 — 약관(2: term, agreement) + 알림 설정(1) = 3
  • 근무 정책 — work_policy_version + daily_schedule = 2
  • 공휴일 — 1
  • 알림 로그 — 1
  • FCM 토큰 — 1

= 8개. 정확히 일치한다. 즉 fake 8개는 "SUT의 결합도가 폭발했다"의 지표가 아니라 도메인이 잘 정규화돼서 테이블이 8개로 쪼개져 있다는 사실의 그림자일 뿐이다.

이 차이를 명확히 의식하지 못하면, mockist 시절의 "fake = 결합도" 직관을 classicist 코드에 그대로 적용해서 잘못된 리팩터링 압력을 만든다.

그래도 SRP 의심점은 있다 — 어디 있는가

테스트 fake의 수보다 production 쪽에서 더 본격적인 의심점이 보인다.

NotificationEligibilityService가 5개 repository를 한 번에 들이고 있다. 자문해본다.

Q: NotificationEligibilityService는 무엇을 하는가?
A: "이 회원이 이 시점에 이 종류의 알림을 받을 자격이 있는가" 한 가지 질문에 답한다.

답이 한 줄로 압축되면 SRP 통과 — 5개 repo는 하나의 자격 판정에 필요한 다섯 가지 입력일 뿐이다.
입력의 수와 책임의 수는 다른 차원이다.

만약 답이 "회원 정보를 합성하고, 약관을 검증하고, 토큰을 조회하고, 휴가를 판정하고…" 처럼 and로 연결되는 여러 동사라면 SRP 위반 — 분리 후보.

Eligibility라는 이름이 한 단어로 응집되어 있다는 사실 자체가 전자의 형태일 가능성이 높다는 신호다. 다섯 입력은 한 답을 위한 다섯 검증 조건이지, 다섯 일이 아니다.

다만 이건 production 코드를 직접 펼쳐서 한 번 더 확인할 가치는 있다. 눈으로 보고 책임이 한 문장에 압축되는지를 본인이 답할 수 있어야 한다.

SRP 의심점 리뷰를 받고 NotificationEligibilityService 다시 한번 직접 리뷰하면서 고민하는 시간을 가졌고 아래와 같은 코드를 볼 수 있었다.

	if (!context.hasAgreedToAll(memberId, requiredCodes)) return null  
	if (!context.isSettingEnabled(memberId, NotificationSettingType.WORK)) return null  
	if (context.shouldSkipNotification(memberId)) return null  
	if (!context.hasFcmToken(memberId)) return null

createNotificationsIfEligible() 메서드가 존재하는데 이는 알림을 생성해도 되는지 판단하는
헬퍼 메서드로 사용되고 있다.

이번 리뷰를 통해
and로 연결되는 여러 동사라면 SRP 위반이라는 피드백을 보고
context 객체를 생성해주는 NotificationEligibilityContext 를 다시 보게 되었고 해당 객체는 회원별 적격성을 판단하기위한 정보를 로드하는 책임뿐 아니라 shouldSkipNotification() 과 같이 알림을 전송해도되는지 판단까지 하는 책임을 가지고 있다고 생각했고 이를 리팩토링 하기로 했다.

  • 추가적으로 호출자에서 직접 메서드를 호출해보고 판단하는게 이후 유지보수에서 일관성일 떨어지는 지점으로 만들 것 같아 해당 책임을 처리하는 클래스 분리를 고려하려고한다.

결론
단순히 많은 자료구조, 많은 클래스를 생성한다고 해서 SRP를 위반했다고 접근하는건 위험한 생각이다.
실제로 NotificationBatchService 가 직접 의존하는 객체는 4개였고 나머지 4개의 경우 단위테스트 작
성을 위해 연관된 실물 클래스들이 사용하는 간접 의존 객체였다.

고전파 방식의 테스트를 진행하기 위해서는 어쩌면 당연한 수순이었고 조금 더 본질을 생각할 수 있는 계기가 되었다.

5-2. Repository 어댑터 (ClaudeCode 작성)

private val publicHolidayRepo = mockk<PublicHolidayRepository>().apply {
    every { existsByDate(any()) } answers { firstArg<LocalDate>() in holidayCalendar }
}

existsByDate가 호출되면 holidayCalendar Set에서 in 연산(Kotlin의 contains)으로 포함 여부 확인. O(1).

더 복잡한 예시:

private val workPolicyRepo = mockk<WorkPolicyVersionRepository>().apply {
    every { findLatestEffectivePoliciesPerMember(any()) } answers {
        val date = firstArg<LocalDate>()
        workPolicies
            .filter { !it.effectiveFrom.isAfter(date) }   // effectiveFrom <= date
            .groupBy { it.memberId }
            .map { (_, versions) -> versions.maxBy { v -> v.effectiveFrom } }
    }
}

이게 재현하는 실제 JPQL:

select distinct w from WorkPolicyVersion w
join fetch w.workdays
where w.effectiveFrom = (
    select max(w2.effectiveFrom)
    from WorkPolicyVersion w2
    where w2.memberId = w.memberId and w2.effectiveFrom <= :date
)

Kotlin 번역:

  • filter { !it.effectiveFrom.isAfter(date) } → effectiveFrom <= date
  • groupBy { it.memberId } → 회원별 그룹
  • map { ... maxBy { v.effectiveFrom } } → 각 그룹에서 최신 버전 선택

⚠️ 중요한 한계: 이 Fake 재현 로직이 실제 JPQL과 의미가 같다는 보장이 없습니다. 재현 로직 자체가 버그일 수 있어요. 그래서 계층 2(@DataJpaTest)가 필요합니다 — 실제 JPQL이 Fake와 같은 의미를 갖는다는 계약을 증명하는 테스트.

5-3. 실물 도메인 서비스

private val publicHolidayService = PublicHolidayService(publicHolidayRepo)
private val eligibilityService = NotificationEligibilityService(
    termRepo, agreementRepo, settingRepo, fcmTokenRepo, dailyScheduleRepo,
)
private val sut = NotificationBatchService(
    workPolicyRepo, notificationLogRepo, eligibilityService, publicHolidayService,
)

mock이 아니라 실물입니다. 이게 고전파의 핵심.

  • PublicHolidayService.isHoliday의 실제 로직이 돌아감
  • NotificationEligibilityService.loadContext가 진짜 실행됨
  • NotificationBatchService.generateHolidayNotifications도 진짜 호출됨

→ 세 서비스의 협력이 올바른지를 한 테스트가 검증.

sut는 System Under Test 약자. 테스트 대상을 명확히 구분하는 관습.

5-4. DSL 헬퍼

private fun 공휴일로_지정(date: LocalDate) {
    holidayCalendar += date
}

private fun 회원_등록(
    id: Long,
    알림_켜짐: Boolean = true,
    정책기준일: LocalDate = 신정,
    근무요일: Set<Workday> = Workday.WEEKDAYS,
) {
    workPolicies += WorkPolicyVersion(...)
    agreements += TermAgreement(memberId = id, termCode = TOS_CODE, agreed = true)
    settings += NotificationSetting(memberId = id, workNotificationEnabled = 알림_켜짐)
    fcmTokens += FcmToken(memberId = id, token = "token-$id")
}

테스트 전용 도메인 언어(DSL). 한글 함수명을 쓴 이유:

  1. 이 프로젝트 컨벤션 (다른 테스트도 한글 백틱 네이밍)
  2. 기획자/PM도 테스트 시나리오를 읽을 수 있게
  3. 비즈니스 의도를 기계적 세팅 코드와 분리

기본값 적극 활용:

  • 알림_켜짐 = true: 대부분 "켜짐"이므로 기본값
  • 정책기준일 = 신정: 대부분 공휴일 테스트
  • 기본값과 다를 때만 인자 지정 → 각 테스트가 "이 테스트에서 특별한 것"만 명시적으로 드러냄

DSL이란?
Domain-Specificic Language (도메인 특화 언어)라는 뜻을 가지고 있다.

단순 Kotlin 함수 호출인데, 이를 잘 작성해두면 읽을 때 도메인 시나리오처럼 읽혀 유비보수성을 높이는데 기여할 수 있다.

주로 모든 셋업이 테스트마다 반복되는 과정을 DSL을 사용하면 도움이된다.
공휴일_지정과 같은 용어로 함수를 지정하면 해당 과정이 뭘 위한 것인지 직관적으로 이해하는데 도움이 된다.
또한, 도메인 마다 다르게 해석될 수 있는 부분을 도메인 용어로 작성하여 도움을 줄 수 있다.

예를 들어 단순히 캘린더 자료구조에 데이터를 추가하는 행위가 약속을 추가하는 건지, 근무일을 추가하는 것인지 구분을 쉽게 할수 있다.

다만 동일한 개념이 자바에도 존재하지만 코틀린만큼 매끄럽지 않기 때문에 자바에서는 잘 사용되지 않는다고한다.

자바 시대부터 있던 Test Data Builder / Object Mother 패턴이 Kotlin의 표현력 좋은 문법(named/default args, 백틱 식별자)과 만나서 "거의 자연어처럼 읽히는 형태"로 진화한 것

7. 주니어가 자주 빠지는 함정 (ClaudeCode 작성)

함정 1: 커버리지 100%를 위해 분기당 테스트 1개 쓰기

안티패턴: if 하나당 테스트 하나.

옳은 접근: 사용자 관점의 시나리오당 테스트 하나. 내부 분기가 2개든 10개든 외부에서 관찰 가능한 행동 단위로 묶음.

예) "공휴일이어도 알림 꺼둔 회원 제외"는 내부적으로 isHoliday 분기 + eligibility 필터 분기를 거치지만 테스트는 1개. 사용자 관점에선 "OFF → 알림 없음" 한 시나리오이기 때문.

함정 2: verify로 모든 호출을 검증하기

안티패턴:

verify(exactly = 1) { workPolicyRepo.findLatestEffectivePoliciesPerMember(any()) }
verify(exactly = 1) { notificationLogRepo.findMemberIdsBy...(...) }
verify(exactly = 1) { eligibilityService.loadContext(any(), any()) }
verify(exactly = 1) { notificationLogRepo.saveAll(any()) }

이건 "구현을 박제"하는 것. 내부가 조금만 바뀌어도 테스트가 깨져 거짓 양성이 쏟아짐.

옳은 접근: 최종 상태만 검증. "어떻게 도달했는가"는 구현 세부사항.

verify가 정당한 순간은 "호출 자체가 명세일 때"뿐입니다. 예: FCM API에 올바른 토큰·제목·본문을 전달했는가 — 이건 호출이 곧 명세.

함정 3: @SpringBootTest 남발

안티패턴: 서비스 테스트마다 @SpringBootTest.

옳은 접근:

대상사용 어노테이션소요 시간
순수 비즈니스 로직어노테이션 없음 (단위)~10ms/테스트
Repository 쿼리@DataJpaTest~500ms/테스트
멱등성·트랜잭션 스모크@SpringBootTest (최소)~2s/테스트

@SpringBootTest는 컨텍스트 로딩에 초 단위 걸립니다. 100개면 5분. 속도는 테스트 문화의 복리입니다. 처음엔 체감 못해도 6개월 뒤에 팀 전체가 테스트 안 돌리는 조직이 됨.

함정 4: Given-When-Then 주석 남발

안티패턴:

@Test
fun `test name`() {
    // Given
    val x = ...
    // When
    val result = sut.doSomething(x)
    // Then
    assertThat(result).isEqualTo(...)
}

옳은 접근: 빈 줄로 섹션 구분. 주석 불필요.

@Test
fun `test name`() {
    공휴일로_지정(신정)
    회원_등록(id = 1L)

    sut.generateNotificationsForDate(신정)

    assertThat(notificationLogs).hasSize(1)
}

빈 줄 하나가 Given/When/Then을 자연스럽게 분리합니다. DSL 헬퍼 이름이 명확하면 주석은 소음.

함정 5: 테스트에 if/for/when 사용

안티패턴:

@Test
fun `다양한 케이스 한번에 검증`() {
    for (case in listOf(case1, case2, case3)) {
        if (case.isHoliday) {
            assertThat(...).isEqualTo(...)
        } else {
            assertThat(...).isEqualTo(...)
        }
    }
}

옳은 접근: 한 테스트 = 한 시나리오. 조건부 로직이 테스트에 있다는 건 여러 테스트가 섞였다는 신호.

다중 케이스를 테이블로 처리하고 싶다면 JUnit 5 @ParameterizedTest. if/for 금지.

함정 6: Fake 재현 로직이 틀렸는지 확인 안 하기

우리 테스트의 findLatestEffectivePoliciesPerMember Fake:

workPolicies
    .filter { !it.effectiveFrom.isAfter(date) }
    .groupBy { it.memberId }
    .map { (_, versions) -> versions.maxBy { v -> v.effectiveFrom } }

이게 진짜 JPQL과 같은 결과를 낼지 단위 테스트 레벨에선 증명 못 합니다. 그래서 @DataJpaTest 계약 테스트가 반드시 있어야 해요.

없으면? 단위 테스트는 전부 녹색인데 프로덕션은 터지는 사고가 납니다.

함정 7: 한 테스트에서 여러 assertion 남발

안티패턴: 한 테스트에서 assertThat(...)가 10줄.

옳은 접근: 한 테스트는 하나의 주장(assertion). 여러 측면을 검증하고 싶으면 테스트를 분리. 단, 같은 객체의 여러 속성을 검증하는 건 허용 (한 개념).

우리 테스트 ①에서 memberId, notificationType, scheduledDate, scheduledTime, status를 모두 검증한 건 "생성된 NotificationLog 객체의 완전성"이라는 하나의 개념에 대한 검증이므로 OK.

profile
기록하면서 레베럽

0개의 댓글