본문으로 건너뛰기
Previous
Next
마이크로서비스 아키텍처 완벽 가이드 | 설계·통신·배포·모니터링·Best Practices

마이크로서비스 아키텍처 완벽 가이드 | 설계·통신·배포·모니터링·Best Practices

마이크로서비스 아키텍처 완벽 가이드 | 설계·통신·배포·모니터링·Best Practices

이 글의 핵심

마이크로서비스 아키텍처를 설계하고 구축하는 완벽 가이드. 서비스 분리, API Gateway, 통신 패턴, 배포, 모니터링, 실패 처리까지 실전 예제로 정리. Microservices·Architecture·API Gateway 중심으로 설명합니다.

이 글의 핵심

마이크로서비스를 책이 말해주는 이상으로만 보면 망하기 쉬워요. 저는 예전에 한 팀에 붙어서, 그냥 잘 굴러가던 모놀리스를 “쪼개야 진짜다”는 분위기에 휩쓸렸다가, 오히려 배포·디버깅·데이터만 더 어려워진 적이 있어요. 반대로, 팀이 쪼개지고 도메인 경계가 진짜로 갈리는 단계에 들어섰을 때는 서비스를 나누는 쪽이 삶의 질이 올랐고요. 이 글은 교과서용 체크리스트제가 겪은 쪽집게를 섞어서 썼습니다. 한 줄 먼저: 모놀리스가 나을 때도 많아요. 그 다음에, 나눌 거면 어떻게 나누는지.

모놀리스에서 마이크로서비스로: 제가 본 이야기

스토리는 뻔한 편이에요. 처음엔 하나의 레포, 하나의 배포로 빠르게 갔죠. 그런데 팀이 늘고, 결제·재고·알림이 한 프로세스 안에서 엮이면 온콜이 “이게 어느 팀 경계인가요?”로 변해요. PM은 “서비스 나누자”를 외치고, 엔지니어는 “일단 DDD로 그어 보자”고 하죠. 실제로 전환은 overnight가 아니라, BFF로 진입을 정리하거나, 읽기/쓰기 부하가 갈리는 애부터 HTTP나 큐로 밖으로 빼는 식이었어요. DB는 당장 못 쪼갠다면 프로세스 경계부터 잡는 팀이 많고요. 배포는 빨라진 팀도 있고, 분산 트랜잭션·로그 추적 때문에 더 느려진 팀도 봤어요. 마이크로서비스는 은총이 아니라, 그만한 운영 비용을 감수할 수 있을 때 쓰는 도구에 가깝다고 봅니다.

그래서 실무에서 뭐가 힘드냐면

한 줄짜리 수정에 전체 배포 — 맞는 말이에요. 다만 “서비스만 나누면 끝”이 아니라, 빌드·테스트·배포 파이프가 늘어나면 그걸 감싸는 문화/도구가 없으면 지옥이에요. 한 곳 터지면 다 멈춤 — 모놀에선 그렇죠. 쪼개도 의존성이 동기 HTTP로 꼬이면 “분산 모놀리스”가 되고, 장애는 퍼져요(그래서 서킷 브레이커, 타임아웃, 모니터링이 글 뒤에 붙어 있는 거예요). 팀끼리 기다림 — 서비스로 나눴는데 스키마·릴리즈를 약속 못 하면, 예전이랑 똑같이 “네 배포 끝나면 우리 켤게요”가 됩니다. 제가 배운 점: 나누기 전에 팀/제품 경계가 먼저이고, 기술은 둘째예요. 그다음이 패턴(게이트웨이, 이벤트, 사가)입니다.

1. 마이크로서비스가 뭔지, 냉정하게

작고 독립적으로 배포되는 서비스의 조합이에요. 글로 보면 쉬운데, 현실은 네트워크, 부분 실패, 최종 일관성이 기본옵션이죠.

갖다 쓰는 말들 — 한 가지 일에 집중(단일 책임), 혼자 배포, DB는 가능하면 서비스마다(안 되면 “지금은 공유 + 나중에 쪼개기”도 현실), 통신은 HTTP·gRPC·큐, 한 군데가 죽어도 전부가 죽지 않게 설계(완벽하진 않아요, 그냥 목표).

