질문의 역사
트랜잭션 아웃박스 패턴은 분산 시스템 아키텍처에 내재된 "이중 쓰기" 문제에 대한 중요한 해결책으로 부상했습니다. 서비스가 데이터베이스를 업데이트하고 동시에 브로커에 메시지를 게시할 때, 이 두 작업은 2PC와 같은 비용이 많이 드는 분산 트랜잭션 없이 원자적으로 수행할 수 없습니다. 현대의 마이크로서비스는 확장성과 가용성 제약으로 인해 이러한 방법을 피합니다. 이 패턴은 비즈니스 데이터 업데이트와 동일한 로컬 데이터베이스 트랜잭션 내에서 이벤트를 아웃박스 테이블에 작성한 뒤, 별도의 릴레이 프로세스가 메시지 버스에 이를 게시하도록 의존합니다.
문제
근본적인 검증 과제는 PostgreSQL 장애 조치나 Kafka 브로커 재균형과 같은 인프라 실패 중에 정확히 한 번 의미(또는 보장된 아이돔포턴시를 가진 최소 한 번 의미)를 보장하는 것입니다. 철저한 자동화 테스트가 없으면 경쟁 조건으로 인해 이벤트가 여러 번 게시되거나 완전히 손실될 수 있으며, 이는 데이터 불일치 및 재정적인 불일치를 초래할 수 있습니다. 또한, 다운스트림 소비자가 중복 메시지를 올바르게 처리하는지 검증하려면 복잡한 네트워크 분할 및 충돌 복구 시나리오를 시뮬레이션해야 하며, 이는 수작업 테스트로는 일관되게 재현할 수 없습니다.
해결책
TestContainers 기반 프레임워크를 구현하여 기본-복제 PostgreSQL 클러스터, Kafka 브로커 및 테스트 중인 애플리케이션 서비스를 조율합니다. 중요한 순간에 데이터베이스와 릴레이 서비스 간에 정확한 네트워크 분할을 주입하기 위해 Toxiproxy를 통합합니다. 검증 스위트는 이벤트가 고유한 아이돔포턴시 키로 아웃박스 테이블에 기록되었는지, 릴레이 프로세스(폴링 또는 Debezium CDC 기반 여부)가 이러한 이벤트를 키 intact로 게시하는지, 소비자가 이러한 키를 기반으로 중복을 거부하는 중복 제거 저장소를 유지하는지 확인해야 합니다. 모든 테스트 워커는 교차 테스트 오염을 방지하기 위해 고립된 Docker 네임스페이스에서 실행됩니다.
-- 아이돔포턴시 제약 조건이 있는 아웃박스 테이블 스키마 CREATE TABLE outbox ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), aggregate_id UUID NOT NULL, event_type VARCHAR(255) NOT NULL, payload JSONB NOT NULL, idempotency_key VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, processed BOOLEAN DEFAULT FALSE ); -- 소비자 중복 제거 테이블 CREATE TABLE processed_messages ( idempotency_key VARCHAR(255) PRIMARY KEY, processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );
// 소비자 아이돔포턴시 로직 public void handleEvent(Message event) { try { deduplicationRepository.insert(event.getIdempotencyKey()); businessService.processOrder(event.getPayload()); } catch (DuplicateKeyException e) { log.info("아이돔포턴트 중복 무시: {}", event.getIdempotencyKey()); } }
문제 설명
우리 전자상거래 플랫폼은 아웃박스 패턴을 이용하여 PostgreSQL 데이터베이스에서 Apache Kafka로 주문 이벤트를 게시하여 재고 및 결제 서비스가 동기화된 상태를 유지했습니다. 중요한 블랙 프라이데이 이벤트 중에 기본 데이터베이스에서 읽기 복제로의 갑작스러운 장애 조치로 인해 폴링 게시자 서비스가 예기치 않게 재시작되었고, 이로 인해 이미 처리된 15,000개의 "OrderCreated" 이벤트가 다시 게시되었습니다. 이로 인해 다운스트림 소비자가 적절한 아이돔포턴시 검사 없이 중복 청구와 재고 초과 판매를 유발하여 상당한 재정적 손실과 고객 신뢰의 손상이 발생했습니다.
해결책 A: 스테이징에서 수동 장애 조치 테스트
장점: 추가 자동화 도구나 복잡한 스크립트 없이 프로덕션과 유사한 인프라를 활용하며, 경험이 풍부한 QA 엔지니어가 장애 시나리오에서 시스템 동작을 직관적으로 관찰할 수 있습니다. 단점: 데이터베이스 장애 조치는 본질적으로 예측할 수 없고 테스트 실행과 정확하게 동기화하기 어렵습니다; CI/CD 파이프라인에 통합할 수 없어 지속적인 회귀 테스트에 적합하지 않으며, 재현성이 부족하고 인간 조정 충돌 없이 병렬로 실행할 수 없습니다.
해결책 B: 모의 리포지토리를 이용한 단위 테스트
장점: 외부 인프라 의존성 없이 100ms 이내에 매우 빠른 실행 시간을 제공합니다; 테스트는 완전히 결정적이며 IDE 환경에서 디버깅이 용이합니다; 실제 분산 시스템에서 유발하기 어려운 이론적 엣지 케이스를 시뮬레이션할 수 있습니다. 단점: 모의는 실제 PostgreSQL 트랜잭션 격리 수준, Kafka 소비자 그룹 재균형 동작 또는 TCP 네트워크 스택의 미세한 차이를 시뮬레이션하는 데 실패합니다; 실제 JDBC 드라이버 또는 커널 수준 구현에서 경쟁 조건을 감지할 수 없습니다.
해결책 C: TestContainers를 이용한 컨테이너화된 혼돈 공학
장점: 실제 PostgreSQL 스트리밍 복제 및 Kafka 브로커를 사용하여 현실적인 환경을 생성합니다; Toxiproxy 또는 Pumba를 이용하여 네트워크 분할 및 대기 시간을 정밀하게 주입할 수 있습니다; CI/CD 파이프라인에 통합할 수 있으며 병렬 실행을 지원하는 완전한 재현 가능성을 제공합니다. 단점: 테스트 스위트당 5-10분의 상당한 초기 설정 시간이 필요하며, 더 높은 컴퓨팅 리소스와 메모리 할당을 요구합니다; 포트 고갈 및 동작 중인 컨테이너를 예방하기 위해 신중한 정리 로직이 필요합니다.
선택된 해결책
우리는 해결책 C를 채택했습니다. 왜냐하면 실제 인프라 상호작용만이 PostgreSQL이 기본 노드에서 트랜잭션을 성공적으로 커밋하지만 네트워크 분할 중에 확인 응답이 손실되는 특정 경쟁 조건을 노출할 수 있었기 때문입니다. 우리는 JUnit 5 확장을 구현하여 Docker Compose와 Pumba를 조율하여 중요한 트랜잭션 단계 중에 네트워크 혼돈을 시뮬레이션했습니다.
결과
자동화된 테스트 스위트는 즉시 아웃박스 테이블에 idempotency_key 열에 대한 고유 제약 조건이 누락되어 재시도 중에 게시자가 중복 행을 생성할 수 있도록 허용했다는 것을 감지했습니다. 제약 조건을 추가하고 소비자에 중복 제거 계층을 구현한 후, 이 테스트는 이제 모든 CI 빌드에서 실행되어 8분 이내에 피드백을 제공하고 메시지 중복과 관련된 생산 사고를 95% 줄였습니다. 이는 다음 분기에 잠재적인 중복 청구로 $50K의 손실을 예방했습니다.
아웃박스 패턴이 근본적으로 사가 패턴과 어떻게 다른지, 그리고 두 단계 커밋(2PC)이 마이크로서비스에 적합하지 않은 이유는 무엇인가요?
아웃박스 패턴은 단일 서비스 경계 내에서 로컬 데이터베이스 상태 변경과 이벤트 게시 간의 원자성을 보장하는 반면, 사가 패턴은 보상 작업을 사용하여 여러 서비스 간의 장기 분산 트랜잭션을 조율합니다. 2PC는 서비스 경계를 넘어 리소스를 잠그기 위해 중앙 조정자가 필요하기 때문에 마이크로서비스에 적합하지 않습니다. 이는 시간적 결합과 가용성 위험을 초래하여 하나의 참가 서비스가 응답하지 않으면 조정자가 모든 다른 참가자를 타임아웃까지 차단하게 되어 마이크로서비스의 자율성 원칙을 위반합니다.
폴링 게시자와 Debezium과 같은 로그 기반 변경 데이터 캡처(CDC) 사용 간의 중요한 트레이드오프는 무엇인가요?
폴링 게시자는 아웃박스 테이블을 주기적으로 조회하므로 구현이 더 간단하고 추가 인프라가 필요하지 않지만 1-5초의 대기 시간을 도입하고 폴링 빈도에 따라 증가하는 데이터베이스 쿼리 부하를 추가합니다. Debezium 및 유사한 CDC 솔루션은 WAL(Write-Ahead Log)을 읽어 최소한의 데이터베이스 영향을 미치는 거의 실시간 이벤트 스트리밍을 제공하지만, 이는 Kafka Connect 클러스터 필요, 논리적 복제 슬롯과 같은 특정 데이터베이스 구성을 요구하며, 소비가 이루어지기 전에 WAL 세그먼트가 잘리면 데이터 손실 위험이 있습니다.
네트워크 분할 복구가 발생한 후 "좀비 인스턴스"—일시적으로 부활하는 오래된 애플리케이션 인스턴스가 구식 아웃박스 이벤트를 게시하는 것을 어떻게 방지하나요?
좀비 인스턴스는 네트워크 분할이 치유된 후 새로운 기본 인스턴스가 선출된 경우 발생하며, 오래된 인스턴스가 자신의 구식 백로그를 계속 처리할 수 있습니다. 이를 방지하기 위해 ZooKeeper 또는 etcd에 저장된 펜싱 토큰 또는 에포크 번호를 구현하세요; 릴레이 프로세스는 게시 전에 자신의 에포크가 최신인지 확인해야 합니다. 또는 Kafka의 트랜잭션 프로듀서를 사용하여 새 인스턴스가 시작될 때 이전 프로듀서를 자동으로 격리시키는 고유한 transactional.id를 사용하여 현재 활성 인스턴스만이 주제에 이벤트를 게시할 수 있도록 보장합니다.