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

드코딩·2026년 5월 3일

실제 MOA 프로젝트에서 클로드 코드를 활용해 테스트 코드를 작성하고 이를 다시 분석하면서 테스트 코드 작성학습과 생각 정리를 위해 기록한다.

운영환경에서 테스트코드 작성 중요성을 체감하고 있고 이것이 가성비가 좋은 테스트인가? 라는 고민을 블라디미르 코리코프의 단위테스트 책을 읽고 시작하게 되었다.

일단 나의 경우 예시에 대입해서 내 생각을 정리하는 것을 좋아하는데, 최근 AI와 함께 좋은 예시를 만들어서 함께 토론하며 생각 정리하는데 정말 좋은 것 같다.

다시 본론으로 돌아와서 MOA 프로젝트에는 사용자들에게 FCM 을 이용해서 알림을 전송하는 기능이 존재하고, 여기에 공휴일엔 공휴일 전용 알림을 전송해 보고자 기능을 추가였고 이에 대한 테스트 코드를 작성하는 것이다.

MOA_GIT_REPO

1. 무엇을 왜 테스트하는가

대상 코드

매일 자정 KST에 NotificationBatchScheduler.createDailyNotifications()가 실행됩니다:

@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
@SchedulerLock(name = "createDailyNotifications", lockAtMostFor = "2m", lockAtLeastFor = "1m")
fun createDailyNotifications() {
    val today = LocalDate.now(ZoneId.of("Asia/Seoul"))
    notificationBatchService.generateNotificationsForDate(today)
}

그리고 NotificationBatchService.generateNotificationsForDate:

fun generateNotificationsForDate(date: LocalDate) {
    if (publicHolidayService.isHoliday(date)) {
        generateHolidayNotifications(date)  // ← 우리 테스트 대상
    } else {
        generateWorkNotifications(date)
    }
}

공휴일이면 PUBLIC_HOLIDAY 타입 NotificationLog를 적격 회원에게 09:00 예약으로 생성한다. 그다음 NotificationDispatchScheduler가 이 로그를 읽어 FCM으로 실제 발송합니다.

실제로 내가 부분을 테스트 할 것인지 클로드 코드에게 알려준다면 더 좋은 퀄리티의
테스트 코드를 작성할 수 있다고 생각합니다.

하지만 아직 테스트 코드를 많이 작성한 경험이 부족하고, 또한 잘못된 가성비가 좋지 않은 테스트 코드를 작성하게 시켜 오히려 좋지 않은 안정망이 만들어 질 수 있다는 핑계? 아닌 생각과 함께 모든 엣지 케이스를 고려하여 테스트 코드를 작성하게 시켰보았습니다.

해당 기능에서 발생할 수 있는 모든 엣지케이스를 고려해서 그 이유와 함께 제시하라는 프롬프트를 입력하였고 아래와 같은 결과를 받을 수 있었습니다.

이 기능이 깨지면 벌어지는 일 (ClaudeCode 답변)

실패 모드사용자 경험비즈니스 임팩트
알림이 안 감"오늘 공휴일 알림 안 왔네"낮음
알림이 두 번 감같은 시각 중복 푸시높음 — 앱스토어 리뷰 폭락
평일에 공휴일 알림업무일 아침 9시에 "오늘은 공휴일"매우 높음 — 즉각적 혼란
약관 미동의자에게 발송법적 분쟁 소지치명적 — 개인정보보호법 이슈

"알림이 두 번 가는 경우"가 실제로 일어날 확률은? 있습니다. 이유:

  • 배치는 재시도됩니다 (k8s pod 리스타트, ShedLock TTL 만료, 수동 재실행)
  • 멱등성은 findMemberIdsByScheduledDateAndNotificationTypeAndMemberIdIn 한 줄에 걸려 있고, 이 쿼리 이름이 오타나 리팩터로 깨지기 쉽습니다
  • 분기(공휴일/평일) × 필터(약관/설정/토큰)의 조합이 많아 리팩터링 중 실수 여지가 큽니다

다음으로 해당 문제들을 해결할 수 있도록 런던파고전파 의 관점에서 더 안정적이고 리팩토링 내성이 좋은 테스트 코드 작성을 요구했고, 다음과 같은 답변을 받을 수 있었습니다.

