Skip to main content

서비스가 성장할수록 코드베이스는 점점 무거워집니다. 처음에는 빠른 출시를 위해 하나의 Django 또는 FastAPI 프로젝트에 모든 기능을 우겨넣었는데, 어느 순간부터 배포할 때마다 식은땀이 흐르기 시작합니다. “이번에도 잘 될까?” 하는 불안감과 함께 말이죠.

코드벤터는 15년간 수십 개의 서비스를 구축해오면서 이 문제를 반복적으로 겪었습니다. 모놀리식 아키텍처로 시작해서 트래픽이 몰리거나 팀이 커질 때 어떤 고통이 오는지 몸으로 알고 있어요. 그래서 오늘은 스타트업이 확장성을 확보하기 위해 마이크로서비스 아키텍처를 도입하는 실전 전략을 공유하려 합니다.

단, 한 가지 먼저 말씀드릴게요. 마이크로서비스가 항상 정답은 아닙니다. 언제 도입해야 하고, 언제 모놀리식을 유지해야 하는지 판단하는 것이 이 글의 핵심입니다.


1. 모놀리식 아키텍처의 한계 — 언제 문제가 생기나?

모놀리식(Monolithic) 아키텍처는 모든 기능이 하나의 애플리케이션 안에 존재하는 구조입니다. 스타트업 초기에는 이게 최선입니다. 빠르게 만들고, 빠르게 배포하고, 빠르게 검증할 수 있으니까요.

하지만 서비스가 성장하면서 다음과 같은 신호들이 나타나기 시작합니다:

  • 배포 시간이 길어진다 — 작은 기능 하나 수정해도 전체를 배포해야 한다
  • 팀 병목이 생긴다 — 개발자 A가 코드를 수정하면 개발자 B의 작업이 영향받는다
  • 특정 기능만 스케일링이 안 된다 — 결제 서버만 트래픽이 몰려도 전체 서버를 증설해야 한다
  • 장애 전파가 심각하다 — 알림 서비스 버그가 전체 시스템 다운으로 이어진다
  • 기술 스택 선택이 고착된다 — 새 기술을 도입하려면 전체 코드를 바꿔야 한다
# 모놀리식의 전형적인 구조
# 하나의 FastAPI 앱에 모든 것이 들어가 있는 상황

# main.py
from fastapi import FastAPI
app = FastAPI()

# 사용자 관련
from routers import users, auth
# 결제 관련
from routers import payments, subscriptions
# 알림 관련
from routers import notifications, emails
# 분석/통계
from routers import analytics, reports
# 파일 업로드
from routers import uploads, media

app.include_router(users.router)
app.include_router(auth.router)
app.include_router(payments.router)
# ... 모든 게 하나에

# 문제: 배포 한 번에 모든 게 영향받음

코드벤터가 관리하는 한 법률 플랫폼의 경우, 초기에는 2-3명이 개발하다가 기능이 추가되면서 배포 주기가 하루 10회에서 주 1-2회로 줄었습니다. 빠른 배포가 생명인 스타트업에서 이건 치명적이었죠.

마이크로서비스 아키텍처 개발 코드
마이크로서비스 전환 전, 복잡하게 얽힌 모놀리식 코드베이스를 정리하는 것이 첫 번째 단계입니다

2. 마이크로서비스 아키텍처란 무엇인가 — 핵심 개념 정리

마이크로서비스(Microservices) 아키텍처는 하나의 큰 애플리케이션을 독립적으로 배포 가능한 작은 서비스들로 분리하는 설계 방식입니다. 각 서비스는:

  • 하나의 비즈니스 기능에 집중 (단일 책임 원칙)
  • 자체 데이터베이스를 소유
  • API(보통 REST 또는 gRPC)로 다른 서비스와 통신
  • 독립적으로 배포, 스케일링 가능
  • 장애 격리 — 한 서비스가 죽어도 다른 서비스는 동작

