SOA에서 마이크로서비스로: 분산 시스템 아키텍처 패턴 실전 가이드
SOA의 ESB 구조와 한계, 마이크로서비스의 동기/비동기 통신, API Gateway·Circuit Breaker·Saga 패턴을 코드와 함께 정리한 분산 시스템 설계 가이드
2026-08-12 · 최초 발행 2026-01-19
서비스 하나를 고치려고 시스템 전체를 다시 배포해야 한다면, 그 아키텍처는 이미 발목을 잡고 있는 것이다. SOA와 마이크로서비스는 둘 다 "큰 시스템을 작은 단위로 쪼갠다"는 목표를 공유하지만, 그 단위를 어떻게 묶고 누가 중앙을 맡느냐에서 완전히 다른 길을 걷는다.
SOA가 그리던 그림
SOA는 몇 가지 원칙 위에 서 있다. 서비스 간 의존성을 최소화하는 느슨한 결합, 인터페이스를 명확히 정의하는 서비스 계약, 서비스가 독립적으로 운영되는 자율성, 구현 세부사항을 감추는 추상화, 여러 컨텍스트에서 재사용 가능하다는 재사용성, 서비스를 조합해 새 기능을 만드는 조합성, 클라이언트 상태를 유지하지 않는 무상태성, 서비스 레지스트리로 검색 가능한 발견 가능성이다.
이 원칙을 실제로 구현하는 축이 ESB(Enterprise Service Bus)였다. 서비스 간 메시지를 전달·변환하는 라우팅, SOAP·REST·JMS 같은 다양한 프로토콜 변환, XML·JSON 같은 데이터 형식 변환, 복잡한 비즈니스 프로세스를 조정하는 서비스 오케스트레이션, 중앙 집중식 보안 정책, 서비스 호출을 추적하는 모니터링까지 ESB 하나가 떠맡았다.
문제는 이 중앙 집중 구조 자체였다. ESB가 뚫리면 시스템 전체가 멈추는 단일 장애점이 됐고, 서비스 간 의존성 관리는 배포를 복잡하게 만들었다. XML 파싱과 SOAP 프로토콜은 무거워서 성능 오버헤드를 냈고, 특정 벤더 플랫폼에 기술 스택이 묶이면서 변경 하나에도 ESB 전체를 재배포해야 하는 느린 사이클이 반복됐다.
마이크로서비스가 다시 그은 경계선
마이크로서비스는 이 중앙을 걷어내는 방향으로 움직인다. 단일 비즈니스 기능에 집중하는 소규모 독립 서비스, 서비스별로 독립 배포·확장하는 구조, 서비스마다 최적의 기술 스택을 고를 수 있는 기술 다양성, 서비스별로 독립된 데이터베이스, 한 서비스의 장애가 전체로 번지지 않는 장애 격리, 그리고 팀별로 서비스를 소유하는 조직 구조 반영이 특징이다.
서비스는 서로 어떻게 말을 거는가
서비스가 나뉘면 통신 방식을 골라야 한다. 동기식 통신은 REST API로 즉시 응답을 받는 방식인데, 아래 예시처럼 결제 서비스를 호출할 때 타임아웃 설정이 필수다 — 상대 서비스가 응답하지 않으면 호출 측도 무한정 묶이기 때문이다. 결제가 실패하면 주문 상태를 PAYMENT_FAILED로 되돌리는 보상 처리도 함께 들어간다.
# REST API를 이용한 동기식 통신
import requests
from typing import Dict, Optional
class OrderService:
def __init__(self, payment_service_url: str):
self.payment_service_url = payment_service_url
def create_order(self, order_data: Dict) -> Dict:
"""주문 생성 및 결제 처리"""
# 1. 주문 생성
order = self._save_order(order_data)
# 2. 동기식으로 결제 서비스 호출
try:
payment_response = requests.post(
f"{self.payment_service_url}/payments",
json={
"order_id": order['id'],
"amount": order['total_amount'],
"user_id": order['user_id']
},
timeout=5 # 타임아웃 설정 중요
)
payment_response.raise_for_status()
# 3. 결제 성공 시 주문 상태 업데이트
order['status'] = 'PAID'
order['payment_id'] = payment_response.json()['id']
self._update_order(order)
return order
except requests.RequestException as e:
# 결제 실패 시 보상 트랜잭션
order['status'] = 'PAYMENT_FAILED'
self._update_order(order)
raise
비동기식 통신은 메시지 큐로 이벤트를 발행하고, 다른 서비스가 그 이벤트를 구독해 처리하는 방식이다. 주문 서비스는 결제 완료를 기다리지 않고 order_created, payment_requested 이벤트만 발행한 뒤 리턴하고, 이후 결제 결과 이벤트를 리스닝해서 주문 상태를 업데이트한다.
# 메시지 큐를 이용한 비동기식 통신
import pika
import json
from typing import Dict
class OrderServiceAsync:
def __init__(self, rabbitmq_host: str):
self.connection = pika.BlockingConnection(
pika.ConnectionParameters(host=rabbitmq_host)
)
self.channel = self.connection.channel()
self.channel.queue_declare(queue='order_created')
self.channel.queue_declare(queue='payment_requested')
def create_order(self, order_data: Dict) -> Dict:
"""주문 생성 및 이벤트 발행"""
# 1. 주문 생성
order = self._save_order(order_data)
order['status'] = 'PENDING'
# 2. 주문 생성 이벤트 발행
self.channel.basic_publish(
exchange='',
routing_key='order_created',
body=json.dumps(order)
)
# 3. 결제 요청 이벤트 발행
self.channel.basic_publish(
exchange='',
routing_key='payment_requested',
body=json.dumps({
'order_id': order['id'],
'amount': order['total_amount'],
'user_id': order['user_id']
})
)
return order
def listen_payment_events(self):
"""결제 이벤트 리스닝"""
def callback(ch, method, properties, body):
payment_event = json.loads(body)
order = self._get_order(payment_event['order_id'])
if payment_event['status'] == 'SUCCESS':
order['status'] = 'PAID'
else:
order['status'] = 'PAYMENT_FAILED'
self._update_order(order)
self.channel.basic_consume(
queue='payment_completed',
on_message_callback=callback,
auto_ack=True
)
self.channel.start_consuming()
장애를 다루는 패턴
서비스가 많아지면 장애를 다루는 방식 자체가 아키텍처의 일부가 된다.
API Gateway는 모든 요청의 진입점을 하나로 모아 적절한 서비스로 라우팅한다. 아래 FastAPI 예시는 서비스 이름을 매핑 테이블에서 찾아 프록시하고, 상대 서비스가 응답하지 않으면 503을 반환한다. 미들웨어로는 요청마다 Correlation ID를 붙여서 나중에 분산 추적에서 요청을 따라갈 수 있게 한다.
# FastAPI를 이용한 API Gateway 구현
from fastapi import FastAPI, HTTPException
import httpx
from typing import Dict
app = FastAPI()
# 서비스 엔드포인트 매핑
SERVICES = {
'users': 'http://user-service:8001',
'products': 'http://product-service:8002',
'orders': 'http://order-service:8003'
}
@app.get("/api/{service}/{path:path}")
async def gateway(service: str, path: str):
"""모든 요청을 적절한 마이크로서비스로 라우팅"""
if service not in SERVICES:
raise HTTPException(status_code=404, detail="Service not found")
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{SERVICES[service]}/{path}",
timeout=10.0
)
return response.json()
except httpx.RequestError:
raise HTTPException(status_code=503, detail="Service unavailable")
@app.middleware("http")
async def add_correlation_id(request, call_next):
"""분산 추적을 위한 Correlation ID 추가"""
import uuid
correlation_id = request.headers.get('X-Correlation-ID', str(uuid.uuid4()))
request.state.correlation_id = correlation_id
response = await call_next(request)
response.headers['X-Correlation-ID'] = correlation_id
return response
Circuit Breaker는 한 서비스의 장애가 호출자까지 연쇄적으로 끌고 내려가는 걸 막는다. 실패 횟수가 임계치(아래 예시는 5회)를 넘으면 상태를 OPEN으로 바꿔 더 이상 호출을 시도하지 않고 즉시 예외를 던진다. 타임아웃(60초)이 지나면 HALF_OPEN으로 바뀌어 한 번 시도해보고, 성공하면 CLOSED로 복구한다.
# Circuit Breaker 구현
import time
from enum import Enum
from typing import Callable, Any
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
def __init__(self, failure_threshold: int = 5, timeout: int = 60):
self.failure_threshold = failure_threshold
self.timeout = timeout
self.failure_count = 0
self.last_failure_time = None
self.state = CircuitState.CLOSED
def call(self, func: Callable, *args, **kwargs) -> Any:
"""Circuit Breaker를 통한 함수 호출"""
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.timeout:
self.state = CircuitState.HALF_OPEN
else:
raise Exception("Circuit breaker is OPEN")
try:
result = func(*args, **kwargs)
# 성공 시 상태 복구
if self.state == CircuitState.HALF_OPEN:
self.state = CircuitState.CLOSED
self.failure_count = 0
return result
except Exception as e:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
raise e
# 사용 예시
payment_breaker = CircuitBreaker(failure_threshold=5, timeout=60)
def call_payment_service(order_id: str):
return payment_breaker.call(
requests.post,
"http://payment-service/payments",
json={"order_id": order_id}
)
Saga 패턴은 여러 서비스에 걸친 트랜잭션을 다룬다. 각 서비스가 독립적인 DB를 가지면 전통적인 분산 트랜잭션(2PC)을 쓰기 어려워지는데, Saga는 각 단계를 순서대로 실행하다가 중간에 실패하면 이미 실행된 단계들을 역순으로 보상 트랜잭션을 실행해 되돌린다. 주문 생성 → 재고 예약 → 결제 처리 → 배송 시작 흐름에서 재고 예약이 실패하면 주문을 취소하고, 결제가 실패하면 재고를 되돌리고, 배송이 실패하면 결제를 환불하는 식이다.
# Saga 패턴 구현 (오케스트레이션 방식)
class OrderSaga:
def __init__(self):
self.steps = []
self.compensations = []
def execute_saga(self, order_data: Dict) -> bool:
"""Saga 실행"""
try:
# Step 1: 주문 생성
order = self.create_order(order_data)
self.steps.append(('create_order', order))
self.compensations.append(self.cancel_order)
# Step 2: 재고 예약
inventory = self.reserve_inventory(order)
self.steps.append(('reserve_inventory', inventory))
self.compensations.append(self.release_inventory)
# Step 3: 결제 처리
payment = self.process_payment(order)
self.steps.append(('process_payment', payment))
self.compensations.append(self.refund_payment)
# Step 4: 배송 시작
shipment = self.ship_order(order)
self.steps.append(('ship_order', shipment))
return True
except Exception as e:
# 보상 트랜잭션 실행 (역순)
for compensation in reversed(self.compensations):
try:
compensation()
except Exception as comp_error:
# 보상 실패 로깅
print(f"Compensation failed: {comp_error}")
return False
컨테이너가 마이크로서비스를 현실로 만든다
마이크로서비스가 이론에서 실무로 넘어온 데는 컨테이너의 역할이 크다. Dockerfile 하나로 서비스마다 독립된 실행 환경을 만들 수 있고, 비루트 사용자로 실행하거나 헬스체크를 내장하는 식으로 운영 안정성을 챙길 수 있다.
# 마이크로서비스 Dockerfile 예시
FROM python:3.11-slim
WORKDIR /app
# 의존성 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드
COPY . .
# 비루트 사용자로 실행
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# 헬스체크
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health')"
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Kubernetes에 배포할 때는 리소스 requests/limits, liveness/readiness probe를 함께 정의해야 컨테이너 하나가 문제를 일으켜도 전체 서비스가 흔들리지 않는다.
# Kubernetes Deployment 및 Service
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: myregistry/order-service:1.0.0
ports:
- containerPort: 8000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- protocol: TCP
port: 80
targetPort: 8000
type: ClusterIP
운영체제 밑단: 격리와 디스커버리
컨테이너의 리소스 제한은 결국 리눅스 cgroups 위에서 동작한다. Docker는 이걸 자동으로 처리해주지만, cgroups를 직접 다루면 무슨 일이 벌어지는지 더 분명해진다 — CPU 쿼터를 50000(코어의 50%)으로 설정하거나 메모리 한도를 바이트 단위로 지정하는 식이다.
# cgroups를 이용한 리소스 제한
# CPU 제한 (50% of one core)
cgcreate -g cpu:/myservice
echo 50000 > /sys/fs/cgroup/cpu/myservice/cpu.cfs_quota_us
cgexec -g cpu:/myservice ./my_service
# 메모리 제한 (512MB)
cgcreate -g memory:/myservice
echo 536870912 > /sys/fs/cgroup/memory/myservice/memory.limit_in_bytes
cgexec -g memory:/myservice ./my_service
# Docker는 이를 자동으로 처리
docker run --memory="512m" --cpus="0.5" myservice
서비스 수가 늘어나면 "이 서비스가 지금 어디에 떠 있는가"를 알아야 한다. Consul 같은 서비스 디스커버리는 서비스가 시작할 때 자기 위치와 헬스체크 엔드포인트를 등록하고, 다른 서비스는 이름으로 조회해서 현재 살아있는 인스턴스의 주소를 받는다.
# Consul을 이용한 서비스 디스커버리
import consul
import socket
class ServiceDiscovery:
def __init__(self, consul_host='localhost', consul_port=8500):
self.consul = consul.Consul(host=consul_host, port=consul_port)
def register_service(self, service_name: str, port: int):
"""서비스 등록"""
service_id = f"{service_name}-{socket.gethostname()}"
self.consul.agent.service.register(
name=service_name,
service_id=service_id,
address=socket.gethostbyname(socket.gethostname()),
port=port,
check=consul.Check.http(
f"http://localhost:{port}/health",
interval="10s",
timeout="5s"
)
)
def discover_service(self, service_name: str) -> str:
"""서비스 검색"""
_, services = self.consul.health.service(service_name, passing=True)
if not services:
raise Exception(f"No healthy instances of {service_name}")
# 간단한 라운드로빈 (실제로는 더 정교한 로드밸런싱 필요)
service = services[0]
return f"http://{service['Service']['Address']}:{service['Service']['Port']}"
요청 하나가 어디를 거쳐갔는지 알아야 한다
서비스가 여러 개로 쪼개지면 요청 하나가 API Gateway → Order Service → Payment Service → Database처럼 여러 서비스를 거치게 된다. 이 경로를 추적하지 못하면 장애가 났을 때 어디서 멈췄는지 알 방법이 없다.
OpenTelemetry 같은 도구는 각 작업을 span으로 감싸서 Jaeger 같은 백엔드로 보낸다. 아래 예시처럼 주문 생성, DB 저장, 결제 서비스 호출 각각을 별도 span으로 나누면 전체 요청 안에서 어느 구간이 느린지 바로 드러난다.
# OpenTelemetry를 이용한 분산 추적
from opentelemetry import trace
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Tracer 설정
trace.set_tracer_provider(TracerProvider())
jaeger_exporter = JaegerExporter(
agent_host_name="localhost",
agent_port=6831,
)
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(jaeger_exporter)
)
tracer = trace.get_tracer(__name__)
def create_order(order_data):
with tracer.start_as_current_span("create_order") as span:
span.set_attribute("order.id", order_data['id'])
span.set_attribute("order.amount", order_data['amount'])
# 데이터베이스 저장
with tracer.start_as_current_span("db.save_order"):
save_to_db(order_data)
# 결제 서비스 호출
with tracer.start_as_current_span("call.payment_service"):
payment_result = call_payment_service(order_data)
return order_data
SOA에서 마이크로서비스로의 이동은 중앙 집중을 버리고 독립성을 택한 결정이었다. 그 대가로 서비스 간 통신, 장애 전파, 분산 트랜잭션, 서비스 위치 추적 같은 문제가 새로 생겼고, API Gateway·Circuit Breaker·Saga·서비스 디스커버리·분산 추적은 전부 그 문제에 대한 답이다. 어떤 패턴을 쓸지는 아키텍처 취향이 아니라 서비스 수와 장애 시나리오가 결정한다.