[Spring Boot] Spring Modulith 사용해보기
Spring Modulith란?
최근 MSA에 대한 피로도가 높아지면서 다시 모놀리스 아키텍처가 주목받고 있다. 하지만 전통적인 모놀리스가 아니라, 모듈화된 모놀리스, 즉 모듈러 모노리스(Modular Monolith) 방식이 각광받고 있다.
Spring에서는 이를 지원하기 위해 Spring Modulith라는 공식 프로젝트를 제공하고 있다.
왜 Spring Modulith가 필요한가?
한때는 마이크로서비스 아키텍처(MSA)가 모든 문제를 해결할 수 있을 것처럼 여겨졌지만, 실제로는 다음과 같은 문제들이 자주 발생한다.
- 인프라 구성의 복잡성 (Service Mesh, Gateway, Config Server 등)
- 배포 및 테스트 환경의 어려움
- 서비스가 많아질수록 높은 운영 비용
- 너무 이른 마이크로서비스 전환으로 인한 설계 문제
이런 문제를 해결하기 위해, 먼저 잘 쪼개진 모놀리스를 만들고 이후 MSA로 자연스럽게 확장하는 방향이 훨씬 안정적인 접근이 된다. 이 구조를 구현할 수 있도록 도와주는 도구가 바로 Spring Modulith다.
모듈러 모노리스란?
하나의 애플리케이션 안에서 도메인별로 모듈을 분리하고, 이들 간에 명확한 경계를 갖도록 구성하는 방식이다. 모듈 간에는 직접 의존하기보다 이벤트 기반 통신을 통해 느슨하게 연결되는 것이 특징이다.
예시 구조는 다음과 같다:
modular-ecommerce/
├── common/ : 공통 유틸, 예외 처리 등
├── product/ : 상품 관리 도메인
├── order/ : 주문 처리 도메인
├── user/ : 사용자 관리 도메인
└── api/ : API 진입점 (Spring Boot main)
각 모듈은 내부적으로 독립적인 계층 구조를 갖고, 별도의 테스트와 설정을 가질 수 있다.
Spring Modulith의 주요 기능
Spring Modulith는 단순한 디렉토리 분리 이상의 기능을 제공한다. 다음은 대표적인 기능들이다.
1. 모듈 선언
@ApplicationModule
package com.example.order;
import org.springframework.modulith.ApplicationModule;
모듈 경계를 명시적으로 선언하고, 의존성 분석이나 테스트에 활용할 수 있다.
2. 이벤트 기반 통신
publisher.publishEvent(new ProductUsedEvent(productId));
@EventListener
public void handleProductUsed(ProductUsedEvent event) {
productRepository.findById(event.id())
.ifPresent(System.out::println);
}
모듈 간 직접 호출을 지양하고, 도메인 이벤트를 통해 간접적으로 통신하도록 유도한다.
3. 문서화 & 테스트 지원
@ApplicationModuleTest를 이용해 모듈 단위 테스트가 가능하다. C4 모델 기반으로 모듈 다이어그램을 자동 생성할 수 있어 시스템 구조를 쉽게 파악할 수 있다.
언제 사용하면 좋을까?
- 초기에는 하나의 앱으로 시작하지만 장기적으로 MSA로 확장할 계획이 있는 경우
- 도메인 중심 설계(DDD)를 적용하고 싶은 경우
- 팀 규모가 작고 복잡한 인프라 없이 빠르게 개발하고 싶은 경우
Spring Modulith 사용해보기
직접 예제 프로젝트를 구현하면서 Spring Modulith의 구조와 코드 흐름을 소개한다.
프로젝트 구조
프로젝트는 다음과 같이 각 도메인을 기준으로 모듈을 나누었다.
modular-ecommerce/
├── build.gradle (루트 빌드 설정)
├── settings.gradle (모듈 포함 설정)
├── common/ <-- 공통 유틸/엔티티/예외
│ └── build.gradle
├── product/ <-- 상품 관리 모듈
│ └── build.gradle
├── order/ <-- 주문 관리 모듈
│ └── build.gradle
├── user/ <-- 사용자 관리 모듈
│ └── build.gradle
└── api/ <-- SpringBoot main + API layer
└── build.gradle
각 모듈은 독립된 책임을 가지고 있으며, 직접 참조 없이 이벤트 기반으로 통신하도록 설계했다.
@ApplicationModule 선언
모듈은 package-info.java 파일을 이용해 명시적으로 선언할 수 있다.
product/package-info.java
@ApplicationModule(allowedDependencies = {"common"})
package com.modularEcommerce.product;
import org.springframework.modulith.ApplicationModule;
- product 모듈은 common 외에는 다른 모듈에 의존하지 않도록 제한
- 나중에 문서화나 테스트에서 이 정보가 활용됨
product/build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.5'
id 'io.spring.dependency-management' version '1.1.7'
}
dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.modulith:spring-modulith-starter-core'
implementation 'org.springframework.modulith:spring-modulith-starter-jpa'
compileOnly 'org.projectlombok:lombok'
}
product 모듈의 implementation project(':common') 설정을 통해 common 프로젝트를 의존한다. 해당 설정이 없는 경우 common 프로젝트 내 파일을 의존하거나 import할 수 없다.
api/package-info.java
@ApplicationModule(allowedDependencies = {"user", "order", "product", "common"})
package com.modularEcommerce.api;
import org.springframework.modulith.ApplicationModule;
각 모듈들을 사용하는 api 프로젝트의 package-info의 경우 다음과 같이 모든 모듈을 사용하도록 선언한다.
api/build.gradle
plugins {
id 'org.springframework.boot'
}
dependencies {
implementation project(':common')
implementation project(':product')
implementation project(':order')
implementation project(':user')
testImplementation project(':common')
testImplementation project(':product')
testImplementation project(':order')
testImplementation project(':user')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.modulith:spring-modulith-starter-core'
implementation 'org.springframework.modulith:spring-modulith-starter-jpa'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
// spring modulith docs
testImplementation "org.springframework.modulith:spring-modulith-docs:1.4.0-SNAPSHOT"
}
repositories {
mavenCentral()
maven { url "https://repo.spring.io/snapshot" }
}
test {
doFirst {
println "Test Classpath: " + classpath.asPath
}
}
모듈 구현
각 모듈의 역할메 맞게 로직을 구현한다.
ProductService
package com.modularEcommerce.product.service;
import com.modularEcommerce.product.domain.Product;
import com.modularEcommerce.product.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public List<Product> getAllProducts() {
return productRepository.findAll();
}
}
UserService
package com.modularEcommerce.user.service;
import com.modularEcommerce.user.domain.User;
import com.modularEcommerce.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public List<User> getUsers() {
return userRepository.findAll();
}
}
위와 같이 모듈 프로젝트 내부에서 사용하는 비즈니스 로직을 구현한다.
common/exception
package com.modularEcommerce.common.exception;
public class CustomCommonException extends RuntimeException {
public CustomCommonException(String message) {
super(message);
}
}
common의 경우 공통으로 사용할 exception을 구현한다.
API 모듈 구현
api 모듈에서 각 모듈을 사용하여 프로젝트를 기동할 수 있는 SpringBootApplication을 설정한다.
package com.modularEcommerce.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication
@ComponentScan(basePackages = "com.modularEcommerce") // 서비스, 컨트롤러 스캔
@EnableJpaRepositories(basePackages = "com.modularEcommerce") // 레포지토리 스캔
@EntityScan(basePackages = "com.modularEcommerce") // @Entity 위치 지정
public class ModularEcommerceApplication {
public static void main(String[] args) {
SpringApplication.run(ModularEcommerceApplication.class, args);
}
}
이벤트 기반 처리 구현
Spring Modulith에서는 모듈 간 직접 의존 대신 도메인 이벤트를 사용하여 느슨한 결합을 유도한다.
Spring Modulith에서는 다른 모듈에 있는 @Component나 @Service등에 직접 DI 하는 경우 강한 결합으로 간주한다. 때문에 직접 간주하는 것이 아니라 common, 이벤트 기반 처리를 통해 순수한 의존성을 유지한다.
order 모듈에 있는 createOrder 메소드 호출 시 이벤트가 발행되면 product 모듈에 있는 ProductEventHandler에서 수신하여 처리하도록 구현한다.
ProductUsedEvent
package com.modularEcommerce.common.event;
public record ProductUsedEvent(Long id) {
}
OrderService
package com.modularEcommerce.order.service;
import com.modularEcommerce.common.event.ProductUsedEvent;
import com.modularEcommerce.order.domain.Order;
import com.modularEcommerce.order.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher publisher;
private final OrderRepository orderRepository;
@Transactional
public Order createOrder(Long userId, Long productId) {
Order order = Order.builder()
.userId(userId)
.productId(productId)
.orderedAt(LocalDateTime.now())
.build();
// publish product event
System.out.println(">> publish event");
publisher.publishEvent(new ProductUsedEvent(productId));
return orderRepository.save(order);
}
}
ProductEventHandler
package com.modularEcommerce.product.event;
import com.modularEcommerce.common.event.ProductUsedEvent;
import com.modularEcommerce.product.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ProductEventHandler {
private final ProductRepository productRepository;
@EventListener
public void handleProductUsed(ProductUsedEvent event) {
System.out.println("✅ 이벤트 수신됨: " + event.id());
// Product 사용 로직
productRepository.findById(event.id()).ifPresent(product -> System.out.println("== Product == : " + product));
}
}
테스트 코드 작성
모듈 간 참조 유효성을 검증하는 테스트 코드를 작성한다.
package com.modularEcommerce.modulestructure;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ModulithStructureTest {
@Test
void verifyModularStructure() {
ApplicationModules modules = ApplicationModules.of("com.modularEcommerce");
modules.verify(); // 모듈 간 순환 참조나 잘못된 접근이 있으면 실패
}
}
이 테스트는 @ApplicationModule(allowedDependencies = {...}) 설정에 기반해 잘못된 의존을 찾아준다.
Documenter를 이용한 시각화
build.gradle에 다음 의존성을 추가한다.
// spring modulith docs
testImplementation "org.springframework.modulith:spring-modulith-docs:1.4.0-SNAPSHOT"
Documenter는 ApplicationModules 객체를 기반으로 문서를 생성한다.
package com.modularEcommerce.modulestructure;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.core.DependencyDepth;
import org.springframework.modulith.docs.Documenter;
class ModulithDocumentationTest {
@Test
void createModuleDocumentation() {
ApplicationModules modules = ApplicationModules.of("com.modularEcommerce");
new Documenter(modules)
.writeModulesAsPlantUml(
Documenter.DiagramOptions.defaults()
.withDependencyDepth(DependencyDepth.ALL)
.withElementsWithoutRelationships(Documenter.DiagramOptions.ElementsWithoutRelationships.VISIBLE) // ⭐ 고립 모듈도 표시
.withStyle(Documenter.DiagramOptions.DiagramStyle.UML) // uml style을 사용할 경우
)
.writeIndividualModulesAsPlantUml();
}
}
ApplicationModules.of(...)는 해당 패키지 하위의 모든 모듈을 분석해서 ApplicationModules 객체로 만든다.
- new Documenter(modules)
- Documenter는 Spring Modulith에서 모듈 관계를 시각화하는 도구
- 전체 모듈 구조를 기반으로 여러 종류의 문서/다이어그램을 생성
- .writeModulesAsPlantUml(...)
- 전체 모듈 간의 의존 관계를 하나의 PlantUML 다이어그램 파일로 생성
- 인자로 전달된 DiagramOptions을 기반으로 어떤 요소들을 포함할지 결정
- DiagramOptions.defaults()
- 기본 설정 옵션
- .withDependencyDepth(DependencyDepth.ALL)
- 모듈 간의 모든 깊이의 의존성을 포함
- 기본값은 DIRECT, 즉 직접적인 의존성만 포함인데, ALL을 주면 간접 의존성까지 포함
- withElementsWithoutRelationships(VISIBLE)
- 의존 관계가 없는 고립된 모듈도 시각화에 포함
- 설정 안 하면 관계없는 모듈은 무시
- .writeIndividualModulesAsPlantUml()
- 각 모듈별로 개별 PlantUML 파일을 생성
- 모듈 단위로 더 자세한 구조를 살펴보기에 유용
생성된 puml 파일은 build/spring-modulith-docs에 저장된다.
생성된 puml 파일은 intellij 플러그인을 통해 확인할 수 있다. 여기서 components.puml을 통해 자동 시각화된 모듈 구조를 확인할 수 있다.
해당 puml은 수동 작업을 통해 더 자세하게 표현할 수 있다.
정리하며
스프링 모듈리스는 아직 나온 지 오래되지 않았고, 활발히 발전 중인 단계라 실무에서 바로 적용하기에는 고민이 필요한 시점이다. 다만, 도메인 기반 아키텍처와 모듈 간의 명확한 경계를 지향하는 방향성은 꽤 인상적이다. 앞으로의 발전이 더욱 기대되는 좋은 개념이고, 충분히 주목해볼 만한 프로젝트다.
자세한 코드는 깃허브를 참고하길 바란다.
링크 : https://github.com/JangDaeHyeok/springboot-modular-ecommerce
GitHub - JangDaeHyeok/springboot-modular-ecommerce: Spring Boot 기반의 모듈러 모노리스 이커머스 예제 프로젝
Spring Boot 기반의 모듈러 모노리스 이커머스 예제 프로젝트. Contribute to JangDaeHyeok/springboot-modular-ecommerce development by creating an account on GitHub.
github.com