Skip to Content

Step 11. 데이터가 진짜 전장이다 — 데이터/영속성 아키텍처

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

데이터 소유권이 경계를 완성하며, 이중 쓰기는 아웃박스와 멱등성으로 다루고 금융 데이터는 불변 원장과 대사로 검증한다.

면접 실전 질문: ① 공유 DB가 왜 결합을 만드는가? ② 아웃박스 패턴은 무엇을 보장하는가? ③ 멱등성과 추가 전용 원장은 어떤 문제를 푸는가?


이 장의 질문: 코드의 경계를 아무리 아름답게 그려도 시스템이 여전히 한 덩어리로 움직인다면, 무엇이 그들을 묶고 있는가? 그리고 트랜잭션의 경계와 아키텍처의 경계가 충돌할 때, 무엇이 양보해야 하는가?


1. 가장 조용한 문장의 대가

앞 장에서 가장 조용히 지나간 문장 — “서비스마다 자기 데이터베이스를 가진다” — 을 이제 정면으로 본다. 왜 이 문장이 마이크로서비스 정의에서 가장 깊은 결정인가.

사고 실험. 코드를 완벽하게 갈랐다 — 주문 모듈과 정산 모듈, 각자의 패키지, 각자의 팀, 포트와 어댑터까지. 그런데 둘이 같은 orders 테이블을 읽고 쓴다면? 정산 팀이 컬럼 하나를 바꾸는 순간 주문 서비스가 깨진다. 인덱스 하나를 추가하면 상대 팀의 쿼리 플랜이 바뀐다. 배포는 다시 조율 대상이 된다. 코드의 화살표는 완벽한데 시스템은 한 덩어리로 움직인다 — 데이터 결합이 코드 경계를 무효화한 것이다.

이 병에는 이미 이름을 배웠다. Step 02의 결합도 표에서 두 번째로 나쁜 것, 공통 결합(common coupling) — 전역 데이터의 공유. 공유 데이터베이스는 공통 결합의 왕이고, Step 09에서 본 “분산 모놀리스”(서비스 열 개, DB 하나)의 정확한 발병 기전이다. 경계의 완성은 코드가 아니라 데이터에서 판가름난다. 그래서 이 장의 제목이다: 진짜 전장은 데이터다.

2. 공유 DB라는 유혹 — 그리고 소유권

공유 DB(Fowler의 용어로 통합 데이터베이스, integration database)가 그토록 흔한 이유는 그것이 어리석어서가 아니라 당장은 이기기 때문이다(Step 05의 논리 그대로). 조인 한 번이면 두 도메인의 데이터가 합쳐지고, 트랜잭션은 공짜로 걸리고, 통합 코드는 한 줄도 필요 없다. 청구서는 나중에 온다: 스키마가 곧 암묵적 공용 API가 되어버려서, 누가 어떤 컬럼에 기대고 있는지 아무도 모르게 되고, 결국 “아무것도 못 바꾸는 DB”가 된다 — DB 위에 세운 진흙공이다.

처방의 이름은 **서비스별 데이터베이스(database per service)**지만, 본질은 물리적 분리가 아니라 **소유권(ownership)**이다: 모든 데이터에는 주인이 하나 있고, 주인이 아닌 자는 저장소에 직접 손대지 않는다 — 필요한 데이터는 주인의 API나 이벤트를 통해서만 얻는다. Step 09의 Amazon API 명령이 정확히 이 규칙이었고, 모듈러 모놀리스가 한 프로세스 안에서도 유지하는 규율이 이것이다(모듈별 스키마 분리, 남의 테이블 조인 금지). 소유권이 있으면 물리적 분리는 나중에 해도 되는 결정이 된다 — 또 한 번, 결정의 연기다.

대가도 정직하게: 주인이 아닌 자의 조인이 사라진다. “주문 + 회원 등급” 화면은 이제 SQL 조인이 아니라 API 조합 또는 복제본 조회다. 여기서 앞 장의 CAP 예시(정산이 든 주문 복제본)가 표준 구도가 되는 이유가 나온다 — 소유권 분리는 필연적으로 복제와 결과적 일관성을 부른다.

3. 두 경계의 충돌 — 트랜잭션 vs 아키텍처