3. 런던파 vs 고전파 — 스타일 결정 (ClaudeCode 답변)

두 학파의 배경

2000년대 초 TDD 커뮤니티에서 단위 테스트를 어떻게 짤 것인가에 대해 두 진영이 갈렸습니다.

런던파 (London School / Mockist)

  • 주창자: Steve Freeman, Nat Pryce — Growing Object-Oriented Software, Guided by Tests (2009)
  • 철학: "단위"는 클래스 하나. 주변 협력자는 전부 mock.
  • 검증: 상호작용(interaction) — "A가 B에게 올바른 메시지를 보냈는가?"

고전파 (Classicist / Detroit School / Chicago School)

  • 주창자: Kent Beck, Martin Fowler, Vladimir Khorikov — Unit Testing: Principles, Practices, and Patterns (2020)
  • 철학: "단위"는 하나의 동작(behavior). 여러 클래스가 협력해서 만드는 결과.
  • 검증: 최종 상태(state) — "실행 후 세계가 어떻게 바뀌었는가?"

같은 로직을 두 스타일로 비교

대상: "공휴일에 적격 회원이면 알림 로그를 저장한다"

런던파 버전

@Test
fun `공휴일에 적격 회원이면 로그를 저장한다`() {
    every { publicHolidayService.isHoliday(날짜) } returns true
    every { workPolicyRepo.findLatestEffectivePoliciesPerMember(날짜) } returns listOf(정책)
    every { notificationLogRepo.findMemberIdsBy...(...) } returns emptyList()
    every { eligibilityService.loadContext(...) } returns 적격_컨텍스트
    every { eligibilityService.findRequiredTermCodes() } returns emptySet()

    sut.generateNotificationsForDate(날짜)

    verify(exactly = 1) {
        notificationLogRepo.saveAll(match<List<NotificationLog>> {
            it.size == 1 && it[0].memberId == 1L
        })
    }
}

고전파 버전 (우리 선택)

@Test
fun `공휴일에 적격 회원이면 로그를 저장한다`() {
    공휴일로_지정(신정)
    회원_등록(id = 1L)

    sut.generateNotificationsForDate(신정)

    assertThat(notificationLogs).hasSize(1)
}

왜 이 코드에 고전파를 선택했나

이유 1: "단위"는 클래스가 아니라 파이프라인이다

NotificationBatchService는 혼자 결정하지 않습니다. PublicHolidayService, NotificationEligibilityService와 협력해서 "공휴일 알림 생성"이라는 하나의 비즈니스 동작을 만듭니다.

런던파로 짜면:

  • NotificationBatchService 단위 테스트: 협력자 모두 mock
  • PublicHolidayService 단위 테스트: 따로 존재
  • NotificationEligibilityService 단위 테스트: 따로 존재
  • → 세 개가 연결됐을 때 올바른지는 아무도 보증 안 함 (이걸 통합 테스트가 대신 해야 하는데, 그러면 단위 테스트의 존재 이유가 약해짐)

고전파로 짜면:

  • 한 테스트가 세 서비스를 실제로 돌려 최종 상태를 검증
  • → 연결 자체가 피처라는 사실이 테스트에 녹아있음

이유 2: 리팩터링 내성

런던파는 "어떻게(how)"를 박제합니다. saveAll이 몇 번 호출됐는지, 어떤 인자로 호출됐는지를 다 검증해요.

예시: 최적화로 saveAll 한 번 대신 save 여러 번 호출하도록 바꾸면?

  • 런던파: 전부 빨개짐 (하지만 동작은 같음) → 거짓 양성
  • 고전파: 통과 (최종 저장 상태가 같으니까)

피처를 안 바꾸고 구현만 바꿀 때 테스트가 깨지면, 팀은 테스트를 신뢰하지 않게 됩니다.

이유 3: 기존 프로젝트 컨벤션

이미 src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt는 고전파 스타일입니다 (mock 없이 실물 계산기 사용). 컨벤션 일관성을 위해 같은 스타일을 유지합니다.

그럼 런던파는 언제 쓰나

