5편은 분산 시스템과 MSA 아키텍처입니다. 면접관은 패턴 이름을 아는지보다 언제 도입하고 어떤 비용을 감수했는지를 봅니다.

시리즈 구성:

  • 1편: 인프라/스케일링
  • 2편: 운영/안정성
  • 3편: 설계/리더십
  • 4편: 동시성/런타임
  • 5편 (현재): 분산/MSA 아키텍처
  • 6편 (예정): 가용성/안정성 패턴

1. SAGA 패턴

Q1. MSA에서 트랜잭션을 어떻게 처리하시나요?

기대 답변: 2PC는 락이 길어져 부적합하기 때문에 SAGA(보상 트랜잭션 릴레이)를 씁니다.

  • Choreography: 각 서비스가 이벤트를 듣고 자기 단계 처리
  • Orchestration: 중앙 조정자가 단계별 호출

선택 기준은 서비스 수와 흐름 복잡도입니다. 단순 흐름은 Choreography, 가시성·복구가 중요하면 Orchestration.

💡 실제 사례 보기

시나리오: 항공 예약 흐름 — 결제 → 좌석 확보 → 마일리지 적립 → 항공사 발권 4단계 분산 서비스.

Orchestration SAGA 설계:

SagaOrchestrator
  step 1: 결제 (Payment Service)
    compensate: 환불
  step 2: 좌석 확보 (Seat Service)
    compensate: 좌석 해제
  step 3: 마일리지 적립 (Mileage Service)
    compensate: 마일리지 차감
  step 4: 항공사 발권 (Airline Adapter)
    compensate: 발권 취소 요청

실제 장애 사례 — 마일리지 적립 실패:

시점 이벤트
T+0s 결제 성공
T+1s 좌석 확보 성공
T+2s 마일리지 서비스 timeout
T+3s Orchestrator가 step 1, 2 보상 시작
T+3s 좌석 해제 성공
T+4s 환불 호출 실패 (PG사 일시 장애)
T+4s 환불 retry 3회 모두 실패 → DLQ로 격리
T+5s 운영 알람 발생 (PagerDuty)

DLQ 처리:

  • 운영 콘솔에서 사가 ID로 조회 → 보상 실패 단계 확인
  • 멱등키로 환불 재실행 (PG사 복구 후 수동 트리거)
  • 정합성 회복 시간: 평균 15분, 최대 2시간

Choreography로 했다면?

  • 같은 시나리오에서 누가 보상을 책임지는지 모호
  • 이벤트 흐름 디버깅이 어려워 RCA 시간 3배 증가
  • 운영 가시성 = Orchestration이 압도적

교훈:

  • 단계가 4개 넘고 보상이 복잡하면 Orchestration
  • 보상 자체가 실패할 수 있음을 가정 → DLQ + 운영 콘솔이 SAGA의 절반
  • 사가 ID 기반의 모든 단계 추적이 운영의 마지막 보루

🔄 꼬리질문 1: 보상 트랜잭션 자체가 실패하면 어떻게 하나요?

기대 답변:

  • 재시도 정책 (지수 백오프 + jitter, 최대 N회)
  • **DLQ(Dead Letter Queue)**로 격리, 운영 알람
  • 멱등키 기반 수동 재처리 콘솔

핵심은 eventual consistency 윈도우를 명시적으로 정의하는 것입니다.

📋 실제 사례

상황: 항공 예약 SAGA에서 마일리지 적립 실패 → 결제 환불 보상이 트리거됐는데, 하필 그 순간 PG사가 일시 장애라 환불(보상) 자체가 3번 모두 실패. “결제는 됐는데 예약은 안 된” 돈만 묶인 상태.

조치 — 보상 실패를 또 가정한 안전망:

  • 보상 retry(지수 백오프 + jitter) 3회 소진 시 → DLQ로 격리 + 즉시 PagerDuty 알람
  • 운영 콘솔에서 사가 ID로 조회 → 어느 단계 보상이 실패했는지 한눈에
  • PG사 복구 후 멱등키로 환불 재실행 (수동 트리거, 중복 환불 방지)
  • eventual consistency 윈도우를 “최대 2시간”으로 SLA 명시

결과:

  • “돈만 묶인” 상태가 평균 15분(최대 2시간) 내 정합성 회복
  • 보상 실패가 조용히 유실되지 않고 항상 추적·복구됨

교훈: SAGA 설계의 절반은 “보상도 실패한다”는 가정이다. DLQ + 운영 콘솔(사가 ID 추적) + 멱등 재실행이 없으면 보상 실패가 조용히 유실된다. eventual consistency 윈도우를 SLA로 명시해야 비즈니스가 신뢰한다.

🔄 꼬리질문 2: Choreography의 단점은?

기대 답변:

  • 흐름이 코드 여러 곳에 흩어져 추적 어려움
  • 새 단계 추가 시 의존 서비스 모두 인지해야
  • 분산 트레이싱 없으면 디버깅 비용 폭증

→ 흐름이 5단계 넘으면 Orchestration으로 전환 권장.

📋 실제 사례

상황: 주문 처리를 Choreography(각 서비스가 이벤트 듣고 다음 이벤트 발행)로 시작. 흐름이 7단계로 늘어나자, “주문이 어디서 멈췄는지” 추적하는 데만 1시간씩 걸리고, 새 단계 추가 때마다 어떤 서비스가 영향받는지 아무도 전체 그림을 모름.

