🍃SpringBoot

Spring Boot: 멀티 모듈

limdaeil 2025. 10. 22. 16:32

🔖Contents

질문의 배경

멀티 모듈은 단어의 의미 그대로 여러 개의 모듈로 구성된 구조입니다. 구체적으로, 각 모듈을 여러 개의 작은 독립적인 모듈로 나누어 관리하는 소프트웨어 구조를 의미합니다. 그러나 이 사실만으로는 "모듈보다는 패키지로 관리하는 것이 더 쉽고 간편하다는 생각"이 들 수 있습니다. 그래서 멀티 모듈의 이해와 활용 방법을 공부하기 위해 작성된 내용입니다.

1. 멀티 모듈 장점

멀티 모듈은 각 모듈을 여러 개의 작은 독립적인 모듈로 나누어 관리하는 소프트웨어 구조 입니다.
멀티 모듈 사용의 주요 이유는다음과 같습니다.

 

1. 코드 재사용성 향상

여러 프로젝트에서 공통으로 사용하는 코드(예: 엔티티, 유틸리티 함수 등)를 별도의 모듈로 만들어 코드 중복을 제거하고 효율적으로 재사용할 수 있습니다.

 

2. 명확한 모듈 분리

각 모듈이 특정 기능이나 책임을 맡아 독립적으로 관리되므로, 코드의 구조가 명확해집니다.
이는 모듈 간의 의존성을 줄여 유지보수성과 확장성을 높여줍니다.

 

3. 유지보수성 및 관리 용이성

기능별로 코드가 분리되어 있어, 특정 기능을 수정하거나 개선할 때 해당 모듈만 집중적으로 작업할 수 있습니다.

 

4. 빌드 시간 최적화

프로젝트 전체를 한 번에 빌드하는 대신, 변경된 모듈만 빌드하여 빌드 시간을 단축할 수 있습니다.

 

5. 코드의 복잡성 감소

모듈화는 큰 문제를 작은 단위로 나누는 것과 같습니다.
이렇게 구성하면 전체 시스템의 복잡성이 줄어들어 설계와 유지보수가 더 쉬워집니다.

 

7. 구조적 설계 강화

도메인 주도 설계(DDD)와 같은 설계 원칙을 적용하여, 개발자와 도메인 전문가를 기준으로 모듈을 나눌 수 있습니다.
이는 코드의 설계 의도를 명확히 보여주는 효과가 있습니다.

2. 멀티 모듈 단점

멀티 모듈은 많은 장점을 갖고 있지만, 다음과 같이 치명적인 단점도 존재합니다.

 

1. 구조 복잡성 증가

도메인에 대한 이해 없이 모듈을 과도하게 나누면 구조가 너무 복잡해져 관리가 어려워집니다.

 

2.빌드 및 배포의 번거로움

사소한 코드 변경이라도 해당 모듈뿐만 아니라 의존하는 다른 모듈까지 다시 빌드하고 배포해야 하는 경우가 발생할 수 있습니다.

 

3. 의존성 관리의 어려움

여러 모듈이 서로 의존하게 되면 특정 라이브러리의 버전 업그레이드가 전체 프로젝트에 영향을 미칠 수 있습니다.

 

4. 초기 설정 및 세팅의 복잡성

프로젝트 초기 단계에서 모듈 구조를 잘못 잡으면 개발 생산성이 저하될 수 있습니다.
특히 도메인 지식이 부족한 상태에서 시작하면 불편함이 따릅니다.

 

5. 모듈 책임의 불분명성

모듈을 명확하게 분리하지 않으면
단일 책임 원칙(SRP)을 위반하거나 코드 복사, 분기 처리 등으로 인해 코드 품질이 저하될 수 있습니다.

 

7. 레거시화 가능성

공통으로 사용되는 커먼 모듈의 변경이 어려워지면 데드 코드(Dead Code)가 쌓여 레거시가 될 가능성이 있습니다.

 

3. 멀티 모듈 적용하는 시점

멀티 모듈은 프로젝트의 규모가 커지거나 복잡도가 높아지는 시점, 그리고 여러 팀이 병렬로 협업해야 하는 상황에서 특히 효과적입니다. 모듈을 기능 단위로 분리하면 재사용성과 유지보수성이 자연스럽게 높아지고, 의존성의 방향을 명확히 통제할 수 있습니다. 모듈별로 독립 빌드와 테스트가 가능해 개발 사이클을 단축할 수 있고, 필요에 따라 모듈마다 다른 기술 스택을 선택해 유연하게 확장할 수도 있습니다.

 

