ai | test | tdd

TDD 관점에서 보는 테스트 전략 9가지 원칙

모킹 경계, Assertion 스타일, 테스트 피라미드, Flaky 테스트 처리까지, Classicist TDD 기반의 언어 공통 테스트 전략 9가지 원칙을 설명합니다.

Mimul
MimulFebruary 27, 2026 · 54 min read · Last Updated:

AI 코딩 도구가 일상이 된 지금, 테스트 코드도 AI가 생성하는 경우가 많아졌다. 문제는 AI가 작성한 테스트가 겉으로는 그럴듯해 보이지만, 구현 세부사항에 강하게 결합되거나 상호작용 검증에만 집중하는 패턴이 반복된다는 점이다. 리팩토링 한 번에 테스트 수십 개가 한꺼번에 깨지는 경험은 그 결과다.

이 글은 특정 언어나 프레임워크의 API 사용법이 아닌, TDD 관점에서 언어를 가로질러 적용 가능한 테스트 철학, 전략, 원칙들을 다룬다. Mockist(London School) 방식이 왜 AI 코드베이스에서 특히 취약한지, Classicist(Chicago School) 접근법이 왜 더 탄탄한지를 중심으로, 현장에서 바로 적용할 수 있는 9가지 원칙을 정리한다. 이 테스트 철학, 전략, 원칙들은 claude의 사용자 레벨(모든 프로젝트 공통)에 구성하고 그 기반 아래 언어별로 상세 테스트 스펙을 프로젝트 디렉토리의 rules 아래에 구현하면 된다.

대상 독자는 TDD 기초는 알고 있지만 실제 코드베이스에 적용할 때 흔들리는 개발자, 또는 AI 도구를 사용하면서 테스트 품질이 낮아졌다고 느끼는 팀이다.

테스트 철학

9가지 원칙은 모두 여섯 가지 철학에서 파생된다. 이 철학을 먼저 받아들이지 않으면, 개별 규칙은 근거 없는 제약으로 느껴질 수 있다.

구현이 아닌 동작을 테스트한다. 순수 리팩토링은 테스트를 깨뜨려서는 안 된다.

테스트의 역할은 코드가 어떻게 동작하는지가 아니라 무엇을 하는지를 보장하는 것이다. 메서드 이름을 바꾸고, 반복문을 재귀로 교체하고, 중간 변수를 제거하는 리팩토링은 외부 동작을 바꾸지 않는다. 이런 변경에 테스트가 깨진다면, 그 테스트는 동작이 아닌 구현을 잠근 것이다. 리팩토링 후 테스트를 고치느라 시간을 쓰는 패턴이 반복된다면, 테스트가 방어망이 아닌 짐이 됐다는 신호다.

시스템 경계에서만 모킹한다. 경계 안쪽은 전부 실제다.

“어디까지 내 코드인가”를 기준으로 경계를 그린다. 내가 소유하고 제어할 수 있는 모든 것은 실제 구현을 사용한다. 데이터베이스, 캐시, 내부 서비스가 여기 해당한다. 반면 외부 결제 API, 서드파티 OAuth 서버, 운영체제의 시스템 시계처럼 내가 제어할 수 없는 것은 경계 밖이다. 경계 안쪽을 모킹하면 협력 객체의 실제 동작이 검증에서 빠지고, 구현 변화에 취약한 테스트만 남는다.

Classicist(Chicago) TDD를 선호한다. Mockist(London) 방식은 AI 기반 코드베이스에서 신뢰도가 빠르게 낮아진다.

이 선택은 다음 섹션에서 자세히 다루지만, 핵심은 단순하다. Mockist 방식은 협력 객체 간 상호작용을 검증하기 때문에 구현에 결합된다. AI가 코드를 리팩토링하거나 재생성할 때마다 구현이 달라지고, 달라진 구현마다 Mockist 테스트가 따라서 깨진다. Classicist 방식의 테스트는 최종 상태와 반환값을 검증하므로, AI가 내부를 어떻게 바꾸든 사양을 만족하면 통과한다.

통합 테스트를 단위 테스트보다 우선한다.

단위 테스트는 빠르지만 실제 시스템과 멀어질수록 검증력이 떨어진다. 유스케이스 하나를 실제 DB와 함께 통합 테스트로 검증하면, 그 경로 위의 단위 테스트 여러 개를 대체한다. 단위 테스트는 통합 테스트로 커버하기 어려운 복잡한 도메인 로직이나 엣지 케이스에만 집중한다.

테스트는 빠르고(Fast), 격리되어 있으며(Isolated), 결정적(Deterministic)이어야 한다.

느린 테스트는 실행을 기피하게 만들고, 격리되지 않은 테스트는 실행 순서에 따라 결과가 달라지며, 비결정적 테스트는 CI를 무작위로 깨뜨린다. 셋 중 하나라도 무너지면 테스트 스위트 전체에 대한 신뢰가 흔들린다. 세 조건을 모두 갖춰야 테스트가 실질적인 안전망이 된다.

의미 있는 소수의 테스트가 신뢰도 낮은 다수의 테스트보다 가치 있다.

테스트 커버리지 숫자를 높이려고 단순 getter, 프레임워크가 자동으로 처리하는 초기화 코드, 설정 상수까지 테스트하는 경우가 많다. 이런 테스트는 실행 비용만 높이고, 실제 버그를 잡지 못한다. 테스트 한 줄을 추가하기 전에 “이 테스트가 없으면 실제 버그가 통과할 수 있는가”를 먼저 묻는다. 그 답이 “아니오”라면 테스트를 추가하지 않는 것이 낫다.