진단:

  • 흐름이 코드 7곳에 흩어져 있어 전체 시퀀스를 한눈에 볼 수 없음
  • “이벤트 A 다음에 뭐가 오는지”가 각 서비스에 암묵적으로만 존재
  • 장애 시 멈춘 지점 추적이 7개 서비스 로그 뒤지기

조치 — Orchestration으로 전환:

  • 중앙 Orchestrator가 전체 시퀀스를 명시적으로 보유 (step 1~7)
  • 사가 ID 기반 상태 대시보드로 “지금 어느 단계인지” 실시간 표시
  • 새 단계 추가는 Orchestrator 한 곳만 수정

결과:

  • 멈춘 지점 추적: 1시간 → 1분 (대시보드 즉시 확인)
  • 새 단계 추가 영향 범위가 명확해짐

교훈: Choreography는 2~3단계 단순 흐름엔 결합도가 낮아 좋지만, 단계가 늘수록 “전체 흐름을 아무도 모르는” 추적 지옥이 된다. 흐름이 5단계 넘고 가시성·복구가 중요하면 Orchestration이 운영 비용을 압도적으로 줄인다.

🔄 꼬리질문 3: SAGA vs Outbox 패턴 차이는?

기대 답변:

  • SAGA: 분산 트랜잭션 흐름 패턴 (보상)
  • Outbox: 로컬 트랜잭션과 이벤트 발행을 원자적으로 묶는 패턴

둘은 보완 관계입니다. SAGA 각 단계에서 이벤트 발행의 신뢰성을 Outbox로 보장.

📋 실제 사례

상황: SAGA를 도입했는데도 간헐적으로 다음 단계가 진행 안 되는 사고. 결제는 성공했는데 좌석 확보 이벤트가 발행 안 돼 SAGA가 멈춤. “SAGA를 썼는데 왜 멈추지?”

진단:

  • SAGA 각 단계가 로컬 트랜잭션 커밋 + Kafka 이벤트 발행을 따로 했음
  • 결제 트랜잭션은 커밋됐는데 직후 Kafka 발행이 실패(네트워크 순단) → 다음 단계 이벤트가 영영 안 감
  • SAGA(흐름)는 맞았지만 각 단계의 이벤트 발행 신뢰성에 구멍

조치 — SAGA에 Outbox 결합:

  • 각 단계에서 로컬 트랜잭션 안에 outbox 테이블 INSERT (커밋과 원자적)
  • 별도 릴레이가 outbox를 폴링해 Kafka 발행 (실패 시 재시도)
  • SAGA(흐름) + Outbox(발행 신뢰성)를 함께 적용

결과:

  • 단계 간 이벤트 유실로 인한 SAGA 멈춤 0건
  • 발행 실패가 있어도 릴레이가 결국 재발행

교훈: SAGA와 Outbox는 경쟁이 아니라 보완이다. SAGA는 “흐름과 보상”, Outbox는 “각 단계 이벤트 발행의 원자성/신뢰성” 을 담당한다. SAGA만 쓰고 이벤트 발행을 트랜잭션 밖에서 하면 단계 간 유실로 흐름이 멈춘다.


2. CQRS

Q2. CQRS는 언제 도입하시나요?

기대 답변: 명령(Command)과 조회(Query)의 데이터 모델·저장소 요구가 달라질 때입니다.

  • 조회 쪽이 복잡 집계·검색이고 쓰기보다 빈도가 훨씬 높음
  • 쓰기 모델이 도메인 규칙으로 무거운데 조회 응답속도 SLO가 엄격

도입 비용은 프로젝션 갱신 지연(eventual consistency)과 운영 복잡도입니다.

💡 실제 사례 보기

시나리오: 셀러 대시보드 — 정규화된 OLTP DB에서 매번 5개 테이블 조인 + GROUP BY. 응답 8초, 셀러 1만 명 동시 접속 시 DB 멈춤.

문제 분석:

  • 쓰기 모델은 정규화가 옳음 (주문 INSERT는 단일 row)
  • 조회 모델은 셀러별 일/주/월 집계가 필요
  • 같은 테이블이 두 요구를 동시에 만족 불가

CQRS 도입:

Write side (기존 유지):

  • PostgreSQL OLTP
  • 주문·결제·환불 정규화 모델

Read side (신규):

  • ClickHouse (OLAP)
  • 셀러별 일별 집계 테이블 (pre-aggregated)
  • 응답 50ms

프로젝션 파이프라인:

Order Service (PG) 
  → Debezium CDC
  → Kafka topic: order.events
  → Kafka Streams aggregator
  → ClickHouse

일관성 윈도우:

  • 정상: 2~5초 lag
  • 피크: 15초 lag
  • 셀러 화면에 “데이터 갱신: 방금 전” 표시

Read Your Writes 처리:

  • 셀러가 방금 자기 행동을 봐야 하는 화면(예: “방금 등록한 상품”)은 OLTP 직접 조회
  • 집계 화면만 ClickHouse 사용

결과:

  • 대시보드 응답: 8초 → 50ms (160배)
  • OLTP CPU: 80% → 25%
  • 셀러 만족도 NPS +12점

