Risk fit
이 테스트가 실제로 방지하려는 손실·불변식·계약 위반과 연결되는가
분류: Layer 9 - 아키텍처 & 설계 패턴
테스팅 전략은 중요한 실패 위험마다 가장 싸고 빠르면서도 그 실패를 실제로 드러낼 수 있는 검증 경계를 선택하고, 그 증거를 반복 가능하게 운영하는 설계다.
테스트 수, 커버리지, 특정 피라미드 비율은 활동량을 보여 줄 수는 있어도 신뢰 자체를 보장하지 않는다. 신뢰는 “어떤 위험을 어떤 관찰로 반증했는가”에서 나온다.
이 문서는 다음 질문에 답한다.
CI 실행 단계와 배포 gate는 CI/CD 기초, 컨테이너 기반 테스트 환경은 Docker 기초, 장애 실험의 신호 설계는 Logs, Metrics, Traces, 테스트 가능한 코드 구조의 보충 관점은 Design Principles를 선택 참고로 읽는다. 이 네 문서는 특정 절을 더 깊게 이해하는 데 도움을 주지만, 이 문서의 핵심 흐름을 시작하기 위한 필수 선수지식은 아니다.
본문의 TypeScript async/await와 PostgreSQL/SQL은 코드·DB 예제를 읽기 위한 선택 배경이다. JWT/Bearer와 NestJS TestingModule·DI 컨테이너 API도 인증 경계와 선택 부록을 구체화하는 예시 기술일 뿐, 테스팅 전략의 핵심 개념을 학습하기 위한 필수 선수지식은 아니다.
테스트가 없으면 회귀 버그(regression bug), 즉 새 변경이 이미 되던 동작을 깨뜨리는 문제를 늦게 발견한다. 그렇다고 테스트 파일을 많이 만드는 것만으로 해결되지는 않는다. 모든 테스트가 같은 가정을 복제하거나, 실제 실패가 일어나는 경계 바깥만 확인할 수 있기 때문이다.
정산 서비스에 다음 테스트가 있다고 하자.
10,000개 Unit Test - Repository mock은 항상 약속한 객체를 반환 - 정상 금액 계산과 메서드 호출 횟수 검증 - 실행 시간 40초
검증하지 않은 경계 - 실제 DB의 NUMERIC 반올림 규칙 - 새 migration 뒤 NOT NULL 제약 - 결제 제공자의 timeout 뒤 결과 미상 상태 - 소비자가 아직 읽는 구버전 event field테스트 수는 많지만, 운영 실패를 만드는 네 경계에는 증거가 없다. expect(repository.save).toHaveBeenCalledTimes(1)이 모두 통과해도 15,000.5가 실제 DB에서 어떻게 반올림되는지, 저장은 성공했지만 응답만 유실된 결제를 재시도해도 되는지는 알 수 없다.
반대로 테스트가 300개뿐이어도 핵심 불변식, 실제 DB 의미, 서비스 계약, 대표 사용자 흐름, 장애 타임라인을 분담해 검증한다면 더 강한 신뢰를 줄 수 있다. 개수는 증거의 위치와 품질을 대신하지 못한다.
Coverage(커버리지)는 테스트 실행 중 코드의 줄, 분기, 함수가 얼마나 실행되었는지 나타낸다. 실행되었다는 사실은 올바르게 검증되었다는 뜻이 아니다.
function calculateFee(amount: number) { return amount * 0.1;}
it("수수료를 계산한다", () => { calculateFee(10_000); // assertion이 없어도 이 줄은 covered다.});이 테스트는 line coverage를 올리지만 반환값이 1,000인지 확인하지 않는다. 더 나쁜 경우, 구현과 테스트가 같은 잘못된 공식을 공유하면 100% coverage에서도 오류가 남는다.
기존 문서의 전체 60~70%, 핵심 Service 100%, branch 60%, function/line 70% 같은 숫자는 정책 예시로는 쓸 수 있지만 보편적 품질선은 아니다. 다음 두 질문과 함께 써야 한다.
Test oracle(테스트 오라클) 은 실제 결과가 옳은지 판정하는 기준이다. 단순히 “예외가 없었다”가 아니라 반환값, 상태 변화, 외부로 보낸 메시지, 불변식처럼 정답을 구별하는 관찰이어야 한다.
Mutation testing(변이 테스트)은 구현의 연산자나 조건을 일부러 바꿔 기존 테스트가 그 오류를 잡는지 확인한다.
원본: amount >= 0변이: amount > 0
테스트가 계속 통과-> 0원 경계에 대한 assertion이 없거나 oracle이 약함Coverage가 “어디를 실행했는가”를 말한다면 mutation score는 “일부러 넣은 잘못을 얼마나 죽였는가”를 근사한다. 변이 테스트도 모든 실제 버그를 대표하지는 않지만, 호출 횟수와 스냅샷만 많은 테스트가 실제 행동을 보호하는지 점검하는 데 유용하다.
이 테스트가 실제로 방지하려는 손실·불변식·계약 위반과 연결되는가
실제 DB·브로커·프로토콜 의미처럼 필요한 현실성을 충분히 보존하는가
같은 코드와 입력이면 시간·순서·환경 잡음에 흔들리지 않고 같은 판정을 내리는가
실패했을 때 어느 불변식이나 경계가 깨졌는지 빠르게 좁힐 수 있는가
변경 시점에 실행할 만큼 빠르고 유지 비용이 감당 가능한가
Fidelity(충실도)가 높다고 항상 좋은 테스트는 아니다. 실제 외부 결제망을 모든 Unit Test에서 호출하면 현실성은 높지만 느리고 비결정적이며 비용도 든다. 반대로 모든 의존성을 Mock으로 바꾸면 빠르지만 실제 wire format과 DB 의미가 빠진다. 전략은 다섯 조건을 한 테스트에서 최대화하는 것이 아니라 여러 경계가 서로의 맹점을 메우게 하는 것이다.
테스트 전략이 없을 때 기본값은 개발 후 수동 확인이나 전체 시스템을 통과하는 E2E 자동화였다. 실제 사용자 경로와 가깝다는 장점은 있지만, 실패 원인이 코드·데이터·네트워크·환경 중 어디인지 좁히기 어렵고 실행 비용이 크다.
Google Testing Blog의 E2E 중심 사례에서는 릴리스 전 90% 통과 기준을 두었지만 환경 장애, 파트너 서비스 실패, 큰 버그가 작은 버그를 가리는 문제 때문에 마일스톤을 1주 늦게 끝냈고, 개발자는 수정 결과를 다음 날에야 확인했다. 이 사례가 주는 결론은 E2E를 없애라는 것이 아니라, 빠른 하위 경계 없이 E2E만 늘리면 피드백이 개발 속도보다 느려진다는 것이다.
테스트 피라미드와 TDD(Test-Driven Development, 테스트 주도 개발)는 이 한계를 다른 축에서 푼다. 피라미드는 경계별 비용을 분산하고, TDD는 작은 행동을 먼저 실패로 고정해 구현과 설계 피드백을 짧게 만든다.
테스트 이름은 사용하는 프레임워크보다 어디까지 실제로 연결했는가로 정한다.
| 종류 | 주로 검증하는 위험 | 현실성 | 대표 비용 | 맡기지 말아야 할 것 |
|---|---|---|---|---|
| Static check | 타입·lint·스키마 문법 | 낮음 | 매우 낮음 | 런타임 의미와 상태 변화 |
| Unit | 계산·상태 전이·분기·오류 매핑 | 의존성을 격리 | 낮음 | DB·브로커의 실제 의미 |
| Integration | 모듈과 실제 DB·cache·broker의 상호작용 | 중~높음 | 중간 | 전체 배포 토폴로지 |
| Contract | consumer/provider의 wire-format·호환성 | 경계에 집중 | 낮~중간 | 내부 불변식, 전체 업무 흐름 |
| E2E | 배포된 구성의 대표 사용자 journey | 높음 | 높음 | 모든 입력 조합과 세부 분기 |
| Property | 넓은 입력 공간의 불변식 | 대상에 따라 다름 | 중간 | 현실 인프라 장애 자체 |
| Migration | 구·신 코드와 스키마·이벤트의 공존 | 높음 | 중~높음 | 일반 기능 전체 |
| Chaos | 실제 실패 격리·복구 가설 | 매우 높음 | 높음·위험 | 기본 기능 correctness |
테스트 피라미드는 바닥에 빠르고 격리된 테스트를 많이 두고, 위로 갈수록 느리고 통합된 테스트를 적게 둔다는 비용 모델이다.
E2E / Chaos Contract / Journey Integration / Component Unit / Property / Static checks기존의 Unit 70% / Integration 20% / E2E 10%는 설명용 출발점이지 목표 비율이 아니다. 저장 프로시저가 핵심인 서비스는 실제 DB Integration Test 비중이 커질 수 있고, 계산 엔진은 Unit과 Property Test가 중심일 수 있다. 외부 consumer가 많은 API는 Contract Test가 별도 큰 층을 이룬다.
SMURF는 단일 도형 대신 Speed, Maintainability, Utilization, Reliability, Fidelity의 다섯 trade-off를 보라고 제안한다. 중요한 질문은 “70%를 채웠는가”가 아니라 다음과 같다.
이 실패를 가장 먼저, 가장 싸게, 충분한 현실성으로 잡을 경계는 어디인가?테스트 트로피는 static check를 바닥에 두고, 사용자가 작성한 모듈들이 실제로 함께 동작하는 Integration Test에 큰 비중을 둔다. 이는 피라미드의 반대가 아니다. “Unit”의 정의가 지나치게 작아 모든 협력 객체를 Mock으로 바꾸는 팀에서, 실제 모듈 상호작용을 더 많이 검증하라는 교정이다.
E2E Integration <- 가장 넓은 몸통 Unit Static checks둘의 공통점은 E2E만으로 모든 신뢰를 사지 않는다는 점이다. 차이는 내부 협력 객체를 어디까지 실제로 연결할지에 있다.
기존 문서의 한 환경에서는 다음 시간이 관찰되었다.
| 예시 | 관찰 시간 | 포함한 경계 | 해석 |
|---|---|---|---|
| Repository Mock Unit | 0.892s | Service + Mock | 매우 빠르지만 실제 ORM(Object-Relational Mapping, 객체 관계 매핑) 의미는 빠짐 |
| PostgreSQL Testcontainer Integration | 8.234s | Service + ORM + 실제 DB | 제약·쿼리·직렬화 경계를 검증 |
| HTTP E2E | 4.521s | 앱 설정 + HTTP + 내부 모듈 | 대표 API 계약과 middleware를 검증 |
| LocalStack SQS Integration suite | 18.456s | SDK(Software Development Kit, 소프트웨어 개발 키트) + emulator + queue | 큐 생성·발행·수신 의미를 검증 |
이 숫자의 순서가 항상 같지는 않다. 컨테이너 재사용, 병렬화, 앱 초기화, 데이터 크기에 따라 E2E가 Integration보다 빠를 수도 있다. 보존할 학습 포인트는 테스트 이름이 아니라 포함한 경계와 피드백 비용을 함께 기록해야 한다는 것이다.
Unit Test는 격리된 작은 경계에서 결정적인 행동을 검증한다. 단위가 클래스 하나일 필요는 없다. 금액 계산, 상태 머신, 정책 객체처럼 빠르게 메모리에서 실행되고 실패 원인이 명확한 협력 객체 묶음도 하나의 단위가 될 수 있다.
type OrderStatus = "pending" | "paid" | "shipped" | "cancelled";
function transition(current: OrderStatus, command: "pay" | "ship" | "cancel") { if (current === "pending" && command === "pay") return "paid"; if (current === "paid" && command === "ship") return "shipped"; if (command === "cancel" && current !== "shipped") return "cancelled"; throw new Error(`invalid transition: ${current} -> ${command}`);}
it("배송 완료 뒤 취소를 거부한다", () => { expect(() => transition("shipped", "cancel")).toThrow("invalid transition");});이 테스트가 보호하는 것은 함수 호출 횟수가 아니라 shipped 상태는 취소로 되돌아가지 않는다는 도메인 불변식이다. 구현을 표 기반 상태 머신으로 바꾸어도 행동이 같으면 테스트는 유지된다.
TDD는 다음 짧은 사이클로 요구와 구현을 분리한다.
Red 실패하는 관찰로 원하는 행동을 고정한다.Green 그 관찰을 통과하는 최소 구현을 만든다.Refactor 외부 행동을 유지하며 구조를 개선한다.Red 단계는 테스트가 실제로 실패하는지 확인하는 단계이기도 하다. 처음부터 Green이면 잘못된 대상을 실행했거나 assertion이 약할 수 있다. Green은 하드코딩을 영원히 남기라는 뜻이 아니라, 한 번에 하나의 이유로 실패하도록 변경 폭을 제한한다. Refactor에서는 테스트가 구현 세부사항에 묶이지 않았는지 함께 본다.
요구사항이 다음과 같다고 하자.
gross = 15,000원fee rate = 3%최종 정산액은 음수가 될 수 없고 원 단위 정수여야 한다.Red에서 정상 예제뿐 아니라 경계를 먼저 적는다.
it.each([ { gross: 15_000, rate: 0.03, expected: 14_550 }, { gross: 0, rate: 0.03, expected: 0 }, { gross: 100, rate: 2, expected: 0 },])("settled amount 불변식을 지킨다", ({ gross, rate, expected }) => { const result = settle(gross, rate); expect(result).toBe(expected); expect(Number.isInteger(result)).toBe(true); expect(result).toBeGreaterThanOrEqual(0);});이 테스트는 예시 세 개와 범용 불변식을 함께 둔다. 하지만 실제 DB의 NUMERIC scale과 환율 반올림은 아직 검증하지 않는다. 그 위험은 Integration Test로 넘겨야 한다.
반대로 UI/UX가 주 1~2회 이상 크게 바뀌는 탐색, 인터페이스 자체가 미정인 알고리즘 spike, 1주 안에 폐기할 PoC에서는 먼저 prototype으로 질문을 줄인 뒤 안정된 규칙에 테스트를 붙일 수 있다. 레거시 코드는 현재 동작을 기록하는 characterization test(특성화 테스트)로 변경 전 경계를 확보하는 편이 낫다.
기존 사례에서 Integration Test 30개가 추가되며 PR 빌드가 7분에서 14분으로 늘었다. PR에서는 Unit만 5분 안에 실행하고 Integration을 nightly 14분 job으로 옮기자 merge cycle은 평균 12분에서 7분으로 줄었다. 대신 통합 회귀 발견이 다음 날로 늦어졌다.
이 선택은 배포 빈도와 실패 비용에 따라 달라진다.
1~2회 배포하고 통합 실패의 blast radius가 작다면 지연 발견을 감수할 수 있다.14분 자체를 줄이는 것일 수도 있다.CI > 10분은 보편적 경계가 아니라 개발자가 기다리지 않고 다른 행동을 시작하는지 관찰하기 위한 신호다.
Integration Test는 둘 이상의 실제 구성요소가 함께 만드는 의미를 검증한다. 대표 대상은 다음과 같다.
Repository Mock이 findOne()에서 객체를 반환하도록 만들 수는 있지만, 실제 쿼리의 where 조건이 잘못되어 null을 반환하는 문제는 잡지 못한다. Fake SQLite가 PostgreSQL의 JSONB, collation, transaction isolation을 정확히 흉내 내지 못하는 경우도 같은 경계다.
Testcontainers는 테스트 코드가 실제 DB·Redis·broker 컨테이너의 수명주기를 제어하게 한다. “실제”라는 말은 production과 동일하다는 뜻이 아니다. 엔진 의미를 보존하되 데이터 규모, 네트워크 토폴로지, 권한, 스토리지는 여전히 축소될 수 있다.
it("중복 idempotency key는 부작용 하나로 수렴한다", async () => { const key = "payment-order-42";
const [a, b] = await Promise.allSettled([ payments.charge({ key, amount: 15_000 }), payments.charge({ key, amount: 15_000 }), ]);
expect([a.status, b.status].filter((s) => s === "fulfilled")).toHaveLength(2); expect(await chargeRepository.countByKey(key)).toBe(1); expect(await ledger.sumByKey(key)).toBe(15_000);});핵심 assertion은 두 호출이 모두 응답한다는 사실이 아니라 저장된 charge와 ledger 부작용이 하나라는 불변식이다. 이 테스트는 실제 unique constraint와 transaction 경합을 써야 의미가 있다.
다음 중 하나가 위험의 핵심이면 실제 구현 또는 높은 충실도의 emulator를 쓴다.
반대로 외부 SaaS가 느리고 비용이 들며 테스트용 상태 제어를 제공하지 않는다면, 대부분의 suite에서는 adapter 경계를 Stub으로 두고 소수의 sandbox smoke test와 Contract Test로 보완한다.
Integration Test가 PostgreSQL 컨테이너에서 통과했다고 production migration이 안전한 것은 아니다.
테스트 DB: 1,000 rows, migration 0.2초운영 DB: 100,000,000 rows, row당 약 200 bytes = 약 20GB 원시 데이터backfill 처리율: 20,000 rows/s단순 계산: 100,000,000 / 20,000 = 5,000초, 약 83분락, WAL(Write-Ahead Log), replica lag, index build 비용까지 더하면 영향은 커질 수 있다. 기능 Integration Test와 production-shaped migration test는 별도 증거다.
1 또는 3이 된다.Test double(테스트 더블)은 실제 협력 객체를 테스트 목적으로 대체하는 객체의 총칭이다.
| 종류 | 역할 | 예 | 주요 위험 |
|---|---|---|---|
| Stub | 정해진 입력에 정해진 값을 반환 | 환율 API가 1,350 반환 | 실제 protocol drift를 모름 |
| Mock | 호출·인수·횟수 같은 상호작용을 검증 | send가 한 번 호출됨 | 구현 세부사항에 결합 |
| Spy | 실제 객체를 감싸 호출을 관찰 | logger 호출 기록 | 실제 부작용이 남을 수 있음 |
| Fake | 단순하지만 동작하는 대체 구현 | in-memory repository | 의미가 실제 구현과 달라질 수 있음 |
| Dummy | 시그니처를 채우지만 사용하지 않음 | 미사용 clock 객체 | 사용되기 시작해도 놓칠 수 있음 |
Jest의 jest.fn() 하나가 Stub과 Mock 역할을 모두 할 수 있다. 도구보다 테스트에서 어떤 역할로 쓰는지가 중요하다.
it("queue timeout을 결과 미상으로 보존한다", async () => { const queue = { send: jest.fn().mockRejectedValue(new Error("timeout")), }; const publisher = new EventPublisher(queue);
await expect(publisher.publish(event)).rejects.toMatchObject({ code: "PUBLISH_UNKNOWN", }); expect(queue.send).toHaveBeenCalledWith( expect.objectContaining({ eventId: event.id }), );});여기서 payload의 eventId는 consumer가 처리 완료 기록을 조회하는 애플리케이션 수준 멱등 키다. SQS는 payload 필드 이름을 해석하지 않으므로 eventId가 같다는 이유만으로 메시지를 중복 제거하지 않는다. Mock은 timeout 분기와 이 키가 payload에 실리는지를 빠르게 확인하고, ack 유실 뒤 redelivery와 consumer가 같은 eventId의 부작용을 한 번으로 수렴시키는지는 Integration Test가 맡는다.
toHaveBeenCalledTimes()가 결과 assertion보다 많다.Classicist TDD는 실제 협력 객체와 상태 기반 검증을 선호하고, Mockist TDD는 협력 객체의 상호작용과 역할 경계를 적극적으로 Mock한다. 실무에서는 한쪽을 전체 저장소 규칙으로 고정하기보다 위험별로 섞는다.
순수 계산·상태 전이 -> 실제 값 객체, 결과와 불변식 검증
외부 결제·메일·SQS adapter -> Unit에서는 Mock/Stub으로 command와 오류 매핑 검증 -> Contract/Integration에서는 실제 wire 의미 검증
DB repository -> domain service Unit에서는 port를 Fake/Stub으로 대체 가능 -> repository 자체는 실제 DB Integration Test 필수“외부 의존성은 반드시 Mock”도 너무 강하다. 외부 의존성의 의미가 시스템 correctness에 들어오면 어딘가에서는 실제 경계를 검증해야 한다.
기존 SQS 예제는 두 테스트를 구분했다.
| 질문 | 적합한 경계 | 기존 관찰 예시 |
|---|---|---|
payload eventId와 MessageBody, DelaySeconds, MessageGroupId가 맞는가? | SDK client Mock Unit | suite 1.234s, 개별 23ms, 5ms |
FIFO에서 명시적 MessageDeduplicationId 또는 content-based deduplication 설정이 의도한 5분 창에 적용되는가? | LocalStack Integration + AWS contract | suite 18.456s, 처리 1.243s |
queue 생성 후 publish한 메시지를 consumer가 받고, 같은 eventId의 부작용을 한 번으로 수렴시키는가? | Consumer + broker Integration | 환경에 따라 측정 |
| SNS subscription, DLQ(Dead-Letter Queue, 배달 실패 메시지 보관 큐) redrive, FIFO 순서가 배포 구성과 맞는가? | staging/real service smoke + contract | 환경에 따라 측정 |
FIFO broker deduplication은 payload의 eventId가 아니라 전송 요청의 MessageDeduplicationId를 사용한다. queue에서 content-based deduplication을 활성화하고 이 값을 생략하면 SQS가 message body의 SHA-256 hash로 ID를 만든다. 두 방식 모두 전송 중복을 억제하는 범위는 5분 deduplication interval이며, Standard queue에는 이 FIFO 중복 제거가 없다. 5분 뒤 재전송, redrive, 소비 도중 visibility timeout 만료 같은 경로까지 막아 주지는 않으므로 consumer idempotency는 계속 필요하다.
LocalStack도 AWS 전체 의미와 quota를 완전히 복제하지 않는다. 따라서 emulator 통과를 production 동일성으로 해석하지 않고, 어떤 의미를 보존하는지 문서화한다.
Determinism(결정성)은 같은 코드·입력·초기 상태에서 같은 판정을 내리는 성질이다. Isolation(격리)은 한 테스트의 상태와 실행 순서가 다른 테스트의 결과를 바꾸지 않는 성질이다.
테스트가 빨라도 결정적이지 않으면 merge gate로 쓸 수 없다. 반대로 느린 테스트라도 실패가 재현되고 원인이 명확하면 운영할 수 있다.
Date.now()와 자정·DST(Daylight Saving Time, 일광 절약 시간) 경계Math.random()과 무작위 UUIDsleep(1000)으로 eventual result를 기다림interface Clock { now(): Date;}
interface IdGenerator { next(): string;}
class CouponService { constructor( private readonly clock: Clock, private readonly ids: IdGenerator, ) {}}Unit Test에서는 2026-01-01T00:00:00Z와 coupon-001을 주입한다. 실제 adapter의 timezone·UUID 동작은 별도 테스트에서 확인한다. 이를 단지 “Mock하기 쉽게 만든 설계”로 보지 말고, 시간과 식별자라는 숨은 입력을 공개한 설계로 본다.
| 전략 | 장점 | 경계와 위험 |
|---|---|---|
| 테스트별 transaction rollback | 빠르고 정리 간단 | 다른 connection·비동기 consumer가 같은 transaction을 못 봄 |
| schema/database per worker | 병렬 격리 강함 | 생성·migration 비용 증가 |
| unique namespace/key | 분산 자원에도 적용 가능 | 정리 누락 시 데이터 축적 |
| truncate/reset | 실제 commit 흐름 검증 | 병렬 테스트와 충돌 가능 |
기존 문서의 DELETE 뒤 sequence를 1로 재설정하는 방식은 작은 suite에서는 가능하지만 ID 값 자체에 테스트가 결합되기 쉽다. 가능하면 생성한 ID를 응답에서 받아 사용하고, ID가 정확히 1이라는 assertion은 ID 정책이 요구사항일 때만 둔다.
비동기 projection이 보통 600ms에 반영된다고 sleep(1000)을 고정하면 느린 CI에서는 실패하고 빠른 로컬에서는 불필요하게 기다린다.
await eventually( async () => readModel.find("order-42"), (order) => order?.status === "paid", { timeoutMs: 5_000, intervalMs: 50 },);이 패턴은 최대 5초라는 제품·테스트 예산 안에서 조건이 만족되는 즉시 끝난다. 하지만 timeout만 늘려 문제를 숨겨서는 안 된다. 실패 출력에 마지막 관찰 상태, 처리한 event version, consumer lag를 남겨야 한다.
Test A: users INSERT -> id=1Test B: users INSERT -> id가 1이라고 가정
단독 실행: B 통과전체 실행: A가 남긴 row 때문에 B의 id=2, 실패순서 랜덤화: 실패가 더 자주 드러남해결은 무조건 sequence를 초기화하는 것이 아니다. 테스트 B가 “첫 user의 ID는 1”을 검증하려는지, “저장 후 같은 user를 조회”하려는지 oracle부터 확인한다. 후자라면 반환된 ID를 사용해 상태 공유 가정을 제거한다.
5초에서 30초, 다시 60초로 늘리는 수정이 반복된다.Contract Test(계약 테스트)는 두 독립 구성요소가 주고받는 요청·응답·이벤트가 약속한 형식과 의미를 지키는지 검증한다. 내부 메서드가 아니라 HTTP status, header, schema, event field, Protobuf message처럼 경계 밖에서 관찰 가능한 것을 다룬다.
{ "type": "OrderPaid", "version": 2, "orderId": "order-42", "amount": 15000, "currency": "KRW"}Schema가 amount를 number로 보장해도 “원 단위인가 소수 통화 단위인가”는 자동으로 알 수 없다. 구조 계약과 의미 계약을 구분하고, 단위·시간대·enum 확장 정책은 이름과 문서·예제로 고정한다.
Provider-driven contract는 OpenAPI, AsyncAPI, Protobuf 같은 제공자 명세를 기준으로 provider 구현과 consumer SDK를 검증한다. 공개 API처럼 모든 consumer를 알 수 없을 때 적합하다.
CDC(Consumer-Driven Contract, 소비자 주도 계약) 는 각 consumer가 실제로 사용하는 요청과 기대 응답을 계약으로 발행하고 provider가 그 계약들을 검증한다. 사내 서비스처럼 consumer 목록과 배포 관계를 관리할 수 있을 때 유용하다.
Shipping consumer test -> "GET /orders/42에서 id와 shippingAddress가 필요" -> pact 발행
Order provider verification -> 알려진 provider state에서 pact interaction 재생 -> 현재 provider version이 기대를 만족하는지 검증CDC는 consumer의 실제 사용을 잘 잡지만 등록되지 않은 consumer는 보호하지 못한다. Provider spec은 전체 표면을 보여 주지만 어떤 필드를 실제로 쓰는지는 모른다. 외부 API는 provider spec을 기본으로, 손실이 큰 내부 통합에는 CDC를 추가할 수 있다.
order-42가 paid 상태로 존재한다는 계약을 검증하려면 provider가 그 상태를 반복 가능하게 준비해야 한다. production data를 우연히 조회하면 계약 테스트가 아니라 환경 운에 의존하는 smoke test가 된다.
Given order-42 is paidWhen GET /orders/order-42Then 200 and { status: "paid" }Provider state handler는 테스트 전용 seed API나 DB fixture로 최소 상태를 만든다. consumer가 provider 내부 DB 구조를 알아서는 안 되므로, 상태 이름은 업무 언어로 두고 provider가 내부 준비 방법을 소유한다.
따라서 계약 테스트가 E2E를 완전히 대체하지는 않는다. 대신 모든 consumer를 전체 환경에 띄우지 않고도 독립 배포 가능성을 빠르게 검증한다.
Order event v1을 Shipping과 Analytics가 읽고 있다고 하자.
v1: { orderId, totalAmount }v2 제안: totalAmount 제거, amount { value, currency } 추가한 번에 교체하면 구버전 consumer가 깨진다. 안전한 순서는 다음과 같다.
1. Producer가 totalAmount와 amount를 함께 발행한다.2. 모든 consumer가 amount를 읽되 totalAmount도 허용한다.3. 배포 registry와 contract matrix로 구 consumer가 없음을 확인한다.4. totalAmount를 제거한다.“새 필드는 optional이니 안전”도 충분하지 않다. consumer가 unknown enum에서 crash하거나, 새 producer가 구 consumer가 모르는 통화를 보낼 수 있다. schema diff와 consumer behavior를 함께 검증한다.
이 문서는 계약 테스트를 테스트 포트폴리오의 한 경계로 다룬다. OpenAPI/AsyncAPI 설계, versioning, deprecation, Pact 배포 gate의 구체 워크플로는 후속 API 설계 & 계약에서 확장한다.
E2E(End-to-End) Test는 시스템 외부 진입점에서 실제 배포 구성과 주요 의존성을 지나 결과를 관찰한다.
E2E는 가장 현실적일 수 있지만 가장 완전한 테스트는 아니다. 입력 공간이 크고 실패 원인이 넓어 모든 분기를 맡기면 느리고 불안정해진다.
단순 CRUD의 모든 status code를 E2E로 반복하기보다, 생성 성공·유효성 실패·권한 거부·중복 재시도처럼 의미가 다른 경계를 고른다.
기존 문서의 JWT 예제는 두 선택을 보여 주었다.
userId를 주입한다. 빠르지만 인증 자체는 검증하지 않는다.Authorization: Bearer ...를 보낸다. 현실적이지만 setup과 실패 표면이 넓다.둘 중 하나만 고르는 것이 아니라 목적을 분리한다. 주문 상태 전이 테스트 수십 개가 매번 실제 login할 필요는 없지만, 서명·만료·issuer·claim mapping을 통과하는 대표 인증 E2E는 별도로 있어야 한다.
E2E 실패 출력에는 최소한 다음 상관관계를 남긴다.
“Expected 201, received 500”만 남으면 테스트가 사용자 실패는 알리지만 원인을 좁히지 못한다.
Example-based test는 사람이 고른 입력과 기대값을 검증한다. 읽기 쉽고 회귀를 명확히 고정하지만, 사람이 생각하지 못한 조합은 탐색하지 않는다.
Property-based testing(속성 기반 테스트)은 입력 생성기가 많은 값을 만들고, 모든 값에 성립해야 할 property(속성·불변식)를 검증한다. 실패하면 shrinking(축소)을 통해 재현 가능한 작은 반례를 찾는다.
정수 금액을 여러 수취인에게 나눌 때 다음 불변식이 필요하다고 하자.
각 몫 >= 0모든 몫의 합 = 원래 금액수취인 수와 결과 수가 같음같은 입력은 같은 결과import fc from "fast-check";
it("분할 뒤 총액이 보존된다", () => { fc.assert( fc.property( fc.nat({ max: 1_000_000_000 }), fc.integer({ min: 1, max: 100 }), (amount, recipients) => { const shares = splitAmount(amount, recipients);
expect(shares).toHaveLength(recipients); expect(shares.every((share) => share >= 0)).toBe(true); expect(shares.reduce((sum, share) => sum + share, 0)).toBe(amount); }, ), );});사람이 15,000 / 3만 고르면 나누어떨어지는 입력만 본다. 생성기는 1 / 3, 최대값, 수취인 100명 같은 경계를 탐색한다. 실패한 거대한 입력을 amount=1, recipients=3으로 줄이면 반올림 오류를 손으로 이해할 수 있다.
랜덤 입력을 많이 넣는 fuzzing만으로 property가 생기지는 않는다. oracle이 “crash하지 않는다”뿐이면 잘못된 업무 결과를 놓칠 수 있다. 또한 seed를 기록하지 않으면 실패를 재현하기 어렵다. generator가 production 입력 분포를 과도하게 좁히거나 넓혀도 의미가 약해진다.
Property Test는 예제 테스트를 없애지 않는다. 대표 예제는 요구를 읽기 쉽게 설명하고, property는 예제 밖의 조합을 탐색한다.
앞선 분산 시스템 기초에서 timeout은 실패 확정이 아니라 결과를 모른다는 증거였다. MSA 패턴은 함수 호출을 네트워크 경계와 여러 로컬 트랜잭션으로 바꿨다. 테스트 전략은 이 두 선수지식의 실패 모델을 happy path 바깥의 입력으로 옮겨야 한다.
여기서 test oracle(테스트 오라클) 은 테스트 결과가 맞는지 가르는 정답 판정 기준이다. 분산 흐름에서는 한 요청의 HTTP status보다 재시도와 재전달이 끝난 뒤 지켜야 할 데이터 불변식이 더 강한 oracle이다.
주문, 결제, 재고가 각각 local transaction을 가진다고 하자.
CreateOrder -> ChargePayment -> ReserveInventoryHTTP 200 하나만 보면 다음 실패를 놓친다.
UNKNOWN이다.OrderCreated가 두 번 전달된다.OrderCancelled(v2) 뒤 늦은 OrderCreated(v1)이 온다.따라서 oracle을 다음처럼 둔다.
동일 idempotency key의 최종 charge 수 <= 1reserved stock >= 0terminal order는 이전 version event로 되돌아가지 않음완료되지 않은 saga는 추적 가능한 PENDING/COMPENSATING/MANUAL_REQUIRED 중 하나outbox의 미발행 row는 유실되지 않고 재시도 가능projection은 정의한 freshness budget 안에 source version을 따라잡음t0 Order -> Payment: charge(key=order-42)t1 Payment: provider 승인 성공, local result 저장t2 Payment -> Order: 응답 패킷 유실t3 Order: 500ms timeout, 결과 UNKNOWNt4 Order: 같은 key로 status 조회 또는 재시도t5 Payment: 저장한 기존 승인 결과 반환
최종 불변식: provider charge 1건, payment record 1건, order는 PAID로 수렴테스트가 t3에서 단순 실패를 기대하고 끝나면 핵심을 놓친다. fault injection으로 t2 응답만 끊고 t1 부작용은 남긴 뒤, 같은 key의 재조회·재시도가 한 결과로 수렴하는지 확인해야 한다.
분산 소비자와 relay의 코드는 정상 반환만 검사해서는 원자성 경계를 증명할 수 없다. 테스트 전용 barrier를 정확한 지점에 두고 worker를 별도 process로 실행한 뒤 SIGKILL처럼 finally나 예외 처리로 복구할 수 없는 crash를 주입한다. 단순히 함수가 예외를 던지게 하면 같은 process의 rollback·retry 코드만 검증하고 실제 연결 단절 뒤 동작을 놓칠 수 있다.
| crash boundary | setup | crash injection | restart/redelivery | 핵심 assertion |
|---|---|---|---|---|
| Inbox marker와 domain change 사이 | 주문 PENDING, inbox(event-17) 없음, broker에 PaymentApproved(event-17) 1건 | 같은 DB transaction에서 inbox insert 뒤 주문 update 전에 worker를 강제 종료 | DB가 끊긴 transaction을 정리한 뒤 consumer 재시작, 같은 event를 redelivery | redelivery 전 marker와 변경이 모두 없고, 처리 완료 뒤 marker 1건·주문 PAID·domain effect 1건 |
| Projection update와 processed marker 사이 | paid_count=0, lastAppliedVersion=6, processed(event-18) 없음 | 같은 DB transaction에서 projection increment 뒤 processed insert 전에 강제 종료 | consumer 재시작 뒤 event-18(version=7) redelivery | redelivery 전 projection과 marker가 모두 rollback되고, 완료 뒤 paid_count=1·version 7·marker 1건 |
| Outbox publish 성공과 published marker 기록 사이 | outbox(event-19, published_at=NULL), consumer inbox marker 없음, domain effect 0건 | broker publish ACK를 받은 직후 published_at update 전에 relay를 강제 종료 | relay가 미발행 row를 다시 publish하고 consumer가 두 delivery를 모두 처리 시도 | publish 관찰 2회·outbox 최종 published, consumer inbox marker 1건·domain effect 1건 |
setup order-42.status = PENDING inbox(event-17) = absent broker delivery count = 1
crash injection BEGIN INSERT inbox(event-17) -- barrier 도달: COMMIT 전에 consumer process kill UPDATE orders SET status = 'PAID' ... # 실행되지 않음
restart/redelivery DB connection 종료로 미커밋 transaction rollback 확인 consumer 재시작 -> event-17 재전달 -> 같은 handler 전체 COMMIT
assertion redelivery 직전: inbox(event-17) = absent, order.status = PENDING 처리 완료 뒤: inbox(event-17) = 1 row, order.status = PAID payment-applied audit/domain effect = 1 row이 예제는 Inbox marker와 domain change가 같은 local transaction에 실제로 묶였고 process crash 시 둘 다 rollback된다는 것을 입증한다. 같은 event가 다시 오면 둘이 함께 commit되어 한 효과로 수렴한다는 증거도 준다. 반면 broker 자체의 장기 내구성, 외부 결제 API 같은 transaction 밖 부작용, DB failover 뒤 commit durability까지 입증하지는 못한다. 그런 경계는 broker Integration, 외부 idempotency, failover test가 별도로 맡아야 한다.
setup daily_order_stats.paid_count = 0 daily_order_stats.lastAppliedVersion = 6 processed(event-18) = absent
crash injection BEGIN UPDATE daily_order_stats SET paid_count = paid_count + 1, lastAppliedVersion = 7 -- barrier 도달: processed marker insert 전에 consumer process kill
restart/redelivery 미커밋 projection update와 marker가 모두 rollback됐는지 먼저 조회 event-18(version=7) 재전달 -> projection update + marker insert COMMIT
assertion redelivery 직전: paid_count = 0, lastAppliedVersion = 6, marker absent 처리 완료 뒤: paid_count = 1, lastAppliedVersion = 7, marker 1 row event-18을 한 번 더 전달해도 위 세 값이 유지됨증분 projection은 중복 적용되면 paid_count=2가 되므로 최종 row만 대충 비교하지 말고 count, 이 projection row의 lastAppliedVersion, marker를 함께 본다. 이 예제는 projection update와 processed marker의 local atomicity, redelivery 뒤 멱등 수렴을 입증한다. 그러나 여러 aggregate의 event 순서, live 처리와 backfill 경합, Event Sourcing의 event store 전체를 읽는 projection replay에서 같은 결과가 만들어지는지는 입증하지 못한다. 이들은 version·Event Sourcing replay·freshness 테스트의 별도 oracle이다.
두 write를 의도적으로 서로 다른 transaction에 둔다면 이 테스트는 실패해야 정상이다. 그 구조가 불가피하면 WHERE last_applied_version < 7 같은 version guard나 event별 absolute upsert로 두 번째 적용을 no-op으로 만들고, 첫 projection commit 뒤 marker 전 crash 변형을 추가해 같은 최종값으로 수렴하는지 입증해야 한다. 단순 count = count + 1을 transaction 밖에서 재실행하는 설계는 processed marker만으로 보호되지 않는다.
setup outbox(event-19).published_at = NULL consumer inbox(event-19) = absent shipment_request(order-42) = 0 rows
crash injection relay -> broker: publish(event-19) broker -> relay: ACK -- barrier 도달: published_at update 전에 relay process kill
restart/redelivery relay 재시작 -> published_at IS NULL인 event-19 조회 -> 다시 publish broker는 원본과 재발행본을 consumer에 전달 consumer는 inbox marker와 shipment_request insert를 한 local transaction으로 처리
assertion relay publish/ACK = 2회 outbox(event-19).published_at != NULL consumer inbox(event-19) = 1 row shipment_request(order-42) = 1 row여기서 publish 2회는 실패가 아니라 broker ACK와 producer marker 사이에는 원자적 commit이 없으므로 재발행이 허용된다는 증거다. 이 예제는 outbox relay가 ACK 뒤 crash에서 event를 유실하지 않고, 협력하는 consumer의 idempotency가 중복 delivery를 한 domain effect로 수렴시킨다는 것을 입증한다. 반면 broker가 exactly-once 전달한다는 뜻은 아니며, 모든 consumer가 멱등하다는 보장도 아니다. 메시지 순서, 장시간 redrive, consumer가 외부 API를 호출하는 경우의 중복은 각각 별도 테스트가 필요하다.
세 테스트 모두 crash hook 이름이 아니라 저장 상태를 oracle로 삼는다. afterInboxInsert, afterProjectionUpdate, afterBrokerAck hook이 호출됐다는 assertion만으로는 transaction 경계가 올바르다는 것을 입증할 수 없다. 반드시 crash 뒤 재시작한 새 process와 새 DB connection에서 중간 상태와 최종 수렴 상태를 관찰한다.
At-least-once 전달에서는 같은 메시지가 다시 올 수 있다. broker가 제한된 deduplication window를 제공해도 운영자 redrive가 더 늦게 일어나면 중복은 다시 나타날 수 있다.
const createdV1 = { id: "evt-1", aggregateId: "order-42", version: 1 };const cancelledV2 = { id: "evt-2", aggregateId: "order-42", version: 2 };
await consumer.handle(cancelledV2);await consumer.handle(createdV1); // 늦은 과거 이벤트await consumer.handle(cancelledV2); // 중복
expect(await projection.get("order-42")).toMatchObject({ status: "cancelled", version: 2,});expect(await sideEffects.countByEvent("evt-2")).toBe(1);단순히 handler가 예외를 던지지 않는지보다 version이 뒤로 가지 않고 부작용이 중복되지 않는지 본다.
Saga는 DB rollback이 아니다. 환불은 실패할 수 있고 이메일은 되돌릴 수 없으며 배송은 출고 뒤 취소가 아니라 반품이 된다. 다음 fault matrix를 최소 단위로 만든다.
| 실패 지점 | 이미 커밋된 것 | 기대 상태 | 후속 검증 |
|---|---|---|---|
| 결제 전 실패 | 주문 PENDING | CANCELLED 가능 | charge 0건 |
| 결제 성공, 재고 실패 | charge 1건 | COMPENSATING | refund는 같은 key로 1회 수렴 |
| 환불 timeout | 승인 성공 확정, 환불 처리 결과만 미상 | RECONCILING | 환불 상태 조회 뒤 결정 |
| 보상 최대 재시도 초과 | 일부 효과 남음 | MANUAL_REQUIRED | DLQ·대사 식별자 존재 |
“예외가 발생했다”가 아니라 모든 중간 상태가 사라지지 않고 운영 가능한 상태로 기록되는지 확인한다. 이 fault matrix는 앞선 Saga Pattern의 상태 머신과 보상 실패 모델을 선수지식으로 사용한다.
CQRS(Command Query Responsibility Segregation)는 변경 모델과 조회 모델을 분리한다. Event Sourcing을 쓰면 event stream이 원본이 되고 projection을 재생성할 수 있다.
여기서 Event Sourcing projection replay는 event store의 확정 이력을 순서대로 다시 읽어 query model을 재구축하는 작업이다. broker나 DLQ의 저장 메시지를 consumer에게 다시 전달하는 message replay, 영속 workflow history로 결정 로직을 재실행해 workflow 상태를 복구하는 workflow replay와는 대상과 oracle이 다르다.
검증할 위험은 다음과 같다.
aggregate streamVersion 이후 event가 하나 빠지거나 중복된다.events: Created(v1) -> Paid(v2) -> Cancelled(v3)
live projection 결과 = Event Sourcing replay로 재구축한 projection 결과lastAppliedVersion = 3같은 stream으로 Event Sourcing projection replay를 두 번 수행해도 최종 row와 집계가 같음Snapshot이 order-42의 aggregate streamVersion=120까지 포함한다면 재수화는 같은 aggregate stream에서 streamVersion > 120인 event부터 이어야 한다. 이 값은 aggregate별 낙관적 동시성·재생 순서를 나타내는 경계이며, 일반적인 event offset이라고 부르지 않는다. 반면 broker offset이나 consumer checkpoint는 partition·subscription에서 어디까지 전달·처리했는지를 나타내는 소비 위치다. broker checkpoint가 전진해도 특정 aggregate snapshot이 최신이라는 뜻은 아니고, snapshot의 streamVersion을 broker offset으로 사용해서도 안 된다.
이 절은 앞선 CQRS & Event Sourcing의 event stream, projection, snapshot 경계를 선수지식으로 사용한다. 여기서는 그 모델을 반복 설명하지 않고 Event Sourcing replay 가능성이 반드시 자동 검증되어야 한다는 점을 잡는다.
MSA 문서의 예시처럼 유입률이 500 events/s, 복구 뒤 처리율이 800 events/s, consumer 중단이 5분이면 backlog는 150,000 events다.
순감소 = 800 - 500 = 300 events/s해소 시간 = 150,000 / 300 = 500초 = 약 8분 20초consumer process가 다시 healthy라는 것과 query model이 최신이라는 것은 다르다. 이 예시의 lag는 aggregate별 streamVersion이 아니라 같은 event log 위치 단위에서 source의 전역 high watermark와 projector의 전역 checkpoint를 비교한 차이다. 단일 전역 순서가 없는 partitioned log라면 전역 단일값 대신 partition별 high watermark와 checkpoint의 offset 차이를 측정한다. 회복 테스트는 process readiness뿐 아니라 이 projection lag가 예산 안으로 돌아오는 시간까지 측정한다.
Rolling deployment에서는 잠시 구 코드 N과 신 코드 N+1이 같은 DB·broker·API를 사용한다. migration 전략은 최종 schema만 맞는지보다 이 공존 구간이 안전한지 검증해야 한다.
t0 old app N + schema St1 schema expand S+ 적용t2 old N과 new N+1 동시 실행t3 data backfillt4 모든 app N+1t5 old column/field contract예를 들어 total_amount를 {amount_value, currency}로 바꾼다고 하자.
Expand 새 nullable column과 새 event field 추가 구 reader가 계속 동작
Migrate 신 writer가 구·신 표현을 함께 기록 backfill과 검증으로 기존 row 이동 consumer를 새 field로 전환
Contract 구 reader가 없음을 확인 dual write 중지 구 column과 field 제거Contract 단계부터 적용하면 구 app이 즉시 깨진다. migration test는 단계별 artifact 조합을 실행해야 한다.
| Writer | Reader | Schema | 기대 |
|---|---|---|---|
| N | N | S+ | 기존 기능 유지 |
| N+1 | N | S+ | 구 reader가 dual-written 값 읽음 |
| N | N+1 | S+ | 신 reader가 old row fallback 처리 |
| N+1 | N+1 | S+ | 새 표현 정상 |
| N rollback | N+1 data | S+ | rollback 뒤에도 읽기 가능 |
모든 조합을 production E2E로 돌릴 필요는 없다. migration fixture와 contract test로 빠르게 검증하고, 대표 rolling deployment만 staging에서 확인할 수 있다.
작은 fixture가 기능 correctness를, production-shaped dataset이 시간·락·자원 비용을 검증한다.
100,000,000 rows20,000 rows/s backfill약 83분의 순수 처리 시간
추가 관찰 lock wait와 p99 query latency WAL 증가율과 replica lag CPU/IO headroom 중단 후 resume cursor 검증 불일치 row 수backfill은 한 번 성공하는 batch가 아니라 중단·재개 가능하고 같은 range를 다시 처리해도 안전해야 한다. updated_at만 cursor로 쓰면 같은 시각의 row를 건너뛸 수 있으므로 (updated_at, id) 같은 안정된 순서를 검토한다.
Event schema는 이미 저장된 과거 데이터까지 읽어야 할 수 있다. 새 consumer가 v2만 읽도록 바꾸면 Event Sourcing replay 중 v1에서 실패한다.
선택지는 다음과 같다.
테스트 fixture에는 가장 오래 지원하는 version, 현재 version, unknown optional field, unknown enum, 누락 field를 포함한다.
1주 데이터만 통과하고 장기 보존 event에서 깨진다.Chaos engineering(카오스 엔지니어링)은 정상 상태에 대한 가설을 세우고, 통제된 실패를 주입해 시스템이 그 가설을 유지하는지 확인하는 실험 방식이다.
정상 상태(steady state) 주문 성공률 >= 99.9%, p99 <= 800ms, 중복 charge = 0
가설 Payment instance 1개가 중단돼도 위 불변식과 오류 예산을 지킨다.
실험 canary traffic 1%에서 instance 1개 종료
중단 조건 중복 charge > 0 또는 주문 오류율 > 1%가 2분 지속blast radius(영향 범위), duration, abort condition, owner, 복구 절차가 없는 장애 주입은 학습 실험이 아니라 운영 위험이다.
기본 기능과 불변식이 Unit/Integration/Contract/E2E에서 이미 검증되어야 한다. 카오스 테스트로 잘못된 금액 공식을 찾으려 해서는 안 된다. 다음 가설에 적합하다.
Gateway, Order, Payment client가 각각 최대 3회 시도하면 최악의 하위 호출 수는 다음과 같다.
3 x 3 x 3 = 27회원 요청 1,000건 -> 최대 27,000 Payment 호출카오스 실험에서 Payment 응답을 지연시키고 호출 수가 27배 가까이 증가한다면 “retry가 잘 동작한다”가 아니라 재시도 소유권이 중복된 실패다. 한 계층만 retry를 소유하고, 전체 deadline·retry budget·exponential backoff·jitter를 함께 검증한다.
평소 1,000 RPS(Requests Per Second, 초당 요청 수) 중 cache hit가 80%라 DB는 200 RPS를 처리한다. cache 장애 시 DB가 1,000 RPS를 받아야 하지만 안전 용량이 500 RPS라면 fallback은 새 장애를 만든다.
평상시 DB 부하: 200 RPScache 장애 뒤: 1,000 RPSDB 안전 용량: 500 RPS초과: 500 RPS카오스 테스트의 oracle은 cache miss를 성공적으로 DB로 보냈는지가 아니라, load shedding·stale response·rate limit 정책이 DB 안전 용량과 핵심 요청을 보호했는지다.
Flaky test(불안정 테스트)는 코드와 의도한 입력이 같아도 실행마다 pass와 fail이 달라지는 테스트다. 원인은 시간, 순서, shared state, race condition, 네트워크, 자원 부족, 실제 production nondeterminism일 수 있다.
Google의 분석에서는 전체 테스트 실행의 약 1.5%가 flaky였고, 테스트의 약 16%가 어느 정도 flakiness를 보였으며, pass에서 fail로 바뀐 전환의 약 84%가 flaky 테스트와 관련되었다. 이 수치는 모든 조직에 그대로 적용할 기준이 아니라, 작은 확률이 큰 suite에서 얼마나 자주 의사결정을 방해하는지 보여 준다.
각 테스트가 독립적으로 0.1% 확률로 거짓 실패한다고 단순 가정하고 1,000개를 실행하면, 기대 거짓 실패 수는 1개다.
suite 전체가 거짓 실패 없이 통과할 확률= 0.999^1000≈ 0.368≈ 36.8%독립 가정은 현실과 다르지만 규모 효과를 보여 준다. 테스트 하나의 flake rate가 작아 보여도 suite 전체 merge gate는 자주 빨간색이 될 수 있다.
기존 Google 사례를 단순 적용해 1,000개 실행에서 1.5%가 flaky failure라면 약 15개 조사 대상이 생긴다. 재실행으로 통과하더라도 사람이 판정을 미룬 시간이 사라지는 것은 아니다.
PR CI 200회/일거짓 실패 5%한 번 분류·재실행·대기 12분
200 x 0.05 x 12 = 120분/일20근무일 = 40시간/월여기에는 context switching과 실제 회귀를 flake로 오판한 비용이 빠져 있다. flaky 테스트는 단지 CI 비용이 아니라 팀이 빨간 신호를 믿지 않게 만드는 신뢰 부채다.
동일 commit에서 3회 중 1회라도 실패하는 테스트를 critical path에서 일시 격리할 수 있다. 그러나 quarantine은 삭제나 무기한 무시가 아니다.
무조건 retry해서 Green으로 바꾸면 pass rate는 좋아 보이지만 첫 실패 정보를 숨긴다. retry는 진단 자료를 더 얻는 수단이어야 한다.
다음 순서로 테스트를 고른다.
1. 잃으면 안 되는 불변식과 사용자 약속을 적는다.2. 그 약속이 깨지는 최소 실패를 만든다.3. 실패를 관찰할 수 있는 가장 좁은 경계를 찾는다.4. 좁은 경계가 현실 의미를 잃으면 실제 의존성을 한 단계 추가한다.5. 남은 조합 위험만 Contract/E2E/Chaos로 올린다.6. 실행 시간, flake rate, 진단 시간을 측정해 포트폴리오를 조정한다.| 위험 | 첫 선택 | 추가 증거 | 이유 |
|---|---|---|---|
| 정산 반올림·음수 방지 | Unit + Property | 실제 DB Integration | 넓은 입력과 DB numeric 의미 모두 필요 |
| SQL·constraint·transaction | 실제 DB Integration | migration test | Mock이 엔진 의미를 모름 |
| HTTP/event schema 호환 | Contract | 대표 E2E | 독립 배포와 실제 routing을 분리 검증 |
| 결제 timeout 뒤 중복 | Unit fault injection | provider sandbox + distributed E2E | 결과 미상 상태와 외부 idempotency 모두 필요 |
| event 중복·순서 역전 | consumer Integration/Property | broker Integration | 상태 머신과 전달 의미를 나눔 |
| rolling schema 변경 | compatibility matrix | staging rollout | 공존 조합과 운영 규모를 나눔 |
| failover·retry 폭주 | deterministic simulation | bounded Chaos | 먼저 값싸게 모델, 나중에 실제 회복 검증 |
| 인증 claim mapping | Unit/Component | 실제 login E2E | 대부분 흐름에서 login 비용을 반복하지 않음 |
모든 경로에 100%를 목표로 하기보다 다음 질문으로 우선순위를 정한다.
단순 getter/setter보다 상태 전이와 migration rollback이 먼저다. TypeORM 자체를 다시 테스트할 필요는 없지만, 우리 mapping과 query가 TypeORM·DB와 함께 만드는 의미는 검증해야 한다.
빠른 feedback과 충분한 증거를 동시에 얻으려면 실행 시점을 나눈다.
로컬/변경 직후 static + 관련 Unit/Property
PR critical path 전체 Unit + 관련 Integration + Contract + migration compatibility
merge/deploy 전 대표 E2E + 실제 artifact smoke
nightly/주기적 넓은 Integration matrix + Event Sourcing replay + 긴 migration + emulator
통제된 실험 load/failover/Chaos느린 테스트를 nightly로 옮기기 전에 실패 발견 지연을 계산한다. release 직전에만 발견하면 수정 비용이 커지는 위험은 PR에 남긴다. 변경 영향 선택을 쓸 때도 공용 schema·base image·shared library 변경은 넓은 suite를 실행한다.
다음 테스트는 삭제 또는 재작성 후보다.
삭제는 테스트 수 감소가 목적이 아니다. 비싼 중복 증거를 제거해 빠진 위험에 예산을 재투자하는 일이다.
| 관찰한 증상 | 먼저 의심할 전략 결함 | 다음 질문 |
|---|---|---|
| coverage는 높은데 금액 오류 발생 | oracle·경계 사례 부족 | mutation과 경계값이 오류를 잡는가? |
| Unit은 Green, 실제 DB에서 실패 | Mock fidelity 부족 | 실제 constraint·dialect 테스트가 있는가? |
| producer 배포 뒤 consumer crash | contract/version matrix 부재 | 구 consumer 기대를 provider가 검증했는가? |
| 같은 event가 두 번 부작용 생성 | idempotency test 부재 | 처리 기록과 부작용이 같은 원자 경계인가? |
| 취소 상태가 생성 상태로 되돌아감 | 순서 역전·version test 부재 | 늦은 v1을 v2 뒤에 주입했는가? |
| consumer는 healthy지만 화면은 8분 stale | freshness oracle 부재 | process health와 data lag를 분리했는가? |
| rollback 뒤 신형 row를 못 읽음 | migration 공존 테스트 부재 | N/N+1 matrix에 rollback 조합이 있는가? |
| CI 재실행 후 통과 | 결정성·격리 실패 | seed·clock·namespace를 재현할 수 있는가? |
| E2E 실패 원인 파악에 수시간 | diagnosticity 부족 | correlation ID와 마지막 완료 단계가 있는가? |
| Chaos 중 하위 QPS 27배 증가 | retry 소유권 중복 | 각 계층 최대 시도 수를 곱해 보았는가? |
| 인증을 Mock한 E2E만 통과 | 실제 인증 journey 누락 | 대표 login·claim mapping 검증이 있는가? |
실패 신호는 바로 “테스트를 더 추가하라”는 뜻이 아니다. 기존 테스트가 잘못된 경계에 있거나 oracle이 약하거나, 같은 위험을 과도하게 중복하는지 먼저 본다.
다음 위험을 가진 주문 서비스를 설계한다고 하자.
핵심 불변식 재고는 0 아래로 내려가지 않는다. 같은 payment key의 charge는 1건이다. shipped 주문은 cancelled로 되돌아가지 않는다.
경계 PostgreSQL, Payment API, SQS, Shipping consumer
분산 실패 timeout, duplicate event, out-of-order event, projection lag
변경 amount number -> { value, currency } migrationUnit 상태 전이, 금액 계산, 오류 매핑
Property 재고 >= 0, 금액 분할 합 보존, command sequence 불변식
PostgreSQL Integration 조건부 재고 차감, unique payment key, transaction 경합
Payment Contract idempotency key, status query, timeout/unknown response shape
SQS Integration publish/consume, redelivery, visibility timeout
Consumer Integration duplicate/out-of-order event와 version 단조성
Migration matrix old/new amount field dual read/write와 rollback
E2E 주문 생성 -> 결제 -> 재고 -> 영수증 대표 journey
Chaos Payment latency와 instance 종료 중 중복 charge 0, 오류 예산 유지관련 Unit + Property: 5초 이내PR 전체 critical path: 8분 목표대표 E2E: 3분 이내projection freshness: 정상 p99 5초 이내Payment timeout fault: charge 최종 1건flaky retry 전 first-attempt pass rate: 99.9% 이상 목표 예시숫자는 팀의 시작 가설이다. 실제 지연 분포와 실패 비용을 측정해 바꾼다. 8분을 지키려고 migration test를 nightly로 미뤄 release 위험을 키우면 잘못된 최적화다. 반대로 같은 위험을 검증하는 E2E 30개를 유지하며 PR을 20분으로 만드는 것도 재구성 신호다.
나쁜 설명은 “테스트 2,300개, coverage 82%라 안전”이다. 더 나은 설명은 다음과 같다.
재고·결제·상태 전이 불변식은 Unit/Property와 실제 DB 경합으로 검증했다.독립 배포 계약은 구·신 consumer matrix에서 통과했다.대표 주문 journey는 실제 routing과 인증을 포함해 검증했다.Payment timeout과 SQS 중복을 주입해 부작용이 1회로 수렴함을 확인했다.남은 위험은 production 규모 1억 row backfill의 IO 영향이며 canary migration으로 제한한다.테스트 전략은 남은 불확실성까지 말할 수 있어야 한다.
본문은 이 부록 없이도 완결된다. 아래 예시는 Jest, NestJS, Testcontainers, LocalStack을 쓰는 환경에서 관찰 지점을 연결하기 위한 절차다. 버전과 팀 CI 구성에 맞게 조정한다.
# 한 Unit 파일과 open handle 관찰npx jest src/order/order.service.spec.ts --runInBand --verbose
# Integration suite: 컨테이너 시작 예산을 명시npx jest --testPathPattern="integration" --testTimeout=60000 --runInBand
# E2E suitenpm run test:e2e
# coverage는 위험 분포와 함께 읽는다.npm test -- --coverage--runInBand에서만 통과하면 병렬 shared state를 의심한다. --detectOpenHandles는 닫히지 않은 timer·connection 탐색에 도움이 되지만 실행 비용이 커 상시 기본값보다 진단 시 사용한다.
실패 출력에서 먼저 보존할 항목:
commit SHA, test seed, worker ID, timezonefixture namespace, DB/container versioncorrelation/event/operation ID첫 실패 시각과 마지막 관찰 상태describe("OrderRepository integration", () => { let container: StartedPostgreSqlContainer; let app: TestingModule;
beforeAll(async () => { container = await new PostgreSqlContainer("postgres:16").start(); app = await createTestingModule(container.getConnectionUri()); await runMigrations(app); }, 60_000);
afterAll(async () => { await app.close(); await container.stop(); });
beforeEach(async () => { await resetOwnedSchema(app); });});기존 문제처럼 NestJS module을 닫지 않거나 DB connection, timer를 남기면 suite가 점점 느려지고 heap limit에 도달할 수 있다. workerIdleMemoryLimit: "512MB"나 Node heap 4096MB 증가는 관찰용 완화책일 뿐 누수 원인을 대신하지 않는다.
Container 시작 때문에 timeout을 기본 5초에서 30초 또는 60초로 늘릴 수 있지만, 앱 로직의 deadline과 fixture setup timeout을 분리한다.
SDK command를 만드는 Unit Test:
const sqs = mockClient(SQSClient);
beforeEach(() => sqs.reset());
it("앱 멱등 키와 FIFO deduplication ID를 각각 전송한다", async () => { sqs.on(SendMessageCommand).resolves({ MessageId: "msg-001" });
await publisher.publish({ id: "evt-001", type: "OrderCreated" });
const [call] = sqs.commandCalls(SendMessageCommand); const input = call.args[0].input; const body = JSON.parse(input.MessageBody!); expect(body).toMatchObject({ id: "evt-001", type: "OrderCreated" }); expect(input.MessageDeduplicationId).toBe("evt-001");});같은 문자열을 사용해도 역할은 다르다. body의 id는 consumer idempotency record의 key이고, MessageDeduplicationId는 FIFO queue가 5분 창에서 전송 중복을 억제할 때 쓰는 broker key다. content-based deduplication을 선택한 queue라면 후자를 생략하고 body hash 생성 설정을 검증하되, consumer의 id 처리 검증은 유지한다.
LocalStack Integration에서는 queue를 만들고 실제 SDK로 publish/receive한다. 여기서는 command shape를 다시 검증하기보다 redelivery, DLQ policy, FIFO group ordering처럼 emulator가 제공하는 broker 의미를 assertion으로 둔다.
aws --endpoint-url=http://localhost:4566 sqs get-queue-attributes \ --queue-url http://localhost:4566/000000000000/order-events-test \ --attribute-names RedrivePolicy ApproximateNumberOfMessages메시지가 0이면 subscription·queue URL·publish를, 메시지가 쌓였는데 처리되지 않으면 consumer schema·visibility timeout·idempotency record를 구분한다.
beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(new Date("2026-01-01T00:00:00Z"));});
afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks();});고정 시간은 Unit 경계에 적합하다. 실제 DB timestamp, TTL, scheduler와의 통합에서는 fake timer가 IO 진행까지 멈출 수 있으므로 주의한다.
비동기 함수에는 Promise를 반드시 반환하거나 await한다.
it("주문을 저장한다", async () => { const result = await service.createOrder(input); expect(result.status).toBe("pending");});실행 순서를 무작위화해 숨은 의존을 찾되 실패 seed를 저장한다. fixed sleep을 늘리기 전에 polling 조건과 마지막 관찰값을 남긴다.
비즈니스 흐름 중심 테스트에서는 Guard를 대체할 수 있다.
.overrideGuard(JwtAuthGuard).useValue({ canActivate(context) { const request = context.switchToHttp().getRequest(); request.user = { userId: "test-user-123" }; return true; },})실제 인증 경계는 별도 대표 E2E에서 login API로 token을 발급하고 Bearer header를 보낸다. 다음을 검증한다.
401/403 구분Guard Mock 테스트 통과는 인증 안전성의 증거가 아니다.
같은 앱에서 HTTP status와 body 계약을 확인하는 최소 예시는 다음과 같다.
it("존재하지 않는 주문은 404 계약을 지킨다", async () => { const response = await request(app.getHttpServer()) .get("/orders/missing-order") .set("Authorization", `Bearer ${token}`) .expect(404);
expect(response.body).toMatchObject({ code: "ORDER_NOT_FOUND", });});이 테스트는 controller 메서드 호출이 아니라 실제 HTTP routing, 인증 middleware, error serialization을 함께 검증한다. DB·외부 결제까지 실제로 연결했는지는 별도 fixture 구성에 따라 달라지므로, 파일 이름만 보고 전체 E2E라고 부르지 않는다.
jobs: test: steps: - run: npm ci - run: npm test -- --coverage - run: npm run test:contract - run: npm run test:integration - run: npm run test:e2e모든 command를 한 직렬 job에 넣기보다 의존성 cache와 fixture 비용을 보고 병렬화한다. 실패 artifact에는 report, seed, trace, container log를 남긴다.
기존 threshold 예시는 다음과 같았다.
coverageThreshold: { global: { branches: 60, functions: 70, lines: 70, statements: 70, }, "./src/settlement/settlement-policy.ts": { branches: 100, functions: 100, lines: 100, },}이 값은 품질의 보편 기준이 아니다. 위험한 정책 파일에 더 높은 변경 방지선을 둘 수 있다는 예다. threshold를 올릴 때 의미 없는 getter 테스트가 늘면 mutation score, defect history, uncovered branch를 함께 검토한다.
| 개념 | 핵심 문장 |
|---|---|
| Confidence | 테스트 수가 아니라 특정 위험을 반증하는 반복 가능한 증거에서 나온다. |
| Pyramid/Trophy | 고정 비율이 아니라 속도·충실도·진단 비용을 배분하는 모델이다. |
| Unit/TDD | 작은 행동과 불변식을 빠르게 고정하고 설계 피드백을 짧게 만든다. |
| Integration | DB·broker·serializer처럼 Mock이 보존하지 못한 실제 의미를 검증한다. |
| Test doubles | 빠른 오류 주입 수단이며 실제 계약 검증을 영구히 대체하지 않는다. |
| Determinism | 같은 입력과 초기 상태에서 같은 판정을 내릴 때만 merge 신호가 된다. |
| Contract | 독립 배포되는 consumer와 provider의 wire-format·호환성 경계를 검증한다. |
| E2E | 대표 journey와 배포 조합을 검증하되 모든 분기를 맡기지 않는다. |
| Property | 사람이 고른 예제 밖에서 불변식의 작은 반례를 찾는다. |
| Distributed failure | timeout·중복·순서 역전·보상 실패를 정상 입력으로 넣고 최종 불변식을 본다. |
| Migration | 최종 schema보다 구·신 코드와 데이터가 공존하는 시간을 검증한다. |
| Chaos | 제한된 영향 범위에서 회복성 가설과 사용자 SLI를 검증한다. |
| Flakiness | 재실행 비용과 실제 회귀 오판을 만드는 신뢰 부채다. |
| Selection | 실패를 잡을 수 있는 가장 싼 충분한 경계에서 시작해 남은 조합 위험만 올린다. |
좋은 테스팅 전략은 “테스트가 몇 개인가”가 아니라 “어떤 실패를 어디에서, 얼마나 빨리, 얼마나 믿을 수 있게 발견하며 아직 무엇을 모르는가”를 설명한다.