본문 바로가기

Language/Java

[Java] Java 모듈화, 무엇으로 해야 할까? - JPMS vs Gradle 멀티 프로젝트 vs Spring Modulith

애플리케이션 규모가 커지면 언젠가는 "이 코드, 어떻게 나눠야 하지?"라는 질문을 마주하게 됩니다. Java 생태계에는 이를 위한 세 가지 수단이 있습니다. JPMS, Gradle 멀티 프로젝트, Spring Modulith. 이름만 들어도 비슷해 보이지만, 각각이 다루는 문제의 레이어가 전혀 다릅니다.

 

세 도구는 배타적이지 않습니다. 레이어가 다르기 때문에 조합해서 쓸 때 서로의 빈틈을 보완합니다. 각각을 레이어별로 살펴봅니다.

 


1. JPMS — JVM/런타임 레벨

JPMS(Java Platform Module System)는 JDK 9에서 Project Jigsaw의 결과물로 도입됐습니다. 도입 목적은 크게 두 가지입니다.

  • JDK 자체의 모듈화java.base, java.sql 등으로 JDK를 쪼개어 필요한 모듈만 포함한 경량 런타임 이미지(jlink) 생성
  • Classpath Hell 해결 — 동일 패키지가 여러 JAR에 분산되거나 내부 API가 무분별하게 노출되는 문제를 JVM 수준에서 차단

 

작동 방식

모듈 경계는 소스 루트에 위치하는 module-info.java 파일 하나로 선언합니다.

// module-info.java
module com.example.order {
    // 의존 모듈 선언
    requires java.sql;
    requires com.example.core;

    // 전이적 의존 — 이 모듈을 사용하는 모듈도 java.logging을 자동으로 읽음
    requires transitive java.logging;

    // 컴파일 타임에만 필요한 선택적 의존 (런타임 선택적)
    requires static com.example.annotation;

    // 외부 공개 패키지 (컴파일 + 런타임)
    exports com.example.order.api;

    // 특정 모듈에만 공개 (qualified export)
    exports com.example.order.spi to com.example.payment;

    // 리플렉션 허용 (런타임 전용, JPA 등)
    opens com.example.order.domain to org.hibernate.orm.core;

    // ServiceLoader SPI 선언
    uses com.example.order.api.PricingStrategy;
    provides com.example.order.api.PricingStrategy
        with com.example.order.internal.DefaultPricingStrategy;
}

exports 하지 않은 패키지는 컴파일 타임과 런타임 모두에서 접근 불가합니다. public 클래스라도 마찬가지입니다. 리플렉션 역시 opens 선언 없이는 차단됩니다.

 

각 지시어의 역할을 정리하면 다음과 같습니다.

지시어 역할
requires 의존 모듈 선언
requires transitive 의존 모듈을 이 모듈의 소비자에게도 전파
requires static 컴파일 타임 전용 의존 (런타임 선택적)
exports 패키지의 public 타입을 모든 모듈에 공개
exports … to 특정 모듈에만 공개 (qualified export)
opens 런타임 리플렉션 허용
opens … to 특정 모듈에만 리플렉션 허용
uses / provides ServiceLoader SPI 등록 및 사용

 

Spring Boot 환경에서의 현실

Spring Framework 6.0 출시 당시 JPMS 완전 지원을 계획했으나 결국 포함되지 않았습니다. 당시 Spring Developer Advocate Oliver Drotbohm은 그 이유를 다음과 같이 설명했습니다.

"Spring Framework 6.0 strongly focuses on AOT and GraalVM native images for optimizing the deployment arrangement of Spring-based applications. At the same time, our module system initiative has not arrived at a build migration to full JPMS module descriptors yet. There have been very few requests for it in the course of this year."

 

근본적인 이유는 리플렉션 제한입니다. Spring, Hibernate, Jackson 등 주요 프레임워크들이 리플렉션에 의존하는데, JPMS는 이를 기본적으로 차단합니다. 모든 리플렉션 대상 패키지에 opens를 일일이 선언해야 하고, module-info.java를 제공하지 않는 서드파티 라이브러리는 "자동 모듈(automatic module)"로 처리됩니다. 자동 모듈은 모든 패키지를 무조건 공개하므로, 세밀한 접근 제어라는 JPMS의 목적과 상충합니다.

 