비용:

  • ClickHouse + Kafka + Debezium 인프라
  • 운영 복잡도 (lag 모니터링, 재처리 시나리오, 스키마 동기화)
  • 팀의 학습 곡선 약 2개월

교훈:

  • CQRS는 단순 분리가 아니라 조회 모델 재설계 기회
  • 일관성 윈도우를 사용자가 인지할 수 있게 UI에 표시
  • 도입 비용이 크므로 진짜 필요한 도메인만 (전체 시스템 X)

🔄 꼬리질문 1: 일관성 윈도우는 어떻게 다루나요?

기대 답변:

  • 사용자 화면에서 자기 변경 사항은 즉시 보여야 함 → Read Your Writes: 쓰기 직후 잠시 마스터에서 조회
  • 외부 알림에는 프로젝션 완료 후 전송
  • 운영 메트릭으로 lag(p99) 지표화
📋 실제 사례

상황: CQRS 도입 후 셀러가 상품을 등록하고 바로 대시보드를 새로고침하면, ClickHouse 프로젝션 lag(5초) 때문에 방금 등록한 상품이 안 보임. 셀러가 “등록이 안 됐나?” 혼란스러워하며 중복 등록.

진단:

  • 집계는 ClickHouse가 빠르지만 방금 한 본인 행동은 lag 때문에 아직 반영 안 됨
  • “남의 데이터 5초 stale”은 무해하나 “내 데이터가 안 보이는 것” 은 사용자가 즉시 인지

조치 — Read Your Writes:

  • 셀러가 방금 자기가 등록/수정한 것을 봐야 하는 화면은 OLTP(원본)에서 직접 조회
  • 전체 집계·통계 화면만 ClickHouse 사용
  • 화면에 “집계 데이터: 약 5초 전 기준” 라벨 표시 (lag을 사용자에게 투명하게)

결과:

  • “방금 등록한 게 안 보임” 혼란·중복 등록 소멸
  • 집계 성능 이점은 그대로 유지

교훈: Eventual Consistency에서 “남의 데이터 stale”은 괜찮지만 “내 행동이 즉시 안 보이는 것”은 치명적이다. Read Your Writes(본인 행동만 원본에서 조회)로 풀고, lag을 UI에 투명하게 표시해 신뢰를 지킨다.

🔄 꼬리질문 2: Event Sourcing과 같이 가야 하나요?

기대 답변: 아닙니다. CQRS는 모델 분리, Event Sourcing은 상태 표현입니다.

  • CQRS만 도입: 일반 RDB + 별도 read DB
  • ES까지 도입: 감사/재구성이 필요할 때만 (운영 부담 큼)

대부분은 CQRS만 도입해도 효과가 충분합니다.

📋 실제 사례

상황: CQRS를 도입하면서 “이왕 하는 거 Event Sourcing도 같이 가자” 는 의견이 강했음. 둘이 자주 세트로 언급되니 당연히 함께 가야 하는 줄 알았음.

판단 — 요구사항 재검토:

  • 우리 조회 성능 문제는 CQRS(읽기 모델 분리)만으로 완전히 해결됨
  • Event Sourcing이 주는 감사·과거 재현 가치는 결제 외 도메인엔 요구사항이 없었음
  • ES는 프로젝션·스냅샷·리플레이 운영 부담이 큼 → 불필요한 복잡도

결정:

  • CQRS만 도입 (OLTP + ClickHouse read 모델)
  • Event Sourcing은 명시적으로 거부 (ADR에 “왜 안 했는지” 기록)
  • 단, 결제 도메인은 별도로 ES 적용 (감사 요구가 진짜 있어서)

결과:

  • 조회 성능 목표 달성 + 운영 단순성 유지
  • “멋있어 보여서” 도입했을 ES 운영 부담을 사전 회피

교훈: CQRS와 ES는 세트가 아니다. CQRS는 “읽기/쓰기 모델 분리”, ES는 “상태를 이벤트로 표현” — 별개 결정이다. 대부분 CQRS만으로 충분하고, ES는 감사·이력 재현이 진짜 요구사항인 도메인에만. 멋있어 보여서 ES를 얹으면 운영이 무너진다.

🔄 꼬리질문 3: 프로젝션 갱신은 어떻게 안정화하나요?

기대 답변:

  • CDC(Debezium 등)로 카프카에 변경 흘리고 컨슈머가 read DB 갱신
  • 멱등 처리 + 순서 보장(파티션 키 = aggregate id)
  • 실패 시 재처리 가능하도록 오프셋·DLQ 관리
📋 실제 사례

상황: 프로젝션 컨슈머가 배포·재시작될 때마다 일부 집계가 틀어지거나 중복 반영되어 ClickHouse 수치가 OLTP와 안 맞는 사고가 반복.

진단:

  • 컨슈머가 at-least-once라 재시작 시 일부 이벤트를 다시 처리 → 집계 중복 가산
  • 순서 보장 안 됨 → 주문 취소주문 생성보다 먼저 반영되는 경우도

조치 — CDC + 멱등 + 순서 보장:

  • Debezium CDC로 OLTP 변경을 Kafka로 (변경 자체를 빠짐없이)
  • 파티션 키 = order_id같은 주문 이벤트는 순서 보장
  • 컨슈머는 이벤트 ID 기반 멱등 처리 (이미 반영한 이벤트는 skip) + 오프셋 관리
  • 틀어지면 특정 시점부터 재처리 가능하도록 오프셋 리셋 절차 마련

