이 글에서 얻는 것

  • “모놀리스 vs 마이크로서비스"를 이분법으로 보지 않고, 모듈러 모놀리스(모듈화) → 점진 분리의 현실적인 경로를 설계할 수 있습니다.
  • 경계(도메인/데이터/트랜잭션)를 먼저 잡아 “분리했더니 더 어려워지는” 함정을 피할 수 있습니다.
  • Strangler(점진 대체)로 트래픽을 안전하게 옮기고, 롤백 가능한 전환을 만드는 방법을 이해합니다.
  • 실무 코드와 의사결정 프레임워크, 운영 체크리스트를 갖추게 됩니다.

0) “나누기"의 목표를 먼저 정하라

서비스를 나누는 이유가 불명확하면, 분리 후 운영 복잡도만 늘어납니다.

분리 목적 매트릭스

목적모듈러 모놀리스로 해결?서비스 분리 필요?판단 기준
배포 독립성△ (모듈별 feature flag)모듈별 배포 주기가 주 3회 이상 다를 때
수평 확장△ (특정 모듈만 스케일 불가)특정 도메인 RPS가 전체의 80%+
변경 충돌 감소✅ 패키지/의존성 규칙팀 4개 이상이 같은 코드베이스
장애 격리△ (프로세스 공유)결제/핵심 도메인 blast radius 축소
기술 다양성ML(Python)과 API(Java) 공존

의사결정 플로우차트:

목표가 명확한가?
  ├─ NO → 모놀리스 유지. 목표부터 정해라.
  └─ YES
      └─ 모듈러 모놀리스로 충분한가?
          ├─ YES → 1단계: 모듈화
          └─ NO  → 2단계: 데이터 분리 → 3단계: Strangler 분리

1) 3가지 선택지 비교표

관점모놀리스모듈러 모놀리스마이크로서비스
배포 단위하나하나(내부 경계 분리)도메인별 독립
개발 속도(초기)⭐⭐⭐⭐⭐⭐⭐⭐
개발 속도(팀 10+명)⭐⭐⭐⭐⭐⭐
운영 복잡도낮음낮음높음
장애 격리
독립 확장
데이터 일관성단일 TX단일 TX최종 일관성
적합 시점초기~중기중기~성장대규모·멀티팀

대부분의 팀은 모듈러 모놀리스에서 큰 가치를 얻고, 필요할 때만 서비스 분리로 넘어갑니다.


2) 0단계: 경계를 잡는다 — 도메인/데이터/트랜잭션

분리는 “코드"보다 “경계"가 먼저입니다. 경계를 잘못 잡으면 분리 후에도 서로의 DB를 찌르고 호출이 난무합니다.

2-1) 도메인 경계 식별 — 이벤트 스토밍 기반

[이벤트 스토밍 워크숍 결과]

주문 Bounded Context:        결제 Bounded Context:
┌──────────────────┐         ┌──────────────────┐
│ OrderCreated     │────────→│ PaymentRequested  │
│ OrderCancelled   │         │ PaymentCompleted  │
│ OrderShipped     │         │ PaymentFailed     │
└──────────────────┘         └──────────────────┘
배송 Bounded Context:
┌──────────────────┐
│ ShipmentCreated  │
│ ShipmentDelivered│
└──────────────────┘

2-2) 데이터 소유권 매핑(필수)

분리 전에 테이블 소유권 표를 반드시 만드세요:

테이블현재 접근 모듈소유권 후보조인 의존
orders주문, 결제, 배송주문users, products
payments결제, 주문결제orders
shipments배송, 주문배송orders
users전체계정없음
products주문, 검색상품categories

핵심: 3개 이상의 모듈이 직접 접근하는 테이블은 분리 난이도가 높으므로 후순위로 미룹니다.

2-3) 트랜잭션 경계 분석