현실적으로는 --add-opens--add-exports 커맨드라인 옵션으로 우회하는 경우가 많지만, 이는 임시방편에 가깝습니다.

# module-info.java 없이 리플렉션 허용을 강제하는 우회책
java --add-opens java.base/java.lang=ALL-UNNAMED \
     --add-opens java.base/java.util=ALL-UNNAMED \
     -jar my-app.jar

이런 우회책이 빌드 스크립트에 늘어난다면 JPMS를 제대로 활용하고 있는 것이 아닙니다.

 

언제 쓰는가

적합 부적합
JDK 자체 또는 라이브러리 개발 Spring Boot 애플리케이션
jlink로 경량 런타임 이미지 생성 리플렉션 기반 프레임워크 사용 시
패키지 은닉이 핵심인 SDK 배포 Fat JAR 배포 환경

 


2. Gradle 멀티 프로젝트 — 빌드 레벨

Gradle 멀티 프로젝트는 빌드 도구가 관리하는 소스 코드 분리입니다. 여러 서브프로젝트(모듈)를 단일 settings.gradle 파일로 묶고, 서브프로젝트 간 의존 관계를 project(":서브프로젝트명") 참조로 선언합니다.

소스 파일이 변경되면 Gradle은 영향받는 프로젝트만 재컴파일합니다. 즉, 모듈 분리는 아키텍처 관심사인 동시에 빌드 성능 관심사이기도 합니다.

 

기본 구조

my-app/
├── settings.gradle.kts          # 서브프로젝트 목록
├── build.gradle.kts             # 공통 설정
├── build-logic/                 # 컨벤션 플러그인 (권장)
│   └── src/main/kotlin/
│       └── java-conventions.gradle.kts
├── api/                         # :api 서브프로젝트
│   └── build.gradle.kts
├── core/                        # :core 서브프로젝트
│   └── build.gradle.kts
└── web/                         # :web 서브프로젝트
    └── build.gradle.kts
// settings.gradle.kts
rootProject.name = "my-app"
include("api", "core", "web")
// :web/build.gradle.kts
dependencies {
    implementation(project(":core"))
    implementation(project(":api"))
}

 

의존 구성 선택: api vs implementation

서브프로젝트 간 의존 선언에서 apiimplementation의 선택은 컴파일 클래스패스의 범위를 결정합니다.

 

// java-library 플러그인 적용 시 api 구성 사용 가능
plugins {
    `java-library`
}

dependencies {
    // api: 이 모듈을 의존하는 상위 모듈에도 노출됨 (전이적)
    api(project(":domain"))

    // implementation: 이 모듈 내부에서만 사용, 상위 모듈에 노출되지 않음
    implementation(project(":infra"))

    // compileOnly: 컴파일 타임에만 필요, 런타임 클래스패스에 포함되지 않음
    compileOnly(libs.lombok)
}

api는 공개 메서드의 리턴 타입이나 파라미터 타입이 다른 모듈에서 온 경우처럼, 소비자도 그 타입을 직접 써야 하는 경우에만 사용합니다. 그 외에는 implementation이 기본입니다.

 

의존 방향 강제

컴파일 타임에 의존성 방향이 강제됩니다. :web:core를 참조하는 동시에 :core:web을 참조하는 순환 의존이 생기면 빌드가 실패합니다. 단, 이는 빌드 수준의 강제이며 Fat JAR로 패키징하면 모든 클래스가 단일 클래스패스에 합쳐집니다.

 

컨벤션 플러그인 패턴

서브프로젝트가 많아지면 build.gradle.kts마다 Java 버전, 테스트 설정이 중복됩니다. Gradle의 공식 권장 패턴은 build-logic 내부 빌드에 컨벤션 플러그인을 두는 것입니다.

// build-logic/src/main/kotlin/java-conventions.gradle.kts
plugins {
    `java-library`
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.encoding = "UTF-8"
}

tasks.test {
    useJUnitPlatform()
}

