# Spring Modulith: 모듈러 모놀리스 아키텍처 해설
> Spring Modulith로 자바 모듈러 모놀리스를 구축하는 방법을 배웁니다. 아키텍처, 모듈, 비동기 이벤트, Spring Boot 3 예제로 살펴보는 테스트.
- Published: 2026-03-23
- Updated: 2026-05-03
- Author: SharpSkill
- Tags: spring modulith, modular architecture, spring boot, java, modular monolith
- Reading time: 18 min
---
Spring Modulith는 Spring Boot 애플리케이션을 응집도 높은 비즈니스 모듈로 구조화하기 위한 실용적인 접근을 제공합니다. 이 아키텍처는 모듈러 모놀리스를 전통적인 모놀리스와 마이크로서비스 사이에 위치시키며, 분산 시스템의 운영 복잡성 없이도 견고한 모듈화를 제공합니다.
> **핵심 통찰**
>
> Spring Modulith는 헥사고날 아키텍처와 도메인 주도 설계의 모범 사례를 Spring Boot에 직접 형식화하며, 모듈 간 의존성을 자동으로 검증합니다.
## 왜 모듈러 모놀리스를 선택해야 하는가
### 전통적 모놀리스의 문제
전통적 모놀리스는 컴포넌트 간 과도한 결합으로 어려움을 겪습니다. 시간이 흐르면서 교차 의존성이 누적되어 애플리케이션을 유지 보수가 불가능한 "빅 볼 오브 머드"로 바꾸어 놓습니다. 빌링 모듈의 변경이 사용자 모듈에 영향을 주고, 다시 알림 모듈로 번지면서 예측 불가능한 부수효과를 만들어 냅니다.
```java
// AntiPattern.java
// Direct coupling between modules - AVOID THIS
@Service
public class OrderService {
// Direct dependencies to other modules
// Creates tight coupling and dependency cycles
private final UserRepository userRepository;
private final InventoryService inventoryService;
private final PaymentProcessor paymentProcessor;
private final NotificationService notificationService;
private final ShippingCalculator shippingCalculator;
public OrderService(UserRepository userRepository,
InventoryService inventoryService,
PaymentProcessor paymentProcessor,
NotificationService notificationService,
ShippingCalculator shippingCalculator) {
this.userRepository = userRepository;
this.inventoryService = inventoryService;
this.paymentProcessor = paymentProcessor;
this.notificationService = notificationService;
this.shippingCalculator = shippingCalculator;
}
public Order createOrder(OrderRequest request) {
// This service knows too many implementation details
User user = userRepository.findById(request.userId()).orElseThrow();
inventoryService.reserveItems(request.items());
BigDecimal shipping = shippingCalculator.calculate(user.getAddress());
paymentProcessor.charge(user, request.total().add(shipping));
notificationService.sendOrderConfirmation(user, request);
// ...
return null;
}
}
```
이 패턴은 구체적인 문제를 야기합니다. 깨지기 쉬운 통합 테스트, 변경 영향 추론의 어려움, 그리고 모듈을 독립적으로 배포하거나 진화시키지 못하는 한계입니다.
### 마이크로서비스가 항상 정답은 아니다
마이크로서비스는 결합 문제를 해결하지만, 네트워크 통신, 결과적 일관성, 분산 배포, 다중 서비스 관측성 등 상당한 운영 복잡성을 동반합니다. 많은 팀에게 이 복잡성은 얻는 이점에 비례하지 않습니다.
모듈러 모놀리스는 대안을 제시합니다. 명확하게 정의되고 강제되는 모듈 경계를 가진 단일 배포 단위입니다. Spring Modulith는 이러한 경계의 검증을 자동화합니다.
## Spring Modulith 시작하기
### 프로젝트 설정
Spring Modulith를 Spring Boot 3 프로젝트에 통합하려면 몇 가지 Maven 의존성이 필요합니다. 메인 스타터가 자동 모듈 감지를 활성화합니다.
```xml
org.springframework.modulith
spring-modulith-starter-core
org.springframework.modulith
spring-modulith-starter-jpa
org.springframework.modulith
spring-modulith-starter-test
test
org.springframework.modulith
spring-modulith-docs
test
org.springframework.modulith
spring-modulith-bom
1.3.0
pom
import
```
### 모듈 구조
Spring Modulith는 애플리케이션의 메인 패키지 바로 아래의 패키지로부터 모듈을 자동으로 감지합니다. 각 하위 패키지는 자체 책임을 가진 별도의 모듈을 의미합니다.
```
com.example.shop/
├── ShopApplication.java # Spring Boot entry point
├── order/ # Order Module
│ ├── Order.java # Public entity (module API)
│ ├── OrderService.java # Public service
│ ├── internal/ # Module-internal package
│ │ ├── OrderRepository.java
│ │ └── OrderValidator.java
│ └── OrderCreatedEvent.java # Published event
├── inventory/ # Inventory Module
│ ├── InventoryService.java
│ ├── Product.java
│ └── internal/
│ └── StockRepository.java
├── customer/ # Customer Module
│ ├── Customer.java
│ ├── CustomerService.java
│ └── internal/
│ └── CustomerRepository.java
└── notification/ # Notification Module
├── NotificationService.java
└── internal/
└── EmailSender.java
```
이 관례는 핵심 규칙을 정의합니다. 모듈 루트 패키지에 위치한 클래스(즉, `internal/`이 아닌)만이 다른 모듈에서 접근 가능한 공개 API를 구성합니다.
```java
// Order.java
// Public entity of Order module - accessible from other modules
package com.example.shop.order;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "orders")
public class Order {
@Id
private UUID id;
// Reference by ID rather than entity
// Avoids direct coupling with Customer module
private UUID customerId;
private BigDecimal totalAmount;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime createdAt;
protected Order() {
// JPA constructor
}
public Order(UUID customerId, BigDecimal totalAmount) {
this.id = UUID.randomUUID();
this.customerId = customerId;
this.totalAmount = totalAmount;
this.status = OrderStatus.PENDING;
this.createdAt = LocalDateTime.now();
}
// Public getters - part of module API
public UUID getId() { return id; }
public UUID getCustomerId() { return customerId; }
public BigDecimal getTotalAmount() { return totalAmount; }
public OrderStatus getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
// Encapsulated business methods
void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Only pending orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
}
}
```
```java
// OrderRepository.java
// Internal repository - NOT accessible from other modules
package com.example.shop.order.internal;
import com.example.shop.order.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
// This repository is in the internal package
// Spring Modulith prohibits access from other modules
interface OrderRepository extends JpaRepository {
// Methods specific to the Order module
List findByCustomerIdAndStatus(UUID customerId, OrderStatus status);
}
```
리포지토리는 내부에 머무릅니다. 데이터 접근은 공개 서비스를 통해 이루어져야 하며, 이를 통해 비즈니스 로직의 캡슐화가 보장됩니다.
> **명명 규약**
>
> `internal` 패키지는 자바에서 특별한 의미가 없습니다. Spring Modulith가 인식하고 테스트 중에 자동으로 검증하는 관례일 뿐이며, 위반이 발생하면 명시적인 오류가 발생합니다.
## 모듈 간 통신
### 도메인 이벤트
모듈 간 통신은 직접 호출이 아닌 도메인 이벤트를 통해 이루어집니다. 이 패턴은 발신 모듈과 수신 모듈을 분리합니다.
```java
// OrderCreatedEvent.java
// Event published by Order module
package com.example.shop.order;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
// Immutable record representing the event
// Contains only information needed by consumers
public record OrderCreatedEvent(
UUID orderId,
UUID customerId,
BigDecimal totalAmount,
LocalDateTime createdAt
) {
// Factory method to create event from entity
public static OrderCreatedEvent from(Order order) {
return new OrderCreatedEvent(
order.getId(),
order.getCustomerId(),
order.getTotalAmount(),
order.getCreatedAt()
);
}
}
```
```java
// OrderService.java
// Public service that publishes events
package com.example.shop.order;
import com.example.shop.order.internal.OrderRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.UUID;
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepository,
ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
public Order createOrder(UUID customerId, BigDecimal amount) {
// Create the order
Order order = new Order(customerId, amount);
order = orderRepository.save(order);
// Publish the event
// Interested modules will react asynchronously
eventPublisher.publishEvent(OrderCreatedEvent.from(order));
return order;
}
public Order confirmOrder(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.confirm();
// Confirmation event
eventPublisher.publishEvent(new OrderConfirmedEvent(
order.getId(),
order.getCustomerId()
));
return order;
}
}
```
다른 모듈은 Order 모듈의 구현 세부 사항을 알지 못한 채 이러한 이벤트를 소비합니다.
```java
// NotificationEventListener.java
// Notification module consuming Order events
package com.example.shop.notification.internal;
import com.example.shop.order.OrderCreatedEvent;
import com.example.shop.order.OrderConfirmedEvent;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class NotificationEventListener {
private final EmailSender emailSender;
private final CustomerLookup customerLookup;
NotificationEventListener(EmailSender emailSender,
CustomerLookup customerLookup) {
this.emailSender = emailSender;
this.customerLookup = customerLookup;
}
// @ApplicationModuleListener guarantees async processing
// and event persistence for retry on failure
@ApplicationModuleListener
void onOrderCreated(OrderCreatedEvent event) {
// Retrieve email via local interface
// Avoids direct dependency on Customer module
String email = customerLookup.getEmailByCustomerId(event.customerId());
emailSender.send(
email,
"Order Received",
"Your order #%s has been received.".formatted(event.orderId())
);
}
@ApplicationModuleListener
void onOrderConfirmed(OrderConfirmedEvent event) {
String email = customerLookup.getEmailByCustomerId(event.customerId());
emailSender.send(
email,
"Order Confirmed",
"Your order #%s is confirmed and being prepared."
.formatted(event.orderId())
);
}
}
```
### 영속화된 비동기 이벤트
Spring Modulith는 강력한 기능을 제공합니다. 바로 이벤트의 영속화입니다. 이벤트는 발행 전에 데이터베이스에 저장되어, 애플리케이션이 충돌하더라도 처리가 보장됩니다.
```java
// EventPublicationConfig.java
// Persisted events configuration
package com.example.shop.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.modulith.events.config.EnablePersistentDomainEvents;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
@EnablePersistentDomainEvents // Enables event persistence
public class EventPublicationConfig {
// Spring Modulith automatically creates required tables
// EVENT_PUBLICATION stores pending events
// Processed events are marked as completed
}
```
```java
// InventoryEventListener.java
// Listener with transactional event handling
package com.example.shop.inventory.internal;
import com.example.shop.order.OrderCreatedEvent;
import com.example.shop.order.OrderCancelledEvent;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
class InventoryEventListener {
private final StockRepository stockRepository;
InventoryEventListener(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
// Transactional processing - if exception thrown,
// event will be retried automatically
@ApplicationModuleListener
@Transactional
void onOrderCreated(OrderCreatedEvent event) {
// Reserve stock for this order
// On failure, event remains in EVENT_PUBLICATION
// and will be reprocessed in next cycle
reserveStockForOrder(event.orderId(), event.items());
}
@ApplicationModuleListener
@Transactional
void onOrderCancelled(OrderCancelledEvent event) {
// Release reserved stock
releaseStockForOrder(event.orderId());
}
private void reserveStockForOrder(UUID orderId, List items) {
for (OrderItem item : items) {
Stock stock = stockRepository.findByProductId(item.productId())
.orElseThrow(() -> new StockNotFoundException(item.productId()));
stock.reserve(item.quantity());
stockRepository.save(stock);
}
}
private void releaseStockForOrder(UUID orderId) {
// Stock release implementation
}
}
```
Spring Modulith가 자동으로 생성하는 `EVENT_PUBLICATION` 테이블:
```sql
-- EVENT_PUBLICATION table structure (PostgreSQL)
CREATE TABLE event_publication (
id UUID PRIMARY KEY,
listener_id VARCHAR(512) NOT NULL,
event_type VARCHAR(512) NOT NULL,
serialized_event TEXT NOT NULL,
publication_date TIMESTAMP NOT NULL,
completion_date TIMESTAMP
);
-- Index for retry queries
CREATE INDEX idx_event_publication_incomplete
ON event_publication (completion_date)
WHERE completion_date IS NULL;
```
### 모듈 간 노출 인터페이스
모듈이 이벤트를 사용하지 않고 다른 모듈의 정보를 필요로 할 때, 출처 모듈에 공개 인터페이스를 두면 결합을 최소한으로 유지할 수 있습니다.
```java
// CustomerLookup.java
// Public interface of Customer module
package com.example.shop.customer;
import java.util.Optional;
import java.util.UUID;
// Interface exposed to other modules
// Defines contract without exposing implementation details
public interface CustomerLookup {
Optional findEmailById(UUID customerId);
boolean exists(UUID customerId);
// Specific DTO for shared information
Optional findInfoById(UUID customerId);
record CustomerInfo(
UUID id,
String email,
String fullName,
String preferredLanguage
) {}
}
```
```java
// CustomerLookupImpl.java
// Internal implementation
package com.example.shop.customer.internal;
import com.example.shop.customer.CustomerLookup;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.UUID;
@Component
class CustomerLookupImpl implements CustomerLookup {
private final CustomerRepository customerRepository;
CustomerLookupImpl(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
@Override
public Optional findEmailById(UUID customerId) {
return customerRepository.findById(customerId)
.map(Customer::getEmail);
}
@Override
public boolean exists(UUID customerId) {
return customerRepository.existsById(customerId);
}
@Override
public Optional findInfoById(UUID customerId) {
return customerRepository.findById(customerId)
.map(customer -> new CustomerInfo(
customer.getId(),
customer.getEmail(),
customer.getFullName(),
customer.getPreferredLanguage()
));
}
}
```
이 접근 방식 덕분에 Notification 모듈은 리포지토리나 Customer 엔티티에 직접 의존하지 않고도 고객 정보에 접근할 수 있습니다.
## 모듈 구조 테스트
### 의존성 자동 검증
Spring Modulith는 아키텍처 규칙이 준수되는지 검증할 테스트 도구를 제공합니다. 한 모듈이 다른 모듈의 내부 클래스에 접근하면 이 테스트는 실패합니다.
```java
// ModularityTests.java
// Modular architecture verification tests
package com.example.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;
class ModularityTests {
// Load application module structure
private final ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
@Test
void verifyModularStructure() {
// Verify all modules are correctly structured
// Fails if a module accesses internal packages of another
modules.verify();
}
@Test
void printModuleOverview() {
// Print module structure to console
// Useful for understanding dependencies
modules.forEach(System.out::println);
}
@Test
void createModuleDocumentation() {
// Generate automatic module documentation
// Includes dependency diagrams
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
@Test
void detectCyclicDependencies() {
// The verify() method also detects cycles
// Module A → Module B → Module C → Module A = failure
modules.verify();
}
}
```
`modules.verify()` 실행은 바이트코드를 분석하여 다음을 감지합니다:
- 다른 모듈에서 `internal` 패키지로의 접근
- 모듈 간 순환 의존
- 캡슐화 규칙 위반
### 모듈 단위 통합 테스트
Spring Modulith는 필요한 빈만 로드하여 각 모듈을 격리해 테스트할 수 있게 해줍니다.
```java
// OrderModuleIntegrationTests.java
// Order module integration test in isolation
package com.example.shop.order;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.Scenario;
import java.math.BigDecimal;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@ApplicationModuleTest // Load only Order module and its dependencies
class OrderModuleIntegrationTests {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrder() {
// Given
UUID customerId = UUID.randomUUID();
BigDecimal amount = new BigDecimal("99.99");
// When
Order order = orderService.createOrder(customerId, amount);
// Then
assertThat(order.getId()).isNotNull();
assertThat(order.getCustomerId()).isEqualTo(customerId);
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
}
@Test
void shouldPublishEventOnOrderCreation(Scenario scenario) {
// Given
UUID customerId = UUID.randomUUID();
// When / Then - verify event is published
scenario.stimulate(() -> orderService.createOrder(customerId, BigDecimal.TEN))
.andWaitForEventOfType(OrderCreatedEvent.class)
.matching(event -> event.customerId().equals(customerId))
.toArriveAndVerify(event -> {
assertThat(event.orderId()).isNotNull();
assertThat(event.totalAmount()).isEqualTo(BigDecimal.TEN);
});
}
@Test
void shouldHandleOrderConfirmation(Scenario scenario) {
// Given - create an order
Order order = orderService.createOrder(UUID.randomUUID(), BigDecimal.TEN);
// When / Then - confirm and verify event
scenario.stimulate(() -> orderService.confirmOrder(order.getId()))
.andWaitForEventOfType(OrderConfirmedEvent.class)
.toArriveAndVerify(event -> {
assertThat(event.orderId()).isEqualTo(order.getId());
});
}
}
```
`@ApplicationModuleTest` 애너테이션은 다음을 자동으로 구성합니다:
- Order 모듈 빈만 로딩
- 다른 모듈로의 의존에 대한 목
- 이벤트 테스트용 인프라
```java
// OrderNotificationIntegrationTest.java
// Inter-module integration test
package com.example.shop;
import com.example.shop.order.OrderService;
import com.example.shop.order.OrderCreatedEvent;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.Scenario;
import org.springframework.modulith.test.ApplicationModuleTest.BootstrapMode;
import java.math.BigDecimal;
import java.util.UUID;
// DIRECT loads all directly dependent modules
@ApplicationModuleTest(BootstrapMode.DIRECT)
class OrderNotificationIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void shouldTriggerNotificationOnOrderCreated(Scenario scenario) {
// This test verifies Order → Notification integration
UUID customerId = UUID.randomUUID();
scenario.stimulate(() -> orderService.createOrder(customerId, BigDecimal.TEN))
.andWaitForEventOfType(OrderCreatedEvent.class)
.toArriveAndVerify(event -> {
// Event was processed by NotificationEventListener
// Test verifies email was sent
});
}
}
```
> **테스트 격리**
>
> 모듈 단위 테스트에는 기본값 `BootstrapMode.STANDALONE`을 사용하세요. `BootstrapMode.ALL_DEPENDENCIES`는 숨겨진 의존성을 피하기 위해 엔드 투 엔드 통합 테스트에 한정해 사용합니다.
## 고급 모듈 구성
### @ApplicationModule 기반의 명시적 모듈
복잡한 사례에서는 `@ApplicationModule` 애너테이션이 모듈 규칙을 명시적으로 구성할 수 있게 해줍니다.
```java
// package-info.java
// Explicit Order module configuration
@org.springframework.modulith.ApplicationModule(
// Modules allowed to depend on this one
allowedDependencies = {"customer", "inventory"},
// Module type: OPEN (free access) or CLOSED (explicit API)
type = Type.CLOSED
)
package com.example.shop.order;
import org.springframework.modulith.ApplicationModule.Type;
```
```java
// NamedInterface.java
// Named interface definition for finer API control
package com.example.shop.order;
import org.springframework.modulith.NamedInterface;
// Exposes only certain classes as public API
@NamedInterface("order-api")
public class OrderApi {
// Classes in this package are accessible via "order-api"
}
```
```java
// package-info.java
// Module depending on a specific named interface
@org.springframework.modulith.ApplicationModule(
allowedDependencies = "order::order-api" // Access limited to named API
)
package com.example.shop.shipping;
```
### 순환 의존 처리
모듈 간 순환 의존은 흔히 설계 문제를 의미합니다. Spring Modulith는 이를 감지하고 검증을 실패시킵니다. 해결책은 보통 새 모듈을 추출하거나 이벤트를 사용하는 것입니다.
```java
// BEFORE - Circular dependency
// Order → Inventory (to check stock)
// Inventory → Order (to know current orders)
// AFTER - Resolution through events
// Order publishes OrderCreatedEvent
// Inventory listens and reserves stock
// Inventory publishes StockReservedEvent
// Order listens and confirms availability
```
```java
// StockReservedEvent.java
// Event published by Inventory
package com.example.shop.inventory;
import java.util.UUID;
public record StockReservedEvent(
UUID orderId,
UUID productId,
int quantity,
boolean success,
String failureReason
) {}
```
```java
// OrderStockListener.java
// Order module listens to Inventory events
package com.example.shop.order.internal;
import com.example.shop.inventory.StockReservedEvent;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class OrderStockListener {
private final OrderRepository orderRepository;
OrderStockListener(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@ApplicationModuleListener
void onStockReserved(StockReservedEvent event) {
Order order = orderRepository.findById(event.orderId())
.orElseThrow();
if (event.success()) {
order.markStockReserved();
} else {
order.markStockUnavailable(event.failureReason());
}
orderRepository.save(order);
}
}
```
## 관측성과 모니터링
### 이벤트 트레이싱
Spring Modulith는 모듈 간 이벤트의 분산 트레이싱을 위해 Micrometer와 통합됩니다.
```java
// ObservabilityConfig.java
// Module observability configuration
package com.example.shop.config;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.modulith.observability.ModuleEventListener;
@Configuration
public class ObservabilityConfig {
@Bean
ModuleEventListener moduleEventListener(ObservationRegistry registry) {
// Adds spans for each processed event
return new ModuleEventListener(registry);
}
}
```
```yaml
# application.yml
# Observability configuration
management:
tracing:
sampling:
probability: 1.0 # Trace all events in dev
endpoints:
web:
exposure:
include: health,info,metrics,modulith
spring:
modulith:
events:
# Retry interval for failed events
republish-outstanding-events-on-restart: true
# Retention duration for completed events
completion-mode: DELETE # or ARCHIVE
```
### 모듈 Actuator 엔드포인트
Spring Modulith는 운영 환경에서 모듈 상태를 시각화하기 위한 Actuator 엔드포인트를 노출합니다.
```java
// ModulithActuatorConfig.java
// Actuator endpoint activation
package com.example.shop.config;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.modulith.actuator.ApplicationModulesEndpoint;
import org.springframework.modulith.core.ApplicationModules;
@Configuration
public class ModulithActuatorConfig {
@Bean
@ConditionalOnAvailableEndpoint
ApplicationModulesEndpoint modulesEndpoint(ApplicationModules modules) {
return new ApplicationModulesEndpoint(modules);
}
}
```
`/actuator/modulith` 엔드포인트는 다음을 반환합니다:
```json
{
"modules": [
{
"name": "order",
"basePackage": "com.example.shop.order",
"dependencies": ["customer"],
"publishedEvents": [
"com.example.shop.order.OrderCreatedEvent",
"com.example.shop.order.OrderConfirmedEvent"
],
"listenedEvents": [
"com.example.shop.inventory.StockReservedEvent"
]
},
{
"name": "inventory",
"basePackage": "com.example.shop.inventory",
"dependencies": [],
"publishedEvents": [
"com.example.shop.inventory.StockReservedEvent"
],
"listenedEvents": [
"com.example.shop.order.OrderCreatedEvent"
]
}
]
}
```
## 마이크로서비스로의 마이그레이션
### 추출 준비
모듈러 아키텍처는 향후 마이크로서비스 추출을 수월하게 합니다. 각 모듈이 추출에 자연스러운 후보가 됩니다.
```java
// ExtractionReadinessChecker.java
// Extraction readiness verification
package com.example.shop;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.core.ApplicationModule;
public class ExtractionReadinessChecker {
public void checkModule(String moduleName) {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
ApplicationModule module = modules.getModuleByName(moduleName)
.orElseThrow();
System.out.println("Module: " + moduleName);
System.out.println("Dependencies: " + module.getDependencies());
System.out.println("Published Events: " + module.getPublishedEvents());
System.out.println("Listened Events: " + module.getBootstrapDependencies());
// A module ready for extraction:
// - Communicates only through events
// - Has no synchronous dependencies to other modules
// - Owns its own data tables
}
}
```
이벤트로만 통신하는 모듈은 최소한의 수정만으로 마이크로서비스로 추출할 수 있습니다. 로컬 이벤트 버스를 메시지 브로커(Kafka, RabbitMQ)로 교체하면 충분합니다.
## 결론
Spring Modulith는 모놀리식 Spring Boot 애플리케이션을 구조화할 수 있는 실용적인 해법을 제공합니다:
✅ **관례 기반 구조**: 패키지 = 모듈, `internal` = 캡슐화
✅ **느슨한 결합 통신**: 모듈 간 도메인 이벤트
✅ **자동 검증**: 위반을 잡아내는 구조 테스트
✅ **영속화된 이벤트**: `@ApplicationModuleListener`로 처리 보장
✅ **격리된 테스트**: 각 모듈을 검증하는 `@ApplicationModuleTest`
✅ **생성된 문서**: 자동 PlantUML 다이어그램
✅ **관측성**: Micrometer 통합과 Actuator 엔드포인트
✅ **마이크로서비스로 가는 길**: 디커플링으로 손쉬워진 추출
이 아키텍처는 마이크로서비스의 운영 복잡성을 떠안지 않고 모놀리스를 구조화하고자 하는 팀에 특히 적합하며, 필요할 때 분산 아키텍처로 진화할 수 있는 선택지를 남겨 둡니다.
---
Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack.
HTML version of this page: https://sharpskill.dev/ko/blog/spring-boot/spring-modulith-modular-monolith-architecture