콘텐츠로 이동

MSA Patterns

분류: Layer 9 - 아키텍처 & 설계 패턴

MSA 패턴 (Microservices Architecture Patterns)

섹션 제목: “MSA 패턴 (Microservices Architecture Patterns)”

MSA(Microservices Architecture, 마이크로서비스 아키텍처)는 비즈니스 능력을 독립적으로 변경·배포·확장할 수 있는 서비스에 맡기고, 서비스마다 코드와 데이터의 소유권을 분명히 하는 아키텍처 스타일이다.

Twelve-Factor App이 실행 단위를 교체 가능한 프로세스로 만들었다면, 프로세스를 네트워크 너머로 나누는 순간 지연·중복·응답 소실 같은 부분 실패가 정상 입력이 된다. MSA는 이 실패를 감수하면서도 독립성을 얻기 위해 서비스 경계와 데이터 소유권을 명시한다.

핵심은 애플리케이션을 작게 자르는 데 있지 않다. 함께 바뀌는 규칙은 한 경계 안에 모으고, 다르게 바뀌는 규칙은 명시적 계약으로 분리하는 것이 핵심이다. 분리한 뒤에는 함수 호출이 네트워크 호출로, 단일 트랜잭션이 여러 로컬 트랜잭션으로 바뀐다. 따라서 API Gateway, 서비스 디스커버리, 타임아웃, 멱등성, 관측성 같은 패턴은 선택 장식이 아니라 분리 비용을 다루는 장치다.

선택 관련 읽기: 프로토콜과 전송 예시는 HTTP Basics, HTTP Cache, WebSocket & gRPC를 참고한다. 배포·디스커버리 구현은 Docker Basics, Kubernetes Basics, 메시지 처리 도구는 Queue / Worker Basics를 선택해서 읽을 수 있다. 보안 경계는 Authentication vs Authorization, Web Security Basics, TLS & HTTPS, 관측 도구는 Logs, Metrics, Traces, CloudWatch Basics에서 더 자세히 다룬다. 이 문서에 등장하는 제품과 도구는 MSA의 핵심 개념을 구체화하는 예시이지 필수 선수지식이 아니다.


2. 왜 서비스 경계가 필요해졌는가

섹션 제목: “2. 왜 서비스 경계가 필요해졌는가”

모놀리스(Monolith)는 하나의 배포 단위로 실행되는 애플리케이션이다. 모놀리스라는 이유만으로 구조가 나쁜 것은 아니다. 같은 프로세스의 함수 호출은 빠르고, 하나의 데이터베이스 트랜잭션으로 여러 규칙을 묶을 수 있으며, 로컬에서 전체 흐름을 추적하기 쉽다. 제품과 팀이 작을 때는 이 단순성이 큰 장점이다.

문제는 시스템이 커지면서 다음 세 단위가 계속 어긋날 때 시작된다.

  • 변경 단위: 결제 규칙만 바뀌었는데 전체 애플리케이션을 다시 검증하고 배포한다.
  • 확장 단위: 상품 조회만 폭증했는데 주문·결제 코드까지 함께 복제한다.
  • 장애 단위: 알림 모듈의 메모리 누수나 느린 외부 호출이 주문 API 프로세스까지 멈춘다.

MSA는 이 단위를 서비스 경계에 맞춰 분리한다. 그러나 분리 자체가 가치를 만들지는 않는다. 결제와 주문을 두 프로세스로 나눠 놓고 항상 함께 배포하거나 같은 테이블을 직접 수정한다면, 네트워크 비용만 추가되고 변경 단위는 그대로다.

2.1 선행 기술의 한계에서 MSA가 등장한 이유

섹션 제목: “2.1 선행 기술의 한계에서 MSA가 등장한 이유”

초기 이커머스 모놀리스를 생각해 보자.

[하나의 애플리케이션]
주문 모듈 -> 결제 모듈 -> 재고 모듈 -> 알림 모듈
|
[하나의 데이터베이스]

한 팀이 하나의 릴리스 주기로 운영할 때는 이 구조가 효율적이다. 주문 생성, 결제 승인, 재고 차감을 한 트랜잭션으로 묶을 수 있고 네트워크 실패도 없다. 하지만 결제팀은 규제 대응 때문에 매일 배포하고, 카탈로그팀은 읽기 트래픽 때문에 독립 확장해야 하며, 알림 장애가 구매를 막아서는 안 되는 상황이 오면 하나의 배포·확장·장애 단위가 조직과 제품 요구를 따라가지 못한다.

MSA는 이 한계를 다음 구조로 바꾼다.

