블로그

상용 CDC 없이 MS-SQL↔MongoDB 실시간 정합성을 보장한 3단계 동기화 파이프라인

예산 제약으로 상용 CDC 솔루션을 쓸 수 없었습니다. 대신 API 레벨 동기화 → Kafka 실패 복구 → Batch 최종 정합성 검증으로 이어지는 3단계 아키텍처를 자체 설계한 과정을 공유합니다.

Spring WebfluxMongoDBMS-SQLKafka데이터정합성Gmarket

참고: 이 글의 코드는 실제 업무에서 경험한 내용을 바탕으로 개념적으로 재작성한 것입니다. 실제 회사 코드와는 무관합니다.

3단계 동기화 파이프라인 구조

성공 실패 성공 실패 있음 없음 데이터 변경 API 1단계: MongoDB실시간 반영 시도 완료 2단계: Kafka실패 복구 큐 Sync Consumer 재처리 DLQ 보관 3단계: Batch (30분 주기)최종 정합성 검증 불일치 감지? MongoDB 자동 보정 정상 확인

배경

광고 랭킹 시스템을 MS-SQL에서 MongoDB로 전환하면서, 두 시스템이 공존하는 전환 기간 동안 데이터 정합성을 유지하는 것이 큰 과제였습니다.

두 데이터베이스가 항상 동일한 데이터를 갖도록 보장해야 했지만, 분산 환경에서 두 DB 간의 트랜잭션을 원자적으로 처리하는 것은 생각보다 훨씬 어려운 문제입니다.


문제: 분산 트랜잭션은 어렵다

처음 시도한 접근법과 그 한계

가장 단순한 접근법은 양쪽에 동시에 쓰기였습니다.

// 초기 접근법 (문제 있음)
public void updateRanking(RankingData data) {
    sqlRepository.update(data);     // MS-SQL 업데이트
    mongoRepository.update(data);   // MongoDB 업데이트
}

이 방식의 문제는 자명합니다. sqlRepository.update()는 성공했는데 mongoRepository.update()가 실패하면? 두 DB의 데이터가 달라집니다. 반대의 경우도 마찬가지입니다.

분산 트랜잭션을 위한 표준적인 해법으로 **2PC(Two-Phase Commit)**가 있지만, 이는 구현이 복잡하고 성능 오버헤드가 큽니다. Kafka의 트랜잭셔널 프로듀서를 활용하는 Outbox Pattern도 있지만, 이를 올바르게 구현하려면 상당한 공수가 필요합니다.

CDC를 고려했지만...

업계 표준적인 해결책은 CDC(Change Data Capture) 솔루션입니다. Debezium 같은 도구를 사용하면 MS-SQL의 변경 로그를 실시간으로 캡처해 MongoDB에 반영할 수 있습니다.

그러나 현실적인 제약이 있었습니다:

  • 상용 CDC 솔루션 라이선스 비용
  • 레거시 MS-SQL 환경에 CDC 에이전트를 추가로 설치해야 하는 인프라 제약
  • 도입 및 안정화까지의 기간

결국 예산과 일정 제약 안에서 자체 동기화 메커니즘을 설계하기로 했습니다.


해결: 3단계 계층형 동기화 아키텍처

핵심 아이디어는 "완벽한 단일 메커니즘" 대신 **"여러 계층의 방어막"**을 두는 것이었습니다.

[1단계] API 레벨 실시간 동기화  ← 정상 케이스 처리
        ↓ 실패 시
[2단계] Kafka 기반 실패 복구    ← 일시적 장애 처리
        ↓ 미반영 잔존 시
[3단계] Batch 최종 정합성 검증  ← 최종 안전망

1단계: API 레벨 실시간 동기화

데이터 변경 API가 호출될 때, Spring Webflux의 비동기 처리로 MongoDB에 즉시 반영을 시도합니다.

@Service
public class RankingService {

    public Mono<Void> updateRanking(RankingUpdateRequest request) {
        return mongoRepository.update(request.toMongoEntity())  // MongoDB 우선 반영
            .onErrorResume(e -> {
                // 실패 시 Kafka로 폴백
                return kafkaPublisher.publish(SYNC_TOPIC, request);
            });
    }
}

