Monolith vs Microservices: 올바른 선택 방법

1. 개요

“Monolith을 선택해야 할까, 아니면 Microservices를 선택해야 할까”라는 질문은 거의 모든 시스템 설계 미팅에서 등장합니다. 이 글은 “정답은 하나”라는 식의 답변을 제시하는 대신, 구체적인 기준들을 살펴봅니다: 팀 규모, 배포 속도, 운영 복잡도, 서비스 간 통신 비용. 이 글을 읽고 나면, 자신의 시스템이 “Monolith – Modular Monolith – Microservices” 스펙트럼의 어느 위치에 있어야 하는지 판단하기 위한 체크리스트를 갖게 될 것입니다.

2. Monolith: 단순하지만 저평가되기 쉬운 선택

Monolith는 모든 비즈니스 로직(Order, Customer, Payment, Inventory…)이 하나의 프로세스, 하나의 코드베이스, 한 번의 배포로 실행된다는 의미입니다.

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        Order order = orderService.create(request);
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

@Service
public class OrderService {

    private final InventoryService inventoryService;
    private final PaymentService paymentService;

    public OrderService(InventoryService inventoryService, PaymentService paymentService) {
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
    }

    public Order create(OrderRequest request) {
        inventoryService.reserve(request.productId(), request.quantity());
        paymentService.charge(request.customerId(), request.amount());
        return new Order(request); // DB에 저장하는 constructor라고 가정
    }
}

OrderService는 InventoryService와 PaymentService를 같은 JVM 내에서 메서드 호출로 직접 호출합니다. 네트워크가 없고, 직렬화도 없으며, 트랜잭션을 단일 @Transactional로 감싸기만 하면 됩니다. 이것이 Monolith의 가장 큰 장점입니다: 일관성을 쉽게 보장할 수 있고, 디버깅이 쉽고, 한 번에 배포하면 끝입니다.

문제는 팀이 커질 때 나타납니다: 모든 사람이 같은 코드베이스를 수정하고, 빌드 시간이 늘어나며, 작은 모듈 하나를 배포할 때도 전체 앱을 다시 빌드해야 합니다.

참고: Monolith이라고 해서 반드시 “복잡한 코드”를 의미하지는 않습니다. 모듈이 명확하게 분리되어 있고(도메인별 패키지, 순환 참조 없음), 잘 작성된 Monolith은 수백만 명의 사용자를 서빙하는 시스템도 안정적으로 운영할 수 있습니다.

3. Microservices: 독립적인 확장은 얻지만, 운영 복잡도를 대가로

Microservices는 Order, Inventory, Payment를 별개의 서비스로 분리하고, 네트워크(REST, gRPC, 메시지 큐)를 통해 통신합니다.

@Service
public class OrderService {

    private final RestClient restClient;

    public OrderService(RestClient restClient) {
        this.restClient = restClient;
    }

    public Order create(OrderRequest request) {
        // Inventory Service를 HTTP로 호출
        restClient.post()
                .uri("http://inventory-service/reservations")
                .body(new ReservationRequest(request.productId(), request.quantity()))
                .retrieve()
                .toBodilessEntity();

        // Payment Service를 HTTP로 호출
        restClient.post()
                .uri("http://payment-service/charges")
                .body(new ChargeRequest(request.customerId(), request.amount()))
                .retrieve()
                .toBodilessEntity();

        return new Order(request);
    }
}

2번 섹션의 Monolith 버전과 비교하면, 핵심 차이는 restClient.post(...)입니다: 프로세스 내 메서드 호출이 네트워크를 통한 HTTP 호출로 바뀌었습니다. 그 결과:

  • 각 호출이 네트워크 타임아웃, 서비스 다운, DNS 오류 등으로 실패할 수 있습니다 — Monolith에는 없는 문제들입니다.
  • 더 이상 서비스 전체를 아우르는 ACID 트랜잭션이 없습니다. 대신 Saga 패턴이나 **최종 일관성(Eventual Consistency)**으로 처리해야 합니다.
  • 서비스 검색, 로드 밸런싱, 분산 추적 등이 필요해져서 오류 발생 시 디버깅이 복잡해집니다.