외부 경계 어댑터에는 런던파가 더 자연스럽습니다:

  • FCM 클라이언트: "올바른 토큰·제목·본문으로 FCM API를 호출했는가?" → 호출 자체가 명세
  • 결제 PG 호출: 호출 인자가 계약
  • 이메일/Slack 발송: 상호작용 검증이 자연스러움

규칙: 내부 도메인 로직 = 고전파, 외부 경계 = 런던파. 실무에서 자주 쓰이는 분업입니다.

나의 생각
테스트를 처음 공부할 때, 고전파 개념은 알 수 없었지만 테스트 더블 방식의 Fake 클래스를 정의하고 객체를 만들어 수행하는 것이 빌드 속도가 빠르면서 실제로 테스트하고자 하는 것을 정확하게 판단할 수 있다는 점과 외부 의존 라이브러리는 변경될 수 있어도 이에 따른 결과는 변경돼서는 안된다. (테스트 코드를 포함해서) 고전 파는 최종 상태를 테스트 하는 리팩토링 내성에서 긍정적으로 생각한다.

하지만 Fake 객체를 만들기 위해서 클래스를 만들고 이를 사용하고 등등 더 많은 코드가 작성되고 정말 중요한 기능이 아니라면 복잡도와 초기 생산성이 너무 낮아질 것 같았다. 하지만 최근엔 ClaudeCode, Codex를 활용하면 정말 빠르고 쉽게 개발할 수 있다는 점에서 고전파의 단점을 극복할 수 있어진건 아닐까? 싶다.

나의 생각을 또 Claude에게 물어봤다.

내가 생각한 코드 양이 늘어나는 것과는 다른 고전파 단점을 알려줬다.

  • 객체 그래프 셋업 복잡도 — 한 테스트가 진짜 협력자들을 다 거치니까, 의미 있는 시나리오 하나 만들려면 많은 사전 상태가 필요해. (그래서 Object Mother, Test Data Builder 같은 패턴이 나옴)
  • 에지 케이스 강제 어려움 — "정확히 이 시점에 DB가 터지면?" 같은 시나리오는 mock이 훨씬 편해.
  • 테스트 실행 속도 — Fake로 어느 정도 해결되지만, 실물 의존성이 많아질수록 느려짐.
  • Fake와 실물의 행동 불일치 — in-memory repo가 실제 JPA repo와 미묘하게 다르게 동작하면 거짓 안심을 주게 됨. (Khorikov는 그래서 가능하면 Testcontainers 같은 실제 인프라 권장)

그렇다면 AI가 극복해주는 부분은 정확히 뭘까?(ClaudeCode 답변)

"AI로 빠르게 짜니까 고전파 단점이 해소된다"는 통찰은 맞는데, 어떤 단점이 해소되는지는 좀 더 정교하게 볼 필요가 있어:

  • 해소됨: Fake 클래스 초기 작성, Test Builder 보일러플레이트, 비슷한 패턴의 테스트 케이스 양산
  • ⚠️ 여전히 사람 영역: 어떤 시나리오를 테스트할지 결정, Fake와 실물의 행동 일치 검증, 테스트가 실패했을 때 디버깅, 리팩터링 시 테스트 의도 보존

오히려 AI 시대에 고전파가 더 유리해지는 다른 이유도 있어: AI가 짠 테스트는 종종 mock 설정이 과한 경향이 있고, 이런 테스트는 리팩터링할 때 우수수 깨져서 AI가 다시 고치고 또 깨지는 악순환을 만들어. 상태 검증 위주의 고전파 테스트는 AI가 나중에 코드를 수정해도 잘 안 깨지는 견고함이 있어서, 사람-AI 협업에 더 잘 어울려.

4. Spring Data JPA에서 고전파 구현하기 (ClaudeCode 답변)

문제: Repository는 애매한 의존성

고전파는 "in-process 협력자는 실물을 쓰라"고 합니다. 그런데 Repository는:

  • 인터페이스는 in-process
  • 실제 구현은 DB(out-of-process)

Khorikov가 이걸 명쾌히 정리했어요: "Repository는 DB 어댑터다. DB는 out-of-process니까 Repository도 out-of-process로 취급하고 대역으로 교체하라."

즉, 단위 테스트에서 Repository는 대역으로 대체. 단, 상호작용 감시용 mock이 아니라 인메모리 Fake로.