MS-SQL이 아닌 MongoDB를 primary 스토어로 두고 먼저 반영을 시도합니다. 광고 랭킹 서빙이 MongoDB에서 이루어지기 때문에, 여기에 먼저 반영되는 것이 비즈니스적으로도 맞았습니다.

2단계: Kafka 기반 실패 복구

MongoDB 반영이 실패하면, 동기화 대상 데이터를 Kafka 토픽에 발행합니다.

@KafkaListener(topics = "ranking-sync-retry")
public class SyncConsumer {
    
    public void consume(RankingUpdateRequest request) {
        mongoRepository.update(request.toMongoEntity())
            .doOnSuccess(v -> log.info("Sync recovered: {}", request.getId()))
            .doOnError(e -> log.error("Sync failed again: {}", request.getId(), e))
            .subscribe();
    }
}

메시지 키를 uuid 기반으로 설정하여 동일한 데이터에 대한 처리가 동일한 파티션으로 라우팅되도록 했습니다. 이렇게 하면 같은 엔티티에 대한 변경 이벤트의 순서가 보장됩니다.

kafkaTemplate.send(ProducerRecord.<String, byte[]>(
    SYNC_TOPIC,
    request.getEntityId(),  // 파티션 키 = 엔티티 ID
    serialize(request)
));

3단계: Batch 최종 정합성 검증

1, 2단계를 통해 대부분의 경우를 처리하지만, 극히 드문 경우(예: Kafka 자체 장애, 컨슈머 다운 등)에 데이터 불일치가 남을 수 있습니다.

이를 위해 MS-SQL의 변경 히스토리 테이블을 기준으로 주기적으로 두 DB를 비교하고 불일치를 보정하는 Batch Job을 개발했습니다.

@Scheduled(cron = "0 */30 * * * *") // 30분마다 실행
public void reconcile() {
    // 1. MS-SQL 변경 히스토리 조회 (최근 30분)
    List<ChangeHistory> recentChanges = changeHistoryRepository.findRecent(Duration.ofMinutes(30));
    
    // 2. 각 변경 건에 대해 MongoDB 상태 확인
    recentChanges.forEach(change -> {
        mongoRepository.findById(change.getEntityId())
            .subscribe(mongoEntity -> {
                if (!isConsistent(change, mongoEntity)) {
                    // 3. 불일치 발견 시 보정
                    log.warn("Inconsistency detected: {}", change.getEntityId());
                    mongoRepository.update(change.toMongoEntity()).subscribe();
                }
            });
    });
}

운영하면서 배운 것

로그가 핵심이다

이 구조에서 운영 가시성이 핵심입니다. 1단계에서 실패해서 Kafka로 넘어간 건수, 3단계에서 보정이 발생한 건수를 추적하면 시스템의 건강 상태를 파악할 수 있습니다.

Datadog 메트릭으로 다음 지표를 추적했습니다:

  • ranking.sync.kafka_fallback.count: 1단계 실패로 Kafka 전환된 건수
  • ranking.sync.reconcile.corrected.count: 3단계 배치에서 보정된 건수

3단계 보정 건수가 꾸준히 0이라면, 1, 2단계가 잘 작동하고 있다는 의미입니다.

멱등성 설계의 중요성

Kafka 컨슈머와 Batch Job 모두 멱등성이 보장되어야 합니다. 같은 이벤트를 두 번 처리해도 결과가 같아야 합니다.

// 멱등성 보장: 덮어쓰기 방식으로 설계
mongoRepository.update(entity) // upsert 방식

결과

이 3단계 구조를 도입한 이후:

  • 데이터 불일치로 인한 광고주 CS 인입 대폭 감소
  • 3단계 보정 건수 거의 0 유지 (1, 2단계에서 대부분 처리)
  • 상용 CDC 솔루션 없이 실시간 수준의 정합성 보장

완벽한 분산 트랜잭션은 아닙니다. 극히 짧은 순간 동안 두 DB가 달라질 수 있습니다. 하지만 광고 랭킹이라는 "최종 일관성(Eventual Consistency)"이 허용되는 도메인에서는 충분히 실용적인 해결책이었습니다.