Classicist vs Mockist — 두 학파와 AI 시대의 선택

TDD에는 두 가지 뚜렷한 학파가 있다. 1990년대 말 Kent Beck이 주도한 디트로이트 방식이 Classicist(Chicago School)이고, 2000년대 초 런던 XP 커뮤니티에서 Steve Freeman과 Nat Pryce가 발전시킨 방식이 Mockist(London School)다. 두 학파는 “테스트에서 협력 객체를 어떻게 다루는가”를 두고 갈린다.

Mockist(London School) 는 테스트 대상 객체가 협력 객체와 어떻게 상호작용하는지를 검증한다. 협력 객체는 Mock으로 교체하고, verify(userRepo).save(user) 같은 방식으로 올바른 메시지가 전달됐는지를 확인한다. 장점은 객체 간 계약을 명시적으로 드러낸다는 것이다. 다만 이 방식의 취약점은 Mockist 자체에 있는 것이 아니라 over-specification 에 있다. 호출 순서, 인자 목록, 내부 메서드 이름까지 전부 검증하는 테스트는 구현이 조금만 바뀌어도 깨진다. 반면 의미 있는 상호작용만 골라 검증하는 좋은 Mockist 테스트는 훨씬 안정적이다.

Classicist(Chicago School) 는 유스케이스 전체를 실제 협력 객체와 함께 실행하고, 최종 상태나 반환값을 검증한다. 데이터베이스도 실제(또는 테스트 전용 DB)를 사용한다. 리팩토링이 일어나도 동작이 동일하면 테스트는 통과한다. 단점은 테스트 환경 구성 비용이 높다는 것이다.

왜 AI 코드베이스에서 over-specification이 더 자주 발생하는가.

AI 도구는 기능을 빠르게 생성하지만, 내부 구현의 형태를 자주 바꾼다. 변수명을 정리하고, 메서드를 합치거나 분리하고, 호출 순서를 재배치한다. 더 큰 문제는 AI가 Mockist 스타일의 테스트를 생성할 때다. AI는 메서드 호출 패턴에서 테스트를 역으로 추론하기 때문에, 생성된 테스트가 구현과 1:1로 결합되는 경향이 있다. 즉 AI 환경에서는 over-specified Mockist 테스트가 기본값이 되기 쉽고, 이것이 “구현을 바꿀 때마다 테스트를 다시 짜야 하는” 패턴을 만든다. 이 반복이 누적되면 팀은 테스트를 신뢰하지 않게 되고, “AI가 코드를 짜고 테스트는 형식적으로 맞춰준다”는 패턴이 고착되기 쉽다.

상호작용 검증이 적합한 영역은 따로 있다.

두 학파의 핵심 차이는 검증 대상이다. Mockist는 “올바른 메시지가 전달됐는가(상호작용)“를 보고, Classicist는 “올바른 결과가 나왔는가(상태)“를 본다. 내부 비즈니스 로직에서는 상태 검증이 대부분 더 적합하지만, 상호작용 검증이 본질적으로 맞는 영역이 있다. 외부 결제 API 호출, 메시지 큐 발행, 이메일 발송 같은 side-effect가 그렇다. 이런 경우는 결과 상태로 검증하기 어렵고, “이 메시지가 실제로 전달됐는가”가 곧 테스트의 핵심이다. 이 영역에서는 상호작용 검증이 더 본질적이다. 결국 선택 기준은 학파가 아니라 검증하려는 동작의 성격이다. 내부 로직은 상태로, 외부 side-effect는 상호작용으로 검증하는 것이 원칙에 부합한다.

1. 모킹 경계 — 무엇을 대체하고, 어떻게 대체하는가

테스트에서 가장 많은 논쟁을 낳는 주제다. 기준은 “내부냐 외부냐”보다 더 정밀해야 한다. 내가 통제하지 못하거나 비결정적인 것을 대체하고, 나머지는 실제 구현을 사용한다.

실무에서 경계는 세 층으로 나뉜다. 프로세스 경계(네트워크, 외부 시스템)는 반드시 대체한다. 시간/환경 경계(시스템 시계, 파일 시스템, 난수)도 제어 불가능하므로 대체한다. 논리적 경계(레이어, 모듈)는 테스트 의도와 범위에 따라 결정한다.

Mock / Stub / Fake — 세 가지를 구분한다

“모킹”이라는 단어 하나로 뭉뚱그려지지만, 역할이 다르고 적합한 상황도 다르다.

  • Mock — 상호작용 검증 목적. “이 메서드가 올바른 인자로 호출됐는가”를 확인한다
  • Stub — 고정된 응답 반환. 상호작용 검증 없이 특정 응답을 주입한다
  • Fake — 동작하는 간단한 구현(in-memory 등). Mock처럼 호출 여부를 검증하지 않고, 실행 후 결과 상태를 확인하는 방식으로 테스트한다

이 글에서 권장하는 방향은 Mock을 줄이고 Fake와 실제 구현을 늘리는 것이다. Mock은 over-specification으로 흐르기 쉽고, Fake는 상태 검증과 자연스럽게 연결된다.

