Spring Modulith로 모듈러 모놀리스 구축하기 — 경계·이벤트·문서화

이번 포스팅에서는 Spring Modulith에 대해서 정리하고자 한다. 서비스가 커지면 늘 같은 갈림길에 선다. 코드가 한 덩어리로 엉켜 손대기 무서운 모놀리스로 갈 것인가, 아니면 운영 복잡도가 폭증하는 마이크로서비스로 쪼갤 것인가. Spring Modulith는 그 사이의 답을 준다. 배포는 하나의 애플리케이션으로 하되, 코드 안에서는 명확한 모듈 경계를 세워 서로 함부로 침범하지 못하게 만드는 것이다. 모듈 경계가 지켜지는지 테스트로 검증하고, 모듈 간 통신은 이벤트로 느슨하게 연결하며, 아키텍처 문서까지 코드에서 자동으로 뽑아낸다. 이 글에서는 Spring Boot 4.1과 Spring Modulith 2.1을 기준으로 모듈 정의와 시작 의존성부터 경계 검증, 하위 패키지 선택 공개, 이벤트 통신과 유실 없는 아웃박스·Kafka 외부화, 문서 자동화, 그리고 실제로 돌려 본 결과까지 실무 순서대로 짚는다.

모놀리스도 MSA도 아닌 제3의 길

Spring Modulith는 하나의 Spring Boot 애플리케이션 안에서 도메인 모듈을 명확히 나눠 관리하게 해주는 공식 툴킷이다. 공식 문서의 표현으로는 “an opinionated toolkit to build domain-driven, modular applications with Spring Boot”다. 배포 단위는 여전히 하나지만, 그 안의 코드를 주문·재고·결제 같은 모듈로 갈라 각 모듈이 다른 모듈의 내부에 직접 손대지 못하도록 규율을 강제한다.

모듈러 모놀리스가 주목받는 이유는 명확하다. 마이크로서비스는 서비스마다 배포·네트워크·데이터 정합성·모니터링을 감당해야 해서, 팀이 작거나 도메인이 아직 안정되지 않았을 때는 배보다 배꼽이 크다(프레임워크 선택 관점의 비교는 Spring Boot vs Quarkus 2026에서 다뤘다). 반대로 전통적 모놀리스는 시간이 지나면 모든 코드가 서로를 참조하는 진흙 덩어리가 돼 한 곳을 고치면 엉뚱한 데가 터진다. Spring Modulith는 “한 프로세스로 배포하는 단순함”과 “모듈로 나눈 명확함”을 함께 취한다. 나중에 특정 모듈이 정말 독립 서비스로 떼어져야 할 때, 경계가 이미 잡혀 있으니 분리도 수월하다.

이 접근은 이벤트 기반 설계와 잘 맞물린다. 모듈끼리 서로의 메서드를 직접 호출하는 대신 이벤트로 소통하면 결합이 느슨해지는데, 이는 Spring Boot Kafka 연동에서 다룬 메시징 사고방식과 이어진다. 실제로 Spring Modulith는 모듈 내부 이벤트를 나중에 Kafka 같은 외부 브로커로 확장하는 길까지 열어 둔다. 이 글의 코드는 Java 21과 Spring Boot 4.1 기준이다.

모듈은 패키지 하나로 정의된다

Spring Modulith에서 애플리케이션 모듈은 별도 설정 없이 패키지 구조로 정의된다. 메인 애플리케이션 클래스가 있는 패키지의 바로 아래 직속 패키지 하나가 곧 하나의 모듈이다. 즉 com.example 아래에 orders, inventory 패키지를 두면 그 둘이 각각 독립된 모듈이 된다.

시작하려면 의존성부터 잡는다. 버전을 한곳에서 관리하도록 spring-modulith-bomdependencyManagement에 넣고, 코어 스타터와 테스트 스타터를 추가한다.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.modulith</groupId>
            <artifactId>spring-modulith-bom</artifactId>
            <version>2.1.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
XML

BOM을 import하면 이후 모든 Modulith 아티팩트의 버전을 생략할 수 있어 버전 충돌을 피한다. starter-core는 모듈 인식과 verify()에 필요한 최소 구성이고, starter-test는 뒤에서 쓸 @ApplicationModuleTest를 제공한다. 이벤트 지속화나 Kafka 외부화가 필요하면 스타터를 바꾸거나 모듈을 더하는데, 그건 뒤에서 다룬다.