각 서브프로젝트는 이 플러그인 하나만 적용하면 됩니다.

// :core/build.gradle.kts
plugins {
    id("java-conventions")
}

dependencies {
    implementation(libs.guava)
}

 

버전 카탈로그

의존성 버전 관리는 gradle/libs.versions.toml로 중앙화합니다.

# gradle/libs.versions.toml
[versions]
spring-boot = "3.5.0"
jackson = "2.18.0"

[libraries]
spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }

 

한계

빌드 수준에서 서브프로젝트 간 의존 방향을 강제하지만, 하나의 서브프로젝트 안에 여러 관심사가 함께 있으면 패키지 간 무분별한 접근을 막지 못합니다. 예를 들어 :order 서브프로젝트 하나 안에 도메인 패키지와 인프라 패키지가 공존한다면, 도메인 코드가 인프라를 직접 참조해도 Gradle은 이를 잡아내지 않습니다. 계층까지 별도 서브프로젝트로 나누면 이 문제가 해결되지만, 그만큼 서브프로젝트 수가 늘어납니다. 서브프로젝트를 도메인 단위로만 나누는 경우 이 지점을 보완하는 것이 Spring Modulith의 역할입니다.

 


3. Spring Modulith — 애플리케이션 아키텍처 레벨

Spring Modulith는 단일 Spring Boot 애플리케이션 내에서 도메인 경계를 코드로 강제하는 도구입니다. 별도 JVM 프로세스나 네트워크 없이, 패키지 구조와 테스트로 모듈 경계를 검증합니다.

 

핵심 발상은 간단합니다. 애플리케이션 메인 패키지 바로 아래의 각 패키지를 하나의 모듈로 취급하고, 하위 패키지는 해당 모듈의 내부 구현으로 취급합니다.

모듈 하위 패키지의 클래스는 public으로 선언되더라도, 다른 모듈에서 참조하면 Spring Modulith 검증 테스트가 실패합니다. Java 컴파일러가 아닌 Spring Modulith가 이 규칙을 강제한다는 점이 JPMS와의 핵심 차이입니다.

 

모듈 경계 검증

의존성 검증은 ArchUnit을 기반으로 합니다. 테스트 하나로 전체 모듈 경계 위반을 한 번에 잡습니다.

@Test
void verifyModularStructure() {
    ApplicationModules.of(Application.class).verify();
}

이 테스트는 다음을 검증합니다.

  • 모듈의 하위 패키지(내부 구현)를 외부 모듈이 참조하는지
  • 모듈 간 순환 의존이 있는지
  • 선언된 allowedDependencies를 위반하는지

 

모듈 구조를 콘솔로 출력해 확인할 수도 있습니다.

ApplicationModules.of(Application.class).forEach(System.out::println);
## example.order ##
> Logical name: order
> Base package: example.order
> Spring beans:
  + ….OrderService                  ← public 빈 (외부 접근 가능)
  o ….internal.OrderJpaRepository   ← package-private 빈 (모듈 외부 비공개)

+public, o는 package-private 빈을 나타냅니다.

 

명시적 의존 선언

// order/package-info.java
@ApplicationModule(allowedDependencies = "inventory")
package com.example.order;

order 모듈은 inventory만 참조할 수 있으며, 다른 모듈을 참조하면 verify() 호출 시 오류가 발생합니다.

 

NamedInterface — 세밀한 공개 API 제어

기본적으로 모듈의 최상위 패키지에 있는 타입은 모두 공개됩니다. 하위 패키지의 일부 타입을 선택적으로 공개하려면 @NamedInterface를 사용합니다.

// order/spi/package-info.java
@NamedInterface("spi")
package com.example.order.spi;

 

다른 모듈에서 이 named interface를 참조할 때는 모듈 이름과 named interface 이름을 :: 로 이어 씁니다.

// inventory/package-info.java
// order 모듈의 spi 패키지만 허용, OrderService 같은 다른 타입은 차단
@ApplicationModule(allowedDependencies = "order :: spi")
package com.example.inventory;

 

모든 named interface를 허용하고 싶다면 와일드카드를 쓸 수 있습니다.

