Advanced (업데이트: 2026. 6. 3.)

Claude Code로 마이크로서비스 설계하기: 경계, API, Compose, 테스트

Claude Code로 마이크로서비스를 설계하고 구현하는 실전 흐름. 서비스 경계, API 계약, DB 소유권, Compose, 관측성, 테스트까지.

Claude Code로 마이크로서비스 설계하기: 경계, API, Compose, 테스트

마이크로서비스는 큰 애플리케이션을 작고 독립적인 서비스로 나누고, API나 이벤트로 연결하는 설계 방식입니다. Claude Code는 여러 파일을 빠르게 만드는 도구로만 쓰면 부족합니다. 서비스 경계, API 계약, 데이터베이스 소유권, 로컬 Compose, 게이트웨이, 로그, 테스트, 배포 체크리스트를 함께 검토하게 할 때 가치가 큽니다.

중요한 전제도 있습니다. 마이크로서비스는 성능을 자동으로 올려 주는 마법이 아닙니다. 분리하는 순간 네트워크 실패, API 호환성, 분산 트랜잭션, 로그 추적, 배포 순서라는 비용이 생깁니다. 도메인이 아직 자주 바뀌는 제품이라면 먼저 모듈형 모놀리스를 유지하는 편이 낫습니다. 관련 기반은 Claude Code API 개발, Docker Compose 가이드, 로깅과 모니터링, 이벤트 기반 아키텍처를 참고하세요.

공식 문서를 검토 기준으로 두세요: Anthropic Claude Code overview, Docker Compose documentation, OpenAPI Specification 3.1. 이렇게 해야 Claude Code의 제안과 실제 운영 계약을 분리해서 볼 수 있습니다.

경계부터 검토하기

Claude Code에 바로 “서비스를 나눠줘”라고 하지 말고, 왜 나누는지 설명하게 만듭니다.

당신은 전자상거래 시스템의 마이크로서비스 분리 설계를 리뷰하는 아키텍트입니다.

상황:
- 주문 흐름은 변경이 잦습니다.
- 재고는 창고 연동 때문에 독립적으로 배포되어야 합니다.
- 결제와 알림 장애가 상품 조회를 막으면 안 됩니다.

출력:
1. 후보 서비스와 책임.
2. 각 서비스가 소유할 데이터.
3. 동기 API와 비동기 이벤트의 구분.
4. 지금은 분리하지 않는 기능.
5. 첫 스프린트에서 만들 최소 구성.

제약:
- 공유 DB 금지.
- 내부 테이블 이름을 API에 노출하지 않기.
- Gateway에 비즈니스 규칙을 넣지 않기.
- 로컬은 Docker Compose로 실행 가능해야 함.

Microsoft Learn의 microservices architecture guide는 서비스 자율성, API 계약, 데이터 격리를 확인하기 좋습니다. Kubernetes 운영을 염두에 둔다면 AKS microservices reference architecture도 비교해 보세요. 게이트웨이의 책임은 API Management gateway overview가 도움이 됩니다.

계약과 데이터 소유권

구현 전에 OpenAPI 계약을 먼저 둡니다.

openapi: 3.1.0
info:
  title: Order Service API
  version: 1.0.0
paths:
  /orders:
    post:
      summary: Create an order after reserving inventory
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customerId, items]
              properties:
                customerId:
                  type: string
                items:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [sku, quantity]
                    properties:
                      sku:
                        type: string
                      quantity:
                        type: integer
                        minimum: 1
      responses:
        "201":
          description: Order accepted
        "409":
          description: Inventory could not be reserved

데이터 소유권은 PR마다 확인합니다.

서비스소유 데이터호출 가능금지
gateway비즈니스 데이터 없음order, inventory할인이나 재고 계산
order-serviceorders, order_itemsinventory API, order-eventsinventory 테이블 조회
inventory-servicestock, reservations초기에는 없음orders 테이블 조회
notification-servicedelivery logsorder-events주문 상태 직접 변경