com.example
├── Application.java          # 메인 클래스 (애플리케이션 루트)
├── orders                    # ← 'orders' 모듈
│   ├── Order.java
│   ├── OrderService.java     # 모듈 공개 API
│   └── internal              # 하위 패키지는 모듈 내부 (외부 접근 차단)
│       └── OrderRepository.java
└── inventory                 # ← 'inventory' 모듈
    └── InventoryService.java
Plaintext

위 구조에서 ordersinventory는 각각 하나의 모듈이다. 핵심 규칙은 이것이다. 모듈의 직속 패키지에 있는 타입은 공개 API로 다른 모듈이 참조할 수 있지만, orders.internal처럼 하위 패키지에 숨긴 타입은 그 모듈 내부에서만 쓸 수 있다. 그래서 OrderRepositoryinternal 아래에 두면, inventory 모듈이 실수로 그걸 직접 건드리는 코드를 짜는 순간 규칙 위반이 된다. 이 규칙을 코드가 아니라 테스트로 강제하는 게 다음 절의 핵심이다.

경계가 무너지지 않았는지 테스트로 검증하기

모듈 경계는 선언만으로는 지켜지지 않는다. Spring Modulith는 ApplicationModules.of(...).verify() 한 줄로 전체 모듈 구조를 검사해, 순환 의존성이나 내부 타입 무단 접근 같은 위반을 테스트에서 잡아낸다. 이 테스트를 CI에 걸어 두면 경계를 깨는 코드가 머지되는 걸 원천 차단한다.

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;

class ModularityTests {

    // 애플리케이션 전체 모듈 구조를 스캔
    ApplicationModules modules = ApplicationModules.of(Application.class);

    @Test
    void verifiesModularStructure() {
        // 순환 의존성·내부 타입 무단 접근 등 위반이 있으면 예외 발생
        modules.verify();
    }

    @Test
    void printsModuleStructure() {
        modules.forEach(System.out::println);   // 감지된 모듈 목록 출력
    }
}
Java

ApplicationModules.of(Application.class)는 메인 클래스를 기준으로 전체 패키지를 스캔해 모듈을 인식한다. verify()는 그 모듈들 사이에 아키텍처 위반이 있으면 예외를 던진다. 대표적으로 두 모듈이 서로를 참조하는 순환 의존성, 그리고 한 모듈이 다른 모듈의 internal 타입을 직접 import하는 경우가 걸린다. 이 테스트가 통과한다는 건 “설계한 경계가 실제 코드에서도 지켜지고 있다”는 뜻이라, 리팩터링 중 실수로 경계를 허무는 일을 막아 준다.

위반이 실제로 잡히면 메시지가 “누가 어느 모듈의 숨긴 타입을 건드렸는지”를 그대로 짚어 준다. 시험 삼아 inventory 모듈이 orders.internal.OrderRepository를 주입받게 해 봤더니 아래처럼 떨어졌다.

org.springframework.modulith.core.Violations:
- Module 'inventory' depends on non-exposed type
  com.example.orders.internal.OrderRepository within module 'orders'!
Field <...InventoryListener.orders> has type
  <...orders.internal.OrderRepository>
Plaintext

위반 모듈(inventory), 침범당한 모듈(orders), 문제의 타입(OrderRepository), 그리고 그게 필드인지 생성자 파라미터인지까지 찍힌다. 그래서 verify가 빨갛게 뜨는 순간 어디를 고쳐야 하는지 헤맬 필요가 없다. 규칙이 문장이 아니라 실패하는 테스트로 존재하는 것이다.

하위 패키지의 타입도 골라서 공개하기 — @NamedInterface

기본 규칙은 “직속 패키지는 공개, 하위 패키지는 내부”지만, 실무에선 하위 패키지의 특정 타입만 골라 공개하고 싶을 때가 많다. @NamedInterface로 하위 패키지를 이름 붙은 공개 창구로 열고, allowedDependencies로 어떤 모듈이 그 창구에만 접근하도록 화이트리스트를 강제한다. 경계를 “전부 열거나 전부 닫거나”가 아니라 세밀하게 조절하는 장치다.

