바운디드 컨텍스트 간 데이터 중복과 동기화: 무엇을 복제하고, 누가 소유하며, 어떻게 수렴시킬까

바운디드 컨텍스트 간 데이터 중복과 동기화: 무엇을 복제하고, 누가 소유하며, 어떻게 수렴시킬까
DDD를 처음 실무에 적용하면서 가장 먼저 부딪히는 질문이 있었다.
"도메인 별로 컨텍스트를 잘 나눴는데 왜 데이터가 자꾸 중복되죠?"
바운디드 컨텍스트를 분리했는데도 데이터가 여러 곳에 나타났던 순간, 설계가 잘못된 것 아닌가 하고 고민했던 기억이 난다.
그리고 두 가지 극단의 질문이 이어졌다.
- "헷갈리니까 다 엄격히 분리시켜버리자."
- "운영 편의 위해서 다 가져와서 저장하자."
둘 다 생각해야 할 부분이 있다.
중복은 비용이지만, 잘 설계된 중복은 오히려 서비스 경계를 안정화한다.
이벤트도 강력한 도구지만, 소유권과 복구 규칙 없이 쓰면 불일치를 더 오래 숨긴다.
이 글은 주문-상품-재고 흐름을 메인 예시로, 어떤 중복이 허용 가능하고 동기화를 어떻게 설계해야 하는지 정리해보았다.
목차
- 문제 제기: 중복은 왜 계속 생길까
- 핵심 개념 정리: 소유권, 정합성, 읽기 모델
- 데이터 중복의 유형: 허용 가능한 중복 vs 위험한 중복
- 왜 중복이 생기는가: 설계가 아니라 운영 요구 때문
- 동기화 방식 비교와 eventual consistency
- 실패 시나리오와 대응: 유실, 중복, 순서 역전
- 안티패턴
- 실제 사례
- 결론
문제 제기: 중복은 왜 계속 생길까
주문 컨텍스트(Ordering)가 상품 컨텍스트(Catalog)의 데이터를 그때그때 조회만 하면 더 깔끔해 보일 수 있다. 하지만 실제 트래픽 환경에서는 주문 생성마다 상품 API를 동기 호출하는 순간, 지연은 커지고 장애는 쉽게 전파된다. 그래서 주문 쪽에는 상품명, 가격, 과세 기준처럼 주문 시점에 필요한 값을 스냅샷으로 저장하게 된다. 바로 그 지점에서 이른바 "중복"이 발생한다.
개인적으로는 중복을 없애는 시도보다, 누가 데이터를 소유하는지, 어느 시점까지 일치해야 하는지, 어긋났을 때 어떻게 복구할지를 먼저 정의하는 편이 훨씬 효과적이었다. 이런 관점 없이 중복 제거에만 집중했을 때는, 중복은 줄었지만 동기 호출 체인이 늘어났고, 각 컨텍스트는 독립성을 잃은 채 사실상 분산 모놀리스에 가까워졌다.
핵심 개념 정리: 소유권, 정합성, 읽기 모델
바운디드 컨텍스트 간 데이터 설계를 단순화하면 세 단어로 압축된다.
- 소유권(Ownership): 어떤 필드를 최종적으로 결정하는 컨텍스트가 하나여야 한다.
- 정합성 수준(Consistency Level): 즉시 일치가 필요한지, 수 초 지연을 허용하는지 합의해야 한다.
- 읽기 모델(Read Model): 조회 최적화를 위해 복제한 데이터는 "원본"이 아니라 "소비 목적의 사본"이라는 사실을 코드와 운영 규칙으로 명시해야 한다.
이 개념이 추상적으로 들릴 수 있지만, 실제로 가장 까다로운 지점은 대개 "필드 단위 소유권"에서 드러난다. LMS를 개발할 때도 바로 그 문제가 가장 어려웠다.
visibility/isActive와 startAt/endAt 같은 학습 노출 관련 필드가 Resource 원본과 하위 도메인(Course, Problem, ...) 양쪽에서 수정 가능해진 순간, 동기화 문제는 기술 문제가 아니라 책임 충돌 문제가 됐다.
누가 최종 상태를 선언하는지 모호해지면, 이벤트가 잘 흘러도 정합성은 깨진다.
이 세 가지 개념에 대해 문서로만 규칙을 정했을 때, 금방 규칙이 흐릿하고 느슨해졌다. 이벤트 스키마 버전 규칙, API 계약, DB 제약조건, 재처리 절차까지 시스템 동작으로 고정했을 때 비로소 중복이 안정적으로 관리됐다.
데이터 중복의 유형: 허용 가능한 중복 vs 위험한 중복
중복은 크게 두 가지다.
- 허용 가능한 중복: 허용 가능한 중복은 "의도된 복제"이다. 예를 들어 주문 생성 시점의 상품명/가격 스냅샷을 저장하면 이후 Catalog가 가격을 바꿔도 과거 주문의 법적 기록은 유지된다. 이 중복은 조회 성능과 감사 추적을 동시에 얻는다. 즉, 중복의 대가를 내는 대신 운영 안정성을 산다.
- 위험한 중복: 위험한 중복은 "소유권 없는 복제"다. 예를 들어 Catalog와 Ordering이 모두 상품 표시명을 수정할 수 있다면 누가 최종값인지 모호해진다. 동기화 지연이 아니라 책임 충돌이 본질 문제다.
중복 유형은 아래와 같은 질문을 통해 판단할 수 있다.
- 이 필드의 단일 소유자는 누구인가?
- 허용 가능한 지연 시간은 얼마인가?
- 불일치가 발생했을 때 자동 복구 가능한가?
- 운영자가 수동 개입해야 한다면 절차와 도구가 준비되어 있는가?
왜 중복이 생기는가: 설계가 아니라 운영 요구 때문
중복은 대개 나쁜 설계의 결과가 아니라 운영 요구의 결과로 인해 생긴 적이 많았다. 서비스 독립 배포, 조회 지연 감소, 장애 격리, 외부 시스템 의존성 완화 같은 요구가 쌓이면 결국 "가져와서 저장"하는 선택을 하게 된다.
문제는 중복 자체가 아니라, 그 선택 이후의 규칙이 비어 있는 상태다. 규칙이 빈약했을 때, "복제는 했는데 갱신 규칙이 없다" 혹은 "이벤트는 발행했지만 소비 실패를 모른다"와 같은 상황이 자주 발생했다. 이런 상태를 운영 가능한 형태로 바꾸기 위한 방법이 바로 동기화 전략이었다.
동기화 방식 비교와 eventual consistency
동기화 방식은 하나로 해결되기보다, 대개 서로 다른 목적을 가진 여러 경로를 조합해 설계된다.
- 동기 API 조회: 최신성이 가장 중요할 때 유용하지만, 업스트림의 지연과 장애가 곧바로 전파된다.
- 이벤트 기반 비동기 동기화: 컨텍스트 간 결합도를 낮출 수 있지만, 재처리·멱등성·순서 보장을 함께 설계해야 한다.
- CDC 기반 변경 반영: 원천 데이터베이스의 변경을 포착해 다른 시스템에 전달하기 좋지만, 도메인 의미까지 전달해 주는 것은 아니다.
- 배치 기반 보정: 실시간성은 약하지만, 누락되거나 어긋난 데이터를 대량으로 재수렴시키는 데 강하다.
가장 효과적이었던 것은 "요청 시점 동기 조회 + 이벤트 반영 + 배치 보정"의 혼합 모델이었다. 이렇게 하면 "최신 데이터가 필요할 때는 동기 조회로 가져오고, 이벤트로 실시간 업데이트를 받고, 배치로 놓친 건 맞춘다"는 보완적 경로가 생긴다. 이때 eventual consistency는 "언젠가 맞겠지"가 아닌, 지연 상한, 재시도 정책, 실패 관측, 수동 복구 절차를 포함한 운영 계약으로 정의해야 한다.
아래 코드는 Ordering이 Catalog를 소유하지 않으면서도, 주문 처리에 필요한 값만 로컬 스냅샷으로 저장하는 예시다.
type ProductSnapshot = {
productId: string;
title: string;
unitPrice: number;
catalogVersion: number;
};
async function placeOrder(cmd: {
userId: string;
productId: string;
quantity: number;
}) {
const product = await catalogClient.getProduct(cmd.productId); // 동기 조회
const snapshot: ProductSnapshot = {
productId: product.id,
title: product.title,
unitPrice: product.price,
catalogVersion: product.version,
};
await orderRepository.insert({
userId: cmd.userId,
quantity: cmd.quantity,
productSnapshot: snapshot,
});
}
이 코드는 중복을 만든다. 대신 "주문 기록의 소유권은 Ordering"이라는 경계를 분명히 한다. 이후 Catalog 변경 이벤트는 주문의 과거 스냅샷을 덮어쓰지 않고, 별도 읽기 모델이나 추천/노출 데이터만 갱신하도록 분리하는 식으로 정합성 규칙을 고정한다.
다음은 결제 승인 이벤트를 Outbox로 안전하게 발행하는 짧은 예시다.
async function confirmPayment(orderId: string, tx: Tx) {
await tx.query(
`UPDATE payments SET status = 'CONFIRMED' WHERE order_id = $1`,
[orderId],
);
await tx.query(
`INSERT INTO outbox_events(event_id, event_type, payload, published)
VALUES (gen_random_uuid(), 'PaymentConfirmed', jsonb_build_object('orderId', $1), false)`,
[orderId],
);
}
핵심은 상태 변경과 이벤트 적재를 같은 로컬 트랜잭션에 묶는 것이다. 이렇게 해야 "결제는 성공했는데 이벤트는 사라진" 구간을 줄일 수 있다.
재고 소비자에서는 멱등성과 버전 체크를 함께 두는 편이 안전하다.
async function applyInventoryEvent(evt: {
eventId: string;
sku: string;
qty: number;
version: number;
}) {
if (await processedEventRepo.exists(evt.eventId)) return; // 멱등 처리
const current = await inventoryRepo.getBySku(evt.sku);
if (evt.version <= current.version) return; // 순서 역전 방지
await inventoryRepo.reserve(evt.sku, evt.qty, evt.version);
await processedEventRepo.save(evt.eventId);
}
이 두 장치가 없으면 재시도는 곧 중복 반영이 되고, 늦게 도착한 이벤트는 최신 상태를 과거 상태로 되돌린다. 결국 동기화 설계에서 핵심은 "전달"보다 "수렴"이다.
실패 시나리오와 대응: 유실, 중복, 순서 역전
자주 발생하는 실패는 복잡한 이론보다 단순한 세 가지였다.
- 이벤트 유실: DB 업데이트는 성공했는데 발행 전에 프로세스가 죽는 경우다. 이는 Outbox 패턴으로 "상태 변경과 이벤트 적재"를 같은 트랜잭션으로 묶어야 줄일 수 있다.
- 중복 소비: 브로커의 at-least-once 전달 환경에서는 같은 이벤트를 여러 번 받을 수 있다. 소비자는 이벤트 ID 기준 멱등성 저장소를 두고, 이미 처리한 이벤트는 무시해야 한다.
- 순서 역전(out-of-order): version 12가 먼저 오고 version 11이 늦게 오면 데이터가 되돌아갈 수 있다. 이때는 버전 필드나 logical timestamp를 비교해 "더 오래된 이벤트"를 폐기해야 한다.
세 가지 실패 유형은 아래와 같이 나타날 수 있다.
- 유실: 결제는 승인됐는데 주문 상태가 계속 Pending으로 남는다.
- 중복: 동일 주문에 대해 재고가 두 번 차감된다.
- 순서 역전: 취소 이벤트 뒤에 승인 이벤트가 늦게 도착해 상태가 되살아난다.
대응은 개별 패턴이 아니라 조합으로 설계해야 한다. Outbox로 유실 구간을 줄이고, 멱등성으로 재시도를 안전하게 만들고, 버전 비교로 상태 역행을 막는다. 그리고 마지막으로 배치 재동기화를 둬서 "놓친 건 결국 맞춰진다"는 보정 경로를 보장해야 한다.
운영 관점에서는 수치 자체보다 관측 원칙이 중요했다. 최소한 동기화 지연, 이벤트 처리 실패율, DLQ 적체량, 재처리 성공 여부는 계속 추적되어야 한다. 정합성은 코드 한 줄로 끝나는 문제가 아니라 관측 가능한 운영 상태로 유지되는 문제다.
안티패턴
안티패턴은 기술 선택보다 책임 설계에서 시작된다.
- 공동 수정: 가장 흔한 안티패턴이다. 같은 필드를 두 컨텍스트에서 수정 가능하게 두면 결국 마지막 쓰기 우연성에 의존한다.
- 이벤트 만능주의: 이벤트를 붙였다는 사실과 동기화가 안전하다는 사실은 다르다. 재처리, 멱등성, 관측이 없으면 이벤트는 오히려 디버깅 난이도만 올린다.
- 공유 테이블로 빠른 합의: 초기에는 빨라 보이지만, 시간이 지나면 컨텍스트 경계와 배포 독립성이 함께 무너진다.
- 타 컨텍스트 DB를 직접 조인하는 조회: 당장은 편하지만, 결국 데이터 소유권과 배포 경계를 무너뜨린다. 조회 최적화처럼 보이지만 실제로는 모델 결합을 고착화하는 지름길이다.
실제 사례
내가 겪었던 SaaS형 LMS 사례에서도, "중복 자체"보다 "소유권 경계가 느슨해진 상태"가 더 큰 문제였다.
Resource가 원본(Source of truth)이고, Course와 Problem은 이를 참조하거나 일부 필드를 복제해 사용했다.
처음에는 조회 성능과 기능 독립성을 위해 합리적인 선택이었다.
문제는 시간이 지나며 visibility/isActive, startAt/endAt 같은 노출/기간 필드가 하위 도메인에서도 수정 가능해진 지점이었다.
즉, 원래는 Resource만 바꿔야 할 필드가 Course 운영 편의 기능을 붙이면서 Course에서도 바뀌고, 일부 배치 작업이 Problem 값을 다시 덮어쓰는 구조가 생겼다. 동기화 채널도 이벤트, API 조회, 배치가 혼재되어 있었고, 어느 경로가 최종 상태를 선언하는지 명확하지 않았다.
이어서 아래와 같은 장애들이 발생하기 시작했다.
- 강의 운영자가 Resource를 비활성화했는데 Course 목록에는 수 분간 노출됨
startAt/endAt이 하위 도메인에 늦게 반영되어 학습 가능 기간이 어긋남- 학습자는 "열려 있어 보이는데 들어가면 막혀 있는" 상태를 경험
비즈니스 영향은 치명적 대규모 장애까지는 아니었지만, 학습 흐름 단절과 CS 증가가 반복되는 중간 강도의 지속 장애였다. 서비스 신뢰는 이런 "자주, 작게, 반복되는 어긋남"에서 더 많이 떨어진다.
당시 대응은 솔직히 방어적이었다. 운영자가 수동 재동기화를 수행하고, 야간 재처리 배치로 맞추는 식으로 버텼다. 시스템은 돌아갔지만 팀 피로도가 높았고, 문제 재발도 잦았다.
회고하면 우선순위는 명확했다.
- 필드별 단일 소유권 고정(
visibility/isActive,startAt/endAt) - 이벤트 계약 버전 규칙 명시(추가/변경/폐기 절차)
- 실패 시 자동 재처리와 수동 복구 절차 분리
이 세 가지를 먼저 했으면, 뒤늦은 운영 비용의 상당수를 줄일 수 있었다. 결국 복구 비용 대부분은 기술 스택의 한계가 아니라 경계 합의의 부재에서 나왔다.
결론
바운디드 컨텍스트 사이의 데이터 중복은 피해야 할 오염이 아니라, 때로는 의도적으로 선택해야 하는 설계 수단이다. 중요한 건 "중복이 있느냐"가 아니라 "누가 소유하고, 어느 시점까지 맞춰야 하며, 어긋났을 때 어떻게 수렴시키느냐"다.
정리하면, 데이터 중복은 그 자체로 악이 아니다. 문제는 중복된 데이터를 어떤 규칙 없이 방치할 때 발생한다. 소유권이 단일해야 하고, 정합성 목표가 수치화되어야 하며, 동기화 전략은 실패 복구까지 포함해야 한다.
이 세 가지가 없다면 작은 지연도 큰 불일치로 번진다. 반대로 이 세 가지가 분명하다면, 중복은 시스템을 느슨하게 결합하면서도 운영 가능한 상태로 유지하게 해 준다.
결국 중요한 것은 중복의 존재 여부가 아니라, 소유권·정합성·동기화 전략을 얼마나 명확하게 설계했느냐이다.