솔직히, 팀이 작고 제품이 아직 찾는 중이면 모놀리스가 학습·출시 모두에 유리한 경우가 많아요. “모놀리스가 나을 때도 많아요”는 변명이 아니라 제약 조건이에요. 나중에 정말 경계가 보이면 그때 쪼개도 늦지 않은 팀도 많고요.

2. 서비스, 어떻게 쪼갤까 (DDD 짧게)

아래 박스는 “한 덩어리 vs 나눈 모습”을 눈으로만 잡는 용이에요. 진짜는 이벤트/유스케이스가 팀 A와 팀 B에서 다르게 바뀌는가를 보는 쪽이 덜 후회해요.

모놀리식:
┌─────────────────────────────┐
│   Single Application        │
│  - Users                    │
│  - Products                 │
│  - Orders                   │
│  - Payments                 │
│  - Notifications            │
└─────────────────────────────┘
마이크로서비스:
┌──────────┐  ┌──────────┐  ┌──────────┐
│  User    │  │ Product  │  │  Order   │
│ Service  │  │ Service  │  │ Service  │
└──────────┘  └──────────┘  └──────────┘
     │             │              │
┌──────────┐  ┌──────────┐  ┌──────────┐
│ Payment  │  │  Notif.  │  │ Shipping │
│ Service  │  │ Service  │  │ Service  │
└──────────┘  └──────────┘  └──────────┘

3. API Gateway

Express Gateway 예시 — 진입을 한곳에 모아 라우팅만 먼저 잡는 패턴이에요. “우리 API는 여기로”만 통일해도, 클라이언트·온콜이 편해져요.

// gateway.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
// User Service
app.use('/api/users', createProxyMiddleware({
  target: 'http://user-service:3001',
  changeOrigin: true,
}));
// Product Service
app.use('/api/products', createProxyMiddleware({
  target: 'http://product-service:3002',
  changeOrigin: true,
}));
// Order Service
app.use('/api/orders', createProxyMiddleware({
  target: 'http://order-service:3003',
  changeOrigin: true,
}));
app.listen(3000, () => console.log('API Gateway running on :3000'));

4. 서비스 간 통신

동기 (HTTP/gRPC)

간단하죠. 대신 체인이 길어지면 지연·실패가 곱절이에요. “그냥 한 번에 부른다”가 아니라, 타임아웃·재시도·서킷을 머릿속에 넣고 짜요.

// order-service.ts
import axios from 'axios';
async function createOrder(userId: number, productId: number) {
  // User Service 호출
  const user = await axios.get(`http://user-service:3001/users/${userId}`);
  
  // Product Service 호출
  const product = await axios.get(`http://product-service:3002/products/${productId}`);
  // 주문 생성
  const order = await db.order.create({
    data: {
      userId,
      productId,
      amount: product.data.price,
    },
  });
  return order;
}

비동기 (메시지 큐)

느슨하게 풀고 싶을 때. 최종 일관성은 “어쩔 수 없이 받는다”가 아니라 의도적으로 설계하는 게 편해요. 저는 “주문은 됐는데 결제 이벤트가 늦게 왔다” 같은 걸 대시보드로 보기 전엔, 로그만으로는 멘탈이 흔들렸어요.

// order-service.ts
import { Kafka } from 'kafkajs';
const kafka = new Kafka({
  clientId: 'order-service',
  brokers: ['localhost:9092'],
});
const producer = kafka.producer();
async function createOrder(order: any) {
  // 주문 생성
  const newOrder = await db.order.create({ data: order });
  // 이벤트 발행
  await producer.send({
    topic: 'order-events',
    messages: [
      {
        value: JSON.stringify({
          type: 'order_created',
          order: newOrder,
        }),
      },
    ],
  });
  return newOrder;
}
// payment-service.ts
const consumer = kafka.consumer({ groupId: 'payment-service' });
await consumer.subscribe({ topic: 'order-events' });
await consumer.run({
  eachMessage: async ({ message }) => {
    const event = JSON.parse(message.value.toString());
    if (event.type === 'order_created') {
      await processPayment(event.order);
    }
  },
});

5. Service Discovery (Consul)

서비스가 늘면 주소가 문제예요. 등록/헬스체크까지 같이 갖다 두면, “지금 켜진 인스턴스가 어디지?”가 줄어요.