[클라이언트]
|
[API Gateway]
|
+--> [주문 서비스] ----> [주문 DB]
+--> [결제 서비스] ----> [결제 DB]
+--> [재고 서비스] ----> [재고 DB]
`--> [알림 서비스] ----> [알림 DB]

이제 결제 서비스는 독립 배포할 수 있고, 카탈로그만 더 많이 복제할 수 있으며, 알림 실패를 주문 성공과 분리할 수 있다. 대신 호출마다 지연·타임아웃·중복이 생길 수 있고, 서비스별 데이터가 즉시 같은 상태라는 보장도 사라진다. MSA의 철학은 복잡성을 제거하는 것이 아니라 필요한 독립성을 얻기 위해 복잡성을 경계와 계약으로 옮기는 것이다.

2.2 Worked Example: 알림 분리가 가치가 되는 순간

섹션 제목: “2.2 Worked Example: 알림 분리가 가치가 되는 순간”

주문 완료 후 이메일을 보내는 흐름을 보자.

초기:
주문 트랜잭션 -> 메일 API 동기 호출 -> 응답
문제:
메일 공급자가 8초 동안 응답하지 않음
-> 주문 요청도 8초 대기
-> 요청 슬롯이 쌓임
-> 메일 장애가 주문 장애로 전파
분리:
주문 트랜잭션 -> OrderCompleted 이벤트 기록 -> 즉시 응답
알림 서비스 -> 이벤트 소비 -> 이메일 발송

여기서 분리 이유는 “알림 코드가 길어서”가 아니다. 주문과 알림의 가용성 요구, 재시도 방식, 배포 주기가 다르기 때문이다. 반대로 주문과 주문 항목은 같은 트랜잭션에서 불변식을 지켜야 하고 함께 변경되는 경우가 많으므로, 단지 클래스가 크다는 이유로 서비스 둘로 나누면 오히려 네트워크 정합성 문제를 만든다.


3. 분해 철학: 무엇을 서비스로 만들 것인가

섹션 제목: “3. 분해 철학: 무엇을 서비스로 만들 것인가”

3.1 Bounded Context는 후보 경계이고 서비스는 실행 경계다

섹션 제목: “3.1 Bounded Context는 후보 경계이고 서비스는 실행 경계다”

DDD(Domain-Driven Design, 도메인 주도 설계)의 Bounded Context(경계 컨텍스트)는 특정 업무 언어와 모델이 일관된 뜻을 유지하는 범위다. 주문 Context의 Order는 구매 항목과 확정 가능 여부를 뜻하지만, 배송 Context의 관심사는 수령인과 출고 상태다.

Bounded Context는 곧 마이크로서비스가 아니다. Context는 개념 경계이고, 서비스는 프로세스·배포·운영 책임을 갖는 실행 경계다. 하나의 서비스가 작은 Context 여러 개를 담을 수도 있고, 하나의 큰 Context가 내부 모듈 여러 개로 구성될 수도 있다. 다음 순서가 안전하다.

  1. 같은 언어와 불변식을 공유하는 업무 경계를 찾는다.
  2. 모듈 경계와 공개 인터페이스로 결합을 먼저 줄인다.
  3. 독립 배포·확장·장애 격리의 이득이 확인된 경계만 프로세스로 추출한다.

3.2 서비스 경계를 찾는 네 가지 힘

섹션 제목: “3.2 서비스 경계를 찾는 네 가지 힘”

서비스 후보는 명사 목록보다 변화의 힘으로 찾는 편이 낫다.

관찰분리 쪽 신호함께 두는 쪽 신호
비즈니스 불변식서로 다른 규칙과 승인 주기를 가진다한 트랜잭션에서 반드시 같이 지켜야 한다
변경·배포 주기팀별 변경 빈도와 릴리스 시간이 다르다대부분 같은 요구사항으로 같이 바뀐다
부하 모양읽기/쓰기 피크와 확장 방식이 다르다같은 요청 경로에서 비슷하게 증가한다
장애 허용한 영역 실패가 다른 영역을 막으면 안 된다한쪽 없이 다른 쪽 성공이 의미 없다

예를 들어 주문, 결제, 재고는 업무 이름이 다르지만 “주문 확정” 경로에서 강한 정합성이 필요할 수 있다. 경계가 아직 흔들리고 한 팀이 함께 변경한다면 모듈로 유지하는 편이 낫다. 결제 승인 규칙이 별도 규제와 팀을 가지며, 장애를 격리해야 하고, 독립 배포가 반복해서 필요해졌다면 서비스 추출 근거가 생긴다.

3.3 서비스 소유권은 코드보다 넓다

섹션 제목: “3.3 서비스 소유권은 코드보다 넓다”

서비스를 소유한다는 것은 저장소 이름에 팀 이름을 붙이는 일이 아니다. 한 팀이 다음 수명주기를 끝까지 책임질 수 있어야 한다.

  • 공개 API와 이벤트 계약
  • 비즈니스 규칙과 스키마 마이그레이션
  • 배포 파이프라인과 롤백
  • 서비스 수준 목표(SLO, Service Level Objective)
  • 대시보드, 경보, 장애 대응
  • 데이터 보존·보안·접근 정책

코드는 팀 A가, DB 변경은 중앙 DBA가, 배포는 플랫폼팀이, 장애 판단은 팀 B가 맡아 매 변경마다 네 팀의 승인이 필요하다면 물리적으로 서비스가 나뉘어도 독립 변경은 이루어지지 않는다. 이 상태에서는 서비스 수가 늘수록 조정 비용만 늘어난다.

3.4 Counterexample: 너무 이른 주문·결제·재고 분리

섹션 제목: “3.4 Counterexample: 너무 이른 주문·결제·재고 분리”

팀이 8명이고 도메인 경계가 매주 바뀌는 상황에서 주문·결제·재고를 세 서비스로 나눴다고 하자. 모놀리스에서는 하나의 ACID(Atomicity, Consistency, Isolation, Durability) 트랜잭션으로 끝나던 변경이 다음 과제를 요구한다.

  • 서비스 간 API와 이벤트 스키마
  • 타임아웃, 재시도, 멱등성 키
  • 부분 성공을 되돌리는 보상 규칙
  • 서비스별 로그를 잇는 분산 추적
  • 세 배포의 순서와 하위 호환성

경계가 매주 바뀌면 이 계약도 매주 바뀐다. 팀은 비즈니스 모델을 발견하는 대신 네트워크 계약을 계속 마이그레이션한다. 이때는 모듈러 모놀리스에서 orders, payments, inventory가 서로의 내부 테이블과 타입을 직접 사용하지 못하게 하는 편이 학습 비용과 변경 비용 모두 낮다.

3.5 분산 모놀리스라는 실패 상태

섹션 제목: “3.5 분산 모놀리스라는 실패 상태”

분산 모놀리스(Distributed Monolith)는 여러 프로세스로 배포하지만 실제 변경·실행·데이터 경계는 강하게 묶인 시스템이다. MSA의 운영 비용은 내면서 모놀리스의 단순성은 잃은 상태다.

겉모습:
Order Service -> Payment Service -> Inventory Service
실제 계약:
- 세 서비스를 항상 같은 날 같은 순서로 배포
- 모두 shared.orders 테이블을 직접 수정
- Order 응답은 Payment와 Inventory의 동기 성공이 있어야만 반환
- 공용 domain 패키지 버전 변경 시 전 서비스 재배포

다음 신호가 둘 이상 반복되면 서비스 수보다 경계를 먼저 다시 봐야 한다.

  • 한 기능 배포에 여러 서비스의 lockstep release가 필요하다.
  • 한 서비스 테스트를 위해 다른 서비스 대부분을 띄워야 한다.
  • 서비스가 다른 서비스의 DB 테이블을 직접 읽거나 쓴다.
  • 동기 호출 그래프에 순환이 있다.
  • 공용 라이브러리의 도메인 타입 변경이 전체 재배포를 만든다.
  • 장애가 한 서비스에 머물지 않고 긴 호출 체인을 따라 전파된다.

3.6 경계를 평가하는 네 가지 결합

섹션 제목: “3.6 경계를 평가하는 네 가지 결합”

서비스 간 결합은 “높다/낮다” 하나의 축으로만 보면 원인을 놓친다. 적어도 다음 네 종류를 구분한다.

한 서비스가 다른 서비스의 내부 클래스, 테이블, 저장소 패키지를 직접 아는 정도다.

나쁜 예:
Order Service가 PaymentEntity를 공용 npm 패키지에서 import
Order Service가 payment_transactions 테이블을 직접 SELECT
경계가 있는 예:
Order Service는 Payment API의 request/response 계약만 앎
Payment 내부 entity와 schema는 Payment만 변경

구조 결합이 높으면 상대 서비스의 내부 리팩터링도 호출자를 다시 배포하게 만든다. API를 쓴다는 사실만으로 결합이 낮아지는 것은 아니다. API가 내부 테이블 구조를 그대로 노출하면 구조 결합이 네트워크 너머로 이동했을 뿐이다.

두 서비스가 같은 순간 살아 있고 응답해야만 흐름이 진행되는 정도다. 동기 호출은 시간 결합을 만들지만 그 자체가 잘못은 아니다. 사용자가 현재 재고를 확인해야 한다면 동기 호출이 의미에 맞을 수 있다. 문제는 알림이나 분석처럼 나중에 처리해도 되는 작업까지 사용자 요청 경로에 묶을 때다.

메시지가 특정 순서로 도착해야만 올바르게 처리되는 정도다.

기대한 순서:
OrderCreated(version=1)
OrderCancelled(version=2)
실제 도착:
OrderCancelled(version=2)
OrderCreated(version=1)

소비자가 단순히 마지막 도착 메시지를 적용하면 취소된 주문이 다시 생성 상태가 된다. Aggregate별 sequence, version 조건부 갱신, 순서가 뒤집혀도 가능한 상태 전이를 사용해 순서 가정을 명시해야 한다.

계약 변경 때 생산자와 소비자가 동시에 배포되어야 하는 정도다. 새 필드를 optional로 추가하고 구버전 소비자가 무시할 수 있으면 독립 배포가 가능하다. 필드 의미를 즉시 바꾸거나 enum 값을 제거하면 lockstep 배포가 필요하다.

결합대표 실패줄이는 메커니즘완전히 제거 가능한가
구조내부 schema 변경이 호출자 파손소유권, 명시적 API, ACL의미 계약은 남음
시간다운스트림 장애가 호출자 장애큐, timeout, fallback즉시 결과가 필요하면 남음
순서늦은 이벤트가 최신 상태 덮어씀version, 상태 머신, partition key비즈니스 순서가 있으면 남음
배포전 서비스 동시 릴리스additive change, 호환성 테스트의미 변경 시 migration 필요

좋은 경계는 결합이 0인 경계가 아니다. 어떤 결합을 허용하고 누가 비용을 소유하는지 설명할 수 있는 경계다.

여기서 ACL(Anti-Corruption Layer, 부패 방지 계층)은 외부 서비스의 모델을 내부 Context 언어로 번역해 외부 변경이 도메인 모델에 직접 퍼지지 않게 하는 경계다.

3.7 Worked Example: Product를 어디까지 나눌까

섹션 제목: “3.7 Worked Example: Product를 어디까지 나눌까”

초기에는 하나의 Product 모델이 카탈로그, 재고, 가격, 정산에 공유될 수 있다.

Product {
id,
displayName,
stockQuantity,
price,
taxCode
}

시간이 지나 다음 변화가 관찰된다.

  • 카탈로그팀은 검색 노출명과 설명을 하루에도 여러 번 바꾼다.
  • 재고팀은 창고별 수량과 예약을 강한 동시성 제어로 관리한다.
  • 가격팀은 캠페인 규칙을 자주 실험한다.
  • 정산팀은 세금 코드와 매출 인식 규정을 월 단위로 바꾼다.

개념 경계는 다음처럼 보인다.

Catalog Context: CatalogItem(productId, displayName, description)
Inventory Context: StockItem(productId, available, reserved)
Pricing Context: Offer(productId, basePrice, discountPolicy)
Settlement Context:SettlementItem(productId, taxCode, category)

하지만 곧바로 서비스 네 개를 만들 필요는 없다. 먼저 모듈 네 개로 나누고 다음을 측정한다.

  1. 변경이 실제로 한 모듈 안에서 끝나는가?
  2. 다른 모듈 데이터가 필요할 때 공개 port나 event만 쓰는가?
  3. 재고 부하가 카탈로그와 다른 확장 곡선을 보이는가?
  4. 서로 다른 팀이 독립 배포를 반복해서 요구하는가?
  5. 카탈로그 장애 중에도 재고 예약이 의미 있게 동작해야 하는가?

재고만 독립 확장과 장애 격리 요구가 확인되었다면 Inventory만 서비스로 추출할 수 있다. 나머지 세 Context는 한 모놀리스 안에 남아도 된다. “Context 네 개 = 서비스 네 개”로 계산하지 않는 것이 핵심이다.

3.8 서비스 크기를 코드 줄 수로 정하지 않는다

섹션 제목: “3.8 서비스 크기를 코드 줄 수로 정하지 않는다”

micro는 코드 줄 수나 클래스 수를 뜻하지 않는다. 서비스가 맡은 비즈니스 능력을 한 팀이 이해하고 독립 운영할 수 있는지가 더 중요하다.

너무 작은 서비스의 신호:

  • 데이터 없는 얇은 CRUD wrapper가 다른 서비스 호출만 전달한다.
  • 한 사용자 요청이 8~10개의 짧은 서비스를 직렬 통과한다.
  • 작은 필드 하나를 추가하는데 여러 저장소와 배포가 필요하다.
  • 서비스별 on-call과 SLO를 둘 만큼 독립된 실패 의미가 없다.

너무 큰 서비스의 신호:

  • 서로 무관한 팀이 같은 저장소의 다른 영역을 매일 충돌시킨다.
  • 한 모듈 부하 때문에 전체 애플리케이션을 반복 확장한다.
  • 배포 전 회귀 테스트 범위가 계속 늘고 rollback 영향이 넓다.
  • 한 장애가 프로세스의 모든 비즈니스 능력을 멈춘다.

서비스 크기는 정답이 아니라 피드백 문제다. 변경 동시성, 호출 깊이, 데이터 불변식, 운영 소유권을 보며 합치거나 나눈다.

3.9 Conway’s Law를 조직도 복사로 오해하지 않는다

섹션 제목: “3.9 Conway’s Law를 조직도 복사로 오해하지 않는다”

Conway’s Law는 시스템의 통신 구조가 조직의 의사소통 구조를 닮는 경향을 말한다. 이를 “팀 하나당 서비스 하나” 규칙으로 적용하면 조직 개편마다 서비스 경계를 다시 그려야 한다.

더 실용적인 해석은 다음과 같다.

  • 자주 협의해야 하는 변경은 같은 팀과 경계 안에 두는 편이 빠르다.
  • 경계 사이 계약은 팀 사이 협의 비용을 반영한다.
  • 플랫폼팀은 모든 비즈니스 서비스의 배포 승인자가 아니라 공통 self-service 기반을 제공해야 한다.
  • 서비스 소유 팀이 너무 작아 24시간 운영을 감당하지 못하면 경계 수를 줄이거나 공동 운영 모델이 필요하다.

팀 구조와 서비스 구조는 서로 영향을 주지만 어느 한쪽을 그대로 복사하지 않는다. 비즈니스 경계와 운영 지속 가능성을 함께 본다.


4. 통신 선택: 동기와 비동기는 무엇을 바꾸는가

섹션 제목: “4. 통신 선택: 동기와 비동기는 무엇을 바꾸는가”

서비스를 나누는 순간 함수 호출은 네트워크 통신이 된다. 네트워크 호출은 성공, 명시적 실패 외에 응답이 사라져 결과를 모르는 상태를 만든다. 요청을 다시 보내면 중복 실행될 수 있고, 기다리면 자원이 고갈될 수 있다. 통신 방식은 문법이 아니라 실패 의미를 고르는 결정이다.

동기 통신은 호출자가 응답을 기다리는 방식이다. HTTP REST와 gRPC(Remote Procedure Call용 고성능 프로토콜)가 대표적이다.

적합한 경우:

  • 사용자가 지금 결과를 알아야 한다.
  • 다음 단계가 응답 데이터에 직접 의존한다.
  • 읽기처럼 실패 시 상태 변경이 남지 않는다.
  • 짧은 호출 깊이와 명확한 시간 예산을 유지할 수 있다.

비용:

  • 호출 대상의 지연과 가용성이 호출자 경로에 합쳐진다.
  • 타임아웃 뒤 실제 처리 여부를 모를 수 있다.
  • 재시도가 부하를 증폭할 수 있다.

비동기 통신은 발행자가 메시지를 남기고 즉시 다음 일을 진행하며, 소비자가 나중에 처리하는 방식이다. 큐, 로그, 이벤트 브로커가 전달을 중재한다.

적합한 경우:

  • 즉시 완료보다 요청의 수락이 중요하다.
  • 일시적 장애 동안 작업을 버퍼링해야 한다.
  • 한 사건을 알림·분석·검색 등 여러 소비자가 독립 처리한다.
  • 발행자가 소비자의 주소와 처리 시간을 몰라도 되어야 한다.

비용:

  • 최종 일관성과 지연을 제품 상태로 표현해야 한다.
  • 중복·순서 역전·독성 메시지를 처리해야 한다.
  • 한 요청의 전체 흐름을 추적하기 어려워진다.

동기 통신과 비동기 통신

응답이 즉시 필요

동기 HTTP/gRPC가 자연스럽다.

짧은 읽기, 즉시 검증, 사용자 응답 조립

시간적으로 분리 가능

이벤트나 큐가 장애와 부하를 흡수한다.

알림, 분석, 검색 색인, 후속 처리

실패 의미

동기는 타임아웃과 연쇄 실패, 비동기는 지연·중복·순서 문제를 만든다.

어느 실패를 제품이 받아들일 수 있는지 먼저 결정

결합 형태

동기는 시간 결합, 비동기는 스키마와 의미 결합이 남는다.

비동기라고 자동으로 느슨한 결합은 아님

4.3 수치 감각: 단순 계획용 지연 예산 산술

섹션 제목: “4.3 수치 감각: 단순 계획용 지연 예산 산술”

같은 프로세스의 단순 함수 호출이 약 1μs이고 서비스 간 HTTP 호출이 약 1ms라고 가정하면 네트워크 경계 하나만으로 약 1,000배 차이가 난다. 이 값은 하드웨어와 런타임에 따라 달라지는 규모감 예시이지 보장값이 아니다. 실제 서비스에서는 직렬화, TLS, 프록시, 큐잉, 원격 처리 시간이 더해진다.

p95는 요청의 95%가 그 값 이하에서 끝났다는 뜻이고, p99는 요청의 99%가 그 값 이하에서 끝났다는 뜻이다. 평균에는 가려지는 느린 꼬리 구간(tail latency)을 읽기 위한 백분위수다. 다만 구성요소별 percentile은 서로 다른 요청 표본과 상관관계를 가지므로 합산해서 end-to-end p95로 해석할 수 없다. 실제 end-to-end p95는 요청 전체 trace의 시작부터 끝까지 걸린 시간 분포에서 구한다.

호출 체인의 초기 deadline을 계획하기 위해 Order, Payment, Inventory에 각각 40ms, 70ms, 90ms의 처리 예산을 배정하고 네트워크·프록시 예산을 호출당 10ms로 잡았다고 하자. 직렬 경로의 단순 계획 산술은 다음처럼 230ms다.

Gateway -> Order 40ms
Order -> Payment 10ms + 70ms
Order -> Inventory 10ms + 90ms
----------------------------------
단순 합계 220ms
Gateway 오버헤드 10ms
계획용 총 예산 230ms

이 합계는 타임아웃과 deadline 배분을 시작하기 위한 예산안이지 관측된 p95가 아니다. 여기에 각 호출이 한 번씩 재시도되면 최악 시간과 다운스트림 요청 수가 거의 두 배가 된다. 서비스가 느려졌는데 모든 계층이 각자 3회 재시도하면 원래 요청 1건이 여러 하위 요청으로 증폭된다. 재시도는 “신뢰성 옵션”이 아니라 전체 호출 그래프의 부하 예산으로 설계해야 한다.

4.4 Worked Example: 주문 확인 응답을 어디서 끊을까

섹션 제목: “4.4 Worked Example: 주문 확인 응답을 어디서 끊을까”

사용자가 주문 버튼을 누른 뒤 다음 두 설계가 가능하다.

A. 모든 단계를 동기 처리
Client -> Order -> Payment -> Inventory -> Notification -> 200 OK
B. 핵심 결정만 동기, 후속 작업은 비동기
Client -> Order -> Payment/Inventory 결정 -> 202 Accepted 또는 200 OK
Order -> OrderConfirmed 이벤트 -> Notification/Analytics/Search

결제 승인과 재고 예약 결과를 즉시 보여줘야 하면 그 구간은 동기가 자연스럽다. 이메일과 분석은 주문 성공의 전제 조건이 아니므로 비동기로 끊을 수 있다. 중요한 것은 “비동기가 더 현대적”이라는 결론이 아니라 사용자에게 약속한 완료 상태가 어디까지인지 정하는 것이다. 202 Accepted를 반환한다면 조회 API는 PENDING, CONFIRMED, FAILED 같은 진행 상태를 설명할 수 있어야 한다.

4.5 비동기라고 결합이 사라지지는 않는다

섹션 제목: “4.5 비동기라고 결합이 사라지지는 않는다”

OrderCreated 이벤트를 다섯 서비스가 소비하면 발행자는 소비자 주소를 모르지만, 이벤트 필드와 의미는 공유한다. 필드 제거, 의미 변경, 순서 가정은 소비자를 깨뜨릴 수 있다. 따라서 이벤트도 버전과 호환성 규칙을 가진 공개 계약으로 취급한다.

또한 대부분의 메시지 시스템은 At-Least-Once Delivery(최소 한 번 전달)를 기본으로 한다. 같은 메시지가 두 번 올 수 있으므로 소비자는 멱등성(Idempotency), 즉 같은 요청을 여러 번 처리해도 최종 결과가 한 번 처리한 것과 같도록 만들어야 한다.

eventId = "order-completed-order-001"
첫 수신:
processed_events에 eventId 삽입 -> 이메일 발송 -> 완료
재수신:
eventId unique 충돌 또는 기존 기록 확인 -> 부작용 생략 -> 성공 응답

“먼저 처리 기록을 저장하고 나중에 이메일을 보낸다”만으로는 저장 후 이메일 실패 시 영원히 누락될 수 있다. 처리 기록과 비즈니스 부작용의 원자성, 재시도 가능한 상태 모델까지 함께 설계해야 한다. 이 문서에서는 중복이 정상 입력이라는 원칙만 잡고, 재시도·멱등성 구현은 선수 토픽을 따른다.

메시지 브로커를 쓴다고 모든 메시지가 이벤트가 되는 것은 아니다. 이름은 소비자의 책임과 실패 의미를 드러내야 한다.

종류의미예시기대 소비자
Command특정 수신자에게 행동을 요청ChargePayment보통 하나
Event이미 일어난 사실을 알림PaymentCharged0개 이상
Query현재 정보를 요청하고 응답을 기대GetPaymentStatus하나

OrderServiceSendEmail을 발행하면 알림 서비스의 행동을 직접 지시하는 Command다. OrderConfirmed를 발행하면 주문이 확정됐다는 사실을 알리고, 알림·분석·검색 서비스가 각자 반응한다. 둘 다 유효하지만 결합의 형태가 다르다.

이벤트 이름을 CreateNotification처럼 미래 행동으로 쓰면 발행자가 소비자 책임까지 소유하기 쉽다. 반대로 결제를 요청해야 하는데 PaymentRequested라는 과거형 이벤트만 던지고 누가 반드시 처리해야 하는지 정하지 않으면 책임이 사라진다.

4.7 이벤트는 사실이고 상태를 대신하지 않는다

섹션 제목: “4.7 이벤트는 사실이고 상태를 대신하지 않는다”

이벤트 payload에 전체 Aggregate snapshot을 항상 넣으면 소비자는 편하지만 발행자의 내부 모델에 강하게 결합된다. 반대로 ID만 넣으면 모든 소비자가 원 서비스에 다시 동기 호출해 시간 결합이 되살아난다.

너무 큰 이벤트:
OrderCreated { Order 전체 entity, 내부 필드 40개 }
-> 내부 schema 변경이 모든 소비자 계약 변경
너무 작은 이벤트:
OrderCreated { orderId }
-> 소비자 8개가 동시에 Order API 조회
-> fan-out 부하와 시간 결합
목적에 맞는 이벤트:
OrderCreated {
eventId,
orderId,
buyerId,
totalAmount,
occurredAt,
version
}

어떤 필드를 넣을지는 미래 소비자를 모두 예측해서가 아니라 사건의 의미와 반복 조회 비용으로 결정한다. 민감 정보와 자주 바뀌는 내부 표현은 최소화하고, 사건을 해석하는 데 필요한 안정적 식별자와 값은 포함한다.

Payment가 기존 응답에 status: "approved" | "declined"를 제공하다가 reviewing 상태를 추가한다고 하자.

한 번에 enum을 추가하면 구버전 Order가 unknown value에서 예외를 낼 수 있다. 독립 배포를 유지하려면 확장과 축소를 나눈다.

1. 소비자 선배포
- 모르는 status를 UNKNOWN/PROCESSING으로 안전 처리
- 계약 테스트 추가
2. 생산자 배포
- reviewing 발행 시작
3. 관찰
- unknown status, fallback 비율 확인
4. 구버전 제거
- 모든 소비자가 새 의미를 처리한 뒤 오래된 경로 삭제

이를 expand-and-contract migration이라고 부른다. DB 컬럼, API 필드, 이벤트 schema 모두 같은 원리로 바꿀 수 있다. “서비스별 독립 배포”는 저장소가 여러 개라는 속성이 아니라 이런 호환성 절차를 수행할 수 있는 능력이다.

4.9 순서 보장은 필요한 범위만 좁힌다

섹션 제목: “4.9 순서 보장은 필요한 범위만 좁힌다”

모든 주문의 이벤트를 전역 순서로 처리하려 하면 하나의 파티션과 병목이 생긴다. 대부분 필요한 것은 전체 순서가 아니라 같은 Aggregate 안의 순서다.

partition key = orderId
order-1: Created(1) -> Paid(2) -> Shipped(3)
order-2: Created(1) -> Cancelled(2)

order-1order-2 사이 순서는 의미가 없으므로 병렬 처리할 수 있다. 같은 주문 안에서는 version을 확인한다. version 3을 처리한 뒤 version 2가 오면 무시하거나 보류하고, gap이 있으면 재조회·재처리 정책을 따른다.

4.10 Backpressure는 비동기 시스템의 시간 예산이다

섹션 제목: “4.10 Backpressure는 비동기 시스템의 시간 예산이다”

Backpressure(역압)는 생산 속도가 소비 속도보다 빠를 때 유입을 늦추거나 버퍼가 넘치지 않게 제어하는 메커니즘이다. 큐가 있다는 이유만으로 부하 문제가 사라지지 않는다. 단지 즉시 timeout 대신 backlog로 모양이 바뀐다.

수치 예를 보자.

생산: 초당 1,000건
소비: 초당 800건
차이: 초당 200건 누적
10분 뒤 backlog:
200 * 60 * 10 = 120,000건
소비가 초당 1,200건으로 회복된 뒤
순감소 속도: 초당 200건
backlog 해소 시간: 다시 10분

평균 consumer CPU가 낮아도 oldest message age가 계속 증가하면 시스템은 실시간 요구를 지키지 못한다. autoscaling은 현재 queue length만 보지 말고 유입률, 처리율, 허용 지연을 함께 봐야 한다. 독성 메시지 하나가 파티션을 막는 경우에는 인스턴스를 늘려도 해결되지 않으므로 DLQ(Dead Letter Queue, 반복 실패 메시지 격리 큐)와 실패 이유가 필요하다.

4.11 요청-응답을 큐에 올려도 동기 의미는 남을 수 있다

섹션 제목: “4.11 요청-응답을 큐에 올려도 동기 의미는 남을 수 있다”

요청을 큐에 넣고 correlation ID로 응답 메시지를 기다리는 RPC over messaging은 전송 기술만 비동기일 뿐 호출자는 여전히 응답에 시간 결합된다. HTTP보다 자동으로 회복성이 좋아지지 않는다.

다음 질문으로 구분한다.

  • 호출자가 응답 없이 다음 상태로 갈 수 있는가?
  • 응답이 늦으면 사용자에게 진행 중 상태를 보여 줄 수 있는가?
  • 소비자가 일시 중단돼도 요청을 보존하는 가치가 있는가?
  • timeout 뒤 늦게 온 응답을 어느 상태에서 수용할 것인가?

응답을 반드시 기다리면서 브로커만 추가하면 디버깅 지점과 지연만 늘 수 있다. 비동기의 이점은 전송 API가 아니라 시간적으로 분리된 제품 상태에서 나온다.


5.1 API Gateway: 외부 계약의 단일 진입점

섹션 제목: “5.1 API Gateway: 외부 계약의 단일 진입점”

API Gateway는 외부 클라이언트와 내부 서비스 사이에서 라우팅, 인증, 속도 제한, 요청 변환, 응답 집계를 수행하는 진입 계층이다. 외부에서 내부로 들어오는 트래픽을 North-South 트래픽이라고 한다.

[Web / Mobile / Partner]
|
[API Gateway]
| | |
v v v
Order User Payment

Gateway가 없으면 클라이언트가 서비스 주소와 분할 구조를 알아야 한다. 서비스가 이동하거나 합쳐질 때 클라이언트도 바뀐다. Gateway는 내부 구조를 숨기고 외부 계약을 안정시킨다.

그러나 “모든 공통 로직”을 Gateway에 넣으면 새로운 모놀리스가 된다. 라우팅, 인증 토큰의 기본 검증, 속도 제한 같은 경계 정책은 적합하지만, 주문 할인·결제 가능 여부 같은 비즈니스 규칙은 소유 서비스에 남겨야 한다.

BFF(Backend for Frontend)는 클라이언트 유형별로 둔 전용 백엔드다. 모바일은 작은 응답과 적은 왕복을 원하고, 관리자 웹은 여러 서비스의 상세 데이터를 한 화면에 모아야 할 수 있다.

[Mobile] -> [Mobile BFF] --+
+--> [공통 서비스]
[Admin] -> [Admin BFF] ----+

BFF는 표시 목적의 집계와 변환에는 유용하지만 도메인 규칙의 소유자가 되어서는 안 된다. 같은 할인 규칙이 Mobile BFF와 Admin BFF에 복제되면 채널마다 결과가 달라진다. 또한 “클라이언트 종류가 2개 이상이면 BFF” 같은 숫자 규칙도 충분하지 않다. 응답 형태와 변경 주기가 실제로 다른지가 기준이다.

5.3 Service Discovery: 주소가 바뀌는 서비스를 찾는 방법

섹션 제목: “5.3 Service Discovery: 주소가 바뀌는 서비스를 찾는 방법”

서비스 디스커버리(Service Discovery)는 호출할 서비스 인스턴스의 현재 주소를 찾는 메커니즘이다. 컨테이너가 재시작되면 IP가 바뀌고, 오토스케일링으로 인스턴스 수가 늘거나 줄기 때문에 주소를 코드에 고정할 수 없다.

1. payment 인스턴스가 레지스트리 또는 DNS에 등록된다.
2. order 서비스가 "payment"라는 논리 이름을 조회한다.
3. 디스커버리 계층이 건강한 인스턴스 주소 목록을 돌려준다.
4. 로드 밸런서나 클라이언트가 한 인스턴스를 선택한다.

Kubernetes Service와 DNS, AWS Cloud Map, 서비스 레지스트리가 이 역할을 한다. 로드 밸런싱은 여러 주소 중 어디로 보낼지 결정하고, 디스커버리는 현재 어떤 주소들이 존재하는지 알려 준다는 점이 다르다.

등록 정보가 실제 상태보다 늦게 갱신되면 이미 종료된 인스턴스로 요청이 간다. 반대로 일시적 지연을 곧바로 비정상으로 판정하면 정상 인스턴스가 모두 빠질 수 있다. 헬스체크는 다음 두 의미를 구분해야 한다.

  • Liveness: 프로세스를 재시작해야 하는가?
  • Readiness: 지금 새 트래픽을 받을 준비가 되었는가?

의존 서비스 하나가 느리다는 이유로 liveness를 실패시키면 정상 프로세스가 연쇄 재시작될 수 있다. 다운스트림 상태는 readiness나 별도 의존성 지표로 표현하고, 프로세스 자체의 생존 여부와 섞지 않는 편이 안전하다.

RPS(Requests Per Second, 초당 요청 수)는 서비스가 1초에 받거나 처리하는 요청 수다. AWS API Gateway의 계정·리전별 quota와 버스트 용량은 서비스 정책과 계정 상태에 따라 달라질 수 있으므로 설계 시점의 공식 Service Quotas 문서와 계정 콘솔에서 확인한다. 핵심 학습 포인트는 quota가 API 하나가 아니라 공유 범위의 합계에 적용될 수 있다는 것이다. 예를 들어 공유 quota를 10,000 RPS로 가정한 용량 계산에서 orders-api가 9,000 RPS, users-api가 2,000 RPS를 동시에 쓰면 각 API만 봐서는 원인을 놓칠 수 있다.

Gateway p99가 1초를 넘을 때는 전체 Latency와 백엔드 IntegrationLatency를 나눠 본다.

  • Latency - IntegrationLatency가 크다: Gateway 변환, 인증, 큐잉, 네트워크 구간을 의심한다.
  • IntegrationLatency가 크다: 다운스트림 서비스와 호출 체인을 의심한다.
  • 429가 증가한다: 평균 RPS보다 quota 공유 범위, 버스트, 재시도 증폭을 확인한다.
  • 503과 p99 3,000ms 이상이 함께 증가한다: Gateway만 확장하기 전에 느린 백엔드와 연결 고갈을 확인한다.

5.5 Gateway aggregation의 지연과 부분 실패

섹션 제목: “5.5 Gateway aggregation의 지연과 부분 실패”

Gateway나 BFF가 User, Order, Recommendation 세 서비스를 병렬 호출해 대시보드를 만든다고 하자.

User p95 80ms, 필수
Order p95 120ms, 필수
Recommendation p95 600ms, 선택

병렬 호출의 정상 지연은 합이 아니라 가장 느린 호출에 가까워지지만, 하나라도 실패하면 전체를 실패시키는 구현은 가용성을 곱셈으로 낮춘다. 각 서비스 가용성이 99.9%이고 세 서비스가 모두 성공해야 한다고 단순 가정하면 합성 가용성은 다음과 같다.

0.999 * 0.999 * 0.999 = 0.997002
약 99.7%

Recommendation이 선택 정보라면 200 OK에 추천 영역만 비워 전체 가용성을 보호할 수 있다. 반면 권한을 확인하는 User 호출을 생략해서는 안 된다. 부분 응답 정책은 기술 계층이 아니라 제품 의미로 정한다.

Gateway가 응답을 집계할 때 확인할 것:

  • 필수 데이터와 선택 데이터를 구분한다.
  • 호출별 timeout을 전체 deadline 안에서 다르게 둔다.
  • 한 서비스의 retry가 전체 fan-out을 반복하지 않게 한다.
  • 부분 응답에 누락 이유와 freshness를 표현한다.
  • 대용량 payload를 Gateway 메모리에 모두 모으지 않는다.

Authentication(인증)은 “누구인가”를 확인하고, Authorization(인가)은 “이 행동을 해도 되는가”를 판단한다. Gateway는 서명, 만료, issuer 같은 토큰의 공통 인증을 처리하기 좋다. 그러나 “이 사용자가 이 주문을 환불할 수 있는가” 같은 도메인 인가는 Order나 Payment가 자신의 최신 상태로 판단해야 한다.

Gateway만 인가를 수행하면 내부 호출이 Gateway를 우회하거나 정책 cache가 stale일 때 권한이 깨질 수 있다. 반대로 모든 서비스가 토큰 파싱과 키 갱신을 제각각 구현하면 정책 drift가 생긴다.

Gateway:
JWT 서명/만료/issuer 검증
subject, tenant, coarse scope 전달
Order Service:
subject가 주문 소유자인가?
현재 상태가 환불 가능한가?
tenant 경계가 맞는가?

공통 인증과 도메인 인가를 분리하면 중앙 경계와 서비스 소유권이 함께 유지된다.

5.7 Instance drain은 디스커버리의 반쪽이다

섹션 제목: “5.7 Instance drain은 디스커버리의 반쪽이다”

새 인스턴스를 등록하는 것만큼 종료 인스턴스로 새 요청이 가지 않게 하는 과정이 중요하다. 배포 중 프로세스가 SIGTERM을 받았다고 즉시 종료하면 처리 중 요청과 메시지가 끊긴다.

종료 중 달성해야 할 목표 상태는 다음과 같다. 아래 목록은 플랫폼이 보장하는 이벤트 순서가 아니다.

트래픽 수신: readiness, load balancer, registry 관점에서 새 요청이 더 라우팅되지 않음
작업 수신: 새 메시지 polling과 새 장기 작업 시작이 중지됨
진행 중 작업: 제한 시간 안에 완료되거나 안전하게 재처리 가능한 상태로 남음
자원 정리: 연결이 닫히고 필요한 telemetry가 flush됨
프로세스 수명: hard termination deadline 전에 종료됨

플랫폼에 따라 readiness 변경, load balancer·registry deregistration 반영, SIGTERM 전달은 서로 경합(race)할 수 있다. 애플리케이션은 특정 순서를 전제하지 말고 어떤 신호를 먼저 관측해도 새 작업 수신을 멈추고 제한된 drain을 시작해야 한다. 이 경계는 Twelve-Factor App의 교체 가능한 프로세스와 disposability 원칙을 서비스 디스커버리 환경에 적용한 것이다.

termination grace period가 최장 요청 시간보다 짧으면 정상 drain이 불가능하다. 반대로 너무 길면 잘못된 인스턴스 교체가 늦어진다. queue consumer는 visibility timeout과 종료 시간을 함께 맞춰야 한다. 처리 중 종료돼 메시지가 다시 나타나는 것은 정상일 수 있으므로 멱등성이 마지막 방어선이다.

5.8 Service Discovery failure counterexamples

섹션 제목: “5.8 Service Discovery failure counterexamples”

오래된 주소: DNS TTL이 60초인데 인스턴스가 5초 만에 종료되면 클라이언트 cache가 최대 60초 동안 죽은 주소를 볼 수 있다. connection pool이 DNS를 다시 조회하지 않으면 더 오래 지속될 수 있다.

flapping: readiness 기준이 너무 민감해 인스턴스가 등록과 제외를 반복하면 남은 인스턴스에 트래픽이 출렁인다. 연속 성공·실패 횟수와 stabilization window가 필요할 수 있다.

모든 인스턴스 동시 제외: 공통 다운스트림 DB가 느릴 때 모든 서비스 인스턴스 readiness가 DB 상태를 그대로 반영하면, 서비스 자체는 fallback 가능해도 전체 endpoint가 사라질 수 있다. readiness에 포함할 의존성은 “없으면 어떤 요청도 의미 있게 처리할 수 없는가”로 고른다.

지역 경계 무시: 멀티 리전에서 가장 가까운 인스턴스만 고르면 데이터의 쓰기 리전과 어긋날 수 있다. 주소 선택은 지연뿐 아니라 데이터 일관성과 failover 정책을 따라야 한다.


6. 회복성: 실패를 없애지 말고 범위를 제한한다

섹션 제목: “6. 회복성: 실패를 없애지 말고 범위를 제한한다”

MSA에서 회복성(Resilience)은 모든 호출을 성공시키는 능력이 아니다. 일부 서비스가 느리거나 실패해도 전체 시스템의 자원과 핵심 기능을 보호하는 능력이다.

타임아웃이 없으면 호출자는 다운스트림이 복구될 때까지 요청 슬롯, 연결, 메모리를 붙잡는다. 타임아웃은 “몇 초 뒤 포기”가 아니라 상위 요청의 전체 시간 예산을 하위 호출에 배분하는 계약이다.

예를 들어 사용자 응답 SLO가 1,000ms라면 결제 호출 하나에 3,000ms 타임아웃을 주는 것은 모순이다. Gateway, 주문 처리, 직렬화, fallback 시간을 빼고 결제에 쓸 수 있는 예산을 계산해야 한다.

6.2 재시도는 조건부로만 안전하다

섹션 제목: “6.2 재시도는 조건부로만 안전하다”

재시도는 일시적 네트워크 오류나 throttling에는 도움이 되지만 다음 조건이 필요하다.

  • 작업이 멱등적이거나 idempotency key가 있다.
  • 전체 deadline 안에 끝난다.
  • 지수 백오프와 jitter(재시도 시점을 흩는 무작위 지연)가 있다.
  • 호출 그래프에서 한 계층만 재시도 책임을 가진다.
  • 영구 오류와 비즈니스 거절은 재시도하지 않는다.

결제 요청이 타임아웃됐다고 새 키로 다시 보내면 첫 요청도 성공했을 수 있어 이중 결제가 생긴다. 같은 주문 ID 기반 키로 결과를 조회하거나 재사용해야 한다.

6.3 Circuit Breaker: 복구 가능성을 탐지하며 빠르게 실패한다

섹션 제목: “6.3 Circuit Breaker: 복구 가능성을 탐지하며 빠르게 실패한다”

Circuit Breaker(회로 차단기)는 실패율이나 지연이 임계치를 넘으면 호출을 잠시 차단해 자원 고갈과 연쇄 장애를 막는 패턴이다.

Closed
정상 호출 허용
실패 임계치 도달
|
v
Open
호출 즉시 거절 또는 fallback
cooldown 경과
|
v
Half-Open
제한된 탐색 요청만 허용
성공 -> Closed
실패 -> Open

Half-Open이 필요한 이유는 복구를 확인해야 하기 때문이다. 영구 차단은 복구된 서비스도 사용하지 못하게 하고, 전체 트래픽을 한 번에 다시 열면 아직 불안정한 서비스에 부하가 몰린다.

설정 의미를 익히기 위한 시작값으로 timeout 3,000ms, errorThresholdPercentage 50%, resetTimeout 30,000ms, volumeThreshold 5를 놓을 수 있다. 이는 복사할 권장값이 아니라 튜닝 항목을 보여 주는 예다.

설정 반례관찰 결과해석
실패율 10%, timeout 500ms네트워크 jitter나 GC pause에도 자주 Openfalse positive, 정상 서비스 차단
실패율 80%, 최소 100건심각한 장애 중에도 오래 Closedfalse negative, 연쇄 실패 허용
reset 30초 고정회복 전 Half-Open이 계속 실패30초부터 최대 5분까지 지수 증가 검토

타임아웃을 “서비스 p99의 3~5배”로 두라는 휴리스틱은 상위 deadline을 넘지 않을 때만 출발점이 된다. 더 중요한 기준은 실제 지연 분포, 실패 비용, fallback 가능성, 전체 요청 예산이다.

6.4 Circuit Breaker만으로는 부족하다

섹션 제목: “6.4 Circuit Breaker만으로는 부족하다”
  • Bulkhead(격벽): 서비스별 연결 풀·동시 실행 수를 나눠 한 의존성이 모든 자원을 쓰지 못하게 한다.
  • Load shedding(부하 차단): 감당할 수 없는 요청을 일찍 거절해 이미 처리 중인 요청을 보호한다.
  • Fallback: 오래된 캐시나 PENDING 상태처럼 기능을 낮춰 응답한다.
  • Rate limiting(속도 제한): 호출자별 사용량을 제한한다.

Fallback은 반드시 의미가 맞아야 한다. 결제 실패를 성공으로 위장하면 회복성이 아니라 데이터 오류다. “결제 처리 중”으로 저장하고 후속 조회를 제공하는 것은 가능하지만, 사용자가 완료로 오해하지 않게 상태를 명시해야 한다.

6.5 Worked Example: 느린 결제가 전체 장애로 번지는 과정

섹션 제목: “6.5 Worked Example: 느린 결제가 전체 장애로 번지는 과정”
Circuit Breaker 없음:
1. 결제 응답이 30초 이상으로 느려진다.
2. Order와 Gateway가 연결을 붙잡고 기다린다.
3. 연결 풀과 요청 슬롯이 고갈된다.
4. 상품 조회처럼 결제와 무관한 요청도 503을 받는다.
Circuit Breaker + Bulkhead:
1. 결제 전용 동시 요청 수를 제한한다.
2. 5회 이상 관측 후 실패율 임계치를 넘으면 Open한다.
3. 후속 호출은 약 10ms 안에 PENDING fallback을 받는다.
4. 30초 뒤 일부 탐색 요청으로 복구를 확인한다.

숫자는 예시이며 실제 임계치는 트래픽과 SLO로 정한다. 학습 포인트는 Circuit Breaker가 결제를 고치는 것이 아니라 대기 자원을 보호해 장애 범위를 제한한다는 점이다.

6.6 잘못된 헬스체크는 장애를 확대한다

섹션 제목: “6.6 잘못된 헬스체크는 장애를 확대한다”

의존 서비스가 실패했다고 현재 서비스의 liveness까지 실패시키면 오케스트레이터가 정상 인스턴스를 재시작한다. 재시작으로 캐시와 연결 풀이 비워지고, 남은 인스턴스에 트래픽이 몰리며, 복구가 더 늦어진다.

예를 들어 ECS 헬스체크에 30초 간격, 5초 timeout, 3회 실패, 60초 start period를 시작값으로 둘 수 있다. 이 값은 환경별 예시이므로 그대로 복사하기보다 다음 질문으로 조정한다.

  • 프로세스가 실제로 멈췄는가, 다운스트림만 느린가?
  • 새 인스턴스가 준비되는 시간보다 start period가 짧지 않은가?
  • 모든 인스턴스가 같은 순간 실패 판정을 받아 동시에 교체되지 않는가?
  • readiness 제외만으로 충분한데 왜 재시작까지 하는가?

6.7 가용성은 호출 그래프에서 합성된다

섹션 제목: “6.7 가용성은 호출 그래프에서 합성된다”

서비스 하나의 가용성만 보고 사용자 흐름의 가용성을 알 수 없다. 직렬 의존성이 모두 성공해야 한다면 단순 모델에서 확률을 곱한다.

Order 99.95%
Payment 99.9%
Inventory 99.9%
0.9995 * 0.999 * 0.999
= 0.997501...
= 약 99.75%

각 서비스가 “three nines”에 가까워 보여도 전체 주문 흐름은 더 낮다. 실제 실패는 독립적이지 않아 공통 DB, 리전, 배포가 함께 실패할 수 있으므로 곱셈보다 더 나쁠 수 있다.

가용성을 높이는 방법은 모든 서비스를 무작정 four nines로 만드는 것만이 아니다.

  • 사용자 흐름에 필요 없는 동기 의존성을 제거한다.
  • 선택 데이터는 부분 응답으로 낮춘다.
  • 공통 장애 도메인을 분리한다.
  • timeout과 bulkhead로 장애 전파를 막는다.
  • 재시도 가능한 상태와 비동기 완료를 제품에 표현한다.

6.8 Error budget으로 회복성 비용을 판단한다

섹션 제목: “6.8 Error budget으로 회복성 비용을 판단한다”

SLO가 월 99.9%라면 단순 계산으로 약 43분의 오류 예산이 있다.

30일 * 24시간 * 60분 = 43,200분
0.1% = 43.2분

이 예산은 “43분 장애가 허용된다”는 면허가 아니다. 기능 출시 속도와 신뢰성 투자를 같은 단위로 대화하기 위한 장치다. 결제 timeout이 한 달 오류 예산의 절반을 하루에 소진한다면 breaker 임계치 미세 조정보다 결제 의존성 구조와 fallback 상태를 먼저 바꿔야 한다.

서비스별 SLO가 있어도 사용자 journey의 SLO가 없으면 팀마다 자기 대시보드는 초록색인데 주문은 실패할 수 있다. Gateway에서 시작해 Order, Payment, Inventory로 이어지는 대표 흐름을 별도 SLI(Service Level Indicator)로 관측한다.

정상 트래픽이 초당 1,000 요청이고 Payment가 20% 실패한다고 하자. 모든 실패를 즉시 한 번 재시도하면 Payment는 추가 200 RPS를 받는다. 재시도도 20% 실패한다고 단순 가정하면 다시 재시도할 때 40 RPS가 더해진다.

원 요청 1,000 RPS
첫 재시도 200 RPS
둘째 재시도 40 RPS
합계 1,240 RPS

장애로 처리량이 줄어든 서비스에 24% 추가 부하를 보내는 셈이다. 여러 상위 계층이 각각 재시도하면 더 커진다. Retry budget은 전체 요청 중 재시도에 쓸 수 있는 비율을 제한한다. 예를 들어 5% 예산을 두면 재시도 폭주가 정상 요청을 밀어내는 일을 줄일 수 있다. 숫자는 예시이며 실패 유형과 복구 시간을 측정해 정한다.

6.10 Timeout은 percentile과 deadline으로 정한다

섹션 제목: “6.10 Timeout은 percentile과 deadline으로 정한다”

timeout = 평소 p99의 3배 같은 규칙은 출발점일 뿐이다. 너무 짧으면 정상 tail latency를 실패로 만들고, 너무 길면 자원을 붙잡는다.

설정 순서:

  1. 사용자 또는 상위 호출의 전체 deadline을 정한다.
  2. 네트워크, Gateway, 직렬화, 상위 로직 예산을 뺀다.
  3. 다운스트림의 실제 지연 분포와 cold start를 본다.
  4. 재시도 횟수와 backoff가 남은 예산 안에 드는지 계산한다.
  5. timeout 뒤 작업 결과를 어떻게 확인할지 정한다.

HTTP client timeout만 두고 DB query나 queue publish에는 timeout이 없으면 전체 deadline이 지켜지지 않는다. 반대로 모든 계층이 같은 3초를 가지면 안쪽 호출이 끝났을 때 바깥 deadline이 이미 소진될 수 있다. deadline을 하위 호출로 전파하고 남은 시간을 줄여야 한다.

6.11 Fallback의 신선도와 용량을 검증한다

섹션 제목: “6.11 Fallback의 신선도와 용량을 검증한다”

fallback cache가 있어도 다음 두 조건을 놓치면 장애를 숨기기만 한다.

신선도: 상품 설명은 5분 stale이어도 괜찮을 수 있지만 재고와 권한은 그렇지 않다. 응답에 asOf 시간을 포함하거나 stale 상태를 명시한다.

headroom: 평소 요청의 80%를 Redis가 처리하고 DB가 20%만 처리하는데 Redis 장애 시 DB가 100%를 감당하지 못하면 fallback이 아니라 부하 증폭 경로다.

평소 총 1,000 RPS
cache hit 80% -> DB 200 RPS
cache 장애 -> DB 1,000 RPS
DB 안전 용량 500 RPS
결과: cache 장애가 DB 장애로 확대

회복성 설계는 fallback 코드 존재가 아니라 fallback 대상의 의미와 용량을 검증해야 완성된다.


7. 데이터 소유권: 서비스는 자기 상태의 유일한 작성자다

섹션 제목: “7. 데이터 소유권: 서비스는 자기 상태의 유일한 작성자다”

Database per Service는 서비스마다 반드시 별도 DB 서버를 사라는 뜻이 아니다. 핵심은 데이터 스키마의 논리적 소유권과 쓰기 권한이 하나의 서비스에만 있다는 것이다. 같은 PostgreSQL 클러스터를 쓰더라도 스키마와 계정 권한을 분리해 다른 서비스가 테이블을 직접 수정하지 못하게 할 수 있다.

공유 DB:
Order Service ----+
+--> shared.users, shared.orders
User Service -----+
User가 users 스키마 변경
-> Order의 SQL이 깨짐
-> 독립 배포 불가
소유권 분리:
Order Service ---> Order schema/DB
User Service ---> User schema/DB
Order는 User의 공개 API나 이벤트만 사용
-> 내부 스키마 변경이 계약 밖으로 새지 않음

서비스가 독립 DB를 가져도 데이터 소유권이 자동으로 생기지는 않는다. 다른 서비스가 DB 계정을 받아 직접 조회하거나, 공용 ETL이 운영 테이블을 수정하거나, 두 팀이 같은 이벤트의 의미를 임의로 바꾸면 경계는 다시 무너진다.

7.2 조인 대신 어떤 선택을 하는가

섹션 제목: “7.2 조인 대신 어떤 선택을 하는가”

DB 경계를 나누면 orders JOIN users 같은 즉석 조인이 사라진다. 대안은 요구사항에 따라 다르다.

  1. 동기 API 조합: 최신 사용자 이름이 꼭 필요하고 호출 수가 작을 때 사용한다.
  2. 이벤트 기반 로컬 복제: 주문 조회에 필요한 사용자 표시 이름을 주문 읽기 모델에 복제한다.
  3. Gateway/BFF 집계: 화면 응답을 위해 여러 서비스의 읽기 API를 병렬 호출한다.
  4. 분석 저장소: 운영 트랜잭션과 분리한 웨어하우스에서 여러 서비스 데이터를 조인한다.

복제는 중복을 만든다. 그러나 중복 자체가 실패는 아니다. 어느 서비스가 원본인지, 갱신 지연을 얼마나 허용하는지, 삭제·정정 이벤트를 어떻게 반영하는지가 명확하면 중복은 결합을 낮추는 비용이 될 수 있다.

7.3 Worked Example: 주문에 사용자 이름을 보여 주기

섹션 제목: “7.3 Worked Example: 주문에 사용자 이름을 보여 주기”

주문 목록마다 User Service를 동기 호출하면 User 장애가 Order 조회 장애가 된다. 주문 20개를 각자 조회하는 N+1 호출은 피해야 한다.

선택 A: 요청 시 User API batch 조회
장점: 최신 이름
비용: User 지연과 장애가 조회 경로에 포함
선택 B: UserProfileChanged 이벤트로 displayName 복제
장점: Order가 독립 조회
비용: 수 초간 과거 이름이 보일 수 있음
선택 C: 주문 당시 이름 snapshot 저장
장점: 영수증의 역사적 사실 보존
비용: 현재 프로필 이름과 달라도 의도된 결과

“중복을 없애자”보다 제품 의미를 먼저 묻는다. 영수증은 주문 당시 이름이 맞고, 계정 화면은 현재 이름이 맞을 수 있다. 같은 필드처럼 보여도 일관성 요구가 다르다.

7.4 로컬 트랜잭션 밖으로 나간 순간

섹션 제목: “7.4 로컬 트랜잭션 밖으로 나간 순간”

Order DB 저장과 메시지 발행을 다음처럼 따로 실행하면 중간 실패가 생긴다.

1. orders INSERT 성공
2. OrderCreated 메시지 발행 실패
3. 주문은 있지만 결제 서비스는 주문을 모름

Transactional Outbox는 주문 row와 outbox row를 하나의 로컬 트랜잭션에 저장하고 별도 relay가 메시지를 발행해 이 간극을 줄인다. relay는 같은 메시지를 두 번 발행할 수 있으므로 소비자 멱등성은 여전히 필요하다. 이 문서에서는 “데이터 소유권을 나누면 상태 전파 메커니즘이 필요하다”까지만 잡는다.

MSA 문서에서 Saga와 CQRS(Command Query Responsibility Segregation)를 모두 구현하려 하면 서비스 경계보다 후속 패턴이 본문을 압도한다. 여기서는 필요 신호와 넘겨받을 질문만 정리한다.

Saga가 필요한 신호

  • 여러 서비스의 로컬 트랜잭션이 하나의 비즈니스 결과를 만든다.
  • 중간 실패 시 이미 커밋된 단계를 의미 있는 역연산으로 보상해야 한다.
  • PENDING, COMPENSATING, FAILED 같은 장기 실행 상태를 추적해야 한다.
Create Order -> Charge Payment -> Reserve Inventory
실패
Refund Payment <- Cancel Order <- 보상

Saga는 단일 DB의 ROLLBACK을 흉내 내는 것이 아니다. 보상도 실패할 수 있고 외부 세계의 효과를 완전히 지우지 못할 수 있다. Choreography와 Orchestration, 격리 부재, 보상 실패는 다음 문서인 Saga Pattern에서 다룬다.

CQRS가 필요한 신호

  • 쓰기 모델의 불변식과 읽기 화면의 조인·정렬 요구가 크게 다르다.
  • 읽기와 쓰기 부하를 독립 확장할 실측 근거가 있다.
  • 읽기 모델의 동기화 지연을 제품이 표현할 수 있다.
Command -> Order DB -> OrderChanged event -> Query projection
Query ------------------------------------> Read model

projection(프로젝션)은 이벤트를 읽어 조회 목적의 현재 상태나 테이블로 투영한 결과다.

CQRS는 서비스마다 필수인 패턴이 아니다. 단순 CRUD에서 모델과 저장소를 둘로 나누면 지연과 운영 지점만 늘어난다. 구체 구현과 Event Sourcing과의 관계는 CQRS & Event Sourcing으로 넘긴다.

7.6 데이터 일관성은 필드가 아니라 업무 결정별로 정한다

섹션 제목: “7.6 데이터 일관성은 필드가 아니라 업무 결정별로 정한다”

“Order Service는 강한 일관성, Catalog Service는 최종 일관성”처럼 서비스 전체에 하나의 라벨을 붙이면 너무 거칠다. 같은 서비스 안에서도 결정별 요구가 다르다.

업무 결정허용 가능한 관찰적합한 접근
결제 승인 중복 방지이전 승인 결과를 반드시 확인idempotency key, 강한 쓰기 제약
재고 1개 예약동시에 2명이 성공하면 안 됨조건부 갱신, local transaction
상품 설명 표시수 초 전 문구 허용cache/read replica
주문 직후 영수증방금 쓴 주문이 보여야 함writer read, command response
추천 점수늦게 수렴해도 됨비동기 projection
주문 당시 배송지이후 프로필 변경과 무관immutable snapshot

서비스 분리는 “어떤 DB를 쓸까”보다 각 업무 결정이 어떤 거짓말을 허용하는지 드러낸다. 한 화면에 여러 일관성 수준이 섞일 수 있으므로 API 응답 상태와 freshness를 제품 언어로 표현한다.

7.7 데이터 중복과 이중 쓰기를 구분한다

섹션 제목: “7.7 데이터 중복과 이중 쓰기를 구분한다”

데이터 중복은 여러 Context가 자기 목적에 맞는 표현을 갖는 것이고, 이중 쓰기(dual write)는 한 요청 코드가 서로 다른 저장소 두 곳을 각각 갱신하는 것이다.

의도된 중복:
User가 UserProfileChanged 발행
Order read model이 displayName 복제
위험한 이중 쓰기:
Order 요청 코드가
1. Order DB UPDATE
2. User DB UPDATE
둘 중 하나만 성공할 수 있음

중복은 원본과 전파 지연을 설계하면 경계를 보호한다. 이중 쓰기는 원자성 간극을 만들어 서비스 소유권을 깨뜨린다. Outbox와 이벤트가 필요한 이유는 중복을 없애기 위해서가 아니라 소유 서비스의 커밋을 다른 Context에 안전하게 전파하기 위해서다.

7.8 공유 DB를 단계적으로 끊는 방법

섹션 제목: “7.8 공유 DB를 단계적으로 끊는 방법”

기존 모놀리스에서 서비스를 추출할 때 바로 물리 DB를 나누지 못할 수 있다. 다음 단계를 밟을 수 있다.

1. 테이블 소유자 지정
orders는 Order 모듈만 쓰기
2. 코드 접근 차단
다른 모듈은 Order repository를 import하지 않음
3. DB 권한 분리
다른 서비스 계정의 write 권한 제거
4. 공개 API/event로 읽기 경로 전환
직접 JOIN 제거
5. schema 또는 instance 분리
논리 경계 검증 뒤 물리 이동

물리 분리를 먼저 하면 남아 있는 cross-schema query와 batch가 배포 중 발견돼 장애가 난다. 먼저 소유권과 접근 경로를 바꾸고, 관측으로 숨은 의존성을 찾은 뒤 이동한다.

7.9 데이터 복제 지연의 수치 감각

섹션 제목: “7.9 데이터 복제 지연의 수치 감각”

이벤트 relay가 평균 100ms, broker 대기 200ms, consumer 처리 300ms라면 정상 projection 지연은 약 600ms다. consumer가 5분 중단되면 처리율이 유입률보다 충분히 높아야 따라잡을 수 있다.

유입률 500 events/s
복구 후 처리율 800 events/s
순감소 300 events/s
5분 backlog = 500 * 300 = 150,000 events
해소 시간 = 150,000 / 300 = 500초, 약 8분 20초

consumer가 복구됐다는 사실과 read model이 최신이라는 사실은 다르다. health는 초록이어도 lag가 8분 남을 수 있다. 사용자에게 필요한 것은 프로세스 상태가 아니라 데이터 freshness다.

7.10 삭제와 정정도 이벤트 계약이다

섹션 제목: “7.10 삭제와 정정도 이벤트 계약이다”

생성 이벤트만 설계하고 삭제·정정 경로를 빼면 복제본이 영원히 남는다. 개인정보 삭제, 사용자 이름 정정, 주문 취소가 각 projection과 검색 인덱스에 어떻게 전달되는지 정한다.

UserDeleted를 받았을 때 모든 소비자가 row를 물리 삭제해야 하는 것은 아니다. 영수증처럼 법적 보존이 필요한 Context는 개인정보를 제거한 역사 snapshot을 유지할 수 있다. 삭제 의미도 Bounded Context마다 다르며, 규정과 데이터 소유자가 계약을 정해야 한다.

7.11 Saga를 시작하기 전 보상 가능성을 확인한다

섹션 제목: “7.11 Saga를 시작하기 전 보상 가능성을 확인한다”

모든 작업에 정확한 역연산이 있는 것은 아니다.

  • 카드 승인은 취소할 수 있지만 이미 발생한 수수료나 환율 차이는 남을 수 있다.
  • 이메일 발송은 보낸 사실을 지울 수 없다. 정정 메일을 보내는 새 행동만 가능하다.
  • 외부 배송 요청은 출고 전에는 취소할 수 있지만 출고 뒤에는 반품 프로세스가 된다.

따라서 Saga 단계는 기술 API가 아니라 비즈니스 보상 의미로 나눈다. 보상 불가능한 작업은 가능한 한 뒤로 미루거나, 사람 개입 상태와 reconciliation(대사) 절차를 설계한다. 상세 상태 머신은 후속 Saga 문서로 넘긴다.


8. Service Mesh와 Edge의 정확한 위치

섹션 제목: “8. Service Mesh와 Edge의 정확한 위치”

8.1 Service Mesh는 서비스 경계를 만들어 주지 않는다

섹션 제목: “8.1 Service Mesh는 서비스 경계를 만들어 주지 않는다”

Service Mesh는 내부 서비스 간 East-West 트래픽에 보안, 라우팅, 재시도, 메트릭 정책을 적용하는 인프라 계층이다. 대표 구현인 Istio의 sidecar mode에서는 Envoy sidecar proxy를 각 workload 옆에 두고 데이터 평면(Data Plane)에서 트래픽을 처리하며, 제어 평면(Control Plane)이 정책을 배포한다. xDS는 이때 제어 평면이 Envoy에 동적 설정을 전달하는 API 집합이다.

[Order process] -> [Envoy] ==== mTLS ==== [Envoy] -> [Payment process]
^ ^
+--- Istiod ------+

mTLS(mutual TLS)는 양쪽이 서로 인증서를 검증하는 TLS 통신이다. Mesh는 언어별 라이브러리에 재시도·mTLS·메트릭 코드를 반복하는 문제를 줄인다. 예를 들어 서비스 30개가 각자 네트워크 SDK를 쓴다면 정책 변경 때 30개를 재배포할 수 있지만, mesh에서는 프록시 정책으로 중앙 제어할 수 있다.

그러나 mesh는 잘못된 서비스 경계, 공유 DB, 순환 호출을 고치지 못한다. 오히려 재시도를 중앙에서 쉽게 켤 수 있어 부하 증폭을 숨기거나, sidecar CPU·메모리와 제어 평면 장애라는 새 실패 지점을 만든다.

“서비스 10개 이상이면 검토, 5개 미만이면 이르다” 같은 수치는 토론 시작용 휴리스틱일 뿐 도입 조건이 아니다. 서비스 수보다 다음 증거가 중요하다.

  • 언어별 통신 정책 불일치가 실제 장애를 만든다.
  • 서비스 간 인증서 수명주기와 mTLS가 필수다.
  • 카나리 라우팅과 트래픽 관측을 공통 계층에서 관리할 운영팀이 있다.
  • sidecar 자원과 두 개의 디버깅 표면을 감당할 수 있다.
패턴트래픽핵심 책임주의할 오해
API Gateway외부 -> 내부, North-South외부 계약, 인증 경계, 라우팅, 제한비즈니스 규칙을 모두 넣지 않는다
Service Mesh내부 서비스 간, East-West서비스 간 보안·트래픽 정책·관측서비스 분해 문제를 해결하지 않는다
Load Balancer여러 인스턴스 앞건강한 대상에 트래픽 분산서비스 의미와 계약은 모른다
Service Discovery내부 주소 조회현재 인스턴스 위치 제공건강 판정의 지연과 오탐을 고려한다

Gateway와 mesh는 경쟁 제품이 아니다. 필요한 트래픽 경계가 다르며, 둘 다 필요하지 않은 시스템도 많다.

8.3 Edge Computing은 전처리 계층이다

섹션 제목: “8.3 Edge Computing은 전처리 계층이다”

Edge Computing은 CDN(Content Delivery Network) 가까운 위치에서 짧은 로직을 실행해 원본까지의 왕복과 부하를 줄인다. MSA의 서비스 분해 패턴이라기보다 클라이언트와 Gateway 사이의 전처리 계층이다.

[사용자] -> [Edge: 헤더/리다이렉트/지역 라우팅] -> [Gateway] -> [서비스]

CloudFront는 여러 PoP(Point of Presence, 사용자와 가까운 엣지 접점)와 리전 엣지 캐시를 운영하고, CloudFront Functions는 짧은 URL·헤더 작업에 맞는다. PoP·리전 엣지 캐시 수와 실행 제한은 변동 가능한 제품 정보이므로 설계 시점의 AWS 공식 문서에서 확인한다. 네트워크·파일 시스템·환경변수 접근이 필요한 로직은 제한된 edge function에 억지로 넣지 않는다. 인증의 최종 권한 판단, 결제 규칙, 데이터베이스 트랜잭션은 소유 서비스에 남긴다.


9. 모듈러 모놀리스가 더 나은 때

섹션 제목: “9. 모듈러 모놀리스가 더 나은 때”

모듈러 모놀리스(Modular Monolith)는 하나의 프로세스와 배포 단위를 유지하면서 내부 모듈의 공개 API, 데이터 접근, 의존 방향을 강하게 제한한 구조다. MSA의 반대말이 아니라 경계를 검증하는 단계이자 장기적으로도 유효한 선택이다.

[하나의 배포 단위]
orders module -> public port/event만 노출
payments module -> 자체 schema 소유
inventory module-> 내부 타입 직접 import 금지

9.1 모듈러 모놀리스가 유리한 조건

섹션 제목: “9.1 모듈러 모놀리스가 유리한 조건”
  • 한 팀 또는 소수 팀이 대부분의 기능을 함께 배포한다.
  • 도메인 경계가 아직 자주 바뀐다.
  • 서비스별 부하와 SLO 차이가 크지 않다.
  • 한 트랜잭션에서 지켜야 할 불변식이 많다.
  • 분산 추적, 자동 배포, on-call 소유권이 아직 준비되지 않았다.
  • 함수 호출과 로컬 디버깅의 단순성이 제품 속도에 더 중요하다.
  • 특정 Context의 배포가 전체 릴리스를 반복해서 막는다.
  • 한 영역의 트래픽이 전체와 다른 곡선을 보이고 독립 확장 효과가 크다.
  • 장애 격리가 명시적 제품 요구다.
  • 팀이 API·데이터·배포·운영을 끝까지 소유할 수 있다.
  • 데이터와 호출 경계를 이미 모듈 수준에서 검증했다.

10명 이하, 30명 이상, 서비스 5개 미만 같은 숫자는 경고용 휴리스틱으로만 사용한다. 팀 30명이어도 하나의 제품 흐름을 함께 변경하면 MSA가 비쌀 수 있고, 팀 8명이어도 규제상 격리된 결제 서비스를 운영해야 할 수 있다. 숫자보다 독립 변경의 빈도와 실패 비용을 측정해야 한다.

9.3 Segment 사례가 보여 주는 역행 가능성

섹션 제목: “9.3 Segment 사례가 보여 주는 역행 가능성”

Twilio Segment는 destination 처리에서 한 destination의 장애가 전체 delivery를 지연시키는 head-of-line blocking을 해결하려고 destination별 큐와 서비스로 분리했다. 장애 격리라는 실제 이득이 있었다. 그러나 서비스가 140개를 넘자 서비스·큐·저장소 운영 비용과 공유 라이브러리 변경 비용이 선형으로 늘었다.

이후 일부 영역을 다시 통합했고, 140개 이상 destination 테스트를 밀리초 단위로 실행할 수 있게 되었으며, 공유 라이브러리 개선 건수도 1년 뒤 32건에서 46건으로 늘었다고 보고했다. 이 사례의 결론은 “MSA가 실패했다”가 아니다.

  • 분리는 head-of-line blocking과 장애 전파를 줄였다.
  • 너무 세밀한 배포 경계는 공유 변경을 140번의 배포로 바꿨다.
  • 경계를 합치는 것도 아키텍처 실패가 아니라 비용 구조 변화에 대한 대응이다.

아키텍처는 일방통행이 아니다. 서비스 경계는 가설이며, 독립 배포율·변경 동시성·장애 전파·운영 시간을 측정해 나누거나 합칠 수 있어야 한다. 출처: Twilio Segment - Goodbye Microservices.

9.4 비용을 수치로 비교하는 방법

섹션 제목: “9.4 비용을 수치로 비교하는 방법”

다음 비교는 MSA가 추가하는 비용의 규모감을 보여 준다.

항목모놀리스MSA에서 추가되는 비용
호출함수 호출 약 1μsHTTP 호출 약 1ms와 직렬화·프록시·원격 처리
운영 객체서비스 1개서비스별 배포 정의, 대시보드, 경보, 라우팅 대상
장애 추적단일 로그에서 약 15분 예시분산 추적 부재 시 45분~2시간 예시

이 숫자는 보편적 벤치마크가 아니라 비교 항목을 드러내는 예시다. 실제 의사결정에서는 다음을 2~4주 측정한다.

  • 한 변경에 같이 수정되는 모듈과 서비스 수
  • 독립 배포한 비율과 lockstep 배포 비율
  • 서비스별 p95/p99 지연과 오류 예산
  • 장애의 최초 원인부터 사용자 영향까지 전파 경로
  • 팀이 배포·경보·스키마 관리에 쓰는 시간

독립 배포가 거의 없고 호출 지연과 운영 시간이 계속 늘어난다면 물리적 분리의 근거가 약하다. 반대로 한 Context가 전체 배포의 병목이고 독립 확장으로 비용을 크게 줄일 수 있다면 추출 근거가 강하다.

9.5 Strangler 방식으로 점진적으로 추출한다

섹션 제목: “9.5 Strangler 방식으로 점진적으로 추출한다”

Strangler Fig pattern은 기존 모놀리스를 한 번에 교체하지 않고, 진입 경로를 조금씩 새 서비스로 돌려 기존 기능을 줄이는 마이그레이션 방식이다.

초기:
Client -> Monolith
전환 중:
Client -> Gateway -> /catalog -> Catalog Service
`-> 나머지 -> Monolith
완료:
새 경계의 트래픽과 데이터 소유권이 안정됨
-> Monolith의 catalog 코드 제거

