콘텐츠로 이동

MSA Patterns

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

MSA 패턴 (Microservices Architecture Patterns)

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

MSA(마이크로서비스 아키텍처)는 하나의 큰 애플리케이션을 독립적으로 배포·확장 가능한 작은 서비스들로 나누어 운영하는 아키텍처 패턴이며, API Gateway·Service Mesh·Edge Computing 같은 주변 패턴들과 함께 동작한다.

프론트엔드 브릿지: 프론트에서 Webpack Module Federation(마이크로 프론트엔드)을 알고 있다면 MSA의 서비스 분리와 동일한 원리다. 마이크로 프론트엔드가 UI를 독립 번들로 나눠 팀별로 배포하듯, MSA는 백엔드 비즈니스 로직을 독립 서비스로 나눠 팀별로 배포한다. “각 팀이 자신의 영역을 독립적으로 빌드·배포한다”는 핵심 철학이 프론트엔드와 백엔드 양쪽에서 동일하게 적용된다.


서비스가 성장할수록 단일 코드베이스(Monolith)는 배포 위험, 확장 비용, 팀 간 충돌이라는 세 가지 문제를 동시에 일으킨다. MSA는 이 문제를 해결하지만 동시에 분산 시스템 특유의 복잡성을 도입한다. BackOps 엔지니어 입장에서는 다음 이유로 필수 지식이다.

  • 배포 독립성: 특정 서비스만 배포할 수 있어 릴리스 주기가 빨라진다.
  • 장애 격리: 결제 서비스가 죽어도 상품 조회 서비스는 살아있다.
  • 팀 자율성: 서비스마다 독립 팀이 기술 스택까지 선택할 수 있다.
  • NestJS와의 연관성: NestJS는 마이크로서비스 모듈(@nestjs/microservices)을 내장하여 MSA 전환을 직접 지원한다.

모놀리식 = 백화점 한 건물 안에 식품, 의류, 가전이 모두 있다. 관리가 편하고 손님이 한 번에 모든 것을 해결할 수 있지만, 식품 코너 화재 시 건물 전체가 영업을 중단해야 한다. 식품 코너만 리모델링하려 해도 전체 건물 공사가 필요하다.

MSA = 전문 매장 거리(스트리트몰) 각 매장(서비스)은 독립 건물이다. 카페가 불이 나도 옆 서점은 영업 중이다. 각 매장은 자체 인테리어·운영 방식(기술 스택)을 선택한다. 단, 손님이 여러 매장을 돌아다녀야 하고 매장 간 재고 조율(데이터 일관성)이 복잡해진다.

모놀리식 구조

[클라이언트]
[단일 애플리케이션]
├── 사용자 모듈
├── 주문 모듈
├── 결제 모듈
└── 배송 모듈
[단일 데이터베이스]

모든 모듈이 같은 프로세스에서 실행된다. 함수 호출이 곧 모듈 간 통신이다. 배포 시 전체를 빌드·재시작해야 한다.

MSA 구조

[클라이언트]
[API Gateway]
├──▶ [사용자 서비스] ──▶ [사용자 DB]
├──▶ [주문 서비스] ──▶ [주문 DB]
├──▶ [결제 서비스] ──▶ [결제 DB]
└──▶ [배송 서비스] ──▶ [배송 DB]

각 서비스는 독립 프로세스(컨테이너)로 실행된다. 서비스 간 통신은 네트워크(HTTP REST, gRPC, 메시지 큐)를 경유한다. 각 서비스는 자체 데이터베이스를 소유한다(Database per Service 패턴).

항목모놀리식MSA
배포전체 재배포 필요, 위험 높음서비스 단위 독립 배포 가능
확장전체 복제(비용 낭비)특정 서비스만 수평 확장 가능
장애 격리단일 장애점(SPOF)서비스별 독립 장애 격리
개발 속도초반 빠름, 규모 커지면 느려짐초반 셋업 오래 걸림, 이후 팀별 병렬 개발
복잡성낮음(단순 구조)높음(분산 시스템, 네트워크 장애 등)
기술 스택단일 스택서비스별 다른 스택 선택 가능
테스트통합 테스트 쉬움서비스 간 통합 테스트 어려움
데이터 일관성트랜잭션으로 보장분산 트랜잭션 필요(Saga 패턴 등)

NestJS 마이크로서비스 코드 예시

섹션 제목: “NestJS 마이크로서비스 코드 예시”
// main.ts - 마이크로서비스로 부트스트랩
import { NestFactory } from "@nestjs/core";
import { MicroserviceOptions, Transport } from "@nestjs/microservices";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
{
transport: Transport.TCP,
options: {
host: "0.0.0.0",
port: 3001, // 주문 서비스 포트
},
},
);
await app.listen();
console.log("주문 서비스가 포트 3001에서 실행 중입니다.");
}
bootstrap();
// order.controller.ts - 메시지 패턴으로 통신
import { Controller } from "@nestjs/common";
import { MessagePattern, Payload } from "@nestjs/microservices";
@Controller()
export class OrderController {
@MessagePattern("create_order") // 이벤트 이름으로 라우팅
async createOrder(@Payload() data: { userId: string; items: any[] }) {
console.log("주문 생성 요청 수신:", data);
// 주문 생성 로직
return { orderId: "ORD-001", status: "created" };
}
}
// 예상 출력
주문 서비스가 포트 3001에서 실행 중입니다.
주문 생성 요청 수신: { userId: 'user-123', items: [...] }

”왜 Database per Service가 필수인가” — 데이터 독립성의 원리

섹션 제목: “”왜 Database per Service가 필수인가” — 데이터 독립성의 원리”

MSA에서 각 서비스가 자체 DB를 소유하는 패턴(Database per Service)은 선택이 아니라 핵심 전제 조건이다. 공유 DB를 사용하면 MSA의 모든 장점이 무너진다.

공유 DB의 문제:
주문 서비스 ──┐
├──▶ [공유 DB] ← users 테이블 스키마 변경
사용자 서비스 ──┘
1. 사용자 서비스가 users 테이블에 컬럼 추가
2. 주문 서비스의 SQL 쿼리가 깨짐 → 배포 실패
3. "독립 배포"가 불가능해짐 → MSA의 핵심 가치 상실
Database per Service:
주문 서비스 ──▶ [주문 DB] ← 독립적 스키마 관리
사용자 서비스 ──▶ [사용자 DB] ← 독립적 스키마 관리
1. 사용자 서비스가 스키마를 자유롭게 변경
2. 주문 서비스는 API를 통해서만 사용자 데이터에 접근
3. 인터페이스(API)만 유지되면 내부 변경은 자유

단점은 서비스 간 데이터 조인이 불가능해진다는 것이다. “주문 + 사용자 정보”를 한 번에 조회하려면 API 호출로 데이터를 모아야 한다. 이 비용을 감수할 수 없는 규모라면 모놀리식이 더 적합하다.

📖 더 보기: Microservices.io — Database per Service Pattern — Database per Service의 장단점과 CQRS/Saga 연계 패턴 (중급)

“우리 팀 규모에서 MSA가 맞는가” 판단 기준

섹션 제목: ““우리 팀 규모에서 MSA가 맞는가” 판단 기준”

MSA는 만능 해결책이 아니다. Twilio의 Segment 팀은 실제로 MSA에서 모놀리식으로 역행한 사례로 유명하다. 다음 기준으로 판단하라.

조건모놀리식 권장MSA 권장
팀 규모~10명 이하30명 이상, 팀 분리 가능
도메인 경계불명확함명확히 정의됨
배포 주기주 1회 이하일 단위, 팀별 독립 배포 필요
트래픽 패턴균일특정 서비스에 집중
DevOps 성숙도낮음높음(컨테이너, CI/CD, 모니터링 완비)

