시니어 백엔드 면접 질문 5편 - 분산/MSA 아키텍처 (5~10년차)
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주 작업):
- Resilience4j 서킷 브레이커 모든 라우트에 적용
- 임계치: 에러율 30%, 슬로우 콜 비율 50% (p99 > 2s)
- Bulkhead: 백엔드별 동시성 상한 (semaphore-based)
- 인스턴스 다중화: 2 → 6, 무상태 + L7 LB
- 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축으로 정리합니다.
- 이벤트 발행 시점 제어: 커밋 후 발행 (
@TransactionalEventListener AFTER_COMMIT) - 유실 방지: Outbox 패턴 — 같은 트랜잭션에 outbox 테이블 insert, 별도 릴레이가 발행
- 실패 처리: 재시도 + 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 진화:
- (단기)
(status, created_at)인덱스 + 적정 batch size(100) + 발행 후 즉시 삭제 - (장기) 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편 핵심 정리
- SAGA: 보상 트랜잭션과 DLQ까지 설계, Choreography vs Orchestration 판단
- CQRS: 일관성 윈도우 관리, Event Sourcing은 선택적
- API Gateway: 횡단 관심사만, 비즈니스 로직 금지
- DDD: 컨텍스트 경계가 코드·팀·DB까지 반영
- 도메인 이벤트 + 아웃박스: 커밋 후 발행 + 유실 방지 + 멱등성
다음 6편은 가용성/안정성 패턴 — Rate Limiting, Bulkhead, Circuit Breaker, Timeout Cascade, 멱등성과 재시도 jitter를 다룹니다.