LabHub

블로그

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

TL;DR


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"
}

장점:

단점:

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;
}

장점:

단점:

언제 사용: 서비스 간 내부 통신, 고성능 필요, 양방향 스트리밍.

2.3 GraphQL — 클라이언트 주도

query GetUserWithPosts {
  user(id: 1000) {
    name
    email
    posts(limit: 10) {
      title
      createdAt
    }
  }
}

장점:

단점:

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

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

문제점:

4.2 Saga 패턴

핵심 아이디어: 큰 트랜잭션을 작은 로컬 트랜잭션의 시퀀스로 분해. 실패 시 보상 트랜잭션(compensating transaction)으로 롤백.

예시: 주문 처리

단계정상실패 시 보상
1. 주문 생성CreateOrderCancelOrder
2. 결제ChargePaymentRefundPayment
3. 재고 차감ReserveInventoryReleaseInventory
4. 배송CreateShipmentCancelShipment

규칙:

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 발행

문제 시나리오:

이를 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가 우수한 이유:


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()

임계값 튜닝:

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 왜 멱등성이 중요한가?

분산 시스템에서 메시지 중복은 불가피합니다:

같은 메시지를 여러 번 처리해도 결과가 동일해야 합니다.

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 주문 처리 시나리오

요구사항:

아키텍처:

  1. API Gateway: REST 진입점, 인증, 레이트 리밋
  2. Order Service: 주문 생성 + Outbox에 이벤트 저장
  3. Saga Orchestrator (Temporal): 결제 → 재고 → 배송 조율
  4. Kafka: 이벤트 버스
  5. 각 도메인 서비스: 자기 도메인만 처리

패턴 적용:


퀴즈

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


참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다