실무 조언: “모놀리식 먼저, 경계가 보이면 분리”가 안전한 접근법이다. 처음부터 MSA를 도입하면 아직 불명확한 도메인 경계 때문에 서비스를 나중에 합쳐야 하는 역설이 발생한다.

모놀리스 → MSA 마이그레이션 체크리스트

섹션 제목: “모놀리스 → MSA 마이그레이션 체크리스트”

MSA로 전환하기 전에 다음 항목을 점검하라. 체크되지 않은 항목이 많다면 모놀리식을 유지하는 것이 낫다.

  • 도메인 경계가 명확히 식별되었는가? (주문/결제/사용자 등 경계가 DDD Event Storming 등으로 검증되었는가)
  • 팀이 30명 이상이고 독립적으로 배포를 원하는가? (소규모 팀에서 MSA는 오버엔지니어링이다)
  • CI/CD 파이프라인이 서비스별로 독립 구성 가능한가? (배포 자동화 없는 MSA는 운영 지옥이다)
  • 각 서비스가 독립 데이터 스토어를 가질 수 있는가? (DB를 공유하면 MSA의 핵심 가치(독립 배포)가 사라진다)
  • 분산 트랜잭션 실패 처리 전략(Saga 패턴 등)이 설계되었는가? (데이터 일관성 문제가 모놀리식보다 훨씬 복잡해진다)
  • 서비스 간 통신 실패·지연에 대한 Circuit Breaker가 준비되었는가? (한 서비스 장애가 전체로 전파되는 카스케이딩 장애를 막아야 한다)
  • 중앙 집중식 모니터링·분산 추적(OpenTelemetry 등)이 구축되었는가? (로그가 여러 서비스에 분산되면 장애 원인 추적이 극도로 어려워진다)

API Gateway = 건물 로비의 안내 데스크 건물(MSA 시스템)에는 여러 층(서비스)이 있다. 방문객(클라이언트)은 안내 데스크에 먼저 들른다. 데스크는 방문 목적을 확인(인증)하고, 해당 층으로 안내(라우팅)하며, 1분에 최대 10명까지만 입장 허용(Rate Limiting)한다. 클라이언트는 내부 구조를 알 필요가 없다.

API Gateway가 없다면 클라이언트는 각 마이크로서비스의 주소를 직접 알아야 한다. 서비스가 10개라면 클라이언트 코드에 10개의 엔드포인트가 하드코딩된다. 서비스 주소가 바뀌면 클라이언트도 수정해야 한다.

API Gateway는 단일 진입점(Single Entry Point) 을 제공한다.

클라이언트 앱들
├── Web Browser
├── iOS App
└── Android App
[API Gateway] ← 여기서 모든 공통 처리
├── 라우팅: /orders → 주문 서비스:3001
├── 인증: JWT 검증 (모든 서비스 공통)
├── Rate Limiting: 초당 100 요청까지
├── 요청 변환: XML → JSON
└── 응답 변환: 여러 서비스 응답 병합
┌────┼────────────┐
▼ ▼ ▼
주문 사용자 결제
서비스 서비스 서비스

1. 라우팅 (Routing)

GET /api/v1/orders → 주문 서비스 (port 3001)
GET /api/v1/users → 사용자 서비스 (port 3002)
POST /api/v1/payments → 결제 서비스 (port 3003)

2. 인증·인가 (Authentication & Authorization) 각 마이크로서비스가 JWT 검증 로직을 중복 구현하는 대신, API Gateway에서 일괄 처리한다.

3. Rate Limiting 클라이언트가 API를 과도하게 호출하는 것을 방지한다.

4. 요청/응답 변환 모바일 클라이언트는 데이터를 최소화하고, 웹 클라이언트는 상세 데이터가 필요할 때, Gateway에서 응답을 필터링·변환한다.

Terminal window
# AWS CLI로 API Gateway 생성
aws apigateway create-rest-api \
--name "MyServiceGateway" \
--description "주문 서비스 API Gateway"
# 예상 출력
{
"id": "abc123def4",
"name": "MyServiceGateway",
"description": "주문 서비스 API Gateway",
"createdDate": "2024-01-15T10:30:00+00:00",
"apiKeySource": "HEADER",
"endpointConfiguration": {
"types": ["EDGE"]
}
}

AWS 콘솔 경로: API GatewayCreate APIREST APIRoutes 설정 → Integration (Lambda 또는 HTTP 엔드포인트 연결)

Kong API Gateway (오픈소스) 설정 예시

섹션 제목: “Kong API Gateway (오픈소스) 설정 예시”
# kong.yml - 선언형 설정
_format_version: "3.0"
services:
- name: order-service
url: http://order-service:3001
routes:
- name: order-routes
paths:
- /api/orders
- name: user-service
url: http://user-service:3002
routes:
- name: user-routes
paths:
- /api/users
plugins:
- name: rate-limiting # Rate Limiting 플러그인
config:
minute: 100 # 분당 100회 제한
policy: local
- name: jwt # JWT 인증 플러그인
config:
secret_is_base64: false

NestJS로 API Gateway 역할을 하는 서비스를 직접 구현할 수 있다.

gateway/src/app.controller.ts
import { Controller, Get, Post, Body, Param, Inject } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { firstValueFrom } from "rxjs";
@Controller("api")
export class AppController {
constructor(
@Inject("ORDER_SERVICE") private orderClient: ClientProxy,
@Inject("USER_SERVICE") private userClient: ClientProxy,
) {}
@Get("orders/:id")
async getOrder(@Param("id") id: string) {
// 주문 서비스에 메시지 전송 후 응답 대기
return firstValueFrom(this.orderClient.send("get_order", { orderId: id }));
}
@Get("dashboard/:userId")
async getDashboard(@Param("userId") userId: string) {
// 여러 서비스에 병렬 요청 후 응답 병합 (BFF 패턴)
const [user, orders] = await Promise.all([
firstValueFrom(this.userClient.send("get_user", { userId })),
firstValueFrom(this.orderClient.send("get_user_orders", { userId })),
]);
return { user, orders, timestamp: new Date().toISOString() };
}
}

API Gateway가 하나의 공통 게이트웨이라면, BFF는 클라이언트 유형별 전용 게이트웨이다.

문제 상황: 모바일 앱은 데이터를 최소화해야 배터리·데이터를 아끼지만, 웹 대시보드는 풍부한 데이터가 필요하다. 단일 API Gateway는 양쪽을 모두 만족시키다 보면 점점 복잡해진다.

BFF 해결책:

[iOS App] [Android App] [Web Browser] [Admin Panel]
│ │ │ │
▼ ▼ ▼ ▼
[Mobile BFF] [Mobile BFF] [Web BFF] [Admin BFF]
│ │
└────────────┬───────────────┘
[공통 마이크로서비스들]
주문 / 사용자 / 결제

모바일 BFF는 응답을 압축·최소화하고, 웹 BFF는 여러 서비스 데이터를 집계하여 반환한다.


Service Mesh = 도로 위의 교통 관제 시스템 마이크로서비스들은 도시의 차량(서비스)이고, 서비스 간 통신은 도로(네트워크)다. Service Mesh는 각 교차로에 신호등과 CCTV(Sidecar Proxy)를 설치하는 것과 같다. 운전자(서비스 코드)는 신호등 존재를 모른다. 그냥 운전할 뿐이다. 하지만 교통량 통계, 신호 제어, 사고 차단(circuit breaker)은 신호등이 자동으로 처리한다.