한 번에 DB까지 복제·전환하면 오류 원인을 찾기 어렵다. 경로별로 관측과 rollback 지점을 둔다.

shadow traffic은 실제 요청의 복사본을 새 경로에도 보내 결과와 성능을 비교하되, 새 경로의 응답은 사용자 결과에 반영하지 않는 검증 방식이다.

  1. 모놀리스 안에서 모듈 경계를 먼저 만든다.
  2. read-only 또는 실패 비용이 낮은 경로를 새 서비스로 옮긴다.
  3. shadow traffic으로 결과를 비교하되 사용자 응답에는 쓰지 않는다.
  4. 작은 비율의 실제 트래픽을 전환한다.
  5. 오류율, 지연, 데이터 차이를 확인한다.
  6. 쓰기 소유권을 하나로 정리한 뒤 이전 경로를 제거한다.

두 시스템에 쓰기를 동시에 보내고 “나중에 맞추자”고 하면 dual write 문제가 생긴다. 전환 중에도 쓰기의 원본은 하나여야 하고, 다른 쪽은 이벤트·CDC(Change Data Capture) 같은 명시적 복제 경로로 따라가야 한다.

9.6 추출 우선순위를 정하는 scorecard

섹션 제목: “9.6 추출 우선순위를 정하는 scorecard”

