이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 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
persistenceapi 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는 나눌 수 있다.
| table | owner module | 외부 접근 |
|---|---|---|
orders | order | order API만 write |
payments | payment | payment API만 write |
products | catalog | catalog API만 write |
order_payment_view | order read model | event로 갱신 |
| 다른 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 문제를 싸게 검증할 수 있기 때문이다.