핵심은 서비스 코드를 수정하지 않고 네트워크 레벨에서 기능을 주입하는 것이다.

기존 방식 (서비스 코드 수정 필요):
[서비스 A] ─── 재시도 로직 직접 구현
[서비스 A] ─── mTLS 핸드쉐이크 직접 구현
[서비스 A] ─── 메트릭 수집 코드 직접 삽입
Sidecar 방식 (서비스 코드 무수정):
┌─────────────────────────┐
│ Pod (Kubernetes) │
│ ┌──────────────────┐ │
│ │ 서비스 A 컨테이너 │ │
│ └────────┬─────────┘ │
│ │ localhost │
│ ┌────────▼─────────┐ │
│ │ Envoy Proxy │ │ ← Sidecar
│ │ (istio-proxy) │ │
│ └──────────────────┘ │
└─────────────────────────┘
│ 외부 네트워크
[다른 서비스 Pod]

Kubernetes Pod 내에서 서비스 컨테이너와 Envoy 프록시 컨테이너가 나란히 실행된다. 서비스의 모든 인바운드/아웃바운드 트래픽은 Envoy를 경유한다. 서비스 A는 localhost로 Envoy와 통신한다고 착각하고, 실제 제어는 Envoy가 담당한다.

Control Plane (Istiod)
┌─────────────────────────────────┐
│ Istiod │
│ ├── Pilot: 라우팅 규칙 관리 │
│ ├── Citadel: 인증서 관리(mTLS) │
│ └── Galley: 설정 검증 │
└────────────┬────────────────────┘
│ xDS 프로토콜로 설정 배포
Data Plane (Envoy Proxies)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Service │ │ Service │ │ Service │
│ A │ │ B │ │ C │
│ [Envoy] │ │ [Envoy] │ │ [Envoy] │
└──────────┘ └──────────┘ └──────────┘

Istiod(Control Plane)는 각 Envoy에게 “어떻게 트래픽을 처리할지” 규칙을 xDS 프로토콜로 전달한다. Envoy들(Data Plane)은 실제 트래픽을 처리한다.

Service Mesh가 MSA 패턴에 제공하는 핵심 가치는 세 가지다.

1. 서비스 간 통신 보안 (mTLS) 서비스 코드 수정 없이 모든 서비스 간 통신을 자동으로 TLS 암호화한다. Istiod(Control Plane)가 인증서를 자동 발급·갱신하고 각 Envoy에 배포한다.

2. 트래픽 관리 카나리 배포(신 버전에 10% 트래픽), A/B 테스트, 서킷 브레이킹, 재시도 정책을 서비스 코드 수정 없이 Control Plane 설정만으로 제어한다.

3. 관찰 가능성 (Observability) 모든 서비스 간 트래픽의 메트릭(지연, 오류율, 처리량)을 자동 수집한다. Prometheus(메트릭), Jaeger(분산 추적), Kiali(서비스 맵 시각화)와 연동된다. mTLS, 분산 추적의 상세 운영 설정은 L6(운영 심화 - 관측성) 을 참조하세요.

”왜 Sidecar 방식을 쓰는가” — 라이브러리 방식의 한계

섹션 제목: “”왜 Sidecar 방식을 쓰는가” — 라이브러리 방식의 한계”

Service Mesh 이전에는 서비스 간 통신 제어를 **라이브러리(SDK)**로 해결했다. 재시도, Circuit Breaker, mTLS를 각 서비스 코드에 직접 구현했다. 이 방식의 문제점은 다음과 같다.

라이브러리 방식의 문제:
1. 언어 종속성: Java용 Hystrix, Node.js용 opossum, Go용 별도 라이브러리...
→ 서비스마다 기술 스택이 다르면 동일한 기능을 N번 구현해야 함
2. 업데이트 지옥: Circuit Breaker 정책을 변경하려면
→ 모든 서비스의 라이브러리 버전을 업데이트
→ 모든 서비스를 재배포
→ 서비스가 30개면 30번 배포
3. 관심사 침투: 비즈니스 로직 코드에 인프라 코드가 섞임
→ 코드 리뷰 시 "이게 비즈니스 로직인가 인프라 설정인가" 혼란
Sidecar 방식의 해결:
1. 언어 무관: Envoy(C++)가 모든 언어의 서비스 옆에서 동작
2. 중앙 업데이트: Istiod에서 정책 변경 → 모든 Envoy에 자동 전파 (재배포 불필요)
3. 관심사 분리: 서비스 코드에 인프라 코드가 0줄

📖 더 보기: Istio Architecture — Istio 공식 문서 — Control Plane과 Data Plane의 상세 동작 원리 (중급)

항목AWS App MeshIstio
설치 복잡도낮음(AWS 관리형)높음(직접 설치·관리)
AWS 통합네이티브(CloudWatch, X-Ray)별도 설정 필요
기능 범위기본 트래픽 관리mTLS, 정책, 관찰성 등 풍부
비용사용량 과금오픈소스(무료, 인프라 비용 별도)
커뮤니티AWS 한정CNCF 표준, 광범위

”지금 당장 필요한가” 판단 기준

섹션 제목: “”지금 당장 필요한가” 판단 기준”

Service Mesh는 강력하지만 소규모 팀에는 오버엔지니어링이 될 수 있다.

Service Mesh 도입 체크리스트:
✅ 필요한 상황:
- 서비스 수 10개 이상
- 서비스 간 보안(mTLS) 요구사항 존재
- 카나리 배포, A/B 테스트 필요
- 분산 추적 없이 장애 원인 찾기 불가능한 수준
- Kubernetes 운영 경험 있는 DevOps 팀 보유
❌ 아직 이른 상황:
- 서비스 수 5개 미만
- 팀이 Kubernetes 자체를 처음 배우는 중
- 모놀리식에서 막 분리한 직후
- 운영 인력이 부족한 소규모 스타트업

대안: 소규모 팀은 AWS ALB + 서비스별 로깅 + X-Ray 분산 추적으로 시작하고, 서비스가 10개를 넘어설 때 Service Mesh를 검토하는 것이 현실적이다.


Edge Computing = 편의점 서울 본사(Origin 서버)에서만 재고를 관리한다면 전국 고객이 서울까지 와야 한다. 대신 각 동네에 편의점(Edge 노드)을 두면 가까운 곳에서 빠르게 처리할 수 있다. CDN이 정적 파일을 배포하듯, Edge Computing은 로직(코드)까지 엣지에서 실행한다.

MSA 아키텍처에서 Edge Computing은 클라이언트와 API Gateway 사이에 위치하는 전처리 계층이다. 서비스 자체의 비즈니스 로직을 건드리지 않으면서도 공통 관심사(인증 토큰 검증, 지역 라우팅, A/B 테스트 트래픽 분기)를 엣지 레벨에서 처리한다.

[사용자(부산)] ──▶ [Edge 노드(부산/대구)] → 응답 (지연: ~5ms)
│ 공통 처리(인증·라우팅·헤더) 후
└── 필요한 경우만 [API Gateway → 마이크로서비스]로 전달

이 구조 덕분에 각 마이크로서비스는 엣지 수준의 공통 처리를 중복 구현하지 않아도 된다.

AWS에서는 CloudFront와 두 가지 실행 환경을 제공한다.

항목CloudFront FunctionsLambda@Edge
실행 위치600+ 엣지 로케이션~13 리전
실행 시간< 1ms5~200ms
네트워크 접근불가가능
주요 용도URL 재작성, 헤더 조작복잡한 로직, DB 조회