// service-registry.ts
import Consul from 'consul';
const consul = new Consul();
// 서비스 등록
await consul.agent.service.register({
  id: 'user-service-1',
  name: 'user-service',
  address: 'localhost',
  port: 3001,
  check: {
    http: 'http://localhost:3001/health',
    interval: '10s',
  },
});
// 서비스 발견
const services = await consul.health.service('user-service');
const healthyServices = services.filter(s => s.Checks.every(c => c.Status === 'passing'));

6. Circuit Breaker

계속 망가지는 쪽에다 계속 박으면, 옆서비스까지 끌고 가요. “빨리 실패”시키는 게, 제 경험에선 장애 반경을 확 줄이더라고요.

// circuit-breaker.ts
import axios from 'axios';
class CircuitBreaker {
  private failureCount = 0;
  private lastFailureTime = 0;
  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
  constructor(
    private threshold: number = 5,
    private timeout: number = 60000
  ) {}
  async call<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
  private onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }
  private onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    if (this.failureCount >= this.threshold) {
      this.state = 'OPEN';
    }
  }
}
// 사용
const breaker = new CircuitBreaker();
try {
  const user = await breaker.call(() =>
    axios.get('http://user-service:3001/users/123')
  );
} catch (error) {
  console.error('Service unavailable');
}

7. 분산 트랜잭션 (Saga) — 이벤트로 풀기

한 DB 트랜잭션으로 끝나면 좋겠지만, 쪼개면 보통 사가·이벤트로 흡수해요. 보상(취소)까지 설계해 두지 않으면, “결제는 됐는데 주문이 없다” 류의 열려 있는 탭이 늘어나요.

// order-service.ts
async function createOrder(order: any) {
  const newOrder = await db.order.create({ data: order });
  await kafka.send({
    topic: 'order-events',
    messages: [{ value: JSON.stringify({ type: 'order_created', order: newOrder }) }],
  });
}
// payment-service.ts
consumer.run({
  eachMessage: async ({ message }) => {
    const event = JSON.parse(message.value.toString());
    if (event.type === 'order_created') {
      try {
        await processPayment(event.order);
        
        await kafka.send({
          topic: 'payment-events',
          messages: [{ value: JSON.stringify({ type: 'payment_completed', orderId: event.order.id }) }],
        });
      } catch (error) {
        await kafka.send({
          topic: 'payment-events',
          messages: [{ value: JSON.stringify({ type: 'payment_failed', orderId: event.order.id }) }],
        });
      }
    }
  },
});
// order-service.ts (보상 트랜잭션)
consumer.run({
  eachMessage: async ({ message }) => {
    const event = JSON.parse(message.value.toString());
    if (event.type === 'payment_failed') {
      await db.order.update({
        where: { id: event.orderId },
        data: { status: 'cancelled' },
      });
    }
  },
});

정리: 제가 실무에서 쓰는 체크 (비표 버전)

  • 진짜로 팀/배포 단위로 갈릴 것인가? — 아니면 모놀 + 모듈 경계로 충분할 수도 있어요. 모놀리스가 나을 때도 많아요.
  • 게이트웨이: URL·인증·레이트리밋의 한 얼굴을 먼저 통일.
  • 디스커버리 + 헬스: “살아있는 곳”만 붙기.
  • 서킷 + 타임아웃 + 멱등: 분산에선 재시도가 칼이 될 수 있어요.
  • 이벤트/사가: “나중에 맞는다”를 눈으로 확인할 관측 없이 가면, 나중이 안 올 때가 많아요.

체크에 그대로 옮기면

  • 경계(팀/도메인) 먼저
  • API Gateway
  • 서비스 간 통신(동기/비동기)과 실패 시나리오
  • Service Discovery
  • Circuit Breaker
  • 모니터링·로그 상관 ID
  • 배포·롤백

같이 보면 좋은 글

이 글에서 다루는 키워드

Microservices, Architecture, API Gateway, Service Mesh, DevOps, Distributed Systems

자주 묻는 질문 (FAQ)

Q. 마이크로서비스는 언제 쓰는 게 맞아요?

