Blog/MSA

모놀리식에서 MSA로: 전환 여정과 교훈

전자상거래 플랫폼을 모놀리식 아키텍처에서 마이크로서비스로 전환한 경험을 공유합니다. 아키텍처 설계, 데이터 분리, 배포 전략 등을 다룹니다.

5분 읽기
MSAArchitectureSpring CloudKubernetesDevOps

왜 MSA로 전환했는가?

우리 팀은 3년간 운영해온 전자상거래 플랫폼을 모놀리식에서 MSA로 전환하기로 결정했습니다. 주요 이유는 다음과 같습니다:

  • 배포 시간 증가: 작은 변경에도 전체 애플리케이션을 재배포 (평균 2시간)
  • 장애 전파: 한 기능의 문제가 전체 시스템에 영향
  • 기술 스택 제약: 새로운 기술 도입이 어려움
  • 팀 확장의 어려움: 코드베이스가 커지면서 협업 복잡도 증가

도메인 분리 전략

1. 도메인 식별

DDD(Domain-Driven Design) 접근 방식으로 도메인을 식별했습니다.

text
┌─────────────────────────────────────────┐
│         Monolithic Application          │
├─────────────────────────────────────────┤
│  User │ Product │ Order │ Payment       │
└─────────────────────────────────────────┘
                    ↓
┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
│  User    │  │ Product  │  │  Order   │  │ Payment  │
│ Service  │  │ Service  │  │ Service  │  │ Service  │
└──────────┘  └──────────┘  └──────────┘  └──────────┘

2. 데이터베이스 분리

가장 어려운 부분은 데이터베이스 분리였습니다.

주의: 외래 키 제약조건을 제거하면 데이터 정합성 관리가 애플리케이션 레벨로 이동합니다.

sql
-- Before: 모놀리식 DB
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT,
    product_id BIGINT,
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);
 
-- After: 마이크로서비스 DB
-- Order Service DB
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT,  -- 외래 키 제약 없음
    product_id BIGINT  -- 외래 키 제약 없음
);

API Gateway 구축

Spring Cloud Gateway를 사용하여 API Gateway를 구축했습니다.

java
@Configuration
public class GatewayConfig {
    
    @Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        return builder.routes()
            .route("user-service", r -> r
                .path("/api/users/**")
                .filters(f -> f
                    .stripPrefix(1)
                    .circuitBreaker(c -> c
                        .setName("userServiceCircuitBreaker")
                        .setFallbackUri("forward:/fallback/users")))
                .uri("lb://user-service"))
            
            .route("product-service", r -> r
                .path("/api/products/**")
                .filters(f -> f
                    .stripPrefix(1)
                    .addRequestHeader("X-Gateway", "true"))
                .uri("lb://product-service"))
            
            .route("order-service", r -> r
                .path("/api/orders/**")
                .filters(f -> f.stripPrefix(1))
                .uri("lb://order-service"))
            
            .build();
    }
}

서비스 간 통신

동기 통신: OpenFeign

java
@FeignClient(name = "product-service")
public interface ProductClient {
    
    @GetMapping("/products/{id}")
    ProductResponse getProduct(@PathVariable Long id);
    
    @PostMapping("/products/{id}/stock/decrease")
    void decreaseStock(@PathVariable Long id, @RequestParam int quantity);
}
 
@Service
@RequiredArgsConstructor
public class OrderService {
    
    private final ProductClient productClient;
    
    public Order createOrder(CreateOrderRequest request) {
        // 상품 정보 조회
        ProductResponse product = productClient.getProduct(request.getProductId());
        
        // 재고 감소
        productClient.decreaseStock(request.getProductId(), request.getQuantity());
        
        // 주문 생성
        return orderRepository.save(new Order(request));
    }
}

비동기 통신: Kafka

java
@Configuration
public class KafkaConfig {
    
    @Bean
    public NewTopic orderCreatedTopic() {
        return TopicBuilder.name("order-created")
            .partitions(3)
            .replicas(2)
            .build();
    }
}
 