결과:

  • 배포·재시작에도 ClickHouse-OLTP 수치 정합성 유지
  • 문제 발생 시 오프셋 되감아 재처리로 복구 가능

교훈: 프로젝션 안정화의 3요소는 (1) CDC로 변경 빠짐없이 캡처, (2) aggregate id 파티션 키로 순서 보장, (3) 이벤트 ID 멱등 처리다. at-least-once 환경에선 멱등성 없이는 재시작마다 집계가 틀어진다. 재처리 가능한 오프셋 관리가 복구의 핵심.


3. API Gateway 패턴

Q3. API Gateway에 어디까지 책임을 두시겠어요?

기대 답변: 횡단 관심사만 둡니다.

  • 인증·인가, 레이트 리밋, 라우팅, 응답 캐싱, 관측(trace ID 부여)

비즈니스 로직·집계는 두지 않습니다. BFF가 필요하면 별도 레이어로.

💡 실제 사례 보기

시나리오: Spring Cloud Gateway 도입 후 6개월. 외부 API 80개를 게이트웨이 단일 진입점으로 통합. 어느 날 게이트웨이가 단일 장애점이 됨.

장애 발생:

  • 한 백엔드 서비스의 응답이 30초로 지연
  • 게이트웨이의 reactor netty 이벤트 루프 점유율 100%
  • 무관한 80개 API 전부 5xx

원인:

  • 게이트웨이에 서킷 브레이커 없음
  • 한 백엔드의 지연이 게이트웨이 자체 자원을 잠식

대응 (긴급 → 영구):

긴급 (장애 중):

  • 문제 백엔드 라우팅을 임시 차단 (spring.cloud.gateway.routes 필터)
  • 게이트웨이 리스타트 → 자원 회복

영구 (1주 작업):

  1. Resilience4j 서킷 브레이커 모든 라우트에 적용
    • 임계치: 에러율 30%, 슬로우 콜 비율 50% (p99 > 2s)
  2. Bulkhead: 백엔드별 동시성 상한 (semaphore-based)
  3. 인스턴스 다중화: 2 → 6, 무상태 + L7 LB
  4. JWT 검증 캐시: ID provider 의존도 축소 (Redis 5분 TTL)

관측 강화:

  • 라우트별 p99, 에러율 대시보드
  • 서킷 Open 발생 시 Slack 알림
  • 인증 캐시 hit ratio 모니터링

결과:

  • 다음 백엔드 지연 발생 시 해당 라우트만 격리, 나머지 정상
  • 인증 캐시로 ID provider QPS 90% 감소

교훈:

  • 게이트웨이는 편의 도구가 아니라 모든 의존성의 집중점
  • 서킷 브레이커 + Bulkhead + 다중화가 게이트웨이의 3종 세트
  • “게이트웨이를 도입했는데 회복력이 줄었다”는 신호면 즉시 점검

🔄 꼬리질문 1: 단일 장애점 위험은 어떻게 줄이나요?

기대 답변:

  • 다중 인스턴스 + 무상태 + L4/L7 LB
  • 게이트웨이 자체에 서킷 브레이커
  • 인증 캐시(JWT 검증 결과)로 ID provider 의존도 축소
📋 실제 사례

상황: 게이트웨이를 2대로 운영 중 한 대가 OOM으로 다운되자, 남은 1대로 트래픽이 몰려 연쇄 다운 → 전체 서비스 입구가 마비. 게이트웨이가 모든 트래픽의 SPOF였음.

조치 — 다중화 + 무상태 + 캐시:

  • 게이트웨이를 2 → 6대로 확장, 완전 무상태로 유지 (세션·로컬 상태 제거) → L7 LB가 자유롭게 분산
  • 한 대 다운 시 나머지가 여유롭게 흡수하도록 capacity를 평소 60%로 운영 (peak 대비 헤드룸)
  • JWT 검증 결과를 Redis 5분 캐시 → 매 요청마다 ID provider 호출하던 부하 90% 감소 (ID provider도 SPOF였음)

결과:

  • 한 대 다운에도 트래픽 영향 0
  • ID provider 부하 급감으로 그쪽 장애 위험도 동반 해소

교훈: 게이트웨이 SPOF는 (1) 무상태 + 다중화(헤드룸 포함), (2) 자체 서킷 브레이커, (3) 인증 결과 캐시로 ID provider 의존 축소 로 푼다. 게이트웨이도 마이크로서비스처럼 수평 확장 가능해야 하고, 뒤에 숨은 SPOF(ID provider) 까지 같이 봐야 한다.

🔄 꼬리질문 2: 게이트웨이가 병목이 될 때 어떤 신호를 보나요?

기대 답변:

  • 게이트웨이 p99 vs 백엔드 p99 격차
  • 커넥션 풀 사용률, keep-alive 재사용률
  • TLS 핸드셰이크 비율(0-RTT/session resumption 적용 효과)
📋 실제 사례

상황: 사용자 체감 응답이 느린데 각 백엔드 서비스는 p99 100ms로 정상. “백엔드는 빠른데 왜 느리지?” 하는 미스터리.

