멀티 모듈 프로젝트 — 스프링 부트에서 모듈을 나누는 전략과 구조
프로젝트가 커지면서 하나의 모듈에 모든 코드가 뒤섞여 있다면, 어디서부터 분리를 시작해야 할까요?
멀티 모듈이란
하나의 프로젝트를 여러 개의 독립적인 모듈(서브 프로젝트)로 나누는 구조입니다. 각 모듈은 자체 build.gradle을 가지고, 필요한 모듈만 의존성으로 선언합니다.
왜 멀티 모듈인가
단일 모듈의 문제:
- **아키텍처 침식 **: 도메인 로직에서 직접 DB 접근 코드를 호출
- ** 의존성 오염 **: 테스트 유틸리티가 프로덕션 코드에 섞임
- ** 빌드 비효율 **: 한 줄 수정에도 전체 빌드
- ** 배포 제약 **: 모든 코드가 하나의 JAR에 포함
멀티 모듈의 이점:
- ** 컴파일 타임 경계 강제 **: 모듈 간 의존성을 Gradle이 검증
- ** 독립적 빌드 **: 변경된 모듈만 재빌드
- ** 재사용 **: 공통 모듈을 여러 프로젝트에서 공유
- ** 명확한 책임 분리 **: 각 모듈이 하나의 관심사를 담당
일반적인 모듈 구조
레이어드 아키텍처 기반
my-project/
├── settings.gradle
├── build.gradle ← 루트 빌드 (공통 설정)
├── module-domain/ ← 도메인 로직 (순수 Java)
├── module-infrastructure/ ← DB, 외부 API, 메시징
├── module-api/ ← REST API (Spring Boot 메인)
└── module-common/ ← 공통 유틸리티, DTO
의존성 방향
module-api → module-infrastructure → module-domain
↗
module-common ─────────────────────
module-domain: 어떤 모듈에도 의존하지 않음 (순수 Java)module-infrastructure: domain에 의존module-api: infrastructure, domain에 의존module-common: 어떤 모듈에도 의존하지 않음
Gradle 설정
settings.gradle
rootProject.name = 'my-project'
include 'module-common'
include 'module-domain'
include 'module-infrastructure'
include 'module-api'
루트 build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0' apply false
id 'io.spring.dependency-management' version '1.1.7' apply false
}
// 모든 서브 프로젝트에 공통 적용
subprojects {
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = JavaVersion.VERSION_21
}
이어서 나머지 빌드 설정을 추가합니다.
repositories {
mavenCentral()
}
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:3.4.0"
}
}
dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.junit.jupiter:junit-jupiter'
}
}
module-domain/build.gradle
// 도메인 모듈: Spring 의존성 없음 (순수 Java)
dependencies {
implementation project(':module-common')
// Spring, JPA 의존성 없음!
}
module-infrastructure/build.gradle
plugins {
id 'org.springframework.boot' apply false
}
dependencies {
implementation project(':module-domain')
implementation project(':module-common')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
runtimeOnly 'com.mysql:mysql-connector-j'
}
module-api/build.gradle
plugins {
id 'org.springframework.boot' // 이 모듈만 실행 가능한 JAR 생성
}
dependencies {
implementation project(':module-domain')
implementation project(':module-infrastructure')
implementation project(':module-common')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
}
도메인 모듈 — 순수 Java
// module-domain/src/main/java/com/example/domain/order/
// 엔티티 (JPA 어노테이션 없음)
public class Order {
private Long id;
private Long customerId;
private OrderStatus status;
private List<OrderItem> items;
private Money totalAmount;
public void complete() {
if (this.status != OrderStatus.CONFIRMED) {
throw new IllegalStateException(
"확인된 주문만 완료할 수 있습니다");
}
this.status = OrderStatus.COMPLETED;
}
}
이어서 나머지 구현 부분입니다.
// 리포지토리 인터페이스 (구현은 infrastructure에서)
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(Long id);
List<Order> findByCustomerId(Long customerId);
}
// 도메인 서비스
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentProcessor paymentProcessor;
이어서 나머지 구현 부분입니다.
public OrderService(OrderRepository orderRepository,
PaymentProcessor paymentProcessor) {
this.orderRepository = orderRepository;
this.paymentProcessor = paymentProcessor;
}
public Order createOrder(Long customerId, List<OrderItem> items) {
Order order = Order.create(customerId, items);
return orderRepository.save(order);
}
}
인프라스트럭처 모듈 — 구현체
// module-infrastructure/src/main/java/com/example/infra/persistence/
// JPA 엔티티 (도메인 Order와 별도)
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long customerId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
이어서 나머지 구현 부분입니다.
// 도메인 객체로 변환
public Order toDomain() {
return new Order(id, customerId, status, ...);
}
// 도메인 객체에서 생성
public static OrderEntity from(Order order) {
return new OrderEntity(order.getId(), ...);
}
}
이어서 나머지 구현 부분입니다.
// JPA Repository
public interface OrderJpaRepository
extends JpaRepository<OrderEntity, Long> {
List<OrderEntity> findByCustomerId(Long customerId);
}
// 도메인 Repository 구현체
@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepository {
private final OrderJpaRepository jpaRepository;
이어서 나머지 어노테이션 기반 구현부입니다.
@Override
public Order save(Order order) {
OrderEntity entity = OrderEntity.from(order);
OrderEntity saved = jpaRepository.save(entity);
return saved.toDomain();
}
@Override
public Optional<Order> findById(Long id) {
return jpaRepository.findById(id)
.map(OrderEntity::toDomain);
}
}
API 모듈 — Spring Boot 진입점
// module-api/src/main/java/com/example/api/
@SpringBootApplication(
scanBasePackages = "com.example" // 모든 모듈의 빈 스캔
)
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@RequestBody OrderRequest request) {
Order order = orderService.createOrder(
request.getCustomerId(), request.toItems());
return ResponseEntity.ok(OrderResponse.from(order));
}
}
이어서 나머지 구현 부분입니다.
// 빈 등록 설정
@Configuration
public class DomainConfig {
@Bean
public OrderService orderService(
OrderRepository orderRepository,
PaymentProcessor paymentProcessor) {
// 도메인 서비스는 순수 Java라 @Service가 없으므로 수동 등록
return new OrderService(orderRepository, paymentProcessor);
}
}
순환 참조 방지
문제: A → B → A
module-order → module-payment (결제 처리)
module-payment → module-order (주문 상태 업데이트) ← 순환!
해결 1: 공통 인터페이스 추출
module-common에 OrderStatusUpdater 인터페이스 정의
module-order에서 구현
module-payment는 인터페이스만 의존
해결 2: 이벤트 기반
// module-payment에서 이벤트 발행
events.publishEvent(new PaymentCompleted(orderId));
// module-order에서 이벤트 구독
@EventListener
void on(PaymentCompleted event) {
orderService.updateStatus(event.orderId(), PAID);
}
해결 3: 모듈 병합
두 모듈이 너무 밀접하게 결합되어 있다면 하나의 모듈로 합치는 것이 나을 수도 있습니다.
빈 스캔 문제
멀티 모듈에서 가장 흔한 문제는 빈 스캔이 다른 모듈의 빈을 찾지 못하는 것입니다.
// 해결: scanBasePackages를 루트 패키지로 설정
@SpringBootApplication(scanBasePackages = "com.example")
public class MyApplication { }
또는 각 모듈에 @Configuration 클래스를 명시적으로 Import합니다.
@SpringBootApplication
@Import({InfraConfig.class, DomainConfig.class})
public class MyApplication { }
실무 팁
1. 모듈 수를 최소화하세요
처음부터 10개의 모듈로 시작하면 관리 복잡도만 높아집니다. 3~5개로 시작하고 필요할 때 분리하세요.
2. 공통 모듈을 비대하게 만들지 마세요
module-common에 모든 것을 넣으면 결국 모놀리스의 축소판이 됩니다. 정말 모든 모듈이 필요로 하는 것만 넣으세요.
3. API별 모듈 분리
module-api-public/ ← 외부 사용자 API
module-api-admin/ ← 관리자 API
module-api-internal/ ← 내부 서비스 간 API
각각 다른 보안 설정, 다른 의존성을 가질 수 있습니다.
4. 의존성 방향 검증
// 빌드 시 의존성 방향 확인
tasks.register('checkDependencies') {
doLast {
// module-domain이 Spring에 의존하면 실패
def domainDeps = project(':module-domain')
.configurations.runtimeClasspath.files
assert domainDeps.findAll { it.name.contains('spring') }.isEmpty()
}
}
주의할 점
1. scanBasePackages를 설정하지 않으면 다른 모듈의 빈을 찾지 못한다
@SpringBootApplication은 기본적으로 자기 패키지 하위만 스캔합니다. API 모듈의 메인 클래스가 com.example.api에 있으면, com.example.infra의 @Repository가 스캔되지 않아 NoSuchBeanDefinitionException이 발생합니다. scanBasePackages = "com.example"로 루트 패키지를 지정하거나, 각 모듈의 @Configuration을 @Import로 명시해야 합니다.
2. 순환 참조가 Gradle 빌드 시점에 에러를 발생시킨다
module-order가 module-payment를 의존하고, module-payment가 다시 module-order를 의존하면 Gradle이 Circular dependency between 에러로 빌드를 거부합니다. 순환이 발견되면 공통 인터페이스를 module-common으로 추출하거나, 직접 호출 대신 이벤트 기반으로 전환해야 합니다.
3. 공통 모듈(module-common)이 비대해지면 모놀리스의 축소판이 된다
"일단 공통이니까 common에 넣자"라는 판단이 반복되면, 모든 모듈이 common에 의존하고 common이 비대해집니다. common을 수정하면 전체 모듈이 재빌드되어 멀티 모듈의 독립 빌드 이점이 사라집니다. common에는 정말 모든 모듈이 필요로 하는 것만 넣고, 2개 모듈만 공유하는 코드는 별도 모듈로 분리하세요.
정리
- 멀티 모듈은 ** 컴파일 타임에 아키텍처 경계를 강제 **합니다. 도메인 모듈이 인프라를 의존하지 못하게 하면 규칙이 자동으로 지켜집니다.
- ** 도메인 모듈 **은 Spring 의존성 없이 순수 Java로 유지합니다. 테스트와 재사용이 쉬워집니다.
- ** 인프라 모듈 **에서 도메인 인터페이스를 구현하고, API 모듈 이 이들을 조합합니다.
- 순환 참조는 공통 인터페이스 추출 또는 ** 이벤트 기반 통신 **으로 해결합니다.
- 처음부터 많은 모듈로 나누지 말고, 3~5개로 시작하여 점진적으로 분리하세요.