Spring Modulith — 모놀리스 안에서 모듈 경계를 지키는 방법
모놀리스가 커지면서 모든 패키지가 서로 얽혀 있다면, 마이크로서비스로 분리하지 않고도 구조를 개선할 수 있는 방법이 있을까요?
개념 정의
Spring Modulith 는 모놀리스 애플리케이션 내부에서 논리적 모듈 경계를 정의하고 검증하는 프레임워크입니다. 마이크로서비스로 분리하지 않아도 모듈 간 결합도를 낮추고, 필요할 때 분리할 수 있는 구조를 만들어줍니다.
왜 필요한가
모놀리스의 흔한 문제:
- 서비스 A가 서비스 B의 내부 구현을 직접 호출 → 변경 영향 범위 예측 불가
- ** 순환 참조 **: order → payment → order
- ** 하나의 변경이 전체에 영향** → 배포 부담 증가
Modulith는 이런 문제를 ** 빌드/테스트 시점에 감지 **합니다.
의존성
// build.gradle
implementation 'org.springframework.modulith:spring-modulith-starter-core'
testImplementation 'org.springframework.modulith:spring-modulith-starter-test'
// 이벤트 영속화가 필요하면
implementation 'org.springframework.modulith:spring-modulith-starter-jpa'
모듈 구조
메인 애플리케이션 클래스의 하위 패키지가 각각 하나의 모듈이 됩니다.
com.example.shop/
├── ShopApplication.java ← 메인 클래스
├── order/ ← order 모듈
│ ├── Order.java ← 공개 API (루트 패키지)
│ ├── OrderService.java ← 공개 API
│ └── internal/ ← 내부 구현 (외부 접근 금지)
│ ├── OrderRepository.java
│ └── OrderValidator.java
├── payment/ ← payment 모듈
│ ├── PaymentService.java
│ └── internal/
│ └── PaymentGateway.java
├── inventory/ ← inventory 모듈
│ ├── InventoryService.java
│ └── internal/
│ └── StockRepository.java
└── notification/ ← notification 모듈
├── NotificationService.java
└── internal/
└── EmailSender.java
** 규칙:**
- 모듈의 ** 루트 패키지 **(예:
order/)에 있는 클래스는 다른 모듈에서 접근 가능 internal/하위 패키지는 해당 모듈 내부에서만 접근 가능- 모듈 간에는 루트 패키지의 ** 공개 API**만 사용
모듈 의존성 검증
@Test
void verifyModuleStructure() {
ApplicationModules modules =
ApplicationModules.of(ShopApplication.class);
// 모듈 경계 위반, 순환 참조 검증
modules.verify();
}
@Test
void printModuleStructure() {
ApplicationModules modules =
ApplicationModules.of(ShopApplication.class);
// 모듈 구조 출력
modules.forEach(System.out::println);
}
출력 예:
## order ##
> Logical name: order
> Base package: com.example.shop.order
> Direct dependencies: [payment, inventory]
> Spring beans: [OrderService, internal.OrderRepository, internal.OrderValidator]
## payment ##
> Logical name: payment
> Base package: com.example.shop.payment
> Direct dependencies: []
위반 시 에러 예시
org.springframework.modulith.core.Violations:
- Module 'payment' depends on non-exposed type
com.example.shop.order.internal.OrderRepository within module 'order'
payment 모듈이 order 모듈의 내부 클래스(internal.OrderRepository)에 접근하면 검증이 실패합니다.
이벤트 기반 모듈 간 통신
직접 호출 대신 이벤트를 발행하여 모듈 간 결합도를 제거합니다.
이벤트 정의
// order 모듈의 공개 API (루트 패키지)
public record OrderCompleted(
Long orderId,
Long customerId,
BigDecimal totalAmount,
LocalDateTime completedAt
) {}
이벤트 발행
// order 모듈
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher events;
@Transactional
public Order completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete();
orderRepository.save(order);
이어서 이벤트 발행 및 처리 로직을 구현합니다.
// 이벤트 발행 — 누가 구독하는지 알 필요 없음
events.publishEvent(new OrderCompleted(
order.getId(),
order.getCustomerId(),
order.getTotalAmount(),
LocalDateTime.now()
));
return order;
}
}
이벤트 구독
// notification 모듈 — order 모듈을 직접 의존하지 않음
@Component
public class OrderNotificationHandler {
@ApplicationModuleListener // @EventListener + @Async + @Transactional
void on(OrderCompleted event) {
emailService.send(
event.customerId(),
"주문이 완료되었습니다. 주문번호: " + event.orderId()
);
}
}
이어서 이벤트를 구독하는 리스너를 정의합니다.
// inventory 모듈
@Component
public class InventoryHandler {
@ApplicationModuleListener
void on(OrderCompleted event) {
stockService.decreaseStock(event.orderId());
}
}
@ApplicationModuleListener는 @EventListener + @Async + @Transactional을 결합한 편의 어노테이션입니다.
Event Publication Log — 이벤트 영속화
이벤트 리스너가 실패하면 이벤트가 유실될 수 있습니다. Event Publication Log는 이벤트를 DB에 저장하여 재처리를 보장합니다.
// build.gradle
implementation 'org.springframework.modulith:spring-modulith-starter-jpa'
# application.yml
spring:
modulith:
republish-outstanding-events-on-restart: true # 재시작 시 미완료 이벤트 재발행
events:
jdbc:
schema-initialization:
enabled: true # 테이블 자동 생성
동작 흐름:
- 이벤트 발행 시 DB의
EVENT_PUBLICATION테이블에 저장 - 리스너가 성공적으로 처리하면 완료 표시
- 리스너가 실패하면 미완료 상태로 남아 있음
- 애플리케이션 재시작 시 미완료 이벤트를 재발행
모듈 테스트
@ApplicationModuleTest
@ApplicationModuleTest // order 모듈만 로드
class OrderModuleTest {
@Autowired
private OrderService orderService;
@Test
void completeOrder_publishesEvent(Scenario scenario) {
// given
Long orderId = createTestOrder();
이어서 이벤트 발행 및 처리 로직을 구현합니다.
// when & then — 이벤트 발행 검증
scenario.stimulate(() -> orderService.completeOrder(orderId))
.andWaitForEventOfType(OrderCompleted.class)
.matching(event -> event.orderId().equals(orderId))
.toArriveAndVerify(event -> {
assertThat(event.totalAmount())
.isGreaterThan(BigDecimal.ZERO);
});
}
}
부트스트랩 모드
@ApplicationModuleTest(mode = BootstrapMode.DIRECT_DEPENDENCIES)
// STANDALONE: 해당 모듈만
// DIRECT_DEPENDENCIES: 직접 의존하는 모듈 포함
// ALL_DEPENDENCIES: 모든 의존 모듈 포함
모듈 문서 자동 생성
@Test
void generateDocumentation() {
ApplicationModules modules =
ApplicationModules.of(ShopApplication.class);
// PlantUML, Asciidoc 형식의 문서 생성
new Documenter(modules)
.writeModulesAsPlantUml() // 모듈 다이어그램
.writeIndividualModulesAsPlantUml() // 개별 모듈 다이어그램
.writeModuleCanvases(); // 모듈 캔버스
}
생성된 PlantUML은 모듈 간 의존 관계를 시각적으로 보여줍니다.
외부 시스템으로 이벤트 전파
Modulith의 이벤트를 Kafka, RabbitMQ, SNS 등 외부 메시지 브로커로 자동 전파할 수 있습니다.
// build.gradle
implementation 'org.springframework.modulith:spring-modulith-events-kafka'
// 이벤트에 외부화 어노테이션 추가
@Externalized("order-events::#{orderId()}") // topic::routing-key
public record OrderCompleted(
Long orderId,
Long customerId,
BigDecimal totalAmount
) {}
이 설정만으로 OrderCompleted 이벤트가 발행되면 자동으로 Kafka의 order-events 토픽에 메시지가 전송됩니다.
Modulith vs 멀티 모듈 프로젝트
| 기준 | Spring Modulith | Gradle/Maven 멀티 모듈 |
|---|---|---|
| 경계 적용 | 런타임 검증 (테스트) | 컴파일 타임 강제 |
| 설정 복잡도 | 낮음 (패키지만 정리) | 높음 (빌드 설정) |
| 이벤트 지원 | 내장 | 직접 구현 |
| 적합한 시점 | 초기~중기 | 규모가 큰 프로젝트 |
둘을 함께 사용할 수도 있습니다. Gradle 멀티 모듈로 물리적 경계를 만들고, Modulith로 논리적 규칙을 검증하는 조합이 가능합니다.
실무 도입 전략
- ** 현재 패키지 구조 분석 **:
ApplicationModules.of()로 현재 모듈 의존성 파악 - ** 순환 참조 제거 **: 이벤트 기반으로 전환하거나 공통 모듈 추출
- **internal 패키지 분리 **: 외부에 노출할 필요 없는 클래스를
internal/로 이동 - **verify() 테스트 추가 **: CI에서 매번 검증
- ** 점진적 이벤트 전환 **: 모듈 간 직접 호출을 하나씩 이벤트로 교체
주의할 점
1. Event Publication Log를 설정하지 않으면 비동기 이벤트가 유실된다
@ApplicationModuleListener는 비동기로 실행되므로 리스너가 실패하면 이벤트가 사라집니다. Event Publication Log(JPA 또는 JDBC)를 설정하지 않으면 재시도가 불가능하여, 결제 완료 후 재고 차감이 누락되는 등의 데이터 불일치가 발생합니다. 중요한 이벤트 처리에는 반드시 spring-modulith-starter-jpa로 이벤트 영속화를 활성화해야 합니다.
2. verify() 테스트를 CI에 넣지 않으면 모듈 경계가 점진적으로 무너진다
ApplicationModules.of(App.class).verify()를 테스트로 작성해도 CI에서 실행하지 않으면, 개발자가 급한 작업 중에 internal 패키지의 클래스를 외부에서 직접 참조하기 시작합니다. 한 번 경계가 무너지면 의존성이 엉키면서 Modulith 도입의 의미가 사라집니다. verify 테스트를 CI 필수 단계로 설정해야 합니다.
3. 모듈 간 동기 이벤트와 비동기 이벤트를 혼동하면 트랜잭션이 꼬인다
@EventListener(동기)와 @ApplicationModuleListener(비동기+새 트랜잭션)는 트랜잭션 참여 방식이 완전히 다릅니다. 동기 이벤트 리스너에서 예외가 발생하면 발행자의 트랜잭션도 롤백되고, 비동기 리스너는 별도 트랜잭션에서 실행됩니다. 이 차이를 인지하지 못하면 "리스너 실패 시 주문도 롤백"되거나 "주문 롤백됐는데 알림은 발송됨" 같은 불일치가 발생합니다.
정리
| 항목 | 설명 |
|---|---|
| 모듈 경계 | 루트 패키지 = 공개 API, internal/ = 내부 구현 |
| 검증 | verify()로 의존성 위반, 순환 참조 빌드 시점 감지 |
| 모듈 간 통신 | 이벤트 발행/구독 (@ApplicationModuleListener) |
| 이벤트 안전성 | Event Publication Log로 실패 시 재시도 보장 |
| 테스트 | @ApplicationModuleTest로 모듈 단위 격리 테스트 |
| 전략 | MSA 전환 전에 Modulith로 모듈 경계 먼저 정리 |