이 장의 심장이다. 두 종류의 경계가 있다.

  • 트랜잭션 경계: “여기까지는 원자적으로, 전부 아니면 전무” — Step 07의 결론대로, 잘 설계된 시스템에서 이것은 애그리거트 하나다.
  • 아키텍처 경계: 모듈/서비스/컨텍스트의 경계 — 변경과 배포의 단위.

충돌은 업무 하나가 두 경계에 걸칠 때 온다. “주문 확정 시 정산 원장에 기록한다” — 업무는 원자성을 원하는데, 주문과 정산은 서로 다른 경계(다른 소유권, 어쩌면 다른 DB)다. 선택지는 둘뿐이다. 경계를 다시 긋거나(둘이 정말 한 트랜잭션이어야 한다면 같은 애그리거트/같은 서비스여야 한다는 신호다 — 경계 설계로 돌아가라), 아니면 원자성을 포기하고 Saga로(앞 장) 결과적 정합을 설계하거나. 기술적으로는 제3의 길이 존재한다 — 앞 장에서 스친 2단계 커밋(2PC/XA)으로 경계를 넘는 원자성을 만드는 것. 그러나 그것은 조정자 블로킹, 참여자 간 가용성 결합, 낮은 확장성, 그리고 현대 브로커·저장소 다수의 XA 미지원이라는 값을 물리학 청구서로 되돌려받는다. 그래서 실무의 메뉴는 사실상 둘이다.

Saga를 고르는 순간 악마는 디테일에 산다. 가장 유명한 함정이 이중 쓰기(dual write) 문제다: “주문을 DB에 커밋하고, 주문확정 이벤트를 브로커에 발행한다” — 이 두 쓰기는 서로 다른 시스템이라 하나의 트랜잭션으로 묶이지 않는다. 커밋 후 발행 전에 프로세스가 죽으면? 주문은 있는데 정산은 영원히 모른다. 표준 처방이 **트랜잭셔널 아웃박스(transactional outbox)**다: 이벤트를 브로커가 아니라 같은 DB의 outbox 테이블에, 업무 데이터와 한 트랜잭션으로 커밋한다. 별도의 릴레이(또는 CDC — DB의 변경 로그를 구독하는 Debezium류)가 outbox를 읽어 브로커로 옮긴다. 원자성이 필요한 두 쓰기를 “한 DB에 두 행”으로 바꿔치기한 것 — 문제를 푼 게 아니라 원자성이 성립하는 곳으로 옮긴 것이다.

그리고 수신 측의 규율, 멱등성(idempotency). 앞 장의 “모른다”(타임아웃) 문제로 발신자는 재시도할 수밖에 없고, 아웃박스 릴레이도 “최소 한 번(at-least-once)” 전달이 기본이다. 따라서 같은 메시지가 두 번 온다는 것은 예외가 아니라 전제다. 수신자는 멱등해야 한다 — 같은 정산 기록 요청이 두 번 와도 원장에는 한 번만 적히도록(멱등 키, 처리 이력 테이블). 분산의 물리학이 코드 규율로 착지하는 지점이다.

마지막 규율 하나 — 경계 밖에서 소비되는 스키마(공개 API, 이벤트)의 변경은 확장-수축(expand-contract) 순서로 간다: 새 필드를 먼저 더하고, 소비자들이 옮겨간 것을 확인한 뒤에 옛것을 거둔다. 한 번에 바꾸는 것은 소유권 경계 너머의 모든 소비자를 동시에 깨뜨리겠다는 선언이다.

4. 폴리글랏 퍼시스턴스 — 저장소도 적재적소

소유권이 갈라지면 부수 효과로 자유가 하나 생긴다: 저장소를 도메인마다 다르게 골라도 된다. Fowler와 Sadalage가 **폴리글랏 퍼시스턴스(polyglot persistence)**라 부른 것이다(『NoSQL Distilled』, 2012). 주문·정산은 관계형(트랜잭션·정합성), 상품 검색은 검색 엔진, 세션·랭킹은 인메모리 저장소, 조회 화면은 CQRS 읽기 모델(앞 장) — 요구가 다르면 도구도 다르게.