진단 — 게이트웨이 vs 백엔드 격차:

  • 게이트웨이 입구 p99 = 800ms, 백엔드 p99 = 100ms → 700ms가 게이트웨이에서 소모
  • reactor netty 이벤트 루프 점유율 100% (한 느린 백엔드가 이벤트 루프를 잠식)
  • 그 700ms 동안 다른 정상 라우트까지 같이 느려짐

조치:

  • 라우트별 Bulkhead(세마포어) 로 백엔드별 동시성 상한 → 한 백엔드 지연이 이벤트 루프 전체를 못 먹게 격리
  • 게이트웨이 p99 vs 백엔드 p99 격차를 상시 대시보드 지표로

결과:

  • 한 백엔드 지연 시에도 게이트웨이 p99 정상 유지 (격리)
  • “백엔드는 빠른데 느린” 미스터리 소멸

교훈: 게이트웨이 병목의 가장 명확한 신호는 게이트웨이 p99 vs 백엔드 p99의 격차다. 격차가 크면 게이트웨이 자체(이벤트 루프·커넥션 풀) 가 병목. 라우트별 Bulkhead로 한 느린 백엔드가 전체를 못 먹게 격리한다.

🔄 꼬리질문 3: 게이트웨이 vs Service Mesh, 어떻게 나누나요?

기대 답변:

  • 게이트웨이: 외부 트래픽 입구 (north-south)
  • Service Mesh: 내부 서비스 간 (east-west) — mTLS, 재시도, 트래픽 분할

둘은 보완. 작은 조직은 게이트웨이만으로 시작, 서비스 50+ 넘으면 Mesh 검토.

📋 실제 사례

상황: 서비스가 60개로 늘어나자, 서비스 간 통신에 mTLS·재시도·타임아웃을 각 서비스가 코드로 제각각 구현. 일관성이 없어 어떤 서비스는 재시도가 없고, 어떤 서비스는 mTLS가 빠져 보안 감사에서 지적.

판단 — north-south vs east-west:

  • 외부 → 내부 (north-south): 인증·레이트리밋·라우팅 → API Gateway 유지
  • 내부 → 내부 (east-west): mTLS·재시도·타임아웃·트래픽 분할 → Service Mesh(Linkerd) 도입

조치:

  • Linkerd 사이드카 주입 → 모든 서비스 간 통신에 자동 mTLS + 재시도 + 타임아웃
  • 각 서비스 코드에서 통신 신뢰성 로직 제거 (Mesh가 일괄 처리)
  • 게이트웨이는 외부 입구 역할만 유지

결과:

  • 서비스 간 통신 전부 자동 mTLS → 보안 감사 통과
  • 재시도·타임아웃 정책이 일관 (코드가 아니라 Mesh 설정)
  • 인증서 회전도 자동

교훈: 게이트웨이와 Service Mesh는 대체가 아니라 역할 분담이다. 게이트웨이 = north-south(외부 입구), Mesh = east-west(내부 통신). 서비스가 적으면 게이트웨이만으로 시작하고, 50개 넘어 내부 통신 신뢰성을 코드로 관리하기 힘들어지면 Mesh가 운영 비용을 줄인다.


4. DDD와 Bounded Context

Q4. Bounded Context를 코드·팀·DB에 어떻게 반영하시나요?

기대 답변:

  • 코드: 모듈/패키지 경계 = 컨텍스트 경계. 다른 컨텍스트 객체 직접 import 금지
  • : 한 컨텍스트는 한 팀의 결정권
  • DB: 스키마 분리 (가능하면 인스턴스도). 공유 DB는 컨텍스트 경계를 깨뜨림
💡 실제 사례 보기

시나리오: 1만 명 규모 모놀리스에서 결제·정산 컨텍스트를 모듈러 모놀리스 형태로 분리.

분리 전 문제:

  • 결제 도메인 객체가 @Entity Order와 직접 결합
  • 정산 로직이 주문 테이블 조인으로 처리됨 → 정산 변경할 때마다 주문 모듈 영향
  • 결제 PR이 평균 12개 모듈 수정 (불필요한 범위 확산)

리팩토링 (3개월, 4단계):

Step 1 — 도메인 모듈 분리 (1개월)

  • domain/payment/ 패키지 신설
  • 결제 도메인 객체(Payment, Refund)를 ORM 엔티티에서 분리
  • 매핑은 별도 PaymentMapper에 격리

Step 2 — DB 스키마 격리 (1개월)

  • 결제 테이블을 별도 schema (payment.*)로 이전
  • Cross-schema FK 제거, 대신 ID 참조만
  • ACL(Anti-Corruption Layer)로 다른 컨텍스트와 통신

Step 3 — 모듈 경계 강제 (3주)

  • ArchUnit으로 컨텍스트 간 직접 import 금지 규칙 추가
  • CI에서 위반 시 빌드 실패
  • 통신은 명시적 API (예: PaymentFacade) 또는 도메인 이벤트

Step 4 — 팀 분리 (2주)

  • 결제 모듈 = 결제팀이 PR 승인권자
  • 다른 팀은 PaymentFacade만 사용, 내부 변경은 결제팀 책임

결과:

  • 결제 PR 평균 수정 모듈: 12개 → 2.5개
  • 결제 도메인 단위 테스트 가능 (외부 의존성 mock)
  • 향후 MSA 분리 시 코드 이동만으로 가능 (DB도 이미 분리됨)