@ApplicationModule(allowedDependencies = "order :: *")
package com.example.inventory;

 

Open Module — 레거시 마이그레이션용

기존 코드베이스에 Spring Modulith를 점진적으로 도입할 때, 일부 모듈의 내부 타입을 완전히 은닉하기 어려운 경우가 있습니다. 이런 경우 Type.OPEN으로 개방형 모듈을 선언합니다.

// legacy/package-info.java
@ApplicationModule(type = ApplicationModule.Type.OPEN)
package com.example.legacy;

개방형 모듈은 하위 패키지의 타입도 외부에서 접근할 수 있습니다. 완전한 모듈화의 목표 지점이 아니라, 마이그레이션 과도기를 위한 선택지입니다.

 

중첩 모듈 (Nested Modules, 1.3+)

모듈 내부를 더 세밀하게 나누어야 할 때는 하위 패키지에 @ApplicationModule을 직접 선언해 중첩 모듈을 만들 수 있습니다.

// inventory/nested/package-info.java
@ApplicationModule
package com.example.inventory.nested;

중첩 모듈의 공개 타입은 상위 모듈(inventory)과 같은 상위 모듈 아래에 있는 다른 중첩 모듈에서만 접근 가능합니다. 반대로 중첩 모듈 코드는 상위 모듈의 비공개 타입까지 접근할 수 있습니다.

 

이벤트 기반 모듈 간 통신

Spring Modulith는 모듈 간 직접 메서드 호출 대신 도메인 이벤트를 권장합니다. 직접 호출은 양쪽 모듈을 강하게 결합하여, 이후 기능 추가·제거 시마다 해당 클래스를 수정해야 합니다.

// 직접 호출 방식 — order가 inventory를 직접 알아야 함
@Transactional
public void complete(Order order) {
    inventory.updateStockFor(order);  // 모듈 간 직접 의존
}

이벤트 방식으로 바꾸면 order 모듈은 무슨 일이 일어났는지만 발행하고, 이후 처리는 각 모듈이 독립적으로 담당합니다.

// order 모듈 — 이벤트 발행
@Transactional
public void complete(Order order) {
    events.publishEvent(new OrderCompleted(order.getId()));
}

// inventory 모듈 — 이벤트 수신
@ApplicationModuleListener
void on(OrderCompleted event) {
    // 재고 차감 처리
}

@ApplicationModuleListener@Async + @Transactional(propagation = REQUIRES_NEW) + @TransactionalEventListener를 하나로 합친 어노테이션입니다.

 

Event Publication Registry — 이벤트 유실 방지

@TransactionalEventListener는 기본 phase가 AFTER_COMMIT이라 트랜잭션 격리는 기본으로 됩니다. 문제는 커밋 이후 리스너 실행 전에 애플리케이션이 재시작되면 이벤트가 유실된다는 점입니다. 이벤트 자체가 JVM 메모리에만 있었기 때문입니다. Spring Modulith는 이를 해결하기 위해 Event Publication Registry를 제공합니다.

 

이벤트가 발행되면 해당 이벤트를 수신할 모든 트랜잭션 리스너에 대한 발행 로그를 원본 트랜잭션의 일부로 DB에 기록합니다. 리스너 실행이 성공하면 완료(COMPLETED) 처리되고, 실패하거나 앱이 재시작되더라도 로그가 남아 재발행(RESUBMITTED)이 가능합니다.

 

이 기능을 사용하려면 영속성 기술에 맞는 스타터를 추가합니다.

// build.gradle.kts
dependencies {
    implementation("org.springframework.modulith:spring-modulith-starter-jpa")
}

JPA 외에도 JDBC, MongoDB, Neo4j 스타터를 지원합니다.

 

모듈 단위 통합 테스트

@ApplicationModuleTest는 해당 모듈과 그 의존 모듈만 로드하는 슬라이스 테스트를 실행합니다. 전체 애플리케이션 컨텍스트를 로드하지 않으므로 테스트가 빠르고, 다른 모듈의 변경에 영향받지 않습니다.

@ApplicationModuleTest
class OrderModuleTest {