// ❌ 분리 불가한 강결합 트랜잭션 (한 TX에 3개 도메인)
@Transactional
public void placeOrder(OrderRequest req) {
    Order order = orderRepository.save(toOrder(req));
    Payment payment = paymentRepository.save(toPayment(order));
    Inventory inventory = inventoryRepository.decrementStock(req.getProductId(), req.getQty());
    // → 세 도메인이 한 트랜잭션에 묶여 있음
}
// ✅ 분리 가능한 설계: 이벤트 기반 느슨한 결합
@Transactional
public void placeOrder(OrderRequest req) {
    Order order = orderRepository.save(toOrder(req));
    // 주문 컨텍스트 내에서만 트랜잭션
    eventPublisher.publish(new OrderCreatedEvent(order.getId(), req.getProductId(), req.getQty()));
}

// 결제 모듈 — 별도 트랜잭션
@EventListener
@Transactional
public void onOrderCreated(OrderCreatedEvent event) {
    paymentService.requestPayment(event.getOrderId());
}

// 재고 모듈 — 별도 트랜잭션
@EventListener
@Transactional
public void onOrderCreated(OrderCreatedEvent event) {
    inventoryService.reserve(event.getProductId(), event.getQty());
}

3) 1단계: 모듈러 모놀리스로 ‘먼저’ 정리

3-1) 패키지 구조 — 기능 기준 분리

com.example.shop/
├── order/                    # 주문 모듈
│   ├── api/                  # 외부 노출 (Controller)
│   ├── application/          # UseCase/Service
│   ├── domain/               # Entity, VO, Repository(interface)
│   ├── infrastructure/       # JPA impl, 외부 API client
│   └── OrderModuleApi.java   # 다른 모듈이 호출하는 공개 인터페이스
├── payment/                  # 결제 모듈
│   ├── api/
│   ├── application/
│   ├── domain/
│   ├── infrastructure/
│   └── PaymentModuleApi.java
├── shipping/                 # 배송 모듈
└── shared/                   # 공통 (최소화!)
    ├── event/                # 도메인 이벤트 정의
    └── vo/                   # Money, Address 등 공용 VO

3-2) 모듈 간 의존성 규칙 (ArchUnit 강제)

import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

public class ModuleBoundaryTest {

    @Test
    void 주문모듈은_결제_infrastructure에_직접_접근하지_않는다() {
        ArchRule rule = noClasses()
            .that().resideInAPackage("..order..")
            .should().accessClassesThat()
            .resideInAPackage("..payment.infrastructure..");

        rule.check(new ClassFileImporter()
            .importPackages("com.example.shop"));
    }

    @Test
    void 모듈간_통신은_ModuleApi를_통해서만_한다() {
        ArchRule rule = noClasses()
            .that().resideInAPackage("..order.application..")
            .should().accessClassesThat()
            .resideInAPackage("..payment.application..");

        rule.check(new ClassFileImporter()
            .importPackages("com.example.shop"));
    }
}

3-3) 모듈 공개 API (Facade) 패턴

// payment/PaymentModuleApi.java — 결제 모듈의 유일한 공개 인터페이스
public interface PaymentModuleApi {
    PaymentResult requestPayment(PaymentCommand cmd);
    PaymentStatus getStatus(Long paymentId);
    void cancelPayment(Long paymentId);
}

// payment/application/PaymentModuleApiImpl.java
@Service
class PaymentModuleApiImpl implements PaymentModuleApi {
    private final PaymentService paymentService;

    @Override
    public PaymentResult requestPayment(PaymentCommand cmd) {
        return paymentService.process(cmd);
    }

    @Override
    public PaymentStatus getStatus(Long paymentId) {
        return paymentService.findStatus(paymentId);
    }

    @Override
    public void cancelPayment(Long paymentId) {
        paymentService.cancel(paymentId);
    }
}

규칙: 다른 모듈이 결제 기능을 쓸 때는 반드시 PaymentModuleApi만 호출. 내부 Service, Repository에 직접 접근 금지.


4) 2단계: 데이터 분리 전략

