Spring Modulith
Spring Boot 애플리케이션을
주문,재고,결제,회원같은 업무 영역별 모듈로 나누고, 모듈 사이의 의존성을 검사, 테스트, 문서화하도록 도와주는 도구
Spring Modulith 공식 Reference Documentation 2.1.0, 공식 예제 저장소
0. 먼저 기억할 한 문장
Spring Modulith의 목표는 다음과 같다.
한 모듈의 변경이 다른 모듈에 불필요하게 퍼지지 않도록 모듈의 경계를 정하고, 그 경계가 실제 코드에서도 지켜지는지 자동으로 검사한다.
여기서 중요한 표현은 영향을 완전히 0으로 만든다가 아니라 불필요하고 통제되지 않은 영향을 줄인다이다.
모듈 사이에 의존성이 아예 없어야 하는 것은 아니다. 의존성이 있더라도 공개 API나 이벤트처럼 정해진 통로를 이용해야 한다.
1. Spring Modulith는 무엇인가?
Spring Modulith는 Spring Boot 애플리케이션을 업무 영역별로 구조화하기 위한 Spring 진영의 툴킷이다.
공식 프로젝트 페이지는 다음 기능을 핵심으로 소개한다.
- 애플리케이션 모듈 구조 검증
- 특정 모듈 단위의 통합 테스트
- 모듈 사이의 이벤트와 호출 관찰
- 실제 코드 구조를 바탕으로 한 문서와 다이어그램 생성
참고: Spring Modulith 공식 프로젝트 페이지
1.1 Spring Modulith는 마이크로서비스가 아니다
Spring Modulith를 사용해도 처음에는 보통 다음과 같다.
- 하나의 Spring Boot 애플리케이션
- 하나의 JVM 프로세스
- 하나의 배포 단위
- 같은 애플리케이션 내부에서 직접 메서드 호출
- 필요하면 하나의 데이터베이스 사용
즉, Spring Modulith는 보통 **모듈형 모놀리스(Modular Monolith)**를 만드는 도구다.
마이크로서비스는 주문 서비스, 결제 서비스, 재고 서비스를 각각 별도의 프로세스와 배포 단위로 나누고 네트워크를 통해 통신하는 방식이다.
Spring Modulith는 먼저 하나의 애플리케이션 안에서 모듈 경계를 잘 만들도록 도와준다.
하나의 Spring Boot 애플리케이션
│
├── 주문 모듈
├── 재고 모듈
├── 결제 모듈
└── 회원 모듈
나중에 정말 필요하면 특정 모듈을 별도 서비스로 분리할 수 있지만, Spring Modulith가 자동으로 마이크로서비스를 만들어주는 것은 아니다.
2. 왜 필요한가?
작은 애플리케이션에서는 다음처럼 기술 종류별로 코드를 나누는 구조도 충분히 이해하기 쉽다.
com.example.shop
├── controller
│ ├── OrderController.java
│ ├── PaymentController.java
│ └── MemberController.java
├── service
│ ├── OrderService.java
│ ├── PaymentService.java
│ └── MemberService.java
├── repository
│ ├── OrderRepository.java
│ ├── PaymentRepository.java
│ └── MemberRepository.java
└── dto
이 구조에서는 컨트롤러끼리, 서비스끼리, 리포지토리끼리 모여 있다.
하지만 기능이 커지면 주문 기능 하나를 수정하기 위해 다음을 모두 찾아다녀야 할 수 있다.
- 주문 컨트롤러
- 주문 서비스
- 주문 리포지토리
- 주문 DTO
- 결제 서비스
- 재고 서비스
- 알림 서비스
- 여러 공통 유틸리티
그리고 서비스들이 서로 직접 호출하기 시작한다.
주문 -> 재고
주문 -> 결제
재고 -> 주문
결제 -> 회원
회원 -> 주문
알림 -> 결제
이렇게 되면 작은 변경 하나가 여러 기능에 영향을 준다.
2.1 학교 축제 운영으로 비유하기
학교 축제에 다음 팀들이 있다고 생각해보자.
- 티켓 판매팀
- 음식 준비팀
- 결제팀
- 안내 방송팀
- 학생 명단 관리팀
처음에는 티켓 판매팀장이 음식 준비팀장에게 직접 전화해도 된다.
티켓이 팔렸으니 음식 하나를 준비해 주세요.
하지만 나중에 좌석 배치팀, 경품팀, 문자 발송팀, 통계팀까지 생기면 티켓 판매팀장이 모든 팀장에게 직접 전화해야 한다.
더 좋은 방법은 방송을 이용하는 것이다.
주문이 완료되었습니다!
그러면 필요한 팀들이 각자 방송을 듣는다.
- 재고팀은 재고를 줄인다.
- 문자팀은 문자를 보낸다.
- 통계팀은 통계를 기록한다.
- 포인트팀은 포인트를 지급한다.
주문팀은 누가 듣는지 일일이 알 필요가 없다.
Spring Modulith에서 이 방송에 해당하는 것이 **애플리케이션 이벤트(Application Event)**다.
3. 모듈이란 무엇인가?
Spring Modulith에서 모듈은 단순히 폴더 하나가 아니다.
모듈은 특정 업무 책임을 가진 코드의 경계다.
예를 들면 다음과 같다.
order -> 주문을 생성·완료·취소하는 책임
inventory -> 재고를 관리하는 책임
payment -> 결제 상태를 관리하는 책임
member -> 회원 정보를 관리하는 책임
하나의 모듈은 보통 세 가지를 가진다.
3.1 외부에 제공하는 기능
다른 모듈이 사용할 수 있도록 공개한 기능이다.
주문 완료
주문 조회
주문 취소
이것을 모듈의 API 또는 **제공 인터페이스(Provided Interface)**라고 부를 수 있다.
3.2 모듈 내부에서만 사용하는 구현
다른 모듈이 알 필요가 없는 내용이다.
주문 금액 계산 방식
할인 규칙
주문 상태 변경 로직
주문 데이터를 저장하는 세부 방식
이것은 모듈의 **내부 구현(Internal Implementation)**이다.
3.3 다른 모듈에서 사용하는 기능
주문 모듈이 회원 모듈의 회원 정보를 사용하거나, 재고 모듈이 주문 완료 이벤트를 듣는 경우다.
이것은 모듈의 **필요 인터페이스(Required Interface)**와 관련된다.
| 개념 | 의미 | 예시 |
|---|---|---|
| API | 다른 모듈에 제공하는 기능 | OrderManagement |
| 내부 구현 | 모듈 안에서만 사용하는 코드 | OrderPolicy |
| 이벤트 | 다른 모듈에 알리는 사실 | OrderCompleted |
| 의존성 | 다른 모듈의 코드나 이벤트를 사용하는 관계 | inventory -> order |
4. 계층형 구조와 도메인형 구조
4.1 전통적인 계층형 구조
com.example.shop
├── controller
├── service
├── repository
└── domain
기술 종류별로 코드를 묶는다.
- 컨트롤러는 컨트롤러끼리
- 서비스는 서비스끼리
- 리포지토리는 리포지토리끼리
작은 애플리케이션에서는 괜찮지만, 기능별 코드를 한곳에서 보기 어려워질 수 있다.
4.2 도메인형 구조
com.example.shop
├── order
│ ├── OrderController.java
│ ├── OrderManagement.java
│ ├── OrderRepository.java
│ └── internal
├── inventory
│ ├── InventoryController.java
│ ├── InventoryManagement.java
│ ├── InventoryRepository.java
│ └── internal
├── payment
│ ├── PaymentController.java
│ ├── PaymentManagement.java
│ └── internal
└── member
이번에는 업무 영역별로 묶는다.
- 주문과 관련된 코드는
order - 재고와 관련된 코드는
inventory - 결제와 관련된 코드는
payment
도메인형 구조라고 해서 다른 모듈과 절대 통신하지 않는 것은 아니다.
핵심은 다음이다.
통신은 하되, 정해진 창구를 통해서만 통신한다.
5. Spring Modulith의 기본 패키지 구조
Spring Modulith는 기본적으로 Spring Boot 애플리케이션 메인 패키지 아래에 있는 직접적인 하위 패키지를 애플리케이션 모듈로 본다.
src/main/java
└── com.example.shop
├── ShopApplication.java
├── order
├── inventory
├── payment
└── member
메인 애플리케이션 클래스는 다음과 같다고 하자.
package com.example.shop;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApplication {
}
그러면 Spring Modulith는 보통 다음을 모듈로 인식한다.
com.example.shop.order -> order 모듈
com.example.shop.inventory -> inventory 모듈
com.example.shop.payment -> payment 모듈
com.example.shop.member -> member 모듈
여기서 com.example.shop 자체는 애플리케이션의 기준 패키지이고, 직접 하위 패키지인 order, inventory 등이 모듈이 된다.
정확히는 “메인 패키지의 각 디렉토리”보다 “메인 패키지의 직접적인 하위 Java 패키지”라고 이해해야 한다.
6. Simple Application Module
기존 발표 메모의 A 부분은 Simple Application Module에 해당한다.
com.example.shop
└── order
├── OrderManagement.java
├── Order.java
└── OrderInternal.java
order 안에 또 다른 하위 패키지가 없다.
이때 Java의 접근 제어자를 이용해 내부 구현을 숨길 수 있다.
package com.example.shop.order;
import org.springframework.stereotype.Service;
@Service
public class OrderManagement {
}
OrderManagement는 public이므로 다른 패키지에서 사용할 수 있다.
반면 내부 구현은 접근 제어자를 생략할 수 있다.
package com.example.shop.order;
import org.springframework.stereotype.Component;
@Component
class OrderInternal {
}
Java에서 클래스 앞에 아무 접근 제어자도 적지 않으면 package-private이 된다.
즉, 같은 패키지에서만 사용할 수 있다.
package com.example.shop.order;
public class OrderManagement {
private final OrderInternal internal;
public OrderManagement(OrderInternal internal) {
this.internal = internal;
}
}
OrderManagement와 OrderInternal은 같은 order 패키지에 있으므로 사용할 수 있다.
하지만 다음 코드는 사용할 수 없다.
package com.example.shop.payment;
import com.example.shop.order.OrderInternal;
public class PaymentManagement {
// 컴파일 오류
}
payment와 order는 서로 다른 Java 패키지이기 때문이다.
Simple 모듈의 기본 규칙
order 패키지
├── public 타입 -> 모듈의 API
└── package-private 타입 -> 모듈 내부 구현
공식 문서도 Simple Application Module에서는 Java의 package scope로 내부 타입을 숨기고, 패키지 안의 public 타입들이 모듈의 API가 된다고 설명한다.
참고: Application Modules - Simple Application Modules
7. Advanced Application Module
다음처럼 internal 하위 패키지를 추가해보자.
com.example.shop
└── order
├── OrderManagement.java
└── internal
└── OrderPolicy.java
여기서 중요한 점은 다음이다.
order와order.internal은 같은 패키지가 아니다.
Java에서 다음 두 패키지는 완전히 다르다.
com.example.shop.order
com.example.shop.order.internal
OrderManagement가 OrderPolicy를 사용하려면 OrderPolicy를 public으로 만들어야 할 수 있다.
package com.example.shop.order.internal;
public class OrderPolicy {
}
그러면 Java 컴파일러 입장에서는 다른 모듈도 다음 코드를 작성할 수 있다.
package com.example.shop.payment;
import com.example.shop.order.internal.OrderPolicy;
public class PaymentManagement {
}
이 코드는 컴파일될 수 있다. OrderPolicy가 public이기 때문이다.
하지만 OrderPolicy는 주문 모듈의 내부 구현이다. 결제 모듈이 사용하면 안 된다.
이 문제를 Spring Modulith가 구조 수준에서 검사한다.
Advanced 모듈의 기본 규칙
order
├── OrderManagement.java -> 외부 공개 API
└── internal
└── OrderPolicy.java -> 내부 구현
Spring Modulith는 기본적으로 다음처럼 해석한다.
- 모듈 루트 패키지의 타입은 API 영역
- 하위 패키지의 타입은 내부 영역
- 다른 모듈이 내부 패키지에 의존하면 구조 위반
즉, Java 접근 제어자가 막지 못하는 문제를 Spring Modulith가 아키텍처 수준에서 잡는다.
8. api라는 이름만 붙인다고 공개 API가 되는가?
order/api 같은 이름은 흔한 관례지만, api라는 이름만으로 자동 공개되는 것은 아니다.
order
├── OrderManagement.java
├── api
│ └── OrderQuery.java
└── internal
└── OrderPolicy.java
order.api를 외부 공개 API라고 알려주려면 Named Interface로 표시하는 것이 안전하다.
공개할 패키지에 package-info.java를 만든다.
@org.springframework.modulith.NamedInterface("api")
package com.example.shop.order.api;
즉, 다음은 서로 다르다.
폴더 이름이 api인 것
@NamedInterface("api")로 공개 영역이라고 선언한 것
Spring Modulith는 후자를 공식적인 공개 인터페이스로 이해한다.
9. Named Interface란?
Named Interface는 모듈 안에서 외부에 공개할 특정 영역에 이름을 붙이는 기능이다.
예를 들어 order.spi를 외부에 공개한다고 하자.
com.example.shop.order
├── OrderManagement.java
├── spi
│ ├── package-info.java
│ └── OrderNotificationPort.java
└── internal
└── OrderPolicy.java
package-info.java:
@org.springframework.modulith.NamedInterface("spi")
package com.example.shop.order.spi;
이제 Spring Modulith는 다음을 알 수 있다.
order 모듈
├── 기본 API
├── spi라는 이름의 공개 인터페이스
└── internal이라는 내부 영역
다른 모듈은 order.spi에 의존할 수 있다.
Named Interface의 장점
모듈 전체를 공개하지 않고 필요한 부분만 공개할 수 있다.
order
├── OrderManagement.java
├── api
│ ├── OrderQuery.java
│ └── OrderView.java
├── spi
│ └── OrderNotificationPort.java
└── internal
├── OrderPolicy.java
└── OrderCalculator.java
다른 모듈이 사용할 수 있는 것은 다음뿐이다.
- 모듈 루트의 기본 API
apinamed interfacespinamed interface
internal은 사용할 수 없다.
10. @ApplicationModule은 무엇을 하는가?
@ApplicationModule은 모듈의 메타데이터와 규칙을 설정하는 어노테이션이다.
10.1 모듈 이름과 설명 지정
@org.springframework.modulith.ApplicationModule(
displayName = "Order Management"
)
package com.example.shop.order;
이렇게 하면 문서나 다이어그램에 사람이 읽기 좋은 이름을 표시할 수 있다.
10.2 허용된 의존성 지정
@org.springframework.modulith.ApplicationModule(
allowedDependencies = "member"
)
package com.example.shop.order;
이 의미는 다음과 같다.
order 모듈은 member 모듈에 의존할 수 있다.
여러 개를 지정할 수도 있다.
@org.springframework.modulith.ApplicationModule(
allowedDependencies = {
"member",
"payment"
}
)
package com.example.shop.order;
10.3 Named Interface만 허용
@org.springframework.modulith.ApplicationModule(
allowedDependencies = "order::api"
)
package com.example.shop.inventory;
이 의미는 다음과 같다.
inventory 모듈은 order 모듈 전체가 아니라 order 모듈의
apinamed interface에만 의존할 수 있다.
형식은 다음과 같다.
모듈이름::named-interface이름
예시:
order::api
order::spi
payment::public
모든 named interface를 허용하려면 다음처럼 작성할 수 있다.
@org.springframework.modulith.ApplicationModule(
allowedDependencies = "order::*"
)
package com.example.shop.inventory;
반대로 다른 모듈 의존성을 허용하지 않겠다는 의미로 빈 배열을 사용할 수도 있다.
@org.springframework.modulith.ApplicationModule(
allowedDependencies = {}
)
package com.example.shop.inventory;
10.4 의존성 설정이 없으면 모두 허용되는가?
아니다.
allowedDependencies를 쓰지 않았다고 해서 다른 모듈의 내부 구현까지 사용할 수 있는 것은 아니다.
기본 규칙은 여전히 적용된다.
- 다른 모듈의 공개 API는 사용 가능
- 공개된 named interface는 사용 가능
- 다른 모듈의 내부 패키지는 사용 불가
allowedDependencies는 여기에 추가로 어떤 모듈까지 의존할 수 있는지를 더 엄격하게 제한하는 기능이다.
11. Open Application Module
기존 레거시 모듈을 한꺼번에 구조화하기 어려울 때는 Open 모듈을 사용할 수 있다.
@org.springframework.modulith.ApplicationModule(
type = org.springframework.modulith.ApplicationModule.Type.OPEN
)
package com.example.shop.legacy;
Open 모듈은 내부 구현을 숨기지 않는다.
따라서 다른 모듈이 하위 패키지의 타입을 참조해도 허용된다.
이 기능은 보통 다음 상황에서 사용한다.
- 오래된 레거시 코드
- 패키지 구조가 매우 복잡한 기존 애플리케이션
- 구조를 한 번에 고칠 수 없는 상황
- 점진적으로 모듈화하는 과정
새로 만드는 애플리케이션이라면 Open 모듈을 많이 사용하지 않는 편이 좋다.
모듈의 경계가 흐려지기 때문이다.
학교 부서로 비유하면, 부서마다 창고가 있는데 모든 사람이 모든 창고에 자유롭게 들어갈 수 있도록 열어두는 것과 같다.
12. 모듈 사이의 의존성
모듈 A가 모듈 B의 코드를 사용하면 다음과 같은 의존성이 생긴다.
A -> B
예를 들어 재고 모듈이 주문 완료 이벤트를 사용한다면 다음과 같다.
inventory -> order
inventory가 order.OrderCompleted 타입을 알아야 하기 때문이다.
12.1 직접 Bean 호출 방식
order -> inventory
주문 모듈이 재고 모듈의 서비스를 직접 주입받는다.
@Service
public class OrderManagement {
private final InventoryManagement inventory;
public OrderManagement(InventoryManagement inventory) {
this.inventory = inventory;
}
public void complete(Order order) {
inventory.decreaseStock(order);
}
}
이렇게 하면 주문 모듈이 재고 모듈의 구체적인 클래스와 메서드 이름까지 알아야 한다.
12.2 이벤트 방식
order -> OrderCompleted 이벤트 <- inventory
주문 모듈은 다음 사실만 알린다.
events.publishEvent(new OrderCompleted(order.id()));
재고 모듈은 그 사실을 듣는다.
@ApplicationModuleListener
void on(OrderCompleted event) {
// 재고 감소
}
주문 모듈은 재고·알림·통계 모듈이 존재하는지 일일이 알 필요가 없다.
flowchart LR
User["사용자"] --> Order["주문 모듈"]
Order --> Event["OrderCompleted 이벤트"]
Event --> Inventory["재고 모듈"]
Event --> Notification["알림 모듈"]
Event --> Statistics["통계 모듈"]
13. 이벤트로 모듈을 연결하는 예제
13.1 이벤트 정의
package com.example.shop.order;
import java.util.UUID;
public record OrderCompleted(UUID orderId) {
}
이 이벤트는 다음 사실을 나타낸다.
주문이 완료되었다.
이벤트 이름은 보통 이미 발생한 사실을 표현한다.
좋은 이벤트 이름:
OrderCompleted
PaymentFinished
MemberRegistered
StockShortageDetected
다음과 같은 이름은 명령에 가깝다.
DecreaseStock
SendEmail
PayNow
이벤트에는 다른 모듈이 꼭 필요한 최소한의 정보만 담는 편이 좋다.
13.2 주문 모듈
package com.example.shop.order;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class OrderManagement {
private final ApplicationEventPublisher events;
@Transactional
public void complete(Order order) {
order.complete();
events.publishEvent(
new OrderCompleted(order.id())
);
}
}
주문 모듈은 재고 모듈을 직접 주입받지 않는다.
주문 완료라는 사실만 이벤트로 발행한다.
13.3 재고 모듈
package com.example.shop.inventory;
import com.example.shop.order.OrderCompleted;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class InventoryManagement {
@ApplicationModuleListener
void on(OrderCompleted event) {
// event.orderId()를 이용해서 재고 감소
}
}
InventoryManagement가 package-private인 것도 볼 수 있다.
외부 모듈이 이 서비스를 직접 호출할 필요가 없기 때문이다.
재고 모듈은 이벤트를 통해 동작한다.
14. @ApplicationModuleListener는 무엇인가?
공식 소스 기준으로 @ApplicationModuleListener는 다음 조합을 편리하게 적는 어노테이션이다.
@Async@TransactionalEventListener- 새로운 트랜잭션에서 실행하는
@Transactional
개념적으로는 다음과 비슷하다.
@Async
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
void on(OrderCompleted event) {
}
실제 코드에서는 다음처럼 간단하게 적는다.
@ApplicationModuleListener
void on(OrderCompleted event) {
}
14.1 실행 순서
sequenceDiagram
actor User as "사용자"
participant Order as "주문 모듈"
participant DB as "데이터베이스"
participant Event as "이벤트 시스템"
participant Inventory as "재고 모듈"
User->>Order: "주문 완료 요청"
Order->>DB: "주문 상태 변경"
Order->>Event: "OrderCompleted 발행"
Event->>DB: "이벤트 처리 기록 저장"
Order-->>User: "주문 처리 완료"
Event->>Inventory: "비동기 이벤트 전달"
Inventory->>DB: "재고 감소"
Inventory-->>Event: "처리 성공 또는 실패"
14.2 트랜잭션 차이
주문 완료와 재고 감소가 항상 같은 트랜잭션에서 실행되는 것은 아니다.
주문 모듈의 트랜잭션이 먼저 끝난 뒤, 재고 모듈이 별도의 트랜잭션에서 실행될 수 있다.
따라서 다음 상황이 가능하다.
주문 완료 성공
재고 감소 실패
이것을 **최종적 일관성(Eventual Consistency)**이라고 한다.
즉시 모든 데이터가 동시에 완벽하게 맞는 것이 아니라, 잠시 뒤에 처리되어 결국 맞아지는 방식이다.
장점:
- 원래 요청이 다른 모듈의 느린 작업 때문에 멈추지 않는다.
- 새로운 이벤트 소비 모듈을 추가해도 주문 모듈을 계속 수정하지 않아도 된다.
- 모듈 사이의 직접적인 Bean 의존성이 줄어든다.
단점:
- 이벤트 처리가 즉시 끝나지 않을 수 있다.
- 리스너가 실패할 수 있다.
- 잠시 동안 데이터가 완전히 일치하지 않을 수 있다.
- 실패 이벤트의 재시도를 고려해야 한다.
참고: Working with Application Events 공식 문서
15. Event Publication Registry
Spring Modulith의 Event Publication Registry는 이벤트 처리 기록을 저장하고 성공 여부를 추적한다.
대략적인 흐름은 다음과 같다.
- 주문 모듈이 이벤트를 발행한다.
- 이벤트를 처리할 리스너를 찾는다.
- 이벤트 처리 기록을 데이터베이스에 저장한다.
- 리스너가 성공하면 완료로 표시한다.
- 실패하면 미완료 또는 실패 상태로 남긴다.
- 나중에 재시도할 수 있다.
Spring Modulith 2.0부터는 이벤트 처리 상태를 더 자세히 구분한다.
| 상태 | 의미 |
|---|---|
PUBLISHED | 이벤트가 발행되었고 처리 대기 중 |
PROCESSING | 리스너가 현재 처리 중 |
COMPLETED | 처리가 성공적으로 완료됨 |
FAILED | 처리 중 오류가 발생함 |
RESUBMITTED | 실패한 이벤트를 다시 처리하도록 등록함 |
일반적인 Spring 이벤트만 사용하면 이벤트가 처리되었는지 기록하지 않을 수 있다.
이벤트 발행 기록을 저장하려면 저장 방식에 따라 다음 스타터를 사용할 수 있다.
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
또는 JDBC를 사용할 수 있다.
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>
단순히 모듈 구조 검증만 하고 싶다면 이벤트 저장 스타터까지 반드시 추가할 필요는 없다.
16. 동기 이벤트와 비동기 이벤트
16.1 일반 Spring 이벤트
events.publishEvent(new OrderCompleted(order.id()));
일반적인 Spring 이벤트는 기본적으로 동기적으로 실행될 수 있다.
즉, 이벤트를 발행한 메서드가 끝나기 전에 리스너가 실행된다.
장점:
- 처리 결과를 즉시 알 수 있다.
- 같은 트랜잭션으로 묶기 쉽다.
- 데이터 일관성 모델이 단순하다.
단점:
- 리스너가 느리면 원래 요청도 느려진다.
- 리스너 실패가 원래 작업까지 실패시킬 수 있다.
- 여러 모듈의 작업이 하나의 긴 트랜잭션에 포함될 수 있다.
16.2 @ApplicationModuleListener
@ApplicationModuleListener
void on(OrderCompleted event) {
}
장점:
- 원래 요청과 후속 처리를 분리한다.
- 리스너를 비동기로 실행한다.
- 별도 트랜잭션에서 처리한다.
- 모듈 간 결합도를 줄인다.
단점:
- 즉시 처리되지 않을 수 있다.
- 실패와 재시도를 고려해야 한다.
- 잠시 동안 데이터가 완전히 일치하지 않을 수 있다.
결제 승인처럼 즉시 결과가 필요한 작업과 이메일 발송처럼 조금 늦어도 되는 작업을 같은 방식으로 처리할 필요는 없다.
17. 외부 시스템으로 이벤트 보내기
Spring Modulith의 모듈 이벤트는 기본적으로 같은 애플리케이션 안에서 발생한다.
하지만 이벤트를 다음과 같은 외부 시스템에도 보내고 싶을 수 있다.
- Kafka
- RabbitMQ 또는 AMQP
- JMS
- Spring Integration
예를 들어 외부화할 이벤트를 표시할 수 있다.
@Externalized("order-completed")
public record OrderCompleted(UUID orderId) {
}
다만 단순히 이벤트를 Kafka에 보내는 것과 안전한 Outbox Pattern을 구현하는 것은 다르다.
다음과 같은 문제가 발생할 수 있다.
데이터베이스 저장 성공
메시지 전송 실패
또는 다음과 같은 문제가 생길 수 있다.
메시지 전송 성공
데이터베이스 저장 실패
중요한 주문·결제 이벤트를 외부 메시지 브로커로 보낼 때는 Outbox Pattern이나 Spring Modulith의 관련 지원 기능을 함께 검토해야 한다.
18. ApplicationModules.verify()
Spring Modulith에서 가장 중요한 구조 검증 코드는 다음 한 줄이다.
ApplicationModules.of(ShopApplication.class).verify();
보통 테스트로 작성한다.
package com.example.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
class ModularityTests {
private final ApplicationModules modules =
ApplicationModules.of(ShopApplication.class);
@Test
void verifiesModularStructure() {
modules.verify();
}
}
이 테스트는 애플리케이션의 Java 패키지와 타입 참조를 분석한다.
공식 문서에서 기본 검증 규칙은 크게 세 가지다.
18.1 모듈 사이에 순환 의존성이 없어야 한다
order -> inventory
inventory -> order
이 구조는 순환 의존성이다.
주문이 재고를 사용하고, 재고도 주문의 서비스를 직접 사용하면 서로 떼어내기 어려워진다.
18.2 다른 모듈의 내부 패키지를 사용하면 안 된다
import com.example.shop.order.internal.OrderPolicy;
이 참조는 컴파일 자체는 가능할 수 있지만 Spring Modulith 검증에서 오류가 난다.
18.3 명시적으로 허용한 모듈만 사용해야 한다
@org.springframework.modulith.ApplicationModule(
allowedDependencies = "member"
)
package com.example.shop.order;
이렇게 했다면 주문 모듈이 결제 모듈을 직접 참조할 때 검증 오류가 난다.
공식 문서: Verifying Application Module Structure
19. ArchUnit은 어떤 역할인가?
기존 발표 메모의 다음 내용은 방향이 맞다.
ArchUnit을 이용하면 아키텍처 규칙을 자동으로 검사하고 구조를 유지할 수 있다.
더 정확히 정리하면 다음과 같다.
- Spring Modulith는 애플리케이션 모듈 구조를 분석한다.
ApplicationModules.verify()를 통해 구조 위반을 자동으로 검사한다.- ArchUnit 계열의 아키텍처 검증과 함께 사용할 수 있다.
- jMolecules가 추가되어 있으면 DDD나 헥사고날 아키텍처 관련 규칙과도 연동할 수 있다.
문서에만 다음처럼 적어두는 것과는 다르다.
결제 모듈은 주문 모듈에 의존하면 안 된다.
누군가 나중에 실제 코드에서 주문 모듈을 참조할 수 있다.
verify() 테스트가 있으면 이런 변경이 테스트 단계에서 드러난다.
코드 작성
↓
테스트 실행
↓
모듈 구조 분석
↓
잘못된 참조 발견
↓
빌드 실패
20. 모듈별 통합 테스트
Spring Modulith는 전체 애플리케이션을 한꺼번에 테스트하는 것뿐 아니라 특정 모듈을 중심으로 통합 테스트를 실행하도록 도와준다.
테스트용 의존성을 추가한다.
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
그리고 모듈 패키지 안에 테스트를 작성한다.
package com.example.shop.order;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.test.ApplicationModuleTest;
@ApplicationModuleTest
class OrderModuleTests {
@Test
void completesOrder() {
// 주문 모듈 통합 테스트
}
}
@SpringBootTest는 보통 애플리케이션 전체를 띄운다.
반면 @ApplicationModuleTest는 테스트가 위치한 모듈을 중심으로 애플리케이션을 구성한다.
20.1 테스트 부트스트랩 모드
| 모드 | 의미 |
|---|---|
STANDALONE | 현재 모듈만 실행 |
DIRECT_DEPENDENCIES | 현재 모듈과 직접 의존하는 모듈 실행 |
ALL_DEPENDENCIES | 현재 모듈과 의존성 전체 실행 |
기본값은 STANDALONE이다.
주문 모듈을 테스트하는데 회원·결제·알림 모듈까지 모두 실행하면 테스트가 느려지고, 어디서 실패했는지 파악하기 어려워진다.
모듈을 독립적으로 실행할 수 있다는 것은 모듈 경계가 비교적 잘 설계되었다는 신호이기도 하다.
21. Scenario API
비동기 이벤트는 테스트하기 어렵다.
다음 코드는 다음 내용을 표현한다.
- 주문 완료 메서드를 실행한다.
OrderCompleted이벤트가 발생하기를 기다린다.- 이벤트가 실제로 도착하면 테스트 성공
- 일정 시간 안에 도착하지 않으면 테스트 실패
@ApplicationModuleTest
class OrderModuleTests {
@Test
void publishesOrderCompletedEvent(Scenario scenario) {
var order = new Order();
scenario.stimulate(() -> orders.complete(order))
.andWaitForEventOfType(OrderCompleted.class)
.matching(event ->
event.orderId().equals(order.id())
)
.toArrive();
}
}
또는 이벤트 대신 상태 변화를 기다릴 수 있다.
scenario.publish(new OrderCompleted(order.id()))
.andWaitForStateChange(() -> inventory.find(order.id()))
.andVerify(result -> {
// 재고가 감소했는지 검증
});
현재 공식 문서의 Scenario API는 자극, 기다릴 결과, 제한 시간, 결과 검증을 하나의 흐름으로 표현한다.
참고: Integration Testing Application Modules
21.1 다른 모듈의 Bean을 직접 사용하는 경우
현재 모듈이 다른 모듈의 Bean을 직접 필요로 하면 독립 테스트가 실패할 수 있다.
그럴 때 테스트에서 가짜 Bean을 만들 수 있다.
@ApplicationModuleTest
class InventoryModuleTests {
@MockitoBean
SomeOtherComponent someOtherComponent;
}
하지만 다른 모듈의 Bean을 너무 많이 가짜로 만들어야 한다면 다음 신호일 수 있다.
이 모듈이 다른 모듈과 너무 강하게 결합되어 있다.
이 경우 직접 Bean을 주입하는 대신 이벤트로 바꿀 수 있는지 검토한다.
22. Spring Modulith로 문서 만들기
모듈 구조를 코드로만 관리하면 시간이 지나면서 아무도 전체 구조를 기억하지 못하게 된다.
Spring Modulith는 모듈 사이의 관계를 다이어그램과 문서로 만들 수 있다.
ApplicationModules modules =
ApplicationModules.of(ShopApplication.class);
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml()
.writeModuleCanvases();
생성할 수 있는 문서:
- 전체 모듈 관계 다이어그램
- 특정 모듈과 직접 의존하는 모듈 다이어그램
- 모듈의 Spring Bean 목록
- 공개 이벤트
- 수신 이벤트
- 설정 프로퍼티
- Aggregate Root 정보
Application Module Canvas는 모듈 하나를 다음처럼 요약하는 문서다.
모듈 이름: inventory
Spring Components:
- InventoryManagement
- InventoryRepository
Published Events:
- StockShort
Events Listened To:
- OrderCompleted
- OrderCanceled
Configuration:
- inventory.restock-threshold
참고: Documenting Application Modules
23. Runtime 검증과 운영 기능
Spring Modulith는 테스트에만 사용할 수도 있지만, 운영 중에도 모듈 구조를 활용할 수 있다.
23.1 애플리케이션 시작 시 구조 검증
spring-modulith-runtime을 추가하고 다음 설정을 사용할 수 있다.
spring.modulith.runtime.verification-enabled=true
그러면 애플리케이션 시작 시 모듈 구조가 잘못되어 있으면 애플리케이션 시작을 중단할 수 있다.
애플리케이션 시작
↓
모듈 구조 분석
↓
위반 발견
↓
시작 실패
23.2 Application Module Initializer
특정 모듈이 애플리케이션 시작 시 초기화 작업을 해야 할 수 있다.
예를 들어 다음과 같은 의존성이 있다고 하자.
member -> order -> payment
이때 모듈 초기화도 의존성 순서에 맞게 실행되어야 할 수 있다.
ApplicationModuleInitializer를 이용하면 모듈 의존성 구조를 바탕으로 초기화 순서를 정할 수 있다.
23.3 Actuator
Spring Modulith의 Actuator 기능을 사용하면 다음 주소에서 모듈 정보를 확인할 수 있다.
GET /actuator/modulith
응답에는 다음과 같은 정보가 들어갈 수 있다.
{
"order": {
"basePackage": "com.example.shop.order",
"dependencies": []
},
"inventory": {
"basePackage": "com.example.shop.inventory",
"dependencies": [
{
"target": "order",
"types": [
"EVENT_LISTENER"
]
}
]
}
}
운영 환경에서 다음을 파악하는 데 유용하다.
- 어떤 모듈이 있는가?
- 어떤 모듈이 어떤 모듈에 의존하는가?
- Bean 호출인가?
- 이벤트 리스너인가?
- 모듈 간 호출이 얼마나 발생하는가?
24. Nested Application Module
Spring Modulith는 모듈 안에 다시 하위 모듈을 만들 수도 있다.
inventory
├── InventoryManagement.java
├── internal
└── warehouse
├── package-info.java
├── WarehouseManagement.java
└── internal
inventory.warehouse 패키지에 @ApplicationModule을 붙이면 중첩 모듈로 취급할 수 있다.
다음과 같은 경우에 유용하다.
inventory
├── 일반 재고
├── 창고 관리
├── 공급업체 관리
└── 재입고 관리
처음부터 Nested Module까지 사용할 필요는 없다.
처음에는 다음 정도로 시작하는 편이 좋다.
order
inventory
payment
member
그다음 특정 모듈이 너무 커졌을 때 내부 구조를 다시 나누는 것이 이해하기 쉽다.
25. Spring Modulith와 데이터베이스
Spring Modulith를 사용한다고 데이터베이스가 자동으로 모듈별로 나뉘는 것은 아니다.
다음처럼 하나의 데이터베이스를 계속 사용할 수 있다.
하나의 Spring Boot 애플리케이션
하나의 데이터베이스
│
├── order 모듈 -> orders 테이블
├── inventory 모듈 -> inventories 테이블
├── payment 모듈 -> payments 테이블
└── member 모듈 -> members 테이블
하지만 모듈별 데이터 소유권을 정하는 것이 좋다.
order 모듈만 orders 테이블을 수정
inventory 모듈만 inventories 테이블을 수정
payment 모듈만 payments 테이블을 수정
좋은 방식:
order 모듈 -> 재고 API 또는 OrderCompleted 이벤트
나쁜 방식:
order 모듈 -> inventory 테이블 직접 조회
Spring Modulith는 패키지와 코드 의존성을 주로 검사한다. 데이터베이스 접근 권한까지 자동으로 완벽하게 분리해주는 것은 아니다.
26. Spring Modulith가 해주지 않는 것
Spring Modulith를 너무 강력한 마법처럼 이해하면 안 된다.
26.1 업무 영역을 자동으로 찾아주지 않는다
다음 결정은 개발자가 해야 한다.
주문과 결제를 하나로 묶을 것인가?
회원과 인증을 분리할 것인가?
포인트를 별도 모듈로 만들 것인가?
26.2 모든 코드를 자동으로 private으로 만들지 않는다
Java의 public, package-private, 패키지 구조를 활용하고, 위반 여부를 검사한다.
26.3 데이터베이스 경계를 자동으로 만들지 않는다
모듈별 테이블 소유권과 데이터 접근 정책은 개발자가 설계해야 한다.
26.4 마이크로서비스로 자동 변환하지 않는다
모듈을 분리해도 여전히 하나의 애플리케이션일 수 있다.
26.5 이벤트 처리 성공을 자동으로 보장하지 않는다
안전한 이벤트 처리가 필요하면 Event Publication Registry나 Outbox Pattern 등을 함께 고려해야 한다.
26.6 잘못된 모듈 경계를 자동으로 고쳐주지 않는다
Spring Modulith는 문제를 발견해준다.
하지만 다음 결정은 개발자의 몫이다.
- 직접 호출을 유지할 것인가?
- 이벤트로 바꿀 것인가?
- 공개 API를 추가할 것인가?
- 모듈을 합칠 것인가?
- 모듈을 더 쪼갤 것인가?
27. 실제 프로젝트에 적용하는 순서
1단계: 업무 영역을 찾기
order
inventory
payment
member
notification
기술별로 나누기보다 비즈니스 책임별로 나눈다.
2단계: 직접 하위 패키지로 이동시키기
com.example.shop.order
com.example.shop.inventory
com.example.shop.payment
3단계: 외부 공개 API를 작게 만들기
order
├── OrderManagement.java
├── OrderCompleted.java
└── internal
├── OrderPolicy.java
└── OrderCalculator.java
모듈 외부에 반드시 필요한 타입만 public으로 남긴다.
4단계: 구조 검증 추가
@Test
void verifiesModularStructure() {
ApplicationModules.of(ShopApplication.class).verify();
}
5단계: 내부 패키지 참조 제거
다음과 같은 코드를 찾는다.
import com.example.shop.order.internal.*;
공개 API 또는 이벤트를 사용하도록 바꾼다.
6단계: 직접 Bean 호출 줄이기
private final InventoryManagement inventory;
대신 다음을 검토한다.
events.publishEvent(new OrderCompleted(order.id()));
7단계: 모듈별 테스트 작성
@ApplicationModuleTest
class OrderModuleTests {
}
8단계: 다이어그램과 문서 생성
new Documenter(modules)
.writeModulesAsPlantUml()
.writeModuleCanvases();
28. 기존 발표 메모를 정확하게 다시 정리하기
한 모듈의 변경이 다른 모듈에 영향을 주지 않게끔 하는 것
방향은 맞다.
더 정확히는 다음과 같다.
한 모듈의 내부 구현 변경이 다른 모듈에 불필요하게 전파되지 않도록 공개 API와 의존성 규칙을 정하는 것
모듈 단위: 메인 패키지의 각 디렉토리
다음처럼 수정하는 것이 정확하다.
기본적으로 Spring Boot 메인 애플리케이션 패키지의 직접적인 하위 Java 패키지가 애플리케이션 모듈이 된다.
A: Simple Application Modules
맞는 내용:
- 모듈 패키지가 하위 패키지를 갖지 않는다.
- Java의 package-private 접근 제어자로 내부 구현을 숨길 수 있다.
- public 타입들이 모듈의 기본 API가 된다.
주의할 점:
- 모듈이 커지면 루트 패키지에 public 타입이 많아질 수 있다.
- 이때 Named Interface나 내부 하위 패키지를 검토한다.
B: Named Interfaces
수정할 내용:
/api라는 이름만으로 공개 API가 되는 것은 아니다.- 공개할 패키지에
@NamedInterface를 붙여야 한다. package-info.java는 패키지에 어노테이션이나 문서를 붙이는 파일이다.
@NamedInterface("api")
package com.example.shop.order.api;
C: Explicit Application Module Dependency
@ApplicationModule은 모듈에 대한 설명만 적는 어노테이션이 아니다.
다음과 같은 설정을 할 수 있다.
- 사람이 읽을 모듈 이름 지정
- 허용된 모듈 의존성 지정
- named interface 단위의 의존성 지정
- Open 또는 Closed 모듈 유형 지정
@ApplicationModule(
allowedDependencies = "order::api"
)
package com.example.shop.inventory;
ArchUnit과 검증
모듈 구조 검증은 다음 한 줄에서 시작한다.
ApplicationModules.of(Application.class).verify();
이 검증은 다음 문제를 찾는다.
- 모듈 순환 의존성
- 다른 모듈의 내부 패키지 참조
- 명시적으로 허용하지 않은 모듈 의존성
29. 최종 기억용 비유
Spring Modulith를 학교의 여러 부서로 기억하면 된다.
| 학교 비유 | Spring Modulith |
|---|---|
| 주문 창구 | 모듈 API |
| 부서 안쪽 사무실 | 내부 구현 |
| 부서 간 공식 공문 | Application Event |
| 아무나 창고에 들어가는 것 | 내부 패키지 직접 참조 |
| 부서 출입 규칙 검사 | ApplicationModules.verify() |
| 한 부서만 시험 운영 | @ApplicationModuleTest |
| 부서 조직도 | Documenter |
| 업무 방송 | @ApplicationModuleListener |
| 방송 처리 기록 | Event Publication Registry |
가장 중요한 문장은 다음이다.
Spring Modulith는 애플리케이션을 업무별 부서로 나누고, 각 부서의 내부를 보호하며, 부서 사이의 공식적인 소통 방법을 정하고, 그 규칙이 실제 코드에서도 지켜지는지 자동으로 검사하는 도구다.
그리고 반드시 기억해야 할 오해는 이것이다.
Spring Modulith의 목표는 모듈 사이의 의존성을 없애는 것이 아니라, 의존성을 작고 명확하고 통제된 형태로 만드는 것이다.
30. 빠른 복습 질문
Q1. order/api라는 폴더를 만들면 자동으로 공개 API가 되는가?
아니다. @NamedInterface("api")처럼 공개 영역이라고 선언하는 것이 안전하다.
Q2. order.internal.OrderPolicy가 public이면 다른 모듈이 사용해도 되는가?
Java 컴파일은 될 수 있지만, 모듈 내부 구현이라면 Spring Modulith 검증에서 위반으로 처리되어야 한다.
Q3. @ApplicationModuleListener는 왜 사용하는가?
모듈 사이의 이벤트 통합을 비동기·트랜잭션 방식으로 쉽게 선언하기 위해 사용한다.
Q4. order -> inventory와 inventory -> order가 동시에 있으면 어떤 문제인가?
모듈 사이에 순환 의존성이 생긴 것이다. 이벤트나 공개 API 재설계를 검토해야 한다.
Q5. ApplicationModules.of(...).verify()는 무엇을 검사하는가?
모듈 순환 의존성, 내부 패키지 참조, 허용되지 않은 모듈 의존성 등을 검사한다.
Q6. Spring Modulith를 사용하면 마이크로서비스가 되는가?
아니다. 보통 하나의 Spring Boot 애플리케이션 안에서 모듈형 모놀리스를 만드는 것이다.