다음 점수는 자동 결정 공식이 아니라 토론을 구체화하는 예시다. 각 항목을 0~2점으로 기록한다.

질문0점1점2점
독립 배포 압력거의 없음월 몇 회 충돌주 단위 반복 병목
독립 확장 효과부하 유사피크가 다름비용 차이가 큼
장애 격리 가치함께 실패해도 됨일부 저하 필요핵심 흐름 보호 필수
경계 안정성자주 이동주요 언어는 안정계약과 소유자 명확
운영 준비공통 배포도 수동일부 자동화SLO/on-call/self-service
트랜잭션 결합같은 DB 원자성 필수일부 비동기 가능독립 상태가 자연스러움

합계가 높아도 법·보안·조직 제약을 별도로 확인하고, 합계가 낮다면 먼저 모듈화를 시도한다. 숫자의 목적은 “12점이면 MSA”가 아니라 서로 다른 이득과 비용을 같은 표에서 드러내는 것이다.

9.7 되돌릴 수 있는 경계를 설계한다

섹션 제목: “9.7 되돌릴 수 있는 경계를 설계한다”

서비스를 합치는 일을 실패로 취급하면 팀은 잘못된 경계를 유지하게 된다. 되돌릴 수 있으려면 다음이 필요하다.

  • 공개 계약과 내부 모델을 분리해 구현 위치를 바꿀 수 있다.
  • 데이터 소유자를 명확히 해 병합 migration 범위를 안다.
  • 서비스별 지표로 분리의 실제 이득을 비교한다.
  • 공용 플랫폼이 저장소 수에 비례해 수동 작업을 만들지 않는다.
  • ADR(Architecture Decision Record)에 기대 이득과 재검토 조건을 남긴다.

