Skip to content
Published on

Strangler Fig 패턴 완벽 가이드: 모놀리스에서 마이크로서비스로의 무중단 마이그레이션

Authors
  • Name
    Twitter
Strangler Fig Pattern

들어가며

모놀리식 애플리케이션을 마이크로서비스로 전환하는 것은 현대 소프트웨어 엔지니어링에서 가장 빈번하게 마주치는 과제 중 하나다. 수년간 쌓인 비즈니스 로직, 복잡하게 얽힌 데이터 모델, 그리고 24/7 가용성에 대한 SLA 요구사항이 동시에 존재하는 환경에서 "어떻게 서비스 중단 없이 아키텍처를 전환할 것인가"라는 질문에 답해야 한다.

2004년 Martin Fowler는 호주 여행에서 교살무화과나무(Strangler Fig)를 관찰한 뒤, 이 자연 현상에서 영감을 받아 소프트웨어 마이그레이션 패턴을 제안했다. 교살무화과나무는 숙주 나무의 상단에서 씨앗이 발아하여 점차 아래로 뿌리를 내리며 숙주를 감싸고, 최종적으로 숙주를 대체한다. Strangler Fig 패턴은 이와 동일한 원리로, 기존 모놀리스 주변에 새로운 마이크로서비스를 점진적으로 구축하여 기존 시스템을 단계적으로 대체한다.

Netflix, Google, Amazon, Microsoft 등 대규모 기업들이 이 패턴을 채택하여 레거시 시스템 마이그레이션에 성공했다. 그러나 패턴의 개념이 단순한 것과 달리, 실제 프로덕션 환경에서의 적용은 Facade 설계, Anti-Corruption Layer, 데이터 동기화, Feature Flag 관리, 롤백 전략 등 수많은 세부 결정을 수반한다.

이 글에서는 Strangler Fig 패턴의 핵심 원리부터 아키텍처 설계, Facade 및 라우팅 구현, Feature Flag 통합, 데이터 마이그레이션 전략, 실패 사례와 복구 방안, 그리고 프로덕션 운영을 위한 체크리스트까지 종합적으로 다룬다.

Strangler Fig 패턴 핵심 개념

패턴의 원리

Strangler Fig 패턴은 세 가지 핵심 단계로 구성된다.

  1. 변환(Transform): 모놀리스에서 특정 기능을 식별하고, 해당 기능을 새로운 마이크로서비스로 재구현한다.
  2. 공존(Coexist): 기존 모놀리스와 새로운 마이크로서비스가 동시에 운영되며, Facade 계층이 트래픽을 적절히 라우팅한다.
  3. 제거(Eliminate): 새로운 서비스가 안정화되면 모놀리스에서 해당 기능을 제거하고, 트래픽을 완전히 새 서비스로 전환한다.

이 과정을 반복하여 모놀리스의 기능을 하나씩 마이크로서비스로 이전하면, 최종적으로 모놀리스가 사라지고 마이크로서비스 아키텍처만 남게 된다.

Phase 1: 마이그레이션 시작
┌──────────────┐     ┌──────────────┐
Facade     │────▶│  Monolith  (Proxy)     │     │  ┌────────┐  │
│              │     │  │Service │  │
│              │     │  │   A    │  │
│              │     │  ├────────┤  │
│              │     │  │Service │  │
│              │     │  │   B    │  │
│              │     │  ├────────┤  │
│              │     │  │Service │  │
│              │     │  │   C    │  │
│              │     │  └────────┘  │
└──────────────┘     └──────────────┘

Phase 2: 점진적 추출
┌──────────────┐     ┌──────────────┐
Facade     │────▶│  Monolith  (Proxy)     │     │  ┌────────┐  │
│              │     │  │Service │  │
│              │─┐   │  │   B    │  │
│              │ │   │  ├────────┤  │
│              │ │   │  │Service │  │
│              │ │   │  │   C    │  │
└──────────────┘ │   └──────────────┘
                 │   ┌──────────────┐
                 └──▶│ MicroserviceA                     └──────────────┘

Phase 3: 완전 전환
┌──────────────┐     ┌──────────────┐
Facade     │────▶│ Microservice  (Proxy)     │     │     A│              │     └──────────────┘
│              │     ┌──────────────┐
│              │────▶│ Microservice│              │     │     B│              │     └──────────────┘
│              │     ┌──────────────┐
│              │────▶│ Microservice│              │     │     C└──────────────┘     └──────────────┘

Facade 계층의 역할

Facade(또는 Proxy)는 Strangler Fig 패턴의 핵심 인프라 컴포넌트다. 모든 클라이언트 요청을 수신하여 레거시 모놀리스 또는 새로운 마이크로서비스로 투명하게 라우팅한다. 클라이언트 관점에서는 동일한 엔드포인트로 요청을 보내므로, 백엔드 아키텍처 변경을 인지하지 못한다.

Facade가 담당하는 주요 기능은 다음과 같다.

  • 요청 라우팅: URL 경로, HTTP 헤더, 쿼리 파라미터 등을 기반으로 요청을 레거시 또는 신규 서비스로 분배
  • 프로토콜 변환: 레거시 시스템의 SOAP/XML 인터페이스를 신규 서비스의 REST/JSON으로 변환
  • 트래픽 분배: 카나리 배포를 위한 비율 기반 트래픽 분배
  • 장애 격리: 신규 서비스 장애 시 레거시로 자동 폴백

실무에서는 API Gateway(Kong, AWS API Gateway, NGINX), Service Mesh(Istio, Linkerd), 또는 커스텀 Reverse Proxy가 Facade 역할을 수행한다.

Anti-Corruption Layer (ACL)

Anti-Corruption Layer는 Domain-Driven Design(DDD)에서 유래한 패턴으로, 레거시 시스템의 도메인 모델과 신규 마이크로서비스의 도메인 모델 사이에서 번역(Translation) 역할을 한다. 모놀리스의 레거시 API나 데이터 모델이 신규 서비스의 설계를 오염시키지 않도록 방어벽을 형성한다.