주요 활용 사례: A/B 테스트 트래픽 분기, 지역별 리다이렉트, JWT 토큰 엣지 검증, 보안 헤더 자동 추가.

상세 구현 코드(CloudFront Functions A/B 테스트, Lambda@Edge JWT 검증)는 L3(AWS 인프라) 또는 L7(네트워크 심화) 를 참조하세요. 이 문서에서는 MSA 패턴 관점의 역할과 위치만 다룹니다.


3.5 Circuit Breaker 패턴 (회로 차단기)

섹션 제목: “3.5 Circuit Breaker 패턴 (회로 차단기)”

집에서 과전류가 흐르면 차단기(두꺼비집)가 내려간다. 차단기가 없으면 전선이 타거나 화재가 발생한다. 마이크로서비스의 Circuit Breaker도 동일하다. 한 서비스가 응답 불능이 될 때, 무한 대기하는 대신 **빠르게 실패(Fail Fast)**하여 전체 시스템이 멈추지 않도록 보호한다.

Circuit Breaker는 세 가지 상태를 전환하며 동작한다.

Closed (정상)
│ 일정 횟수 이상 실패 발생
Open (차단됨) ──── 모든 요청 즉시 실패 반환
│ 일정 시간(cooldown) 경과
Half-Open (탐지 중) ──── 일부 요청만 통과시켜 테스트
│ │
│ 성공 │ 실패
▼ ▼
Closed (복구) Open (재차단)

왜 이렇게 설계되었는가?

단순히 “실패하면 에러 반환”이 아니라 복구 시점을 탐지해야 하기 때문이다. Open 상태에서 영구적으로 차단하면 서비스가 복구되어도 트래픽을 받지 못한다. Half-Open은 “조심스럽게 복구를 확인하는 단계”다.

📖 더 보기: Circuit Breaker Pattern — microservices.io — 상태 전환 다이어그램과 적용 기준을 명확하게 설명한 레퍼런스

방법 1: @nestjs/axios + 수동 구현

circuit-breaker.service.ts
import { Injectable } from "@nestjs/common";
enum CircuitState {
CLOSED = "CLOSED",
OPEN = "OPEN",
HALF_OPEN = "HALF_OPEN",
}
@Injectable()
export class CircuitBreakerService {
private state = CircuitState.CLOSED;
private failureCount = 0;
private lastFailureTime: number | null = null;
private readonly FAILURE_THRESHOLD = 5; // 5회 실패 → OPEN
private readonly COOLDOWN_MS = 30_000; // 30초 후 HALF_OPEN
async call<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
// OPEN 상태: 쿨다운 확인
if (this.state === CircuitState.OPEN) {
const elapsed = Date.now() - (this.lastFailureTime ?? 0);
if (elapsed > this.COOLDOWN_MS) {
this.state = CircuitState.HALF_OPEN;
console.log("[CircuitBreaker] OPEN → HALF_OPEN: 복구 탐지 시작");
} else {
console.log("[CircuitBreaker] OPEN 상태: 즉시 fallback 반환");
return fallback();
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
return fallback();
}
}
private onSuccess(): void {
this.failureCount = 0;
this.state = CircuitState.CLOSED;
console.log("[CircuitBreaker] 성공 → CLOSED 상태 유지");
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.FAILURE_THRESHOLD) {
this.state = CircuitState.OPEN;
console.log(
`[CircuitBreaker] ${this.failureCount}회 실패 → OPEN 상태 전환`,
);
}
}
}
// order.service.ts - Circuit Breaker 적용
@Injectable()
export class OrderService {
constructor(
private readonly circuitBreaker: CircuitBreakerService,
private readonly paymentClient: ClientProxy,
) {}
async processPayment(orderId: string, amount: number) {
return this.circuitBreaker.call(
// 실제 결제 서비스 호출
() =>
firstValueFrom(
this.paymentClient.send("process_payment", { orderId, amount }),
),
// Circuit이 열렸을 때 대안 반환 (Fallback)
() => ({
status: "pending",
message: "결제 서비스 점검 중. 잠시 후 재시도해주세요.",
}),
);
}
}
// 예상 시뮬레이션 로그
// 1~4번째 실패:
[CircuitBreaker] 실패 기록: 1/5
[CircuitBreaker] 실패 기록: 2/5
...
// 5번째 실패 → OPEN 전환:
[CircuitBreaker] 5회 실패 → OPEN 상태 전환
// 이후 요청들 (30초 동안):
[CircuitBreaker] OPEN 상태: 즉시 fallback 반환
{ status: 'pending', message: '결제 서비스 점검 중. 잠시 후 재시도해주세요.' }
// 30초 경과 후 첫 요청:
[CircuitBreaker] OPEN → HALF_OPEN: 복구 탐지 시작
// 결제 서비스 복구 성공:
[CircuitBreaker] 성공 → CLOSED 상태 유지

방법 2: opossum 라이브러리 (권장) + ECS 헬스체크 연계

Terminal window
npm install opossum @types/opossum
// circuit-breaker.service.ts — opossum 기반 구현
import { Injectable, Logger } from "@nestjs/common";
import * as CircuitBreaker from "opossum";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs";
@Injectable()
export class PaymentCircuitBreakerService {
private readonly logger = new Logger(PaymentCircuitBreakerService.name);
private readonly breaker: CircuitBreaker;
constructor(private readonly httpService: HttpService) {
this.breaker = new CircuitBreaker(this.callPaymentService.bind(this), {
timeout: 3000, // 3초 초과 시 실패 처리
errorThresholdPercentage: 50, // 50% 실패율 초과 시 OPEN
resetTimeout: 30000, // 30초 후 HALF-OPEN 시도
volumeThreshold: 5, // 최소 5번 요청 후 통계 시작
});
// 상태 변화 로그 (CloudWatch로 수집됨)
this.breaker.on("open", () =>
this.logger.warn("Circuit OPEN: 결제 서비스 차단"),
);
this.breaker.on("halfOpen", () =>
this.logger.log("Circuit HALF-OPEN: 결제 서비스 탐색 중"),
);
this.breaker.on("close", () =>
this.logger.log("Circuit CLOSED: 결제 서비스 복구"),
);
}
private async callPaymentService(orderId: string, amount: number) {
const response = await firstValueFrom(
this.httpService.post("http://payment-service/process", {
orderId,
amount,
}),
);
return response.data;
}
async processPayment(orderId: string, amount: number) {
return this.breaker.fire(orderId, amount).catch((err) => {
this.logger.error(`결제 처리 실패 (fallback): ${err.message}`);
return { success: false, reason: "payment_service_unavailable", orderId };
});
}
}

ECS Task Definition의 헬스체크와 Circuit Breaker를 연계하면 자가 회복(Self-Healing) 구조를 만들 수 있다. Circuit Breaker가 OPEN 상태임을 헬스체크 엔드포인트에 반영하면, ECS가 장애 서비스를 자동 감지하여 재시작한다.

// health.controller.ts — 의존 서비스 상태를 헬스체크에 반영
@Controller("health")
export class HealthController {
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
() =>
this.http.pingCheck("payment-service", "http://payment-service/health"),
() =>
this.http.pingCheck(
"notification-service",
"http://notification-service/health",
),
]);
}
}
# ECS Task Definition — 헬스체크 설정
healthCheck:
command: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
interval: 30 # 30초마다 체크
timeout: 5 # 5초 내 응답 없으면 실패
retries: 3 # 3번 연속 실패 시 UNHEALTHY
startPeriod: 60 # 컨테이너 시작 후 60초 유예 기간