청구서: 저장소마다 운영·백업·장애 모드·전문성이 필요하고, 무엇보다 정합성의 표면적이 넓어진다 — 원본과 검색 인덱스, 원본과 캐시, 원본과 읽기 모델 사이 전부가 3절의 동기화 문제(아웃박스/CDC/멱등성)를 반복한다. “저장소 추가”의 실제 비용은 라이선스가 아니라 동기화 경로다.

5. 이벤트 소싱 — 상태 대신 사건을 저장하기

앞 장에서 CQRS와 분리해둔 개념을 이제 제대로 보자. 이벤트 소싱(event sourcing): 현재 상태(잔액=8,000)를 저장하는 대신 일어난 사건들(+10,000 입금, -2,000 출금)을 불변의 추가 전용(append-only) 로그로 저장하고, 상태는 사건의 재생(replay)으로 얻는다.

이 아이디어가 낯설다면, 사실은 그 반대다 — 인류에서 가장 오래된 데이터 모델이다. 회계의 원장, 즉 추가 전용 분개장이 정확히 이것이다: 회계사는 잔액을 덮어쓰지 않는다. 거래를 한 줄씩 적고, 잔액은 그 합산이다. 여기에 복식부기의 관행이 겹친다 — 잘못된 거래는 지우는 게 아니라 반대 분개(역분개)로 상쇄한다. Saga의 보상 트랜잭션(앞 장)이 회계에서는 수백 년 된 실무였던 셈이다.

무엇을 얻는가. 완전한 감사 추적 — “왜 잔액이 8,000인가”에 모든 사건으로 답할 수 있다(금융 규제 환경에서는 이것이 선택이 아니라 요구사항인 경우가 많다). 시간 여행 — 지난달 말 기준 상태의 재구성. 디버깅과 재처리 — 버그 수정 후 사건 재생으로 상태 복구. 그리고 도메인 이벤트가 저장 모델 그 자체이므로 “상태 쓰기”와 “이벤트 쓰기”가 하나의 append로 합쳐진다 — 이중 쓰기의 불일치 위험이 구조적으로 사라진다. (외부 전파에는 여전히 로그 구독이 필요하지만, 이벤트 로그 자체가 아웃박스 역할을 하므로 별도 아웃박스가 불필요해진다.)

무엇을 지불하는가. 이벤트 스키마의 진화 — 5년치 저장된 사건의 형식을 바꾸는 일은 테이블 마이그레이션보다 어렵다(버저닝, 업캐스팅). 조회의 복잡성 — “상태가 X인 주문 목록”은 재생으로 답할 수 없어 결국 CQRS 읽기 모델(프로젝션)이 사실상 필수가 되고, 그 프로젝션은 결과적 일관성이다. 그리고 팀의 전문성 — 이 모델로 사고하는 훈련. 그래서 정직한 지침은: 시스템 전체가 아니라 감사 추적이 본질 요구인 애그리거트에만(원장, 계약, 정산) 선별 적용하는 것이 보통 옳다.

6. 금융 도메인 종합 — 정산 시스템이라는 시험장

이 장의 도구를 전부 꺼내는 사례로 닫자. 주문·결제·정산이 분리된 시스템에서 “일 마감 정산”을 설계한다.

  • 우선순위(Step 04): 무결성 > 가용성. 정산이 한 시간 늦는 것은 사고가 아니지만, 1원이 안 맞는 것은 사고다. CAP의 갈림길에서 정산은 C를 고른다.
  • 경계(3절): 주문 확정과 원장 기입은 다른 소유권 — 원자성 대신 아웃박스 + 멱등 수신으로 “최소 한 번 + 멱등 = 정확히 한 번 효과”를 만든다.
  • 원장(5절): 추가 전용, 수정은 역분개로만. 이벤트 소싱을 쓰지 않더라도 원장 테이블만큼은 이벤트 소싱의 규율(불변, 상쇄)을 따른다.
  • 보고와 분석(4절의 귀결): 소유권 분리가 만든 “전사를 가로지르는 조회” 문제는 운영계에 조인을 되살려 풀지 않는다 — CDC로 웨어하우스/레이크에 모아 운영계(OLTP)와 분석계(OLAP)를 분리해 푼다. 규제 보고가 무거운 금융에서는 사실상 필수 구도다.
  • 대사(對査, reconciliation): 그리고 업계가 수십 년 전부터 해온 것 — 마감 때 내 기록과 상대(PG사, 은행)의 기록을 맞춰보고 차이를 계정으로 관리한다. 대사는 결과적 일관성의 실무 버전이다: “불일치는 발생한다”를 전제로, 수렴을 검증하고 강제하는 절차를 시스템에 내장한 것. 분산 시스템 이론이 정식화하기 훨씬 전부터, 돈을 다루는 업계는 결과적 일관성과 사는 법을 알고 있었다.

