Monolith vs MSA — 서비스를 어떻게 나눌 것인가

최병현·2026년 6월 18일

spring boot

목록 보기
28/34

1. 개념 소개

백엔드 아키텍처를 공부하다 보면 거의 항상 등장하는 비교가 있다. 모놀리식(Monolithic)과 MSA(Microservice Architecture)다.

모놀리식 아키텍처는 하나의 애플리케이션 안에 모든 기능(회원, 주문, 결제, 상품 등)이 함께 들어있는 구조다. 하나의 코드베이스, 하나의 빌드 산출물(JAR), 하나의 배포 단위로 동작한다. 우리가 Spring Boot 프로젝트를 처음 시작할 때 만드는 구조가 보통 이 형태다.

MSA는 각 기능을 독립적인 서비스로 쪼개서, 서비스마다 별도로 개발, 배포, 확장하는 구조다. 회원 서비스, 주문 서비스, 결제 서비스가 각각 독립된 애플리케이션으로 존재하고, 서비스 간에는 네트워크(API 호출, 메시징)로 통신한다.

[모놀리식]
┌─────────────────────────────┐
│       하나의 애플리케이션         │
│  ┌──────┐ ┌──────┐ ┌──────┐ │
│  │ 회원  │ │ 주문  │ │ 결제  │ │
│  └──────┘ └──────┘ └──────┘ │
│         하나의 DB              │
└─────────────────────────────┘

[MSA]
┌────────┐  ┌────────┐  ┌────────┐
│ 회원서비스 │  │ 주문서비스 │  │ 결제서비스 │
│  자체 DB │  │  자체 DB │  │  자체 DB │
└────┬───┘  └────┬───┘  └────┬───┘
     └──── 네트워크 통신 ────┘

2. 왜 필요한가

처음부터 MSA로 시작하는 게 정답은 아니다. 각각의 구조가 해결하는 문제가 다르다.

모놀리식이 좋은 이유

  • 개발 초기 속도가 빠르다 — 하나의 프로젝트만 신경 쓰면 됨
  • 배포가 단순하다 — 빌드 하나, 배포 하나
  • 트랜잭션 관리가 쉽다 — 같은 DB, 같은 JVM 안에서 처리되므로 일관성 보장이 직관적
  • 디버깅이 쉽다 — 호출 흐름이 하나의 애플리케이션 안에서 끝남

모놀리식의 한계

  • 서비스가 커지면 코드베이스가 비대해지고, 빌드/배포 시간이 길어진다
  • 한 기능에 버그가 생기면 전체 애플리케이션에 영향을 줄 수 있다
  • 특정 기능만 트래픽이 몰려도 전체 애플리케이션을 스케일링해야 한다
  • 팀이 커지면 같은 코드베이스를 여러 팀이 수정하면서 충돌과 의존성 문제가 늘어난다

MSA가 해결하는 것

  • 독립 배포 — 주문 서비스만 수정해서 배포해도 다른 서비스에 영향 없음
  • 독립 확장 — 트래픽이 몰리는 서비스만 인스턴스를 늘릴 수 있음
  • 장애 격리 — 결제 서비스에 장애가 나도 상품 조회는 영향받지 않을 수 있음 (설계에 따라)
  • 기술 스택 자유도 — 서비스마다 다른 언어, DB를 쓸 수 있음
  • 팀 단위 소유권 — 팀마다 자신의 서비스를 독립적으로 책임지고 운영

MSA는 공짜로 얻어지는 게 아니다. 서비스 간 통신, 데이터 일관성, 분산 트랜잭션, 운영 복잡도라는 새로운 문제가 따라온다.


3. 전체 동작 흐름

"주문하기" 기능이 두 구조에서 어떻게 처리되는지 비교해보자.

모놀리식에서의 흐름

[Client]
    |
    | POST /api/orders
    ↓
[단일 Spring Boot 애플리케이션]
    |
    | OrderController
    | → OrderService.createOrder()
    |     → UserRepository (같은 JVM, 같은 DB)
    |     → ProductRepository (같은 JVM, 같은 DB)
    |     → PaymentService (같은 JVM, 같은 DB)
    |     → 하나의 @Transactional 안에서 전부 처리
    ↓
[Client]
    Response (단일 트랜잭션으로 일관성 보장)

MSA에서의 흐름

[Client]
    |
    | POST /api/orders
    ↓
[API Gateway]
    |
    | 요청을 Order Service로 라우팅
    ↓
