Spring Modulith로 도메인 모듈화하기

Lee Jin Hyuk·2025년 10월 12일

최근 멀티모듈, MSA를 찾아보다 흥미로운 Spring Modulith를 발견해서 공유하고자 합니다.


목차

  1. Spring Modulith가 뭔데?
  2. Spring Modulith의 강점
  3. 예제로 알아보기 (E-commerce)
  4. 모듈 경계 자동 검증하기
  5. 이벤트 기반으로 모듈 간 통신하기
  6. 아키텍처 문서 자동 생성

Spring Modulith가 뭔데?

Spring Modulith는 Spring 팀에서 만든 프로젝트로, 모놀리스 애플리케이션을 잘 정의된 모듈로 구성할 수 있게 도와주는 툴킷입니다.

핵심 기능

  1. 모듈 구조 자동 검증 - 패키지 구조만으로 모듈 경계 설정
  2. 의존성 규칙 강제 - 테스트 코드로 아키텍처 위반 방지
  3. 이벤트 기반 통신 - 모듈 간 느슨한 결합 유지
  4. 문서 자동 생성 - C4 다이어그램 자동 생성
  5. 통합 테스트 지원 - 개별 모듈 단위 테스트 가능

의존성 추가

// 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'
}

Spring Modulith의 강점

1. 명확한 모듈 경계 설정

기존 패키지 구조의 문제:

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

핵심 규칙:

  • 메인 패키지 바로 아래 = 모듈
  • 모듈 루트 패키지 = API (공개 인터페이스)
  • internal 패키지 = 내부 구현 (다른 모듈 접근 불가)

2. 아키텍처적 위반(Architectural Violations) 방지

코드로 아키텍처 규칙을 강제할 수 있습니다!

@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에서 자동으로 걸립니다!

3. 아키텍처 문서 자동 생성

코드 = 문서

@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 문서

→ 신규 팀원 온보딩이 쉬워집니다!


예제로 알아보기 (E-commerce)

주문(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

Order 모듈 - API

// 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;
    }
}

Order 모듈 - Internal

// 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)

위반 사례

Case 1: Internal 접근

// 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'!

Case 2: 순환 참조

// order → product → order

에러:

Cycle detected: order -> product -> order

이벤트 기반으로 모듈 간 통신하기

왜 이벤트인가?

직접 의존 (BAD):

@Service
public class OrderService {
    
    private final NotificationService notificationService; // 강결합!
    
    public void createOrder(...) {
        // ...
        notificationService.sendEmail(...); // 직접 호출
    }
}

문제점:

  • Order가 Notification의 내부를 알아야 함
  • Notification 변경 시 Order 영향
  • 테스트 시 Notification 모킹 필요

이벤트 기반 (GOOD):

@Service
public class OrderService {
    
    private final ApplicationEventPublisher eventPublisher;
    
    public void createOrder(...) {
        // ...
        eventPublisher.publishEvent(new OrderCreatedEvent(...)); // 발행만!
    }
}

장점:

  • Order는 이벤트만 발행
  • 누가 구독하는지 몰라도 됨
  • 리스너 추가 시 Order 코드 변경 없음

이벤트 발행

@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());
    }
}

@ApplicationModuleListener의 마법

이 어노테이션 하나로:

  • @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

자동 생성되는 테이블:

idevent_typeserialized_eventcompletion_date
1OrderCreatedEvent{...}2025-01-15 14:30:00
2OrderCreatedEvent{...}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     # 모듈 캔버스

PlantUML 예시

@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/

모듈 캔버스 (HTML)

자동 생성되는 문서에 포함:

  • Provided API - 제공하는 인터페이스
  • Internal Types - 내부 구현
  • Published Events - 발행하는 이벤트
  • Listened Events - 구독하는 이벤트
  • Dependencies - 의존 모듈

→ 코드 안 봐도 구조 파악!


마치며

Spring Modulith의 핵심 가치

  1. 낮은 진입 장벽 - 패키지 구조만 바꾸면 됨
  2. 자동 검증 - 테스트로 아키텍처 보호
  3. 명확한 경계 - 도메인 분리가 명확
  4. 이벤트 통신 - 느슨한 결합
  5. 문서 자동화 - 코드가 곧 문서

언제 사용하면 좋을까?

  • 모놀리스 프로젝트의 도메인 분리가 필요할 때
  • 아키텍처 규칙을 강제하고 싶을 때
  • 팀 협업 시 모듈 경계를 명확히 하고 싶을 때
  • 점진적으로 모듈화를 적용하고 싶을 때

참고 자료

공식 문서

아티클

0개의 댓글