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가 정리한 개발자가 자주 하는 잘못된 가정:
- 네트워크는 신뢰할 수 있다
- 지연시간은 0이다
- 대역폭은 무한하다
- 네트워크는 안전하다
- 토폴로지는 변하지 않는다
- 관리자는 한 명이다
- 전송 비용은 0이다
- 네트워크는 동질적이다
모두 거짓입니다. 마이크로서비스를 설계할 때 이 가정들을 의식적으로 깨뜨려야 합니다.
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
email
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 왜 비동기인가?
시나리오: 주문 → 결제 → 재고 차감 → 배송 → 알림
동기 방식의 문제:
- 결합도: 모든 서비스가 즉시 응답해야 함
- 연쇄 실패: 알림 서비스 다운 = 전체 실패
- 느림: 모든 단계의 지연 합산
- 확장성: 가장 느린 서비스가 병목
비동기 방식의 해결:
- 주문 서비스: "주문 생성됨" 이벤트 발행 → 즉시 응답
- 다른 서비스들이 자기 페이스로 처리
- 한 서비스 다운 → 다른 서비스는 영향 없음
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):
- Prepare 단계: 모든 참여자에게 "준비됐어?" 확인
- 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가지 상태:
- Closed: 정상, 호출 통과
- Open: 장애 감지, 즉시 실패 (타임아웃 절약)
- 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
import time
import random
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만 주문 처리
아키텍처:
- API Gateway: REST 진입점, 인증, 레이트 리밋
- Order Service: 주문 생성 + Outbox에 이벤트 저장
- Saga Orchestrator (Temporal): 결제 → 재고 → 배송 조율
- Kafka: 이벤트 버스
- 각 도메인 서비스: 자기 도메인만 처리
패턴 적용:
- REST: 클라이언트 ↔ API Gateway
- gRPC: 내부 서비스 간
- Kafka: 비동기 이벤트
- Saga (Orchestration): 분산 트랜잭션
- Outbox + CDC: dual-write 해결
- Circuit Breaker: 외부 결제 게이트웨이 보호
- Idempotency Key: 중복 결제 방지
- OpenTelemetry: 전체 트레이싱
퀴즈
1. REST와 gRPC 중 무엇을 선택해야 하나요?
답: 둘 다 사용이 정답입니다. 외부 API(클라이언트, 파트너)는 REST/JSON으로 (디버깅 쉬움, 캐시), 내부 서비스 간 통신은 gRPC로 (성능 5-10배, 강한 타입). API Gateway가 REST를 받아 내부적으로 gRPC로 변환하는 패턴이 일반적입니다. GraphQL은 다양한 클라이언트(웹/모바일)에 다양한 데이터 조합이 필요할 때 사용합니다.
2. 2PC 대신 Saga 패턴을 사용하는 이유는?
답: 2PC는 (1) 블로킹 — 하나라도 응답 안 하면 모든 참여자가 락 유지, (2) 단일 장애점 — 코디네이터 장애 시 멈춤, (3) 확장성 부족. Saga는 큰 트랜잭션을 작은 로컬 트랜잭션으로 분해하고, 실패 시 보상 트랜잭션으로 되돌립니다. 단점은 isolation이 약하다는 것 (중간 상태 노출). Saga가 사실상 표준입니다.
3. Outbox 패턴이 해결하는 문제는?
답: Dual-write problem — DB에 저장하고 Kafka에도 발행해야 할 때, 둘 사이의 원자성 보장. 한쪽만 성공하면 데이터 불일치. Outbox는 DB 트랜잭션 안에서 outbox 테이블에 이벤트를 함께 저장 → 원자성 보장. 별도 프로세스(Polling 또는 CDC/Debezium)가 outbox에서 Kafka로 전달. Debezium + CDC가 가장 효율적입니다 (WAL 기반).
4. Circuit Breaker의 3가지 상태는?
답: (1) Closed — 정상, 호출 통과. 실패가 누적되면 Open으로 전환. (2) Open — 장애 감지, 즉시 실패 반환 (타임아웃 절약, cascade failure 방지). (3) Half-Open — 일정 시간 후 시험적으로 통과. 성공하면 Closed로, 실패하면 다시 Open으로. 임계값: failure_threshold 5-10회, recovery_timeout 30-60초.
5. 멱등성 키가 필요한 이유는?
답: 분산 시스템에서 메시지 중복은 불가피합니다 (네트워크 재전송, 재처리, 재시도). 같은 결제 요청이 두 번 처리되면 사용자가 두 번 청구됩니다. 멱등성 키는 클라이언트가 생성한 고유 ID로, 서버는 같은 키의 요청은 캐시된 결과를 반환합니다. Stripe, Square 같은 결제 API가 1급으로 지원합니다 (Idempotency-Key 헤더).
참고 자료
- Microservices.io — 패턴 카탈로그
- Designing Data-Intensive Applications — Martin Kleppmann
- Building Microservices — Sam Newman
- Microservices Patterns — Chris Richardson
- Temporal — 워크플로 엔진
- Debezium — CDC 도구
- OpenTelemetry
- W3C Trace Context
- Apache Kafka Documentation
- Resilience4j — Java 회복탄력성 라이브러리
- Polly — .NET 회복탄력성
현재 단락 (1/359)
- **동기 vs 비동기**: REST/gRPC는 결합도 높고 빠른 응답, 이벤트는 결합도 낮고 비동기. 둘 다 필요