저는 멀티 모듈을 공부하면서 어느 정도 이상의 규모에서 적합한 소프트웨어 구조라는 인상을 받았습니다. 멀티 모듈의 장점을 극대화하고 단점을 줄이기 위해서는 DDD(Domain-Driven Design)나 헥사고날 아키텍처 같은 방법론을 함께 적용하는 것이 유의미한데, 이는 모듈이 담당하는 도메인 경계를 선명하게 하고 교체 가능성과 테스트 용이성을 높여줍니다. 다만 이러한 방법론은 대체로 많은 트래픽과 복잡한 규칙을 가진 시스템에서 효과가 극대화되며, 소규모 초기 단계에서는 투자 대비 이득이 제한적일 수 있습니다.

 

멀티 모듈과 DDD 둘을 결합한 구조로 개발한다고 가정합니다. 각 모듈이 특정 도메인 또는 하위 도메인에 해당하는 설계 원칙을 따르도록 만들어, 복잡한 시스템을 더 체계적이고 유연하게 관리할 수 있습니다. 예를 들어, '도메인' 모듈은 도메인 로직을, 'DB' 모듈은 데이터베이스 관련 설정을 분리하는 식입니다. 다음과 같이 멀티 모듈과 DDD를 결합하는 방법을 고민할 수 있습니다.

 

도메인 중심의 모듈 분리

시스템의 각 도메인이나 하위 도메인을 별도의 모듈로 구성합니다.
예를 들어, '상품 도메인' 모듈, '주문 도메인' 모듈 등으로 나눌 수 있습니다.

 

명확한 모듈 간 의존성

각 모듈은 자신이 담당하는 도메인에 필요한 기술만을 사용하도록 의존성을 제한합니다.
예를 들어, 'API' 모듈은 '도메인' 모듈을 사용하지만, '도메인' 모듈은 'API' 모듈을 직접 사용하지 않도록 설정하여 관심사를 분리합니다.

 

기술을 위한 모듈 분리

데이터베이스나 로깅, 외부 연동 등 기술적인 부분을 별도의 모듈로 분리하여 애플리케이션 코어의 복잡성을 줄입니다.예를 들어, 'DB 모듈'은 데이터베이스 연결 설정, 엔티티 클래스 등을 포함하고, 'API 모듈'은 'DB 모듈'의 구현체를 활용하되, 기술적인 설정은 'API 모듈'에 직접 포함시키지 않습니다.

 

재사용성과 유연성 극대화

각 모듈이 독립적인 유닛처럼 작동하게 하여 코드의 재사용성을 높이고,
필요에 따라 특정 모듈을 교체하거나 확장하기 용이하게 만듭니다.

그러나 멀티 모듈 + DDD의 성공적인 적용을 위해 반드시 다음을 생각해야 합니다.

  • 초기 설계 중요성: 멀티 모듈 구조는 초기 설계가 중요하며, 잘못된 설계는 변경이 어렵고 빌드 및 배포에 큰 영향을 줄 수 있습니다.
  • 모듈 나눔의 기준: 시스템 특성에 맞게 모듈을 나누는 기준을 명확해야 하며, 정답은 없으므로 다양한 관점을 고려해야 합니다.

4. 멀티 모듈 설정하기

Spring Boot 프로젝트를 초기화한 상태에서 src 디렉토리를 삭제하고 순서대로 진행하면 됩니다.

 

1. 루트 프로젝트 하위 경로에 폴더 생성

루트 디렉토리 하위에 디렉토리를 생성합니다.

루트 디렉토리 하위에 디렉토리 생성하기

 

2. settings.gradle 안에 include 작성

settings.gradle 안에 include 명령과 함께 생성한 디렉토리명을 작성하고, Gradle 동기화 버튼을 클릭합니다.

 

settings.gradle에 디렉터리 이름을 추가하면 Gradle은 “프로젝트 트리의 존재”만 알게 됩니다. 그래서 빈 디렉토리 아이콘에서 모듈 아이콘으로 변경만 된 상태인 것 입니다. 해당 디렉토리가 Gradle 프로젝트로서 어떤 성격(자바/스프링/라이브러리 등)을 갖는지build.gradle(또는 루트 build.gradlesubprojects 블록)을 통해 플러그인과 설정이 실제로 적용되어야 정해집니다.

 

초기에는 각 하위 디렉터리가 빈 폴더였고, Gradle 입장에서는 “아직 아무 플러그인도 붙지 않은 일반 폴더”였기 때문에, IDE(IntelliJ)도 해당 디렉터리를 자바 소스 루트로 인식하지 못해 “Java 파일 생성” 같은 동작을 막았던 겁니다.