    @Autowired OrderService orderService;

    @Test
    void placingOrderPublishesEvent(PublishedEvents events) {
        orderService.complete(new Order("order-1"));

        assertThat(events.ofType(OrderCompleted.class)).hasSize(1);
    }
}

 

부트스트랩 범위는 세 가지 모드로 조절합니다.

모드 설명
STANDALONE (기본) 현재 모듈만 로드
DIRECT_DEPENDENCIES 현재 모듈과 직접 의존 모듈 로드
ALL_DEPENDENCIES 현재 모듈과 전이적 의존 모듈 전체 로드

다른 모듈의 빈이 필요하지만 포함하고 싶지 않다면 @MockitoBean으로 대체합니다.

@ApplicationModuleTest
class InventoryModuleTest {

    @MockitoBean NotificationService notificationService;  // 다른 모듈 빈 모킹
}

 

아키텍처 문서 자동 생성

Documenter를 사용하면 모듈 구조를 PlantUML 다이어그램과 AsciiDoc 문서로 자동 생성할 수 있습니다.

@Test
void writeDocumentation() {
    var modules = ApplicationModules.of(Application.class).verify();

    new Documenter(modules)
        .writeModulesAsPlantUml()           // 전체 컴포넌트 다이어그램
        .writeIndividualModulesAsPlantUml(); // 모듈별 상세 다이어그램
}

결과물은 target/modulith-docs/ 디렉터리에 저장됩니다.

 

한계

JVM이 모듈 경계를 강제하지 않으므로, 테스트를 실행해야만 위반이 드러납니다. 테스트를 건너뛰면 경계 위반 코드가 프로덕션에 배포될 수 있습니다. Spring Modulith 2.0부터 spring.modulith.runtime.verification-enabled=true 설정으로 런타임 검증을 활성화할 수 있지만, 기본값은 비활성입니다.


세 가지 비교 정리

구분 JPMS Gradle 멀티 프로젝트 Spring Modulith
레이어 JVM / 런타임 빌드 도구 애플리케이션 프레임워크
경계 선언 module-info.java settings.gradle 패키지 구조 + @ApplicationModule
강제 시점 컴파일 + 런타임 컴파일 타임 테스트 시점 (옵션: 런타임)
리플렉션 제어 ✅ (opens 필요)
JVM 인식
순환 의존 감지 컴파일 오류 빌드 오류 테스트 오류
Fat JAR 호환 제한적
Spring Boot 호환 공식 미지원
주요 사용처 라이브러리 / JDK 배포 대규모 멀티팀 프로젝트 모듈러 모놀리스
실무 채택률 낮음 높음 점진적으로 증가

 


실무 조합 패턴

소규모 팀 / 단일 도메인

Gradle 단일 프로젝트 + Spring Modulith

Gradle 서브프로젝트 분리 없이, 패키지 구조와 Spring Modulith로 도메인 경계를 관리합니다. 오버헤드가 적고 빠르게 시작할 수 있습니다.

 

중규모 팀 / DDD + 헥사고날 아키텍처

Gradle 멀티 프로젝트 + Spring Modulith

도메인 간 경계와 계층 간 경계는 성격이 다릅니다. 도메인 경계 위반(reservationpayroll을 직접 참조하는 것)은 아키텍처를 근본적으로 망가뜨리지만, 계층 경계 위반(apirepository를 직접 참조하는 것)은 테스트로 잡을 수 있는 수준의 규칙 위반에 가깝습니다. 강도가 다른 두 경계에 같은 수준의 강제 수단을 쓸 필요가 없습니다.

 

이 패턴은 도메인 경계는 Gradle이 컴파일 타임에 강하게 막고, 계층 경계는 Spring Modulith가 테스트 시점에 검증하는 역할 분담입니다.

my-app/
├── :reservation              ← 도메인 서브프로젝트 (Gradle) → reservation.jar
│   ├── com.example.reservation.api/        ← Spring Modulith 모듈
│   ├── com.example.reservation.service/
│   ├── com.example.reservation.domain/
│   └── com.example.reservation.infra/
├── :payroll                  ← 도메인 서브프로젝트 (Gradle) → payroll.jar
│   └── ...
└── :app                      ← composition root → Fat JAR
    └── BOOT-INF/lib/
        ├── reservation.jar
        └── payroll.jar