하위 패키지를 공개하려면 그 패키지의 package-info.java@NamedInterface를 붙인다.

// order/spi/package-info.java — 'spi'라는 이름의 공개 창구로 연다
@org.springframework.modulith.NamedInterface("spi")
package com.example.order.spi;
Java

이제 com.example.order.spi는 base 패키지와 별개로 “spi”라는 이름으로 노출된다. order.internal은 여전히 숨은 채, order.spi에 둔 타입만 다른 모듈이 참조할 수 있다. 공개할 창구와 감출 내부를 패키지 단위로 분리하는 셈이다.

받는 쪽 모듈은 @ApplicationModule(allowedDependencies = ...)로 의존을 명시적으로 제한한다.

// inventory/package-info.java — order 모듈의 'spi' 창구에만 의존 허용
@org.springframework.modulith.ApplicationModule(
        allowedDependencies = "order :: spi"
)
package com.example.inventory;
Java

"모듈명 :: 인터페이스명" 형식이 핵심이다. 이렇게 걸면 inventoryorderspi 창구에만 닿을 수 있고, order의 base 패키지 타입에 손대는 순간 verify가 실패한다. 모듈의 모든 named interface를 허용하려면 "order :: *"를 쓴다. allowedDependencies를 한 번 선언하면 목록에 없는 모듈 의존은 전부 거부되므로, 머릿속에만 있던 “이 모듈은 저 모듈만 알아야 한다”는 의도가 코드로 못박힌다.

모듈 하나만 떼어 테스트하기

모듈 단위로 격리된 테스트도 지원한다. @ApplicationModuleTest를 붙이면 그 모듈과 관련된 빈만 로드한 슬라이스 테스트가 돌아, 전체 애플리케이션 컨텍스트를 띄우지 않고도 해당 모듈을 검증할 수 있다. 다른 모듈에 대한 의존은 목(mock)이나 이벤트로 대체한다.

import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.junit.jupiter.api.Test;

@ApplicationModuleTest
class OrderModuleTest {

    @Autowired OrderService orders;

    @Test
    void 주문을_생성한다() {
        var order = orders.create("keyboard", 2);
        // orders 모듈만 로드된 상태에서 검증
    }
}
Java

@ApplicationModuleTest는 이 테스트 클래스가 속한 모듈(orders)을 자동으로 인식해, 그 모듈의 빈만 올린 컨텍스트를 구성한다. @SpringBootTest가 전체 애플리케이션을 띄우는 것과 달리, 필요한 모듈만 로드하므로 테스트가 빠르고 관심사가 좁아진다. 모듈이 늘어날수록 이 격리 테스트의 이점이 커진다. 전체 통합 테스트만 있으면 모듈 하나를 고쳐도 매번 전체를 띄워야 하지만, 모듈 슬라이스 테스트는 바뀐 모듈만 빠르게 검증한다.

모듈 간 통신은 직접 호출 대신 이벤트로

모듈끼리 서로의 서비스를 직접 호출하면 결합이 다시 강해진다. Spring Modulith는 모듈 간 통신을 스프링 이벤트로 푸는 방식을 권장하며, @ApplicationModuleListener로 다른 모듈이 발행한 이벤트를 받는다. 주문 모듈이 “주문 완료” 이벤트를 던지면 재고 모듈이 그걸 듣고 재고를 차감하는 식이다.

// orders 모듈 — 이벤트 발행
@Service
public class OrderService {

    private final ApplicationEventPublisher events;

    public OrderService(ApplicationEventPublisher events) {
        this.events = events;
    }

    public void complete(String orderId) {
        // ... 주문 완료 처리 ...
        events.publishEvent(new OrderCompleted(orderId));   // 이벤트 발행
    }
}

// inventory 모듈 — 이벤트 수신
@Component
public class InventoryListener {

    @ApplicationModuleListener   // 트랜잭션·비동기가 함께 적용된 리스너
    void on(OrderCompleted event) {
        // 주문 완료를 듣고 재고 차감
    }
}
Java