실제 스타트업 서비스를 예시로 들면 이렇게 분리됩니다:

# 마이크로서비스 구조 예시 (docker-compose.yml)
version: '3.8'
services:
  # API Gateway - 모든 요청의 진입점
  api-gateway:
    build: ./gateway
    ports: ["80:8000"]
    environment:
      - AUTH_SERVICE_URL=http://auth-service:8001
      - USER_SERVICE_URL=http://user-service:8002
      - PAYMENT_SERVICE_URL=http://payment-service:8003

  # 인증 서비스 - JWT 발급/검증만 담당
  auth-service:
    build: ./services/auth
    ports: ["8001:8001"]
    environment:
      - DATABASE_URL=postgresql://auth_db/auth

  # 사용자 서비스 - 프로필, 설정 관리
  user-service:
    build: ./services/users
    ports: ["8002:8002"]
    environment:
      - DATABASE_URL=postgresql://user_db/users

  # 결제 서비스 - 토스페이먼츠 연동, 구독 관리
  payment-service:
    build: ./services/payments
    ports: ["8003:8003"]
    environment:
      - DATABASE_URL=postgresql://payment_db/payments
      - TOSS_SECRET_KEY=${TOSS_SECRET_KEY}

  # 알림 서비스 - 이메일, SMS, 푸시
  notification-service:
    build: ./services/notifications
    ports: ["8004:8004"]

  # 메시지 브로커 - 서비스 간 비동기 통신
  rabbitmq:
    image: rabbitmq:3-management
    ports: ["5672:5672", "15672:15672"]

핵심은 서비스 간 경계(Bounded Context)를 명확하게 설정하는 것입니다. 도메인 주도 설계(DDD)의 개념을 빌리면, 각 서비스는 특정 도메인의 전문가가 되어야 합니다.

API Gateway 패턴 — 단일 진입점 설계

클라이언트가 수십 개의 서비스 URL을 알 필요는 없습니다. API Gateway가 모든 요청을 받아 적절한 서비스로 라우팅합니다:

# FastAPI 기반 API Gateway 예시
from fastapi import FastAPI, Request, HTTPException
import httpx

app = FastAPI()

SERVICE_MAP = {
    "/api/auth": "http://auth-service:8001",
    "/api/users": "http://user-service:8002",
    "/api/payments": "http://payment-service:8003",
    "/api/notifications": "http://notification-service:8004",
}

@app.middleware("http")
async def proxy_middleware(request: Request, call_next):
    # JWT 검증 (모든 보호된 엔드포인트)
    if request.url.path.startswith("/api/") and \
       not request.url.path.startswith("/api/auth/login"):
        token = request.headers.get("Authorization")
        if not token:
            raise HTTPException(status_code=401)

        # auth-service에 토큰 검증 요청
        async with httpx.AsyncClient() as client:
            verify_response = await client.post(
                "http://auth-service:8001/verify",
                headers={"Authorization": token}
            )
            if verify_response.status_code != 200:
                raise HTTPException(status_code=401)

    return await call_next(request)
클라우드 인프라 마이크로서비스
마이크로서비스는 클라우드 네이티브 환경에서 각 서비스를 독립적으로 스케일링할 수 있습니다

3. 서비스 간 통신 전략 — 동기 vs 비동기

마이크로서비스 도입에서 가장 많이 실수하는 부분이 바로 서비스 간 통신 방식입니다. 모든 서비스를 REST API로 동기 호출하면, 오히려 모놀리식보다 더 복잡하고 느린 시스템이 만들어집니다.

동기 통신 (REST / gRPC) — 즉시 응답이 필요할 때

# 동기 통신 예시 - 결제 정보 조회 (즉시 응답 필요)
# payment-service/routers/payments.py

from fastapi import FastAPI, Depends
import httpx

async def get_user_info(user_id: str) -> dict:
    # user-service에서 사용자 정보 동기 조회
    async with httpx.AsyncClient(timeout=5.0) as client:
        response = await client.get(
            f"http://user-service:8002/users/{user_id}"
        )
        response.raise_for_status()
        return response.json()