ACL은 다음 세 가지 하위 패턴의 조합으로 구현된다.

  • Facade 패턴: 레거시 시스템의 복잡한 인터페이스를 단순화
  • Adapter 패턴: 레거시 데이터 형식을 신규 도메인 모델로 변환
  • Translator 패턴: 양방향 데이터 매핑 규칙 정의

ACL의 핵심 원칙은 **일시적(Tactical)**이어야 한다는 것이다. 마이그레이션이 완료되면 ACL도 함께 제거되어야 한다. ACL이 영구적인 계층으로 남게 되면 시스템 복잡도가 증가하고 유지보수 비용이 누적된다.

마이그레이션 전략 비교: Strangler Fig vs Big Bang vs Parallel Run

마이그레이션 전략을 선택하기 전에 각 접근 방식의 특성과 위험도를 명확히 이해해야 한다. 아래 표는 세 가지 대표적 전략을 비교한 것이다.

항목Strangler FigBig BangParallel Run
전환 방식기능 단위 점진적 전환전체 시스템 일괄 전환두 시스템 동시 운영 후 전환
다운타임무중단계획된 다운타임 필요무중단
위험도낮음 (기능 단위)매우 높음 (전체 시스템)중간 (데이터 정합성)
롤백 용이성즉시 롤백 (라우팅 변경)매우 어려움 (전체 복원)용이 (트래픽 전환)
소요 기간수개월~수년수주~수개월수개월
비용점진적 증가초기 집중 투자이중 인프라 비용
ROI 실현 시점첫 서비스 배포 직후전체 전환 완료 후검증 완료 후
적합 환경대규모 레거시 시스템소규모 단순 시스템금융/의료 등 고신뢰 요구
팀 부담분산 (기능별 병렬 진행)집중 (전체 팀 동시 투입)높음 (이중 운영)
데이터 동기화기능별 개별 전략일괄 마이그레이션실시간 동기화 필수

Big Bang 전략은 전체 시스템을 한 번에 재구축하여 일괄 전환하는 방식으로, 모든 것이 완벽하게 준비되어야만 전환이 가능하다. 하나의 모듈 지연이 전체 Go-Live를 지연시키며, 전환 실패 시 전체 롤백이 필요한 고위험 전략이다.

Parallel Run 전략은 Strangler Fig의 한 단계로 볼 수도 있다. 레거시와 신규 시스템을 동시에 운영하면서 결과를 비교 검증한 뒤 최종 전환하는 방식이다. 금융권이나 의료 시스템처럼 데이터 정확성이 절대적으로 중요한 환경에서 선호된다.

Strangler Fig 전략은 점진적 접근으로 위험을 분산하며, 첫 번째 마이크로서비스가 배포되는 순간부터 ROI가 발생한다. 비즈니스 우선순위에 따라 마이그레이션 순서를 동적으로 조정할 수 있어, 경쟁 상황 변화에 빠르게 대응할 수 있다.

아키텍처 설계와 구현

Facade(프록시) 라우팅 구현

Strangler Fig 패턴의 첫 번째 구현 단계는 Facade 라우팅 계층을 구축하는 것이다. 아래는 TypeScript로 구현한 API Gateway 라우팅 로직이다.

// strangler-fig-router.ts
// Strangler Fig 패턴 - API Gateway 라우팅 핸들러

interface RouteConfig {
  path: string
  target: 'legacy' | 'microservice'
  serviceUrl: string
  migrationStatus: 'not_started' | 'canary' | 'partial' | 'complete'
  canaryPercentage?: number // 신규 서비스로 보낼 트래픽 비율 (0-100)
  fallbackToLegacy: boolean
  healthCheckUrl: string
}

interface ServiceHealth {
  isHealthy: boolean
  lastChecked: Date
  consecutiveFailures: number
}

const routeConfigs: RouteConfig[] = [
  {
    path: '/api/orders/*',
    target: 'microservice',
    serviceUrl: 'http://order-service:8080',
    migrationStatus: 'complete',
    fallbackToLegacy: true,
    healthCheckUrl: 'http://order-service:8080/health',
  },
  {
    path: '/api/products/*',
    target: 'microservice',
    serviceUrl: 'http://product-service:8081',
    migrationStatus: 'canary',
    canaryPercentage: 20,
    fallbackToLegacy: true,
    healthCheckUrl: 'http://product-service:8081/health',
  },
  {
    path: '/api/users/*',
    target: 'legacy',
    serviceUrl: 'http://monolith:3000',
    migrationStatus: 'not_started',
    fallbackToLegacy: false,
    healthCheckUrl: 'http://monolith:3000/health',
  },
]

const LEGACY_BASE_URL = 'http://monolith:3000'
const healthCache = new Map<string, ServiceHealth>()

async function checkServiceHealth(config: RouteConfig): Promise<boolean> {
  const cached = healthCache.get(config.path)
  if (cached && Date.now() - cached.lastChecked.getTime() < 5000) {
    return cached.isHealthy
  }

  try {
    const response = await fetch(config.healthCheckUrl, {
      signal: AbortSignal.timeout(2000),
    })
    const isHealthy = response.ok
    healthCache.set(config.path, {
      isHealthy,
      lastChecked: new Date(),
      consecutiveFailures: isHealthy ? 0 : (cached?.consecutiveFailures ?? 0) + 1,
    })
    return isHealthy
  } catch {
    const failures = (cached?.consecutiveFailures ?? 0) + 1
    healthCache.set(config.path, {
      isHealthy: false,
      lastChecked: new Date(),
      consecutiveFailures: failures,
    })
    return false
  }
}