대체해야 하는 대상:

  • 서드파티 HTTP API (결제, OAuth, 외부 알림 서비스) — HTTP 레벨 Fake 서버로 대체
  • 파일 시스템, 시스템 시계, 난수 생성기 — 주입 가능한 인터페이스로 교체
  • 프로세스 경계를 넘는 모든 것 (메시지 큐, 외부 네트워크 소켓) — 대체 필수

피해야 하는 대체:

  • DB/ORM을 Mock으로 대체 — 인덱스, 외래키, 트랜잭션 격리 수준은 Mock으로 재현할 수 없다. 테스트 종류에 따라 전략이 달라진다.
    • 단위 테스트(서비스 순수 로직): DB를 직접 붙이지 않고 in-memory Fake Repository를 사용한다
    • 통합 테스트(Repository, 유스케이스): 실제 DB(Testcontainers 등)를 사용한다
  • 순수 함수와 유틸리티 — 실행 비용이 없으므로 대체할 이유가 없다
  • 내가 소유한 Value Object, DTO, 도메인 엔티티 — 직접 생성해서 사용한다

DB를 Mock하지 말라 는 것이지, 항상 실제 DB를 붙여야 한다 는 뜻이 아니다. Mock은 DB 동작을 흉내만 내지만, Fake는 실제로 동작하기 때문에 상태 기반 검증이 가능하다.

같은 코드베이스 내 의존성도 기계적으로 real/mock을 결정하지 않는다. 기준은 “같은 코드인가”가 아니라 이 테스트가 검증하려는 동작 범위에 포함되는가 다. 안정적인 도메인 로직은 real로, 무겁고 변화가 잦은 의존성은 Fake나 Stub으로 대체할 수 있다.

Mock된 Repository 테스트가 전부 통과했음에도 프로덕션 배포 후 트랜잭션 오류로 장애가 발생하는 경우가 드물지 않다. 테스트가 통과한다는 것과 시스템이 올바르게 동작한다는 것은 다른 이야기다.

HTTP 경계에서는 인터페이스 Mock보다 HTTP 레벨의 Fake 서버(wiremock, msw, nock 등)를 선호한다. 직렬화/역직렬화, HTTP 헤더 처리, 상태 코드 분기까지 검증 범위에 포함되기 때문이다.

대체가 허용되는 기술적 기준:

Mock보다 Fake를 먼저 고려하되, 다음 조건 중 하나에 해당할 때 내부 의존성을 Test Double로 대체하는 것을 허용한다(substituting internal collaborators).

  • 비결정성 제거 — 시스템 시계, 난수처럼 테스트를 불안정하게 만드는 요소
  • 재현 불가능한 실패 시뮬레이션 — 실제 인프라에서 만들기 어려운 에러 케이스
  • 피드백 루프 단축 — 순수 계산 로직 검증에서 DB 기동이 불필요한 경우

세 조건 중 어디에도 해당하지 않는 내부 의존성 대체는 테스트 범위를 좁힐 뿐, 신뢰도를 높이지 않는다.

2. Assertion 스타일 — 관찰 가능한 결과를 검증하라

테스트 구조는 AAA 패턴으로 한다. Arrange(테스트에 필요한 상태와 의존성을 준비), Act(검증 대상 동작을 딱 한 번 실행), Assert(관찰 가능한 결과를 검증). 세 단계가 명확히 구분되지 않는 테스트는 한 번에 여러 동작을 검증하거나 Arrange가 Act에 뒤섞인 것이다. 구조를 먼저 정리한다.

좋은 Assertion의 기준은 관찰 가능성(Observability) 이다. 외부에서 관찰할 수 있는 결과만 검증한다. 반환값, DB 상태, API 응답, 이벤트 발생 여부가 여기 해당한다. 반면 내부 메서드 호출 순서나 협력 객체에게 전달된 인자는 외부에서 관찰할 수 없는 구현 세부사항이다.

원칙은 상호작용 검증을 기본 수단으로 삼지 않는다 이지, 상호작용 검증 자체를 배제하는 것이 아니다. 함수 내부를 리팩토링해서 DB 조회 횟수를 줄이거나 메서드 호출 순서를 바꾸면 toHaveBeenCalledWith 중심 테스트는 깨진다. 동작은 동일한데도 불구하고.

상호작용 검증 함수란? toHaveBeenCalledWith(Jest/JS), mockall::expect(Rust), verify(Java Mockito)는 모두 “이 함수가 특정 인자로 호출됐는가”를 검증하는 Mock 전용 함수다. 결과 상태가 아닌 호출 사실 자체를 확인하기 때문에, 내부 구현이 바뀌면 동작이 동일해도 테스트가 깨진다.

// 상호작용 검증 — 구현에 결합
expect(userRepo.save).toHaveBeenCalledWith(user);

// 상태 검증 — 결과에 집중
const saved = await db.users.findByEmail(user.email);
expect(saved).not.toBeNull();

기본 원칙 — 결과 검증 우선:

  • 반환값과 관찰 가능한 상태(DB 내용, API 응답)를 검증한다
  • 상호작용 검증(toHaveBeenCalledWith, verify)은 보조 수단이다
  • 부작용(DB 쓰기, 이벤트 발행)은 명시적으로 검증한다

쓰면 안 되는 패턴:

  • 내부 메서드 호출 여부 검증 — 리팩토링 시 동작이 동일해도 테스트가 깨진다
  • repository.save() 호출 여부 검증 — DB에서 직접 조회해 저장 여부를 확인하는 것이 맞다
  • “이 함수가 불렸는가” 중심 테스트 — 구현 세부사항을 잠근 것이다

