프로젝트를 진행하면서 단일 프로젝트 구조에 익숙했던 나에게 처음으로 도입한 Gradle Multi-Module 구조는 많은 시행착오를 안겨줬다. 공식 문서나 기업들의 Multi-Module 도입 영상도 찾아보면서 구조를 분리해봤지만 설정을 추가할 때마다 문제가 생겨서 구조에 문제가 있는 것을 깨닫고 수차례 재정비해야 했다.
이 글은 그 과정을 되짚으며, 내가 겪은 문제와 그 해결 과정, 특히 Gradle이 권장하는 구조로의 전환을 중심으로 정리한 것이다.
credit(root)
├── settings.gradle -> root.projectName과 하위 모듈을 정의하는 include 존재
├── build.gradle
├── credit-api/
│ └── build.gradle
│ └── settings.gradle -> root.projectName만 존재
├── credit-common/
│ └── build.gradle
│ └── settings.gradle -> root.projectName만 존재
├── credit-core/
│ └── build.gradle
│ └── settings.gradle -> root.projectName만 존재
└── credit-external-api/
└── build.gradle
└── settings.gradle -> root.projectName만 존재
모듈을 나눈 건 좋아 보였지만 실제로 프로젝트를 운영하면서 몇 가지 구조적 한계에 부딪혔다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
}
allprojects {
group = 'com'
version = '0.0.1-SNAPSHOT'
}
subprojects {
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
repositories {
mavenCentral()
}
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
tasks.named('test') {
useJUnitPlatform()
}
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
Gradle 공식 문서에서도 언급되듯 cross-project configuration 방식은 configuration-on-demand 기능을 방해하고, 각 모듈의 설정을 외부에서 암묵적으로 주입하는 방식이라 유지보수에 불리하다.
왜 문제가 될까?
- Configuration-Time coupling 유발 : 프로젝트 간에 구성 시간 결합을 유발하여 configuration-on-demand와 같은 최적화 기능이 제대로 작동하지 못하게 할 수 있다.
configuration-on-demand는 빌드 시간을 줄이기 위해 요청된 Task에 관련된 프로젝트만 구성하려고 시도하는데allProjects{}나 subProjects{}와 같은 구성을 통한 주입은 프로젝트 간의 결합을 만들어서 최적화를 방해할 수 있다.- 숨겨진 빌드 로직 : 빌드 로직이 하위 모듈 프로젝트의 빌드 스크립트에서 명확하게 드러나지 않고 상위 수준에서 주입되기 때문에, 해당 프로젝트의 빌드 스크립트만으로는 어떤 로직이 적용되는지 파악하기 어려울 수 있다.
해결책
- Convention Plugins 사용 : Convention Plugins는 프로젝트 루트에 있는 특별한
buildSrc 디렉토리내에 위치하며, 공통 빌드 로직(Plugin 적용, 공통 의존성, Task 설정 등)을 중앙 집중화하고 재사용성을 높일 수 있다.
예를 들어, 특정 유형의 하위 프로젝트에 플러그인이나 기타 구성을 적용하는 로직은 해당 유형의 하위 프로젝트에 직접 Convention Plugin을 적용하는 것으로 대체할 수 있다.
루트 디렉토리의 build.gradle에 plugin의 의존성 버전이 직접 하드코딩 될 경우, 여러 하위 프로젝트에서 동일한 의존성을 사용할 때 버전이 중복 선언될 수 있다.
왜 문제가 될까?
버전이 중복이 되면 나중에 버전을 업그레이드하거나 변경할 때 모든 파일을 일일이 수정해야 해서 번거롭고 실수를 유발할 수 있습니다. 또 프로젝트 간의 의존성 충돌 문제가 발생할 수 도 있다.
해결책
의존성 버전 관리의 비효율성 문제를 해결하기 위해settings.gradle 파일에서pluginManagement{} 블록을 이용하여 Gradle 빌드에서 사용될 플러그인들의 버전 관리를 한 곳에서 할 수 있다.
초기 Sub-Module
credit-api/build.gradle
(다른 하위 모듈들 아래 모듈 gradle 설정이 비슷하기에 1개만 대표로 올립니다.)
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
}
dependencies {
implementation project(':credit-core')
implementation project(':credit-external-api')
implementation project(':credit-common')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'com.h2database:h2'
}
tasks.named('test') {
useJUnitPlatform()
}
위에서 언급된 문제점들을 해결하고, Gradle이 권장하는 중앙 집중화와 Convention Plugins 방식을 도입하여 빌드 구조를 개선했습니다.
credit(root)
├── settings.gradle
├── buildSrc
│ └── src
│ └── main
│ └── groovy
│ └── java-common-convention.gradle
│ └── spring-boot-convention.gradle
│ └── build.gradle
├── credit-api/
│ └── build.gradle
├── credit-common/
│ └── build.gradle
├── credit-core/
│ └── build.gradle
└── credit-external-api/
└── build.gradle
모든 하위 모듈에서 settings.gradle 제거공통 로직은 buildSrc로 convention plugin으로 재구성모듈별로 필요한 설정만 명시적으로 선언하도록 변경
credit/settings.gradle
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
plugins {
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
}
}
rootProject.name = 'credit'
include 'credit-api'
include 'credit-core'
include 'credit-external-api'
include 'credit-common'
이제 모듈을 추가하거나 제거할 때 이 root settings.gradle 파일만 수정하면 되니깐 유지 보수성 측면에서 많이 편해졌다.
초기 구조에서는 모든 공통 설정을 루트 build.gradle에서 처리하다 보니 모듈별로 불필요한 설정까지 강제로 적용되고 있었다. 이를 개선하기 위해 Gradle에서 권장하는 buildSrc 디렉토리를 만들어 Custom Gradle Plugin을 정의했다.
buildSrc란?
Gradle에서 buildSrc는 자동으로 빌드에 포함되는 특별한 디렉토리 입니다.
여기에 정의한 플로그인은 프로젝트 내 어떤 모듈에서도 바로 사용할 수 있습니다.
buildSrc/build.gradle
plugins {
id 'groovy-gradle-plugin'
}
repositories {
gradlePluginPortal()
mavenCentral()
}
buildSrc/.../java-common-coventions.gradle (New)
plugins {
id 'java'
}
group = 'com'
version = '0.0.1-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2'
compileOnly 'org.projectlombok:lombok:1.18.30'
annotationProcessor 'org.projectlombok:lombok:1.18.30'
}
tasks.named('test') {
useJUnitPlatform()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
이제 각 모듈에서는 다음과 같이 id 'java-common-coventions' 한 줄만 선언하면 java 공통 설정을 바로 사용할 수 있습니다.
credit-api/build.gradle
plugins {
id 'java-common-conventions'
id 'org.springframework.boot'
id 'io.spring.dependency-management'
}
dependencies {
implementation project(':credit-core')
implementation project(':credit-external-api')
implementation project(':credit-common')
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'com.h2database:h2'
}
(credit-core/build.gradle 및 credit-external-api/build.gradle, credit-common/build.gradle도 위와 유사하게 개선)
이전 개선된 구조에서 java-common-conventions.gradle을 통해 java 관련 공통 설정을 중앙에서 관리했다. 하지만 여전히 org.springframework.boot 및 io.spring.dependency-management플러그인 적용과 특정 Spring Boot starter 의존성(spring-boot-starter-web, spring-boot-starter-validation, test 등)은 각 모듈의 build.gradle 파일에 직접 명시되어 있었다.
나는 이게 마음에 들지 않아서, 모든 Spring Boot 관련 공통 관심사도 Java 처럼 별도의 Convention Plugin으로 통합함으로써 각 모듈의 build.gradle 파일을 간결하게 만들고싶었다.
buildSrc/.../spring-boot-coventions.gradle (New)
plugins {
id 'java-common-conventions'
id 'org.springframework.boot'
id 'io.spring.dependency-management'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
buildSrc/build.gradle
...
dependencies { -> 새롭게 추가된 부분
implementation 'org.springframework.boot:spring-boot-gradle-plugin:3.2.5'
implementation 'io.spring.dependency-management:io.spring.dependency-management.gradle.plugin:1.1.4'
}
credit-api/build.gradle
plugins {
id 'spring-boot-conventions'
}
dependencies {
implementation project(':credit-core')
implementation project(':credit-external-api')
implementation project(':credit-common')
testImplementation 'com.h2database:h2'
}
(credit-core/build.gradle 및 credit-external-api/build.gradle, credit-common/build.gradle도 위와 유사하게 개선)
spring boot 관련 Convention Plugin을 만들고 적용하려 했을 때 아래 오류가 발생했다.
buildSrc/build.gradle (오류 발생 당시)
plugins {
id 'groovy-gradle-plugin'
}
repositories {
gradlePluginPortal()
mavenCentral()
}

왜 UnknownPluginException이 발생했을까?
이 오류의 핵심은 "Plugin with id 'org.springframework.boot' not found" 라는 메시지에 있었다. 이는 buildSrc가 spring-boot-conventions.gradle을 Convention Plugin으로 컴파일하여 만들려고 할 때 발생한 문제였다.
공식문서를 찾아보며 더 깊이 파고들면 그 이유를 찾을 수 있었다.
Gradle은 buildSrc 디렉토리를 발견하면, 이를 독립적인 Gradle 서브 프로젝트처럼 취급하고 메인 빌드 보다 먼저 빌드 한다.
spring-boot-conventions.gradle 파일은 그 자체로 Groovy 코드 스크립트이기도 하면서 buildSrc가 컴파일하여 생성할 커스텀 플러그인의 정의이다. 이 스크립트 내부에 plugins {id 'org.springframework.boot`}와 같이 다른 플러그인을 참조하는 구문이 있다.
문제는 buildSrc가 이 spring-boot-conventions.gradle을 컴파일할 때 발생했다. spring-boot-conventions.gradle 내부에서 참조하는 org.springframework.boot 플러그인 (org.springframework.boot:spring-boot-gradle-plugin JAR 파일 내부의 클래스 및 메타데이터)이 buildSrc 프로젝트의 컴파일 타임 클래스패스에 존재하지 않았던 것이다.
오류 발생 당시의 buildSrc/build.gradle 파일에는 groovy-gradle-plugin만 적용되어 있었고, org.springframework.boot 플러그인 자체에 대한 의존성 선언이 누락되어 있었다. 즉, buildSrc는 자신이 빌드해야 할 spring-boot-conventions가 참조하는 org.springframework.boot 플러그인이 무엇인지, 그리고 어디에서 그 정의를 찾을 수 있는지 전혀 알 방법이 없었던 것이다. 이로 인해 UnknownPluginException이 발생했던 것입니다.
결론적으로, buildSrc/build.gradle에 org.springframework.boot:spring-boot-gradle-plugin과 io.spring.dependency-management:io.spring.dependency-management.gradle.plugin JAR 파일을 implementation 의존성으로 추가함으로써 문제가 해결되었다.
buildSrc/build.gradle
...
dependencies {
implementation 'org.springframework.boot:spring-boot-gradle-plugin:3.2.5'
implementation 'io.spring.dependency-management:io.spring.dependency-management.gradle.plugin:1.1.4'
}

이로써,성공적으로 빌드가 완료되었다.
buildSrc의 build.gradle 파일에 Spring Boot Gradle Plugin과 Spring Dependency Management Gradle Plugin을 implementation 의존성으로 추가했다.
Gradle은 메인 프로젝트 빌드를 시작하기 전에 buildSrc 디렉토리를 독립적인 프로젝트로 먼저 빌드한다. 이 과정에서 implementation으로 선언된 두 플러그인의 JAR 파일들이 buildSrc 프로젝트의 컴파일 타임 및 런타임 클래스패스에 포함된다.
이렇게 빌드된 buildSrc의 결과물(커스텀 컨벤션 플러그인들)은 메인 빌드의 클래스패스에 자동으로 추가된다.
결과적으로, 메인 프로젝트의 하위 모듈(credit-api 등)에서 plugins { id 'spring-boot-conventions' }와 같이 spring-boot-conventions 플러그인을 적용할 때, 이 플러그인 내에서 참조하는 org.springframework.boot 플러그인에 대한 정의가 이미 buildSrc를 통해 메인 빌드의 클래스패스에 로드되어 있으므로 Gradle은 해당 플러그인을 성공적으로 찾아 적용할 수 있게 된 것이다.
많은 도움이 되셨으면 좋겠습니다!