@ApplicationModuleListener가 이 방식의 핵심이다. 이 애너테이션은 스프링의 @TransactionalEventListener를 비동기·트랜잭션 설정과 함께 묶은 것으로, 발행 모듈의 트랜잭션이 커밋된 뒤에 수신 모듈이 별도 트랜잭션으로 이벤트를 처리한다. 그래서 주문 모듈은 재고 모듈의 존재를 몰라도 되고, 재고 모듈은 주문 모듈의 내부에 의존하지 않는다. 두 모듈은 오직 OrderCompleted 이벤트라는 계약으로만 이어진다. 나중에 이 이벤트를 @Externalized로 표시하면 Kafka 같은 외부 브로커로 내보내, 모듈을 진짜 독립 서비스로 분리하는 발판이 된다.

한 가지 실전에서 걸리기 쉬운 지점이 있다. @ApplicationModuleListenerspring-modulith-starter-core에 들어 있지 않아, 코어 스타터만 넣으면 package org.springframework.modulith.events does not exist 컴파일 에러가 난다. 이 애너테이션을 쓰려면 이벤트 모듈 의존성을 따로 추가해야 한다.

<!-- @ApplicationModuleListener를 쓰려면 이벤트 모듈이 필요하다 -->
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-events-api</artifactId>
</dependency>
XML

spring-modulith-events-api@ApplicationModuleListener·@Externalized 같은 이벤트 API 타입을 제공한다. 이 애너테이션만으로 도는 인메모리 이벤트는 앞에서 본 예제 수준까지는 충분하지만, 프로덕션에서는 한 가지가 더 필요하다. 바로 다음 절에서 다룬다.

이벤트를 잃지 않고 내보내기 — 아웃박스와 Kafka 외부화

인메모리 이벤트에는 약점이 있다. 리스너가 실패하거나 앱이 죽으면 그 이벤트는 그대로 증발한다. Spring Modulith는 이를 막는 **이벤트 발행 레지스트리(Event Publication Registry)**를 제공한다. 트랜잭셔널 아웃박스 패턴으로, 발행된 이벤트를 원본 비즈니스 트랜잭션과 함께 EVENT_PUBLICATION 테이블에 기록하고 리스너가 성공해야 완료로 표시한다. 즉 “주문은 커밋됐는데 재고 이벤트만 유실”되는 사고를 구조적으로 막는다.

지속화를 켜려면 코어 스타터를 JPA(또는 JDBC)용으로 바꾼다.

<!-- 코어 스타터 대신 JPA 스타터 → 이벤트 아웃박스 자동 구성 -->
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
XML

이 스타터가 들어가면 EVENT_PUBLICATION 테이블이 자동 생성되고(스키마 초기화는 spring.modulith.events.jdbc.schema-initialization.enabled로 제어), 모든 @ApplicationModuleListener가 이 레지스트리를 거친다. 리스너가 예외로 실패하면 그 항목은 미완료로 남아 재시도 대상이 된다. 앱 재시작 시 미완료 이벤트를 자동으로 다시 발행하려면 아래 속성을 켠다.

# 재시작 시 완료되지 않은 이벤트를 다시 발행
spring.modulith.events.republish-outstanding-events-on-restart=true

여기서 실전 함정이 하나 있다. 이 자동 재발행을 켠 상태에서 미완료 이벤트를 밀어 주는 커스텀 스케줄러까지 같이 두면, 재시작 순간 같은 이벤트가 중복 발행될 수 있다. 둘 중 하나만 쓰거나, 수신 측을 멱등(idempotent)하게 설계해 두 번 들어와도 결과가 같도록 만들어야 한다.

모듈 내부 이벤트를 진짜 외부 브로커로 내보내는 건 @Externalized 한 줄이면 된다.

// "토픽::메시지키" — 뒤쪽 키는 SpEL로 이벤트 필드를 참조
@Externalized("order-completed::#{#this.orderId()}")
public record OrderCompleted(String orderId) {}
Java