테스트 대역(Test Double) 5종

종류역할예시
Dummy자리만 채움null, 빈 객체
Stub정해진 값 반환every { x() } returns 5
Spy실물 + 호출 기록spyk(obj)
Mock호출 기대값 사전 설정 + 검증verify { x() }
Fake간단한 실제 동작 구현인메모리 Map으로 DB 흉내

고전파 = Fake 위주, 런던파 = Mock 위주입니다.

"MockK-as-Fake" 패턴

Spring Data JPA Repository는 JpaRepository<T, ID>를 확장해 수십 개 메서드를 상속합니다. 손으로 Fake 클래스를 만들면 다 구현해야 해서 현실적이지 않아요.

실무 해법: MockK를 Fake처럼 쓴다.

private val notificationLogs = mutableListOf<NotificationLog>()  // 인메모리 저장소

private val notificationLogRepo = mockk<NotificationLogRepository>().apply {
    // 쓰기: 리스트에 추가
    every { saveAll(any<Iterable<NotificationLog>>()) } answers {
        val added = firstArg<Iterable<NotificationLog>>().toList()
        notificationLogs += added
        added
    }
    // 읽기: 리스트에서 필터
    every {
        findMemberIdsByScheduledDateAndNotificationTypeAndMemberIdIn(any(), any(), any())
    } answers {
        val date = firstArg<LocalDate>()
        val type = secondArg<NotificationType>()
        val ids = thirdArg<Collection<Long>>()
        notificationLogs
            .filter { it.scheduledDate == date && it.notificationType == type && it.memberId in ids }
            .map { it.memberId }.distinct()
    }
}

이건 mock이 아니라 fake입니다. 왜냐하면:

  • verify { saveAll(...) }호출 횟수를 감시하지 않음
  • 대신 notificationLogs 리스트의 최종 상태를 단언
  • 라이브러리(MockK)는 같지만, 사용 의도가 Fake

📖 Khorikov Unit Testing 6장: "out-of-process 의존성을 in-memory로 대체하는 건 fake다." — 딱 이 패턴.

진짜 Fake 클래스 대신 MockK를 쓰는 이유

Q: "진짜 FakeNotificationLogRepository 클래스를 만드는 게 더 깔끔하지 않나요?"

A: 깔끔하지만 유지비가 큽니다.

  • JpaRepository에 메서드가 추가되면 Fake 클래스도 같이 수정해야 함
  • MockK-as-Fake는 실제로 호출되는 메서드만 stub하면 됨
  • 나머지 메서드를 호출하면 런타임에 "stubbing 안 됨" 에러 → 쓰지 않는 메서드는 구현 안 해도 되는 미니멀 전략

일단 현재까지 내가 이해한 부분으로는 Mockk은 원래 런던파 방식으로 Mock 객체를 만들어서 

특정 메서드가 실행되었다고 치고 호출 횟수를 검증하는 방식으로 내부 구현이 리팩토링되면 테스트가 깨지는 단점을 가지고 있고, Fake 객체는 실제 테스트하고자 하는 레포지토리에 메서드가 추가되면 이를 인터페이스로 만들어두고 페이크 클래스는 구현하는 형태로 만들어지기에 신규 기능 추가 시 컴파일이 깨지는 단점이 있다.

오히려 테스트 커버리지를 엄격하게 요구하는 조직이라면 신규 기능 추가 시 테스트가 깨지는 것은 장점일 수도? 라는 생각과 함께 이런식으로 하네스를 만들어보는 것도? 좋지 않을까?

일단 MockK가 어떻게 위 단점들을 우회할 수 있는지 궁금해서 추가 질문을 했다.

MockK이 이걸 어떻게 우회하나 (Claude 답변)

핵심은 MockK이 만드는 객체가 "타입은 만족하지만 행동은 비어있는 프록시" 라는 것.

kotlin

val repo = mockk<NotificationLogRepository>()

이 시점에서 repo는:

  • 타입 시스템 입장에선 NotificationLogRepository를 완벽히 구현한 객체
  • 실제로는 모든 메서드가 비어있음 (호출하면 MockKException 던짐)
  • 컴파일러는 "어 30개 메서드 다 있네" 하고 통과시킴