@app.get("/payments/{payment_id}")
async def get_payment(payment_id: str, user_id: str = Depends(get_current_user)):
    payment = await db.get_payment(payment_id)
    # 사용자 이름 표시를 위해 user-service 동기 호출
    user = await get_user_info(user_id)
    return {
        **payment,
        "user_name": user["name"]
    }

비동기 통신 (Message Queue) — 즉시 응답이 필요 없을 때

결제 완료 후 이메일 발송, 주문 처리 후 재고 업데이트 같은 경우는 굳이 기다릴 필요가 없습니다. RabbitMQ나 Redis Pub/Sub으로 이벤트를 발행하고, 각 서비스가 알아서 처리하면 됩니다:

# 비동기 이벤트 발행 예시 (RabbitMQ + aio-pika)
import aio_pika
import json
from datetime import datetime

async def publish_event(exchange_name: str, event_type: str, data: dict):
    # 이벤트 발행 - fire and forget
    connection = await aio_pika.connect_robust("amqp://rabbitmq/")
    async with connection:
        channel = await connection.channel()
        exchange = await channel.declare_exchange(
            exchange_name, aio_pika.ExchangeType.TOPIC
        )
        message = aio_pika.Message(
            body=json.dumps({
                "event_type": event_type,
                "data": data,
                "timestamp": datetime.utcnow().isoformat()
            }).encode(),
            content_type="application/json"
        )
        await exchange.publish(message, routing_key=event_type)

# payment-service에서 결제 완료 이벤트 발행
@app.post("/payments/complete")
async def complete_payment(payment_id: str):
    payment = await db.complete_payment(payment_id)

    # 비동기로 이벤트 발행 (응답 안 기다림)
    await publish_event(
        exchange_name="payments",
        event_type="payment.completed",
        data={"payment_id": payment_id, "amount": payment.amount, "user_id": payment.user_id}
    )

    return {"status": "completed", "payment_id": payment_id}

# notification-service에서 이벤트 구독
async def on_payment_completed(message: aio_pika.IncomingMessage):
    async with message.process():
        data = json.loads(message.body)
        await send_payment_receipt_email(
            user_id=data["data"]["user_id"],
            amount=data["data"]["amount"]
        )

코드벤터 경험상, 전체 서비스 간 통신의 70-80%는 비동기로 처리해도 충분합니다. 사용자가 “결제했어요” 버튼을 눌렀을 때 영수증 이메일이 0.1초 후에 오든 2초 후에 오든 크게 체감하지 못하거든요.

4. 데이터 관리 전략 — 서비스별 DB 분리의 현실

마이크로서비스의 황금률 중 하나는 “서비스마다 자체 DB를 가져야 한다”는 것입니다. 하지만 현실적으로 소규모 스타트업에서 처음부터 PostgreSQL 인스턴스를 5개 운영하는 건 비용적으로, 운영적으로 부담스럽습니다.

실용적인 접근법은 점진적 분리입니다:

  1. 1단계 (MVP~초기): 같은 PostgreSQL 서버, 스키마(schema)로 분리 — user_schema, payment_schema, notification_schema
  2. 2단계 (성장기): 같은 서버, 다른 데이터베이스로 분리 — db_users, db_payments, db_notifications
  3. 3단계 (확장기): 완전히 분리된 DB 서버 — RDS 인스턴스 분리, 읽기 복제본 추가
-- 1단계: PostgreSQL 스키마로 논리적 분리
-- 스키마 생성
CREATE SCHEMA IF NOT EXISTS users;
CREATE SCHEMA IF NOT EXISTS payments;
CREATE SCHEMA IF NOT EXISTS notifications;

-- 각 스키마에 테이블 생성
CREATE TABLE users.profiles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE payments.transactions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,  -- users.profiles를 FK로 참조하지 않음!
    amount DECIMAL(10,2) NOT NULL,
    status VARCHAR(50) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- 중요: 스키마 간 외래키 제약은 두지 않는다