예상치 못한 효과:

  • 결제팀이 온콜 부담 감소 (다른 모듈 장애에 안 깨도 됨)
  • 결제 도메인 ADR이 5개 작성됨 (그동안 암묵지로 흩어져 있었음)

교훈:

  • 컨텍스트 경계는 코드만으로는 부족, DB·팀까지 정렬되어야 진짜
  • 모놀리스 안에서도 DDD가 충분히 의미 있음 (MSA 갈 필요 없음)
  • ArchUnit 같은 자동 강제가 인간 규율보다 강력

🔄 꼬리질문 1: 컨텍스트 간 통신은 어떻게 하나요?

기대 답변:

  • 비동기 도메인 이벤트 우선
  • 동기 호출은 오픈 호스트 서비스 형태의 명시적 계약
  • 직접 DB 조회는 금지 (Anti-Corruption Layer로 격리)
📋 실제 사례

상황: 정산 모듈이 결제 데이터를 결제 테이블을 직접 조인해서 읽고 있었음. 결제팀이 내부 스키마를 리팩토링할 때마다 정산이 깨져, 결제팀이 자기 테이블 구조도 마음대로 못 바꾸는 상황.

진단:

  • 정산이 결제 내부 구현(테이블 구조) 에 직접 결합 → 컨텍스트 경계 붕괴
  • 결제의 모든 스키마 변경이 정산에 전파

조치 — 도메인 이벤트 + ACL:

  • 직접 조인 제거. 결제가 PaymentCompleted 도메인 이벤트를 발행
  • 정산은 그 이벤트만 구독해 자기 모델로 변환(Anti-Corruption Layer)
  • 결제 내부 스키마는 이벤트 계약만 유지하면 자유롭게 변경 가능

결과:

  • 결제팀이 내부 스키마를 자유롭게 리팩토링 (정산 영향 0)
  • 정산은 결제 내부를 모른 채 이벤트 계약만 의존

교훈: 컨텍스트 간 통신에서 직접 DB 조회/조인은 경계를 무너뜨린다. 비동기 도메인 이벤트 + ACL(받는 쪽이 자기 모델로 변환) 이 결합도를 결정적으로 낮춘다. 핵심은 내부 구현이 아니라 명시적 계약(이벤트)에만 의존하게 만드는 것.

🔄 꼬리질문 2: 도메인 모델과 ORM 엔티티가 같아야 하나요?

기대 답변: 규모에 따라 다릅니다.

  • 작은 도메인: 같아도 OK (@Entity = 도메인 객체)
  • 복잡 도메인: 분리 (도메인 객체 + 영속 엔티티 + 매핑). 운영 부담은 늘지만 도메인 규칙이 ORM에 오염되지 않음
📋 실제 사례

상황: 결제 도메인이 복잡해지면서 @Entity Payment 하나에 JPA 어노테이션 + 영속 관심사 + 복잡한 결제 도메인 규칙이 뒤섞임. 환불 규칙을 바꾸려는데 JPA lazy loading·cascade 때문에 의도치 않은 부작용이 자꾸 발생.

판단 — 도메인 복잡도에 비례한 분리:

  • 결제 도메인(복잡): 도메인 객체(Payment)와 영속 엔티티(PaymentEntity)를 분리 + Mapper
    • 도메인 규칙은 순수 객체에 → JPA 부작용 없이 단위 테스트
    • 영속 관심사는 엔티티에 격리
  • 알림 도메인(단순): @Entity 하나로 충분 (분리하면 오히려 보일러플레이트만 늘어남)

결과:

  • 결제 도메인 규칙을 JPA 오염 없이 자유롭게 변경·테스트
  • 단순 도메인은 과한 추상화 없이 빠르게 개발
  • 둘 다 정답 (도메인별로 다른 선택)

교훈: “도메인 모델 = ORM 엔티티” 통일은 도메인 복잡도에 따라 다르다. 단순 도메인은 같아도 충분, 복잡 도메인(규칙이 무거운 곳)은 분리해 도메인 규칙이 JPA lazy/cascade 부작용에 오염되지 않게 한다. 모든 도메인에 분리를 강제하면 보일러플레이트만 폭증한다.

🔄 꼬리질문 3: 마이크로서비스로 분리하는 기준은?

기대 답변: 컨텍스트 경계가 분명하고 다음 중 둘 이상이면 분리:

  • 배포 주기/팀이 다름
  • 스케일 요구가 다름 (예: 결제는 5배, 카탈로그는 1배)
  • 장애 격리 SLO가 다름

코드 모듈로 시작해 문제가 보일 때 분리하는 모듈러 모놀리스 → MSA 흐름이 안전합니다.

📋 실제 사례

상황: “MSA가 트렌드니 다 쪼개자”는 분위기에서, 처음부터 15개 마이크로서비스로 시작한 다른 팀이 분산 트랜잭션·네트워크 디버깅·배포 복잡도에 짓눌려 개발 속도가 급락하는 것을 목격. 우리는 다른 길을 택함.

