마이크로서비스 설계 — 서비스 경계, 통신 패턴, 데이터 소유권
"이 기능은 주문 서비스에 넣어야 하나, 결제 서비스에 넣어야 하나?" — 서비스를 나누는 게 어려운 건 기술이 아니라 경계를 정하는 기준이 모호하기 때문입니다.
마이크로서비스를 도입하면 자연스럽게 세 가지 질문이 생깁니다: 서비스를 어떻게 나눌 것인가, 서비스 간 통신은 어떻게 할 것인가, 데이터는 누가 소유할 것인가. 이 글에서 각각의 원칙과 패턴을 정리합니다.
이게 뭔가요?
- 서비스 경계(Service Boundary): 하나의 마이크로서비스가 책임지는 기능의 범위
- 통신 패턴(Communication Pattern): 서비스 간 데이터를 주고받는 방식 (동기 vs 비동기)
- 데이터 소유권(Data Ownership): 각 데이터를 어느 서비스가 관리하고, 다른 서비스는 어떻게 접근하는지에 대한 원칙
왜 필요한가요?
서비스 경계를 잘못 나누면 마이크로서비스의 장점이 사라집니다:
잘못된 서비스 경계의 증상:
- 하나의 기능을 수정하려면 3~4개 서비스를 동시에 배포해야 함
- 서비스 A가 서비스 B의 DB를 직접 조회함
- 서비스 간 API 호출이 거미줄처럼 얽혀 있음
- "분산된 모놀리식" — 모놀리식의 단점 + 분산 시스템의 단점
좋은 경계를 가진 서비스는 독립적으로 배포 가능하고, 다른 서비스의 변경에 영향을 최소한으로 받습니다.
어떻게 동작하나요?
1. 서비스 경계 정하기
DDD의 Bounded Context 활용:
도메인 주도 설계(DDD)의 Bounded Context가 서비스 경계를 나누는 가장 검증된 기준입니다.
이커머스 도메인 예시:
[주문 Context] [결제 Context] [배송 Context]
- 주문 생성 - 결제 처리 - 배송 추적
- 주문 취소 - 환불 처리 - 배송 상태 관리
- 주문 이력 - 결제 수단 관리 - 배송지 관리
각 Context가 하나의 마이크로서비스 후보
경계를 나누는 기준:
좋은 경계:
✅ 높은 응집도 — 관련된 기능이 한 서비스 안에 있음
✅ 낮은 결합도 — 다른 서비스 변경에 영향을 적게 받음
✅ 독립 배포 가능 — 이 서비스만 바꿔서 배포할 수 있음
✅ 팀 하나가 소유 — 한 팀이 전체를 책임지는 규모
나쁜 경계:
❌ 기술 레이어로 나눔 (API 서비스, DB 서비스, 로직 서비스)
❌ 하나의 유스케이스가 5개 서비스를 걸침
❌ 서비스 간 양방향 의존이 존재
기술이 아니라 비즈니스 기능으로 나누세요:
❌ 기술 기준으로 나눈 서비스:
- user-api-service (API만 담당)
- user-logic-service (비즈니스 로직만)
- user-data-service (DB 접근만)
→ 사용자 관련 변경이 3개 서비스에 동시에 영향
✅ 비즈니스 기준으로 나눈 서비스:
- user-service (사용자 관련 전부)
- order-service (주문 관련 전부)
→ 각 서비스가 독립적으로 변경 가능
2. 통신 패턴
서비스 간 통신은 크게 동기와 비동기로 나뉩니다.
동기 통신 (Request-Response):
[주문 서비스] ──HTTP/gRPC──→ [재고 서비스]
↑ |
└────── 응답 대기 ───────────┘
// REST 기반 동기 호출
@Service
public class OrderService {
private final RestClient inventoryClient;
public Order createOrder(CreateOrderRequest request) {
// 재고 서비스에 동기 호출 — 응답이 올 때까지 대기
InventoryResponse inventory = inventoryClient
.get()
.uri("/api/inventory/{productId}", request.getProductId())
.retrieve()
.body(InventoryResponse.class);
if (!inventory.isAvailable(request.getQuantity())) {
throw new OutOfStockException("재고 부족");
}
return orderRepository.save(Order.from(request));
}
}
장점: 구현이 단순, 즉시 결과 확인 가능 단점: 호출 대상이 죽으면 호출자도 실패, 지연 전파
비동기 통신 (Event-Driven):
[주문 서비스] ──이벤트 발행──→ [메시지 브로커] ──→ [재고 서비스]
(Kafka, RabbitMQ) ──→ [알림 서비스]
──→ [포인트 서비스]
// 이벤트 기반 비동기 통신
@Service
public class OrderService {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public Order createOrder(CreateOrderRequest request) {
Order order = orderRepository.save(Order.from(request));
// 이벤트만 발행하고 끝 — 소비자가 누군지 모름
kafkaTemplate.send("order-events",
new OrderCreated(order.getId(), order.getItems()));
return order;
}
}
// 재고 서비스에서 이벤트를 소비
@Service
public class InventoryEventHandler {
@KafkaListener(topics = "order-events")
public void handleOrderCreated(OrderCreated event) {
inventoryService.reserve(event.getItems());
}
}
장점: 서비스 간 결합도가 낮음, 하나가 죽어도 이벤트는 브로커에 보존 단점: 결과적 일관성 처리 필요, 디버깅이 복잡
어떤 통신 방식을 쓸까?
| 상황 | 권장 방식 |
|---|---|
| 즉시 응답이 필요 (잔액 조회) | 동기 (REST/gRPC) |
| 후속 처리가 여러 서비스에 걸침 | 비동기 (이벤트) |
| 실패해도 재시도로 해결 가능 | 비동기 (메시지 큐) |
| 호출 대상이 자주 변경됨 | 비동기 (이벤트) |
| 트랜잭션 일관성이 즉시 필요 | 동기 + Saga 패턴 |
동기 호출의 장애 전파를 막는 패턴:
// 서킷 브레이커 — 연속 실패 시 호출을 차단
@CircuitBreaker(name = "inventory", fallbackMethod = "fallback")
public InventoryResponse checkInventory(String productId) {
return inventoryClient.get(productId);
}
// 폴백 — 서킷이 열리면 대체 로직 실행
public InventoryResponse fallback(String productId, Exception e) {
// 캐시된 재고 정보 반환 또는 "잠시 후 다시 시도" 응답
return InventoryResponse.unavailable();
}
3. 데이터 소유권
원칙: Database per Service
❌ 공유 데이터베이스
[주문 서비스] ──→ [공유 DB] ←── [결제 서비스]
→ 스키마 변경이 모든 서비스에 영향
→ 서비스 간 독립성이 사라짐
✅ 서비스별 데이터베이스
[주문 서비스] ──→ [주문 DB]
[결제 서비스] ──→ [결제 DB]
→ 각 서비스가 자신의 스키마를 독립적으로 변경
다른 서비스의 데이터가 필요할 때:
방법 1: API 호출
주문 서비스가 사용자 이름이 필요 → 사용자 서비스 API 호출
장점: 항상 최신 데이터
단점: 서비스 의존성 발생, 지연 추가
방법 2: 이벤트로 데이터 복제
사용자 서비스가 "이름 변경" 이벤트 발행
→ 주문 서비스가 이벤트를 받아 자체 DB에 사용자 이름 저장
장점: 다른 서비스 호출 없이 조회 가능
단점: 데이터 동기화 지연 (결과적 일관성)
방법 3: API Composition
게이트웨이나 BFF가 여러 서비스의 데이터를 모아서 응답 구성
장점: 서비스 간 데이터 복제 없음
단점: 조합 로직 필요, 지연 증가
분산 트랜잭션 — Saga 패턴:
여러 서비스에 걸친 트랜잭션은 @Transactional로 해결할 수 없습니다.
주문 생성 Saga:
1. 주문 서비스: 주문 생성 (PENDING)
2. 결제 서비스: 결제 처리
3. 재고 서비스: 재고 차감
4. 주문 서비스: 주문 확정 (CONFIRMED)
만약 3단계에서 실패하면?
→ 보상 트랜잭션 실행:
2. 결제 서비스: 결제 취소 (환불)
1. 주문 서비스: 주문 취소 (CANCELLED)
자주 헷갈리는 포인트
서비스를 얼마나 작게 나눠야 하나요?
"마이크로"라는 이름에 속아서 너무 작게 나누면 안 됩니다. 기준은 크기가 아니라 독립 배포 가능성 입니다.
- 한 팀(5~9명)이 소유할 수 있는 규모
- 독립적으로 배포했을 때 다른 서비스에 영향이 없는 범위
- 2주 안에 처음부터 다시 짤 수 있는 크기 (Sam Newman의 기준)
REST vs gRPC?
- REST: 범용적, 브라우저 호환, 디버깅 쉬움 → 외부 API, BFF
- gRPC: 바이너리 프로토콜이라 빠름, 스트리밍 지원 → 서비스 간 내부 통신
둘 다 동기 통신이므로, 장애 전파 방지를 위한 서킷 브레이커/타임아웃/재시도는 공통으로 필요합니다.
이벤트 순서가 보장되나요?
Kafka의 경우 같은 파티션 내에서만 순서가 보장됩니다.
// 주문 ID를 파티션 키로 사용 → 같은 주문의 이벤트는 순서 보장
kafkaTemplate.send("order-events", order.getId(), event);
서로 다른 주문의 이벤트 간 순서는 보장되지 않지만, 보통 그럴 필요도 없습니다.
공유 DB를 정말 안 써야 하나요?
원칙은 "서비스별 DB"이지만, 현실적으로 마이그레이션 과정에서는 공유 DB를 점진적으로 분리합니다:
1단계: 같은 DB, 다른 스키마 (schema per service)
2단계: 읽기 전용 뷰를 통한 접근
3단계: 완전히 분리된 DB
정리
- 서비스 경계는 기술이 아닌 비즈니스 기능(Bounded Context) 기준으로 나누기
- 동기 통신은 즉시 응답이 필요할 때, 비동기 통신은 결합도를 낮추고 싶을 때 사용
- 서킷 브레이커와 타임아웃으로 동기 호출의 장애 전파를 방지
- 각 서비스는 자신의 데이터베이스를 소유하고, 다른 서비스의 DB에 직접 접근하지 않기
- 분산 트랜잭션은 Saga 패턴으로 보상 트랜잭션을 통해 처리
- 서비스를 너무 작게 나누지 말 것 — "독립 배포 가능성"이 기준