루트 디렉토리 하위에 settings.gradle 안에 include 작성

 

3. build.gradle 수정

루트 디렉토리 안의 build.gradle으로 빌드 프로세스를 통합 관리할 수 있습니다.

subprojectsjava, org.springframework.boot, io.spring.dependency-management 플러그인을 적용하고, 테스트 플러그인과 의존성을 선언해주자마자 상황이 바뀝니다. Gradle은 이제 각 하위 디렉터리를 자바 프로젝트로 인식하고, 표준 소스셋(src/main/java, src/test/java)을 갖는다고 간주합니다. 그 결과 IDE가 해당 디렉터리를 모듈(진짜 모듈)로 임포트하고 소스 루트를 지정해 주기 때문에, IDE에서 자연스럽게 클래스/패키지 생성이 가능해진 것입니다.

그리고 subprojects에 스프링 부트 플러그인을 일괄 적용하면 모든 하위 모듈이 실행 애플리케이션(bootJar) 성격을 띠게 되어 무거워질 수 있습니다. 일반적으로는 공통 모듈(예: commons)은 라이브러리로, 실행 모듈(예: product 또는 app)만 부트 애플리케이션으로 나눠 설정하시는 편이 좋습니다. 즉, 루트에서는 공통 속성만 두고, 다음처럼 모듈별로 성격을 분리합니다.

요약하면, settings.gradle은 “누가 프로젝트인지”만 말해주고, build.gradle은 “그 프로젝트가 무엇을 할 수 있는지”를 말해주는 역할입니다. 더 세밀한 빌드 프로세스 설정을 위해서는 Gradle 문법과 동작 방식에 대한 이해가 필요해요. 지금은 성공적인 실행을 목표로 Gradle 에 대한 설명은 제외했습니다.

 

Gradle 공식 홈페이지로 이동: https://docs.gradle.org/current/userguide/build_lifecycle.html

 

// root: build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.6'
    id 'io.spring.dependency-management' version '1.1.7'
}