이게 트릭이야. 상속받은 메서드를 우리가 직접 구현할 필요가 없어졌어. 바이트코드 수준에서 프록시가 자동 생성돼서 모든 메서드 시그니처를 만족시키거든.

Mock 객체 생성원리와 동작 방식을 공부하고 이해하고 있어야 좋을 것 같다.

그 다음에 필요한 메서드만 "살려준다"

kotlin

every { repo.saveAll(any<Iterable<NotificationLog>>()) } answers {
    val added = firstArg<Iterable<NotificationLog>>().toList()
    notificationLogs += added  // ← 외부 상태에 기록
    added
}

여기서 핵심 문법 두 가지를 구분해야 해:

returns (정적 Stub)

kotlin

every { repo.count() } returns 5L

호출되면 항상 5 반환. 인자 무관, 상태 무관. 전형적인 Stub.

answers (동적 Fake)

kotlin

every { repo.findByMemberId(any()) } answers {
    val id = firstArg<Long>()
    notificationLogs.filter { it.memberId == id }
}

호출 시점에 람다가 실행됨. 인자를 받고, 외부 상태를 읽고, 결과를 계산해서 반환. 이게 Fake처럼 동작하는 핵심이야.

firstArg(), secondArg(), thirdArg()는 호출 시 실제 들어온 인자를 꺼내오는 헬퍼야. 타입 추론을 위해 firstArg<Long>()처럼 제네릭을 명시해.

"왜 Fake냐" — 사용 의도가 정한다

문서에서 강조한 부분:

kotlin

// London 스타일 (Mock으로 쓰는 법)
verify(exactly = 1) { repo.saveAll(match { it.size == 1 }) }
//     ^^^^^^^ ─ "saveAll이 1번 호출됐는지" 검증 = 상호작용 검증

// Classicist 스타일 (Fake로 쓰는 법)
assertThat(notificationLogs).hasSize(1)
//                          ^^^^^^^^ ─ "최종 상태가 어떤지" 검증 = 상태 검증

똑같은 MockK 객체인데, 무엇을 단언하느냐에 따라 정체성이 갈려.

  • verify { ... } 쓰면 → Mock 역할 → 런던파
  • 외부 컬렉션의 최종 상태만 보면 → Fake 역할 → 고전파

라이브러리는 도구일 뿐이고, 학파를 결정하는 건 단언의 대상이야. 이게 문서에서 "MockK-as-Fake"라고 부르는 이유고.

정리: 두 문제를 어떻게 동시에 해결하나

문제진짜 Fake순수 MockMockK-as-Fake
30+ 메서드 구현 강제❌ 다 구현해야 함✅ 자동 프록시✅ 자동 프록시
리팩터링 내성✅ 상태 검증❌ 호출 검증으로 깨짐✅ 상태 검증
신규 메서드 추가❌ 컴파일 에러✅ 영향 없음✅ 영향 없음

MockK이 제공하는 건 "타입 시스템 만족"이고, Fake 의미론은 우리가 answers + 외부 상태 + 상태 단언으로 만들어내는 것이야. 두 도구의 좋은 점만 결합한 셈.

한 가지 함정

every { repo.findByX(...) } answers { ... }로 정의 안 한 메서드를 SUT가 호출하면 런타임에 MockKException: no answer found이 터져.

이게 단점처럼 보이지만 사실 장점이야. "내가 미처 생각 못한 호출 경로가 있다"는 신호거든. 진짜 Fake였으면 빈 구현이 조용히 빈 결과를 반환해서 거짓 통과하는 일이 생길 수 있어. MockK은 시끄럽게 실패해서 알려줘.

(이 동작이 부담스러우면 mockk<T>(relaxed = true)로 완화할 수 있는데, 고전파 의도라면 strict가 정답이야. 모르는 호출이 있으면 알아야 하니까.)

나의 생각
이전에는 Mock 객체를 사용하면 무조건 런던파 방식이라는 틀린 생각을 가지고 있었고, 이번 테스트 코드는 이를 깨는 계기가 되었다.

내용이 너무 길어지는 것 같아 실제 테스트 파일 구조 해부의 경우 다음 포스팅으로 진행하겠습니다~

profile
기록하면서 레베럽

0개의 댓글