4-1) 분리 전략 비교표

전략복잡도결합도적합 시점
공유 DB + 스키마 분리낮음높음(JOIN 가능)초기 전환기
논리적 분리(뷰/동의어)중간중간읽기 모델만 공유
DB 물리 분리높음낮음목표 상태
CDC/이벤트 동기화높음낮음파생 데이터(검색/통계)

4-2) 점진적 데이터 분리 로드맵

Phase 1: 스키마 분리 (1~2주)
─────────────────────────────
같은 DB 인스턴스, 스키마를 모듈별로 분리
  - order_schema.orders
  - payment_schema.payments
Cross-schema JOIN은 허용하되 목록으로 관리


Phase 2: 읽기 경로 분리 (2~4주)
─────────────────────────────
다른 모듈 데이터가 필요하면 API 호출 or 이벤트 구독
  - 주문 → 결제 상태: PaymentModuleApi.getStatus()
  - 검색 → 상품 데이터: CDC로 Elasticsearch 동기화


Phase 3: DB 물리 분리 (4~8주)
─────────────────────────────
모듈별 독립 DB 인스턴스
  - order-db, payment-db, shipping-db
  - Cross-schema JOIN 0건 달성 후 분리

4-3) 분산 트랜잭션 대안: Saga 패턴

// Choreography Saga — 이벤트 기반 (소규모·단순 흐름)
@Component
public class OrderSaga {

    @EventListener
    public void onOrderCreated(OrderCreatedEvent e) {
        // 결제 요청 이벤트 발행
        eventPublisher.publish(new PaymentRequestedEvent(e.getOrderId(), e.getAmount()));
    }

    @EventListener
    public void onPaymentCompleted(PaymentCompletedEvent e) {
        // 재고 차감 이벤트 발행
        eventPublisher.publish(new InventoryReserveEvent(e.getOrderId()));
    }

    @EventListener
    public void onPaymentFailed(PaymentFailedEvent e) {
        // 보상 트랜잭션: 주문 취소
        orderService.cancelOrder(e.getOrderId());
    }

    @EventListener
    public void onInventoryFailed(InventoryReserveFailedEvent e) {
        // 보상 트랜잭션: 결제 취소 → 주문 취소
        paymentService.refund(e.getOrderId());
        orderService.cancelOrder(e.getOrderId());
    }
}
// Orchestration Saga — 중앙 조정자 (복잡한 흐름)
@Service
public class OrderOrchestrator {

    public OrderResult placeOrder(OrderCommand cmd) {
        // 1. 주문 생성
        Order order = orderService.create(cmd);

        // 2. 결제 시도
        PaymentResult payment = paymentClient.requestPayment(order);
        if (payment.isFailed()) {
            orderService.cancel(order.getId());
            return OrderResult.failed("결제 실패");
        }

        // 3. 재고 차감 시도
        InventoryResult inventory = inventoryClient.reserve(order);
        if (inventory.isFailed()) {
            paymentClient.refund(order.getId());   // 보상
            orderService.cancel(order.getId());     // 보상
            return OrderResult.failed("재고 부족");
        }

        // 4. 성공
        orderService.confirm(order.getId());
        return OrderResult.success(order);
    }
}
비교ChoreographyOrchestration
구현 복잡도낮음높음
흐름 가시성낮음(이벤트 추적 어려움)높음(조정자에 로직 집중)
결합도낮음중간(조정자가 모든 서비스 알아야 함)
적합단계 3개 이하 단순 흐름단계 4개+ 복잡한 보상 로직

5) 3단계: Strangler로 점진 분리

5-1) Strangler 전환 3단계 구현