[Order Service]
    |
    | ① User Service에 사용자 정보 조회 요청 (HTTP/gRPC)
    |   GET http://user-service/users/{id}
    ↓
[User Service] → 응답
    |
    | ② Product Service에 재고 확인 요청
    |   GET http://product-service/products/{id}/stock
    ↓
[Product Service] → 응답
    |
    | ③ Order 데이터 자체 DB에 저장
    | ④ Payment Service에 결제 요청 이벤트 발행 (메시지 큐)
    ↓
[Message Queue] → [Payment Service]
    |
    | 비동기로 결제 처리, 결과를 이벤트로 다시 발행
    ↓
[Order Service]
    | 결제 완료 이벤트 수신 → 주문 상태 업데이트
    ↓
[Client]
    Response (즉시 응답, 결제는 비동기로 진행될 수 있음)

모놀리식은 하나의 트랜잭션으로 묶이지만, MSA는 여러 서비스에 걸친 흐름이라 "전부 성공 또는 전부 실패"를 보장하기가 훨씬 복잡해진다. 이 문제를 해결하기 위해 Saga 패턴, 이벤트 기반 아키텍처 같은 별도 기법이 필요해진다.


4. 핵심 구성 요소

4-1. API Gateway

MSA에서 클라이언트의 모든 요청을 받아서 적절한 서비스로 라우팅하는 진입점이다. 인증, 로깅, 속도 제한 같은 공통 기능도 여기서 처리하는 경우가 많다. 클라이언트는 개별 서비스의 위치를 알 필요 없이 Gateway 하나만 알면 된다.

4-2. 서비스 간 통신 방식

  • 동기 통신 (REST, gRPC) — 즉시 응답이 필요한 경우. 호출하는 서비스가 응답을 기다림
  • 비동기 통신 (메시지 큐 - Kafka, RabbitMQ) — 즉시 응답이 필요 없는 경우. 이벤트를 발행하고 다른 서비스가 나중에 처리

4-3. 서비스 디스커버리 (Service Discovery)

서비스 인스턴스가 동적으로 늘어나거나 줄어들 때, 각 서비스가 서로의 현재 위치(IP, 포트)를 찾을 수 있게 해주는 메커니즘이다. Eureka, Consul 같은 도구가 이 역할을 한다.

4-4. 분산 트랜잭션 — Saga 패턴

하나의 비즈니스 흐름이 여러 서비스에 걸쳐 있을 때, 각 서비스의 로컬 트랜잭션을 순차적으로 실행하고, 실패 시 보상 트랜잭션(Compensating Transaction)으로 되돌리는 패턴이다. 전통적인 DB 트랜잭션처럼 완벽한 ACID를 보장하진 않지만, 분산 환경에서 일관성을 다루는 현실적인 방법이다.

4-5. Circuit Breaker

한 서비스가 응답하지 않거나 느려질 때, 호출하는 쪽이 무한정 기다리지 않고 빠르게 실패 처리하거나 대체 응답을 주도록 하는 패턴이다. 한 서비스의 장애가 연쇄적으로 전체 시스템에 퍼지는 것을 막아준다. Resilience4j 같은 라이브러리로 구현한다.


5. Spring Boot에서 어떻게 연결되는가

모놀리식에서 MSA로 전환할 때 Spring 생태계가 제공하는 도구들이다.

[모놀리식 - 기본 Spring Boot]
spring-boot-starter-web
spring-boot-starter-data-jpa
→ 하나의 애플리케이션, 하나의 배포 단위

[MSA - Spring Cloud 생태계]
spring-cloud-starter-gateway       → API Gateway
spring-cloud-starter-netflix-eureka-client → 서비스 디스커버리
spring-cloud-starter-openfeign     → 서비스 간 HTTP 통신을 선언적으로
spring-cloud-starter-circuitbreaker-resilience4j → 장애 격리
spring-kafka                       → 비동기 이벤트 통신

각 서비스는 독립적인 Spring Boot 애플리케이션이고, 각자 자신의 build.gradle, 자신의 DB, 자신의 배포 파이프라인을 갖는다. 서비스 간 연결은 코드 레벨이 아니라 네트워크 레벨에서 이루어진다.


6. 간단한 예제 코드

6-1. 모놀리식 — 하나의 트랜잭션으로 처리

@Service
@RequiredArgsConstructor
public class OrderService {

    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    private final PaymentService paymentService;
    private final OrderRepository orderRepository;