판단 — 모듈러 모놀리스로 시작:

  • 처음엔 하나의 배포 단위(모놀리스) 안에서 모듈 경계만 강하게(ArchUnit 강제)
  • 1년 운영하며 진짜 분리가 필요한 신호를 관찰:
    • 결제: 배포 주기 다름(잦은 핫픽스) + 스케일 5배 + 장애 격리 SLO 엄격 → 3개 신호 → MSA 분리
    • 카탈로그·알림 등: 신호 1개 이하 → 모놀리스 유지

결과:

  • 결제만 MSA로 분리 (이미 모듈·DB가 분리돼 있어 코드 이동만으로 가능)
  • 나머지는 모놀리스 유지 → 분산 복잡도 회피
  • “전부 MSA”였다면 겪었을 운영 지옥을 회피

교훈: MSA 분리 기준은 (1) 배포 주기/팀, (2) 스케일 요구, (3) 장애 격리 SLO 중 둘 이상이 다를 때다. 처음부터 다 쪼개면 분산 복잡도에 짓눌린다. 모듈러 모놀리스로 시작 → 신호가 분명한 도메인만 분리가 안전하다. 모듈·DB를 미리 분리해두면 분리가 코드 이동만으로 끝난다.


5. 모놀리스 내 도메인 이벤트와 아웃박스

Q5. 같은 모놀리스 안 두 모듈을 직접 호출 대신 도메인 이벤트로 묶을 때 트랜잭션 경계는 어떻게 설계하나요?

기대 답변: 3축으로 정리합니다.

  1. 이벤트 발행 시점 제어: 커밋 후 발행 (@TransactionalEventListener AFTER_COMMIT)
  2. 유실 방지: Outbox 패턴 — 같은 트랜잭션에 outbox 테이블 insert, 별도 릴레이가 발행
  3. 실패 처리: 재시도 + DLQ + 보상 트랜잭션
💡 실제 사례 보기

시나리오: 주문 모듈 → 알림·재고·통계 3개 컨슈머. 평소 잘 돌다가 한 달에 2~3건 주문은 있는데 알림은 안 가는 사고 발생.

문제 코드 (Before):

@Transactional
fun createOrder(req: OrderRequest): Order {
  val order = orderRepo.save(Order(...))
  
  // 🚨 같은 트랜잭션 안에서 Kafka 발행
  kafkaTemplate.send("order.created", order.toEvent())
  
  return order
}

왜 유실되는가:

  • Kafka 발행은 성공했는데 트랜잭션 커밋이 실패 → 컨슈머는 이미 처리, DB에는 주문 없음
  • 또는 Kafka 발행은 실패했는데 트랜잭션은 커밋 → DB에 주문 있는데 알림 안 감
  • 분산 트랜잭션은 답이 없음

해결 — Outbox 패턴 (After):

@Transactional
fun createOrder(req: OrderRequest): Order {
  val order = orderRepo.save(Order(...))
  
  // 같은 트랜잭션 안에 outbox 테이블 insert (DB 보장)
  outboxRepo.save(OutboxEvent(
    aggregateId = order.id,
    eventType = "order.created",
    payload = order.toEvent().toJson(),
    status = "PENDING"
  ))
  
  return order
}

// 별도 스케줄러가 outbox를 폴링해 Kafka 발행
@Scheduled(fixedDelay = 1000)
fun relay() {
  val pending = outboxRepo.findByStatus("PENDING", limit = 100)
  pending.forEach { event ->
    try {
      kafkaTemplate.send(event.eventType, event.payload).get(3, SECONDS)
      event.markPublished()
    } catch (e: Exception) {
      event.incrementRetry()
      if (event.retries > 5) event.moveToDLQ()
    }
  }
  outboxRepo.saveAll(pending)
}

컨슈머 측 멱등성:

@KafkaListener(topics = ["order.created"])
fun onOrderCreated(event: OrderCreatedEvent) {
  if (processedEventRepo.existsByEventId(event.id)) return  // 멱등
  
  sendNotification(event)
  processedEventRepo.save(ProcessedEvent(event.id))
}

결과:

  • 유실 0건 (3개월간)
  • 한 달 한 번 DB 마이그레이션 중 outbox lag 30분 → 알람으로 즉시 인지, 데이터는 결국 다 발행됨

진화 — CDC로 전환 (6개월 후):

  • Outbox 폴링이 DB 부하 + 1초 lag
  • Debezium CDC로 outbox 테이블 변경을 자동으로 Kafka로
  • 폴링 제거, lag 50ms 미만

교훈:

  • 원자성은 같은 DB 트랜잭션 안에서만 보장 → 외부 발행은 별 트랙으로
  • Outbox는 단순하지만 유실 0을 진짜로 가능하게 함
  • 규모가 커지면 CDC로 진화하는 것이 자연스러운 흐름

🔄 꼬리질문 1: 커밋 전 발행하면 어떤 일이 생기나요?

기대 답변:

  • 커밋 실패 시 컨슈머가 이미 처리한 상태 → 정합성 깨짐
  • 같은 트랜잭션 안 다른 작업의 lock과 경합

AFTER_COMMIT이 기본, 단 커밋 후 발행 실패는 별도로 잡아 outbox로 보완.

📋 실제 사례

상황: 주문 생성 트랜잭션 안에서 (커밋 전) 알림 이벤트를 발행했음. 어느 날 주문 트랜잭션이 제약 위반으로 롤백됐는데, 알림은 이미 발송되어 사용자가 “주문 완료” SMS를 받았지만 실제 주문은 없는 황당한 상황.