예를 들어 “독립 배포율이 30% 미만이고 3개월간 모든 변경이 Order와 lockstep이면 병합 검토”처럼 재검토 조건을 미리 둘 수 있다. 수치는 팀 맥락의 가설이며 실제 데이터로 수정한다.

9.8 플랫폼은 서비스 수의 고정비를 낮춘다

섹션 제목: “9.8 플랫폼은 서비스 수의 고정비를 낮춘다”

서비스 하나를 추가할 때 저장소, CI/CD, secret, dashboard, alert, runtime, on-call 문서를 매번 수동으로 만들면 운영 비용이 서비스 수에 거의 선형으로 증가한다. 플랫폼은 golden path(조직이 검증해 둔 표준 생성·배포·관측 경로)를 제공해 이 고정비를 낮춘다.

하지만 템플릿이 있다고 운영 소유권이 생기지는 않는다. 자동 생성된 대시보드가 비즈니스 실패를 모르고, 기본 retry가 비멱등 작업을 반복할 수 있다. 플랫폼은 공통 메커니즘을 제공하고 서비스 팀은 의미와 임계치를 소유해야 한다.

서비스 추출 전 확인할 self-service 항목:

  • 표준 build/deploy/rollback 경로
  • secret과 identity 발급
  • 로그·메트릭·trace 기본 연결
  • readiness, drain, autoscaling 기본값
  • API/event 계약 테스트 위치
  • 비용과 리소스 소유자 태그