한 화면에서 주문과 재고가 같이 필요하다고 해서 DB를 직접 JOIN하지 않습니다. API 조합, 읽기 모델, 검색 인덱스, 이벤트 기반 캐시 중 하나를 선택합니다.

리뷰를 위해 저장소에 작은 service-inventory.json을 두면 좋습니다. 경계 결정은 사람이 하고, Claude Code에는 변경이 그 경계를 깨는지 확인하게 합니다.

{
  "services": [
    {
      "name": "gateway",
      "owns": [],
      "mayCall": ["order-service", "inventory-service"],
      "mustNot": ["store business data", "calculate discounts"]
    },
    {
      "name": "order-service",
      "owns": ["orders", "order_items"],
      "mayCall": ["inventory-service"],
      "mustNot": ["read inventory tables directly"]
    },
    {
      "name": "inventory-service",
      "owns": ["stock", "reservations"],
      "mayCall": [],
      "mustNot": ["change order status"]
    }
  ],
  "releaseRules": [
    "no shared database tables",
    "public APIs hide internal table names",
    "every service has healthcheck, logs, tests, and rollback notes"
  ]
}

실행 가능한 로컬 예제

mkdir microservices-demo
cd microservices-demo
mkdir services
npm init -y
npm pkg set type=module
npm install express zod pino redis undici

compose.yaml을 만듭니다.

services:
  gateway:
    image: node:22-alpine
    working_dir: /workspace
    command: node services/service.mjs
    environment:
      SERVICE: gateway
      PORT: 3000
      ORDER_URL: http://order-service:3000
      INVENTORY_URL: http://inventory-service:3000
    ports:
      - "8080:3000"
    volumes:
      - .:/workspace
    depends_on:
      - order-service
      - inventory-service

  order-service:
    image: node:22-alpine
    working_dir: /workspace
    command: node services/service.mjs
    environment:
      SERVICE: order
      PORT: 3000
      INVENTORY_URL: http://inventory-service:3000
      REDIS_URL: redis://redis:6379
    volumes:
      - .:/workspace
    depends_on:
      redis:
        condition: service_healthy
      inventory-service:
        condition: service_started

  inventory-service:
    image: node:22-alpine
    working_dir: /workspace
    command: node services/service.mjs
    environment:
      SERVICE: inventory
      PORT: 3000
    volumes:
      - .:/workspace

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

services/service.mjs를 만듭니다.

import express from "express";
import pino from "pino";
import { createClient } from "redis";
import { request } from "undici";
import { z } from "zod";
import { randomUUID } from "node:crypto";

const service = process.env.SERVICE ?? "inventory";
const port = Number(process.env.PORT ?? 3000);
const log = pino({ name: service });

function appBase(name) {
  const app = express();
  app.use(express.json());
  app.use((req, res, next) => {
    req.requestId = req.header("x-request-id") ?? randomUUID();
    res.setHeader("x-request-id", req.requestId);
    next();
  });
  app.get("/health", (_req, res) => res.json({ ok: true, service: name }));
  return app;
}

function inventory() {
  const app = appBase("inventory");
  const stock = new Map([["sku-1", 5], ["sku-2", 2]]);
  const Reserve = z.object({ sku: z.string().min(1), quantity: z.number().int().positive() });
  app.get("/inventory/:sku", (req, res) => res.json({ sku: req.params.sku, quantity: stock.get(req.params.sku) ?? 0 }));
  app.post("/inventory/reservations", (req, res) => {
    const parsed = Reserve.safeParse(req.body);
    if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
    const available = stock.get(parsed.data.sku) ?? 0;
    if (available < parsed.data.quantity) return res.status(409).json({ error: "insufficient_stock", available });
    stock.set(parsed.data.sku, available - parsed.data.quantity);
    log.info({ requestId: req.requestId, sku: parsed.data.sku }, "reserved");
    res.status(201).json({ sku: parsed.data.sku, remaining: stock.get(parsed.data.sku) });
  });
  app.listen(port, () => log.info({ port }, "inventory started"));
}