객체 비교 — 전체 vs 부분:

안정적인 DTO처럼 결정적인 출력이라면 전체 비교가 간결하고 누락 방지에 유리하다.

expect(result).toEqual(expected) // 안정적인 DTO에서 유효

그러나 타임스탬프, 자동 생성 ID처럼 비결정적 필드가 섞인 객체는 전체 비교가 오히려 테스트를 불안정하게 만든다. 이 경우 의미 있는 핵심 필드만 골라 검증한다.

expect(result).toMatchObject({
  name: "kim",
  status: "ACTIVE"
}) // 비결정적 필드(id, createdAt)는 무시

전체 비교는 필드가 추가될 때마다 테스트가 깨지는 부담도 생긴다. “이 필드가 검증에 필요한가”를 먼저 판단한다.

비결정성 — 피하기보다 제거한다:

타임스탬프, LLM 응답, 순서 없는 집합은 스냅샷으로 저장하지 않는다. 더 나아가 비결정성을 테스트 코드 안에 피하는 것보다, 소스에서 제거하는 것이 낫다. 시스템 시계는 주입 가능한 인터페이스로 교체하고, UUID 생성기도 주입으로 제어 가능하게 만들면 테스트에서 고정된 값을 사용할 수 있다.

상호작용 검증이 허용되는 경우:

상호작용 검증은 다음 중 하나에 해당할 때만 사용한다.

  • side-effect 자체가 비즈니스 요구사항 — 이메일 발송, 메시지 큐 발행, 외부 API 호출. “발생했는가”라는 이벤트 자체가 요구사항이다
  • 외부 시스템과의 호출 계약 검증 — 결과 상태로 확인할 수 없는 경우
  • 호출 정책 검증 — rate limit, retry, idempotency key 전달처럼 호출 방식 자체가 계약인 경우

부작용 검증에는 두 가지 방법이 있고, 둘을 구분해서 사용한다.

  • 상태 검증(기본값) — DB 조회로 쓰기가 반영됐는지 확인
  • 상호작용 검증(이벤트 등) — 이벤트 버스나 Fake로 발행 여부 확인. 이벤트는 DB처럼 조회할 수 없으므로 상호작용 검증이 정당화된다

판단 기준은 검증하려는 것이 도메인 요구사항인가, 단순 구현 세부사항인가 다. 요구사항이라면 상호작용 검증이 허용된다.

예를 들어 사용자 생성 기능을 테스트할 때, userRepository.save()가 호출됐는지를 검증하는 것은 구현 세부사항이다. 올바른 방향은 실제 DB에서 레코드를 조회해 저장됐는지 확인하는 것(상태), 그리고 가입 완료 이벤트가 발행됐는지 확인하는 것(상호작용)을 함께 검증하는 것이다.

3. 테스트 네이밍 — 행동 기반 템플릿

테스트 이름은 구현 방식이 아닌 관찰 가능한 동작을 설명해야 한다. 좋은 테스트 이름은 테스트가 실패했을 때 무엇이 깨졌는지 바로 파악할 수 있게 해준다.

나쁜 이름 — 구현 중심:

test_findUnique_called_once()
test_calls_upsert_then_emits_event()
should_work()

좋은 이름 — 행동 중심:

returns_cached_result_when_fetched_within_ttl()
rejects_login_when_password_is_expired()
charges_full_price_for_non_vip_users()

권장 템플릿:

<action>_<expected_behavior>_when_<condition>

예시:

  • create_todo_succeeds_when_input_is_valid
  • create_todo_fails_when_title_is_empty
  • login_rejects_when_password_is_incorrect
  • get_user_returns_none_when_not_found

동사는 일관되게 사용한다 (create, get, update, delete, returns, fails, rejects). works, handles, processes 같은 모호한 표현은 피한다.

이 템플릿의 또 다른 장점은, 테스트 이름을 먼저 쓰면 무엇을 테스트할지 명확해진다는 점이다. “어떤 조건에서 어떤 행동이 어떤 결과를 낳는가”를 한 문장으로 표현하지 못한다면, 테스트 대상 자체가 불분명한 것이다.

4. 테스트 피라미드 — 레이어별 권장 비율과 생략 기준

테스트 피라미드는 레이어별로 테스트 비용과 피드백 속도가 다르다는 현실에서 출발한다. Shift Left Testing의 핵심 근거도 같은 데이터에서 나온다. IBM Systems Sciences Institute 연구에 따르면 결함을 코딩 단계에서 발견할 때의 수정 비용을 1로 볼 때, 통합 테스트 단계는 약 15배, 프로덕션 배포 후에는 최대 100배까지 증가한다. 피라미드 하단(단위 테스트)에 비중을 두는 것은 피드백 속도뿐 아니라 수정 비용을 최소화하기 위한 경제적 선택이다.

레이어목적권장 비율
Unit알고리즘, 도메인 규칙, 경계 조건, 복잡한 분기많음 — 단, 비자명한 로직에만
Integration유스케이스 + 실제 DB/캐시/큐유스케이스당 1~3개
E2E핵심 사용자 경로 (API 수준)경로당 1개