이 기반이 없으면 서비스 분리는 코드 작업보다 티켓과 대기 시간을 늘릴 수 있다.


10. 종합 Worked Example: 주문 경계를 단계적으로 분리하기

섹션 제목: “10. 종합 Worked Example: 주문 경계를 단계적으로 분리하기”

단계 1: 모듈러 모놀리스에서 경계를 검증한다

섹션 제목: “단계 1: 모듈러 모놀리스에서 경계를 검증한다”
orders 모듈
- Order Aggregate와 주문 DB schema 소유
- PaymentPort를 통해 결제 요청
payments 모듈
- 결제 승인·취소 규칙과 payment schema 소유
- orders 내부 타입을 import하지 않음
notifications 모듈
- OrderConfirmed domain event 구독
- 실패해도 주문 트랜잭션을 되돌리지 않음

이 단계에서 다른 모듈의 테이블을 직접 읽지 못하게 하고 계약 테스트를 만든다. 네트워크는 없지만 경계의 변경 빈도와 의존 방향을 관찰할 수 있다.

알림은 주문과 가용성 요구가 다르고 최종 일관성을 허용하기 쉽다. OrderConfirmed를 outbox에 기록하고 알림 서비스가 소비하게 한다. 알림 공급자가 실패하면 큐 backlog는 늘지만 주문 API는 성공할 수 있다.