function shouldRouteToMicroservice(config: RouteConfig, requestId: string): boolean {
  if (config.migrationStatus === 'complete') return true
  if (config.migrationStatus === 'not_started') return false

  if (config.migrationStatus === 'canary' && config.canaryPercentage) {
    // 요청 ID 기반 결정적 라우팅 (동일 사용자는 항상 동일 서비스로)
    const hash = simpleHash(requestId)
    return hash % 100 < config.canaryPercentage
  }

  return false
}

function simpleHash(str: string): number {
  let hash = 0
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i)
    hash = (hash << 5) - hash + char
    hash = hash & hash // 32bit 정수 변환
  }
  return Math.abs(hash)
}

async function routeRequest(path: string, requestId: string, request: Request): Promise<Response> {
  const config = routeConfigs.find((r) => path.startsWith(r.path.replace('/*', '')))

  if (!config) {
    // 매칭되는 라우트 없음 - 레거시로 전달
    return fetch(`${LEGACY_BASE_URL}${path}`, { ...request })
  }

  const useNewService = shouldRouteToMicroservice(config, requestId)

  if (useNewService) {
    const isHealthy = await checkServiceHealth(config)

    if (isHealthy) {
      try {
        const response = await fetch(`${config.serviceUrl}${path}`, { ...request })
        // 성공 메트릭 기록
        recordMetric('route_to_microservice', config.path, 'success')
        return response
      } catch (error) {
        recordMetric('route_to_microservice', config.path, 'error')

        if (config.fallbackToLegacy) {
          console.warn(`Microservice 실패, 레거시 폴백: ${config.path}`)
          recordMetric('fallback_to_legacy', config.path, 'triggered')
          return fetch(`${LEGACY_BASE_URL}${path}`, { ...request })
        }
        throw error
      }
    } else if (config.fallbackToLegacy) {
      recordMetric('fallback_to_legacy', config.path, 'health_check_failed')
      return fetch(`${LEGACY_BASE_URL}${path}`, { ...request })
    }
  }

  return fetch(`${LEGACY_BASE_URL}${path}`, { ...request })
}

function recordMetric(type: string, path: string, status: string): void {
  // Prometheus, Datadog 등 메트릭 시스템으로 전송
  console.log(`[METRIC] ${type} | path=${path} | status=${status} | ts=${Date.now()}`)
}

이 구현에서 핵심은 세 가지다. 첫째, canaryPercentage를 통해 트래픽 비율을 점진적으로 조정할 수 있다. 둘째, 요청 ID 기반의 결정적(deterministic) 라우팅으로 동일 사용자가 항상 동일한 서비스로 라우팅된다. 셋째, fallbackToLegacy 옵션으로 마이크로서비스 장애 시 자동으로 레거시로 폴백한다.

Anti-Corruption Layer 구현

ACL은 레거시 모놀리스와 신규 마이크로서비스 사이의 도메인 모델 불일치를 해결한다. 아래는 Python으로 구현한 주문 서비스의 ACL 예시다.

# anti_corruption_layer.py
# 주문 서비스 Anti-Corruption Layer

from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
import httpx


# === 신규 마이크로서비스 도메인 모델 ===
class OrderStatus(Enum):
    PENDING = "pending"
    CONFIRMED = "confirmed"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"


@dataclass
class OrderItem:
    product_id: str
    product_name: str
    quantity: int
    unit_price: Decimal
    total_price: Decimal


@dataclass
class Order:
    order_id: str
    customer_id: str
    items: list[OrderItem]
    status: OrderStatus
    total_amount: Decimal
    currency: str
    created_at: datetime
    updated_at: datetime


# === ACL: 레거시 모놀리스 데이터 변환 ===
class LegacyOrderACL:
    """
    레거시 모놀리스의 주문 데이터 모델을 신규 마이크로서비스의
    도메인 모델로 변환하는 Anti-Corruption Layer.

    레거시 시스템은 다음과 같은 문제를 가지고 있다:
    - 주문 상태가 숫자 코드 (0, 1, 2, 3, 4)
    - 금액이 정수형 (센트 단위)
    - 날짜가 Unix timestamp
    - 상품 정보가 주문과 같은 테이블에 JSON 문자열로 저장
    """

    # 레거시 숫자 코드 -> 신규 도메인 상태 매핑
    STATUS_MAP: dict[int, OrderStatus] = {
        0: OrderStatus.PENDING,
        1: OrderStatus.CONFIRMED,
        2: OrderStatus.SHIPPED,
        3: OrderStatus.DELIVERED,
        4: OrderStatus.CANCELLED,
    }

    def __init__(self, legacy_api_url: str):
        self.legacy_api_url = legacy_api_url
        self.client = httpx.AsyncClient(
            base_url=legacy_api_url,
            timeout=10.0,
        )

    async def get_order(self, order_id: str) -> Optional[Order]:
        """레거시 API에서 주문을 조회하고 신규 도메인 모델로 변환한다."""
        response = await self.client.get(f"/legacy/orders/{order_id}")
        if response.status_code == 404:
            return None

        legacy_data = response.json()
        return self._translate_order(legacy_data)

    def _translate_order(self, legacy: dict) -> Order:
        """레거시 주문 데이터를 신규 Order 도메인 객체로 변환한다."""
        items = self._translate_items(legacy.get("items_json", "[]"))
        total = Decimal(legacy["total_cents"]) / 100

        return Order(
            order_id=str(legacy["id"]),
            customer_id=str(legacy["cust_id"]),
            items=items,
            status=self.STATUS_MAP.get(legacy["stat_cd"], OrderStatus.PENDING),
            total_amount=total,
            currency=legacy.get("curr", "KRW"),
            created_at=datetime.fromtimestamp(legacy["created_ts"]),
            updated_at=datetime.fromtimestamp(legacy["modified_ts"]),
        )

    def _translate_items(self, items_json: str) -> list[OrderItem]:
        """레거시 JSON 문자열 형태의 상품 목록을 OrderItem 리스트로 변환한다."""
        import json
        raw_items = json.loads(items_json)
        return [
            OrderItem(
                product_id=str(item["pid"]),
                product_name=item.get("pname", "Unknown"),
                quantity=item["qty"],
                unit_price=Decimal(item["price_cents"]) / 100,
                total_price=Decimal(item["price_cents"] * item["qty"]) / 100,
            )
            for item in raw_items
        ]

    def translate_to_legacy(self, order: Order) -> dict:
        """신규 Order를 레거시 형식으로 역변환한다 (레거시 시스템 업데이트 시 사용)."""
        reverse_status = {v: k for k, v in self.STATUS_MAP.items()}
        return {
            "id": int(order.order_id),
            "cust_id": int(order.customer_id),
            "stat_cd": reverse_status.get(order.status, 0),
            "total_cents": int(order.total_amount * 100),
            "curr": order.currency,
            "created_ts": int(order.created_at.timestamp()),
            "modified_ts": int(order.updated_at.timestamp()),
        }

    async def close(self):
        await self.client.aclose()

