이벤트 소싱은 완전한 감사 추적 및 시간 기반 쿼리 기능이 필요한 도메인에 대한 중요한 패턴으로 떠올랐습니다. 전통적인 CRUD 아키텍처와 달리, 불변 이벤트가 추가 전용 스토어에 상태 전이를 저장하며 이벤트 리플레이를 통해 집합 상태를 재구성합니다. 2010년대 금융 및 의료 시스템에서 채택이 증가함에 따라 QA 팀은 기존의 모킹 전략이 집합과 이벤트 스토어 간의 통합 문제를 잡아내지 못한다는 것을 발견했습니다. 특히 낙관적 동시성 제어와 스냅샷 최적화 메커니즘과 관련하여 그랬습니다.
전통적인 단위 테스트는 모킹된 저장소를 사용하여 집합을 격리하며 이벤트 스토어의 일관성 보장을 완전히 우회합니다. 이는 중요한 실패 모드를 간과합니다: 동시 이벤트 추가로 인한 스트림 버전 충돌, 손상된 스냅샷(집합 상태를 캐시하는 성능 최적화)이 과거 데이터를 반환하고, 특정 이벤트 시퀀스 동안에만 발생하는 불법 상태 전이입니다. 자동화된 검증 없이는 이러한 결함이 프로덕션에서만 발생하며 레이스 조건 하에 데이터 불일치로 이어져 역사적으로 조정이 거의 불가능합니다.
TestContainers를 사용하여 실제 EventStoreDB 또는 Apache Kafka 인스턴스를 시작하는 통합 테스트 프레임워크를 구현합니다. 불변 이벤트 빌더를 사용하여 복잡한 시나리오를 구성하기 위해 Given-When-Then 패턴을 채택합니다. 무작위 이벤트 시퀀스와 인터리빙을 생성하고 집합 불변 조건이 역사에 관계없이 유지되는지 자동으로 검증하기 위해 Property-Based Testing(via jqwik 또는 ScalaCheck)을 사용합니다. 충돌 후 스냅샷 복원을 검증하기 위해 Toxiproxy를 사용하여 네트워크 실패 및 디스크 지연을 주입합니다. 스냅샷에서 재구성된 집합이 전체 이벤트 리플레이와 비트 단위로 일치하는 것을 주장합니다.
@Test public void shouldMaintainInvariantAfterConcurrentEventAppends() { // 주어진: 버전 10의 스냅샷이 있는 집합 String streamId = "order-" + UUID.randomUUID(); OrderAggregate aggregate = new OrderAggregate(streamId); aggregate.loadFromSnapshot(snapshotAtVersion10); // 언제: PaymentProcessed의 동시 추가 시뮬레이션 List<DomainEvent> concurrentEvents = Arrays.asList( new ItemAdded("SKU-123", 2), // v11 new PaymentProcessed(BigDecimal.valueOf(100.00)) // v12 ); // 그 때: 불변 조건 검증 (장바구니에 없는 품목에 대한 결제는 불가능) assertThrows(IllegalStateException.class, () -> { aggregate.apply(concurrentEvents); }); // 스냅샷 복원이 전체 리플레이와 일치하는지 검증 OrderAggregate fromSnapshot = repository.loadFromSnapshot(streamId); OrderAggregate fromReplay = repository.loadFromEvents(streamId); assertEquals(fromSnapshot.calculateHash(), fromReplay.calculateHash()); }
50,000건의 주문을 처리하는 기업 전자상거래 플랫폼이 주문 관리 경계 컨텍스트에 대해 이벤트 소싱을 채택했습니다. 각 OrderAggregate는 OrderCreated, ItemAdded, PaymentProcessed와 같은 이벤트를 내보냈습니다. 높은 트래픽을 처리하기 위해 시스템은 전체 이력을 재생하지 않고 체크아웃 중 스냅샷을 20 이벤트마다 생성했습니다.
블랙 프라이데이 기간 동안 시스템은 결제가 체결되었지만 재고 수준이 변하지 않는 "환상 재고" 결함을 경험했습니다. 근본 원인 분석 결과, 높은 동시성 하에서 스냅샷 영속성이 이벤트 추가 뒤에서 몇 밀리초 지연된 것으로 나타났습니다. 이러한 오래된 스냅샷에서 재구성할 때 최근 ItemAdded 이벤트가 중복 처리되었으며, 이는 논리적으로 결함이 있는 아이도포턴시 처리 로직으로 인해 발생하여 재고 계산 오류 및 초과 판매 문제를 초래했습니다.
해결책 A: 순수 이벤트 리플레이 및 스냅샷 제거
테스트 아키텍처에서 스냅샷을 완전히 제거하여 모든 테스트가 첫 번째 이벤트에서 전체 이벤트 스트림을 재생하도록 합니다. 장점: 스냅샷 손상 위험을 완전히 제거합니다; 스냅샷 비교 로직을 제거하여 테스트 주장을 단순화합니다; 집합이 항상 절대 진리를 기반으로 계산하므로 수학적 일관성을 보장합니다. 단점: 집합이 성숙해짐에 따라 테스트 실행 시간이 기하급수적으로 증가하며 (1000개 이상의 이벤트) CI 파이프라인에서 비현실적으로 만듭니다; 스냅샷 생성 중에만 나타나는 프로덕션 특정 레이스 조건을 감지하지 못합니다; 부하 하에서 사용자 경험에 영향을 미치는 성능 병목 현상을 감추게 됩니다.
해결책 B: 수동 이진 비교
QA 엔지니어가 테스트 실행 후 스냅샷 파일을 수동으로 내보내고, 작업 전후에 이진 직렬화를 비교하기 위해 차분 도구를 사용합니다. 장점: 직렬화 형식 변화에 대한 직접적인 가시성을 제공합니다; 스냅샷 버전과 현재 집합 코드 간의 스키마 불일치를 잡아냅니다; 추가 인프라 투자 없이 가능합니다. 단점: 스냅샷 쓰기와 이벤트 추가 간의 레이스 조건을 감지할 수 없습니다; 검증에서 사람의 오류가 불가피하게 발생합니다; 타임스탬프 정밀도나 JSON 키 정렬과 같은 작은 형식 변화에 매우 취약합니다; CI/CD 환경에서 대규모로 실행하기 불가능합니다.
해결책 C: 속성 기반 상태 머신 검증
이벤트 시퀀스를 무작위로 생성하기 위해 jqwik을 사용하여 수천 개의 랜덤 유효 이벤트 시퀀스를 생성하고, 무작위 간격에서 스냅샷 생성을 강제하고, Byteman을 통해 프로세스 종료를 주입하며, 집합 불변 조건(예: "지불 금액은 품목 가격의 합계와 같아야 한다")이 재구성 방법에 관계없이 유지되는지 검증합니다. 장점: 수작업으로 스크립트할 수 없는 엣지 케이스를 자동으로 탐색합니다; 동시 접근 패턴 및 낙관적 동시 실패를 검증합니다; 사례 기반 테스트가 아니라 수학적 속성 검증을 통해 결정론적인 버그를 감지합니다. 단점: 함수형 프로그래밍 개념 및 속성 기반 테스트 프레임워크에 대한 상당한 전문성이 필요합니다; 적절한 시드 없이 실패는 비결정론적일 수 있으며 로컬에서 재현하기 어려울 수 있습니다; 수천 개의 생성된 테스트 케이스 때문에 CI 실행 시간이 15-20분 증가합니다.
선택된 해결책 및 근거
팀은 재현 가능성을 위해 Git에 저장된 결정론적 시드를 가진 해결책 C를 선택했습니다. 이 선택은 해결책 A가 스냅샷 메커니즘을 완전히 제거하여 실질적인 프로덕션 버그를 가리는 반면, 해결책 B는 스냅샷 영속성과 이벤트 추가 작업 간의 50밀리초 레이스 창을 포착하지 못했기 때문에 의무화되었습니다. 속성 기반 테스트는 두 개의 빠른 ItemAdded 이벤트 사이에서 스냅샷이 생성되었을 때, 낙관적 동시성 버전 검사가 집합 버전이 아닌 스냅샷 버전을 이벤트 스트림 버전과 잘못 비교하는 미세한 논리 오류를 드러냈습니다. 이는 특정 인터리빙에서만 볼 수 있는 은밀한 논리 오류입니다.
결과
이 프레임워크는 출시 전에 세 가지 중요한 버그를 감지했습니다: 동시 쓰기 중 스냅샷 버전 불일치, PaymentProcessed 핸들러에서 누락된 아이도포턴시 검사, 이벤트가 테넌트 스트림 사이에서 유출되는 집합 경계 위반. 이제 CI는 빌드마다 5,000개의 무작위 생성 이벤트 시퀀스를 실행합니다. 주문 상태 불일치와 관련한 배포 후 프로덕션 사건 발생률이 94% 감소했으며, 자동 경고를 통해 스냅샷 손상 탐지까지 걸리는 평균 시간이 4시간에서 30초로 단축되었습니다.
시스템 시계 시간에 테스트를 결합하거나 Thread.sleep()을 사용하지 않고 이벤트 소싱 시스템에서 시간 쿼리를 어떻게 테스트합니까?
후보자들은 종종 **Thread.sleep()**에 의지하거나 시스템 시계를 조작하여 CI 환경에서 간헐적으로 실패하는 불안정한 테스트를 생성합니다. 올바른 접근 방식은 Clock 추상화의 의존성 주입을 포함합니다(예: Java의 java.time.Clock 또는 .NET의 Microsoft.Extensions.Internal.ISystemClock).
테스트에서, 결정론적으로 진행될 수 있는 MutableClock 또는 FixedClock 구현을 주입합니다. "어제 오후 3시의 주문 상태는 어땠는가"를 테스트할 때, 그 순간에 시계를 고정하고, 명령을 실행하며, 알려진 역사적 상태에 대해 주장합니다. "주문은 24시간 후에 자동으로 취소된다"는 논리를 테스트할 때는, 단순히 주입된 시계를 25시간 진행시키고 예상 OrderExpired 이벤트가 실제로 기다리지 않고 방출되는지를 검증합니다. 이를 통해 복잡한 시간 기반 비즈니스 규칙을 정확하게 검증하면서 테스트를 밀리초 단위로 실행할 수 있습니다.
이벤트 스토어에서 테스트 데이터를 물리적으로 삭제하는 것이 안티 패턴으로 간주되는 이유는 무엇이며, 추가 전용 의미를 위반하지 않고 깨끗한 테스트 환경을 보장하기 위한 격리 전략은 무엇입니까?
많은 후보자들은 이벤트 스트림을 잘라내거나 정리 블록에서 집합을 삭제하는 것을 제안하며, 이벤트 스토어가 아키텍처 제약으로 인해 추가 전용임을 근본적으로 오해하고 있습니다. 물리적 삭제는 감사 요구 사항을 위반하며 기술적으로 지원되지 않을 수 있습니다(예: EventStoreDB는 진정한 삭제가 아닌 tombstoning만 지원합니다). 또한, 동시 테스트 실행은 스트림 이름이 재사용될 경우 낙관적 동시성 충돌을 경험할 수 있습니다.
적절한 전략은 UUID를 사용한 고유 스트림 명명 규칙(예: order-{testRunId}-{uuid})과 메타데이터로 필터링된 카테고리 기반 프로젝션을 사용하는 것입니다. 통합 스위트의 경우, 각 테스트 클래스마다 격리된 이벤트 스토어 인스턴스를 생성하기 위해 TestContainers를 사용합니다. 단위 테스트의 경우, Marten의 경량 문서 저장소 모드 또는 Axon Framework의 SimpleEventStore와 같은 인메모리 구현을 활용합니다. 테스트 간 집합 ID를 재사용하지 않고, 이벤트 스토어를 불변 인프라로 취급하고, 쿼리를 특정 시간적 슬라이스 또는 스트림 접두사로 범위 지정하여 다른 테스트 실행에서의 데이터를 효과적으로 무시합니다.
이벤트 스키마 마이그레이션(업캐스팅)이 기존 이벤트 유형에 새로운 필드를 추가할 때 역호환성을 유지하는지 어떻게 검증합니까?
후보자들은 이벤트 소싱이 이벤트 버전 관리와 업캐스팅(과거 이벤트를 현재 스키마 버전으로 변환하는 것)을 필요로 한다는 점을 종종 간과합니다. OrderCreated V2에 필드를 추가할 경우, 스토어에 이미 수천 개의 V1 이벤트가 존재하며 이들은 올바르게 역직렬화되어야 합니다.
테스트 전략은 프로덕션에서 실제 직렬화된 역사적 이벤트 JSON의 골든 마스터 리포지토리를 유지하는 것을 요구합니다. CI에서는 이 역사적 페이로드를 업캐스터 체인을 통해 역직렬화하고, 유효한 V2 객체로 변환되는지 검증합니다(예: currencyCode를 널 대신 컨텍스트 구성에서 파생). 의도하지 않은 직렬화 형식 변화를 감지하기 위해 승인 테스트를 구현합니다. 또한, 왕복 직렬화 테스트를 수행합니다: V2 객체를 가져와서 V1로 다운캐스팅한 후(해당되는 경우), 다시 V2로 업캐스팅하여 동등성을 주장합니다. 이는 새로운 코드가 5년 된 이벤트를 데이터 손실 없이 처리할 수 있도록 보장하며, 이는 이벤트가 불변 감사 추적을 나타내며 프로덕션 데이터베이스에서 사후 수정할 수 없기 때문에 중요합니다.