들어가며
마이크로서비스 아키텍처에서 서비스 간 통신 전략은
시스템의 성능, 확장성, 유지보수성을 결정짓는 핵심 요소입니다.
API Gateway가 요청을 라우팅하고, Worker가 작업을 처리하며,
결과를 저장소에 기록하는 모든 과정이 서비스 간 통신으로
이루어집니다.
이 글에서는 직접 통신(REST, gRPC, WebSocket)과 간접 통신(Message Queue),
네 가지 주요 통신 전략을 비교하고 각각 어떤 상황에서 적합한지 알아봅니다.
api-communication-examples
https://github.com/tklee-yonsei/api-communication-examples
통신 전략 한눈에 보기
| 구분 | 방식 | 패턴 | 지연시간 | 사용 사례 |
|---|
| 직접 | REST API | 요청-응답 | 중간 | CRUD 작업, 외부 API |
| 직접 | gRPC | 요청-응답/스트리밍 | 낮음 | 내부 서비스 간 고속 통신 |
| 직접 | WebSocket | 양방향 실시간 | 매우 낮음 | 실시간 모니터링, 알림 |
| 간접 | Message Queue | 비동기 | 가변 | 작업 큐, 이벤트 처리 |
직접 통신 (Point-to-Point)
서비스 간 중간 브로커 없이 직접 연결하여 통신합니다.
요청-응답(REST, gRPC)부터 양방향 실시간(WebSocket)까지 다양한 패턴을 포함하며,
통신 당사자가 서로를 알고 있어야 합니다.
REST API: HTTP 기반의 표준 통신
REST API 개념
REST(Representational State Transfer)는 HTTP 프로토콜을 활용한 통신 방식입니다.
웹의 기본 원리를 그대로 활용하기 때문에 가장 널리 사용됩니다.
핵심 원칙
리소스 중심 설계: 모든 것을 “리소스"로 표현하고 URL로 식별합니다.
1
2
3
4
5
6
| GET /jobs → 작업 목록 조회
POST /jobs → 새 작업 생성
GET /jobs/{id} → 특정 작업 조회
PUT /jobs/{id} → 작업 전체 수정
PATCH /jobs/{id} → 작업 부분 수정
DELETE /jobs/{id} → 작업 삭제
|
실제 구현 예시
Flask를 사용한 REST API 서버:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| from flask import Flask, request, jsonify
import uuid
app = Flask(__name__)
jobs = {}
@app.route('/jobs', methods=['POST'])
def create_job():
"""새로운 작업 생성"""
data = request.get_json()
job_id = str(uuid.uuid4())
jobs[job_id] = {
'id': job_id,
'type': data['type'],
'params': data['params'],
'status': 'pending'
}
return jsonify({
'job_id': job_id,
'status': 'pending'
}), 201 # 201 Created
@app.route('/jobs/<job_id>', methods=['GET'])
def get_job(job_id):
"""작업 상태 조회"""
if job_id not in jobs:
return jsonify({'error': 'Job not found'}), 404
return jsonify(jobs[job_id]), 200
|
클라이언트 요청:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| import requests
# 작업 생성
response = requests.post(
'http://api-server:8080/jobs',
json={
'type': 'data_processing',
'params': {'input_file': 'data.csv', 'batch_size': 100}
}
)
job = response.json()
print(f"생성된 작업 ID: {job['job_id']}")
# 결과 조회
result = requests.get(f"http://api-server:8080/jobs/{job['job_id']}")
print(result.json())
|
REST API 장단점
- 장점
- 표준 HTTP를 사용하여 방화벽/프록시 친화적
- 브라우저에서 직접 테스트 가능
- 캐싱, 로드밸런싱 등 기존 인프라 활용
- 학습 곡선이 낮음
- 단점
- 텍스트 기반(JSON)으로 바이너리 대비 오버헤드 존재
- 요청마다 연결을 새로 맺는 비용
- 실시간 양방향 통신에 부적합
REST API 적합한 사용 시나리오
- 외부 클라이언트(웹 브라우저, 모바일 앱) 연동
- CRUD 중심의 데이터 조작
- 다양한 언어/플랫폼 간 통신
gRPC: 고성능 바이너리 통신
gRPC 개념
gRPC는 Google이 개발한 고성능 RPC(Remote Procedure Call) 프레임워크입니다.
HTTP/2 기반에 Protocol Buffers라는 바이너리 직렬화 포맷을 사용하여 REST 대비 월등한 성능을 제공합니다.
Protocol Buffers 정의
서비스와 메시지를 .proto 파일로 정의합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| // processing.proto
syntax = "proto3";
package processing;
// 서비스 정의
service ProcessingService {
// 단일 요청-응답
rpc SubmitJob(JobRequest) returns (JobResponse);
// 서버 스트리밍: 클라이언트 1회 요청, 서버 여러 번 응답
rpc StreamResults(JobId) returns (stream ProcessingResult);
// 양방향 스트리밍
rpc InteractiveProcess(stream InputData)
returns (stream OutputData);
}
// 메시지 정의
message JobRequest {
string job_type = 1;
int32 batch_size = 2;
repeated double input_data = 3;
}
message JobResponse {
string job_id = 1;
Status status = 2;
enum Status {
PENDING = 0;
RUNNING = 1;
COMPLETED = 2;
FAILED = 3;
}
}
message ProcessingResult {
string chunk_id = 1;
bytes result_data = 2; // 바이너리 데이터
double timestamp = 3;
}
|
서버 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| import grpc
from concurrent import futures
import processing_pb2
import processing_pb2_grpc
class ProcessingServicer(processing_pb2_grpc.ProcessingServiceServicer):
def SubmitJob(self, request, context):
"""단일 작업 제출"""
job_id = create_job(request.job_type, request.batch_size)
return processing_pb2.JobResponse(
job_id=job_id,
status=processing_pb2.JobResponse.PENDING
)
def StreamResults(self, request, context):
"""결과 스트리밍 - 처리가 완료될 때마다 전송"""
for result in process_job(request.job_id):
yield processing_pb2.ProcessingResult(
chunk_id=result['chunk_id'],
result_data=result['data'],
timestamp=result['time']
)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
processing_pb2_grpc.add_ProcessingServiceServicer_to_server(
ProcessingServicer(), server
)
server.add_insecure_port('[::]:50051')
server.start()
server.wait_for_termination()
|
클라이언트 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| import grpc
import processing_pb2
import processing_pb2_grpc
def run_job():
with grpc.insecure_channel('worker-service:50051') as channel:
stub = processing_pb2_grpc.ProcessingServiceStub(channel)
# 작업 제출
response = stub.SubmitJob(processing_pb2.JobRequest(
job_type='batch_processing',
batch_size=100
))
print(f"작업 ID: {response.job_id}")
# 결과 스트리밍 수신
for result in stub.StreamResults(
processing_pb2.JobId(id=response.job_id)
):
print(f"Chunk {result.chunk_id}: {len(result.result_data)} bytes")
|
REST vs gRPC 성능 비교
| 측정 항목 | REST (JSON) | gRPC (Proto) |
|---|
| 메시지 크기 | 100% | ~30% |
| 직렬화 속도 | 1x | ~6x |
| 지연시간 | 1x | ~0.5x |
| CPU 사용량 | 1x | ~0.7x |
gRPC 장단점
- 장점
- 바이너리 포맷으로 높은 성능
- 타입 안전성 (컴파일 타임 검증)
- HTTP/2의 멀티플렉싱, 헤더 압축
- 스트리밍 지원
- 단점
- 브라우저 직접 호출 불가 (gRPC-Web 필요)
- 디버깅이 REST 대비 어려움
- 추가 도구 필요 (protoc 컴파일러)
gRPC 적합한 사용 시나리오
- 마이크로서비스 간 내부 통신
- 대용량 바이너리 데이터 전송
- 저지연이 중요한 실시간 처리
WebSocket: 실시간 양방향 통신
WebSocket 개념
WebSocket은 단일 TCP 연결을 통해 클라이언트와 서버 간 전이중(full-duplex) 양방향 통신을 제공합니다.
HTTP의 요청-응답 패턴과 달리, 연결이 유지되는 동안 양쪽에서 언제든 메시지를 보낼 수 있습니다.
연결 흐름
sequenceDiagram
participant 클라이언트
participant 서버
클라이언트->>서버: HTTP Upgrade 요청
서버-->>클라이언트: 101 Switching Protocols
Note over 클라이언트,서버: WebSocket 연결 수립
서버->>클라이언트: 서버 푸시: 상태 업데이트
클라이언트->>서버: 클라이언트 메시지
서버->>클라이언트: 서버 푸시: 실시간 데이터
서버 구현 (Flask-SocketIO)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| from flask import Flask
from flask_socketio import SocketIO, emit, join_room
app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")
@socketio.on('connect')
def handle_connect():
print(f"클라이언트 연결: {request.sid}")
emit('status', {'message': '연결 성공'})
@socketio.on('subscribe')
def handle_subscribe(data):
"""특정 채널의 실시간 업데이트 구독"""
channel = data['channel']
join_room(channel)
emit('subscribed', {'channel': channel})
def broadcast_update(channel, data):
"""특정 채널에 업데이트 브로드캐스트"""
socketio.emit('update', {
'channel': channel,
'data': data,
'timestamp': time.time()
}, room=channel)
@socketio.on('user_action')
def handle_action(data):
"""사용자 액션 수신 → 처리 결과 반환"""
result = process_action(data['action'], data['params'])
emit('action_result', {
'action_id': data['action_id'],
'result': result
})
if __name__ == '__main__':
socketio.run(app, host='0.0.0.0', port=8080)
|
클라이언트 구현 (JavaScript)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // 웹 브라우저 클라이언트
const socket = io('http://realtime-server:8080');
socket.on('connect', () => {
console.log('서버 연결됨');
// 채널 구독
socket.emit('subscribe', { channel: 'job-updates' });
});
socket.on('update', (data) => {
console.log(`업데이트: ${data.channel}`);
updateUI(data.data);
});
socket.on('action_result', (data) => {
handleResult(data.result);
});
// 사용자 액션 전송
function sendAction(action, params) {
socket.emit('user_action', {
action_id: generateId(),
action: action,
params: params
});
}
|
Python 클라이언트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| import socketio
sio = socketio.Client()
@sio.event
def connect():
print('서버 연결됨')
sio.emit('subscribe', {'channel': 'job-updates'})
@sio.on('update')
def on_update(data):
print(f"업데이트 수신: {data['channel']}")
process_update(data['data'])
sio.connect('http://realtime-server:8080')
sio.wait()
|
WebSocket 장단점
- 장점
- 실시간 양방향 통신
- 연결 유지로 오버헤드 최소화
- 서버 푸시 가능
- 단점
- 상태 유지 필요 (stateful)
- 로드밸런싱 복잡 (sticky session 필요)
- 연결 관리 오버헤드
WebSocket 적합한 사용 시나리오
- 실시간 모니터링 대시보드
- 채팅, 알림 시스템
- 라이브 데이터 스트리밍
- 협업 도구 (동시 편집)
중간 브로커를 통해 간접적으로 통신합니다.
생산자와 소비자가 서로를 알 필요가 없어 결합도가 낮고,
부하 분산과 장애 격리에 효과적입니다.
Message Queue: 비동기 작업 처리
Message Queue 개념
메시지 큐는 서비스 간 비동기 통신을 위한 중간 계층입니다.
생산자(Producer)가 메시지를 큐에 넣으면,
소비자(Consumer)가 자신의 속도로 처리합니다.
시스템 컴포넌트 간 결합도를 낮추고 부하를 분산합니다.
아키텍처 패턴
flowchart LR
A[API Server
Producer] --> B[Message Queue
Redis/RabbitMQ]
B --> C[Worker
Consumer]
C --> D[(Database
결과 저장)]
Redis 기반 구현
생산자 (API Server)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| import redis
import json
import uuid
redis_client = redis.Redis(host='redis-server', port=6379)
def submit_job(job_type, params):
"""작업을 큐에 제출"""
job_id = str(uuid.uuid4())
job_data = {
'job_id': job_id,
'type': job_type,
'params': params,
'submitted_at': time.time()
}
# 작업을 큐에 추가 (LPUSH: 왼쪽에서 삽입)
redis_client.lpush('job_queue', json.dumps(job_data))
# 작업 상태 초기화
redis_client.hset(f'job:{job_id}', mapping={
'status': 'queued',
'progress': 0
})
return job_id
|
소비자 (Worker)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| import redis
import json
redis_client = redis.Redis(host='redis-server', port=6379)
def worker_loop():
"""작업 큐에서 작업을 가져와 처리"""
print("Worker 시작, 작업 대기 중...")
while True:
# BRPOP: 오른쪽에서 추출, 블로킹 (타임아웃 0 = 무한 대기)
_, job_json = redis_client.brpop('job_queue', timeout=0)
job = json.loads(job_json)
job_id = job['job_id']
print(f"작업 처리 시작: {job_id}")
try:
# 상태 업데이트
redis_client.hset(f'job:{job_id}', 'status', 'running')
# 실제 작업 수행
result = process_job(job['type'], job['params'])
# 결과 저장
save_result(job_id, result)
# 완료 상태
redis_client.hset(f'job:{job_id}', mapping={
'status': 'completed',
'progress': 100
})
except Exception as e:
redis_client.hset(f'job:{job_id}', mapping={
'status': 'failed',
'error': str(e)
})
|
고급 패턴: 우선순위 큐
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| # 우선순위별 큐 사용
QUEUES = {
'high': 'job_queue:high',
'normal': 'job_queue:normal',
'low': 'job_queue:low'
}
def submit_job(job_data, priority='normal'):
redis_client.lpush(QUEUES[priority], json.dumps(job_data))
def worker_with_priority():
"""우선순위 순서로 작업 처리"""
while True:
# 높은 우선순위부터 확인
result = redis_client.brpop(
[QUEUES['high'], QUEUES['normal'], QUEUES['low']],
timeout=5
)
if result:
queue_name, job_json = result
process_job(json.loads(job_json))
|
RabbitMQ 대안
더 복잡한 라우팅이 필요하면 RabbitMQ를 고려합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| import pika
# 연결 설정
connection = pika.BlockingConnection(
pika.ConnectionParameters('rabbitmq-server')
)
channel = connection.channel()
# 교환기와 큐 선언
channel.exchange_declare(exchange='jobs', exchange_type='topic')
channel.queue_declare(queue='image_processing', durable=True)
channel.queue_bind(
exchange='jobs',
queue='image_processing',
routing_key='job.image.*'
)
# 메시지 발행
channel.basic_publish(
exchange='jobs',
routing_key='job.image.resize',
body=json.dumps(job_data),
properties=pika.BasicProperties(delivery_mode=2) # 메시지 영속성
)
|
Message Queue 장단점
- 장점
- 서비스 간 느슨한 결합
- 부하 분산 및 버퍼링
- 장애 격리 (한 서비스 실패가 전체에 영향 X)
- 재시도 및 데드레터 큐 지원
- 단점
- 즉시 응답 불가 (비동기)
- 메시지 순서 보장이 복잡할 수 있음
- 추가 인프라 필요
Message Queue 적합한 사용 시나리오
- 시간이 오래 걸리는 작업 (이미지 처리, 보고서 생성)
- 작업 부하 분산 (여러 Worker로 병렬 처리)
- 서비스 간 이벤트 전달
- 재시도가 필요한 작업
통신 전략 선택 가이드
결정 플로우차트
flowchart TD
A[작업 특성 분석] --> B{실시간 양방향 필요?}
B -->|Yes| C[WebSocket]
B -->|No| D{즉시 응답 필요?}
D -->|Yes| E{외부 클라이언트?}
E -->|Yes| F[REST API]
E -->|No| G[gRPC]
D -->|No| H[Message Queue]
일반적인 적용 예시
| 통신 구간 | 권장 방식 | 이유 |
|---|
| 웹/모바일 → API 서버 | REST | 브라우저 호환성, 범용성 |
| API 서버 → 작업 큐 | Message Queue | 비동기 작업 분배 |
| 마이크로서비스 간 | gRPC | 고성능, 타입 안전성 |
| 서버 → 클라이언트 푸시 | WebSocket | 실시간 업데이트 |
| 데이터 CRUD | REST | 단순함, 표준화 |
하이브리드 아키텍처
구간별 기술 배치
실제 시스템에서는 각 구간의 특성에 맞게 여러 방식을 조합합니다:
flowchart TB
subgraph Client[웹 브라우저]
end
subgraph Backend[백엔드 서비스]
API[API Gateway
Flask/FastAPI]
RT[Realtime Server
SocketIO]
MQ[(Message Queue
Redis)]
W1[Worker A]
W2[Worker B]
DB[(Database)]
end
Client -->|REST| API
Client <-->|WebSocket| RT
API -->|LPUSH| MQ
RT -->|Pub/Sub| MQ
MQ -->|BRPOP| W1
MQ -->|BRPOP| W2
W1 <-->|gRPC| W2
W1 -->|REST| DB
W2 -->|REST| DB
기술 간 결합 패턴: gRPC + WebSocket 브릿지
같은 구간에서 두 기술을 결합하는 패턴도 실무에서 자주 사용됩니다.
대표적인 예가 gRPC 스트리밍과 WebSocket을 연결하는 브릿지 패턴입니다.
gRPC는 브라우저에서 직접 호출할 수 없기 때문에,
Realtime Server가 내부적으로 gRPC 스트리밍을 수신하고
이를 WebSocket으로 브라우저에 중계합니다.
flowchart LR
subgraph Browser[웹 브라우저]
UI[Dashboard UI]
end
subgraph Bridge[Realtime Server]
WS[WebSocket 엔드포인트]
GC[gRPC Client]
end
subgraph Internal[내부 서비스]
PS[Processing Service
gRPC Server]
end
UI <-->|WebSocket| WS
WS --- GC
GC <-->|gRPC Stream| PS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| # Realtime Server: gRPC 스트리밍 → WebSocket 브릿지
import grpc
import processing_pb2
import processing_pb2_grpc
from flask_socketio import SocketIO, emit
@socketio.on('subscribe_job')
def handle_subscribe(data):
"""gRPC 스트리밍을 받아 WebSocket으로 중계"""
job_id = data['job_id']
# 내부 gRPC 서비스에 스트리밍 요청
channel = grpc.insecure_channel('processing-service:50051')
stub = processing_pb2_grpc.ProcessingServiceStub(channel)
for result in stub.StreamResults(
processing_pb2.JobId(id=job_id)
):
# gRPC 결과를 WebSocket으로 실시간 전달
emit('job_progress', {
'job_id': job_id,
'chunk_id': result.chunk_id,
'data_size': len(result.result_data),
'timestamp': result.timestamp
})
|
이 패턴의 장점은 내부 서비스 간에는 gRPC의 고성능과 타입 안전성을 유지하면서,
브라우저에는 WebSocket의 실시간 양방향 통신을 제공할 수 있다는 것입니다.
결론
통신 전략 선택은 단순한 기술 결정이 아니라 시스템 아키텍처를 형성하는 핵심 요소입니다.
통신 전략 비교 요약
| 항목 | REST API | gRPC | WebSocket | Message Queue |
|---|
| 연결 | 직접 (요청-응답) | 직접 (요청-응답/스트리밍) | 직접 (양방향 실시간) | 간접 (브로커 경유) |
| 프로토콜 | HTTP/1.1 | HTTP/2 | TCP (WS) | AMQP / Redis 등 |
| 데이터 | JSON (텍스트) | Protobuf (바이너리) | 자유 형식 | 자유 형식 |
| 성능 | 중간 | 높음 | 높음 | 가변 |
| 결합도 | 중간 | 높음 (.proto 공유) | 중간 | 낮음 |
| 브라우저 | 지원 | 미지원 (gRPC-Web) | 지원 | 미지원 |
| 적합 구간 | 외부 API | 내부 서비스 간 | 실시간 푸시 | 비동기 작업 분배 |
전략 선택 한눈에 보기
flowchart LR
subgraph 직접통신[직접 통신]
REST[REST API
외부 노출, CRUD]
GRPC[gRPC
내부 고성능]
WS[WebSocket
실시간 양방향]
end
subgraph 간접통신[간접 통신]
MQ[Message Queue
비동기 작업 분배]
end
REST ---|결합| WS
GRPC ---|브릿지| WS
REST ---|작업 위임| MQ
MQ ---|결과 알림| WS
- 핵심 원칙:
- 외부 노출 API는 REST로 시작하여 필요시 확장
- 내부 고성능 통신은 gRPC 고려
- 실시간 업데이트는 WebSocket
- 시간이 오래 걸리는 작업은 Message Queue로 비동기화
- 필요에 따라 gRPC + WebSocket 브릿지처럼 기술을 결합
대부분의 실제 시스템에서는 이 네 가지를 적절히 조합하여,
API Gateway는 REST로 외부 요청을 받고,
Message Queue로 Worker에 작업을 분배하며,
실시간 서버는 WebSocket으로 라이브 업데이트를 제공하는 하이브리드 구조가 효과적입니다.
참고 자료