"마이크로서비스를 도입하면 문제가 해결될까?"
많은 팀이 모놀리스의 복잡성을 해결하기 위해 마이크로서비스를 도입하지만, 결과적으로 "분산된 모놀리스"라는 더 나쁜 상황에 빠지는 경우가 적지 않습니다. Martin Fowler는 이를 "마이크로서비스 프리미엄(Microservice Premium)"이라 부르며, 시스템의 복잡성이 일정 수준 이상일 때만 마이크로서비스의 이점이 비용을 초과한다고 경고합니다.
그렇다면 마이크로서비스는 언제, 어떻게 도입해야 할까요?
---
모놀리스 vs 마이크로서비스 — 트레이드오프 이해하기
모놀리스는 하나의 코드베이스와 하나의 배포 단위로 구성됩니다. 개발 초기에는 단순하고 빠르지만, 코드가 수십만 줄을 넘어가면 빌드 시간이 길어지고, 작은 변경도 전체 재배포가 필요하며, 팀 간 코드 충돌이 빈번해집니다.
마이크로서비스는 각 비즈니스 기능을 독립적인 서비스로 분리합니다. 각 서비스는 독립적으로 배포, 확장, 기술 선택이 가능하죠. 하지만 그 대가로 네트워크 통신, 데이터 일관성, 운영 복잡성이라는 새로운 도전이 생깁니다.
Sam Newman의 저서 "Building Microservices"(O'Reilly, 2021)에서 강조하는 핵심 원칙은 이것입니다: "마이크로서비스는 모놀리스의 문제를 해결하는 것이 아니라, 문제를 네트워크 경계 너머로 옮기는 것이다." 이 경계를 잘못 설정하면 복잡성이 줄어들기는커녕 폭발적으로 증가합니다.
---
서비스 경계 정의 — DDD의 Bounded Context
마이크로서비스 설계에서 가장 중요하면서도 가장 어려운 결정이 "서비스 경계를 어디에 그을 것인가"입니다. Eric Evans의 Domain-Driven Design(DDD)에서 제시한 Bounded Context 개념이 여기서 유용합니다.
``` 예: 이커머스 시스템
[주문 서비스] [결제 서비스] [배송 서비스]
- 주문 생성 - 결제 처리 - 배송 추적
- 주문 상태 관리 - 환불 처리 - 배송지 관리
- 장바구니 - 청구 내역 - 운송장 관리
[상품 서비스] [회원 서비스] [알림 서비스]
- 상품 카탈로그 - 회원 가입/인증 - 이메일
- 재고 관리 - 프로필 관리 - 푸시 알림
- 가격 정책 - 권한 관리 - SMS
```
각 Bounded Context 안에서 같은 용어가 다른 의미를 가질 수 있습니다. "고객"이라는 개념이 주문 서비스에서는 "주문자(배송지, 연락처)"를 의미하고, 결제 서비스에서는 "결제자(카드 정보, 청구 주소)"를 의미하는 식이죠. 이런 의미의 경계가 서비스의 경계가 되어야 합니다.
---
통신 패턴 — 동기 vs 비동기
동기 통신 (HTTP/gRPC)
```typescript // 주문 서비스 → 결제 서비스 (동기 호출) async function createOrder(orderData: CreateOrderInput) { const order = await db.order.create({ data: orderData });
// 동기적으로 결제 요청 const paymentResult = await paymentService.charge({ orderId: order.id, amount: order.totalAmount, method: orderData.paymentMethod, });
if (!paymentResult.success) { await db.order.update({ where: { id: order.id }, data: { status: 'PAYMENT_FAILED' }, }); throw new PaymentFailedError(paymentResult.reason); }
return order; } ```
동기 통신은 구현이 간단하지만, 호출 체인이 길어지면 전체 지연 시간이 누적되고, 하나의 서비스 장애가 연쇄적으로 전파되는 문제가 있습니다.
비동기 통신 (메시지 큐/이벤트)
```typescript // 주문 서비스 — 이벤트 발행 async function createOrder(orderData: CreateOrderInput) { const order = await db.order.create({ data: { ...orderData, status: 'PENDING' }, });
// 이벤트 발행 (비동기) await eventBus.publish('order.created', { orderId: order.id, userId: orderData.userId, totalAmount: order.totalAmount, items: orderData.items, });
return order; // 즉시 응답 }
// 결제 서비스 — 이벤트 구독 eventBus.subscribe('order.created', async (event) => { const result = await processPayment(event); if (result.success) { await eventBus.publish('payment.completed', { orderId: event.orderId, transactionId: result.transactionId, }); } else { await eventBus.publish('payment.failed', { orderId: event.orderId, reason: result.reason, }); } }); ```
비동기 이벤트 기반 통신은 서비스 간 결합도를 낮추고, 장애 전파를 방지합니다. 하지만 이벤트 순서 보장, 멱등성 처리, 최종 일관성(Eventual Consistency) 같은 새로운 과제가 생기죠.
---
API Gateway — 단일 진입점
클라이언트가 수십 개의 마이크로서비스를 직접 호출하는 것은 비현실적입니다. API Gateway가 단일 진입점 역할을 수행합니다.
``` 클라이언트 → API Gateway → 주문 서비스 → 결제 서비스 → 회원 서비스 ```
API Gateway의 핵심 역할은 라우팅(URL → 서비스 매핑), 인증/인가(JWT 검증), 레이트 리미팅, 요청/응답 변환, 로드 밸런싱입니다. Kong, AWS API Gateway, Nginx 등이 대표적인 구현체이고, BFF(Backend For Frontend) 패턴을 적용하여 클라이언트 유형별로 다른 게이트웨이를 운영하기도 합니다.
---
서킷 브레이커 — 장애 전파 차단
분산 시스템에서 한 서비스의 장애가 연쇄적으로 전파되는 것을 방지하는 패턴입니다. 가전제품의 과전류 차단기에서 이름을 따왔죠.
```typescript class CircuitBreaker { private failCount = 0; private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED'; private lastFailTime = 0;
constructor( private threshold: number = 5, // 실패 임계치 private timeout: number = 30000, // 차단 시간 (30초) ) {}
async call<T>(fn: () => Promise<T>): Promise<T> { if (this.state === 'OPEN') { if (Date.now() - this.lastFailTime > this.timeout) { this.state = 'HALF_OPEN'; // 탐색적 재시도 } else { throw new CircuitOpenError('서비스 일시 중단'); } }
try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } }
private onSuccess() { this.failCount = 0; this.state = 'CLOSED'; }
private onFailure() { this.failCount++; this.lastFailTime = Date.now(); if (this.failCount >= this.threshold) { this.state = 'OPEN'; } } } ```
---
데이터 관리 — Database per Service
마이크로서비스의 핵심 원칙 중 하나가 각 서비스가 자신의 데이터베이스를 소유하는 것입니다. 다른 서비스의 데이터에 직접 접근(공유 DB)하면 서비스 간 결합도가 높아져, 독립 배포가 불가능해집니다.
이 원칙의 대가는 분산 트랜잭션의 어려움입니다. 주문과 결제가 다른 DB에 있으면, 원자적(atomic) 트랜잭션이 불가능합니다. 이를 해결하는 패턴이 Saga 패턴입니다. 각 서비스가 로컬 트랜잭션을 수행하고, 실패 시 보상 트랜잭션(Compensation)을 실행하여 전체 일관성을 유지하는 방식이죠.
---
관측 가능성 — 분산 시스템의 눈과 귀
마이크로서비스에서는 하나의 사용자 요청이 여러 서비스를 거칩니다. 문제가 발생했을 때 "어디서 느려졌는지" 파악하려면 분산 트레이싱이 필수적입니다.
로그 집중화: 각 서비스의 로그를 중앙(ELK Stack, Loki)에 모아야 합니다. 상관 ID(Correlation ID)를 요청 전체에 전파하여 하나의 요청에 관련된 모든 로그를 추적할 수 있게 하세요.
분산 트레이싱: OpenTelemetry + Jaeger 조합이 업계 표준으로 자리잡고 있습니다. 요청이 어떤 서비스를 거쳤는지, 각 구간에서 얼마나 걸렸는지를 시각화합니다.
메트릭 모니터링: Prometheus + Grafana로 각 서비스의 요청율, 에러율, 지연 시간(RED 메트릭)을 대시보드화하세요.
마이크로서비스는 "작은 서비스를 많이 만드는 것"이 아니라, "올바른 경계에서 올바른 방법으로 시스템을 분리하는 것"입니다. 모놀리스가 잘 동작하고 있다면 굳이 마이크로서비스로 전환할 필요가 없습니다. "Monolith First" — 모놀리스로 시작하여 도메인을 충분히 이해한 후, 필요한 부분만 점진적으로 분리하는 것이 가장 안전한 접근법입니다.