ECS는 헬스체크 실패 시 해당 Task를 교체하고, ALB는 UNHEALTHY 인스턴스로의 트래픽을 자동 차단한다. Circuit Breaker + ECS HealthCheck 조합이 MSA 환경 자가 회복의 핵심 패턴이다.

”왜 Circuit Breaker 없이는 위험한가”

섹션 제목: “”왜 Circuit Breaker 없이는 위험한가””
Circuit Breaker 없는 경우:
1. 결제 서비스가 느려짐 (응답 30초 이상)
2. API Gateway가 결제 서비스 응답 대기 중
3. 모든 요청이 대기 상태로 쌓임 (스레드 풀 고갈)
4. API Gateway 자체가 타임아웃 → 503 오류
5. 주문 서비스 → API Gateway → 전체 시스템 다운 (Cascading Failure)
Circuit Breaker가 있는 경우:
1. 결제 서비스가 느려짐
2. 5회 실패 후 즉시 OPEN → Fallback 응답 반환 (10ms 이내)
3. 다른 서비스는 정상 동작 유지
4. 30초 후 자동 복구 탐지

📖 더 보기: Resilient Microservices with NestJS: Circuit Breaker — NestJS 인터셉터로 Circuit Breaker를 구현하는 실전 가이드 (중급)


3.6 CQRS 패턴 (Command Query Responsibility Segregation)

섹션 제목: “3.6 CQRS 패턴 (Command Query Responsibility Segregation)”

비유: 주문받는 직원 vs 재고 확인 직원

섹션 제목: “비유: 주문받는 직원 vs 재고 확인 직원”

식당에서 주문을 받는 직원(Command)과 메뉴판을 보여주는 직원(Query)은 다르다. 주문 처리는 복잡한 규칙(재고 확인, 결제, 알림)을 거치지만, 메뉴 조회는 그냥 빠르게 보여주기만 하면 된다. CQRS는 이 두 역할을 코드 수준에서 명확히 분리한다.

원리: 읽기와 쓰기를 분리하는 이유

섹션 제목: “원리: 읽기와 쓰기를 분리하는 이유”

단일 모델로 읽기와 쓰기를 모두 처리하면 두 가지 문제가 생긴다.

단일 모델의 한계:
1. 성능 충돌:
쓰기(주문 생성) → 복잡한 도메인 검증, 트랜잭션, 이벤트 발행
읽기(주문 목록) → 단순 조회, JOIN, 페이징
→ 복잡한 도메인 모델이 단순 조회 성능까지 희생
2. 확장 충돌:
쓰기는 트래픽이 낮지만 처리가 복잡 → 수직 확장 필요
읽기는 트래픽이 높지만 처리가 단순 → 수평 확장 필요
→ 하나의 서비스로 두 가지를 동시에 최적화 불가
CQRS 분리 후:
Command Side: 도메인 모델 기반, 강한 일관성, 쓰기 최적화 DB
Query Side: 읽기 전용 모델, 최종 일관성, 읽기 최적화 DB(뷰, 캐시)
Terminal window
npm install @nestjs/cqrs
create-order.command.ts
// Command: 상태를 변경하는 요청
export class CreateOrderCommand {
constructor(
public readonly userId: string,
public readonly items: { productId: string; quantity: number }[],
) {}
}
// Command Handler: 도메인 로직 실행
// create-order.handler.ts
import { CommandHandler, ICommandHandler, EventBus } from "@nestjs/cqrs";
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
constructor(
private readonly orderRepository: OrderRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: CreateOrderCommand): Promise<string> {
const order = Order.create(command.userId, command.items);
await this.orderRepository.save(order);
// 도메인 이벤트 발행 → Query Side가 읽기 모델 업데이트
this.eventBus.publish(new OrderCreatedEvent(order.id, order.userId));
return order.id;
}
}
get-order-list.query.ts
// Query: 데이터 조회 요청 (도메인 모델 거치지 않음)
export class GetOrderListQuery {
constructor(
public readonly userId: string,
public readonly page: number,
) {}
}
// Query Handler: DB에서 직접 읽기 최적화 조회
// get-order-list.handler.ts
import { QueryHandler, IQueryHandler } from "@nestjs/cqrs";
@QueryHandler(GetOrderListQuery)
export class GetOrderListHandler implements IQueryHandler<GetOrderListQuery> {
constructor(private readonly dataSource: DataSource) {}
async execute(query: GetOrderListQuery): Promise<OrderListDto[]> {
// 읽기 전용 최적화 쿼리 (도메인 모델을 거치지 않음)
return this.dataSource.query(
`SELECT o.id, o.status, o.total_price, o.created_at
FROM orders o
WHERE o.user_id = $1
ORDER BY o.created_at DESC
LIMIT 20 OFFSET $2`,
[query.userId, (query.page - 1) * 20],
);
}
}
// Controller에서 사용
@Controller("orders")
export class OrdersController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
async create(@Body() dto: CreateOrderDto) {
const orderId = await this.commandBus.execute(
new CreateOrderCommand(dto.userId, dto.items),
);
return { orderId };
}
@Get()
async list(@Query("userId") userId: string, @Query("page") page = 1) {
return this.queryBus.execute(new GetOrderListQuery(userId, page));
}
}
// 예상 동작 흐름
POST /orders
→ CommandBus → CreateOrderHandler
→ Order.create() (도메인 규칙 검증)
→ orderRepository.save()
→ EventBus.publish(OrderCreatedEvent)
→ 응답: { orderId: "ord-abc123" }
GET /orders?userId=user-1&page=1
→ QueryBus → GetOrderListHandler
→ 직접 SQL 조회 (도메인 모델 없음, 빠름)
→ 응답: [{ id, status, total_price, created_at }, ...]

“왜 AWS SNS/SQS와 CQRS를 함께 쓰는가” — 이벤트 기반 읽기 모델 동기화

섹션 제목: ““왜 AWS SNS/SQS와 CQRS를 함께 쓰는가” — 이벤트 기반 읽기 모델 동기화”

MSA에서 CQRS의 Query Side는 다른 서비스의 이벤트를 수신하여 자신만의 읽기 모델을 유지한다. AWS SNS/SQS는 이 이벤트 전달의 신뢰성을 보장한다.

이벤트 기반 읽기 모델 동기화:
주문 서비스 알림 서비스
OrderCreatedEvent ──▶ SNS Topic ──▶ SQS Queue ──▶ Query Model 업데이트
(Command Side) (자체 읽기 DB에 저장)
사용자 서비스가 "최근 주문 5건"을 조회할 때:
← 주문 서비스에 직접 API 호출 (❌ 동기 의존)
← 자신의 읽기 모델 DB에서 조회 (✅ 독립성 유지)
// NestJS에서 SQS 이벤트 수신 후 읽기 모델 업데이트
import { SqsMessageHandler } from "@ssut/nestjs-sqs";
@Injectable()
export class OrderEventHandler {
constructor(private readonly orderReadRepository: OrderReadRepository) {}
@SqsMessageHandler("order-events-queue", false)
async handleOrderEvent(message: AWS.SQS.Message) {
const event = JSON.parse(message.Body);
if (event.type === "OrderCreated") {
// 읽기 모델(Query Side) 업데이트
await this.orderReadRepository.upsert({
id: event.orderId,
userId: event.userId,
status: "pending",
createdAt: event.timestamp,
});
}
}
}

📖 더 보기: Decompose Monoliths using CQRS and Event Sourcing — AWS Prescriptive Guidance — AWS 환경에서 CQRS와 이벤트 소싱으로 모놀리스를 마이크로서비스로 분해하는 공식 가이드 (중급)

📖 더 보기: NestJS Microservices with AWS SNS and SQS — NestJS에서 SNS 팬아웃 패턴으로 여러 SQS 큐에 이벤트를 배포하는 실전 구현 (중급)


