Skip to content

✍️ 필사 모드: 마이크로서비스 통신 패턴 완전 가이드 2025: REST vs gRPC vs 비동기 메시징, Saga, Outbox

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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
    email
    posts(limit: 10) {
      title
      createdAt
    }
  }
}

장점:

  • 클라이언트가 필요한 필드만 요청 (over-fetching 방지)
  • 단일 엔드포인트
  • 강한 타입 시스템

단점:

  • N+1 쿼리 위험 (DataLoader 필수)
  • 캐싱 복잡 (HTTP 캐시 우회)
  • 학습 곡선

언제 사용: 다양한 클라이언트(웹/모바일), 다양한 필드 조합.

2.4 비교표

특성RESTgRPCGraphQL
직렬화JSONProtobufJSON
전송HTTP/1.1 또는 HTTP/2HTTP/2HTTP
스키마OpenAPI (선택).proto (필수)SDL (필수)
캐시HTTP 캐시 (쉬움)어려움복잡
양방향 스트리밍Subscriptions
브라우저gRPC-Web
성능보통최고보통
학습 곡선낮음중간높음
추천 사용외부 API내부 서비스다양한 클라이언트

3. 비동기 통신 — 이벤트 드리븐

3.1 왜 비동기인가?

시나리오: 주문 → 결제 → 재고 차감 → 배송 → 알림

동기 방식의 문제:

  1. 결합도: 모든 서비스가 즉시 응답해야 함
  2. 연쇄 실패: 알림 서비스 다운 = 전체 실패
  3. 느림: 모든 단계의 지연 합산
  4. 확장성: 가장 느린 서비스가 병목

비동기 방식의 해결:

  1. 주문 서비스: "주문 생성됨" 이벤트 발행 → 즉시 응답
  2. 다른 서비스들이 자기 페이스로 처리
  3. 한 서비스 다운 → 다른 서비스는 영향 없음

3.2 메시지 브로커 비교

KafkaRabbitMQNATSAWS SQSRedis Streams
모델로그 (영속)큐 (소비 시 삭제)메시지 + JetStream로그
처리량100만+ msg/s5만 msg/s1100만+ msg/s매니지드100만+ msg/s
영속성✅ (디스크)✅ (옵션)JetStream만
순서 보장파티션 내큐 내부분적FIFO 큐Stream 내
라이선스Apache 2.0MPL 2.0Apache 2.0AWSRSAL
사용 사례이벤트 소싱, 분석작업 큐, 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. 주문 생성CreateOrderCancelOrder
2. 결제ChargePaymentRefundPayment
3. 재고 차감ReserveInventoryReleaseInventory
4. 배송CreateShipmentCancelShipment

규칙:

  • 각 로컬 트랜잭션은 독립적으로 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

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만 주문 처리

아키텍처:

  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: 전체 트레이싱

퀴즈

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 헤더).


참고 자료

현재 단락 (1/359)

- **동기 vs 비동기**: REST/gRPC는 결합도 높고 빠른 응답, 이벤트는 결합도 낮고 비동기. 둘 다 필요

작성 글자: 0원문 글자: 10,714작성 단락: 0/359