이 ACL은 레거시 시스템의 숫자 상태 코드, 센트 단위 금액, Unix timestamp 등 기술 부채가 반영된 데이터 모델을 신규 서비스의 깔끔한 도메인 모델로 변환한다. 역방향 변환(translate_to_legacy)도 제공하여, 마이그레이션 과도기에 레거시 시스템을 업데이트해야 하는 상황도 지원한다.

Feature Flag 기반 트래픽 전환

Feature Flag는 코드 배포와 기능 릴리스를 분리하여, 런타임에 트래픽을 동적으로 제어할 수 있게 한다. Strangler Fig 패턴에서 Feature Flag는 마이그레이션 진행 상태를 코드 수준에서 제어하는 핵심 메커니즘이다.

// feature-flag-migration.ts
// Feature Flag 기반 마이그레이션 제어

interface MigrationFlag {
  name: string
  enabled: boolean
  rolloutPercentage: number // 0-100
  allowedUserIds?: string[] // 화이트리스트 (내부 테스터)
  excludedUserIds?: string[] // 블랙리스트 (VIP 고객 등 보수적 전환)
  enabledRegions?: string[] // 지역별 롤아웃
  createdAt: string
  updatedAt: string
  metadata: Record<string, string>
}

class MigrationFeatureFlagService {
  private flags: Map<string, MigrationFlag> = new Map()
  private refreshIntervalMs = 30_000 // 30초마다 갱신

  constructor(private flagSource: string) {
    this.startPeriodicRefresh()
  }

  async loadFlags(): Promise<void> {
    try {
      const response = await fetch(this.flagSource)
      const data: MigrationFlag[] = await response.json()
      this.flags = new Map(data.map((f) => [f.name, f]))
    } catch (error) {
      console.error('Feature flag 로드 실패, 캐시된 값 유지:', error)
      // 로드 실패 시 기존 캐시 유지 - 안전한 기본 동작
    }
  }

  isEnabled(
    flagName: string,
    context: {
      userId: string
      region?: string
      sessionId?: string
    }
  ): boolean {
    const flag = this.flags.get(flagName)
    if (!flag || !flag.enabled) return false

    // 블랙리스트 확인 (최우선 - VIP 고객 보호)
    if (flag.excludedUserIds?.includes(context.userId)) {
      return false
    }

    // 화이트리스트 확인 (내부 테스터 우선 적용)
    if (flag.allowedUserIds?.includes(context.userId)) {
      return true
    }

    // 지역별 제한 확인
    if (flag.enabledRegions && context.region) {
      if (!flag.enabledRegions.includes(context.region)) {
        return false
      }
    }

    // 비율 기반 롤아웃 (사용자 ID 기반 결정적 분배)
    if (flag.rolloutPercentage < 100) {
      const hash = this.consistentHash(context.userId + flagName)
      return hash % 100 < flag.rolloutPercentage
    }

    return true
  }

  private consistentHash(input: string): number {
    let hash = 5381
    for (let i = 0; i < input.length; i++) {
      hash = (hash << 5) + hash + input.charCodeAt(i)
      hash = hash & hash
    }
    return Math.abs(hash)
  }

  private startPeriodicRefresh(): void {
    setInterval(() => this.loadFlags(), this.refreshIntervalMs)
  }
}

// 사용 예시: 주문 서비스 마이그레이션
const flagService = new MigrationFeatureFlagService('http://config-server:8888/flags/migration')

async function processOrder(userId: string, orderData: unknown) {
  const useNewOrderService = flagService.isEnabled('migration.order-service', {
    userId,
    region: 'ap-northeast-2',
  })

  if (useNewOrderService) {
    // 신규 마이크로서비스로 주문 처리
    return await newOrderService.createOrder(orderData)
  } else {
    // 레거시 모놀리스로 주문 처리
    return await legacyMonolith.createOrder(orderData)
  }
}

Feature Flag 설정을 외부 Config Server에서 관리하면, 코드 재배포 없이 트래픽 비율을 조정하거나 즉시 롤백할 수 있다. VIP 고객을 블랙리스트에 등록하여 안정성이 충분히 검증된 후에만 전환하고, 내부 테스터를 화이트리스트에 등록하여 우선 검증하는 전략이 실무에서 효과적이다.

데이터 마이그레이션과 동기화

데이터 마이그레이션은 Strangler Fig 패턴 적용에서 가장 복잡한 영역이다. 모놀리스와 마이크로서비스가 공존하는 기간 동안 데이터 정합성을 보장해야 하며, 이를 위한 전략은 크게 세 가지로 나뉜다.

전략 1: Shared Database (과도기)

가장 단순한 방식으로, 마이그레이션 초기 단계에서 모놀리스와 마이크로서비스가 동일 데이터베이스를 공유한다. 구현이 간단하지만 데이터베이스 스키마 변경에 양쪽 모두 영향을 받으며, 마이크로서비스의 독립성을 훼손한다. 단기적 전술적 선택으로만 사용해야 한다.

