테스트 전략: 계약 테스트에 대하여
🔸테스트 전략
하지만 실질적으로는 테스트를 작성하는데 있어 시간과 리소스의 한계가 있으므로 별다른 소독이 없는 테스트를 작성하느라 시간을 허비하고 싶지는 않다. … 테스트의 범위와 다양한 종류의 테스트 사이의 균형을 영리하게 결정해야 한다. … 상황에 맞춰 올바른 테스트를 진행할 수 있도록 돕기 위해 테스트 사분면과 테스트 피라미드를 소개하고자 한다.
- 테스트 사분면
- “무엇을/왜 테스트하나” (목적의 분류) - 테스트를 두 축으로 4분면에 나눈다.
- 가로축: 비즈니스 대면(business-facing) ↔ 기술 대면(technology-facing)
- 세로축: 팀 지원(supporting the team) ↔ 제품 비판(critiquing the product)
- “무엇을/왜 테스트하나” (목적의 분류) - 테스트를 두 축으로 4분면에 나눈다.
-
테스트 피라미드
-
“얼마나 많이” (계층별 분량) - 테스트를 계층으로 쌓되, 아래로 갈수록 많이, 위로 갈수록 적게 두라는 원칙이다.
-
하단 (단위 테스트): 가장 많이. 빠르고(밀리초), 격리돼 있고, 안정적. 로직 검증의 주력.
-
중간 (통합/컴포넌트 테스트): 적당히. 실제 의존성(DB 등)과의 연동 검증. 느리지만 신뢰도 높음.
-
상단 (E2E 테스트): 최소한. 전체 시스템 관통. 신뢰도는 최고지만 느리고 깨지기 쉽고 유지비가 큼.
-
-
“테스트는 많이 짤수록 좋은건 아니다. 테스트마다 작성·유지·실행 비용이 들고, 느리고 깨지기 쉬운 테스트를 잘못된 곳에 많이 두면 오히려 개발을 방해한다. 그래서 ‘어디에 어떤 테스트를 얼마나 둘 것인가’를 먼저 정하는 게 전략이고, 책은 이를 위한 두 도구 — 테스트 사분면과 테스트 피라미드 — 를 제시하고 이 둘은 경쟁 관계가 아니라 서로 다른 질문에 답하는 상호 보완적 틀이다.”
🔸계약 테스트
MSA에서 가장 무서운 건 “내 서비스는 안 건드렸는데 다른 서비스 때문에 깨지는” 상황이다.
contents-service가 응답 필드명을 title → contentTitle 로 바꿈
↓
그걸 쓰던 BFF가 title을 못 찾아 런타임에 깨짐
↓
그런데 이걸 배포하고 나서야 알게 됨
이걸 잡으려는 기존 방법들엔 한계가 있다:
- 단위 테스트: 각 서비스는 자기 안만 봄. 서비스 사이의 불일치는 못 잡음.
- E2E 테스트: 잡긴 하는데 느리고, 모든 서비스를 다 띄워야 하고, 깨지기 쉬움. 조합이 폭발.
계약 테스트는 이 사이를 메웁니다. E2E처럼 전체를 안 띄우면서도, 서비스 간 인터페이스 호환성을 빠르게 검증한다.
소비자(Consumer)와 제공자(Provider)가 주고받는 API의 형식에 대한 합의를 코드로 명시한 것이다.
- 제공자(Provider): API를 제공하는 쪽 (예: contents-service)
- 소비자(Consumer): 그 API를 호출하는 쪽 (예: BFF)
- 계약(Contract): “소비자는 이런 요청을 보내고, 제공자는 이런 형식으로 응답한다”는 약속
계약 테스트는 이 약속을 양쪽이 각각 지키는지 독립적으로 검증한다. 양쪽을 동시에 띄워서 실제 통신시키는 게 아니라, 계약 문서를 매개로 따로따로 확인하는 게 핵심이다.
- 계약 테스트 프레임워크
- Pact
- Spring Cloud Contracts
소비자 주도 계약 (Consumer-Driven Contract, CDC)
책이 강조하는 방식으로 계약을 소비자가 먼저 정의한다.
왜 소비자 주도를 강조하는가:
- 제공자가 “내가 주는 응답 전부”를 계약으로 삼으면, 소비자가 실제로 안 쓰는 필드까지 계약에 묶입니다. 그 필드를 바꾸면 아무도 안 쓰는데도 계약 위반이 됨.
-
소비자가 “내가 실제로 쓰는 것만” 계약으로 선언하면, 제공자는 그것만 지키면 나머지는 자유롭게 바꿀 수 있다.
Client(소비자): “나는 contents의 응답에서 code, title, label만 쓴다” ↓ (이게 계약) contents(제공자): “그 세 필드는 항상 이 형식으로 준다” 를 검증 → contents가 다른 필드(내부용)를 바꿔도 BFF 계약은 안 깨짐
1단계: 소비자 측 — 계약 생성
소비자가 “이런 응답을 기대한다”는 테스트를 짜면, 그게 계약 파일(pact) 로 산출된다.
// (소비자) 측 테스트
@Pact(consumer = "client", provider = "contents-service")
fun getContentContract(builder: PactDslWithProvider): RequestResponsePact =
builder
.given("content P001 exists") // 제공자가 갖춰야 할 상태
.uponReceiving("get content P001")
.path("/v1/contents/P001")
.method("GET")
.willRespondWith()
.status(200)
.body(newJsonBody { o ->
o.stringType("code", "P001")
o.stringType("title", "도깨비")
o.`object`("label") { it.booleanType("isFree", true) }
}.build())
.toPact()
// 이 테스트를 실행하면 → client-contents.json (계약 파일) 생성
이때 소비자는 제공자를 모킹해서 테스트한다. 계약대로 응답하는 가짜 제공자로 BFF 로직을 검증하고, 동시에 계약 파일을 뽑아낸다.
2단계: 제공자 측 — 계약 검증
생성된 계약 파일을 제공자가 가져가서, 자기 실제 응답이 그 계약을 만족하는지 검증한다.
// contents-service(제공자) 측 테스트
@Provider("contents-service")
@PactFolder("pacts") // 소비자가 만든 계약 파일들
class ContentsProviderTest {
@State("content P001 exists") // 계약의 given 상태를 셋업
fun setupContent() {
repo.save(Content("P001", "도깨비", ...))
}
@TestTemplate
fun verifyContract(context: PactVerificationContext) {
context.verifyInteraction() // 실제 응답이 계약과 맞는지 검증
}
}
제공자가 필드명을 바꾸거나 형식을 어기면 이 테스트가 실패 → 배포 전에 CI에서 잡힘.
API 계약 저장소와 발행
- git
- Pact Broker
소비자가 만든 계약을 제공자가 가져가려면 중간 저장소가 필요하다. Pact Broker(또는 PactFlow)가 계약 파일을 저장·버전관리·공유한다.
- 소비자 CI: 계약 생성 → Broker에 발행(publish)
- 제공자 CI: Broker에서 계약 가져와(fetch) 검증
- Broker: “이 버전 조합이 서로 호환되는가”를 매트릭스로 관리
“can-i-deploy: Broker의 킬러 기능. ‘지금 이 제공자 버전을 배포하면 기존 소비자들이 다 안전한가?’를 배포 직전에 물어볼 수 있다. 호환 안 되면 배포 차단.”
[소비자 CI 파이프라인] [제공자 CI 파이프라인]
(BFF 저장소) (contents 저장소)
│ │
소비자 테스트 실행 제공자 테스트 실행
│ │
가짜 제공자(mock)로 Broker에서 계약 받아
BFF 로직 검증 실제 contents 응답과 대조
│ │
계약 파일 생성 │
│ │
└──── Broker에 발행 ──────────▶ 구독/fetch
🔸API 컴포넌트 테스트
컴포넌트 테스트는 여러 단위가 협업해 완성하는 동작을 테스트하는 것으로 외부 의존성을 호출해서는 안된다.
테스트 사분면의 Q1, Q2에 속한다.
🔸API 통합 테스트
서비스 전체를 띄우되 외부는 stub으로, DB는 Testcontainers 테스트 수행
-
stub 서버 구성의 장점
-
외부 결제사 같은 서드파티 API는 계약을 걸수조차 없기 때문에 stub을 구성해야한다.
-
에러/엣지 시나리오 재현
-
🔸종단 간 테스트
실제 사용자의 여정 전체를, 모든 구성요소가 실제로 맞물린 상태에서 검증하는 테스트이다.
-
서비스 간 실제 연동 문제: 계약 테스트는 형식만, 컴포넌트는 stub만 봤다. 실제로 다 연결했을 때만 드러나는 문제 — 예: 인증 토큰이 서비스를 거치며 제대로 전파 안 됨, 타임아웃이 사슬 전체에서 누적됨.
-
환경/설정 문제: 실제 배포 환경의 네트워크, 로드밸런서(ALB), 서비스 디스커버리, 설정값이 맞물려야 나타나는 문제.
-
전체 흐름의 데이터 정합성: A 서비스가 쓴 데이터를 B가 읽어 C로 넘기는 전 과정이 맞는지.
“아래 층에서 잡을 수 있는 건 절대 E2E로 올리지 마라. 로직 버그는 단위로, DB 연동은 통합으로, 경계 호환은 계약으로 잡고 — E2E는 ‘그 모든 게 다 맞물렸을 때만 드러나는 것’에만 쓴다. E2E가 많다는 건 아래 층이 부실하다는 신호이다.”
🔸정리
계약 테스트가 재밌었다.
- 백엔드 서비스 간
- 클라이언트 ↔︎ 서버
- Kafka 프로듀서 ↔︎ 컨슈머