3.1. 숨겨진 비용: 분산 트랜잭션

@Service
public class OrderSagaOrchestrator {

    public void createOrder(OrderRequest request) {
        try {
            inventoryClient.reserve(request.productId(), request.quantity());
            paymentClient.charge(request.customerId(), request.amount());
        } catch (PaymentFailedException e) {
            // 보상 단계를 직접 구현해서 rollback 처리해야 함
            inventoryClient.release(request.productId(), request.quantity());
            throw e;
        }
    }
}

Monolith에서는 rollback이 @Transactional로 자동 처리됩니다. Microservices에서는 보상 로직을 직접 작성해야 합니다(reserve 실패 후 release 호출). 이 코드는 Monolith 버전에는 존재하지 않지만, Microservices에서는 데이터 불일치를 방지하기 위해 반드시 필요합니다.

4. 결정 기준: “큰 회사들이 하니까”만으로는 안 됨

4.1. 팀 규모와 Conway의 법칙

Conway의 법칙: 시스템의 구조는 조직의 구조를 반영합니다. 개발자가 5명인데 5개 서비스로 나누면, 각 사람이 하나씩 맡게 되고 — 누구도 다른 사람의 코드를 리뷰할 수 없으며, “많은 사람이 한 버그를 함께 찾는” 이점을 잃게 됩니다.

실제 참고 기준:

1–15명, 1개 팀              -> Monolith (모듈화)
15–50명, 2–5개 팀          -> Modular Monolith 또는 2–3개 서비스로 분리
50명 이상, 여러 팀          -> 바운디드 컨텍스트별 Microservices

4.2. 배포 속도가 실제로 병목인가?

Monolith을 2주마다 배포하는데 심각한 병합 충돌이 없다면, “독립 배포”를 위해 서비스를 나누는 것은 이점보다 운영 비용이 더 많습니다(각 서비스별 CI/CD, 모니터링, 버전 관리).

Microservices가 가치를 발휘하는 경우: 팀 A는 결제 기능을 하루에 10번 배포해야 하고, 팀 B는 인벤토리를 주 1회만 배포해야 한다면, 분리해서 A가 B를 기다리지 않도록 하는 것이 의미가 있습니다.

4.3. 인프라 장애 대응 능력

# docker-compose.yml - Microservices 최소 인프라의 복잡도 예시
services:
  order-service:
    image: order-service:1.0
    depends_on:
      - inventory-service
      - payment-service
      - service-registry
  inventory-service:
    image: inventory-service:1.0
  payment-service:
    image: payment-service:1.0
  service-registry:
    image: eureka-server:1.0
  api-gateway:
    image: spring-cloud-gateway:1.0

비즈니스 로직 서비스 3개만으로도 이미 service-registry와 api-gateway가 필요합니다. 실제로 20개 서비스를 운영하면, 움직이는 부품의 수가 기하급수적으로 늘어나 — Kubernetes, 분산 로깅(ELK), 추적(Jaeger/Zipkin) 등을 능숙하게 다룰 DevOps 팀이 필요합니다.

참고: 만약 회사에 컨테이너 오케스트레이션과 가시성 스택에 능숙한 사람이 없다면, Microservices는 매 사건마다 “여러 서비스의 로그를 헤매며 원인을 찾는” 악몽이 될 것입니다.

5. Modular Monolith: 간과되는 중간 선택지

Modular Monolith는 Monolith이지만 코드를 독립적인 모듈로 구성하고, 명확한 인터페이스로 통신하며, 데이터베이스 간 직접 참조를 없앤 형태입니다.

// Package: com.nonladev.shop.order
public interface InventoryPort {
    void reserve(String productId, int quantity);
}

// Package: com.nonladev.shop.inventory
@Service
public class InventoryService implements InventoryPort {
    @Override
    public void reserve(String productId, int quantity) {
        // 비즈니스 로직, inventory 모듈의 독립적인 DB 접근
    }
}