전략 2: Dual Write (위험)

두 시스템에 동시에 쓰기를 수행하는 방식이다. 한쪽 쓰기 성공, 다른 쪽 실패 시 데이터 불일치가 발생하는 근본적 문제가 있어 안티패턴으로 분류된다. 분산 트랜잭션을 도입하더라도 성능 저하와 복잡도 증가를 피할 수 없다.

전략 3: Change Data Capture (권장)

CDC(Change Data Capture)는 데이터베이스의 변경 로그를 실시간으로 캡처하여 다른 시스템으로 전파하는 방식이다. Debezium, AWS DMS, Oracle GoldenGate 같은 도구를 활용한다.

# docker-compose.yml - Debezium CDC 구성 예시
version: '3.8'
services:
  # 레거시 모놀리스 DB (MySQL)
  legacy-db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: legacy_password
      MYSQL_DATABASE: monolith
    ports:
      - '3306:3306'
    volumes:
      - legacy-data:/var/lib/mysql
    command: >
      --server-id=1
      --log-bin=mysql-bin
      --binlog-format=ROW
      --binlog-row-image=FULL
      --gtid-mode=ON
      --enforce-gtid-consistency=ON

  # Kafka - CDC 이벤트 스트리밍
  kafka:
    image: confluentinc/cp-kafka:7.6.0
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
    ports:
      - '9092:9092'
    depends_on:
      - zookeeper

  # Debezium Connect - CDC 커넥터
  debezium:
    image: debezium/connect:2.5
    environment:
      BOOTSTRAP_SERVERS: kafka:9092
      GROUP_ID: strangler-fig-cdc
      CONFIG_STORAGE_TOPIC: cdc_configs
      OFFSET_STORAGE_TOPIC: cdc_offsets
      STATUS_STORAGE_TOPIC: cdc_status
    ports:
      - '8083:8083'
    depends_on:
      - kafka
      - legacy-db

  # 신규 마이크로서비스 DB (PostgreSQL)
  order-service-db:
    image: postgres:16
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: order_service
      POSTGRES_PASSWORD: secure_password
    ports:
      - '5432:5432'
    volumes:
      - order-data:/var/lib/postgresql/data

volumes:
  legacy-data:
  order-data:
# cdc_consumer.py
# Debezium CDC 이벤트를 소비하여 마이크로서비스 DB에 동기화

import json
from kafka import KafkaConsumer
from datetime import datetime
from decimal import Decimal


class CDCEventConsumer:
    """
    레거시 DB의 변경사항을 Debezium CDC로 캡처하여
    신규 마이크로서비스 DB에 동기화하는 컨슈머.
    """

    def __init__(self, kafka_servers: str, topic: str, order_repository):
        self.consumer = KafkaConsumer(
            topic,
            bootstrap_servers=kafka_servers,
            group_id='order-service-cdc-sync',
            auto_offset_reset='earliest',
            enable_auto_commit=False,  # 수동 커밋으로 exactly-once 보장
            value_deserializer=lambda m: json.loads(m.decode('utf-8')),
        )
        self.order_repo = order_repository
        self.processed_count = 0
        self.error_count = 0

    def start(self):
        """CDC 이벤트 소비 루프를 시작한다."""
        print(f"CDC Consumer 시작 - 레거시 DB 변경 감지 중...")

        for message in self.consumer:
            try:
                self._process_event(message.value)
                self.consumer.commit()
                self.processed_count += 1

                if self.processed_count % 1000 == 0:
                    print(f"처리 완료: {self.processed_count}건, "
                          f"오류: {self.error_count}건")
            except Exception as e:
                self.error_count += 1
                print(f"CDC 이벤트 처리 실패: {e}")
                # Dead Letter Queue로 전송
                self._send_to_dlq(message.value, str(e))

    def _process_event(self, event: dict):
        """Debezium CDC 이벤트를 파싱하고 적절한 핸들러로 전달한다."""
        operation = event.get('op')  # c=create, u=update, d=delete, r=read(snapshot)
        after = event.get('after')   # 변경 후 데이터
        before = event.get('before') # 변경 전 데이터

        if operation in ('c', 'r'):
            # INSERT 또는 스냅샷
            self._handle_create(after)
        elif operation == 'u':
            # UPDATE
            self._handle_update(before, after)
        elif operation == 'd':
            # DELETE
            self._handle_delete(before)

    def _handle_create(self, data: dict):
        """레거시 DB INSERT를 신규 서비스 DB에 반영한다."""
        order = self._transform_legacy_to_new(data)
        self.order_repo.upsert(order)

    def _handle_update(self, before: dict, after: dict):
        """레거시 DB UPDATE를 신규 서비스 DB에 반영한다."""
        order = self._transform_legacy_to_new(after)
        self.order_repo.upsert(order)

    def _handle_delete(self, data: dict):
        """레거시 DB DELETE를 신규 서비스 DB에 반영한다."""
        order_id = str(data['id'])
        self.order_repo.soft_delete(order_id)

    def _transform_legacy_to_new(self, legacy: dict) -> dict:
        """레거시 데이터 형식을 신규 마이크로서비스 형식으로 변환한다."""
        status_map = {0: 'pending', 1: 'confirmed', 2: 'shipped',
                      3: 'delivered', 4: 'cancelled'}

        return {
            'order_id': str(legacy['id']),
            'customer_id': str(legacy['cust_id']),
            'status': status_map.get(legacy.get('stat_cd', 0), 'pending'),
            'total_amount': Decimal(legacy.get('total_cents', 0)) / 100,
            'currency': legacy.get('curr', 'KRW'),
            'created_at': datetime.fromtimestamp(legacy.get('created_ts', 0)),
            'updated_at': datetime.fromtimestamp(legacy.get('modified_ts', 0)),
            'sync_source': 'cdc_legacy',
            'synced_at': datetime.utcnow(),
        }

    def _send_to_dlq(self, event: dict, error_msg: str):
        """처리 실패한 이벤트를 Dead Letter Queue로 전송한다."""
        # DLQ 토픽으로 전송하여 수동 재처리 가능하도록 보관
        print(f"DLQ 전송: order_id={event.get('after', {}).get('id')}, "
              f"error={error_msg}")