Google은 단위/통합/E2E 비율을 70:20:10으로 권장한다. 하지만 Justin Searls의 말처럼 “테스트 유형 비율 논쟁은 산만함. 명확한 경계, 빠른 실행, 신뢰성 있는 테스트가 핵심”이다. 피라미드의 가치는 특정 숫자가 아니라 “하단으로 갈수록 많고 빠르며, 상단으로 갈수록 적고 느리다”는 방향성에 있다. Regression 테스트는 별도 레이어가 아니라 과거 버그 재현을 목적으로 Unit 또는 Integration 레벨에서 작성하는 테스트다.

표의 숫자는 기본값이지, 상한선이 아니다. 유스케이스 복잡도나 비즈니스 리스크가 높다면 더 많이 작성한다. “정해진 개수를 채운다”가 아니라 “최솟값에서 시작해 리스크에 따라 확장한다”가 올바른 사용법이다.

언제 생략할 수 있는가:

  • 권한, 필터링, 조건 분기가 없는 순수 CRUD는 별도 단위 테스트 없이 통합 테스트 하나로 충분하다. 단, CRUD라도 최소 1개의 통합 테스트는 유지한다
  • 프레임워크 자체(DI, 라우팅, 모듈 등록)는 프레임워크가 이미 테스트하고 있다. 다만 우리 설정이 프레임워크에 올바르게 연결되는지는 얇게 검증한다 (예: 잘못된 annotation, 잘못된 route binding)
  • 정적 설정/상수는 타입 시스템이나 스키마 검증기가 대신한다
  • 삭제 예정 코드는 원칙적으로 테스트를 추가하지 않는다. 단, 그 코드에 변경이 발생한다면 최소한의 보호 테스트를 추가한다. “곧 삭제”는 실제로 오래 유지되는 경우가 많고, 테스트 없이 방치되면 이후 삭제나 리팩토링이 더 어려워진다

반드시 테스트해야 하는 것:

  • 인증, 결제, 권한 같은 비즈니스 핵심 로직
  • 복잡한 분기 또는 상태 전환
  • 과거에 버그가 있었던 경로 (회귀 테스트) — 순서는 “버그 재현 테스트 작성 → 실패 확인 → 수정”이다

중요한 실행 원칙은 성능이다. 트랜잭션 롤백과 컨테이너 재사용을 전제로, 통합 테스트는 개발 피드백 루프를 방해하지 않는 수준으로 유지한다 (CI 환경과 DB 종류에 따라 다르지만 실무 기준으로 100 ~ 300ms를 목표로 삼는다). 전체 DB 초기화보다 트랜잭션 롤백을 우선하고, 병렬 실행 시 트랜잭션 격리가 테스트 간 공유 상태를 만들지 않는지 주의한다. 비용이 큰 외부 시스템 연동 테스트는 환경 변수 플래그(LIVE_TEST=true)로 격리해 일반 CI에서는 실행하지 않는다.

5. 도메인 엔티티 추출 — 언제, 왜

서비스 레이어에 비즈니스 로직이 쌓이면 테스트가 점점 복잡해진다. DB가 없으면 로직을 실행할 수 없고, 순수한 계산 로직 하나를 검증하기 위해 통합 환경을 통째로 띄워야 한다. 이 신호가 보이면 도메인 엔티티를 추출할 때다.

추출 기준 — 다음 중 하나라도 해당하면 추출한다:

  • 같은 데이터를 대상으로 비즈니스 로직이 2개 이상의 서비스에 흩어져 있다
  • 서비스가 ORM 행(row)에 직접 산술 연산이나 상태 전환을 수행한다
  • 실제로는 순수 로직인데 테스트를 위해 DB를 띄워야 한다
  • 불변식(invariant)이 여러 서비스에 중복 체크되고 있다 (예: “할인액은 최대 한도를 초과할 수 없다”, “CONFIRMED 주문은 PENDING으로 되돌릴 수 없다”)

같은 규칙이 세 군데에 흩어져 있다면 엔티티로 끌어올려 한 곳에서 보장해야 한다. DDD에서 불변식 보호는 Aggregate Root의 책임이다. Aggregate Root는 경계 내 모든 상태 변화의 단일 진입점으로서, 어떤 경로로도 불변식이 깨지지 않도록 통제한다.

Before — 서비스에 로직이 뒤섞인 상태:

// OrderService
const discountAmount = Math.min(order.totalPrice * discount.rate, MAX_DISCOUNT)
order.discountAmount = discountAmount
order.finalPrice = order.totalPrice - discountAmount
order.status = 'CONFIRMED'
await db.order.update(order)

After — 엔티티에 로직을 이전한 상태:

// OrderService
order.applyDiscount(discount)
order.confirm()
await orderRepo.save(order)

엔티티 메서드는 자기 상태와 불변식에만 책임을 가진다. 정책이 외부에서 주입되거나 다른 aggregate와 연관된 경우, 또는 시간/환경에 의존하는 경우는 엔티티 내부에서 처리하지 않는다.

Order.applyDiscount()는 이제 순수 메모리 기반 단위 테스트가 된다. DB 없이 밀리초 단위로 실행되고, 모킹도 필요 없다. 엔티티가 상태를 직접 보유하기 때문에 호출 여부가 아닌 결과 상태를 검증하는 상태 기반 테스트가 자연스럽게 가능해진다. 도메인 로직을 엔티티에 캡슐화하면 테스트 속도가 빨라지고, 로직이 여러 서비스에 중복되는 문제도 함께 해결된다. 엔티티 추출의 가장 직접적인 효과는 단위 테스트를 가능하게 만드는 구조 자체를 확보하는 것이다.