3.7 Saga 패턴과 Outbox 패턴 — 분산 트랜잭션 일관성

섹션 제목: “3.7 Saga 패턴과 Outbox 패턴 — 분산 트랜잭션 일관성”

A 계좌에서 B 계좌로 100만 원을 이체한다고 가정하자. 모놀리식에서는 하나의 DB 트랜잭션으로 처리된다. MSA에서는 “A 계좌 서비스”와 “B 계좌 서비스”가 물리적으로 다른 DB를 쓴다. 이 상황에서 A에서 출금 후 B 입금이 실패하면 어떻게 되는가?

Saga 패턴은 이 문제를 **보상 트랜잭션(Compensating Transaction)**으로 해결한다. B 입금이 실패하면 A 출금을 자동으로 되돌리는 “취소 트랜잭션”을 실행하는 것이다.

Choreography(안무) 방식 — 각 서비스가 이벤트를 발행하고 서로 반응

1. OrderService → "order_placed" 이벤트 발행
2. PaymentService 수신 → 결제 처리
├── 성공: "payment_completed" 이벤트 발행
└── 실패: "payment_failed" 이벤트 발행
3. OrderService가 "payment_failed" 수신 → 주문 취소 (보상 트랜잭션)
4. InventoryService가 "order_cancelled" 수신 → 재고 복구
[OrderService] [PaymentService] [InventoryService]
order_placed ──────────────▶ 결제 시도
payment_failed ──────────────▶ (무관)
order_cancelled ◀─────────── (OrderService가 처리)
재고 복구 ◀────────

Orchestration(지휘) 방식 — 중앙 Orchestrator가 흐름 제어

[Saga Orchestrator]
│ 1. OrderService에 "createOrder" 명령
│ 2. PaymentService에 "processPayment" 명령
│ 3. InventoryService에 "reserveItems" 명령
│ 4. 실패 시 역순으로 "compensate" 명령 발행
└── AWS Step Functions 또는 Temporal로 구현

NestJS + AWS Step Functions로 Orchestration Saga 구현

섹션 제목: “NestJS + AWS Step Functions로 Orchestration Saga 구현”
// order-saga.handler.ts — Step Functions 실행 시작
import { Injectable } from "@nestjs/common";
import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
@Injectable()
export class OrderSagaHandler {
private readonly sfnClient = new SFNClient({ region: "ap-northeast-2" });
async startOrderSaga(order: CreateOrderDto): Promise<string> {
const command = new StartExecutionCommand({
stateMachineArn: process.env.ORDER_SAGA_STATE_MACHINE_ARN,
input: JSON.stringify({
orderId: order.id,
userId: order.userId,
items: order.items,
amount: order.totalAmount,
}),
});
const result = await this.sfnClient.send(command);
console.log(`[OrderSaga] 실행 시작: ${result.executionArn}`);
return result.executionArn;
}
}
// Step Functions 상태 머신 정의 (단순화)
{
"Comment": "Order Saga",
"StartAt": "ProcessPayment",
"States": {
"ProcessPayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:payment-handler",
"Next": "ReserveInventory",
"Catch": [{ "ErrorEquals": ["PaymentFailed"], "Next": "CompensateOrder" }]
},
"ReserveInventory": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:inventory-handler",
"End": true,
"Catch": [
{ "ErrorEquals": ["InventoryFailed"], "Next": "CompensatePayment" }
]
},
"CompensatePayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:payment-refund-handler",
"Next": "CompensateOrder"
},
"CompensateOrder": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:order-cancel-handler",
"End": true
}
}
}
// 예상 실행 로그 (결제 실패 시나리오)
[OrderSaga] 실행 시작: arn:aws:states:...:execution:order-saga:exec-001
[PaymentHandler] 결제 처리 시도: orderId=ORD-001, amount=15000
[PaymentHandler] 결제 실패: InsufficientFunds
[OrderSaga] 보상 트랜잭션 시작: CompensateOrder
[OrderCancelHandler] 주문 취소 처리: ORD-001 → status: cancelled

📖 더 보기: Saga Orchestration for Microservices Using the Outbox Pattern — InfoQ — AWS Step Functions와 Outbox 패턴을 결합한 실전 아키텍처 (중급)

Outbox 패턴 — 이벤트 발행의 원자성 보장

섹션 제목: “Outbox 패턴 — 이벤트 발행의 원자성 보장”

📌 Outbox Pattern 상세 구현(outbox-relay.service.ts, cron relay, idempotency key)은 L8 cqrs-event-sourcing.md에서 다룹니다. Saga에서 Outbox를 사용하는 이유: DB 저장과 이벤트 발행을 하나의 로컬 트랜잭션으로 묶어 “DB는 커밋됐는데 SQS 발행이 실패”하는 상황을 방지합니다.

패턴해결하는 문제보장
Saga여러 서비스에 걸친 분산 트랜잭션 일관성최종 일관성 + 보상 트랜잭션
Outbox이벤트 발행의 원자성 (DB 저장 = 발행)At-Least-Once 이벤트 전달 보장
조합Saga 각 단계를 Outbox로 안전하게 전달메시지 유실 없는 분산 트랜잭션

📖 더 보기: Microservices.io — Transactional Outbox Pattern — Outbox 패턴의 공식 레퍼런스. Message Relay 구현 방법과 주의사항 포함 (중급)


API Gateway 활용 (가장 보편적)

  • AWS API Gateway + Lambda로 서버리스 API 구성
  • 인증 미들웨어를 각 서비스에 중복 구현하지 않고 Gateway에서 일괄 처리
  • Throttling 설정으로 DDoS 기본 방어
  • Stage(dev/prod) 분리로 환경별 배포 관리

NestJS 마이크로서비스 실무 패턴

// 주문이 완료되면 이벤트 발행 → 알림 서비스가 구독
@EventPattern('order_completed')
async handleOrderCompleted(@Payload() data: OrderCompletedEvent) {
await this.notificationService.sendPushNotification({
userId: data.userId,
message: `주문 ${data.orderId}가 완료되었습니다.`,
});
}

Service Mesh 도입 타이밍 대부분의 스타트업은 서비스 5~10개 규모에서 Kubernetes로 이전하면서 Istio를 검토한다. AWS EKS 환경이라면 AWS App Mesh가 CloudWatch, X-Ray와 기본 통합되어 있어 진입 장벽이 낮다.


  • NestJS: @nestjs/microservices 모듈을 활용하면 TCP/Redis/RabbitMQ 기반 마이크로서비스를 빠르게 구현할 수 있다.
  • AWS 스택: API Gateway + Lambda 조합은 NestJS 앱을 서버리스로 배포할 때도 그대로 사용 가능하다.
  • BackOps 관점: 서비스 간 의존 관계를 파악하고, 장애 시 어떤 서비스가 다운스트림에 영향을 주는지 추적하는 것이 핵심 역할이다.
  • CloudFront Functions: 인증 토큰 검증을 엣지에서 처리하면 Origin(NestJS 서버) 부하를 크게 줄일 수 있다.

패턴역할위치선택 기준
API Gateway단일 진입점, 라우팅·인증클라이언트-서비스 사이모든 MSA에서 기본
BFF클라이언트별 최적화API Gateway 역할 분화클라이언트 종류가 2개 이상
Service Mesh서비스 간 네트워크 제어서비스-서비스 사이서비스 10개 이상, Kubernetes 환경
Load Balancer트래픽 분산Gateway 앞단모든 환경
Edge Computing엣지에서 로직 실행CDN 노드지연 최소화, 글로벌 서비스

