비동기 메시징을 위한 계약 테스트는 조직이 Kafka를 사용하여 마이크로서비스의 분리를 위해 이벤트 기반 아키텍처를 채택하게 되면서 등장했습니다. 초기 계약 테스트 구현은 주로 REST API에 초점을 맞추어, 생산자가 이벤트 페이로드를 소비자에게 알리지 않고 수정할 때 발생하는 무음 브레이킹 체인에 메시징 통합이 취약하게 되었던 사례입니다. 다중 버전 소비자 지원의 특정 도전 과제는 팀이 Kafka 주제가 종종 다른 배포 주기 및 업그레이드 사이클을 가진 여러 소비자 애플리케이션을 제공한다는 것을 깨달았을 때 발생했습니다. 이 질문은 결제 서비스에서 단일 이벤트 스키마 변경이 분석, 알림 및 감사 서비스에서 동시에 실패를 일으킬 수 있는 실제 사례를 반영합니다. 이는 분산 스트리밍 플랫폼에서 스키마 레지스트리 검증과 행동 계약 보증 간의 중요한 격차를 다룹니다.
근본적인 어려움은 Kafka 생산자가 모든 하위 소비자의 동시 배포를 강요하지 않고 이벤트 스키마를 발전시킬 수 있도록 하는 것입니다. 이는 마이크로서비스 독립성 원칙을 위반합니다. Confluent와 같은 전통적인 스키마 레지스트리는 직렬화 수준에서 역호환성을 검증하지만, 선택적 필드를 필수로 변경하거나 날짜 형식을 수정하는 것과 같이 소비자 비즈니스 로직을 깨뜨리는 의미적 변경을 감지할 수 없습니다. 여러 소비자 버전이 프로덕션에서 공존할 때, 생산자는 지원되는 가장 오래된 소비자와의 호환성을 유지해야 하며, 새로운 소비자는 추가 필드를 필요로 하여 수동 조정으로는 이러한 버전 관리가 확장 가능하지 않습니다. 이는 프로덕션 이벤트의 역직렬화 실패나 레거시 소비자에서 잘못된 처리를 초래하여 메시지 처리 지연 및 데이터 손실을 초래하는 "스키마 드리프트"로 이어집니다. 이 문제는 Kafka의 게시-구독 모델로 인해 하나의 브레이킹 체인이 모든 구독자에게 동시에 영향을 미치기 때문에 더욱 심각합니다. 이는 REST와 달리 라우팅이 엔드포인트를 독립적으로 버전화할 수 있는 옵션이 없습니다.
해결책은 Pact의 메시지 pact 형식과 Confluent 스키마 레지스트리 통합을 결합한 소비자 주도 계약 테스트를 구현하는 것입니다. 생산자는 각 소비자 버전의 예상 이벤트 페이로드를 정의하는 메시지 pact를 생성하며, 이는 실행 중인 Kafka 브로커 없이도 실제 직렬화 논리와 검증됩니다. Pact 브로커는 소비자 버전 태그를 사용하여 계약 버전을 관리하며, 새로운 생산자 코드 변경이 레거시 소비자와 현재 소비자 모두를 위한 계약을 충족하는지 확인하기 위한 "can-i-deploy" 검사를 시행합니다. 스키마 발전을 위해 워크플로우는 생산자가 이전 필드를 유지하면서 새로운 필드를 먼저 추가하도록 강제하는 "expand-contract" 패턴을 시행하며, 모든 소비자가 업그레이드하고 계약을 업데이트한 후에야 더 이상 사용되지 않는 필드를 제거합니다. 이는 Pact 검증이 태그가 있는 소비자 버전과 충돌할 때 빌드가 실패되도록 하는 CI 게이트를 통해 자동화되어 간단한 스키마 구조 이상으로 행동적 호환성을 보장합니다.
@PactTestFor(providerName = "payment-service", providerType = ProviderType.ASYNCH) public class PaymentEventContractTest { @Pact(consumer = "analytics-service", consumerVersion = "v2.1.0") public MessagePact paymentProcessedPactV2(MessagePactBuilder builder) { return builder .expectsToReceive("analytics를 위한 결제 처리 이벤트") .withContent(new PactDslJsonBody() .uuid("paymentId") .decimalType("amount") .stringType("currency", "USD") .stringType("status") // v2에서 요구하는 새로운 필드 .date("timestamp", "yyyy-MM-dd'T'HH:mm:ss")) .toPact(); } @Pact(consumer = "notification-service", consumerVersion = "v1.0.0") public MessagePact paymentProcessedPactV1(MessagePactBuilder builder) { return builder .expectsToReceive("알림을 위한 결제 처리 이벤트") .withContent(new PactDslJsonBody() .uuid("paymentId") .decimalType("amount") .stringType("currency", "USD")) .toPact(); } @Test @PactTestFor(pactMethod = "paymentProcessedPactV2") public void verifyV2Contract(List<Interaction> interactions) { byte[] messageBytes = interactions.get(0).getContents().getValue(); PaymentEvent event = deserialize(messageBytes); assertThat(event.getStatus()).isNotNull(); analyticsProcessor.process(event); } }
이 코드는 서로 다른 스키마 버전에 대해 여러 소비자 계약을 테스트하는 것을 보여주며, 생산자가 레거시 및 현재의 요구 사항을 동시에 충족해야 함을 보장합니다.
한 전자상거래 플랫폼은 결제 처리 팀이 Kafka 결제 이벤트에 "discountApplied" 부울 필드를 추가하고 이를 필수로 만들었을 때 심각한 중단을 겪었습니다. 분석 팀은 이 필드를 처리하도록 소비자를 업데이트했지만 레거시 알림 서비스는 알 수 없는 필드를 거부하는 엄격한 역직렬화로 인해 다운되었습니다. 이로 인해 주문 이행 파이프라인 전반에 걸쳐 연쇄적인 실패가 발생했습니다. 이 중단은 이벤트 버스를 통해 오류가 전파되어 세 개의 의존하는 서비스 간에 메시지 처리 지연과 경고 폭풍을 초래하여 두 시간 동안 지속되었습니다. 팀은 처음에 모든 소비자가 유연한 역직렬화 스키마를 사용해야 한다고 고려했지만, 이는 이후의 브레이킹 체인을 가릴 수 있고 프로덕션에서 런타임 오류가 발생하기까지 통합 불일치의 감지를 지연시킬 수 있다는 것을 깨달았습니다.
재발 방지를 위해 세 가지 가능한 솔루션이 평가되었습니다. 첫 번째 접근 방식은 모든 서비스 버전이 동시에 배포된 전용 통합 테스트 환경을 만드는 것이었지만, 이는 비싼 인프라를 유지해야 했고 테스트 실행 시간이 40분에 달해 지속적인 배포 파이프라인을 상당히 느리게 만들었습니다. 두 번째 옵션은 Confluent 스키마 레지스트리의 역호환성 검사만 사용하는 것이었지만, 이는 데이터가 각 소비자에 대해 특정 비즈니스 계약을 충족하는지 또는 필수 필드가 있는지를 확인하는 것이 아니라 Avro 레벨에서만 스키마의 역호환성이 있음을 검증했습니다. 세 번째 솔루션은 Pact 계약 테스트와 기존 스키마 레지스트리를 결합하여 각 소비자가 요구하는 필드와 예상 데이터 형식을 정확하게 지정하는 독립 계약을 게시할 수 있도록 했습니다. 전체 스키마 구조와 무관하게 말입니다.
조직은 소비자별 행동 유효성을 제공하기 때문에 세 번째 솔루션을 선택했습니다. 그들은 Pact 브로커를 구성하여 의미론적 태그를 사용하여 소비자 버전을 추적하게 하였으며, 결제 서비스가 모든 배포가 진행될 수 있도록 알림 서비스 v1 및 분석 서비스 v2 계약에 대해 검증해야 했습니다. 결제 팀이 새로운 필수를 추가하려고 시도했을 때, CI 파이프라인은 v1 계약 검증 실패로 인해 즉시 실패하여 그들이 기존 필드를 처음에 선택적으로 만들어 두 번째 계약 패턴을 이행하도록 강제했습니다. 이후 분기 동안 통합 관련 프로덕션 사고가 85% 감소하였고, 팀은 모든 하위 팀과의 조율 없이 하루에 세 번 생산자 변경을 안전하게 배포할 수 있게 되어 배포 속도와 시스템 안정성을 크게 개선했습니다.
왜 스키마 레지스트리 검증이 Kafka 생산자와 소비자 간의 이벤트 호환성을 보장하는 데 불충분하며, 어떤 특정 실패를 놓치나요?
후보자들은 종종 Confluent 스키마 레지스트리의 역호환성 모드가 프로덕션 환경에서 브레이킹 체인으로부터 충분한 보호를 제공한다고 가정합니다. 그러나 스키마 레지스트리는 데이터 구조가 Avro 또는 JSON 스키마 정의에 맞는지 검증할 뿐, 값이 소비자 기대치를 충족하는지 또는 의미론적 의미가 버전 간 일관되게 유지되는지를 검증하지 않습니다. 예를 들어, 스키마는 타임스탬프 필드에 대한 문자열을 허용할 수 있지만 소비자는 ISO8601 형식을 기대하는 반면 생산자가 갑자기 Unix epoch으로 전환한다고 합니다. 레지스트리는 둘 다 유효한 문자열로 받아들이지만 소비자는 런타임에 파싱 예외로 실패합니다. 계약 테스트는 실제 소비자 코드를 실행하여 실제 생산자 출력과 상호작용하며, 구조적 검증을 넘어 행동적 호환성을 보장하여 이러한 의미적 및 값 수준 비호환성을 포착합니다.
여러 생산자가 동일한 Kafka 주제에 게시하고 소비자들이 모든 출처로부터 일관된 스키마를 기대할 때 계약 테스트에서 "다이아몬드 문제"를 어떻게 처리합니까?
이 질문은 한 주제가 단일 출처가 아니라 여러 생산자 서비스에서 발생하는 이벤트를 집계하는 복잡한 이벤트 소싱 시나리오에 대한 이해를 테스트합니다. 후보자들은 종종 Pact가 일반적으로 생산자-소비자 관계를 1:1로 모델링하다고 간과하지만, Kafka 주제는 종종 서로 다른 코드베이스를 가진 여러 출처가 있습니다. 해결책은 주제를 제공자 인터페이스로 취급하고 모든 게시 서비스에서 계약을 집계하는 "메타 제공자"를 만드는 것입니다. 각 생산자는 그들의 이벤트가 주제를 위한 결합 계약을 충족하는지 검증해야 하며, 이를 통해 소비자는 어떤 생산자 인스턴스가 이벤트를 게시하든 일관된 메시지 구조를 받게 됩니다. 이는 Pact 브로커의 기능을 사용하여 단일 소비자 계약에 대한 여러 제공자를 관리하거나 하나의 팀이 주제의 게이트키퍼 역할을 하여 모든 생산자 간의 변경을 조정하는 단일 스키마 소유 모델을 표준화하는 것이 필요합니다.
Kafka 스키마 발전의 맥락에서 "expand-contract" 패턴이란 무엇이며, 계약 테스트가 CI/CD 동안 이 워크플로를 어떻게 시행합니까?
많은 후보자들은 여러 활성 소비자 버전이 있는 메시징 시스템에서 제로 다운타임 스키마 변경의 실제 메커니즘을 설명하는 데 어려움을 겪습니다. expand-contract 패턴은 생산자가 먼저 이전 필드를 유지하면서 새로운 필드를 추가하는 변경 사항을 배포하도록 요구하며(확장 단계), 모든 소비자가 새 필드를 사용하도록 마이그레이션한 후에야 더 이상 사용되지 않는 필드를 제거합니다(계약 단계). 계약 테스트는 Pact 브로커에 각 소비자에 대한 별도의 계약 버전을 유지함으로써 이를 시행합니다. 생산자의 CI 파이프라인은 배포 승인이 이루어지기 전에 모든 활성 소비자 버전에 대한 호환성을 검증해야 합니다. 만약 생산자가 소비자 v1이 여전히 요구하는 필드를 제거하려고 시도하면, can-i-deploy 검사가 즉시 실패하여 브레이킹 체인이 Kafka에 도달하는 것을 방지합니다. 후보자들은 종종 이를 위해 브로커에서 명시적인 버전 태깅이 필요하고, 파이프라인이 최신 하나의 소비자 버전만 쿼리하는 것이 아니라 모든 태그가 있는 소비자 버전을 쿼리해야 한다는 점을 간과합니다. 이는 전체 소비자 집단 간의 포괄적인 호환성을 보장합니다.