@Externalized("타깃::키")에서 앞은 브로커 토픽, 뒤는 메시지 키이며 SpEL(#this)로 이벤트 값을 꺼내 쓴다. 여기에 spring-modulith-events-kafka와 직렬화용 spring-modulith-events-jackson을 더하면 OrderCompleted가 트랜잭션 커밋 후 Kafka로 발행된다. 아웃박스가 뒤를 받쳐 주므로 “DB엔 저장됐는데 Kafka 발행은 실패”하는 이중 쓰기 문제 없이 발행이 보장된다. 이 지점이 모듈러 모놀리스를 나중에 Kafka 기반 MSA로 확장하는 실제 경로다. 다만 브로커 전송이 실패해도 완료로 표시되던 과거 이슈(#395)처럼 버전별 동작 차이가 있으니, 재시도·멱등 처리는 반드시 직접 검증하고 넘어가야 한다.

아키텍처 문서를 코드에서 뽑아내기

모듈 구조 문서는 손으로 그리면 금세 코드와 어긋난다. Spring Modulith의 Documenter는 실제 모듈 모델에서 C4 스타일 다이어그램과 모듈 명세를 자동으로 생성해, 문서와 코드가 항상 일치하게 한다. 테스트를 한 번 돌리면 각 모듈의 관계도와 의존성 문서가 파일로 떨어진다.

import org.springframework.modulith.docs.Documenter;
import org.junit.jupiter.api.Test;

class DocumentationTests {

    ApplicationModules modules = ApplicationModules.of(Application.class);

    @Test
    void writeDocumentation() {
        new Documenter(modules)
                .writeModulesAsPlantUml()        // 전체 모듈 관계도 (PlantUML/C4)
                .writeIndividualModulesAsPlantUml()   // 모듈별 상세도
                .writeModuleCanvases();          // 모듈 캔버스(공개 API·이벤트 정리)
    }
}
Java

Documenter에 모듈 모델을 넘기고 위 메서드들을 호출하면, target/spring-modulith-docs 아래에 다이어그램과 문서가 생성된다. writeModulesAsPlantUml()은 전체 모듈의 의존 관계를 C4 컴포넌트 다이어그램으로, writeModuleCanvases()는 각 모듈의 공개 API·발행 이벤트·수신 이벤트를 표로 정리한 “모듈 캔버스”를 만든다. 핵심은 이 문서가 실제 코드 모델에서 나온다는 점이다. 모듈을 추가하거나 의존이 바뀌면 다음 테스트 실행 때 문서도 자동으로 갱신되므로, “문서는 옛날 것이라 믿지 마세요” 같은 말이 필요 없어진다.

직접 돌려본 결과 — verify 통과와 문서 자동 생성

여기까지의 내용을 실제 프로젝트로 확인해 봤다. Spring Boot 4.1 + Spring Modulith 2.1로 orders(공개 OrderService + internal.OrderRepository)와 inventory(InventoryListener) 두 모듈을 만들고, ApplicationModules.of(App.class).verify()Documenter를 한 테스트에서 돌렸다. 결과는 아래와 같다.

왼쪽이 모듈 감지와 verify() 결과다. printModuleStructure()orders·inventory를 각각 인식하고, 공개 API인 OrderService+로, internal 패키지에 숨긴 OrderRepositoryo로 구분해 표시한다. 앞서 설계한 “직속 패키지는 공개, 하위 패키지는 내부”라는 경계가 실제 스캔 결과에 그대로 반영된 것이다. 순환 의존성이나 내부 타입 무단 접근이 없어 verify()는 통과하고 BUILD SUCCESS로 끝난다.

오른쪽은 Documenter가 만든 문서다. target/spring-modulith-docs 아래에 전체 관계도(components.puml)와 모듈별 상세도·모듈 캔버스(module-orders.adoc 등)가 파일로 떨어졌다. 캔버스에는 orders 모듈의 공개 빈(OrderService)과 발행 이벤트(OrderCompleted)가 실제 코드에서 추출돼 정리돼 있다. 손으로 그린 게 아니라 코드 모델에서 나온 문서라, 모듈을 바꾸면 다음 실행에서 자동으로 갱신된다. 참고로 앞서 만난 spring-modulith-events-api 의존성 누락도 이 프로젝트를 실제로 컴파일하다 부딪힌 것이다.

FAQ

Spring Modulith는 마이크로서비스와 뭐가 다른가요?

배포 단위가 다르다. 마이크로서비스는 모듈마다 별도 프로세스로 배포해 네트워크로 통신하지만, Spring Modulith는 모든 모듈을 하나의 애플리케이션으로 배포하고 코드 안에서만 경계를 나눈다. 덕분에 분산 시스템의 복잡도(네트워크 실패·데이터 정합성·배포 오케스트레이션) 없이 모듈의 명확함을 얻는다. 나중에 특정 모듈이 독립 서비스로 떼어져야 할 때, 경계가 이미 잡혀 있어 분리도 수월하다.

모듈 경계는 어떻게 강제되나요?

ApplicationModules.of(App.class).verify() 테스트로 강제한다. 모듈의 직속 패키지 타입만 공개 API로 취급되고, 하위 패키지(예: internal)의 타입은 다른 모듈이 참조할 수 없다. 어떤 모듈이 다른 모듈의 내부 타입을 직접 import하거나 두 모듈이 순환 참조하면 이 테스트가 예외를 던진다. CI에 걸어 두면 경계를 깨는 코드가 병합되는 것을 막는다.

기존 Spring Boot 프로젝트에 점진적으로 도입할 수 있나요?

가능하다. Spring Modulith는 패키지 구조를 모듈로 해석할 뿐이라, 코드를 도메인별 패키지로 정리하고 의존성만 추가하면 시작할 수 있다. 처음에는 verify() 테스트가 여러 위반을 잡아낼 텐데, 그것들을 하나씩 정리하면서 경계를 세워 나가면 된다. 한 번에 전체를 바꿀 필요 없이 모듈을 하나씩 잘라 나가는 방식이 현실적이다.

모듈 간 이벤트는 트랜잭션이 어떻게 처리되나요?

@ApplicationModuleListener는 발행 모듈의 트랜잭션이 커밋된 뒤에 실행되고, 수신 처리는 별도 트랜잭션에서 비동기로 돈다. 즉 주문 저장이 성공적으로 커밋돼야 재고 차감 리스너가 호출되므로, 주문은 실패했는데 재고만 줄어드는 사고가 방지된다. 이벤트 처리 자체가 실패하거나 앱이 죽는 경우를 대비해 spring-modulith-starter-jpa를 넣으면 발행 이력이 EVENT_PUBLICATION 테이블에 남아 재시도되는데, 이 아웃박스 동작은 본문 “이벤트를 잃지 않고 내보내기” 절에서 자세히 다뤘다.

생성된 문서는 어디에 쓰나요?

새 팀원 온보딩과 아키텍처 리뷰에 쓴다. Documenter가 만든 C4 다이어그램은 “이 시스템에 어떤 모듈이 있고 서로 어떻게 의존하는가”를 한눈에 보여주고, 모듈 캔버스는 각 모듈의 공개 API와 주고받는 이벤트를 정리한다. 코드에서 자동 생성되므로 항상 최신이라, 위키에 박제된 낡은 다이어그램과 달리 실제 구조와 어긋나지 않는다.

마치며

지금까지 Spring Modulith로 모듈러 모놀리스를 구축하는 과정을 정리해 보았다. 개인적으로 이 도구의 진짜 가치를 느낀 건 화려한 기능이 아니라 verify() 테스트 하나였다. 예전에는 “이 패키지는 저기 참조하면 안 된다”는 규칙을 팀 위키에 적어 두고 코드 리뷰에서 눈으로 잡았는데, 사람이 놓치면 그대로 새어 나갔다. Modulith를 붙인 뒤로는 경계를 넘는 순간 테스트가 빨갛게 뜨니, 규칙이 문서가 아니라 실행되는 코드가 됐다.

도입을 고민한다면 마이크로서비스로 가기 전에 한 번쯤 들러 볼 정류장으로 생각하길 권한다. 대부분의 서비스는 처음부터 분산 시스템이 필요하지 않고, 모듈 경계가 잘 잡힌 모놀리스만으로도 충분히 오래 간다. 그러다 정말 특정 모듈이 독립해야 할 순간이 오면, 이미 이벤트로 느슨하게 연결해 둔 경계 덕에 분리가 쉬워진다. 처음부터 쪼개는 대신, 잘 나눠 두고 필요할 때 떼어 내는 편이 훨씬 현실적이다.