TL;DR
- **동기 vs 비동기**: REST/gRPC는 결합도 높고 빠른 응답, 이벤트는 결합도 낮고 비동기. 둘 다 필요
- **REST vs gRPC vs GraphQL**: REST(표준), gRPC(성능 + 타입), GraphQL(클라이언트 주도)
- **Saga 패턴**: 분산 트랜잭션의 표준. Choreography(이벤트) vs Orchestration(중앙 조정자)
- **Outbox**: dual-write 문제(DB + Kafka 동시 쓰기 실패)를 해결하는 필수 패턴
- **회복탄력성 4종**: Circuit Breaker(차단), Retry(재시도), Bulkhead(격리), Timeout(시간 제한)
1. 마이크로서비스 통신의 본질적 어려움
1.1 모놀리스 vs 마이크로서비스 통신
| | 모놀리스 | 마이크로서비스 |
|---|---|---|
| 함수 호출 | 메서드 호출 (ns) | 네트워크 호출 (ms) |
| 트랜잭션 | 단일 ACID | 분산 (Saga) |
| 디버깅 | 스택 트레이스 | 분산 트레이싱 |
| 데이터 일관성 | 즉시 | 최종 일관성 |
| 배포 | 한 번 | 독립적 |
**핵심 통찰**: 마이크로서비스는 **분산 시스템의 모든 어려움을 상속**합니다. CAP, 부분 실패, 네트워크 지연, 메시지 순서 등.
1.2 8가지 분산 컴퓨팅의 오류
L. Peter Deutsch가 정리한 **개발자가 자주 하는 잘못된 가정**:
1. 네트워크는 신뢰할 수 있다
2. 지연시간은 0이다
3. 대역폭은 무한하다
4. 네트워크는 안전하다
5. 토폴로지는 변하지 않는다
6. 관리자는 한 명이다
7. 전송 비용은 0이다
8. 네트워크는 동질적이다
**모두 거짓**입니다. 마이크로서비스를 설계할 때 이 가정들을 의식적으로 깨뜨려야 합니다.
2. 동기 통신 — REST vs gRPC vs GraphQL
2.1 REST — 사실상의 표준
GET /api/v1/users/1000 HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer xyz...
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 1000,
"name": "Alice",
"email": "alice@example.com"
}
**장점**:
- HTTP/JSON 표준 — 모든 언어 지원
- 캐시 친화적 (HTTP 캐시)
- 디버깅 쉬움 (curl, 브라우저)
- API 게이트웨이와 잘 통합
**단점**:
- 텍스트 직렬화 (JSON) 비효율
- HTTP/1.1 한계 (HTTP/2로 개선)
- 스키마 강제 부족 (OpenAPI로 보완)
- 양방향 스트리밍 어려움
2.2 gRPC — 고성능 RPC
// user.proto
syntax = "proto3";
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc StreamUsers(StreamUsersRequest) returns (stream User);
}
message GetUserRequest {
int64 user_id = 1;
}
message User {
int64 id = 1;
string name = 2;
string email = 3;
}
**장점**:
- **Protocol Buffers** 바이너리 직렬화 (JSON 대비 5-10배 작음)
- HTTP/2 기반 (멀티플렉싱, 양방향 스트리밍)
- 강한 타입 (.proto 스키마)
- 자동 코드 생성 (모든 언어)
- 4가지 통신 모드 (Unary, Server Streaming, Client Streaming, Bidirectional)
**단점**:
- 브라우저 직접 지원 X (gRPC-Web 필요)
- 디버깅 어려움 (바이너리)
- HTTP/2 인프라 필요
**언제 사용**: 서비스 간 내부 통신, 고성능 필요, 양방향 스트리밍.
2.3 GraphQL — 클라이언트 주도
query GetUserWithPosts {
user(id: 1000) {
name
posts(limit: 10) {
title
createdAt
}
}
}
**장점**:
- 클라이언트가 필요한 필드만 요청 (over-fetching 방지)
- 단일 엔드포인트
- 강한 타입 시스템
**단점**:
- N+1 쿼리 위험 (DataLoader 필수)
- 캐싱 복잡 (HTTP 캐시 우회)
- 학습 곡선
**언제 사용**: 다양한 클라이언트(웹/모바일), 다양한 필드 조합.
2.4 비교표
| 특성 | REST | gRPC | GraphQL |
|---|---|---|---|
| 직렬화 | JSON | Protobuf | JSON |
| 전송 | HTTP/1.1 또는 HTTP/2 | HTTP/2 | HTTP |
| 스키마 | OpenAPI (선택) | .proto (필수) | SDL (필수) |
| 캐시 | HTTP 캐시 (쉬움) | 어려움 | 복잡 |
| 양방향 스트리밍 | ❌ | ✅ | Subscriptions |
| 브라우저 | ✅ | gRPC-Web | ✅ |
| 성능 | 보통 | **최고** | 보통 |
| 학습 곡선 | 낮음 | 중간 | 높음 |
| 추천 사용 | 외부 API | 내부 서비스 | 다양한 클라이언트 |
3. 비동기 통신 — 이벤트 드리븐
3.1 왜 비동기인가?
**시나리오**: 주문 → 결제 → 재고 차감 → 배송 → 알림
**동기 방식의 문제**:
1. **결합도**: 모든 서비스가 즉시 응답해야 함
2. **연쇄 실패**: 알림 서비스 다운 = 전체 실패
3. **느림**: 모든 단계의 지연 합산
4. **확장성**: 가장 느린 서비스가 병목
**비동기 방식의 해결**:
1. 주문 서비스: "주문 생성됨" 이벤트 발행 → 즉시 응답
2. 다른 서비스들이 자기 페이스로 처리
3. 한 서비스 다운 → 다른 서비스는 영향 없음
3.2 메시지 브로커 비교
| | Kafka | RabbitMQ | NATS | AWS SQS | Redis Streams |
|---|---|---|---|---|---|
| 모델 | 로그 (영속) | 큐 (소비 시 삭제) | 메시지 + JetStream | 큐 | 로그 |
| 처리량 | 100만+ msg/s | 5만 msg/s | 1100만+ msg/s | 매니지드 | 100만+ msg/s |
| 영속성 | ✅ (디스크) | ✅ (옵션) | JetStream만 | ✅ | ✅ |
| 순서 보장 | 파티션 내 | 큐 내 | 부분적 | FIFO 큐 | Stream 내 |
| 라이선스 | Apache 2.0 | MPL 2.0 | Apache 2.0 | AWS | RSAL |
| 사용 사례 | 이벤트 소싱, 분석 | 작업 큐, RPC | 마이크로서비스 | AWS 기본 | Redis 사용 시 |
3.3 Kafka 패턴
**Topic 설계**:
order-events # 주문 도메인 이벤트
payment-events # 결제 도메인 이벤트
inventory-events # 재고 도메인 이벤트
**파티션 전략**: `order_id` 기준 → 같은 주문의 이벤트가 같은 파티션 → 순서 보장.
producer.send(
'order-events',
key=str(order_id).encode(), # 같은 키 → 같은 파티션
value=json.dumps(event).encode()
)
**Consumer Group**: 같은 그룹의 컨슈머는 파티션을 분담 → 자동 부하 분산.
4. Saga 패턴 — 분산 트랜잭션
4.1 문제: 2PC는 왜 안 되나?
분산 트랜잭션의 전통적 해결책 **2-Phase Commit (2PC)**:
1. Prepare 단계: 모든 참여자에게 "준비됐어?" 확인
2. Commit 단계: 모든 응답이 OK면 commit, 아니면 abort
**문제점**:
- **블로킹**: 하나라도 응답 안 하면 모든 참여자가 락 유지
- **단일 장애점**: 코디네이터 장애 → 전체 멈춤
- **확장성 X**: 분산 환경에서 비현실적
4.2 Saga 패턴
**핵심 아이디어**: 큰 트랜잭션을 작은 로컬 트랜잭션의 시퀀스로 분해. 실패 시 **보상 트랜잭션(compensating transaction)**으로 롤백.
**예시: 주문 처리**
| 단계 | 정상 | 실패 시 보상 |
|---|---|---|
| 1. 주문 생성 | `CreateOrder` | `CancelOrder` |
| 2. 결제 | `ChargePayment` | `RefundPayment` |
| 3. 재고 차감 | `ReserveInventory` | `ReleaseInventory` |
| 4. 배송 | `CreateShipment` | `CancelShipment` |
**규칙**:
- 각 로컬 트랜잭션은 독립적으로 commit
- 실패 시 이미 commit된 트랜잭션을 보상으로 되돌림
- 보상은 **반드시 멱등** (중복 호출에도 안전)
4.3 Choreography vs Orchestration
Choreography (분산 조율)
서비스들이 이벤트를 통해 서로 트리거.
[Order Service]
↓ "OrderCreated" event
[Payment Service]
↓ "PaymentProcessed" event
[Inventory Service]
↓ "InventoryReserved" event
[Shipping Service]
**장점**: 결합도 낮음, 각 서비스 독립적
**단점**: 흐름 추적 어려움, 디버깅 복잡
Orchestration (중앙 조율)
**Orchestrator**가 각 단계를 명시적으로 호출.
[Saga Orchestrator]
├─ Call Payment Service
├─ Call Inventory Service
└─ Call Shipping Service
(실패 시 보상 호출)
**장점**: 흐름 명시적, 디버깅 쉬움
**단점**: Orchestrator가 단일 장애점, 복잡한 로직
**도구**: AWS Step Functions, Camunda, Temporal, Netflix Conductor
4.4 Temporal — 차세대 워크플로 엔진
@workflow.defn
class OrderSaga:
@workflow.run
async def run(self, order_id: str):
try:
await workflow.execute_activity(
charge_payment, order_id,
start_to_close_timeout=timedelta(seconds=30)
)
await workflow.execute_activity(
reserve_inventory, order_id,
start_to_close_timeout=timedelta(seconds=30)
)
await workflow.execute_activity(
create_shipment, order_id,
start_to_close_timeout=timedelta(seconds=30)
)
except ActivityError:
자동 보상
await workflow.execute_activity(refund_payment, order_id)
raise
**Temporal의 강점**:
- 워크플로가 코드처럼 보임 (가독성)
- **상태 자동 영속화** — 서버 재시작 후 이어서 실행
- 자동 재시도, 타임아웃, 보상
5. Outbox 패턴 — Dual-Write 문제 해결
5.1 문제: DB와 Kafka 동시 쓰기
def create_order(order):
db.save(order) # 1. DB 저장
kafka.send("order-events", order) # 2. Kafka 발행
**문제 시나리오**:
- 1번 성공, 2번 실패: DB에는 있지만 이벤트 안 감 → 다른 서비스가 모름
- 1번 실패, 2번 성공: 이벤트는 갔지만 DB에 없음 → 데이터 불일치
이를 **dual-write problem**이라고 합니다.
5.2 해결책: Outbox 테이블
DB에 `outbox` 테이블을 만들어 이벤트를 함께 저장:
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(255),
aggregate_id VARCHAR(255),
event_type VARCHAR(255),
payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
def create_order(order):
with db.transaction():
db.save(order)
db.save(OutboxEvent(
aggregate_type='Order',
aggregate_id=order.id,
event_type='OrderCreated',
payload=order.to_dict()
))
트랜잭션 commit 시 두 INSERT가 원자적
5.3 Outbox → Kafka 전달
**방법 1: Polling Publisher**
별도 프로세스가 주기적으로 outbox를 폴링:
while True:
events = db.query("SELECT * FROM outbox WHERE processed_at IS NULL LIMIT 100")
for event in events:
kafka.send(event.aggregate_type, event.payload)
db.update("UPDATE outbox SET processed_at = NOW() WHERE id = ?", event.id)
sleep(1)
**방법 2: CDC (Change Data Capture)**
**Debezium** 같은 도구가 PostgreSQL의 WAL을 읽어 자동으로 Kafka에 전송:
Debezium connector config
{
"name": "outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.dbname": "orders",
"table.include.list": "public.outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter"
}
}
**CDC가 우수한 이유**:
- 별도 폴링 프로세스 불필요
- WAL 기반이라 거의 실시간
- 트랜잭션 순서 보장
6. 회복탄력성 패턴
6.1 Circuit Breaker
장애 서비스 호출을 차단하여 cascade failure 방지.
**3가지 상태**:
1. **Closed**: 정상, 호출 통과
2. **Open**: 장애 감지, 즉시 실패 (타임아웃 절약)
3. **Half-Open**: 일정 시간 후 시험적으로 통과
from circuitbreaker import circuit
@circuit(failure_threshold=5, recovery_timeout=60)
def call_payment_service(order):
response = requests.post('http://payment/charge', json=order)
return response.json()
**임계값 튜닝**:
- `failure_threshold`: 5-10회 (너무 낮으면 false positive)
- `recovery_timeout`: 30-60초 (서비스 회복 시간)
6.2 Retry with Exponential Backoff
def retry_with_backoff(func, max_retries=3):
for attempt in range(max_retries):
try:
return func()
except Exception as e:
if attempt == max_retries - 1:
raise
wait = (2 ** attempt) + random.uniform(0, 1) # 지수 + jitter
time.sleep(wait)
**왜 jitter가 필요한가?**: 모든 클라이언트가 동시에 재시도하면 thundering herd. Random jitter로 분산.
6.3 Bulkhead (격리)
선박의 격벽처럼, 한 부분 장애가 전체로 퍼지지 않게 격리.
서비스마다 별도 스레드 풀
payment_pool = ThreadPoolExecutor(max_workers=10)
inventory_pool = ThreadPoolExecutor(max_workers=10)
Payment 서비스가 느려져도 inventory 호출은 영향 없음.
6.4 Timeout
**모든 외부 호출에 타임아웃 필수**.
requests.get(url, timeout=(3, 10)) # connect 3s, read 10s
타임아웃 없으면 → 클라이언트 무한 대기 → 스레드 고갈 → 전체 서비스 멈춤.
7. 멱등성 (Idempotency)
7.1 왜 멱등성이 중요한가?
분산 시스템에서 메시지 중복은 **불가피**합니다:
- 네트워크 재전송
- 컨슈머 재처리
- 실패 후 retry
**같은 메시지를 여러 번 처리해도 결과가 동일**해야 합니다.
7.2 멱등성 키
def process_payment(idempotency_key, amount):
if cache.exists(f"payment:{idempotency_key}"):
return cache.get(f"payment:{idempotency_key}") # 이전 결과 반환
result = charge_card(amount)
cache.set(f"payment:{idempotency_key}", result, ttl=86400)
return result
**Stripe API**는 멱등성 키를 1급으로 지원:
POST /v1/charges HTTP/1.1
Idempotency-Key: my-unique-key-123
7.3 멱등성 패턴
| 작업 | 멱등성 |
|---|---|
| `GET` | ✅ (자연 멱등) |
| `PUT user/1 {name: "Alice"}` | ✅ (덮어쓰기) |
| `DELETE user/1` | ✅ (이미 없으면 OK) |
| `POST` | ❌ (멱등성 키 필요) |
| `INCR counter` | ❌ |
8. 분산 트레이싱
8.1 왜 필요한가?
마이크로서비스 환경에서 한 사용자 요청이 여러 서비스를 거치면, "왜 느려?"를 디버깅하기 어렵습니다. **트레이스 ID**로 전체 흐름을 추적해야 합니다.
8.2 OpenTelemetry
표준 트레이싱 프레임워크. 모든 언어 SDK 제공.
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
def process_order(order):
with tracer.start_as_current_span("process_order") as span:
span.set_attribute("order.id", order.id)
with tracer.start_as_current_span("charge_payment"):
payment_service.charge(order)
with tracer.start_as_current_span("reserve_inventory"):
inventory_service.reserve(order)
**백엔드**: Jaeger, Tempo, Datadog, Honeycomb.
8.3 W3C Trace Context
서비스 간 트레이스 ID 전달의 표준:
GET /api/inventory HTTP/1.1
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
모든 서비스가 이 헤더를 읽고 전달하면 → 전체 요청 흐름이 단일 트레이스로 시각화.
9. 실전 패턴 조합
9.1 주문 처리 시나리오
**요구사항**:
- 주문 생성 시 재고 확인, 결제, 배송 시작
- 어떤 단계라도 실패하면 롤백
- 200ms 이내 응답
- 일일 100만 주문 처리
**아키텍처**:
1. **API Gateway**: REST 진입점, 인증, 레이트 리밋
2. **Order Service**: 주문 생성 + Outbox에 이벤트 저장
3. **Saga Orchestrator (Temporal)**: 결제 → 재고 → 배송 조율
4. **Kafka**: 이벤트 버스
5. **각 도메인 서비스**: 자기 도메인만 처리
**패턴 적용**:
- REST: 클라이언트 ↔ API Gateway
- gRPC: 내부 서비스 간
- Kafka: 비동기 이벤트
- Saga (Orchestration): 분산 트랜잭션
- Outbox + CDC: dual-write 해결
- Circuit Breaker: 외부 결제 게이트웨이 보호
- Idempotency Key: 중복 결제 방지
- OpenTelemetry: 전체 트레이싱
퀴즈
**답**: **둘 다 사용**이 정답입니다. 외부 API(클라이언트, 파트너)는 REST/JSON으로 (디버깅 쉬움, 캐시), 내부 서비스 간 통신은 gRPC로 (성능 5-10배, 강한 타입). API Gateway가 REST를 받아 내부적으로 gRPC로 변환하는 패턴이 일반적입니다. GraphQL은 다양한 클라이언트(웹/모바일)에 다양한 데이터 조합이 필요할 때 사용합니다.
**답**: 2PC는 (1) 블로킹 — 하나라도 응답 안 하면 모든 참여자가 락 유지, (2) 단일 장애점 — 코디네이터 장애 시 멈춤, (3) 확장성 부족. Saga는 큰 트랜잭션을 작은 로컬 트랜잭션으로 분해하고, 실패 시 보상 트랜잭션으로 되돌립니다. 단점은 isolation이 약하다는 것 (중간 상태 노출). Saga가 사실상 표준입니다.
**답**: **Dual-write problem** — DB에 저장하고 Kafka에도 발행해야 할 때, 둘 사이의 원자성 보장. 한쪽만 성공하면 데이터 불일치. Outbox는 DB 트랜잭션 안에서 outbox 테이블에 이벤트를 함께 저장 → 원자성 보장. 별도 프로세스(Polling 또는 CDC/Debezium)가 outbox에서 Kafka로 전달. **Debezium + CDC**가 가장 효율적입니다 (WAL 기반).
**답**: (1) **Closed** — 정상, 호출 통과. 실패가 누적되면 Open으로 전환. (2) **Open** — 장애 감지, 즉시 실패 반환 (타임아웃 절약, cascade failure 방지). (3) **Half-Open** — 일정 시간 후 시험적으로 통과. 성공하면 Closed로, 실패하면 다시 Open으로. 임계값: `failure_threshold` 5-10회, `recovery_timeout` 30-60초.
**답**: 분산 시스템에서 메시지 중복은 **불가피**합니다 (네트워크 재전송, 재처리, 재시도). 같은 결제 요청이 두 번 처리되면 사용자가 두 번 청구됩니다. 멱등성 키는 클라이언트가 생성한 고유 ID로, 서버는 같은 키의 요청은 캐시된 결과를 반환합니다. Stripe, Square 같은 결제 API가 1급으로 지원합니다 (`Idempotency-Key` 헤더).
참고 자료
- [Microservices.io](https://microservices.io/) — 패턴 카탈로그
- [Designing Data-Intensive Applications](https://dataintensive.net/) — Martin Kleppmann
- [Building Microservices](https://samnewman.io/books/building_microservices_2nd_edition/) — Sam Newman
- [Microservices Patterns](https://microservices.io/book) — Chris Richardson
- [Temporal](https://temporal.io/) — 워크플로 엔진
- [Debezium](https://debezium.io/) — CDC 도구
- [OpenTelemetry](https://opentelemetry.io/)
- [W3C Trace Context](https://www.w3.org/TR/trace-context/)
- [Apache Kafka Documentation](https://kafka.apache.org/documentation/)
- [Resilience4j](https://resilience4j.readme.io/) — Java 회복탄력성 라이브러리
- [Polly](https://github.com/App-vNext/Polly) — .NET 회복탄력성
현재 단락 (1/344)
- **동기 vs 비동기**: REST/gRPC는 결합도 높고 빠른 응답, 이벤트는 결합도 낮고 비동기. 둘 다 필요