이 원칙은 DDD(도메인 주도 설계)의 Rich Domain Model과 맞닿아 있다. Fowler는 Anemic Domain Model을 안티패턴으로 규정한다. “In essence it’s really just a procedural style design, exactly the kind of thing that object bigots like me were fighting against in the 1990s.” 빈약한 도메인 모델은 서비스 비대화와 테스트 복잡도 상승을 동시에 유발한다. 다만 Rich Domain Model이 항상 더 좋은 것은 아니다. CRUD 중심의 단순한 시스템에서는 Anemic 모델이 더 단순하고 적합하다. Rich Domain Model이 효과를 발휘하는 것은 도메인 복잡도가 일정 수준 이상일 때다.

과도한 엔티티화를 경계한다:

엔티티로 로직을 옮기다 보면 단순 setter까지 메서드화하거나 의미 없는 래핑을 추가하는 경우가 생긴다. 엔티티는 단순 데이터 보관이 아니라 의미 있는 규칙과 상태 전환을 캡슐화할 때만 도입한다. 추출 기준에 해당하지 않는 단순 필드 변경은 엔티티 메서드로 만들지 않는다.

서비스 레이어는 엔티티를 도입해도 사라지지 않는다. 서비스는 유스케이스 조율(orchestration) — 여러 엔티티를 연결하고 외부 시스템과 통합하는 — 역할을 계속 담당한다. 엔티티 추출의 목표는 서비스를 없애는 것이 아니라, 서비스에 있던 도메인 규칙을 엔티티로 돌려보내 각자의 역할을 명확히 하는 것이다.

6. Property-Based Testing — 예시 테스트로 부족할 때

일반적인 예시 기반(example-based) 테스트는 개발자가 직접 생각해낸 케이스만 검증한다. 경계값, 예외적인 조합, 엣지 케이스를 빠뜨리기 쉽다. Property-Based Testing은 이 빈틈을 메운다.

// example-based — 개발자가 떠올린 케이스 하나만 검증
test('sort works', () => {
  expect(sort([3, 1, 2])).toEqual([1, 2, 3])
})

// property-based — 모든 입력에 대해 규칙(불변 조건)을 검증 (fast-check)
fc.assert(
  fc.property(fc.array(fc.integer()), arr => {
    // 멱등성: 두 번 정렬해도 결과는 동일하다
    expect(sort(sort(arr))).toEqual(sort(arr))
  })
)

속성 기반 테스트는 명확한 불변 조건(invariant)이 있는 순수 로직에 적합하다:

  • 검증기(validator)
  • 도메인 규칙
  • 파서/매퍼
  • 상태 머신
  • 계산 로직
  • 정렬/검색/변환 함수 — idempotent, commutative, associative 같은 algebraic 성질이 있는 것

대표 라이브러리: fast-check (TypeScript), hypothesis (Python), proptest (Rust)

전환 시점: 여러 입력에 대해 동일한 규칙을 반복해서 검증하고 있다면 속성 기반 테스트로의 전환을 고려한다. “네 번째 예시 테스트를 쓰는 순간”은 이 신호를 감지하는 하나의 휴리스틱이지 절대 기준은 아니다. 함수에 따라 예시 2개로도 property가 명확할 수 있고, 예시 10개여도 property가 불분명할 수 있다.

적합하지 않은 곳:

  • 네트워크 의존, 외부 API 연동 — 속도와 결정성 문제
  • 부작용이 주목적인 유스케이스

단, 조건부로 가능한 영역도 있다. 상태 머신 + persistence, idempotent API처럼 명확한 속성을 가진 경우는 통합 테스트에 속성 기반 접근을 결합할 수 있다. “insert 후 반드시 조회 가능”, “동일 요청을 두 번 보내도 결과가 같다” 같은 속성이 그 예다.

Shrinking — property-based testing의 진짜 강점:

테스트가 실패할 때 단순히 “실패한 입력”을 보여주는 게 아니라, 실패를 재현하는 최소 재현 케이스(minimal reproducible example) 를 자동으로 찾아준다. 수천 개의 무작위 입력 중 하나가 실패했을 때 원인을 직접 추적하는 것은 어렵지만, shrinking이 적용된 도구는 그 입력을 최대한 단순화해서 돌려준다. fast-check, proptest, hypothesis 세 라이브러리 모두 기본으로 지원한다.

Generator 품질 — 랜덤과 좋은 테스트는 다르다:

속성 기반 테스트의 품질은 generator가 결정한다. 완전 무작위 입력은 도메인 제약을 무시하고, 실제로 발생 가능한 엣지 케이스를 놓치기 쉽다. 좋은 generator는 엣지 케이스(0, 최대값, 빈 문자열, 음수)를 명시적으로 포함하고, 도메인 제약(유효한 이메일 형식, 0~100 범위)을 반영한다. “random이니까 자동으로 엣지 케이스를 찾아준다”는 생각은 절반만 맞다.

속성 기반 테스트의 핵심은 각 속성이 명확한 불변 조건을 정의해야 한다는 점이다. “어떤 입력을 넣어도 X 속성이 성립한다”를 한 문장으로 표현할 수 없다면, 그 테스트는 방향이 잘못된 것이다. 속성 기반 테스트는 반복 실행하므로 실행 횟수와 속도를 함께 고려한다. 빠르게 실행 가능한 범위에서 반복 횟수를 조절하는 것이 올바른 운용 방식이다.