API Gateway vs Service Mesh 명확한 차이

  • API Gateway: 외부 클라이언트 → 내부 서비스 트래픽 제어 (North-South 트래픽)
  • Service Mesh: 내부 서비스 ↔ 내부 서비스 트래픽 제어 (East-West 트래픽)

두 가지는 경쟁 관계가 아니라 상호 보완적으로 함께 사용된다.


1. 서비스 간 순환 의존 (Circular Dependency)

섹션 제목: “1. 서비스 간 순환 의존 (Circular Dependency)”

증상/에러

서비스 A → 서비스 B 호출 중 타임아웃
서비스 B → 서비스 A를 다시 호출하는 구조
결과: 두 서비스 모두 응답 없이 hang 상태
로그: [OrderService] Timeout waiting for UserService response (30000ms)

원인 도메인 경계가 잘못 정의된 경우 발생한다. 주문 서비스가 사용자 서비스를 호출하고, 사용자 서비스가 다시 주문 이력을 위해 주문 서비스를 호출하는 패턴이다. 동기 호출 체인에서는 데드락이 발생한다.

해결 방법

  1. 이벤트 기반 비동기 통신으로 전환: 동기 HTTP 호출 대신 메시지 큐(RabbitMQ, SQS) 활용
  2. 도메인 경계 재설계: 주문 이력은 주문 서비스가 소유하고, 사용자 서비스는 주문 서비스에 의존하지 않도록 변경
  3. 데이터 복제: 사용자 서비스가 필요한 주문 데이터를 이벤트로 수신하여 자체 DB에 복제 (CQRS 패턴)
// 해결책: 이벤트 발행으로 순환 의존 제거
// 주문 서비스 - 이벤트 발행
this.eventEmitter.emit('order.created', {
userId: order.userId,
orderId: order.id,
amount: order.totalAmount,
});
// 사용자 서비스 - 이벤트 수신 (주문 서비스를 직접 호출하지 않음)
@OnEvent('order.created')
async handleOrderCreated(payload: OrderCreatedEvent) {
await this.userOrderHistoryRepository.save(payload);
}

증상/에러

모든 서비스 응답은 정상인데 전체 응답 시간이 느림
AWS CloudWatch: API Gateway P99 latency > 3000ms
특정 시간대에 503 Service Unavailable 급증
로그: [API Gateway] Throttling limit exceeded for path /api/orders

원인

  • AWS API Gateway 기본 계정 한도: 초당 10,000 요청 (리전당)
  • 스테이지별 Throttling 설정이 너무 낮게 설정됨
  • Gateway 자체가 단일 장애점(SPOF)이 되는 구조
  • 응답 캐싱 미설정으로 불필요한 백엔드 호출 발생

해결 방법

Terminal window
# 1. AWS API Gateway Throttling 한도 증가 요청
aws service-quotas request-service-quota-increase \
--service-code apigateway \
--quota-code L-8A5B8E43 \
--desired-value 20000
# 2. API Gateway 응답 캐싱 활성화 (콘솔 경로)
# API Gateway → Stage → Default Route Throttling
# Cache Settings → Enable API cache: ✅
# Cache capacity: 0.5GB
# Cache TTL: 300초
# 3. CloudWatch로 병목 구간 파악
aws cloudwatch get-metric-statistics \
--namespace AWS/ApiGateway \
--metric-name Latency \
--dimensions Name=ApiName,Value=MyServiceGateway \
--start-time 2024-01-15T00:00:00Z \
--end-time 2024-01-15T23:59:59Z \
--period 300 \
--statistics p99

예상 출력

{
"Datapoints": [
{
"Timestamp": "2024-01-15T10:00:00Z",
"ExtendedStatistics": { "p99": 2800.5 },
"Unit": "Milliseconds"
}
]
}

3. 분산 트랜잭션 실패 (Distributed Transaction Failure)

섹션 제목: “3. 분산 트랜잭션 실패 (Distributed Transaction Failure)”

증상/에러

주문 생성 성공 → 결제 처리 실패 → 주문이 '결제 대기' 상태로 영구 stuck
사용자에게는 주문이 완료된 것으로 표시되나 결제는 되지 않은 상태
로그: [PaymentService] Payment failed: InsufficientFunds
[OrderService] Order ORD-001 status: pending_payment (rollback 없음)

원인 마이크로서비스에서는 여러 서비스에 걸친 작업에 RDBMS의 ACID 트랜잭션을 적용할 수 없다. 주문 서비스 DB와 결제 서비스 DB는 물리적으로 분리되어 있기 때문이다.

해결 방법: Saga 패턴

Saga 패턴은 분산 트랜잭션을 일련의 로컬 트랜잭션 + 보상 트랜잭션으로 분해한다. 구현 방식은 두 가지다.

구분Choreography (안무)Orchestration (지휘)
중앙 제어없음. 각 서비스가 이벤트 발행/구독있음. Saga Orchestrator가 흐름 제어
결합도낮음 (이벤트만 알면 됨)중간 (Orchestrator가 모든 서비스를 알아야 함)
디버깅어려움 (이벤트 체인 추적 필요)쉬움 (Orchestrator 로그에 전체 흐름)
적합한 상황서비스 3~4개 이내, 단순 흐름서비스 5개 이상, 복잡한 분기
도구 예시SQS, RabbitMQ 이벤트Temporal, AWS Step Functions

실무 팁: 처음 시작할 때는 Choreography가 간단하다. 서비스가 늘어나고 흐름이 복잡해지면(조건부 분기, 타임아웃 등) Orchestration으로 전환을 검토한다. 하이브리드 방식(단순 흐름은 Choreography, 복잡한 흐름은 Orchestration)도 실무에서 흔하다.

📖 더 보기: Saga Pattern — microservices.io — Choreography와 Orchestration 방식의 상세 비교와 선택 기준 (중급)

📖 더 보기: Saga Pattern Demystified — ByteByteGo — 실무 예시와 함께 두 방식을 시각적으로 비교 (입문~중급)

Choreography-based Saga (이벤트 기반):
1. OrderService: 주문 생성 → 'order_created' 이벤트 발행
2. PaymentService: 이벤트 수신 → 결제 처리 시도
├── 성공: 'payment_completed' 이벤트 발행
└── 실패: 'payment_failed' 이벤트 발행
3. OrderService: 'payment_failed' 수신 → 주문 상태를 'cancelled'로 변경 (보상 트랜잭션)
// 보상 트랜잭션 (Compensating Transaction) 구현
@EventPattern('payment_failed')
async handlePaymentFailed(@Payload() data: { orderId: string; reason: string }) {
console.log(`결제 실패 이벤트 수신: ${data.orderId}, 사유: ${data.reason}`);
// 보상 트랜잭션: 주문 취소 처리
await this.orderRepository.update(data.orderId, {
status: 'cancelled',
cancelReason: `결제 실패: ${data.reason}`,
cancelledAt: new Date(),
});
// 재고 복구 이벤트 발행
this.eventEmitter.emit('order.cancelled', { orderId: data.orderId });
console.log(`주문 ${data.orderId} 취소 처리 완료`);
}
// 예상 로그
[OrderService] 결제 실패 이벤트 수신: ORD-001, 사유: InsufficientFunds
[OrderService] 주문 ORD-001 취소 처리 완료
[InventoryService] 재고 복구 완료: 상품 PROD-001 수량 +1

4. SQS 메시지 중복 처리 (Idempotency 위반)

섹션 제목: “4. SQS 메시지 중복 처리 (Idempotency 위반)”

증상/에러