async function order() {
  const app = appBase("order");
  const redis = createClient({ url: process.env.REDIS_URL ?? "redis://localhost:6379" });
  await redis.connect();
  const Order = z.object({
    customerId: z.string().min(1),
    items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1),
  });
  app.post("/orders", async (req, res) => {
    const parsed = Order.safeParse(req.body);
    if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
    for (const item of parsed.data.items) {
      const response = await request(`${process.env.INVENTORY_URL}/inventory/reservations`, {
        method: "POST",
        headers: { "content-type": "application/json", "x-request-id": req.requestId },
        body: JSON.stringify(item),
      });
      if (response.statusCode >= 400) return res.status(response.statusCode).json(await response.body.json());
    }
    const order = { id: randomUUID(), ...parsed.data, status: "accepted" };
    await redis.xAdd("order-events", "*", { type: "OrderAccepted", payload: JSON.stringify(order) });
    res.status(201).json(order);
  });
  app.listen(port, () => log.info({ port }, "order started"));
}

function gateway() {
  const app = appBase("gateway");
  async function forward(req, res, url) {
    const response = await request(url, {
      method: req.method,
      headers: { "content-type": "application/json", "x-request-id": req.requestId },
      body: req.method === "GET" ? undefined : JSON.stringify(req.body),
    });
    res.status(response.statusCode).send(await response.body.text());
  }
  app.post("/orders", (req, res) => forward(req, res, `${process.env.ORDER_URL}/orders`));
  app.get("/inventory/:sku", (req, res) => forward(req, res, `${process.env.INVENTORY_URL}/inventory/${encodeURIComponent(req.params.sku)}`));
  app.listen(port, () => log.info({ port }, "gateway started"));
}

if (service === "inventory") inventory();
else if (service === "order") await order();
else if (service === "gateway") gateway();
docker compose up
curl http://localhost:8080/inventory/sku-1
curl -X POST http://localhost:8080/orders -H "content-type: application/json" -d '{"customerId":"cust-1","items":[{"sku":"sku-1","quantity":2}]}'
docker compose down

출시 전 판단 기준

마이크로서비스는 서비스 개수가 많다고 성공하는 구조가 아닙니다. 각 서비스가 독립적으로 수정, 테스트, 배포, 롤백될 수 있어야 합니다. Claude Code에는 하나의 업무 흐름을 기준으로 데이터 소유자, 공개 API 계약, 재시도 가능한 이벤트, 사람이 개입해야 하는 실패 지점을 표로 정리하게 하세요. 이 표가 있어야 분리의 효과와 운영 비용을 같이 볼 수 있습니다.

처음부터 결제, 권한, 검색, 알림을 모두 나누기보다 주문과 재고처럼 경계가 명확한 흐름부터 작게 검증하는 것이 좋습니다. 각 서비스에는 /health, 구조화 로그, 계약 테스트, 장애 시 대체 경로가 있어야 합니다. 이 네 가지가 없는 분리는 코드 저장소만 복잡하게 만들고, 장애 대응 속도는 오히려 느려질 수 있습니다.

세 가지 구체적인 적용 사례

첫 번째는 전자상거래나 예약 시스템입니다. 주문, 재고, 결제, 알림은 변경 빈도와 장애 영향이 다릅니다. 재고 연동이 느려져도 상품 조회는 유지되어야 하고, 알림 서비스가 멈춰도 주문 접수는 보존되어야 합니다. Claude Code에는 먼저 이 경계를 설명하게 하고, 그 다음 API 계약과 테스트를 생성하게 하는 편이 안전합니다.

두 번째는 B2B SaaS입니다. 청구, 권한, 감사 로그, 워크플로는 담당 팀과 배포 주기가 갈라지기 쉽습니다. 이때 가장 위험한 실수는 tenant, role, plan 같은 핵심 사실을 여러 서비스가 각자 복사해 관리하는 것입니다. 먼저 어떤 서비스가 사실의 원본인지 정하고, 나머지는 API, 이벤트, 읽기 모델 중 무엇으로 읽을지 결정해야 합니다.