AI 도구는 구체적인 예시 테스트는 잘 생성하지만, invariant를 직접 정의하는 데는 취약하다. 예시 케이스를 나열하는 것은 쉽지만, “어떤 입력에서도 성립해야 하는 불변 조건”은 도메인 이해 없이는 만들기 어렵다. 속성 기반 테스트는 AI가 놓치는 이 영역을 인간이 채워야 하는 테스트 전략이다.

7. Flaky 테스트 — 격리와 근본 원인 제거

불안정한(flaky) 테스트는 팀의 신뢰를 갉아먹는다. CI가 무작위로 빨간불을 켜면 개발자들은 CI 결과를 무시하기 시작하고, 그 순간부터 테스트 스위트는 존재 의미를 잃는다. Fowler의 표현을 빌리면 “Non-deterministic tests can completely destroy the value of an automated regression suite.”

원칙:

  1. 불안정한 테스트는 커밋하지 않는다. 이미 들어왔다면 빠르게 격리한다(실무 기준으로 24시간을 넘기지 않는다). 탐지 → 분류 → 격리의 흐름을 명확히 하고, 동일 테스트가 반복 실패하면 머지를 차단하는 기준을 CI에 정의해둔다(실무 기준으로 2회 이상).
  2. 격리(quarantine)는 skip/ignore 처리 + 이슈 링크 + 담당자 지정 + 기한 설정 + 원인 가설 기록을 의미한다. 담당자도 가설도 없으면 삭제한다. 원인 가설 없이 격리만 하면 같은 flaky가 반복 생성된다.
  3. 재시도 루프, sleep() 삽입, 타임아웃 늘리기는 증상 완화일 뿐, 근본 원인 없이 장기 유지하지 않는다. 인프라 노이즈나 외부 의존성 때문에 임시 재시도가 필요한 경우라도, 별도 이슈로 추적하고 일정 기한 내에 제거한다.

흔한 근본 원인:

  • 공유 전역 상태 — 테스트 간 격리 미흡
  • 실제 시스템 시계 의존 — 시계를 주입 가능하게 설계
  • 테스트 실행 순서 의존 — 각 테스트가 독립적으로 설정/정리해야 함
  • 시드 없는 난수 생성 — 결정적 시드를 사용
  • 네트워크 의존 — 경계에서 모킹하거나 실제 인프라로 교체
  • 비동기 처리 미완료 — race condition. async 코드는 완료 시점을 명시적으로 대기한다
  • 병렬 실행 시 자원 충돌 — 포트, 파일, DB row를 테스트 간 공유하지 않는다
  • 리소스 누수 — DB 연결, 파일 핸들, 소켓을 테스트 후 해제하지 않으면 후속 테스트에 영향을 준다

감지와 방치 방지:

flaky를 수동으로 발견하는 것에 의존하면 대응이 늦어진다. CI에서 동일 테스트의 과거 실패율을 추적하고, 기준 이상이면 자동으로 flaky 플래그를 부여하거나 이슈를 생성하는 메커니즘이 있으면 운영이 쉬워진다. quarantine 상태로 격리된 테스트는 방치되기 쉽다. 주 1회 격리 테스트를 재활성화 시도하고, 통과하면 복귀, 실패하면 원인 재분석을 원칙으로 삼는다.

불안정성의 근본 원인 중 상당수는 테스트 자체가 아닌 설계 문제에서 비롯된다. 시간에 의존하는 코드, 전역 상태를 공유하는 컴포넌트, 외부 서비스에 직접 의존하는 로직이 그 원인이다. flaky 테스트는 설계 개선의 신호로 읽어야 한다. 시계 주입, 순수 함수 분리, 외부 의존성 경계화가 뒤따라야 할 아키텍처 작업이다.

AI 도구가 생성한 코드는 비동기 흐름과 숨은 상태 의존성이 예상보다 많다. AI 코드베이스에서 flaky 테스트가 늘어난다면, 생성된 코드가 외부 상태에 암묵적으로 의존하고 있다는 신호로 읽는 것이 맞다.

8. 점진적 마이그레이션 — 기존 Mockist 코드베이스에 적용하기

이미 Mockist 방식으로 수백 개의 테스트가 작성된 코드베이스를 한 번에 바꾸는 것은 현실적이지 않다. 점진적 마이그레이션이 유일한 실용적 선택이다.

마이그레이션 순서:

  1. 새 테스트부터 — 새 테스트는 상태 기반 검증을 기본으로 하되, 레이어를 구분한다. 도메인/순수 로직은 in-memory 기반 unit 테스트로, 유스케이스는 실제 DB를 사용하는 통합 테스트로 작성한다. “모든 새 테스트에 실제 DB”는 피드백 루프를 오히려 늦춘다.
  2. 수정하는 파일에서 — 기존 테스트를 편집할 때 해당 테스트의 내부 모킹을 함께 제거한다. 외부 경계 모킹만 남긴다. 단, 동작 변경과 테스트 리팩토링은 별도 커밋으로 분리해 PR 리뷰 부담을 줄인다.
  3. 최악의 파일부터toHaveBeenCalledWith 호출이 가장 많은, 변경 빈도가 높고 버그가 잦은, 자주 flaky가 발생하는, 핵심 비즈니스 로직을 담은 파일 3~5개를 먼저 식별해 한 도메인씩 재작성한다.
  4. 실제 DB를 점진적으로 도입 — 시작 전에 현재 테스트 실행 시간, flaky 비율, CI 소요 시간을 측정해 baseline을 확보한다. 스키마가 가장 단순하거나 버그 발생률이 가장 높은 도메인 하나부터 시작해, 속도(실무 기준으로 100~300ms/테스트), 결정성, CI 안정성을 검증한다. baseline 대비 개선이 확인된 후에만 다른 도메인으로 확장한다.
  5. 스냅샷 정리 — 비결정적 출력이거나 의미를 파악할 수 없는 대형 구조의 스냅샷만 구조적 검증 또는 불변 조건 검증으로 대체한다. 안정된 UI 구조나 고정된 직렬화 포맷에 대한 스냅샷은 유효하다.