    @Transactional // 전부 성공 또는 전부 롤백
    public Order createOrder(OrderRequest request) {
        User user = userRepository.findById(request.userId()).orElseThrow();
        Product product = productRepository.findById(request.productId()).orElseThrow();

        product.decreaseStock(request.quantity());
        paymentService.charge(user, product.getPrice());

        Order order = Order.create(user, product, request.quantity());
        return orderRepository.save(order);
        // 중간에 예외 발생 시 전부 롤백됨 — 같은 DB, 같은 트랜잭션
    }
}

6-2. MSA — 서비스 간 호출 (Feign Client)

// Order Service에서 User Service를 호출하는 인터페이스
@FeignClient(name = "user-service") // Eureka에 등록된 서비스 이름으로 찾음
public interface UserServiceClient {

    @GetMapping("/users/{id}")
    UserResponse getUser(@PathVariable Long id);
}

// Order Service에서 Product Service 호출
@FeignClient(name = "product-service")
public interface ProductServiceClient {

    @GetMapping("/products/{id}")
    ProductResponse getProduct(@PathVariable Long id);

    @PostMapping("/products/{id}/decrease-stock")
    void decreaseStock(@PathVariable Long id, @RequestBody DecreaseStockRequest request);
}

@Service
@RequiredArgsConstructor
public class OrderService {

    private final UserServiceClient userServiceClient;
    private final ProductServiceClient productServiceClient;
    private final OrderRepository orderRepository; // Order Service 자체 DB

    public Order createOrder(OrderRequest request) {
        // 다른 서비스를 네트워크로 호출 — 트랜잭션이 통합되지 않음!
        UserResponse user = userServiceClient.getUser(request.userId());
        ProductResponse product = productServiceClient.getProduct(request.productId());

        productServiceClient.decreaseStock(request.productId(),
                new DecreaseStockRequest(request.quantity()));

        Order order = Order.create(user.id(), product.id(), request.quantity());
        return orderRepository.save(order);
        // 만약 여기서 실패하면? 이미 호출한 decreaseStock은 어떻게 되돌릴까?
        // → 이게 MSA에서 분산 트랜잭션 문제가 발생하는 지점
    }
}

6-3. Circuit Breaker로 장애 격리

@Service
@RequiredArgsConstructor
public class OrderService {

    private final ProductServiceClient productServiceClient;

    @CircuitBreaker(name = "productService", fallbackMethod = "getProductFallback")
    public ProductResponse getProduct(Long productId) {
        return productServiceClient.getProduct(productId);
    }

    // Product Service가 응답하지 않을 때 대체 처리
    public ProductResponse getProductFallback(Long productId, Throwable t) {
        return ProductResponse.unavailable(productId);
        // 전체 시스템이 멈추는 대신 기본값으로 응답
    }
}

6-4. 이벤트 기반 비동기 통신 (Kafka)

// Order Service — 주문 생성 후 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {

    private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
    private final OrderRepository orderRepository;

    @Transactional
    public Order createOrder(OrderRequest request) {
        Order order = orderRepository.save(Order.create(request));

        // 결제 서비스에게 "주문이 생성됐다"는 이벤트만 발행
        // 결제 처리 결과를 기다리지 않음
        kafkaTemplate.send("order-created", new OrderCreatedEvent(order.getId(), order.getTotalPrice()));

        return order;
    }
}

// Payment Service — 이벤트를 구독해서 결제 처리
@Component
@RequiredArgsConstructor
public class OrderEventConsumer {

    private final PaymentService paymentService;

    @KafkaListener(topics = "order-created")
    public void handleOrderCreated(OrderCreatedEvent event) {
        paymentService.processPayment(event.orderId(), event.totalPrice());
        // 결제 완료 후 "payment-completed" 이벤트를 다시 발행해서
        // Order Service가 주문 상태를 업데이트하도록 함
    }
}

7. 자주 헷갈리는 부분

MSA가 항상 더 좋은 것은 아니다

"대기업은 다 MSA를 쓰니까 우리도 써야 한다"는 생각은 위험하다. 팀 규모가 작고 서비스가 단순하다면, MSA의 운영 복잡도가 오히려 손해가 된다. 서비스 디스커버리, 분산 트랜잭션, 모니터링 인프라까지 신경 써야 할 게 너무 많아진다. 초기 스타트업이나 소규모 팀에서는 모놀리식으로 시작해서 필요한 부분만 점진적으로 분리하는 게 현실적인 경우가 많다.

MSA에서 DB는 서비스마다 분리되어야 한다