OrderService는 InventoryPort 인터페이스에만 의존하고, inventory 모듈의 구현 세부사항은 모릅니다. 덕분에 나중에 inventory를 별개 서비스로 분리해야 할 때, InventoryPort의 구현을 HTTP 클라이언트로 바꾸기만 하면 됩니다 — OrderService의 호출 코드는 변경할 필요가 없습니다.

이것이 “Monolith에서 시작해서 필요할 때 천천히 분리“하는 전략이며, 많은 대형 시스템(초기 Shopify, GitHub)이 성공적으로 적용한 방식입니다.

6. 테스트: in-process 호출과 네트워크 호출의 지연 시간 측정

아래 테스트는 지연 시간의 차이를 보여줍니다 — 서비스 분리를 결정하기 전에 실제로 측정해야 하는 요소입니다.

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
class OrderServiceLatencyTest {

    @Test
    void inProcessCallShouldBeFasterThanRemoteCall() {
        InventoryPort inProcess = (productId, qty) -> { /* no-op */ };
        long startInProcess = System.nanoTime();
        inProcess.reserve("P-1", 1);
        long inProcessDuration = System.nanoTime() - startInProcess;

        InventoryPort remoteSimulated = (productId, qty) -> {
            try {
                Thread.sleep(20); // 네트워크 왕복 시간을 ~20ms로 시뮬레이션
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };
        long startRemote = System.nanoTime();
        remoteSimulated.reserve("P-1", 1);
        long remoteDuration = System.nanoTime() - startRemote;

        assertThat(remoteDuration).isGreaterThan(inProcessDuration);
    }
}

이 테스트는 실제 인프라의 성능을 측정하지는 않지만, 본질을 보여줍니다: Order가 Inventory를 네트워크를 통해 호출할 때마다, 수 밀리초에서 수십 밀리초가 더해집니다. 한 요청이 5개 서비스를 연쇄적으로 호출해야 한다면, 지연 시간이 누적되어 SLA를 초과할 수 있습니다 — 이것은 아키텍처 다이어그램을 그릴 때 자주 간과되는 비용입니다.

7. Microservices를 선택하면 안 되는 경우

  • 팀이 10명 미만: 여러 서비스의 운영 비용(CI/CD, 모니터링, 온콜)이 분리의 이점을 초과합니다.
  • 도메인이 아직 불안정: 비즈니스가 계속 변한다면(초기 스타트업 단계), 서비스 경계가 자꾸 틀려서 분리했다가 다시 병합하는 악순환에 빠집니다.
  • 가시성 인프라가 없음: 분산 추적(distributed tracing), 중앙화된 로깅이 없으면 서비스 간 오류를 디버깅하기가 거의 불가능합니다.
  • 강한 일관성이 필수: 핵심 금융 거래 같이 강한 ACID 트랜잭션이 중요한 경우, 하나의 트랜잭션 경계(Monolith) 안에 두는 게 복잡한 Saga 구현보다 안전합니다.

반대로 Microservices가 가치 있는 경우: 독립적인 여러 팀이 있고, 각 부분을 따로 확장해야 하거나(예: 검색 서비스는 CPU 집약적, 결제 서비스는 메모리 집약적), 각 도메인마다 다른 기술을 써야 할 때입니다(Node.js로 실시간 채팅, Java로 청구 시스템).

8. 결론

“정답인” 아키텍처는 없습니다 — 팀 규모, 배포 속도, 현재의 운영 역량에 맞는 아키텍처만 있습니다. 확실하지 않다면, Modular Monolith에서 시작하세요: 모듈 간 인터페이스를 명확하게 하고, 데이터베이스 간 순환 참조를 피해서, 나중에 실제로 분리해야 할 때 구현만 바꾸면 되도록 설계하세요. Microservices는 조직과 확장을 해결하는 도구이지, 아키텍처 자체가 목표가 아닙니다.

microservicesmonolithsystem-designspring-boot아키텍처