// Producer (Order Service)
@Service
@RequiredArgsConstructor
public class OrderEventPublisher {
    
    private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
    
    public void publishOrderCreated(Order order) {
        OrderCreatedEvent event = new OrderCreatedEvent(
            order.getId(),
            order.getUserId(),
            order.getTotalAmount()
        );
        
        kafkaTemplate.send("order-created", event);
    }
}
 
// Consumer (Payment Service)
@Service
public class OrderEventConsumer {
    
    @KafkaListener(topics = "order-created", groupId = "payment-service")
    public void handleOrderCreated(OrderCreatedEvent event) {
        // 결제 처리 로직
        processPayment(event);
    }
}

분산 트랜잭션: Saga 패턴

Choreography 방식의 Saga 패턴을 구현했습니다.

java
// Order Service
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
    
    private final KafkaTemplate<String, Object> kafkaTemplate;
    
    @Transactional
    public void createOrder(CreateOrderRequest request) {
        // 1. 주문 생성 (Pending 상태)
        Order order = orderRepository.save(Order.pending(request));
        
        // 2. 재고 확인 이벤트 발행
        kafkaTemplate.send("check-inventory", new CheckInventoryEvent(order));
    }
    
    @KafkaListener(topics = "inventory-checked")
    public void handleInventoryChecked(InventoryCheckedEvent event) {
        if (event.isAvailable()) {
            // 3. 결제 요청 이벤트 발행
            kafkaTemplate.send("process-payment", new ProcessPaymentEvent(event.getOrderId()));
        } else {
            // 재고 부족 - 주문 취소
            cancelOrder(event.getOrderId());
        }
    }
    
    @KafkaListener(topics = "payment-processed")
    public void handlePaymentProcessed(PaymentProcessedEvent event) {
        if (event.isSuccess()) {
            // 4. 주문 확정
            confirmOrder(event.getOrderId());
        } else {
            // 결제 실패 - 보상 트랜잭션
            compensateOrder(event.getOrderId());
        }
    }
}

Saga 패턴: 각 서비스가 로컬 트랜잭션을 실행하고, 이벤트를 발행하여 다음 단계를 트리거합니다. 실패 시 보상 트랜잭션으로 롤백합니다.

Kubernetes 배포

yaml
# order-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
      - name: order-service
        image: myregistry/order-service:1.0.0
        ports:
        - containerPort: 8080
        env:
        - name: SPRING_PROFILES_ACTIVE
          value: "prod"
        - name: SPRING_DATASOURCE_URL
          valueFrom:
            secretKeyRef:
              name: order-db-secret
              key: url
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "1Gi"
            cpu: "1000m"
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 20
          periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
  - port: 80
    targetPort: 8080
  type: ClusterIP

전환 결과

지표전환 전전환 후개선율
배포 시간2시간12분90% ↓
장애 복구 시간30분5분83% ↓
개발 속도1주/기능2일/기능71% ↑
시스템 가용성99.5%99.9%0.4%p ↑

교훈

1. 점진적 전환이 답이다

빅뱅 방식이 아닌 Strangler Fig 패턴으로 점진적으로 전환했습니다.

2. 모니터링이 생명이다

분산 시스템에서는 통합 모니터링이 필수입니다. 우리는 다음을 사용했습니다:

  • 로그 수집: ELK Stack
  • 메트릭: Prometheus + Grafana
  • 분산 추적: Jaeger

3. 팀 구조도 변경이 필요하다

Conway's Law에 따라 팀 구조도 서비스 단위로 재편성했습니다.

마치며

MSA 전환은 기술적 도전이자 조직적 변화입니다. 우리 팀은 6개월간의 전환 과정을 통해 많은 것을 배웠고, 이제는 더 빠르고 안정적으로 서비스를 제공할 수 있게 되었습니다.

다음 글에서는 MSA 환경에서의 테스트 전략에 대해 다루겠습니다.

text