┌─────────────────────────────────────────┐
│              API Gateway                │
│  (Spring Cloud Gateway / Nginx)         │
│                                         │
│  /api/orders/** ───┐                    │
│  /api/payments/** ─┼──→ 라우팅 결정     │
│  /api/shipping/** ─┘                    │
└───────────┬──────────────┬──────────────┘
            │              │
            ▼              ▼
     ┌──────────┐   ┌──────────┐
     │ 모놀리스  │   │ 새 서비스 │
     │ (Legacy) │   │ (결제)   │
     └──────────┘   └──────────┘

Spring Cloud Gateway 설정 예시:

spring:
  cloud:
    gateway:
      routes:
        # Phase 1: Shadow (미러링) — 결과 비교만, 응답은 모놀리스
        - id: payment-shadow
          uri: http://payment-service:8080
          predicates:
            - Path=/api/payments/**
            - Header=X-Shadow, true
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20

        # Phase 2: Canary — 10% 트래픽만 새 서비스
        - id: payment-canary
          uri: http://payment-service:8080
          predicates:
            - Path=/api/payments/**
            - Weight=payment-group, 10
          metadata:
            response-timeout: 3000

        # Phase 3: Cutover — 100% 새 서비스
        - id: payment-new
          uri: http://payment-service:8080
          predicates:
            - Path=/api/payments/**

5-2) Shadow(미러링) 검증 코드

@Component
public class ShadowVerifier {

    private final MeterRegistry meterRegistry;
    private final Counter matchCounter;
    private final Counter mismatchCounter;

    public ShadowVerifier(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.matchCounter = Counter.builder("shadow.result")
            .tag("outcome", "match").register(meterRegistry);
        this.mismatchCounter = Counter.builder("shadow.result")
            .tag("outcome", "mismatch").register(meterRegistry);
    }

    public void verify(String endpoint, Object legacyResponse, Object newResponse) {
        if (Objects.equals(legacyResponse, newResponse)) {
            matchCounter.increment();
        } else {
            mismatchCounter.increment();
            log.warn("[Shadow Mismatch] endpoint={}, legacy={}, new={}",
                endpoint,
                truncate(legacyResponse),
                truncate(newResponse));
        }
    }
}

5-3) Canary 전환 기준 & 롤백 조건

단계트래픽 비율기간진행 조건즉시 롤백 조건
Shadow0% (미러링)1주불일치율 < 0.1%불일치율 > 5%
Canary 10%10%3일에러율 < 0.5%, P99 < 기존 1.2x에러율 > 2% or P99 > 기존 2x
Canary 50%50%3일동일 기준동일 기준
Cutover100%-7일 안정동일 기준
Legacy 제거-2주 유예Cutover 후 2주 무사고-

롤백 자동화 (Prometheus Alert → Gateway 설정 변경):

# Prometheus alerting rule
groups:
  - name: strangler-canary
    rules:
      - alert: CanaryErrorRateHigh
        expr: |
          rate(http_server_requests_seconds_count{service="payment-new", status=~"5.."}[5m])
          / rate(http_server_requests_seconds_count{service="payment-new"}[5m])
          > 0.02
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Canary 에러율 2% 초과 — 자동 롤백 트리거"

6) Anti-Corruption Layer (ACL)

새 서비스와 레거시 사이에 변환 계층을 두어 모델이 오염되지 않게 합니다:

// 새 결제 서비스에서 레거시 주문 데이터를 받을 때
@Component
public class LegacyOrderAntiCorruptionLayer {

    private final LegacyOrderClient legacyClient;

    /**
     * 레거시 주문 모델 → 새 결제 컨텍스트의 PaymentOrder로 변환
     * 레거시의 nullable 필드, 다른 이름, 다른 단위를 여기서 정리
     */
    public PaymentOrder toPaymentOrder(Long orderId) {
        LegacyOrderDto legacy = legacyClient.getOrder(orderId);

        return PaymentOrder.builder()
            .orderId(legacy.getId())
            .amount(Money.won(legacy.getTotalPrice()))  // 레거시: int → Money VO
            .customerEmail(Objects.requireNonNullElse(
                legacy.getEmail(), "unknown@example.com"))
            .currency("KRW")  // 레거시에는 통화 필드 없음 — 기본값
            .build();
    }
}