이 사례가 보여주는 것: 데이터 아키텍처의 답은 기술 카탈로그가 아니라 도메인의 무결성 요구에서 역산된다는 것이다.


7. 흔한 오해 바로잡기

오해 1: “공유 DB가 가장 빠른 통합이다.” 단기적으로 참이라서 위험하다. 지불은 나중에, 스키마가 암묵적 공용 API로 굳은 뒤에 온다. 통합이 필요하면 계약(API/이벤트)으로 하라 — 계약은 버전 관리라도 된다. 스키마 결합은 버전조차 없다.

오해 2: “서비스별 DB = DB 인스턴스를 나누는 것.” 본질은 인프라가 아니라 소유권이다. 인스턴스가 하나여도 스키마/접근 권한으로 소유권이 지켜지면 성립하고, 인스턴스를 갈라도 남의 데이터를 CDC로 통째로 복제해 조인하고 있다면 결합은 그대로다.

오해 3: “정합성은 DB가 지켜준다.” 트랜잭션 경계 에서만 참이다. 경계를 넘는 순간(두 애그리거트, 두 서비스, DB와 브로커) 정합성은 DB의 일이 아니라 설계의 일이 된다 — 아웃박스, 멱등성, 대사. “트랜잭션 걸면 되죠”가 통하지 않는 곳부터가 아키텍처다.

오해 4: “이벤트 소싱은 마이크로서비스/CQRS의 필수 짝이다.” 셋 다 독립이다. 모놀리스 안의 원장 하나에만 이벤트 소싱을 쓸 수 있고, 이벤트 소싱 없는 CQRS(읽기 테이블)가 오히려 표준이다. 결합해서 쓰는 것은 선택이지 정의가 아니다.

오해 5: “이중 쓰기는 잘 짜면 안 생긴다.” 코드 품질의 문제가 아니라 물리학이다 — 서로 다른 두 시스템에 걸친 원자성은 존재하지 않는다(앞 장). 해법은 “더 조심하기”가 아니라 구조(아웃박스/CDC)다.


8. 요약, 그리고 다음 장으로

  • 코드 경계를 데이터 결합이 무효화한다 — 공유 DB는 공통 결합의 왕이자 분산 모놀리스의 발병 기전. 경계의 완성은 데이터 소유권이다.
  • 소유권 분리의 대가는 조인의 상실 — 복제와 결과적 일관성이 표준 구도가 된다.
  • 트랜잭션 경계(애그리거트) vs 아키텍처 경계: 업무가 두 경계에 걸치면 경계를 다시 긋거나 Saga로. 이중 쓰기는 아웃박스/CDC로 원자성이 성립하는 곳으로 옮기고, 수신은 멱등으로 — “최소 한 번 + 멱등 = 정확히 한 번 효과”.
  • 폴리글랏 퍼시스턴스의 실비용은 저장소가 아니라 동기화 경로다.
  • 이벤트 소싱은 원장·복식부기라는 수백 년 실무의 소프트웨어 판 — 감사 추적을 사고 스키마 진화·조회 복잡성을 지불한다. 필요한 애그리거트에만.
  • 금융의 지혜: 무결성 우선, 불변 원장, 역분개, 그리고 대사 — 결과적 일관성을 검증 절차로 내장한 오래된 실무.

다음 장 예고: Part 2의 마지막 질문이 남았다. 여기까지 배운 모든 경계와 규율은 — 세워둔 그날은 완벽하다. 그런데 6개월 뒤에도 그럴까? 아키텍처는 완성이 아니라 방어라는 것, 침식을 막는 자동화된 방어선(적합도 함수)과 함께, 진화적 아키텍처로 Part 2를 닫는다.