-- 서비스 경계를 DB 레벨에서도 명확히 유지

가장 중요한 원칙: 서비스 경계를 넘는 조인(JOIN)은 절대 하지 않습니다. 다른 서비스의 데이터가 필요하면 반드시 API 호출을 통해 가져와야 합니다. 이 규칙 하나가 나중에 서비스를 실제로 분리할 때의 비용을 엄청나게 줄여줍니다.

개발팀 협업 마이크로서비스
마이크로서비스는 팀이 커질수록 빛을 발합니다. 각 팀이 독립적으로 개발하고 배포할 수 있으니까요

5. 쿠버네티스 vs Docker Compose — 스타트업에 맞는 오케스트레이션

“마이크로서비스 = 쿠버네티스”라고 생각하시는 분이 많은데, 이건 오해입니다. 쿠버네티스는 강력하지만 운영 복잡도가 극도로 높습니다. 팀 규모가 작은 스타트업에서는 오히려 독이 될 수 있어요.

코드벤터의 권장 단계별 오케스트레이션:

단계팀 규모서비스 수권장 방식
초기1-3명2-4개Docker Compose + 단일 서버
성장3-10명4-8개Docker Swarm 또는 ECS
확장10명+8개+Kubernetes (EKS/GKE)

AWS ECS(Elastic Container Service)는 쿠버네티스보다 학습 곡선이 낮으면서도 충분한 오케스트레이션 기능을 제공합니다. 코드벤터가 운영하는 서비스 대부분은 ECS Fargate를 사용해서, 서버 관리 없이 컨테이너를 실행하고 있습니다:

# GitHub Actions - ECS 자동 배포 (각 서비스 독립 배포)
# .github/workflows/deploy-payment-service.yml

name: Deploy Payment Service

on:
  push:
    branches: [main]
    paths:
      - 'services/payments/**'  # 결제 서비스만 변경됐을 때만 배포

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-2

      - name: Build and push to ECR
        run: |
          aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_REGISTRY
          docker build -t payment-service ./services/payments
          docker tag payment-service:latest $ECR_REGISTRY/payment-service:$GITHUB_SHA
          docker push $ECR_REGISTRY/payment-service:$GITHUB_SHA

      - name: Update ECS service
        run: |
          aws ecs update-service \
            --cluster production \
            --service payment-service \
            --force-new-deployment

핵심은 결제 서비스 코드만 바뀌면 결제 서비스만 배포된다는 것입니다. 다른 서비스는 영향받지 않아요. 이것 하나만으로도 배포의 자신감이 완전히 달라집니다.

6. 마이크로서비스 전환, 언제 시작해야 할까?

솔직히 말씀드리면, 대부분의 스타트업은 마이크로서비스를 너무 일찍 도입하려 합니다. Martin Fowler도 “모놀리스로 시작해라. 복잡도가 정당화될 때 분리해라”라고 말합니다.

마이크로서비스 전환을 고려해야 할 신호:

  • ✅ 개발팀이 5명 이상이고 팀 간 코드 충돌이 잦다
  • ✅ 특정 기능(예: 검색, 추천)의 부하가 다른 기능과 극단적으로 차이난다
  • ✅ 배포가 주 1회 미만으로 줄었다 (너무 무서워서)
  • ✅ 특정 기능의 장애가 전체 서비스 다운으로 이어진다
  • ✅ 기술 스택을 부분적으로 바꾸고 싶은데 전체를 바꿔야 한다

반대로 아직 모놀리식을 유지해야 할 신호:

  • ❌ 팀이 3명 이하다
  • ❌ 서비스 도메인 경계가 아직 불명확하다 (기획이 자주 바뀐다)
  • ❌ 일일 활성 사용자(DAU)가 1만 명 이하다
  • ❌ 네트워크 레이턴시, 분산 트랜잭션 처리에 익숙하지 않다

