마이크로서비스 아키텍처에서 가장 큰 도전 과제 중 하나는 서비스 간 통합 테스트의 복잡성입니다.
전통적인 end-to-end 테스트는 모든 서비스를 실행해야 하므로 시간이 오래 걸리고 유지보수 비용이 높습니다.
Contract Testing은 이러한 문제를 해결하는 혁신적인 접근 방식으로, API 계약을 기반으로 각 서비스를 독립적으로 테스트할 수 있게 해줍니다.
Contract Testing이란 무엇인가?
Contract Testing은 마이크로서비스 간의 API 계약(Contract)을 정의하고, 이 계약을 기반으로 각 서비스가 올바르게 작동하는지 검증하는 테스트 방법론입니다.
Consumer(API 호출자)와 Provider(API 제공자) 사이의 명시적인 계약을 통해 서비스 간 호환성을 보장합니다.
Contract Testing의 핵심은 실제 서비스를 실행하지 않고도 서비스 간 통합을 검증할 수 있다는 점입니다.
이를 통해 테스트 실행 시간을 대폭 단축하고, 각 팀이 독립적으로 개발할 수 있는 환경을 제공합니다.
Spring Boot에서 Contract Testing 도구 선택하기
Spring Boot 환경에서 Contract Testing을 구현할 때 가장 널리 사용되는 도구들을 살펴보겠습니다.
Spring Cloud Contract는 Spring 생태계에 최적화된 Contract Testing 프레임워크입니다.
Groovy DSL이나 YAML을 사용해 계약을 정의하며, Maven이나 Gradle 플러그인을 통해 쉽게 통합할 수 있습니다.
Pact는 언어에 독립적인 Contract Testing 도구로, 다양한 프로그래밍 언어를 지원합니다.
Consumer-driven 방식으로 작동하며, Pact Broker를 통해 계약을 중앙에서 관리할 수 있습니다.
WireMock은 HTTP 서비스를 모킹하는 도구로, Contract Testing과 함께 사용하여 더욱 견고한 테스트 환경을 구축할 수 있습니다.
Spring Cloud Contract 실무 구현 가이드
Spring Cloud Contract를 사용한 실제 구현 예제를 통해 Contract Testing의 구체적인 적용 방법을 살펴보겠습니다.
Producer(API 제공자) 설정
먼저 상품 정보를 제공하는 Producer 서비스를 구현해보겠습니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock'
}
contracts {
packageWithBaseClasses = 'com.example.product'
baseClassForTests = 'com.example.product.BaseContractTest'
}
Product 컨트롤러를 구현합니다:
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
Product product = productService.findById(id);
if (product == null) {
return ResponseEntity.notFound().build();
}
ProductResponse response = new ProductResponse(
product.getId(),
product.getName(),
product.getPrice(),
product.getCategory()
);
return ResponseEntity.ok(response);
}
}
Contract 정의 파일을 작성합니다 (src/test/resources/contracts/product/get_product_success.groovy
):
package contracts.product
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "성공적인 상품 조회 계약"
request {
method GET()
url "/api/products/1"
headers {
contentType(applicationJson())
}
}
response {
status OK()
headers {
contentType(applicationJson())
}
body([
id: 1,
name: "스마트폰",
price: 799000,
category: "전자제품"
])
}
}
Base Contract Test 클래스를 구성합니다:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMessageVerifier
public abstract class BaseContractTest {
@Autowired
private ProductController productController;
@MockBean
private ProductService productService;
@BeforeEach
void setup() {
Product mockProduct = new Product(1L, "스마트폰", 799000, "전자제품");
when(productService.findById(1L)).thenReturn(mockProduct);
RestAssuredMockMvc.standaloneSetup(productController);
}
}
Consumer(API 호출자) 구현
주문 서비스에서 상품 정보를 조회하는 Consumer를 구현해보겠습니다.
@Service
public class OrderService {
private final ProductClient productClient;
public OrderService(ProductClient productClient) {
this.productClient = productClient;
}
public Order createOrder(Long productId, int quantity) {
ProductResponse product = productClient.getProduct(productId);
if (product == null) {
throw new ProductNotFoundException("상품을 찾을 수 없습니다: " + productId);
}
return new Order(
generateOrderId(),
productId,
product.getName(),
product.getPrice(),
quantity,
product.getPrice() * quantity
);
}
}
Product Client 구현:
@Component
public class ProductClient {
private final RestTemplate restTemplate;
private final String productServiceUrl;
public ProductClient(RestTemplate restTemplate, @Value("${product.service.url}") String productServiceUrl) {
this.restTemplate = restTemplate;
this.productServiceUrl = productServiceUrl;
}
public ProductResponse getProduct(Long productId) {
try {
String url = productServiceUrl + "/api/products/" + productId;
return restTemplate.getForObject(url, ProductResponse.class);
} catch (Exception e) {
return null;
}
}
}
Consumer 테스트에서 Contract Stub 사용:
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.example:product-service:+:stubs:8100",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class OrderServiceContractTest {
@Autowired
private OrderService orderService;
@TestConfiguration
static class TestConfig {
@Bean
@Primary
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@Test
void 상품_정보로_주문_생성_테스트() {
// given
Long productId = 1L;
int quantity = 2;
// when
Order order = orderService.createOrder(productId, quantity);
// then
assertThat(order).isNotNull();
assertThat(order.getProductId()).isEqualTo(productId);
assertThat(order.getProductName()).isEqualTo("스마트폰");
assertThat(order.getUnitPrice()).isEqualTo(799000);
assertThat(order.getQuantity()).isEqualTo(quantity);
assertThat(order.getTotalPrice()).isEqualTo(1598000);
}
}
Pact를 활용한 Consumer-Driven Contract Testing
Pact는 Consumer가 주도하는 Contract Testing 방식으로, 실제 사용 사례를 기반으로 계약을 정의합니다.
Pact Consumer 테스트 구현
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "product-service")
class ProductClientPactTest {
private ProductClient productClient;
@Pact(consumer = "order-service")
public RequestResponsePact getProductPact(PactDslWithProvider builder) {
return builder
.given("상품 ID 1이 존재함")
.uponReceiving("상품 정보 조회 요청")
.path("/api/products/1")
.method("GET")
.headers("Content-Type", "application/json")
.willRespondWith()
.status(200)
.headers("Content-Type", "application/json")
.body(LambdaDsl.newJsonBody(body -> body
.numberType("id", 1)
.stringType("name", "스마트폰")
.numberType("price", 799000)
.stringType("category", "전자제품")
).build())
.toPact();
}
@Test
@PactTestFor(pactMethod = "getProductPact")
void 상품_정보_조회_계약_테스트(MockServer mockServer) {
// given
RestTemplate restTemplate = new RestTemplate();
productClient = new ProductClient(restTemplate, mockServer.getUrl());
// when
ProductResponse product = productClient.getProduct(1L);
// then
assertThat(product).isNotNull();
assertThat(product.getId()).isEqualTo(1L);
assertThat(product.getName()).isEqualTo("스마트폰");
assertThat(product.getPrice()).isEqualTo(799000);
assertThat(product.getCategory()).isEqualTo("전자제품");
}
}
Pact Provider 검증
Provider 측에서는 Consumer가 생성한 Pact 파일을 기반으로 검증을 수행합니다:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("product-service")
@PactBroker(host = "localhost", port = "9292")
class ProductServicePactTest {
@LocalServerPort
private int port;
@MockBean
private ProductService productService;
@BeforeEach
void setup(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@State("상품 ID 1이 존재함")
void productExists() {
Product product = new Product(1L, "스마트폰", 799000, "전자제품");
when(productService.findById(1L)).thenReturn(product);
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}
}
Contract Testing 모범 사례와 실무 팁
Contract Testing을 성공적으로 도입하기 위한 핵심 원칙들을 살펴보겠습니다.
계약 설계 원칙
의미 있는 계약 작성하기
계약은 실제 비즈니스 시나리오를 반영해야 합니다.
단순한 기술적 검증이 아닌, 실제 사용자 요구사항을 바탕으로 계약을 정의해야 합니다.
최소한의 필요 데이터만 포함하기
계약에는 Consumer가 실제로 사용하는 필드만 포함해야 합니다.
불필요한 필드를 포함하면 Provider의 변경이 불필요하게 Consumer에게 영향을 줄 수 있습니다.
// Good - 실제 사용하는 필드만 포함
response {
body([
id: 1,
name: "상품명",
price: 10000
// category, description 등 사용하지 않는 필드는 제외
])
}
버전 관리 전략
Semantic Versioning 적용
Contract 변경 시 Semantic Versioning 규칙을 적용하여 버전을 관리합니다.
Breaking Change가 있을 때는 Major 버전을 올리고, 하위 호환성을 유지하는 변경은 Minor 버전을 올립니다.
점진적 배포 전략
새로운 Contract 버전을 배포할 때는 Consumer와 Provider가 모두 새 버전을 지원할 수 있도록 점진적으로 배포합니다.
# application.yml - 다중 버전 지원 설정
spring:
profiles:
active: v1,v2
api:
versions:
supported: ["v1", "v2"]
default: "v2"
테스트 데이터 관리
일관된 테스트 데이터 사용
모든 Contract에서 일관된 테스트 데이터를 사용하여 혼란을 방지합니다.
테스트 데이터는 별도 파일로 관리하여 재사용성을 높입니다.
public class TestDataConstants {
public static final Long VALID_PRODUCT_ID = 1L;
public static final String PRODUCT_NAME = "테스트 상품";
public static final Integer PRODUCT_PRICE = 10000;
public static final String PRODUCT_CATEGORY = "테스트 카테고리";
}
CI/CD 파이프라인 통합
Contract Testing을 CI/CD 파이프라인에 통합하여 자동화된 검증을 구현합니다.
# .github/workflows/contract-test.yml
name: Contract Testing
on:
pull_request:
branches: [main]
jobs:
contract-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
- name: Run Contract Tests
run: ./gradlew contractTest
- name: Publish Pact Files
run: ./gradlew pactPublish
- name: Verify Provider Contracts
run: ./gradlew pactVerify
마이크로서비스 환경에서의 Contract Testing 확장
대규모 마이크로서비스 환경에서 Contract Testing을 효과적으로 확장하는 방법을 알아보겠습니다.
Contract Registry 구축
중앙화된 Contract Registry를 구축하여 모든 서비스의 계약을 관리합니다.
@Configuration
public class ContractRegistryConfig {
@Bean
public PactBrokerClient pactBrokerClient() {
return new PactBrokerClient("http://pact-broker:9292");
}
@Bean
public ContractRegistry contractRegistry() {
return new ContractRegistry(pactBrokerClient());
}
}
의존성 그래프 관리
서비스 간 의존성을 시각화하고 관리하여 변경 영향도를 파악합니다.
@Service
public class DependencyAnalyzer {
public List<ServiceDependency> analyzeDependencies(String serviceName) {
List<Contract> contracts = contractRegistry.getContractsForService(serviceName);
return contracts.stream()
.map(this::extractDependency)
.collect(Collectors.toList());
}
private ServiceDependency extractDependency(Contract contract) {
return new ServiceDependency(
contract.getConsumer(),
contract.getProvider(),
contract.getVersion(),
contract.getInteractions().size()
);
}
}
Contract Testing 성능 최적화 방법
Contract Testing의 성능을 최적화하여 빠른 피드백 루프를 구현하는 방법들을 살펴보겠습니다.
병렬 테스트 실행
test {
maxParallelForks = Runtime.runtime.availableProcessors()
forkEvery = 100
systemProperty 'junit.jupiter.execution.parallel.enabled', 'true'
systemProperty 'junit.jupiter.execution.parallel.mode.default', 'concurrent'
}
캐시 활용
테스트 결과와 Stub을 캐시하여 반복 실행 시간을 단축합니다.
@Configuration
@EnableCaching
public class ContractTestConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("contracts", "stubs");
}
@Cacheable("stubs")
public StubMapping getStubMapping(String contractId) {
return stubGenerator.generateStub(contractId);
}
}
스마트 테스트 실행
변경된 계약만 선별적으로 테스트하여 실행 시간을 최적화합니다.
@Component
public class SmartTestRunner {
public List<Contract> getChangedContracts(String baseCommit, String targetCommit) {
Set<String> changedFiles = gitService.getChangedFiles(baseCommit, targetCommit);
return contractRegistry.getAllContracts().stream()
.filter(contract -> isContractChanged(contract, changedFiles))
.collect(Collectors.toList());
}
private boolean isContractChanged(Contract contract, Set<String> changedFiles) {
return changedFiles.contains(contract.getFilePath()) ||
changedFiles.stream().anyMatch(file ->
file.contains(contract.getConsumer()) ||
file.contains(contract.getProvider())
);
}
}
실무에서 마주치는 Contract Testing 문제 해결
Contract Testing 도입 과정에서 자주 발생하는 문제들과 해결 방법을 정리했습니다.
스키마 변경 관리
API 스키마가 변경될 때 Contract를 안전하게 업데이트하는 방법입니다.
@Test
void 하위_호환성_검증_테스트() {
// 기존 계약
Contract oldContract = loadContract("v1/product-contract.json");
// 새로운 계약
Contract newContract = loadContract("v2/product-contract.json");
// 하위 호환성 검증
CompatibilityResult result = compatibilityChecker.check(oldContract, newContract);
assertThat(result.isBackwardCompatible()).isTrue();
assertThat(result.getBreakingChanges()).isEmpty();
}
환경별 설정 관리
개발, 테스트, 운영 환경별로 다른 Contract 설정을 관리합니다.
# application-contract-dev.yml
contract:
testing:
mode: stub
stub-url: http://localhost:8080
# application-contract-prod.yml
contract:
testing:
mode: real
real-service-urls:
product-service: http://product-service:8080
user-service: http://user-service:8080
테스트 데이터 동기화
여러 팀 간 테스트 데이터를 동기화하는 전략입니다.
@Component
public class TestDataSynchronizer {
@EventListener
public void handleContractUpdated(ContractUpdatedEvent event) {
Contract contract = event.getContract();
// 테스트 데이터 업데이트
updateTestData(contract);
// 관련 팀에 알림
notificationService.notifyTeams(contract.getAffectedTeams());
}
private void updateTestData(Contract contract) {
TestDataSet testData = extractTestData(contract);
testDataRepository.save(testData);
}
}
Contract Testing 도입 로드맵
조직에서 Contract Testing을 단계적으로 도입하는 실용적인 로드맵을 제시합니다.
1단계: 파일럿 프로젝트 (1-2주)
가장 간단한 서비스 쌍을 선택하여 Contract Testing의 기본 개념을 학습합니다.
// 간단한 헬스체크 API로 시작
@GetMapping("/health")
public ResponseEntity<Map<String, String>> health() {
return ResponseEntity.ok(Map.of("status", "UP"));
}
2단계: 핵심 서비스 확장 (2-4주)
비즈니스 핵심 서비스들에 Contract Testing을 적용합니다.
3단계: 전체 시스템 적용 (1-2개월)
모든 마이크로서비스에 Contract Testing을 도입하고 자동화를 구축합니다.
4단계: 최적화 및 고도화 (지속적)
성능 최적화, 고급 기능 활용, 팀 간 협업 프로세스 개선을 진행합니다.
Contract Testing은 마이크로서비스 아키텍처에서 서비스 간 통합의 안정성을 보장하면서도 개발 효율성을 크게 향상시키는 강력한 도구입니다.
Spring Boot 환경에서 Spring Cloud Contract나 Pact 같은 도구들을 활용하면 비교적 쉽게 도입할 수 있으며, 점진적인 적용을 통해 조직 전체의 테스트 문화를 개선할 수 있습니다.
성공적인 Contract Testing 도입을 위해서는 기술적 구현뿐만 아니라 팀 간 협업 프로세스와 CI/CD 파이프라인 통합까지 종합적으로 고려해야 합니다.
적절한 도구 선택, 모범 사례 적용, 그리고 지속적인 최적화를 통해 안정적이고 효율적인 마이크로서비스 개발 환경을 구축할 수 있을 것입니다.
'Spring & Spring Boot 실무 가이드' 카테고리의 다른 글
Mutation Testing으로 테스트 커버리지 향상시키기: Spring Boot 프로젝트의 테스트 품질 혁신 (0) | 2025.05.25 |
---|---|
Spring Boot에서 P6spy로 SQL 쿼리 모니터링하기: 완벽 가이드 (JPA와 MySQL 연동) (0) | 2025.05.21 |
Spring에서 Bean Scope의 차이 – Singleton, Prototype, Request (0) | 2025.05.18 |
REST API 예외 처리 패턴 – 글로벌 핸들러 vs 컨트롤러 별 처리 (0) | 2025.05.18 |
코드 한 줄 안 바꾸고 Spring Boot 성능 3배 올리기: JVM 튜닝 실전 가이드 (1) | 2025.05.17 |