관찰할 지표:

  • 이벤트 발행 지연과 가장 오래된 메시지 나이
  • 중복 이벤트 무시 횟수
  • 알림 실패가 주문 p99와 오류율에 영향을 주는지
  • 이벤트 스키마 변경 시 소비자가 독립 배포 가능한지

단계 3: 결제 분리는 더 엄격하게 판단한다

섹션 제목: “단계 3: 결제 분리는 더 엄격하게 판단한다”

결제는 외부 PG, 멱등성, 감사, 보상 규칙이 있다. 별도 팀과 규제 경계가 있고 독립 배포가 실제로 필요하면 추출할 수 있다. 주문이 결제 timeout을 받았을 때 결과를 모르는 상태를 모델링해야 하며, 무조건 재호출해서는 안 된다.

POST /payments
Idempotency-Key: order-123
timeout 발생
-> 같은 key로 결과 조회 또는 재시도
-> Payment는 이미 승인된 key에 같은 결과 반환

단계 4: 재고까지 분리하면 장기 실행 상태를 드러낸다

섹션 제목: “단계 4: 재고까지 분리하면 장기 실행 상태를 드러낸다”

결제 성공 후 재고 예약이 실패할 수 있다. 이때 주문은 단순 SUCCESS/FAIL이 아니라 PENDING_PAYMENT, RESERVING, COMPENSATING, CANCELLED 같은 상태를 가질 수 있다. 이 시점부터 Saga의 보상과 격리 문제를 별도 설계해야 한다.

이 예제의 핵심은 “가장 쉬운 서비스부터 많이 추출”이 아니다. 최종 일관성을 허용하기 쉬운 경계에서 운영 능력을 검증하고, 돈과 재고처럼 실패 비용이 큰 경계는 더 강한 근거와 상태 모델을 요구하는 것이다.

단계 5: 독립성을 지표로 검증한다

섹션 제목: “단계 5: 독립성을 지표로 검증한다”

추출 후 “배포에 성공했다”만 보면 경계 품질을 알 수 없다. 다음 가설이 실제로 맞았는지 확인한다.

기대검증 지표반대 신호
독립 배포다른 서비스 변경 없이 배포한 비율대부분 lockstep 배포
장애 격리알림 장애 중 주문 성공률같은 시간 Order 5xx 증가
독립 확장알림 backlog에 따라 consumer만 증가모놀리스 자원도 함께 증가
데이터 소유권cross-service DB query 0건임시 read 계정이 계속 늘어남
팀 자율성변경 lead time 감소플랫폼/다른 팀 승인 대기 증가

독립성 지표가 개선되지 않으면 더 분리하기 전에 원인을 고친다. 계약이 너무 넓은지, 운영 self-service가 없는지, 애초에 함께 바뀌는 Context를 잘못 나눴는지 확인한다.

10.1 관측성은 경계를 검증하는 도구다

섹션 제목: “10.1 관측성은 경계를 검증하는 도구다”

Observability(관측성)는 로그를 한곳에 모으는 것보다 시스템 출력으로 내부 상태를 추론할 수 있는 능력이다. MSA에서는 한 사용자 요청이 여러 프로세스를 지나므로 로그·메트릭·trace를 같은 식별자로 연결해야 한다.

최소 세 신호:

  • Metrics: 오류율, 지연 분포, 처리량, saturation(자원 포화)을 수치로 본다.
  • Logs: 특정 실패의 도메인 상태와 원인을 구조화해 남긴다.
  • Traces: 한 요청의 서비스별 span과 호출 시간을 연결한다.

trace가 있다고 원인이 자동으로 보이지는 않는다. 모든 span이 HTTP 500만 기록하고 orderId, idempotency key, dependency outcome이 없으면 비즈니스 흐름을 찾기 어렵다. 반대로 카드번호·토큰 같은 민감 정보를 trace에 넣으면 보안 문제가 된다. 안정적 식별자와 상태 전이만 남긴다.

동기 요청에서는 하나의 trace ID로 흐름을 잇기 쉽다. 비동기 이벤트는 원 요청과 다른 시간에 처리되므로 두 관계를 구분하면 좋다.

  • correlation ID: 같은 비즈니스 흐름 전체를 묶는다. 예: checkout-123.
  • causation ID: 이 메시지를 직접 발생시킨 앞 메시지를 가리킨다.
  • event ID: 메시지 자체의 고유 ID로 멱등성에 사용한다.
HTTP checkout
correlationId=checkout-123
OrderCreated
eventId=e-1
correlationId=checkout-123
causationId=http-request-9
PaymentCharged
eventId=e-2
correlationId=checkout-123
causationId=e-1

이 관계가 있으면 전체 흐름과 직접 원인을 모두 찾을 수 있다. orderId 하나를 모든 메시지의 유일한 추적 키로 쓰면 한 주문의 재결제·부분 취소 같은 여러 흐름을 구분하기 어렵다.

10.3 Golden Signals를 서비스 경계에 맞춰 읽는다

섹션 제목: “10.3 Golden Signals를 서비스 경계에 맞춰 읽는다”

Latency, Traffic, Errors, Saturation 네 신호를 서비스마다 본다. 그러나 평균만 보면 tail latency와 부분 실패가 숨는다.

평균 latency 80ms
p95 150ms
p99 3,200ms

평균이 정상이어도 1% 사용자는 3초 이상 기다린다. Gateway p99와 각 downstream span p99를 연결해 어느 경계에서 tail이 생기는지 본다. queue consumer는 HTTP latency 대신 processing duration, backlog, oldest age, retry/DLQ 비율을 본다.

Saturation은 CPU만 뜻하지 않는다. thread/connection pool, event loop lag, queue partition, DB connection, rate limit 잔여량도 포화 자원이다. CPU가 30%인데 DB connection pool이 100%면 인스턴스 확장보다 느린 query와 pool 경계를 확인해야 한다.

10.4 Contract test는 독립 배포의 증거다

섹션 제목: “10.4 Contract test는 독립 배포의 증거다”

Unit test가 각 서비스를 통과해도 생산자와 소비자의 계약이 맞는지는 별도 문제다. Contract test는 API나 이벤트의 요청·응답·schema 약속을 검증한다.

확인할 내용:

  • 필수 필드와 optional 필드
  • enum의 unknown value 처리
  • 오류 코드와 재시도 가능 여부
  • 이벤트의 version과 기본값
  • timeout 뒤 같은 idempotency key의 응답
  • 구버전 소비자와 신버전 생산자의 호환성

모든 서비스의 end-to-end 환경 하나에만 의존하면 테스트가 느리고 실패 원인도 넓다. 소유 서비스의 계약 테스트와 소수의 핵심 journey 테스트를 조합한다. “독립 배포 가능”은 각 저장소 CI가 초록색이라는 뜻이 아니라 인접 버전 조합이 호환된다는 뜻이다.

10.5 장애 실험은 가설을 검증한다

섹션 제목: “10.5 장애 실험은 가설을 검증한다”

회복성 설정은 문서만으로 검증되지 않는다. 스테이징이나 제한된 운영 범위에서 다음 가설을 실험한다.

  • Payment 지연이 2초가 되면 Order의 timeout과 fallback이 예상대로 동작하는가?
  • Notification consumer를 5분 멈추면 backlog가 계산한 시간 안에 해소되는가?
  • 한 인스턴스를 종료할 때 새 요청이 drain되고 중복 처리가 안전한가?
  • mesh proxy가 실패할 때 앱 health와 원인 위치를 구분할 수 있는가?
  • read model을 지연시키면 UI가 PENDING과 freshness를 올바르게 표현하는가?

실험의 목표는 운영을 위험하게 만드는 것이 아니라 경계가 약속한 장애 격리가 실제인지 작은 범위에서 확인하는 것이다. blast radius, 중단 조건, 관찰 지표, rollback을 먼저 정한다.


실패 신호의미먼저 확인할 것
여러 서비스가 항상 함께 배포된다배포 경계와 변경 경계 불일치합치거나 계약의 독립성 강화
Order -> User -> Order 순환 호출소유권과 조회 책임이 불명확Context 경계, 로컬 read model
한 서비스가 다른 서비스 DB를 직접 수정데이터 소유권 붕괴API/event 계약, DB 권한
p99가 호출 깊이에 따라 급증시간 결합과 직렬 호출 누적호출 그래프, deadline, 병렬화
장애 때 재시도 트래픽이 평소보다 커짐retry amplification재시도 계층, backoff, budget
Circuit이 평소에도 자주 Open임계치 과민 또는 의존성 지연 악화timeout 분포, 최소 표본, GC/네트워크
Circuit이 장애 중에도 Closed임계치 둔감실패율 창, volume threshold
큐 backlog와 oldest age가 계속 증가소비 처리량 부족 또는 독성 메시지consumer 지연, DLQ, 확장 정책
이메일·재고가 2~3번 처리됨At-Least-Once 입력을 한 번으로 가정idempotency key와 원자성
배포 후 종료된 인스턴스로 요청디스커버리/Readiness 갱신 지연deregistration, drain, TTL
의존성 장애 때 모든 pod가 재시작liveness에 외부 의존성을 결합liveness/readiness 분리
Gateway 429와 공유 RPS 증가quota 공유 범위 또는 재시도 증폭계정·리전 합계, burst
Gateway 정상인데 전체 p99 3초 이상다운스트림 병목IntegrationLatency, trace
read model에서 방금 쓴 값이 안 보임최종 일관성 또는 projection 지연read-your-writes 요구, lag
보상 실패 상태가 수동 목록에 쌓임Saga 복구 정책 부족재시도, DLQ, 운영 소유자
shared 패키지 변경이 전 서비스 재배포공용 도메인 모델 비대화계약 최소화, 소유 서비스
mesh 도입 후 원인 위치가 더 불명확앱과 proxy 관측 분리 부족Envoy 지표, xDS 상태, trace
서비스별 대시보드·경보 소유자가 없다운영 소유권 없는 분리팀 경계와 SLO 재설계

11.1 순환 의존을 비동기로 바꾸기 전에 볼 것

섹션 제목: “11.1 순환 의존을 비동기로 바꾸기 전에 볼 것”

Order -> User -> Order 순환을 발견하면 무조건 이벤트로 바꾸지 않는다. 먼저 어느 서비스가 사실을 소유해야 하는지 묻는다. “사용자의 최근 주문”은 User의 핵심 상태가 아니라 Order가 소유한 주문의 조회 표현일 수 있다. User가 필요하면 Order API를 한 번 호출하거나 Order 이벤트로 로컬 read model을 만들 수 있다.

비동기 전환이 순환 화살표를 숨기기만 하고 두 서비스가 서로의 이벤트를 반드시 기다린다면 논리적 순환은 남는다. 이벤트 소비 순서와 상태 머신을 그려 한쪽이 독립적으로 진행 가능한지 확인한다.

SQS FIFO의 MessageDeduplicationId는 제한된 중복 제거 창 안의 발행 중복을 줄인다. AWS 공식 문서에 안내된 5분은 변동 가능한 제품 값이므로 설계·운영 전에 반드시 최신 AWS 공식 문서에서 다시 확인해야 한다. 중요한 반례는 중복 제거 창이 5분이라고 가정할 때 같은 이벤트가 7분 뒤 DLQ redrive나 운영자 재처리로 돌아오면 창을 벗어난다는 점이다.

따라서 broker deduplication은 영구 멱등성을 대체하지 않는다. 소비자 저장소의 unique idempotency key, 부작용과 처리 기록의 원자성, 보존 기간이 함께 필요하다. 출처: Amazon SQS message quotas, Amazon SQS exactly-once processing.

11.3 장기 실행 작업의 수치 반례

섹션 제목: “11.3 장기 실행 작업의 수치 반례”

Step Functions Standard Workflow가 장기 실행을 지원하더라도 개별 결제 단계가 무제한 대기해도 된다는 뜻은 아니다. 지원되는 워크플로 최대 기간은 변동 가능한 제품 제한이므로 설계 시점의 AWS 공식 문서에서 확인한다. 직접 Lambda ARN을 Resource로 사용하는 Task는 callback token이 없고 Lambda 함수가 SendTaskHeartbeat를 보낼 수 없으므로 HeartbeatSeconds를 진행 신호로 사용하지 않는다. 대신 TimeoutSeconds: 300처럼 Step Functions가 해당 Task를 기다릴 최대 시간을 둔다. 300초는 예시이며 결제 공급자의 SLO와 전체 Saga deadline으로 정한다.

출처: AWS Step Functions - Task state, AWS Step Functions - workflow type.

States.Timeout이 발생하면 Step Functions는 Task 대기를 실패로 끝내고 Catch 경로로 이동할 수 있지만, 이미 시작된 Lambda 실행이나 외부 결제 효과까지 취소됐다고 보장하지는 않는다. 이때 결과를 모른다는 이유로 즉시 환불하면 실제 승인 여부를 확인하기 전에 새로운 부작용을 만들 수 있다. 먼저 같은 idempotency key로 결제 공급자의 상태를 조회하고 내부 기록과 대사한 뒤, 확인된 최종 상태에 따라 재고 예약·대기·실패 또는 필요한 보상을 결정한다.

이 구현은 Saga Pattern의 범위다. 여기서 보존할 원칙은 장기 실행 플랫폼의 최대 수명을 개별 단계의 타임아웃으로 오해하지 않는 것이다.