A. 팀·조직·릴리즈가 서로 안 밟히게 나뉘어 있고, 운영(옵스, 온콜, 대시보드)을 감당할 수 있을 때요. “유행”만으로 쪼개면 비용이 먼저 옵니다. 작을 땐 모놀이 흔히 더 싸고 빨라요. 모놀리스가 나을 때도 많아요, 정말로.

Q. 얼마나 잘게 쪼갤까요?

A. “한 피자는 두 팀” 같은 말도 있지만, 저는 한 팀이 주인인 서비스 정도로 머리에 둡니다. 너무 잘게 쪼갠 건 “나중에 합칠까?”가 반복돼요. 복잡도가 서비스 개수에 비례한다는 것만 기억해도 절반이에요.

Q. 데이터는 어떻게 맞춰요?

A. 전부를 강한 일관성으로 묶기 어렵죠. 사가, 이벤트 소싱, 아웃박스… 최종 일관성비즈니스가 괜찮다고 말해 줄 때가 진짜 마이그레이션이에요. 아니면 아직 쪼갤 타이밍이 아닐 수도 있고요.

Q. 프로덕션에 써도 돼?

A. 대기업도 많이 써요. 다만 “걔네는 되는데 우리는?”에서 우리가 없으면(관측, 런북, 팀) 망하더라… 가 제 케이스였어요. 패턴은 도구고, 운영이 본론이에요.

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제를 돌아가는 머리 기준으로 다시 압축한 거예요. 제목이 길어서 그대로 두었는데(위 본문 제목), 실무에선 “입력 → 검증 → 핵심 → 부작용 → 관측”으로 쪼개서 장애를 잡는 편이 편해요.

내부 동작 감 잡기

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건을 문장으로 써 두면 디버깅이 빨라져요(버퍼, 격리, FD 상한…).
  • 시간·네트워크 섞인 층이랑 순수에 가까운 층을 나누면, 테스트도 장애 분석도 쉬워요.
  • 백프레셔 어디 둘지 합의 안 하면, 큐가 있어도 터져요.

프로덕션에서 자주 던지는 질문들 (표 말고 그냥 나열)

관측성 — 요청마다 상관 ID가 있고, p95·p99랑 의존성 타임아웃/재시도가 대시보드에 보이나요? 안전 — 인증/감사 로그가 경로마다 일관되나요? 신뢰 — 재시도는 멱등한 쪽에만? 서킷·DLQ는? 성능 — 캐시, 풀, 인덱스, N+1? 배포 — 롤백, 카나리, 마이그레이션 순서, 피처플래그 문서? 용량 — 피크 때 FD·스레드·디스크 상한? 스테이징은 데이터 양·RTT를 꼭 가짜로라도 맞춰보면 재현이 올라가요. 제 교훈: 스테이징이 “너무 가볍”으면, 프로드 장애가 옵니다.

확장: 엔드투엔드에 옮기면

  1. 입력 계약 고정(스키마, 버전, 최대 페이로드, 타임아웃, 에러 코드)
  2. 핵심 경로에 ID·지연·외부 결과 코드 꽂기
  3. 고장을 스테이징에서 재현(타임아웃, 5xx, 부분 데이터)
  4. 호환·롤백 — 설정/마이그/클라 버전
  5. 부하 후 p95, 에러율, 알림
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

막혔을 때 (표 없이)

  • 가끔 실패 — 레이스, 타임아웃, DNS, 밖 서비스. 최소 재현 → 트레이스·로그로 상관 → 재시도·서킷 값 보기.
  • 느려짐 — N+1, 락, 직렬화, 캐시 미스. 프로파일/APM에서 한 가지씩.
  • 메모리 — 캐시 무한, 리스너 누수, 커넥션 미반납. 상한·스냅샷 비교.
  • 빌드만 깨짐 — env, lockfile, 이미지 버전. CI vs 로컬 diff.
  • 설정 엇갈림 — 프로필/시크릿/기본값. 검증된 단일 소스가 있나요?
  • 데이터 안 맞음 — 비멱등 재시도, 캐시 무효화. 멱등 키·아웃박스 다시.

순서는: 최소 재현 → 최근 변경 줄이기 → 환경 차이 → 가설로 관측 → 수정 후 부하/회귀. 배포 전에는 git addgit commitgit push 하고 npm run deploy — 이건 예전 팀 루북에도 그렇고, 아직도 유효해요.