CDC 방식의 핵심 장점은 레거시 애플리케이션 코드를 전혀 수정하지 않고도 데이터 변경 사항을 신규 서비스로 전파할 수 있다는 것이다. 데이터베이스의 WAL(Write-Ahead Log) 또는 binlog를 직접 읽기 때문에 애플리케이션 계층의 개입이 불필요하다.

운영 시 주의사항과 트러블슈팅

Facade가 단일 장애점(SPOF)이 되는 문제

Strangler Fig 패턴에서 Facade/Proxy 계층은 모든 트래픽이 통과하는 관문이므로, 그 자체가 단일 장애점이 될 수 있다. Facade의 장애는 모놀리스와 마이크로서비스 모두에 접근할 수 없게 만든다.

해결 방안:

  • Facade를 다중 인스턴스로 배포하고 로드밸런서를 앞단에 배치한다
  • 라우팅 로직을 최소화하여 Facade가 "또 다른 모놀리스"가 되지 않도록 한다
  • Circuit Breaker를 적용하여 다운스트림 장애가 Facade로 전파되지 않게 한다
  • 서비스 메시(Istio, Linkerd)를 활용하면 Facade 기능을 인프라 계층으로 위임할 수 있다

Facade 자체가 모놀리스화되는 문제

마이그레이션이 진행될수록 Facade에 라우팅 규칙, 프로토콜 변환, 인증/인가, Rate Limiting 등 로직이 누적되어 Facade 자체가 복잡한 모놀리스로 성장하는 경우가 있다. 이 현상을 "Proxy Monolith" 안티패턴이라 한다.

해결 방안:

  • Facade의 책임을 순수 라우팅으로 제한하고, 비즈니스 로직은 마이크로서비스에 위임한다
  • 인증/인가, Rate Limiting 등 횡단 관심사(Cross-cutting Concerns)는 서비스 메시 사이드카로 분리한다
  • Facade의 라우팅 규칙을 외부 설정 파일이나 Config Server에서 관리하여, 코드 변경 없이 라우팅을 수정한다

데이터 정합성 이슈

마이그레이션 과도기에 레거시와 마이크로서비스가 동일 데이터에 대해 서로 다른 값을 가지는 경우가 발생할 수 있다. CDC 지연, 네트워크 파티션, 트랜잭션 경계 불일치 등이 원인이다.

해결 방안:

  • 데이터 검증 파이프라인을 구축하여 주기적으로 양측 데이터를 비교 검증한다
  • Eventual Consistency를 수용하되, 비즈니스 크리티컬 데이터(결제, 재고 등)에는 동기식 확인 절차를 추가한다
  • CDC 컨슈머의 지연(lag)을 모니터링하고, 임계치 초과 시 알림을 발생시킨다

모놀리스가 계속 성장하는 문제

마이그레이션 진행 중에도 비즈니스 요구사항은 계속 발생한다. 새로운 기능을 모놀리스에 추가하면 마이그레이션 범위가 지속적으로 증가하여 영원히 완료되지 않는 상황이 발생한다.

해결 방안:

  • "Freeze" 정책: 신규 기능은 반드시 마이크로서비스로 개발한다는 원칙을 수립한다
  • 마이그레이션 대상 모듈에 대한 코드 변경을 Architecture Decision Record(ADR)로 관리하여 가시성을 확보한다
  • 마이그레이션 진행 상황을 대시보드로 시각화하여 조직 전체가 현황을 공유한다

실패 사례와 복구 전략

실패 사례 1: 잘못된 분해 경계

한 이커머스 기업에서 주문(Order) 서비스를 마이크로서비스로 분리했으나, 주문과 결제(Payment) 간의 강한 결합을 간과했다. 주문 생성 시 결제 처리, 재고 차감, 포인트 적립이 동일 트랜잭션에서 처리되고 있었는데, 주문만 별도 서비스로 분리하면서 분산 트랜잭션 문제가 발생했다.

복구 전략: 주문-결제를 하나의 Bounded Context로 재정의하고, Saga 패턴을 도입하여 분산 트랜잭션을 이벤트 기반 보상 트랜잭션으로 전환했다. 초기 도메인 분석에 DDD의 Event Storming을 적용하여 Bounded Context 경계를 재설정했다.

실패 사례 2: 영구적 Dual Write

금융 서비스 기업에서 계좌 서비스를 마이그레이션하면서, 레거시와 신규 시스템에 동시에 데이터를 쓰는 Dual Write 방식을 "일시적"으로 도입했다. 그러나 전환 완료 시점이 계속 지연되면서 Dual Write가 2년 넘게 유지되었고, 그 사이에 양측 데이터 불일치 건수가 수천 건에 달했다.

복구 전략: Dual Write를 즉시 중단하고 CDC 기반 단방향 동기화로 전환했다. 데이터 불일치 건은 일괄 정합성 검증 스크립트로 식별하고, 레거시 데이터를 기준으로 신규 시스템 데이터를 보정했다. 마이그레이션 완료 기한을 명시적으로 설정하고 SLA에 포함시켰다.

실패 사례 3: 불완전한 마이그레이션 상태 영구화

소셜 미디어 플랫폼에서 사용자 프로필 서비스를 3년 전에 마이그레이션을 시작했으나, 핵심 기능 70%만 전환한 뒤 마이그레이션 프로젝트가 사실상 중단되었다. ACL, 호환성 계층, 임시 데이터 동기화 로직이 프로덕션에 영구적으로 남아 유지보수 비용이 두 배가 되었다.