MSA 경계와 운영 준비 점검

  • 서비스 후보가 Bounded Context의 언어·불변식과 일치하는가?
  • 독립 배포·확장·장애 격리 중 어떤 이득을 얻는지 측정할 수 있는가?
  • 한 팀이 코드·데이터·배포·SLO·장애 대응을 끝까지 소유하는가?
  • 다른 서비스의 DB와 내부 타입을 직접 사용하지 않는가?
  • 동기 호출마다 deadline, 타임아웃, 재시도 책임이 명확한가?
  • 비동기 소비자가 중복·순서 역전·지연을 정상 입력으로 처리하는가?
  • Gateway에 경계 정책만 있고 비즈니스 규칙은 소유 서비스에 남아 있는가?
  • Service Discovery의 readiness와 liveness 의미를 분리했는가?
  • Circuit Breaker, Bulkhead, fallback이 실패 의미를 왜곡하지 않는가?
  • 데이터 복제본의 원본, 허용 지연, 정정·삭제 경로가 명확한가?
  • Saga나 CQRS가 정말 필요한 신호가 있을 때 후속 설계를 진행하는가?
  • 같은 요구를 모듈러 모놀리스로 더 단순하게 해결할 수 없는가?
  • 서비스들이 lockstep 배포되는 분산 모놀리스 신호가 없는가?
  • Mesh 도입 문제와 새 운영 비용을 모두 관측할 수 있는가?

다음 질문에 패턴 이름이 아니라 실패 의미와 소유권으로 답해 본다.

  1. Bounded Context와 마이크로서비스가 왜 일대일 관계가 아닌가?
  2. 주문 완료 후 알림은 비동기로 보내도 되지만 결제 결과는 즉시 필요할 수 있는 이유는 무엇인가?
  3. 서비스가 별도 DB를 쓰면서도 분산 모놀리스가 될 수 있는 예를 들어라.
  4. API Gateway, Service Discovery, Load Balancer, Service Mesh의 책임을 각각 구분하라.
  5. timeout 3초, retry 2회를 각 호출 계층에 복사하면 어떤 증폭이 생기는가?
  6. Readiness 실패와 liveness 실패를 같은 엔드포인트로 처리하면 왜 위험한가?
  7. 주문 목록의 사용자 이름을 동기 API, 이벤트 복제, 주문 당시 snapshot 중 무엇으로 만들지 판단하라.
  8. 서비스 30개라는 숫자만으로 MSA나 mesh 도입을 결정할 수 없는 이유는 무엇인가?
  9. 모듈러 모놀리스에서 먼저 검증할 수 있는 경계 규칙은 무엇인가?
  10. Saga와 CQRS를 이 문서에서 끝까지 구현하지 않고 후속 토픽으로 넘기는 이유는 무엇인가?

키워드설명
Microservice독립 변경·배포·확장 가능한 비즈니스 능력의 실행 경계
Bounded Context모델과 업무 언어가 일관된 뜻을 유지하는 개념 경계
Modular Monolith하나의 배포 단위 안에서 모듈·데이터 경계를 강제한 구조
Distributed Monolith여러 프로세스가 배포·데이터·실행에서 강하게 묶인 실패 상태
Service OwnershipAPI, 데이터, 배포, SLO, 장애 대응까지 포함한 책임
Database per Service데이터 쓰기 권한과 스키마 변경 책임을 한 서비스가 소유하는 원칙
API Gateway외부 계약과 North-South 트래픽의 진입 계층
BFF클라이언트 유형별 응답 조립을 맡는 전용 백엔드
Service Discovery동적으로 변하는 서비스 인스턴스 주소를 찾는 메커니즘
East-West Traffic내부 서비스 사이의 트래픽
North-South Traffic외부 클라이언트와 내부 서비스 사이의 트래픽
Circuit Breaker실패 임계치에서 호출을 차단하고 제한적으로 복구를 탐지하는 패턴
Bulkhead의존성별 자원 풀을 분리해 장애 범위를 제한하는 패턴
Idempotency같은 요청을 반복해도 최종 결과가 달라지지 않는 성질
Eventual Consistency갱신 직후 차이를 허용하지만 시간이 지나 수렴하는 일관성 모델
Transactional Outbox상태와 발행할 메시지를 한 로컬 트랜잭션에 기록하는 패턴
Saga여러 로컬 트랜잭션과 보상으로 장기 실행 일관성을 다루는 패턴
CQRS상태 변경과 조회 책임을 분리하는 패턴
Service MeshEast-West 트래픽 정책을 프록시 계층에서 적용하는 인프라
mTLS통신 양쪽이 서로 인증하는 TLS 방식

15. 선택 부록: 환경별 관찰과 최소 구현

섹션 제목: “15. 선택 부록: 환경별 관찰과 최소 구현”

본문은 아래 부록 없이도 완결된다. 명령과 코드는 특정 AWS, Kubernetes, NestJS 환경에서 개념을 관찰할 때만 사용한다.

부록 A. API Gateway quota와 지연 구간 확인

계정의 API 목록과 stage 설정을 확인한다.

Terminal window
aws apigateway get-rest-apis --region ap-northeast-2
aws apigateway get-stage \
--rest-api-id abc123 \
--stage-name prod \
--region ap-northeast-2

Gateway 지연을 볼 때 전체 LatencyIntegrationLatency를 같은 기간·percentile로 비교한다. quota 증액부터 요청하기 전에 재시도 증폭, 공유 계정 RPS, backend p99를 확인한다.

Terminal window
aws cloudwatch get-metric-data --metric-data-queries file://gateway-latency.json

아래 명령은 quota 증액 요청의 형태만 보여 준다. quota code와 허용 가능한 요청값은 변동 가능한 계정·리전별 입력이므로 실행 시점의 공식 Service Quotas 콘솔이나 API에서 확인한다.

Terminal window
aws service-quotas request-service-quota-increase \
--service-code apigateway \
--quota-code "$CURRENT_QUOTA_CODE" \
--desired-value "$VALIDATED_DESIRED_VALUE"
부록 B. Kubernetes/Istio에서 디스커버리와 proxy 상태 관찰
Terminal window
kubectl get pods -n production
kubectl get endpointslices -n production
kubectl top pod -n production --containers
istioctl proxy-status

관찰 포인트:

  • Pod는 Running인데 EndpointSlice에 없으면 readiness와 label selector를 확인한다.
  • 종료된 Pod 주소가 남으면 deregistration과 termination grace period를 확인한다.
  • istio-proxy CPU가 앱보다 높으면 retry, telemetry, 정책 비용을 의심한다.
  • proxy-status가 STALE이면 Istiod와 xDS 설정 전파를 확인한다.

istioctl authn tls-check는 Istio 버전에 따라 제공 여부와 대체 명령이 달라질 수 있다. 현재 버전 공식 문서의 proxy-config, authn tls-check 지원 여부를 확인한 뒤 사용한다.

부록 C. NestJS 메시지 소비자의 멱등성 골격

아래 예시는 개념 골격이다. eventId에는 unique constraint가 있어야 하고, 처리 기록과 부작용 사이의 실패를 다루는 상태 전이가 추가로 필요하다.

async function handleOrderCompleted(event: OrderCompletedEvent) {
const eventId = `order-completed-${event.orderId}`;
const claimed = await processedEvents.tryClaim(eventId);
if (!claimed) return;
try {
await notifications.sendOrderCompleted(event);
await processedEvents.markCompleted(eventId);
} catch (error) {
await processedEvents.markRetryable(eventId, error);
throw error;
}
}

FIFO 큐에 메시지를 보낼 때의 환경별 예시는 다음과 같다.

Terminal window
aws sqs send-message \
--queue-url https://sqs.ap-northeast-2.amazonaws.com/123456789012/orders.fifo \
--message-body '{"orderId":"order-001"}' \
--message-group-id order-group \
--message-deduplication-id order-001-completed
부록 D. Circuit Breaker 상태를 손으로 관찰하는 최소 예제

라이브러리를 고르기 전에 다음 상태 전이를 테스트한다.

초기 Closed
1~4회 실패: 표본 누적
5회 실패: Open
Open 30초: 호출 없이 fallback
30초 후: Half-Open 탐색 1건
탐색 성공: Closed
탐색 실패: Open, 다음 탐색 간격 증가

검증할 반례:

  • 요청이 거의 없는 서비스에서 1/1 실패를 100% 실패율로 보고 즉시 차단하지 않는가?
  • timeout 뒤 결제가 실제 성공했는데 fallback이 새 결제를 만들지 않는가?
  • 모든 인스턴스의 breaker가 같은 순간 열리고 닫혀 트래픽이 요동치지 않는가?
  • fallback 저장소도 느릴 때 별도 bulkhead가 있는가?
부록 E. Step Functions 단계 타임아웃 확인

아래 상태 머신은 직접 Lambda ARN을 호출하므로 heartbeat가 아니라 최대 대기 시간만 설정한다. TimeoutSeconds를 넘기면 States.Timeout을 잡아 ReconcilePayment로 이동한다. 300초는 결제 단계의 실제 SLO와 전체 Saga deadline에 맞춰 바꿀 예시다. 모든 Lambda ARN과 ReserveInventory, PaymentFailed Pass 상태는 설명을 위한 placeholder다. 실제 환경에서는 계정·리전·함수 이름을 바꾸고 각 상태의 업무 트랜잭션을 구현해야 한다.

{
"Comment": "결제 타임아웃 뒤 idempotency key로 결과를 대사하는 예시",
"StartAt": "ProcessPayment",
"States": {
"ProcessPayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:payment-handler",
"Parameters": {
"idempotencyKey.$": "$.payment.idempotencyKey",
"order.$": "$.order"
},
"TimeoutSeconds": 300,
"ResultPath": "$.paymentResult",
"Catch": [
{
"ErrorEquals": ["States.Timeout"],
"ResultPath": "$.paymentTimeout",
"Next": "ReconcilePayment"
}
],
"Next": "ReserveInventory"
},
"ReconcilePayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:reconcile-payment",
"Parameters": {
"idempotencyKey.$": "$.payment.idempotencyKey",
"order.$": "$.order"
},
"TimeoutSeconds": 15,
"ResultPath": "$.reconciliation",
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.reconciliationError",
"Next": "PersistPendingAndOutbox"
}
],
"Next": "RouteReconciliationResult"
},
"RouteReconciliationResult": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.reconciliation.status",
"IsPresent": false,
"Next": "PersistPendingAndOutbox"
},
{
"Variable": "$.reconciliation.status",
"StringEquals": "SUCCEEDED",
"Next": "ReserveInventory"
},
{
"Variable": "$.reconciliation.status",
"StringEquals": "FAILED",
"Next": "PaymentFailed"
},
{
"Variable": "$.reconciliation.status",
"StringEquals": "PENDING",
"Next": "PersistPendingAndOutbox"
}
],
"Default": "PersistPendingAndOutbox"
},
"ReserveInventory": {
"Type": "Pass",
"End": true
},
"PaymentFailed": {
"Type": "Pass",
"End": true
},
"PersistPendingAndOutbox": {
"Type": "Task",
"Resource": "arn:aws:lambda:ap-northeast-2:123456789012:function:persist-pending-and-outbox",
"Parameters": {
"idempotencyKey.$": "$.payment.idempotencyKey",
"order.$": "$.order"
},
"TimeoutSeconds": 15,
"ResultPath": "$.pendingPersistence",
"End": true
}
}
}
Terminal window
aws stepfunctions get-execution-history \
--execution-arn arn:aws:states:ap-northeast-2:123456789012:execution:order-saga:exec-001 \
--max-items 20

ProcessPaymentParameters는 결제 함수에 필요한 idempotency key와 주문 정보만 전달하고, ResultPath: $.paymentResult는 함수 결과를 원본 실행 입력에 병합한다. 따라서 뒤 상태에서도 원래의 paymentorder를 사용할 수 있다.

TaskTimedOutReconcilePayment로 이동했는지 확인한다. 대사도 짧은 시간 안에 끝나지 않거나 실패할 수 있으므로 TimeoutSecondsCatch를 두고, 응답에 $.reconciliation.status가 없는 경우도 IsPresent로 탐지해 PersistPendingAndOutbox로 보낸다. 이 설명용 Lambda는 자신이 소유한 로컬 DB에서 결제 또는 주문 상태를 PENDING으로 갱신하고 reconciliation outbox 행을 추가한 뒤, 둘을 하나의 DB 트랜잭션으로 커밋한다. Lambda가 실패하면 실행을 성공으로 끝내지 말고 실패를 드러내 운영 재처리 대상에 남긴다. 반대로 Task의 End는 이 로컬 트랜잭션이 커밋됐다는 뜻일 뿐, 외부 재대사 발행이나 예약까지 완료됐다는 뜻이 아니다.

별도 relay가 커밋된 outbox를 polling해 후속 공급자 상태 조회 메시지를 발행하거나 재대사 실행을 예약한다. DB 커밋 직후 relay가 중단돼도 outbox 행이 남으므로 다음 polling에서 복구할 수 있다. relay가 같은 행을 중복 전달해도 원래 결제의 idempotency key를 재사용해 같은 대사 작업으로 수렴시킨다. outbox 행의 처리 완료 표시는 외부 발행/예약 성공을 확인한 뒤 갱신하며, DB 상태 변경과 외부 시스템 호출을 하나의 원자적 작업이라고 가정하지 않는다.

Step Functions의 timeout은 이미 시작된 Lambda나 외부 결제 작업의 취소를 보장하지 않는다. 따라서 결과를 알 수 없거나 PENDING인 상태에서는 새 결제나 환불을 만들지 않는다. 같은 idempotency key로 공급자 상태를 조회하고 내부 기록과 대사해 SUCCEEDED, FAILED, 아직 확정할 수 없는 PENDING을 구분하며, relay가 만든 후속 시도도 같은 키를 재사용해 늦은 완료를 흡수해야 한다. 이 부록은 MSA에서 “무한 대기는 경계가 아니다”를 관찰하기 위한 것이며 Saga 설계의 대체 문서가 아니다.



MSA는 작은 서비스의 집합이 아니라 변경·데이터·배포·장애 소유권을 비즈니스 경계에 맞추는 방식이며, 그 독립성의 이득이 네트워크·정합성·운영 비용보다 클 때만 모듈러 모놀리스보다 나은 선택이다.