코드벤터의 실무 경험상, 잘 설계된 모놀리식은 수십만 DAU도 거뜬히 처리합니다. 인스타그램도 수억 명의 사용자를 처음에는 Django 모놀리식으로 운영했고, Shopify도 오랫동안 Rails 모놀리식을 유지했습니다.


실전 체크리스트 — 마이크로서비스 도입 전 준비사항

막상 마이크로서비스를 도입하려 할 때 빠지기 쉬운 함정들이 있습니다. 코드벤터가 직접 겪고 정리한 체크리스트입니다:

🏗️ 아키텍처 설계

  • ☐ 서비스 경계(Bounded Context)를 도메인 기준으로 명확히 정의했는가?
  • ☐ API Gateway를 통한 단일 진입점을 설계했는가?
  • ☐ 서비스 간 통신 방식(동기/비동기)을 결정했는가?
  • ☐ 메시지 브로커(RabbitMQ, Kafka, Redis)를 선택했는가?
  • ☐ Circuit Breaker 패턴을 적용해 장애 전파를 막을 계획이 있는가?

💾 데이터 전략

  • ☐ 서비스별 데이터 소유권을 명확히 했는가?
  • ☐ 서비스 경계를 넘는 조인(JOIN)을 하지 않기로 합의했는가?
  • ☐ 분산 트랜잭션 처리 방법(Saga 패턴 등)을 고려했는가?
  • ☐ 데이터 일관성(Eventual Consistency)에 대한 팀 이해가 있는가?

🚀 배포 및 운영

  • ☐ CI/CD 파이프라인이 서비스별로 독립 구성되어 있는가?
  • ☐ 컨테이너화(Docker)가 완료되었는가?
  • ☐ 서비스 디스커버리 방식을 결정했는가?
  • ☐ 중앙화된 로그 수집(ELK Stack, CloudWatch)이 설정되어 있는가?
  • ☐ 분산 추적(Distributed Tracing)을 위해 Jaeger나 Zipkin을 고려했는가?

🔒 보안

  • ☐ 서비스 간 통신에 mTLS 또는 서비스 계정 토큰을 적용했는가?
  • ☐ 비밀키(Secret)를 환경변수가 아닌 Secret Manager로 관리하는가?
  • ☐ 각 서비스의 최소 권한 원칙(Least Privilege)을 적용했는가?

마무리 — 확장성은 설계에서 시작됩니다

마이크로서비스 아키텍처는 확장성 문제를 해결하는 강력한 도구이지만, 동시에 엄청난 복잡도를 수반합니다. 분산 시스템의 어려움(네트워크 실패, 데이터 일관성, 서비스 간 버전 관리)을 감수할 준비가 되어 있을 때만 도입하세요.

코드벤터의 조언은 이렇습니다:

  1. 모놀리식으로 빠르게 시작한다 — PMF(Product-Market Fit) 검증이 먼저다
  2. 도메인 경계를 코드 레벨에서 먼저 분리한다 — 모듈화된 모놀리식(Modular Monolith)
  3. 고통이 실제로 느껴질 때 분리한다 — 배포 고통, 팀 병목, 특정 기능 스케일링 필요
  4. 한 번에 하나씩 분리한다 — 가장 독립적인 서비스(알림, 파일 업로드)부터
  5. 관찰 가능성(Observability)을 먼저 갖춘다 — 로그, 메트릭, 트레이싱 없이 분산 시스템은 블랙박스다

여러분의 서비스가 마이크로서비스 전환이 필요한 시점인지, 혹은 모놀리식을 어떻게 최적화할지 고민이 있으시다면 코드벤터와 상담해보세요. 15년간의 실무 경험을 바탕으로 여러분의 상황에 맞는 최적의 아키텍처를 함께 설계해 드립니다.

코드픽 - 외주 전문 AI 바이브 코딩 글로벌 진출

댓글 남기기