이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Modular Monolith는 Monolith와 무엇이 다르고 어떤 문제를 줄이는가?
  • Module 경계는 package 이름이 아니라 어떤 의존성 규칙과 데이터 접근 규칙으로 증명되는가?
  • Modular Monolith는 성능, 정합성, 장애 격리, 운영 복잡도 중 무엇을 바꾸는가?

개요

Modular Monolith는 하나의 process와 하나의 배포 단위를 유지하되 내부 module 경계를 강제하는 구조다. MSA처럼 network service를 여러 개 만들지는 않는다. 대신 order, payment, catalog, shipping 같은 업무 책임을 package, build module, public API, repository 접근 규칙으로 나눈다. 목표는 운영 복잡도를 크게 늘리지 않으면서 변경 결합도를 낮추는 것이다. 잘 만든 Modular Monolith는 그 자체로 충분한 최종 구조가 될 수 있고, 나중에 service 분리가 필요할 때도 안전한 출발점이 된다.

이 문서가 바꾸는 설계 축

바뀌는 내용
성능network hop 없이 호출하지만 module별 병목을 관찰하고 나눌 수 있다.
정합성단일 DB transaction을 유지하되 module owner를 분리한다.
장애 격리process 장애 격리는 약하지만 코드 변경과 dependency 전파를 줄인다.
운영 복잡도service 운영 비용은 낮게 유지하고 build/test 규칙은 늘어난다.

핵심은 강제 규칙이다

폴더만 나누면 Modular Monolith가 아니다. 경계는 다음 규칙으로 확인된다.

규칙의미
public API만 호출다른 module의 internal service나 repository를 직접 호출하지 않는다.
의존성 방향 고정order -> payment.api는 가능해도 payment -> order.internal은 금지한다.
DB 접근 제한module 밖 table은 직접 write하지 않는다.
event 경계 사용transaction 후 후속 작업은 내부 event나 outbox로 분리한다.
테스트로 검증architecture rule이 깨지면 CI가 실패한다.
강제 규칙이 없으면 시간이 지나며 다시 big ball of mud가 된다.

대표 시나리오

커머스 Monolith에서 주문 module이 결제 repository를 직접 호출하고 있다고 하자.

// 나쁜 방향
orderService.placeOrder(command);
paymentRepository.save(Payment.from(order));

처음에는 편하다. 하지만 결제 schema를 바꾸면 주문 코드가 깨지고, 결제 owner가 독립적으로 변경하기 어렵다. Modular Monolith에서는 주문 module이 결제 module의 public API를 호출한다.

public interface PaymentPort {
    PaymentReservation reserve(OrderId orderId, Money amount);
}

구현은 같은 process 안에 있어도 호출자는 내부 table과 구현을 모른다.

Module 구조 예시

commerce-app
  order
    api
    application
    domain
    persistence
  payment
    api
    application
    domain
    persistence
  catalog
    api
    application
    domain
    persistence

api package는 다른 module이 볼 수 있다. application, domain, persistence는 해당 module 내부에서만 사용한다. 이 구조는 Java package, Gradle module, JPMS, Spring Modulith, ArchUnit 등으로 강제할 수 있다. 도구보다 중요한 것은 “어떤 방향의 호출을 허용할 것인가”이다.

의존성 검증

Architecture rule은 사람의 기억에 맡기지 않는다.