복구 전략: 전체 마이그레이션 범위를 재평가하고, 나머지 30% 중 비즈니스 가치가 낮은 기능은 마이그레이션 대상에서 제외(Deprecate)했다. 남은 핵심 기능의 마이그레이션을 6개월 타임박스로 설정하고 전담팀을 배정했다. 마이그레이션 완료 후 ACL과 호환성 계층을 단계적으로 제거했다.

롤백 전략 설계

모든 마이그레이션 단계에는 즉각적인 롤백 계획이 수반되어야 한다. 효과적인 롤백 전략의 핵심 요소는 다음과 같다.

# rollback-playbook.yaml
# 마이크로서비스 마이그레이션 롤백 플레이북

rollback_triggers:
  - condition: '신규 서비스 에러율 > 1% (5분 지속)'
    severity: warning
    action: '카나리 비율을 이전 단계로 축소'

  - condition: '신규 서비스 P99 레이턴시 > 레거시 대비 200% (10분 지속)'
    severity: critical
    action: '즉시 전체 트래픽을 레거시로 전환'

  - condition: '데이터 불일치 건수 > 10건/분'
    severity: critical
    action: 'CDC 동기화 중단, 트래픽 레거시 전환, 데이터 정합성 검증'

rollback_procedures:
  instant_rollback:
    description: 'API Gateway 라우팅 변경으로 즉시 롤백 (소요시간: ~10초)'
    steps:
      - 'API Gateway 라우팅 규칙에서 대상 서비스를 legacy로 변경'
      - 'Feature Flag를 disabled로 설정'
      - '모니터링 대시보드에서 레거시 서비스 정상 동작 확인'
      - '장애 원인 분석 및 포스트모템 시작'
    estimated_time: '10초 ~ 1분'

  gradual_rollback:
    description: '카나리 비율을 단계적으로 축소 (소요시간: ~30분)'
    steps:
      - '카나리 비율을 현재 값의 50%로 축소'
      - '5분 대기 후 에러율/레이턴시 확인'
      - '정상이면 유지, 비정상이면 카나리 비율을 0%로 설정'
      - '데이터 정합성 검증 스크립트 실행'
    estimated_time: '30분 ~ 1시간'

  data_rollback:
    description: '데이터 불일치 발생 시 복구 절차'
    steps:
      - 'CDC 파이프라인 즉시 중단'
      - '트래픽을 레거시로 전환'
      - '신규 서비스 DB의 변경 사항을 레거시 DB와 비교'
      - '불일치 데이터 식별 및 보정 스크립트 실행'
      - '정합성 검증 후 CDC 파이프라인 재시작'
    estimated_time: '1시간 ~ 4시간'

monitoring_during_rollback:
  metrics:
    - '에러율 (5xx responses)'
    - 'P50/P95/P99 레이턴시'
    - '초당 요청 수 (RPS)'
    - '데이터베이스 커넥션 풀 사용률'
    - 'CDC 컨슈머 지연 (lag)'
  alerts:
    - '롤백 시작/완료 Slack 알림'
    - '데이터 정합성 검증 결과 보고'
    - '포스트모템 일정 자동 생성'

이 롤백 플레이북의 핵심은 트리거 조건을 사전에 정의하는 것이다. 에러율, 레이턴시, 데이터 불일치 건수 등 정량적 기준을 명확히 설정해야 운영 중 판단 지연을 방지할 수 있다. "상황을 보고 결정한다"는 기준은 장애 상황에서 의사결정을 지연시키는 가장 흔한 원인이다.

프로덕션 마이그레이션 체크리스트

마이그레이션 전 준비

  • 도메인 분석 완료: Event Storming 또는 Domain Storytelling으로 Bounded Context 식별
  • 마이그레이션 대상 서비스 우선순위 결정 (비즈니스 가치, 기술 부채, 결합도 기준)
  • Facade/API Gateway 아키텍처 설계 및 구축
  • Feature Flag 인프라 구축 (LaunchDarkly, Unleash, 또는 자체 구현)
  • CDC 파이프라인 설계 (Debezium, AWS DMS 등)
  • 모니터링/관찰성 인프라 준비 (Prometheus, Grafana, Jaeger)
  • 롤백 플레이북 작성 및 팀 교육
  • 성능 기준선(Baseline) 측정: 현재 모놀리스의 레이턴시, 처리량, 에러율

마이그레이션 진행 중

  • 카나리 배포로 초기 트래픽을 1~5%부터 시작
  • 신규 서비스와 레거시의 응답 결과를 Shadow Testing으로 비교 검증
  • CDC 컨슈머 지연(lag) 모니터링 설정 (임계치 30초 이내)
  • 데이터 정합성 검증 배치 잡을 주기적으로 실행
  • 카나리 비율 단계적 증가: 5% -> 10% -> 25% -> 50% -> 75% -> 100%
  • 각 단계에서 최소 24시간~72시간 안정성 확인 후 다음 단계 진행
  • 에러율과 레이턴시가 SLA 범위 내인지 지속 확인
  • 주간 마이그레이션 진행 상황 보고 (트래픽 전환율, 이슈 현황)

마이그레이션 완료 후

  • 레거시 코드 제거 및 관련 데이터베이스 테이블 아카이빙
  • ACL 및 호환성 계층 제거
  • CDC 파이프라인 중단 및 리소스 반환
  • Feature Flag 정리 (마이그레이션 관련 플래그 제거)
  • Facade 라우팅 규칙 정리 (레거시 경로 제거)
  • 포스트모템 및 회고: 성공 요인, 개선 사항, 교훈 문서화
  • 마이그레이션 경험을 조직 내 Knowledge Base에 등록

단계별 마이그레이션 로드맵

실제 프로덕션 환경에서 Strangler Fig 패턴을 적용할 때, 다음과 같은 4단계 로드맵을 권장한다.