allprojects {
    group = 'com.food'
    version = '0.0.1-SNAPSHOT'
    description = 'shopping'

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply plugin: 'java'
    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'

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

    tasks.named('test') {
        useJUnitPlatform()
    }
}
dependencies {

}
// order: build.gradle
dependencies {
    implementation project(':commerce-commons')

    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

4. Gradle 소스 세트 생성

생성된 모듈 안에 Gradle 소스 세트 4개를 모두 생성합니다.

모두 생성하기

 

5. 스프링 부트 클래스 생성 및 실행

src/main/java안에 그룹명.도메인명으로 패키지 경로를 생성합니다. 스프링 부트 프로젝트 초기화 구성과 동일하게 "모듈명=도메인명=도메인명Application.java" 로 생성하면 됩니다. 예를 들어, 다음처럼 작성하면 스프링 부트에서 자동 초기화하는 구조와 동일합니다.

  • 모듈명: Member
  • 패키지 경로: com.food.member
  • 메인 클래스명: MemberApplication

패키지 생성 후, 부트 메인 클래스 생성

 

resources 안에 application.yml 파일을 작성합니다.(생략하는 경우 포트 기본 값은 8080 입니다.)

resources/application.yml

 

 

웹 브라우저를 통해 해당 모듈의 정상적인 실행을 확인하면 멀티 모듈 생성을 모두 마치게 됩니다.

 

생성된 세 개의 멀티 모듈

 

commons 모듈은 각 모듈의 공통 설정을 담당하는 모듈이에요. 그래서 지금처럼 부트 클래스를 생성하고 실행하지 않습니다.

웹 브라우저로 이동 결과

 

💡모듈명, 패키지 경로, 메인 클래스명이 스프링 부트의 자동 구성과 다르게 작성

저는 모듈명 shopping-order, 패키지 경로 마지막은 order, 메인 클래스명은 OrderApplication.java로 스프링 부트에서 자동 생성하는 구성과 약간 다릅니다. 만약 인텔리제이 혹은 스프링 부트 스타터 홈페이지에서 생성했다면 아래와 같이 자동 생성됩니다. (패키지명에 -을 포함할 수 없습니다. Spring Initializr 에러에요!)

 

Spring Initializr
IntelliJ  IDEA

  • 모듈명: shopping-order
  • 패키지 경로: com.food.shoppingorder
  • 메인 클래스명: ShoppingOrderApplication

저처럼 스프링 부트의 자동 생성 방식과 다르게 구성한다면, 내부 동작이나 예상하지 못한 상황에서 에러가 발생하게 될까요?

결론부터 말씀드리면, 모듈명(예: shopping-order)과 부트 메인 클래스명(예: OrderApplication)이 달라도 전혀 문제 없습니다. 스프링 부트의 실행·스캔 메커니즘은 “모듈(프로젝트) 이름”이나 “클래스 파일명”과는 무관하게 동작합니다. 런타임에서 중요한 것은 메인 클래스의 FQCN(fully qualified class name)패키지 경계입니다.

다만 아래와 같은 동작 원리와 예외 상황을 이해해두시면 안전합니다.

  • 부트가 찾는 것은 “Start-Class” 입니다. Gradle 부트 플러그인이 빌드 시 @SpringBootApplication이 붙은 클래스를 찾아 JAR의 Start-Class(메인 클래스)로 기록합니다. 클래스명이 모듈명과 달라도, 패키지명이 어떻게 생겼어도, 해당 클래스를 정확히 지정해 두기만 하면 실행에 지장 없습니다. 만약 한 모듈에 메인 클래스가 2개 이상이라면 build.gradlespringBoot { mainClass = 'com.food.order.OrderApplication' } 같이 명시해 주시면 충돌을 피할 수 있습니다.
  • 컴포넌트 스캔의 기준은 “메인 클래스의 패키지” 입니다. @SpringBootApplication 은 기본적으로 자기 패키지와 하위 패키지만 스캔합니다. 지금 OrderApplicationcom.food.order에 있다면, 기본 스캔 범위는 com.food.order.** 입니다.
    따라서 빈, 설정, 컨트롤러, 서비스, 리포지토리, 엔티티 등이 com.food.order의 하위에 있다면 자동으로 인식됩니다. 반대로 com.food.common 처럼 형제/상위 패키지에 있는 컴포넌트는 기본 스캔에 잡히지 않습니다. 이런 구조가 필요하면
    • @SpringBootApplication(scanBasePackages = {"com.food.order", "com.food.common"}) 로 스캔 범위를 올려주거나,
    • @ComponentScan, @EntityScan, @EnableJpaRepositories 에서 각각의 basePackages를 지정하거나,
    • 공통 모듈은 애초에 빈을 만들지 않는 순수 라이브러리로 유지하는 편이 좋습니다.
      일반적으로 shopping-commons에는 빈이 아닌 DTO/유틸/베이스 타입만 두는 이유가 여기에 있습니다.
  • 애플리케이션 이름과도 무관 합니다. 운영에서 서비스 식별 등에 쓰는 이름은 보통 application.ymlspring.application.name 으로 정합니다. 이는 모듈명/클래스명과 별개입니다.
  • JAR 파일명과도 무관 합니다. 산출물 이름은 Gradle의 프로젝트명과 버전에 의해 정해집니다(shopping-order-0.0.1-SNAPSHOT.jar 등). 메인 클래스명은 JAR 내부 MANIFEST.MFStart-Class 로 연결됩니다.
  • 테스트에서도 동일한 규칙 이 적용됩니다. @SpringBootTest는 기본적으로 현재 테스트 패키지에서 가장 가까운 @SpringBootConfiguration(= @SpringBootApplication)을 찾아 컨텍스트를 올립니다. 테스트 패키지가 com.food.order.* 하위에 있으면 자연스럽게 OrderApplication이 기준이 됩니다. 만약 스캔 경계가 달라서 빈을 못 찾는다면 @SpringBootTest(classes = OrderApplication.class)@ContextConfiguration으로 기준을 명시하면 됩니다.

정리하면, 모듈명 ↔ 메인 클래스명 불일치는 아무 문제 없고, 실제로 신경 써야 하는 것은 메인 클래스의 패키지 위치가 스캔 루트로서 적절한가 입니다. 공통 모듈에 스프링 빈을 두지 않는 현재의 방향을 유지하시면 패키지 경계 문제를 대부분 피할 수 있습니다. 만약 공통 모듈에 어쩔 수 없이 설정/빈을 둬야 한다면, 위에서 말씀드린 scanBasePackages·@EntityScan·@EnableJpaRepositories 등으로 스캔 경계만 명시적으로 넓혀주시면 됩니다.

 

 

🔥버전 관리와 문서화

좀 더 나아가서, 멀티 모듈을 통해 각 라이브러리의 버전을 한 곳에서 관리할 수 있고, Spring Docs 혹은 Swagger 같은 문서 자동화 설정도 가능합니다. 그리고 Gradle 문법과 동작 방식에 대한 이해가 충분히 있다면, 도메인 간의 의존성을 더 효율적으로 관리하고 가벼운 라이브러리를 사용할 수 있습니다. 

라이브러리 버전 관리