전환 기간 동안 혼합된 스타일이 코드베이스에 공존하는 것은 허용된다. 새 테스트가 안정될 때까지 기존 테스트를 유지하고, PR은 한 도메인 단위로 작게 쪼갠다. 혼합 상태의 이유와 전환 방향은 팀 문서에 명시해두어야 리뷰 충돌을 막을 수 있다.

테스트 실행 시간이 상대적으로 2배 이상 늘거나, 전체 테스트가 5분 또는 PR 단위 테스트가 1분을 초과하면 진행을 멈추고 최적화(트랜잭션 롤백, 컨테이너 재사용)를 먼저 적용한다(2배·5분·1분은 실무 기준이며 팀 환경에 따라 조정한다). 성능 문제를 무시하면 팀 전체가 결국 테스트 실행을 기피하게 된다.

마이그레이션 성공 지표:

외부 시스템 전환 시 계약 테스트(WireMock 등)를 추가해 외부 API 계약이 깨지는 것을 조기에 탐지한다. 마이그레이션이 실제로 개선되고 있는지는 지표로 판단한다. flaky 비율 감소, 테스트 실패 시 버그 재현률 증가, CI 안정성 향상이 기준이다. 이 지표가 없으면 “좋아졌다”는 느낌으로만 판단하게 되고, 방향 수정이 늦어진다.

AI 도구가 기존 코드를 재생성하거나 리팩토링할 때 Mockist 테스트는 자주 깨진다. “수정할 때 같이 고친다”는 원칙은 AI 코드베이스에서 가장 현실적인 전환 전략이다. 한꺼번에 마이그레이션하려 하면 AI가 기존 패턴을 다시 생성하면서 전환 효과가 상쇄된다.

9. PR Red Flags — 리뷰에서 반려해야 할 신호

코드 리뷰에서 테스트를 제대로 검토하는 팀은 드물다. 다음 신호 중 하나라도 보이면 반드시 재작업을 요청한다.

반려(Reject) 기준:

  • 상호작용 검증만 있고 반환값이나 관찰 가능한 상태 검증이 없는 테스트 — 단, 이메일 발송·이벤트 발행처럼 외부 효과 자체가 요구사항인 경우는 예외
  • 통합/유스케이스 테스트에서 실제 DB 대신 mock DB/Repository를 사용 — in-memory Fake는 허용, interaction Mock은 제한
  • 비공개 또는 내부 모듈의 private API에 직접 접근하는 테스트
  • 비결정적 출력(타임스탬프, LLM 응답, 네트워크 결과)을 스냅샷으로 저장
  • 이슈, 담당자, 기한 없이 skip/ignore 처리된 테스트
  • 테스트 이름이 함수명이나 구현 구조를 반영하는 경우 — 동작이 아닌 구현을 잠근 신호
  • 기존 도구로 해결 가능한데 새 모킹 프레임워크를 추가
  • assertion이 없는 테스트 — CI를 통과하지만 아무것도 검증하지 않는다
  • 의미 없는 assertion(expect(result).toBeDefined() 등) — 테스트는 존재하지만 실제 버그를 잡지 못한다

검토(Review) 기준:

  • 상태 검증보다 상호작용 검증이 더 많은 경우
  • 실제 검증 코드에 비해 설정(setup)·모킹 코드가 압도적으로 많은 경우 — fixture나 builder 패턴 도입이 필요하다는 신호

이 신호들은 테스트가 구현에 과도하게 결합되어 있음을 나타낸다. 테스트 파일이 크다는 것 자체가 문제는 아니다. 복잡한 도메인 규칙이나 property-based 테스트는 본래 길다. 문제는 크기가 아니라 구성이다.

AI 도구는 이 목록에 있는 패턴을 자주 만든다. 과도한 mock, 구현을 따라가는 테스트 이름, 의미 없는 assertion, 비결정적 스냅샷 남발이 AI 생성 테스트의 전형적인 특징이다. 이 기준을 AI 생성 테스트에 그대로 적용하면 가치 없는 테스트를 걸러내는 필터가 된다.

마무리

AI 도구가 테스트를 자동으로 생성해주는 시대에, “테스트가 있다”는 사실보다 “테스트가 올바른 것을 검증하고 있다”는 신뢰가 더 중요해졌다. 모킹 경계를 명확히 하고, 상태를 검증하고, 행동 기반으로 이름 짓고, 불안정한 테스트를 즉시 격리하는 습관이 그 신뢰를 만든다. 새 기능을 짤 때 AI에게 “이 유스케이스의 테스트를 먼저 작성해줘”라고 요청하는 것, 거기서 시작하면 충분하다.


Mimul

Written byMimul
Mimul is a programmer, technologist, exercise enthusiast and more.
Connect

Related ArticlesView All

Related StoriesView All