1단계: 분석과 준비 (4~8주)

이 단계에서는 마이그레이션의 기반을 다진다. Event Storming 워크숍을 통해 현재 모놀리스의 도메인 경계를 식별하고, 서비스 간 의존성을 그래프로 시각화한다. 가장 결합도가 낮고 독립적인 서비스를 첫 마이그레이션 대상(Pilot)으로 선정한다.

동시에 Facade/API Gateway, Feature Flag 시스템, CDC 파이프라인, 모니터링 인프라를 구축한다. 이 인프라는 이후 모든 마이그레이션 단계에서 재사용된다.

2단계: 파일럿 마이그레이션 (4~6주)

선정된 Pilot 서비스를 마이크로서비스로 구현하고, 카나리 배포로 프로덕션 트래픽의 1%부터 전환을 시작한다. 이 단계의 목표는 서비스 자체의 안정성뿐만 아니라, 마이그레이션 프로세스 전체(Facade 라우팅, CDC 동기화, Feature Flag 관리, 롤백 절차)를 검증하는 것이다.

Pilot에서 발견된 문제점과 개선 사항을 반영하여 마이그레이션 프로세스를 보완한다. 이 단계에서 확립된 프로세스가 이후 모든 서비스 마이그레이션의 템플릿이 된다.

3단계: 본격 마이그레이션 (수개월~수년)

검증된 프로세스를 활용하여 나머지 서비스를 순차적으로 마이그레이션한다. 여러 서비스의 마이그레이션을 병렬로 진행할 수 있으나, 의존 관계가 있는 서비스는 순서를 지켜야 한다.

비즈니스 우선순위에 따라 마이그레이션 순서를 동적으로 조정한다. 시장 상황 변화에 대응하기 위해 특정 서비스의 마이그레이션을 앞당기거나, 안정성이 우선인 서비스는 뒤로 미룰 수 있다.

4단계: 정리와 완료 (4~8주)

모든 기능이 마이크로서비스로 전환된 후, 레거시 모놀리스 코드를 제거한다. ACL, 호환성 계층, CDC 파이프라인 등 과도기에만 필요했던 인프라를 정리한다. 포스트모템을 실시하여 전체 마이그레이션 과정의 성공 요인과 개선 사항을 조직 차원에서 공유한다.

마이그레이션 미완료 상태가 영구화되는 것은 가장 흔한 실패 패턴이므로, 이 단계를 건너뛰지 않는 것이 중요하다. 마이그레이션 완료 기한을 명시적으로 설정하고, 경영진의 지원을 확보해야 한다.

Strangler Fig 패턴 적용 시 핵심 원칙

Strangler Fig 패턴을 성공적으로 적용하기 위한 핵심 원칙을 정리한다.

점진적 전환 원칙: 한 번에 하나의 기능만 마이그레이션한다. 여러 기능을 동시에 전환하면 장애 원인 파악이 어려워지고 롤백 범위가 넓어진다. 각 기능의 마이그레이션은 독립적으로 롤백 가능해야 한다.

가역성 원칙: 모든 마이그레이션 단계는 되돌릴 수 있어야 한다. Facade 라우팅 변경과 Feature Flag 토글을 통해 수 초 내에 이전 상태로 복귀할 수 있어야 하며, 이 롤백 절차는 사전에 테스트해야 한다.

관찰성 원칙: 마이그레이션 전후의 시스템 동작을 정량적으로 비교할 수 있어야 한다. 에러율, 레이턴시(P50/P95/P99), 처리량 등의 메트릭을 Facade 계층에서 수집하고, 레거시와 신규 서비스의 메트릭을 동일 대시보드에서 비교 확인한다.

완결성 원칙: 마이그레이션을 시작했으면 반드시 완료한다. 불완전한 마이그레이션 상태가 영구화되면, 이중 유지보수 비용, ACL 복잡도 증가, 개발 속도 저하 등의 비용이 누적된다. 명시적인 완료 기한과 정리(Cleanup) 작업을 계획에 포함한다.

도메인 우선 원칙: 기술적 편의가 아닌 비즈니스 도메인 경계에 따라 서비스를 분리한다. DDD의 Bounded Context 개념을 활용하여 서비스 경계를 설정하고, Event Storming으로 도메인 전문가와 함께 경계를 검증한다. 잘못된 분해 경계는 분산 모놀리스라는 최악의 결과를 초래할 수 있다.

마치며

Strangler Fig 패턴은 모놀리스에서 마이크로서비스로의 전환에서 가장 널리 검증된 접근 방식이다. Martin Fowler가 2004년에 제안한 이후 20년 이상 수많은 기업에서 실전 적용되어 왔으며, 점진적 전환이라는 핵심 원리는 변하지 않았다.

그러나 패턴의 개념이 단순하다고 해서 실행이 쉬운 것은 아니다. Facade 설계, ACL 구현, 데이터 동기화, Feature Flag 관리, 롤백 전략 등 각 단계에서 수많은 엔지니어링 결정이 필요하며, 이 결정들의 품질이 마이그레이션의 성공을 좌우한다. 또한 기술적 실행 능력만큼이나 조직적 합의와 경영진 지원이 중요하다. 마이그레이션의 목적, 범위, 일정, 완료 기준에 대한 명확한 합의 없이 시작된 마이그레이션은 불완전한 상태로 영구화될 가능성이 높다.

최근에는 과도하게 분해된 마이크로서비스를 다시 합치는 역방향 마이그레이션 사례도 증가하고 있다. Strangler Fig 패턴은 이런 역방향에도 동일하게 적용할 수 있다. 중요한 것은 특정 아키텍처 스타일에 대한 맹목적 추종이 아니라, 비즈니스 요구사항과 조직 역량에 맞는 아키텍처를 선택하고 안전하게 전환하는 것이다.

참고자료