최근 멀티모듈, MSA를 찾아보다 흥미로운 Spring Modulith를 발견해서 공유하고자 합니다.
Spring Modulith는 Spring 팀에서 만든 프로젝트로, 모놀리스 애플리케이션을 잘 정의된 모듈로 구성할 수 있게 도와주는 툴킷입니다.
// Spring Modulith BOM
dependencyManagement {
imports {
mavenBom 'org.springframework.modulith:spring-modulith-bom:1.4.3'
}
}
dependencies {
implementation 'org.springframework.modulith:spring-modulith-starter-core'
testImplementation 'org.springframework.modulith:spring-modulith-starter-test'
}
기존 패키지 구조의 문제:
com.example.shop
├── order/
│ ├── OrderService.java
│ └── OrderRepository.java
├── product/
│ ├── ProductService.java
│ └── ProductRepository.java
└── notification/
├── NotificationService.java
└── EmailSender.java
문제점:
OrderService에서 EmailSender 직접 접근 가능Spring Modulith 구조:
com.example.shop
├── ShopApplication.java # 메인 클래스
├── order/ # Order 모듈
│ ├── OrderService.java # API (공개)
│ └── internal/ # 내부 구현 (숨김)
│ └── OrderRepository.java
├── product/ # Product 모듈
│ ├── ProductService.java
│ └── internal/
│ └── ProductRepository.java
└── notification/ # Notification 모듈
├── NotificationService.java
└── internal/
└── EmailSender.java
핵심 규칙:
internal 패키지 = 내부 구현 (다른 모듈 접근 불가)코드로 아키텍처 규칙을 강제할 수 있습니다!
@SpringBootTest
class ModularityTests {
@Test
void verifyModularity() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
// 모듈 구조 검증!
modules.verify();
}
}
만약 Order 모듈에서 Notification의 internal 접근하면?
// ❌ 잘못된 코드
package com.example.shop.order;
import com.example.shop.notification.internal.EmailSender; // 접근 금지!
@Service
public class OrderService {
@Autowired
private EmailSender emailSender; // 위반!
}
테스트 실패:
Test verifyModularity() FAILED!
Module 'order' depends on non-exposed type
'com.example.shop.notification.internal.EmailSender'
in module 'notification'!
Allowed targets:
- com.example.shop.notification.NotificationService
→ CI/CD에서 자동으로 걸립니다!
코드 = 문서
@Test
void generateDocumentation() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
new Documenter(modules)
.writeModulesAsPlantUml() // PlantUML 다이어그램
.writeIndividualModulesAsPlantUml() // 모듈별 상세
.writeModuleCanvases(); // 모듈 캔버스
}
자동 생성:
target/modulith-docs/
├── components.puml # 전체 구조
├── module-order.puml # order 모듈 상세
├── module-product.puml # product 모듈 상세
├── module-notification.puml # notification 모듈 상세
└── module-order-canvas.html # HTML 문서
→ 신규 팀원 온보딩이 쉬워집니다!
주문(Order), 상품(Product), 알림(Notification) 모듈로 구성된 쇼핑몰을 만들어봅시다.
src/main/java
└── com.example.shop
├── ShopApplication.java
├── order/ # 주문 모듈
│ ├── OrderService.java # API
│ ├── OrderController.java
│ └── internal/ # 내부
│ ├── OrderRepository.java
│ └── OrderEntity.java
├── product/ # 상품 모듈
│ ├── ProductService.java
│ └── internal/
│ ├── ProductRepository.java
│ └── ProductEntity.java
└── notification/ # 알림 모듈
├── NotificationService.java
└── internal/
└── EmailSender.java
// com.example.shop.order.OrderService.java
package com.example.shop.order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.context.ApplicationEventPublisher;
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = Order.create(request);
orderRepository.save(order);
// 이벤트 발행
eventPublisher.publishEvent(
new OrderCreatedEvent(order.getId(), order.getCustomerId())
);
return order;
}
}
// com.example.shop.order.internal.OrderRepository.java
package com.example.shop.order.internal;
import org.springframework.data.jpa.repository.JpaRepository;
// internal = 다른 모듈 접근 불가!
interface OrderRepository extends JpaRepository<OrderEntity, Long> {
}
// com.example.shop.order.OrderCreatedEvent.java
package com.example.shop.order;
public record OrderCreatedEvent(
Long orderId,
Long customerId
) {}
@SpringBootTest
class ModularityTests {
@Test
void verifyModularity() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
// 모듈 구조 검증
modules.verify();
}
}
@Test
void printModuleStructure() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
modules.forEach(module -> {
System.out.println("Module: " + module.getName());
System.out.println("Base Package: " + module.getBasePackage());
module.getDependencies().forEach(dep ->
System.out.println(" → depends on: " + dep)
);
System.out.println();
});
}
출력:
Module: order
Base Package: com.example.shop.order
→ depends on: notification
Module: product
Base Package: com.example.shop.product
Module: notification
Base Package: com.example.shop.notification
(no dependencies)
// order 모듈에서
import com.example.shop.product.internal.ProductRepository; // 접근 금지!
@Service
public class OrderService {
@Autowired
private ProductRepository productRepository; // 위반!
}
에러:
Module 'order' depends on non-exposed type
'ProductRepository' in module 'product'!
// order → product → order
에러:
Cycle detected: order -> product -> order
직접 의존 (BAD):
@Service
public class OrderService {
private final NotificationService notificationService; // 강결합!
public void createOrder(...) {
// ...
notificationService.sendEmail(...); // 직접 호출
}
}
문제점:
이벤트 기반 (GOOD):
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public void createOrder(...) {
// ...
eventPublisher.publishEvent(new OrderCreatedEvent(...)); // 발행만!
}
}
장점:
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = Order.create(request);
orderRepository.save(order);
// 이벤트 발행
eventPublisher.publishEvent(
new OrderCreatedEvent(order.getId(), order.getCustomerId())
);
return order;
}
}
// Notification 모듈
@Service
public class NotificationService {
@ApplicationModuleListener
public void onOrderCreated(OrderCreatedEvent event) {
log.info("주문 생성 이벤트 수신: {}", event.orderId());
// 이메일 발송
sendEmail(event.customerId(), "주문 완료!");
}
}
// Product 모듈 - 재고 차감
@Service
public class InventoryService {
@ApplicationModuleListener
public void onOrderCreated(OrderCreatedEvent event) {
log.info("재고 차감: {}", event.orderId());
// 재고 감소
decreaseStock(event.orderId());
}
}
이 어노테이션 하나로:
@Async - 비동기 실행@Transactional - 트랜잭션 관리@TransactionalEventListener - 트랜잭션 커밋 후 실행서버 재시작 시 이벤트 유실 방지!
dependencies {
implementation 'org.springframework.modulith:spring-modulith-starter-jpa'
}
# application.yml
spring:
modulith:
events:
jdbc-schema-initialization:
enabled: true
republish-outstanding-events-on-restart: true
자동 생성되는 테이블:
| id | event_type | serialized_event | completion_date |
|---|---|---|---|
| 1 | OrderCreatedEvent | {...} | 2025-01-15 14:30:00 |
| 2 | OrderCreatedEvent | {...} | null (실패!) |
→ 리스너 실패 시 재시도!
@Test
void generateDocumentation() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
new Documenter(modules)
.writeModulesAsPlantUml() // 다이어그램
.writeIndividualModulesAsPlantUml() // 모듈별 상세
.writeModuleCanvases(); // HTML 문서
}
target/modulith-docs/
├── components.puml # 전체 구조 다이어그램
├── module-order.puml # order 모듈 상세
├── module-product.puml # product 모듈 상세
├── module-notification.puml # notification 모듈 상세
└── module-order-canvas.html # 모듈 캔버스
@startuml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml
Container_Boundary(order, "order") {
Component(orderService, "OrderService")
}
Container_Boundary(notification, "notification") {
Component(notificationService, "NotificationService")
}
Rel(orderService, notificationService, "publishes OrderCreatedEvent")
@enduml
렌더링: https://www.plantuml.com/plantuml/uml/
자동 생성되는 문서에 포함:
→ 코드 안 봐도 구조 파악!