7) 흔한 함정과 대응

함정증상대응
분산 모놀리스서비스 분리했는데 동기 호출이 20+개이벤트 기반으로 전환, 비동기 우선
공유 라이브러리 지옥공통 모듈 버전 업데이트 시 전체 배포공통은 VO/이벤트 정의만, 로직 금지
데이터 조인 필요분리 후 JOIN 없어 성능 저하CQRS 읽기 모델 or 데이터 복제
장애 전파서비스 A → B → C 동기 호출 연쇄Circuit Breaker + Fallback + Timeout
테스트 어려움통합 테스트가 전체 서비스 필요Contract Testing (Pact/Spring Cloud Contract)

Contract Testing 예시 (Consumer-Driven)

// 소비자(주문 서비스)가 결제 API에 기대하는 계약
@Pact(consumer = "order-service", provider = "payment-service")
public RequestResponsePact createPaymentPact(PactDslWithProvider builder) {
    return builder
        .given("결제 가능한 주문이 존재")
        .uponReceiving("결제 요청")
            .method("POST")
            .path("/api/payments")
            .body(new PactDslJsonBody()
                .integerType("orderId", 1001)
                .decimalType("amount", 50000.0))
        .willRespondWith()
            .status(200)
            .body(new PactDslJsonBody()
                .stringType("paymentId")
                .stringValue("status", "COMPLETED"))
        .toPact();
}

8) 분리 준비도 체크리스트

Phase 0: 분리 판별 (1~2일)

  • 분리 목적(배포 독립/확장/장애 격리)이 문서화되었는가?
  • 모듈러 모놀리스로 충분하지 않은 근거가 있는가?
  • 현재 팀/조직 구조가 서비스 운영을 감당할 수 있는가?

Phase 1: 모듈러 모놀리스 정리 (2~4주)

  • 도메인별 패키지 분리 완료
  • ArchUnit 의존성 규칙 테스트 추가
  • 모듈 간 통신은 ModuleApi/Facade를 통해서만
  • 이벤트 스토밍 결과 → Bounded Context 매핑 문서화

Phase 2: 데이터 분리 (4~8주)

  • 테이블 소유권 표 작성
  • Cross-schema JOIN 목록 및 제거 계획
  • Saga/보상 트랜잭션 설계
  • CDC/이벤트 동기화 파이프라인 구축 (필요 시)

Phase 3: Strangler 전환 (4~8주)

  • API Gateway 라우팅 규칙 설정
  • Shadow 검증 1주 → 불일치율 < 0.1%
  • Canary 10% → 50% → 100% 단계별 전환
  • 롤백 자동화 알림/스크립트 준비
  • Legacy 코드 제거 일정 합의 (Cutover 후 2주)

운영 준비

  • 분산 트레이싱 (Zipkin/Jaeger) 구축
  • Circuit Breaker (Resilience4j) 설정
  • 서비스별 독립 모니터링 대시보드
  • Contract Test CI 파이프라인
  • Runbook: 장애 시 롤백 절차 문서화

연습(추천)

  1. 소유권 표 만들기: 현재 프로젝트의 테이블/컬렉션을 나열하고, 어떤 모듈이 접근하는지 표로 정리. 소유권이 불명확한 테이블이 몇 개인지 세보기.
  2. ArchUnit 적용: 기존 프로젝트에 모듈 경계 규칙을 추가하고, 위반 건수를 트래킹. 매주 줄여나가기.
  3. Strangler 시나리오 작성: 가장 독립적인 도메인 1개를 골라 Shadow → Canary → Cutover 전환 계획서를 작성. 롤백 조건과 모니터링 지표 포함.
  4. Saga 보상 설계: 주문→결제→재고 흐름에서 각 단계 실패 시 보상 트랜잭션을 그려보기. Choreography/Orchestration 중 어느 것이 적합한지 판단.

관련 심화 학습