주문 완료 이메일이 한 건인데 고객에게 2~3통 발송됨
재고가 실제 주문 수보다 더 많이 차감됨
로그: [NotificationService] 이메일 전송: order-001 (타임스탬프 3회)

원인

AWS SQS는 At-Least-Once Delivery를 보장한다. 즉, 네트워크 이슈나 처리 시간 초과 시 동일한 메시지가 여러 번 전달될 수 있다. 또한 ECS 태스크가 배포 중 재시작되면, 처리 중이던 메시지가 visibility timeout 만료 후 다시 큐에 들어온다.

해결 방법: 멱등성(Idempotency) 보장

// notification.service.ts - 멱등성 키로 중복 처리 방지
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
@Injectable()
export class NotificationService {
constructor(
@InjectRepository(ProcessedEvent)
private readonly processedEventRepo: Repository<ProcessedEvent>,
) {}
async handleOrderCompleted(event: OrderCompletedEvent) {
const idempotencyKey = `order-completed-${event.orderId}`;
// 이미 처리된 이벤트인지 확인
const alreadyProcessed = await this.processedEventRepo.findOne({
where: { key: idempotencyKey },
});
if (alreadyProcessed) {
console.log(`[중복 무시] 이미 처리된 이벤트: ${idempotencyKey}`);
return; // 중복 메시지 → 아무 작업 없이 종료 (SQS에서 삭제됨)
}
// 처리 기록 저장 (트랜잭션으로 묶기)
await this.processedEventRepo.save({
key: idempotencyKey,
processedAt: new Date(),
});
// 실제 비즈니스 로직
await this.sendOrderCompletionEmail(event.userId, event.orderId);
console.log(`[처리 완료] 주문 완료 이메일 전송: ${event.orderId}`);
}
}
// ProcessedEvent 엔티티
@Entity("processed_events")
export class ProcessedEvent {
@PrimaryColumn()
key: string; // idempotency key (unique)
@Column()
processedAt: Date;
// TTL 처리: 7일 이상 된 레코드는 배치 작업으로 정리
}
// 첫 번째 메시지 처리 시
[처리 완료] 주문 완료 이메일 전송: order-001
// 동일 메시지 재전달 시 (SQS 재시도)
[중복 무시] 이미 처리된 이벤트: order-completed-order-001

추가 팁: SQS FIFO 큐는 MessageDeduplicationId를 제공하여 5분 내 중복 메시지를 자동으로 제거한다. Standard 큐보다 처리량이 낮지만(초당 300 TPS), 순서 보장이 필요한 결제·재고 처리에 적합하다.

Terminal window
# SQS FIFO 큐로 메시지 전송 (중복 제거 ID 포함)
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"

MSA 설계 전 검토 항목:

  • 팀 규모와 서비스 수에 비해 MSA가 적합한가 (10명 이하, 5개 미만이면 모놀리식 권장)
  • 도메인 경계(Bounded Context)가 명확히 정의되어 있는가
  • CI/CD 파이프라인이 서비스별 독립 배포를 지원하는가
  • 분산 추적(AWS X-Ray 또는 Jaeger)이 구성되어 있는가
  • API Gateway에 인증·Rate Limiting이 설정되어 있는가
  • 서비스 간 통신 실패 시 재시도/Circuit Breaker 정책이 있는가
  • 분산 트랜잭션이 필요한 경우 Saga 패턴으로 설계되었는가
  • 서비스 다운 시 장애가 다른 서비스로 전파되지 않는가
  • Edge Computing 활용 시 CloudFront Functions/Lambda@Edge 중 복잡도에 맞는 선택을 했는가
  • Service Mesh 도입 전 “정말 필요한가”를 팀과 합의했는가

키워드설명
Microservices독립 배포·확장 가능한 작은 서비스 단위 아키텍처
Monolith단일 코드베이스에 모든 기능을 포함하는 전통적 구조
API Gateway클라이언트와 서비스 사이의 단일 진입점
BFF (Backend for Frontend)클라이언트 유형별로 최적화된 전용 API Gateway
Service Mesh서비스 간 트래픽을 투명하게 관리하는 인프라 레이어
Sidecar Proxy서비스 컨테이너 옆에 실행되는 네트워크 프록시 컨테이너
EnvoyIstio에서 사용하는 고성능 오픈소스 프록시
IstioKubernetes 기반 Service Mesh 솔루션
mTLS서비스 간 양방향 TLS 인증·암호화
Saga 패턴분산 트랜잭션을 이벤트 기반 보상 트랜잭션으로 구현하는 패턴
Edge ComputingCDN 엣지 노드에서 로직을 실행하는 컴퓨팅 패러다임
CloudFront FunctionsAWS CDN 엣지에서 실행되는 경량 JavaScript 함수
Lambda@EdgeCloudFront 엣지에서 실행되는 AWS Lambda 확장
North-South 트래픽외부 클라이언트 ↔ 내부 서비스 간 트래픽 (API Gateway 영역)
East-West 트래픽내부 서비스 ↔ 내부 서비스 간 트래픽 (Service Mesh 영역)


실습 1: NestJS 마이크로서비스 로컬 테스트

섹션 제목: “실습 1: NestJS 마이크로서비스 로컬 테스트”
Terminal window
# NestJS 마이크로서비스 프로젝트 생성
npx @nestjs/cli new order-service
cd order-service
npm install @nestjs/microservices
# 마이크로서비스로 실행
npm run start:dev
// 예상 출력
[Nest] LOG [NestFactory] Starting Nest application...
[Nest] LOG [InstanceLoader] AppModule dependencies initialized
[Nest] LOG [NestMicroservice] Nest microservice successfully started
Terminal window
# 현재 계정의 API Gateway 목록 조회
aws apigateway get-rest-apis --region ap-northeast-2
# 예상 출력
{
"items": [
{
"id": "abc123",
"name": "MyAPI",
"createdDate": "2024-01-01T00:00:00+00:00",
"version": "1",
"apiKeySource": "HEADER",
"endpointConfiguration": {
"types": ["REGIONAL"]
}
}
]
}
# API Gateway 특정 엔드포인트 Throttle 설정 확인
aws apigateway get-stage \
--rest-api-id abc123 \
--stage-name prod \
--region ap-northeast-2

실습 3: Istio 서비스 메시 상태 확인 (Kubernetes 환경)

섹션 제목: “실습 3: Istio 서비스 메시 상태 확인 (Kubernetes 환경)”
Terminal window
# Istio 설치 상태 확인
kubectl get pods -n istio-system
# 예상 출력
NAME READY STATUS RESTARTS
istio-ingressgateway-xxx 1/1 Running 0
istiod-xxx 1/1 Running 0
# Sidecar Proxy 주입 여부 확인 (READY 컬럼이 2/2이면 Sidecar 주입됨)
kubectl get pods -n production
# 예상 출력
NAME READY STATUS RESTARTS
order-service-xxx 2/2 Running 0 2/2: 컨테이너 + Envoy
user-service-xxx 2/2 Running 0
# 서비스 간 mTLS 상태 확인
istioctl authn tls-check order-service.production.svc.cluster.local
# 예상 출력
HOST:PORT STATUS SERVER CLIENT AUTHN POLICY
order-service.production.svc.cluster.local:80 OK STRICT STRICT /default

MSA는 서비스를 독립적으로 분리하고(마이크로서비스), API Gateway로 진입점을 단일화하며, Service Mesh로 서비스 간 네트워크를 제어하고, Edge Computing으로 지연을 최소화하는 아키텍처 패턴의 집합이다. — 단, 소규모 팀은 “모놀리식 먼저, 경계가 보이면 분리”가 현실적인 전략이다.