세 번째는 콘텐츠 플랫폼입니다. 입고, 이미지 변환, 검색 색인, 추천, 분석은 부하 특성이 다릅니다. 무거운 변환 작업을 편집 화면에서 분리하는 것은 가치가 있지만, 재시도, 멱등키, 실패 큐가 없으면 장애가 편집팀과 배포팀 사이의 책임 공방으로 번집니다.

실패 사례와 이전 순서

가장 흔한 실패는 테이블 단위로 서비스를 나누는 것입니다. users-service, profiles-service, addresses-service처럼 ERD를 그대로 서비스로 만들면 한 화면을 그리기 위해 여러 동기 호출이 필요합니다. 경계는 테이블이 아니라 업무 능력에서 나와야 합니다.

두 번째 실패는 공유 DB입니다. 서비스는 나뉘어 보이지만 실제로는 같은 schema를 동시에 이해해야 합니다. Claude Code가 migration을 만들 때는 “이 변경은 어느 서비스 소유인가”를 PR 설명에 적게 하고, 다른 서비스가 내부 테이블을 직접 읽지 못하게 해야 합니다.

세 번째 실패는 거대한 shared domain library입니다. 로그 형식, 에러 래퍼, 인증 helper는 공유할 수 있지만 주문 상태나 재고 규칙을 공통 패키지로 빼면 모든 서비스가 같은 릴리스 타이밍에 묶입니다. 공통화는 편리함보다 독립 배포 가능성을 먼저 기준으로 봐야 합니다.

이전 순서는 작게 잡는 것이 좋습니다. 먼저 모듈형 모놀리스 안에서 service-inventory.json, OpenAPI 계약, 계약 테스트를 만듭니다. 다음 단계에서 Docker Compose로 하나의 옆길 서비스를 띄우고, 마지막에 Feature Flag로 아주 적은 트래픽만 전환합니다. 각 단계마다 롤백 명령과 사람이 확인할 기준을 남기세요.

관측성, 테스트, 배포 체크

최소한 x-request-id, 구조화 로그, 서비스 이름, 주요 ID, /health, 오류율과 지연 시간 지표는 첫 릴리스에 포함해야 합니다. Claude Code에는 API 호환성, DB 마이그레이션 소유권, Gateway의 비즈니스 로직 혼입, 400/409/500 실패 경로, Redis 중단 시 동작, Feature Flag, 카나리와 롤백 절차를 검토하게 하세요.

좋은 사용 사례는 주문/재고/결제/알림이 분리되는 전자상거래, 청구/권한/감사 로그가 명확한 B2B SaaS, 입고/변환/검색/배포가 다른 속도로 확장되는 콘텐츠 플랫폼입니다. 흔한 함정은 테이블 단위 분리, 공유 DB, 거대한 공유 도메인 라이브러리, Gateway에 업무 규칙 넣기, 관측성 없이 배포하기입니다.

영어 키워드로 정리하면 좋은 use case는 독립 변경, 독립 확장, 독립 실패 처리가 필요한 업무 능력입니다. 대표 pitfall은 서비스 폴더는 늘었지만 owner, 데이터 소유권, rollback이 분명하지 않은 상태입니다. Claude Code에는 후보 서비스마다 owner, API, owned tables, events, dashboard, SLO, deploy command, rollback command, dependency failure 동작을 짧은 표로 쓰게 하세요.

재사용 가능한 CLAUDE.md 조각, 리뷰 체크리스트, API 계약 템플릿이 필요하다면 ClaudeCodeLab의 실무 템플릿 제품에서 시작할 수 있습니다.

실제 프로젝트의 경계와 API 계약, Compose 환경, 모니터링 설계를 함께 점검하고 싶다면 Claude Code training and consultation에서 작은 업무 흐름 하나를 기준으로 시작하는 편이 안전합니다.

#Claude Code #마이크로서비스 #API 설계 #Docker Compose #관측성 #테스트
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.