"각 서비스를 분리했지만 DB는 하나를 공유한다"는 흔한 실수다. 이러면 진짜 MSA가 아니라 "분산된 모놀리식"이 된다. 한 서비스가 DB 스키마를 바꾸면 다른 서비스에 영향을 줄 수 있고, 서비스 간 독립성이 깨진다. MSA의 핵심 원칙은 "각 서비스는 자신의 데이터를 자신만 소유한다"는 것이다.

분산 트랜잭션은 일반 트랜잭션과 다르다

모놀리식의 @Transactional은 DB 레벨에서 원자성을 보장하지만, MSA에서 여러 서비스에 걸친 작업은 네트워크 호출의 연속이라 한순간에 롤백시킬 방법이 없다. Saga 패턴으로 "이미 한 작업을 거꾸로 취소하는 보상 트랜잭션"을 직접 설계해야 한다. 이건 자동으로 되는 게 아니라 개발자가 명시적으로 구현해야 하는 부분이다.


8. 실무에서 중요한 포인트

모놀리식으로 시작해서 필요할 때 분리하기

처음부터 완벽한 MSA로 설계하려고 하면 오버엔지니어링이 되기 쉽다. 모놀리식으로 시작해서 도메인 경계를 명확히 만들어두고, 실제로 독립 배포/확장이 필요한 지점이 생기면 그 부분만 분리하는 전략이 많이 쓰인다. 이걸 "모놀리식 우선(Monolith First)" 전략이라고 부른다.

도메인 경계를 미리 잘 나눠두면 분리가 쉬워진다

모놀리식이라도 패키지 구조를 도메인 단위로 명확히 나누고 (order, user, payment 패키지로 분리하고 서로 직접 참조하지 않도록 설계), DB 테이블도 도메인 경계를 의식해서 설계해두면 나중에 MSA로 분리할 때 훨씬 수월하다.

분산 트레이싱 도구는 MSA 운영의 필수 요소

요청이 여러 서비스를 거치면 "어디서 느려졌는지", "어디서 에러가 났는지" 파악이 어려워진다. Zipkin, Jaeger 같은 분산 트레이싱 도구로 요청 하나의 전체 흐름을 추적할 수 있게 해야 한다. 이게 없으면 MSA의 디버깅은 거의 불가능에 가깝다.

API 버전 관리와 하위 호환성

서비스가 독립적으로 배포되기 때문에, 한 서비스의 API가 바뀌면 그 API를 쓰는 다른 서비스가 깨질 수 있다. API 버전을 명시하고(/v1/users, /v2/users), 하위 호환성을 유지하면서 점진적으로 변경하는 습관이 필요하다.


9. 정리

  • 모놀리식은 하나의 애플리케이션에 모든 기능이 통합된 구조로, 개발/배포가 단순하고 트랜잭션 관리가 직관적이다
  • MSA는 기능을 독립 서비스로 분리해서 독립 배포, 독립 확장, 장애 격리가 가능하지만, 운영 복잡도가 크게 늘어난다
  • MSA에서는 서비스 간 통신(동기/비동기), 서비스 디스커버리, Circuit Breaker, 분산 트랜잭션(Saga 패턴) 같은 추가 개념이 필요하다
  • 각 서비스는 자신의 데이터를 독립적으로 소유해야 한다 — DB 공유는 MSA의 원칙에 위배된다
  • MSA가 항상 정답은 아니다. 팀 규모와 서비스 복잡도에 맞는 선택이 중요하다
  • "모놀리식 우선" 전략으로 시작해서 도메인 경계를 명확히 만들어두고 필요할 때 분리하는 것이 현실적인 접근이다
  • MSA 운영에는 분산 트레이싱, API 버전 관리 같은 추가 인프라와 습관이 필수다

10. 느낀 점

처음엔 MSA가 "더 좋은 최신 기술"이라고만 생각했는데, 실제로 동작 흐름을 비교해보니 모놀리식의 단순함이 가진 장점이 명확하게 보였다. 하나의 트랜잭션으로 처리되던 게 MSA에서는 여러 네트워크 호출로 쪼개지고, 실패했을 때 되돌리는 것조차 직접 설계해야 한다는 게 생각보다 큰 부담이었다.

MSA는 "기술적으로 우월한 구조"가 아니라 "특정 문제를 해결하기 위한 트레이드오프"라는 걸 느꼈다. 독립 배포와 확장성을 얻는 대신 복잡도와 운영 비용을 지불하는 구조다. 지금 만들고 있는 서비스 규모에서 이 트레이드오프가 정말 필요한지 먼저 따져보는 게 아키텍처를 고르는 시작점이라는 걸 깨달았다.

profile
Develop

0개의 댓글