진단:

  • 이벤트가 커밋 전에 나가면, 커밋이 롤백돼도 이벤트는 회수 불가
  • “DB엔 없는데 알림은 간” 정합성 붕괴
  • 또 트랜잭션 안에서 외부 발행을 하니 발행 지연이 트랜잭션 락 holding 시간을 늘림

조치 — @TransactionalEventListener(AFTER_COMMIT):

  • 이벤트 발행을 커밋 성공 후로 이동
  • 커밋 후 발행 실패에 대비해 Outbox로 보완 (커밋과 함께 outbox INSERT → 릴레이가 발행)

결과:

  • “주문 없는데 알림 간” 사고 0건
  • 트랜잭션 락 holding 시간도 단축 (외부 발행이 트랜잭션 밖으로)

교훈: 커밋 발행은 롤백 시 이벤트를 회수 못 해 정합성이 깨진다. AFTER_COMMIT이 기본이고, 커밋 후 발행 실패까지 막으려면 Outbox(커밋과 원자적인 outbox INSERT)로 보완한다. 트랜잭션 안에서 외부 호출하면 락 holding도 늘어난다.

🔄 꼬리질문 2: Outbox 폴링의 DB 부하는 어떻게 제어하나요?

기대 답변:

  • 인덱스(status, created_at)와 적정 batch size
  • 발행 후 즉시 삭제 또는 status 업데이트
  • 규모가 커지면 CDC(Debezium)로 전환해 폴링 자체를 제거
📋 실제 사례

상황: Outbox 릴레이가 1초마다 폴링(SELECT ... WHERE status='PENDING')하는데, outbox 테이블이 커지면서 폴링 쿼리가 풀스캔으로 느려지고 DB QPS가 10% 추가로 늘어 부담.

진단:

  • status 컬럼에 인덱스가 없어 매 폴링이 풀스캔
  • 발행 완료된 row를 삭제하지 않고 status만 변경해 테이블이 계속 비대
  • 1초 폴링이라 lag도 최대 1초

조치 — 인덱스 → 배치 → CDC 진화:

  1. (단기) (status, created_at) 인덱스 + 적정 batch size(100) + 발행 후 즉시 삭제
  2. (장기) Outbox 규모가 더 커지자 Debezium CDC로 전환 → outbox 테이블 변경을 DB WAL에서 직접 캡처해 Kafka로 → 폴링 자체 제거

결과:

  • 단기 조치로 폴링 부하·lag 즉시 개선
  • CDC 전환 후: 폴링 DB 부하 0, lag 1s → 50ms

교훈: Outbox 폴링 부하는 (단기) 인덱스 + batch size + 발행 후 삭제로, (장기) CDC(Debezium)로 폴링 자체를 제거해서 푼다. Outbox는 시작점이고 CDC가 규모의 답이다. “폴링이 부담되면 CDC로 진화”가 자연스러운 경로.

🔄 꼬리질문 3: 컨슈머 측 멱등성은 어떻게 보장하나요?

기대 답변:

  • 이벤트 ID를 유니크 키로 두고 처리 이력 테이블에 insert
  • 처리 완료 후 ack
  • 멱등 키 보존 기간을 비즈니스 윈도우보다 길게
📋 실제 사례

상황: Kafka 컨슈머 그룹이 rebalance(파드 재배포)될 때마다 일부 메시지가 재처리되어, 같은 주문에 대해 알림이 2번 발송되는 클레임 발생.

진단:

  • Kafka는 at-least-once → rebalance·재시작 시 마지막 커밋 이후 메시지를 다시 전달
  • 알림 컨슈머에 멱등 처리가 없어 재전달 = 중복 발송
  • “공급자(발행)는 한 번만 보냈는데 소비자가 두 번 처리”

조치 — 컨슈머 멱등 처리:

  • processed_events(event_id PK, processed_at) 테이블 추가
  • 처리 전 INSERT 시도 → 이미 있으면(중복) skip, 없으면 처리 후 ack
  • 멱등키 보존 기간을 재처리 윈도우보다 길게(7일)

결과:

  • rebalance·재시작에도 알림 중복 발송 0건
  • at-least-once의 중복을 컨슈머가 무해화

교훈: at-least-once 환경에선 중복 전달이 정상이므로 멱등성은 발행자가 아니라 소비자가 책임져야 안전하다. 이벤트 ID 유니크 키 + 처리 이력 테이블로 중복을 skip하고, 멱등키 보존 기간을 재처리 윈도우보다 길게 둔다.


마무리: 5편 핵심 정리

  1. SAGA: 보상 트랜잭션과 DLQ까지 설계, Choreography vs Orchestration 판단
  2. CQRS: 일관성 윈도우 관리, Event Sourcing은 선택적
  3. API Gateway: 횡단 관심사만, 비즈니스 로직 금지
  4. DDD: 컨텍스트 경계가 코드·팀·DB까지 반영
  5. 도메인 이벤트 + 아웃박스: 커밋 후 발행 + 유실 방지 + 멱등성

다음 6편은 가용성/안정성 패턴 — Rate Limiting, Bulkhead, Circuit Breaker, Timeout Cascade, 멱등성과 재시도 jitter를 다룹니다.