:reservation:payroll은 각각 독립 JAR로 빌드됩니다. :reservationbuild.gradle.kts:payroll 의존이 선언되어 있지 않으면 컴파일 자체가 실패하므로, 도메인 간 무단 참조는 빌드 단계에서 차단됩니다.

 

계층 간 규칙(apiinfra 직접 참조 금지 등)은 각 서브프로젝트 내부에서 ArchUnit 테스트로 검증합니다. Spring Modulith는 도메인 단위 패키지 경계와 이벤트 기반 통신이 필요한 경우에 추가로 활용할 수 있습니다.

 

대규모 팀 / DDD + 헥사고날 아키텍처 (도메인·계층 모두 Gradle)

Gradle 멀티 프로젝트 (도메인 × 계층)

도메인 경계와 헥사고날 계층 경계 모두를 Gradle 서브프로젝트로 물리적으로 분리합니다. 계층 간 잘못된 참조도 컴파일 타임에 막을 수 있어 가장 강한 강제력을 가집니다.

my-app/
├── :reservation:domain       → reservation-domain.jar
├── :reservation:service      → reservation-service.jar
├── :reservation:api          → reservation-api.jar
├── :reservation:repository-jdbc → reservation-repository-jdbc.jar
├── :payroll:domain           → payroll-domain.jar
├── :payroll:service          → payroll-service.jar
├── ...
└── :app                      ← composition root → Fat JAR
    └── BOOT-INF/lib/
        ├── reservation-domain.jar
        ├── reservation-service.jar
        ├── reservation-api.jar
        ├── reservation-repository-jdbc.jar
        └── ...

:reservation:apibuild.gradle.kts:reservation:repository-jdbc 의존이 없으면 컴파일이 실패하는 방식으로, 계층 간 잘못된 참조도 빌드 단계에서 차단됩니다.

 

다만 도메인이 5개, 계층이 5개면 서브프로젝트가 25개가 됩니다. settings.gradle.kts 관리, 컨벤션 플러그인, 도메인 간 공유 타입 처리까지 빌드 시스템 자체의 복잡도가 상당히 올라갑니다. 헥사고날 계층 구조가 완전히 안정됐고 팀이 빌드 복잡도를 감당할 여유가 있을 때 선택하는 패턴입니다. 그렇지 않다면 앞의 "중규모 팀" 패턴으로 시작하고 필요해질 때 점진적으로 분리하는 것이 현실적입니다.

 

라이브러리 / SDK 개발

Gradle 멀티 프로젝트 + JPMS

공개 API 패키지만 exports하고, 내부 구현을 JVM 수준에서 은닉합니다. 리플렉션에 의존하는 프레임워크 없이 순수 라이브러리를 배포할 때 적합합니다.

 


정리

  • JPMS는 JVM이 직접 강제하는 가장 강력한 캡슐화를 제공하지만, Spring Boot 환경에서는 리플렉션 제한으로 현실적인 채택이 어렵습니다. 라이브러리 개발이나 jlink 기반 경량 런타임 구성에 적합합니다.
  • Gradle 멀티 프로젝트는 빌드 단위 분리와 병렬 빌드 최적화에 강점을 가집니다. api/implementation 구성으로 의존성 전이를 제어하고, 컨벤션 플러그인으로 빌드 설정 중복을 제거합니다. 서브프로젝트는 각각 독립 JAR로 빌드되어 최종 Fat JAR 안에 조립됩니다. 다만 단일 서브프로젝트 내부의 패키지 경계는 관여하지 않습니다.
  • Spring Modulith는 단일 Spring Boot 애플리케이션 안에서 도메인 경계를 코드와 테스트로 관리합니다. Event Publication Registry로 이벤트 유실을 방지하고, 아키텍처 문서를 자동 생성하는 기능까지 갖추고 있습니다. 마이크로서비스로의 점진적 분리를 고려하는 모듈러 모놀리스 구조에 잘 맞습니다.