코드로 만지는 아웃박스·멱등성·원장

이 장의 핵심 세 개념은 말로 끝내기 아까우니 실제로 돌려보자. architecture 모듈의 data 패키지에 있고, 전부 실행되고 테스트가 돈다 — 저장소는 여느 때처럼 맵으로 흉내냈지만(요점은 저장 기술이 아니라 트랜잭션 경계다), 개념의 뼈대는 실물 그대로다.

이중 쓰기 vs 아웃박스. ConfirmationService는 두 경로를 나란히 제공한다.

// 위험한 길 — 두 시스템에 두 번 쓰기 (하나의 트랜잭션이 아니다) public void confirmNaive(String orderId, BigDecimal amount) { store.confirmOnly(orderId, amount); // 쓰기 1: DB broker.publish(...); // 쓰기 2: 브로커 — 여기서 죽으면 주문만 남고 이벤트는 유실 } // 안전한 길 — 주문과 이벤트를 한 트랜잭션으로 public void confirmWithOutbox(String orderId, BigDecimal amount) { OutboxEvent event = new OutboxEvent("evt-" + orderId, "OrderConfirmed", orderId, amount); store.confirmWithOutbox(orderId, amount, event); // 둘 다 커밋되거나 둘 다 안 되거나 }

DualWriteTest가 그 차이를 증명한다: naive 경로는 발행 직전 크래시(broker.failNextPublish())에서 주문은 저장됐는데 이벤트는 영영 유실되고, 아웃박스 경로는 같은 크래시에도 이벤트가 DB(아웃박스)에 남아 릴레이의 재시도로 결국 발행된다. “이중 쓰기는 코드 품질이 아니라 물리학”이라는 오해 5가 테스트로 시연된다.

최소 한 번 + 멱등 = 정확히 한 번 효과. SettlementLedger.on()eventId로 중복을 거른다.

public void on(OutboxEvent event) { if (processedEventIds.contains(event.eventId())) return; // 이미 처리 — 멱등 processedEventIds.add(event.eventId()); entries.add(new LedgerEntry(event.orderId(), event.amount(), "SETTLEMENT")); }

IdempotencyTest는 브로커가 같은 이벤트를 세 번 전달해도(redeliver) 원장에 한 줄만 남음을 확인한다.

추가 전용 원장과 역분개. 같은 SettlementLedger가 이벤트 소싱의 규율을 품는다 — 잔액은 저장된 값이 아니라 entries의 합이고, 정정은 UPDATE가 아니라 반대 부호의 행 추가다.

public void reverse(String orderId, BigDecimal amount) { entries.add(new LedgerEntry(orderId, amount.negate(), "REVERSAL")); // 원본은 그대로 } public BigDecimal balance() { return entries.stream().map(LedgerEntry::amount).reduce(BigDecimal.ZERO, BigDecimal::add); }

LedgerTest는 정정 후 잔액이 0으로 상쇄되면서도 두 행(SETTLEMENT + REVERSAL)이 모두 남아 감사 추적이 보존됨을 확인한다 — 회계사가 잔액을 덮어쓰지 않는 수백 년의 규율이 코드 열 줄이 된 것이다.

경로내용
architecture/src/main/java/org/hyeonqz/architecture/data/OrderStore(트랜잭셔널 아웃박스), EventBroker(실패·중복 주입), OutboxRelay, SettlementLedger(멱등·추가 전용·역분개), ConfirmationService
architecture/src/test/java/org/hyeonqz/architecture/data/DualWriteTest, IdempotencyTest, LedgerTest

실행: ./gradlew :architecture:test


원전

연도문헌이 장에서의 역할
2004~Fowler, IntegrationDatabase (bliki)통합(공유) DB의 병리
2005Fowler, Event Sourcing (bliki)이벤트 소싱 정리 (Young의 실무화와 함께)
2011/2012Fowler, PolyglotPersistence (bliki) / Fowler & Sadalage, NoSQL Distilled폴리글랏 퍼시스턴스
2015Newman, Building Microservices공유 DB 안티패턴, 서비스별 데이터
2018Richardson, Microservices Patterns (microservices.io)database per service, 트랜잭셔널 아웃박스, Saga 실무화
Last updated on