@Test
void order_module_must_not_access_payment_internal() {
    noClasses()
        .that().resideInAPackage("..order..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("..payment.application..", "..payment.persistence..");
}

이런 테스트는 service 분리보다 싸다. 잘못된 의존성이 들어오는 순간 CI에서 막을 수 있다. 경계가 깨지는 지점을 빨리 보는 것이 Modular Monolith의 장점이다.

데이터 접근 규칙

하나의 DB를 쓰더라도 table owner는 나눌 수 있다.

tableowner module외부 접근
ordersorderorder API만 write
paymentspaymentpayment API만 write
productscatalogcatalog API만 write
order_payment_vieworder read modelevent로 갱신
다른 module이 편의상 join query를 직접 쓰기 시작하면 경계가 무너진다.
필요한 조회가 있다면 API, read model, projection, reporting DB 중 하나로 만든다.
DB 하나를 쓰더라도 소유권은 하나여야 한다.

Transaction 경계

Modular Monolith는 단일 transaction을 유지할 수 있다. 하지만 모든 것을 하나의 transaction으로 묶으면 module 경계가 약해진다. 다음처럼 기준을 나눈다.

작업추천 경계
주문 생성과 주문 row 저장order transaction
결제 예약 요청payment API 또는 event
알림 발송transaction 이후 event
정산 집계batch 또는 read model
동기 호출이 필요한 경우에도 public API를 통과한다.
비동기 후속 작업은 transaction commit 이후 event로 나누면 장애 영향이 줄어든다.

내부 이벤트

같은 process 안에서도 event는 유용하다.

@Transactional
public OrderId placeOrder(PlaceOrderCommand command) {
    Order order = orderRepository.save(Order.create(command));
    domainEvents.raise(new OrderPlaced(order.id(), order.totalPrice()));
    return order.id();
}

주의할 점은 event가 module 경계를 숨기는 마법이 아니라는 것이다. 이벤트 payload, 처리 순서, 실패 시 재처리 기준을 정해야 한다. 나중에 외부 broker로 옮길 수 있다면 idempotency key와 event version을 미리 둔다.

MSA로 가기 전 검증

Modular Monolith는 service 추출을 미루는 핑계가 아니라 검증 단계다. 다음 질문에 답해야 한다.

  • payment module은 order internal class 없이 동작하는가?
  • payment table schema 변경이 order 배포 없이 가능한가?
  • payment 장애를 order 조회와 분리할 수 있는가?
  • payment owner가 API 계약을 관리하는가?
  • payment event를 재처리할 수 있는가? 이 질문에 답하지 못하면 service로 나누어도 문제가 network 너머로 이동할 뿐이다.

개인 프로젝트 기준

  • package를 api, application, domain, persistence로 나누고 접근 규칙을 적는다.
  • 다른 module repository를 직접 호출하지 않는 예시를 만든다.
  • module별 table owner를 README나 설계 문서에 남긴다.
  • ArchUnit 같은 간단한 테스트로 의존성 방향을 검증한다.
  • MSA를 쓰지 않은 이유를 운영 비용과 transaction 단순성으로 설명한다.

기업 운영 기준

  • module owner와 code review 책임을 지정한다.
  • module dependency graph를 CI나 dashboard로 확인한다.
  • schema migration은 owner module 승인 없이는 진행하지 않는다.
  • 내부 event도 idempotency, version, replay 기준을 가진다.
  • service 추출 후보는 module 경계 지표와 운영 준비도를 함께 본다.

위험 신호

  • package 이름만 나누고 모든 module이 서로 service를 직접 호출한다.
  • 다른 module의 repository나 entity를 import한다.
  • 공유 util과 common module이 사실상 모든 domain logic을 가진다.
  • module owner는 없고 팀 전체가 모든 table을 수정한다.
  • 내부 event 실패가 조용히 무시된다.
  • MSA로 옮길 계획은 있지만 현재 module 경계를 테스트하지 않는다.

확인 질문

확인 질문

  • 확인 질문: Modular Monolith의 핵심은 무엇인가?
    • 답변: process와 배포는 하나로 유지하면서 module API, 의존성 방향, 데이터 owner를 강제하는 것이다.
  • 확인 질문: DB를 하나 쓰면 module 경계가 의미 없나?
    • 답변: 아니다. 물리 DB는 하나여도 table owner와 write 경계를 정하면 변경 결합도를 줄일 수 있다.
  • 확인 질문: Modular Monolith가 MSA 전 단계로 좋은 이유는 무엇인가?
    • 답변: network와 운영 복잡도를 늘리기 전에 경계, owner, event, migration 문제를 싸게 검증할 수 있기 때문이다.