AI 코딩 도구가 일상이 된 지금, 테스트 코드도 AI가 생성하는 경우가 많아졌다. 문제는 AI가 작성한 테스트가 겉으로는 그럴듯해 보이지만, 구현 세부사항에 강하게 결합되거나 상호작용 검증에만 집중하는 패턴이 반복된다는 점이다. 리팩토링 한 번에 테스트 수십 개가 한꺼번에 깨지는 경험은 그 결과다.
이 글은 특정 언어나 프레임워크의 API 사용법이 아닌, TDD 관점에서 언어를 가로질러 적용 가능한 테스트 철학, 전략, 원칙들을 다룬다. Mockist(London School) 방식이 왜 AI 코드베이스에서 특히 취약한지, Classicist(Chicago School) 접근법이 왜 더 탄탄한지를 중심으로, 현장에서 바로 적용할 수 있는 9가지 원칙을 정리한다. 이 테스트 철학, 전략, 원칙들은 사용자 레벨(모든 프로젝트 공통)에서 정의히고 이 기반하에 프로젝트별로 언어나 환경 등의 디테일을 더해주면 AI마다 일관된 테스트 코드가 나온다. 위임을 잘하려면 기준이 명확해야 한다. 이런 관점에서 이 글을 쓴다.
대상 독자는 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. 모킹 경계 — 무엇을 대체하고, 어떻게 대체하는가
테스트 전략 중에서 가장 오랜 논쟁거리이자, 실무에서 가장 많이 잘못 쓰이는 부분이 바로 모킹(mocking) 이다. 특히 AI 코딩 도구가 테스트를 대량으로 생성하는 요즘, “Mock을 남발한 테스트”가 넘쳐나면서 오히려 테스트 신뢰도가 떨어지는 역설적인 상황이 벌어지고 있다.
이 원칙의 핵심 메시지
“내가 통제하지 못하거나 비결정적인 것을 대체하고, 나머지는 실제 구현을 사용한다.”
기준을 단순히 “내부냐 외부냐”로 나누는 것이 아니라, 통제 가능성과 결정성이라는 더 정밀한 잣대로 판단하라는 뜻이다. 이는 Classicist TDD(상태 기반 테스트)의 핵심 철학을 관통한다.
실무에서 모킹 경계는 세 층으로 나뉜다
-
프로세스 경계 (네트워크, 외부 시스템)
→ 반드시 대체해야 한다. 외부 결제사, OAuth 제공자, 서드파티 알림 서비스 등이 여기에 속한다. -
시간/환경 경계 (시스템 시계, 파일 시스템, 난수 생성기 등)
→ 제어 불가능하므로 대체한다.LocalDateTime.now(),Random, 파일 I/O 등이 대표적이다. 그대로 두면 테스트가 flaky(불안정)해진다. -
논리적 경계 (레이어, 모듈, 같은 코드베이스 내 협력 객체)
→ 테스트가 검증하려는 의도와 범위에 따라 결정하며, 기계적으로 Mock을 붙이지 않는다.
Mock, Stub, Fake — 제대로 구분하자
-
Mock — 상호작용 검증(Interaction Verification)에 특화. “이 메서드가 정확히 이 인자로 몇 번 호출되었는가?”를 확인한다. 과도하게 사용하면 over-specification이 발생해 리팩토링 내성을 떨어뜨린다.
-
Stub — 단순히 고정된 응답을 반환한다. 호출 여부는 검증하지 않는다.
-
Fake — 실제로 동작하는 간단한 구현체(예: in-memory Repository). 호출 횟수를 검증하지 않고, 실행 후 결과 상태를 확인하는 방식으로 테스트한다. 상태 기반 검증(State-based verification)과 가장 잘 어울린다.
권장 방향:
Mock을 최대한 줄이고, Fake와 실제 구현을 늘리는 것이다. Mock은 테스트를 깨지기 쉽게 만들고, Fake는 테스트를 더 현실적이고 신뢰할 수 있게 만든다.
대체해야 할 대상 vs 피해야 할 대상
실제 구현체(Real Implementation)를 Test Double(Mock, Stub, Fake 등)로 교체해야 하는지를 판단하는 기준을 말한다.
반드시 대체해야 하는 것
- 서드파티 HTTP API (결제, OAuth, 외부 알림) → HTTP 레벨 Fake 서버 (WireMock, MSW, Nock 등) 추천
- 파일 시스템, 시스템 시 계, 난수 생성기 → 주입 가능한 인터페이스로 교체
- 메시지 큐, 외부 네트워크 소켓 등 프로세스 경계 전부
피해야 할 대체 (특히 조심)
-
DB/ORM을 Mock으로 대체하는 것. 인덱스, 외래키, 트랜잭션 격리 수준 등을 Mock으로는 제대로 재현할 수 없다.
- 단위 테스트(순수 로직): in-memory Fake Repository 사용
- 통합 테스트: 실제 DB (Testcontainers 등) 사용
-
순수 함수, 유틸리티 클래스 → 대체할 이유 없음
-
내가 직접 소유한 Value Object, DTO, 도메인 엔티티 → 실제 객체 직접 생성하여 사용
중요: “DB를 Mock하지 말라”는 것이지, “항상 실제 DB를 붙여야 한다”는 뜻이 아니다. Mock은 흉내만 내지만, Fake는 실제 동작하므로 상태 기반 검증이 가능하다.
같은 코드베이스 내 의존성도 기계적으로 결정하지 말라
기준은 “이 코드가 내 코드인가?”가 아니라, “이번 테스트가 검증하려는 동작 범위에 이 의존성이 포함되는가?” 이다.
- 안정적이고 핵심적인 도메인 로직 → 실제 구현(real) 사용
- 무겁거나 자주 변하는 의존성 → Fake나 Stub으로 대체 가능
많은 팀이 Mock Repository 테스트는 모두 통과했는데, 프로덕션에서 트랜잭션 오류로 장애를 겪는 이유가 바로 여기에 있다. 테스트가 통과했다는 것과 시스템이 실제로 올바르게 동작한다는 것은 다른 이야기다.
HTTP 경계에서는 더 현실적으로
인터페이스 수준 Mock 대신 HTTP 레벨 Fake 서버를 사용하는 것을 권장한다. 이렇게 해야 직렬화/역직렬화, HTTP 헤더 처리, 상태 코드 분기 로직까지 자연스럽게 검증 범위에 포함된다.
대체를 허용하는 기술적 기준
Mock보다 Fake를 먼저 고려하되, 내부 의존성을 Test Double로 대체하는 것은 다음 세 조건 중 하나 이상에 해당할 때만 허용한다.
- 비결정성 제거 — 시스템 시계, 난수 등 테스트를 불안정하게 만드는 요소
- 재현 불가능한 실패 시뮬레이션 — 실제 인프라에서는 만들기 어려운 에러 케이스
- 피드백 루프 단축 — 순수 계산 로직 검증 시 DB 기동 비용이 과도할 때
이 세 조건에 해당하지 않는데 내부 의존성을 대체한다면? → 테스트 범위만 좁아질 뿐, 신뢰도는 높아지지 않는다.
2. Assertion 스타일 — 관찰 가능한 결과를 검증하라
테스트에서 가장 중요한 것은 무엇을 검증하느냐이다.
이 원칙의 본질은 명확하다. 구현 세부사항이 아닌, 외부에서 관찰 가능한 결과를 검증하라는 것이다.
테스트 구조는 AAA 패턴을 철저히 지켜라
모든 테스트는 다음 세 단계를 명확하게 구분해야 한다:
- Arrange: 테스트에 필요한 상태와 의존성을 준비한다.
- Act: 검증 대상 동작을 정확히 한 번만 실행한다.
- Assert: 관찰 가능한 결과를 검증한다.
이 세 단계가 모호하거나 뒤섞여 있다면, 그 테스트는 한 번에 여러 가지를 검증하려 하거나 구조가 잘못된 것이다. 좋은 테스트는 구조부터 명확해야 한다.
좋은 Assertion의 유일한 기준 — 관찰 가능성(Observability)
검증할 수 있는 것은 오직 외부에서 관찰 가능한 결과뿐이다.
- 함수의 반환값
- DB에 저장된 실제 상태
- API 응답
- 이벤트 발생 여부
반대로, 내부 메서드 호출 순서나 협력 객체에 전달된 인자 같은 것은 외부에서 관찰할 수 없는 구현 세부사항이므로 검증 대상이 되어서는 안 된다.
상호작용 검증의 올바른 위치
이 원칙의 핵심 문장은 다음과 같다.
“상호작용 검증을 기본 수단으로 삼지 않는다. 다만 완전히 배제하는 것도 아니다.”
toHaveBeenCalledWith(Jest), verify(Mockito), mockall::expect(Rust) 같은 함수들은 모두 Mock 전용 상호작용 검증 도구 다. 이들은 “이 함수가 특정 인자로 호출되었는가”만 확인할 뿐, 실제 결과 상태는 검증하지 않는다. 때문에 내부 구현을 조금만 리팩토링해도(예: DB 조회 횟수 변경, 메서드 호출 순서 변경) 동작은 동일한데 테스트가 깨지는 취약한 상황이 발생한다.
// ❌ 나쁜 예 - 구현에 강하게 결합됨
expect(userRepo.save).toHaveBeenCalledWith(user);
// ✅ 좋은 예 - 결과 상태에 집중
const saved = await db.users.findByEmail(user.email);
expect(saved).not.toBeNull();기본 원칙: 결과 검증을 우선하라
- 반환값과 관찰 가능한 상태(DB 내용, API 응답)를 먼저 검증한다.
- 상호작용 검증은 보조 수단으로만 사용한다.
- 부작용(DB 쓰기, 이벤트 발행 등)은 반드시 명시적으로 검증한다.
절대 하면 안 되는 패턴
- 내부 메서드 호출 여부를 검증하는 것
- repository.save()가 호출되었는지 확인하는 것 → 대신 실제 DB에서 데이터를 조회해 저장 여부를 검증해야 한다.
- “이 함수가 불렸는가” 중심으로 작성된 테스트 → 이는 구현 세부사항을 잠그는 행위다.
객체 비교 전략: 전체 비교 vs 부분 비교
전체 비교 (toEqual)
안정적이고 결정적인 DTO나 결과 객체에서는 강력 추천된다. 간결하며 필드 누락 위험을 줄여준다.
expect(result).toEqual(expected); // 안정적인 DTO에서 유효부분 비교 (toMatchObject)
타임스탬프, 자동 생성 ID 등 비결정적 필드가 포함된 경우 사용한다. 핵심 비즈니스 필드에만 집중한다.
expect(result).toMatchObject({
name: "kim",
status: "ACTIVE"
}); // 비결정적 필드(id, createdAt)는 무시전체 비교를 사용할 때는 “이 필드가 정말 검증에 필요한가?”를 항상 고민해야 한다. 불필요한 필드까지 검증하면 테스트 유지보수 비용이 크게 증가한다.
비결정성(Non-determinism)은 피하지 말고 제거하라
타임스탬프, LLM 응답, 순서가 보장되지 않는 집합 등은 스냅샷 테스트로도 다루지 말아야 한다. 진짜 좋은 방법은 테스트에서 비결정성을 회피하는 것이 아니라, 소스 코드 자체에서 비결정성을 제거하는 것이다.
- 시스템 시계 → 주입 가능한 Clock 인터페이스로 교체
- 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_validcreate_todo_fails_when_title_is_emptylogin_rejects_when_password_is_incorrectget_user_returns_none_when_not_found
동사는 일관되게 사용한다 (create, get, update, delete, returns, fails, rejects). works, handles, processes 같은 모호한 표현은 피한다.
이 템플릿의 또 다른 장점은, 테스트 이름을 먼저 쓰면 무엇을 테스트할지 명확해진다는 점이다. “어떤 조건에서 어떤 행동이 어떤 결과를 낳는가”를 한 문장으로 표현하지 못한다면, 테스트 대상 자체가 불분명한 것이다.
4. 테스트 피라미드 — 레이어별 권장 비율과 생략 기준
테스트 피라미드는 테스트 비용과 피드백 속도, 결함 수정 비용이라는 현실에서 출발한 실용적인 전략이다.
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, 라우팅, 모듈 등록 등)은 프레임워크가 이미 테스트하고 있으므로 생략 가능하다. 다만 우리 코드가 프레임워크와 올바르게 연결되었는지는 얇게 검증해야 한다.
- 정적 설정값이나 상수는 타입 시스템이나 스키마 검증기가 대신 검증할 수 있다.
- 삭제 예정 코드는 원칙적으로 테스트를 추가하지 않는다. 다만 실제로 변경이 발생한다면 최소한의 보호 테스트는 추가하는 것이 안전하다. “곧 삭제한다”는 말이 실제로는 오래 유지되는 경우가 많기 때문이다.
반드시 테스트해야 하는 것
- 인증, 결제, 권한 체크 등 비즈니스 핵심 로직
- 복잡한 분기 로직이나 상태 전환 로직
- 과거에 버그가 발생했던 경로 (회귀 테스트)
→ 작성 순서는 “버그 재현 테스트 작성 → 실패 확인 → 수정”이다.
실무에서 중요한 실행 원칙: 성능
통합 테스트의 성능은 개발자 피드백 루프에 직접적인 영향을 준다. 트랜잭션 롤백과 테스트 컨테이너 재사용을 적극 활용해, 통합 테스트 하나당 100~300ms 수준을 목표로 해야 한다.
- 전체 DB 초기화보다는 트랜잭션 롤백을 우선 사용
- 병렬 실행 시 트랜잭션 격리가 제대로 동작하는지 확인하여 테스트 간 상태 공유 문제를 방지
- 비용이 큰 외부 시스템 연동 테스트는 환경 변수(
LIVE_TEST=true)로 분리하여 일반 CI에서는 실행하지 않도록 격리
이 원칙을 잘 지키면 AI가 생성하는 수많은 테스트 속에서도, 진짜 중요한 부분에 집중하면서도 비용 효율적인 테스트 전략을 유지할 수 있다.
5. 도메인 엔티티 추출 — 언제, 왜
서비스 레이어에 비즈니스 로직이 점점 쌓이다 보면 테스트가 급격히 복잡해지는 신호가 나타난다. 순수한 계산 로직 하나를 검증하기 위해 DB를 통째로 띄워야 하고, DB 없이는 로직을 실행조차 할 수 없게 된다. 이런 상황이 보이면 도메인 엔티티를 추출해야 할 시점이다.
추출 기준 — 다음 중 하나라도 해당하면 엔티티로 추출한다
- 같은 데이터를 대상으로 하는 비즈니스 로직이 2개 이상의 서비스에 흩어져 있을 때
- 서비스가 ORM의 행(row)에 직접 산술 연산이나 상태 전환을 수행하고 있을 때
- 실제로는 순수 로직인데 테스트를 위해 매번 DB를 띄워야 할 때
- 중요한 불변식(Invariant) 이 여러 서비스에 중복해서 체크되고 있을 때(예: “할인액은 최대 한도를 초과할 수 없다”, “CONFIRMED 상태의 주문은 PENDING으로 되돌릴 수 없다”)
같은 규칙이 세 군데 이상에 흩어져 있다면, 이를 엔티티로 끌어올려 한 곳에서 중앙 집중적으로 보호해야 한다. DDD 관점에서 이는 Aggregate Root의 핵심 책임이 다. Aggregate Root는 경계 내부의 모든 상태 변화에 대한 단일 진입점으로서, 어떤 경로로도 불변식이 깨지지 않도록 통제한다.
Before → After 비교
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)엔티티 메서드(applyDiscount(), confirm() 등)는 자신의 상태와 불변식에만 책임을 가진다. 외부 정책, 다른 Aggregate와의 협력, 시간/환경 의존성은 엔티티 내부에 두지 않는다.
엔티티 추출의 핵심 효과
Order.applyDiscount() 같은 메서드는 이제 순수 메모리 기반 단위 테스트가 가능해진다. DB나 Mock 없이 밀리초 단위로 빠르게 실행된다. 호출 여부가 아닌 결과 상태를 검증하는 상태 기반 테스트(State-based Test)가 자연스럽게 작성된다. 도메인 로직의 중복을 제거하고, 테스트 속도와 신뢰도를 동시에 높인다.
엔티티 추출의 가장 큰 가치는 단위 테스트를 가능하게 만드는 구조 자체를 확보한다는 점이다.
Rich Domain Model vs Anemic Domain Model
이 원칙은 DDD의 Rich Domain Model과 깊이 맞닿아 있다. Martin Fowler는 Anemic Domain Model(빈약한 도메인 모델)을 강한 안티패턴으로 규정했다. 그는 이를 “1990년대에 객체지향을 지지하던 사람들이 싸워왔던 절차적 설계”라고 비판했다. 다만, Rich Domain Model이 항상 정답은 아니다.
- CRUD 중심의 단순 시스템에서는 Anemic 모델이 더 간결하고 적합할 수 있다.
- 도메인 복잡도가 일정 수준 이상으로 높아질 때 Rich Domain Model의 진가가 발휘된다.
과도한 엔티티화를 경계하라
엔티티 추출을 하다 보면 단순한 setter까지 메서드로 만들거나, 의미 없는 래핑을 추가하는 경우가 생긴다. 엔티티는 단순 데이터 보관용이 아니라, 의미 있는 규칙과 상태 전환을 캡슐화할 때만 도입해야 한다. 추출 기준에 해당하지 않는 단순 필드 변경은 엔티티 메서드로 만들지 않는 것이 좋다.
서비스 레이어의 역할은 여전히 존재한다
엔티티를 도입한다고 해서 서비스 레이어가 사라지는 것은 아니다. 서비스는 유스케이스 조율(orchestration) 역할을 계속 담당한다. 여러 엔티티를 연결하고, 외부 시스템과 통합하며, 트랜잭션 경계를 관리하는 일을 한다. 엔티티 추출의 진짜 목표는 서비스를 없애는 것이 아니라, 서비스에 있던 도메인 규칙을 제자리로 돌려보내 각 레이어의 책임을 명확히 하는 것이다.
6. Property-Based Testing — 예시 테스트로 부족할 때
일반적인 예시 기반(Example-based) 테스트는 개발자가 직접 생각해낸 몇 가지 케이스만 검증한다. 그렇기 때문에 경계값, 예외적인 조합, 다양한 엣지 케이스를 놓치기 매우 쉽다.
Property-Based Testing은 이러한 한계를 보완한다. “특정 입력”이 아니라, “모든 가능한 입력에 대해 항상 성립해야 하는 규칙(속성, Property)” 을 검증한다.
예시 기반 vs 속성 기반 테스트 비교
// 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) 도메인 규칙 파서(Parser)와 매퍼(Mapper) 상태 머신(State Machine) 복잡한 계산 로직 정렬, 검색, 변환 함수 등 (idempotent, commutative, associative 같은 대수적 성질이 있는 경우)
대표 라이브러리: fast-check (TypeScript), hypothesis (Python), proptest (Rust)
언제 전환을 고려해야 하는가
여러 입력에 대해 동일한 규칙을 반복해서 검증하고 있다면 속성 기반 테스트로 전환을 진지하게 검토해야 한다. “네 번째 예시 테스트를 작성하려는 순간”은 전환을 고려할 수 있는 좋은 휴리스틱이다. 다만 함수에 따라 예시 2개로 충분할 수도 있고, 예시 10개여도 속성이 불분명할 수도 있다.
적합하지 않은 경우
- 네트워크 호출, 외부 API 연동 (속도와 결정성 문제)
- 부작용(Side-effect)이 주요 목적인 유스케이스
단, 조건부로 결합할 수 있는 영역도 있다. 예: “insert 후 반드시 조회가 가능해야 한다”, “동일한 요청을 두 번 보내도 결과 가 동일하다(idempotent)” 같은 속성을 가진 통합 테스트에서는 속성 기반 접근을 유용하게 활용할 수 있다.
Shrinking — Property-Based Testing의 진짜 강점
속성 기반 테스트가 실패했을 때, 단순히 “실패한 입력”을 보여주는 것이 아니라, 최소 재현 케이스(Minimal Reproducible Example)를 자동으로 찾아준다. 수천 개의 무작위 입력 중 하나가 실패하더라도, 도구가 그 입력을 최대한 단순화시켜 실패 원인을 빠르게 파악할 수 있게 해준다. fast-check, hypothesis, proptest 모두 기본적으로 Shrinking 기능을 제공한다.
Generator 품질이 테스트 품질을 결정한다
“랜덤”이라고 해서 좋은 테스트가 되는 것은 아니다. 좋은 Generator는 단순 무작위가 아니라, 도메인 제약 조건을 반영하고 엣지 케이스(0, 최대값, 빈 문자열, 음수 등)를 의도적으로 포함해야 한다.
핵심 원칙
- 각 속성은 한 문장으로 명확하게 표현할 수 있는 불변 조건이어야 한다.
- “어떤 입력을 넣어도 이 속성이 성립한다”고 말할 수 없으면 테스트 방향이 잘못된 것이다.
- 반복 실행되므로 실행 속도와 반복 횟수를 적절히 조절해야 한다.
- AI 도구는 구체적인 예시 테스트는 잘 만들지만, invariant(불변 조건) 을 스스로 정의하는 데는 취약하다. 이 부분은 인간 개발자가 도메인을 깊이 이해하고 채워 넣어야 하는 영역이다.
7. Flaky 테스트 — 격리와 근본 원인 제거
불안정한(Flaky) 테스트는 팀의 테스트 신뢰도를 가장 빠르게 갉아먹는다. CI가 무작위로 실패하면 개발자들은 점점 CI 결과를 무시하게 되고, 결국 자동화된 회귀 테스트 스위트 전체가 무용지물이 된다.
Martin Fowler는 이를 이렇게 표현했다.
“Non-deterministic tests can completely destroy the value of an automated regression suite.”
핵심 원칙
불안정한 테스트는 절대 커밋하지 않는다.
이미 코드베이스에 들어왔다면 최대한 빠르게 격리해야 한다. 실무에서는 24시간을 넘기지 않는 것을 원칙으로 한다.
격리(Quarantine) 기준
- 단순히
skip이나ignore처리하는 것이 아니라,
이슈 링크 + 담당자 지정 + 기한 설정 + 원인 가설 기록을 반드시 포함해야 한다. - 담당자와 가설이 없는 격리는 의미가 없으며, 그런 테스트는 과감히 삭제한다.
- 동일 테스트가 반복 실패할 경우(실무 기준 2회 이상) CI에서 자동으로 머지를 차단하는 규칙을 두는 것이 좋다.
임시방편은 장기적으로 금지
재시도(retry) 루프, sleep() 삽입, 타임아웃 증가 등은 증상 완화일 뿐이다. 근본 원인을 해결하지 않은 채로 장기적으로 유지해서는 안 된다. 인프라 노이즈나 외부 의존성 때문에 불가피한 경우라도 별도 이슈로 등록하고, 정해진 기한 안에 제거해야 한다.
흔한 Flaky 테스트의 근본 원인
- 공유 전역 상태 (테스트 간 격리가 제대로 안 됨)
- 실제 시스템 시계(
new Date(),LocalDateTime.now()등) 의존 - 테스트 실행 순서에 의존하는 코드
- 시드 없는 난수 생성
- 네트워크나 외부 서비스 직접 의존
- 비동기 처리 미완료로 인한 Race Condition
- 병렬 실행 시 자원 충돌 (포트, 파일, DB row 공유)
- 리소스 누수 (DB 연결, 파일 핸들, 소켓 등 정리하지 않음)
감지와 방치 방지 전략
Flaky 테스트를 수동으로 발견하는 것에만 의존하면 대응이 늦어진다. CI에서 각 테스트의 과거 실패율을 추적하고, 일정 기준을 초과하면 자동으로 flaky 플래그를 부여하거나 이슈를 생성하는 시스템을 갖추는 것이 효과적이다. 격리된 테스트는 방치되기 매우 쉽다. 따라서 주 1회 격리된 테스트를 재활성화하여 시도하고, 통과하면 정상 복귀, 실패하면 원인을 재분석하는 규칙을 운영하는 것이 좋다.
중요한 인사이트
많은 flaky 테스트의 진짜 원인은 테스트 코드 자체가 아니라 소프트웨어 설계 문제에 있다. 시간 의존성, 전역 상태 공유, 외부 서비스 직접 의존 등이 대표적이다. 따라서 flaky 테스트는 단순한 테스트 문제가 아니라, 아키텍처 개선의 신호로 읽어야 한다.
시계 주입, 순수 함수 분리, 외부 의존성의 명확한 경계화 등의 구조 개선 작업이 뒤따라야 한다.
AI 도구가 생성한 코드에서는 특히 비동기 흐름과 숨겨진 상태 의존성이 많아 flaky 테스트가 자주 발생한다. AI 코드베이스에서 flaky가 늘어난다면, 이는 “생성된 코드가 외부 상태에 암묵적으로 의존하고 있다”는 중요한 경고 신호로 받아들여야 한다.
8. 점진적 마이그레이션 — 기존 Mockist 코드베이스에 적용하기
이미 Mockist 스타일로 수백, 수천 개의 테스트가 쌓인 코드베이스를 한 번에 바꾸는 것은 현실적으로 거의 불가능하다.
따라서 점진적 마이그레이션만이 유일하고 실용적인 전략이다.
마이그레이션 순서
- 새 테스트부터 Classicist 스타일로 작성한다
새로운 테스트는 상태 기반 검증(State-based Verification)을 기본으로 한다. 다만 레이어를 명확히 구분해야 한다.
- 도메인 로직, 순수 계산 로직 → in-memory 기반 단위 테스트
- 유스케이스 → 실제 DB를 사용하는 통합 테스트
“모든 새 테스트에 실제 DB를 붙인다”는 극단적 접근은 피드백 루프를 오히려 느리게 만들 수 있다.
- 수정하는 파일부터 함께 개선한다
기존 테스트를 수정할 때, 해당 테스트의 내부 모킹(toHaveBeenCalledWith 등)을 함께 제거한다. 외부 시스템(HTTP, 결제 등) 경계 모킹은 유지하되, 내부 모킹은 최대한 줄인다. 동작 변경과 테스트 리팩토링은 별도 커밋으로 분리하여 리뷰 부담을 줄이는 것이 좋다.
- 최악의 파일부터 공격한다
우선순위 기준:
toHaveBeenCalledWith호출이 가장 많은 파일- 변경 빈도가 높고 버그가 자주 발생하는 파일
- flaky 테스트가 자주 나타나는 파일
- 핵심 비즈니스 로직이 집중된 파일
위 조건에 해당하는 파일 3~5개를 먼저 선정해, 도메인 단위로 재작성한다.
- 실제 DB를 점진적으로 도입한다
마이그레이션 시작 전에 baseline을 반드시 측정한다. (현재 테스트 실행 시간, flaky 비율, CI 소요 시간)
스키마가 가장 단순하거나, 버그 발생률이 높은 도메인 하나부터 시작한다.
실무 기준으로 통합 테스트 하나당 100~300ms 수준을 목표로 하며, 속도·결정성·CI 안정성이 개선되는 것을 확인한 후에만 다음 도메인으로 확대한다.
- 스냅샷 정리
비결정적 출력이거나 의미를 파악하기 어려운 대형 스냅샷은 구조적 검증이나 불변 조건 검증으로 대체한다. 반대로 안정된 UI 구조나 고정된 직렬화 포맷에 대한 스냅샷은 그대로 유지해도 좋다.
운영 원칙
- 전환 기간 동안 혼합된 스타일이 공존하는 것은 자연스럽고 허용된다.
- 새 테스트가 안정화될 때까지 기존 테스트는 유지한다.
- PR은 한 도메인 단위로 작게 쪼개서 제출한다.
- 혼합 상태의 이유와 전환 방향은 팀 문서에 명확히 기록해 두어야 리뷰 충돌을 최소화할 수 있다.
성능 경계선
테스트 실행 시간이 기존 대비 2배 이상 증가하거나, 전체 테스트 스위트가 5분, PR 단위 테스트가 1분을 초과하면 즉시 진행을 멈추고 최적화 작업(트랜잭션 롤백, 컨테이너 재사용 등)을 우선 수행해야 한다.
성능 저하를 방치하면 팀 전체가 테스트 실행을 기피하게 된다.
마이그레이션 성공 지표
- flaky 테스트 비율 감소
- 테스트 실패 시 실제 버그 재현률 증가
- CI 안정성 향상
- 외부 API 변경 시 계약 테스트(WireMock 등)를 통해 조기 탐지
이 지표들을 정기적으로 측정하지 않으면 “느낌상 좋아졌다”는 주관적 판단에 의존하게 되어 방향 수정이 늦어진다.
AI 시대에서의 현실적 전략
AI 도구가 기존 코드를 재생성하거나 리팩토링할 때 Mockist 테스트는 매우 자주 깨진다. “수정할 때마다 같이 고친다”는 원칙이 AI가 많은 코드베이스에서 가장 현실적이고 효과적인 전환 전략이다. 한 번에 대규모 마이그레이션을 시도하면 AI가 다시 Mockist 패턴을 생성하면서 노력의 상당 부분이 상쇄될 위험이 크다.
9. PR Red Flags — 리뷰에서 반려해야 할 신호
코드 리뷰에서 테스트 코드를 제대로 검토하는 팀은 생각보다 많지 않다.
그러나 테스트 품질은 전체 코드 품질과 직결되므로, 다음 Red Flags 중 하나라도 발견되면 반드시 재작업을 요청해야 한다.
반려(Reject) 기준 — 즉시 수정 요청해야 할 신호
- 상호작용 검증(
toHaveBeenCalledWith,verify등)만 있고, 반환값이나 관찰 가능한 상태(DB 상태, API 응답 등)를 검증하지 않는 테스트
→ 단, 이메일 발송이나 이벤트 발행처럼 부작용 자체가 비즈니스 요구사항인 경우는 예외 - 통합 테스트나 유스케이스 테스트에서 실제 DB 대신 Mock DB/Repository를 사용하는 경우
→ in-memory Fake Repository는 허용하지만, interaction 중심의 Mock은 제한해야 함 - 비공개(private) 메서드나 내부 모듈의 private API에 직접 접근하는 테스트
- 타임스탬프, LLM 응답, 네트워크 결과 등 비결정적 출력을 스냅샷으로 저장한 테스트
- 이슈, 담당자, 기한 없이 단순히
skip또는ignore처리된 테스트 - 테스트 이름이 함수명이나 구현 구조를 그대로 반영하는 경우
→ 이는 동작이 아닌 구현을 잠그는 위험 신호 - 기존 도구로 충분히 해결 가능한데 새 모킹 프레임워크를 추가하는 경우
- Assertion이 없는 테스트
→ CI는 통과하지만 실제로 아무것도 검증하지 않는 테스트 - 의미 없는 Assertion (
expect(result).toBeDefined(),expect(result).not.toBeNull()등)
→ 테스트는 존재하지만 실제 버그를 잡아내지 못하는 허울뿐인 테스트
검토(Review) 기준 — 주의 깊게 살펴봐야 할 신호
- 상태 검증보다 상호작용 검증이 압도적으로 많은 경우
- 실제 검증 코드(assertion)에 비해 설정(setup)과 모킹 코드가 훨씬 많은 경우
→ Fixture나 Builder 패턴 도입이 필요하다는 강한 신호
이 Red Flags들은 테스트가 구현 세부사항에 과도하게 결합되어 있음을 나타낸다.
테스트 파일이 크다는 것 자체는 문제가 아니다.
복잡한 도메인 규칙이나 Property-Based Testing은 본래 길어질 수밖에 없다.
중요한 것은 크기가 아니라 구성의 질이다.
AI 시대에서의 의미
AI 도구는 위 목록에 나오는 나쁜 패턴을 매우 자주 생성한다.
- 과도한 Mock 사용
- 구현 구조를 그대로 따르는 테스트 이름
- 의미 없는 Assertion
- 비결정적 스냅샷 남발
이 Red Flags 기준을 AI가 생성한 테스트 코드에 엄격하게 적용하면, 가치 없는 테스트를 효과적으로 걸러내는 필터가 된다.
마무리
AI 도구가 테스트를 자동으로 생성해주는 시대에, “테스트가 있다”는 사실보다 “테스트가 올바른 것을 검증하고 있다”는 신뢰가 더 중요해졌다. 모킹 경계를 명확히 하고, 상태를 검증하고, 행동 기반으로 이름 짓고, 불안정한 테스트를 즉시 격리하는 습관이 그 신뢰를 만든다. 새 기능을 짤 때 AI에게 “이 유스케이스의 테스트를 먼저 작성해줘”라고 요청하는 것, 거기서 시작하면 충분하다.

