Gradle 멀티모듈에서 반복되는 build.gradle.kts 줄이는 방법

궁금하면 500원·2025년 5월 24일

Java

목록 보기
12/15

Gradle 멀티모듈에서 buildSrc와 Convention Plugin으로 빌드 설정 공통화하기

멀티모듈 프로젝트가 커지기 시작하면 애플리케이션 코드뿐 아니라 Gradle 빌드 스크립트에서도 중복이 생긴다.

예를 들어 다음과 같은 설정이 여러 모듈에서 반복될 수 있다.

plugins {
    java
}

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

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
}

tasks.test {
    useJUnitPlatform()
}

프로젝트가 작을 때는 큰 문제가 없어 보인다.

하지만 모듈이 다음처럼 늘어난다고 생각해보자.

root
├─ app
├─ domain
├─ infrastructure
├─ batch
├─ fhir
└─ common

각 모듈마다 다음 설정을 반복해서 작성하기 시작한다.

  • Java 버전
  • Java Toolchain
  • 테스트 프레임워크
  • Compiler 옵션
  • 공통 플러그인
  • 코드 품질 도구
  • 테스트 설정

이렇게 되면 build.gradle.kts 자체가 또 하나의 관리 대상이 된다.

예를 들어 Java 버전을 21에서 25로 변경한다고 했을 때 여러 모듈의 Gradle 파일을 전부 수정해야 한다면 관리하기 좋은 구조라고 보기 어렵다.

이 문제를 해결하기 위해 Gradle에서는 빌드 로직을 별도로 분리하고 재사용하는 방식을 사용할 수 있다.

대표적인 방법이 buildSrcConvention Plugin이다.


1. buildSrc란?

Gradle 프로젝트 루트에 buildSrc라는 특별한 디렉터리를 만들면 Gradle이 이 디렉터리를 자동으로 빌드한다.

root
├─ app
├─ domain
├─ infrastructure
├─ build.gradle.kts
├─ settings.gradle.kts
└─ buildSrc
    ├─ build.gradle.kts
    └─ src
        └─ main
            └─ kotlin

buildSrc 내부에는 Gradle 빌드에서 사용할 수 있는 공통 코드나 플러그인을 작성할 수 있다.

예를 들어 여러 모듈에서 사용하는 Java 설정을 하나의 Convention Plugin으로 만들 수 있다.

buildSrc
└─ src
   └─ main
      └─ kotlin
         └─ java-conventions.gradle.kts

java-conventions.gradle.kts

plugins {
    java
}

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

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

tasks.test {
    useJUnitPlatform()
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
}

그러면 각각의 모듈에서는 복잡한 설정을 반복해서 작성할 필요가 없다.

plugins {
    id("java-conventions")
}

기존에는 각 모듈마다 Java 버전이나 테스트 설정을 작성했다면, 이제는 하나의 플러그인을 적용하는 것으로 끝난다.


2. Convention Plugin이란?

Convention Plugin에서 Convention은 말 그대로 프로젝트 내부의 규칙 또는 관례라는 의미다.

예를 들어 프로젝트에서 다음과 같은 규칙을 정했다고 해보자.

Java 모듈은 Java 21을 사용한다.

모든 테스트는 JUnit 5를 사용한다.

Java 파일 인코딩은 UTF-8을 사용한다.

Spring Boot 애플리케이션 모듈에는
Spring Boot 관련 설정을 적용한다.

이 규칙을 각 build.gradle.kts에 복사하는 대신 하나의 Gradle Plugin으로 만드는 것이다.

구조를 단순하게 표현하면 다음과 같다.

멀티모듈 프로젝트

app
domain
infrastructure
batch
fhir
       │
       │ Convention Plugin 적용
       ▼
build-logic
       │
       ├─ Java 21
       ├─ JUnit 5
       ├─ Compiler 설정
       ├─ 테스트 설정
       └─ 공통 플러그인

즉 Convention Plugin의 목적은 단순히 코드 몇 줄을 줄이는 것이 아니다.

핵심은

프로젝트 전체에서 사용해야 하는 빌드 규칙을 하나의 정책으로 관리하는 것

이다.


3. 왜 subprojects {}만 사용하지 않을까?

멀티모듈 Gradle 프로젝트를 처음 구성하면 다음과 같은 코드를 많이 사용한다.

subprojects {

    apply(plugin = "java")

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

루트 프로젝트에서 모든 하위 프로젝트에 공통 설정을 강제로 적용하는 방식이다.

작은 프로젝트에서는 충분히 사용할 수 있다.

하지만 프로젝트 규모가 커지면 문제가 생길 수 있다.

예를 들어 모듈 종류가 달라질 수 있다.

app
    → Spring Boot Application

domain
    → 순수 Java Library

batch
    → Spring Batch

frontend
    → Node.js

integration-test
    → 테스트 전용 모듈

모든 모듈에 동일한 설정을 강제로 적용하기 어려워진다.

Convention Plugin을 사용하면 역할별로 규칙을 나눌 수 있다.

build-logic

├─ java-conventions
├─ java-library-conventions
├─ spring-boot-conventions
├─ spring-batch-conventions
└─ integration-test-conventions

그리고 필요한 모듈에서 필요한 규칙만 선택해서 적용한다.

plugins {
    id("spring-boot-conventions")
}

또는

plugins {
    id("java-library-conventions")
}

이렇게 하면 각 모듈의 역할도 훨씬 명확해진다.


4. buildSrc 방식

가장 간단하게 시작할 수 있는 방법은 buildSrc를 사용하는 것이다.

예를 들어 다음 구조를 만들 수 있다.

root
├─ app
│  └─ build.gradle.kts
│
├─ domain
│  └─ build.gradle.kts
│
├─ infrastructure
│  └─ build.gradle.kts
│
├─ buildSrc
│  ├─ build.gradle.kts
│  └─ src
│     └─ main
│        └─ kotlin
│           └─ java-conventions.gradle.kts
│
├─ build.gradle.kts
└─ settings.gradle.kts

buildSrc/build.gradle.kts

plugins {
    `kotlin-dsl`
}

repositories {
    gradlePluginPortal()
    mavenCentral()
}

그리고

buildSrc/src/main/kotlin/java-conventions.gradle.kts

파일을 만든다.

plugins {
    java
}

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

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

tasks.withType<Test>().configureEach {
    useJUnitPlatform()
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
}

이제 각 모듈에서는 다음 정도만 작성하면 된다.

plugins {
    id("java-conventions")
}

결과적으로

app/build.gradle.kts
domain/build.gradle.kts
infrastructure/build.gradle.kts

에 반복되던 Java 관련 설정을 제거할 수 있다.


5. buildSrc의 장점과 한계

buildSrc의 가장 큰 장점은 설정이 간단하다는 것이다.

Gradle이 특별하게 인식하는 디렉터리이기 때문에 별도의 included build 설정 없이 바로 사용할 수 있다.

그래서 작은 프로젝트나 Convention Plugin을 처음 적용하는 프로젝트에서는 이해하기 쉽다.

하지만 프로젝트가 커지면서 빌드 로직도 복잡해지면 별도의 build-logic 프로젝트로 분리하는 방법이 더 적합할 수 있다.


6. build-logic Included Build 방식

빌드 로직을 독립적인 Gradle 빌드로 관리하는 방식이다.

예를 들어 다음과 같은 구조를 만들 수 있다.

root
├─ app
├─ domain
├─ infrastructure
│
├─ build-logic
│  ├─ build.gradle.kts
│  ├─ settings.gradle.kts
│  └─ src
│     └─ main
│        └─ kotlin
│           ├─ java-conventions.gradle.kts
│           └─ spring-conventions.gradle.kts
│
├─ build.gradle.kts
└─ settings.gradle.kts

루트 settings.gradle.kts에서는 빌드 로직을 포함한다.

pluginManagement {
    includeBuild("build-logic")
}

rootProject.name = "example"

include(
    "app",
    "domain",
    "infrastructure"
)

build-logic/build.gradle.kts

plugins {
    `kotlin-dsl`
}

repositories {
    gradlePluginPortal()
    mavenCentral()
}

그리고 Convention Plugin을 작성한다.

plugins {
    java
}

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

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

tasks.withType<Test>().configureEach {
    useJUnitPlatform()
}

사용하는 모듈에서는 그대로 플러그인을 적용한다.

plugins {
    id("java-conventions")
}

7. buildSrcbuild-logic의 차이

둘 다 공통 Gradle 로직을 관리할 수 있지만 구조적인 차이가 있다.

구분buildSrcbuild-logic Included Build
구성 난이도낮음조금 높음
Gradle 자동 인식OincludeBuild 필요
작은 프로젝트적합다소 과할 수 있음
대형 멀티모듈가능더 적합
빌드 로직 분리프로젝트 내부 특수 영역독립적인 Gradle Build
확장성보통높음

따라서 무조건 어느 한쪽이 정답이라고 할 필요는 없다.

작은 멀티모듈 프로젝트라면 buildSrc로 시작해도 충분하다.

반면 모듈과 빌드 규칙이 계속 증가하고 있다면 다음처럼 별도의 빌드 로직 영역을 두는 구조가 관리하기 좋다.

build-logic
├─ java-conventions
├─ spring-conventions
├─ spring-boot-conventions
└─ test-conventions

8. 어떤 설정을 Convention Plugin으로 옮겨야 할까?

무조건 모든 Gradle 설정을 공통화하면 오히려 구조가 복잡해질 수 있다.

Convention Plugin으로 옮기기 좋은 것은 여러 모듈에서 반복되면서 프로젝트 전체 정책으로 볼 수 있는 설정이다.

예를 들면 다음과 같다.

Java 버전

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

Compiler 설정

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

테스트 설정

tasks.withType<Test>().configureEach {
    useJUnitPlatform()
}

코드 품질 플러그인

plugins {
    checkstyle
}

공통 테스트 라이브러리

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter")
}

반대로 특정 모듈에서만 필요한 라이브러리는 해당 모듈에서 관리하는 편이 자연스럽다.

예를 들어 domain 모듈에서만 필요한 라이브러리를 모든 Java 모듈의 Convention Plugin에 넣어버리는 것은 좋은 공통화가 아니다.


9. 공통화 기준은 "반복"보다 "규칙"

공통화할 때 가장 중요한 기준이다.

단순히 코드가 두 번 등장했다고 무조건 Convention Plugin으로 옮기는 것은 아니다.

예를 들어 다음 설정들은 프로젝트의 명확한 규칙이 될 수 있다.

Java는 21을 사용한다.
JUnit 5를 사용한다.
UTF-8을 사용한다.
컴파일 경고 정책을 통일한다.

이런 것은 Convention Plugin에 적합하다.

반면

이 모듈에서만 사용하는 QueryDSL
이 모듈에서만 필요한 AWS SDK
특정 Batch 모듈에서만 사용하는 라이브러리

같은 것은 해당 모듈에 남겨두는 것이 좋다.

결국 판단 기준은

이 설정이 특정 모듈의 구현 세부사항인가?

아니면

프로젝트 전체가 따라야 하는 빌드 규칙인가?

이다.


10. 멀티모듈 구조가 단순해진다

Convention Plugin을 적용하기 전에는 각 모듈이 다음과 같을 수 있다.

plugins {
    java
}

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

tasks.withType<Test>().configureEach {
    useJUnitPlatform()
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter")
}

Convention Plugin을 적용하면 다음처럼 줄일 수 있다.

plugins {
    id("java-conventions")
}

dependencies {
    implementation(project(":domain"))
}

모듈의 build.gradle.kts에는 이제 해당 모듈이 실제로 어떤 의존성을 가지고 있는지와 어떤 역할을 하는지가 더 잘 드러난다.

이게 Convention Plugin을 사용하는 중요한 이유다.

단순히 Gradle 코드 몇 줄을 줄이는 것이 아니다.

빌드 설정의 노이즈를 제거하고 각 모듈의 책임을 더 명확하게 만드는 것이다.


11. 프로젝트에서 확인해볼 것

멀티모듈 프로젝트를 사용하고 있다면 먼저 다음 명령으로 현재 모듈 구조를 확인할 수 있다.

./gradlew projects

Windows에서는 다음과 같이 실행한다.

gradlew.bat projects

사용 가능한 Task도 확인해볼 수 있다.

./gradlew tasks

그다음 각 모듈의 build.gradle.kts를 비교해본다.

예를 들어 다음 설정이 반복되고 있는지 확인한다.

java {}
tasks.test {}
repositories {}
공통 plugins
compilerOptions
JUnit 설정
Spring Boot 설정
코드 품질 플러그인

여러 모듈에서 같은 설정이 반복되고 있고 그것이 프로젝트 공통 정책이라면 Convention Plugin으로 분리할 후보가 된다.


12. 전체 흐름 정리

Gradle 멀티모듈 프로젝트가 커지면 자연스럽게 다음 문제가 발생한다.

멀티모듈 증가
        ↓
build.gradle.kts 증가
        ↓
Java / Test / Compiler 설정 중복
        ↓
설정 변경 비용 증가
        ↓
Convention Plugin 도입
        ↓
공통 빌드 규칙 중앙화
        ↓
각 모듈 build.gradle.kts 단순화

buildSrc와 Convention Plugin의 핵심을 단순하게 정리하면 다음과 같다.

buildSrc
→ Gradle 공통 빌드 코드를 쉽게 관리할 수 있는 특별한 영역

Convention Plugin
→ 프로젝트에서 반복되는 빌드 규칙을 Plugin으로 정의하는 방식

build-logic
→ Convention Plugin을 별도의 Included Build로 분리하는 구조

결국 중요한 것은 buildSrc 자체가 아니다.

멀티모듈 프로젝트가 커질수록

빌드 설정도 애플리케이션 코드처럼 구조화하고 관리해야 한다.

Java 버전, 테스트 정책, Compiler 설정처럼 여러 모듈이 공통으로 따라야 하는 규칙을 Convention Plugin으로 분리하면 중복을 줄이고 프로젝트 전체의 빌드 정책을 일관되게 유지할 수 있다.

그리고 규모가 더 커진다면 buildSrc에 모든 로직을 몰아넣기보다는 별도의 build-logic Included Build로 분리해 빌드 로직 자체도 하나의 독립적인 구조로 관리하는 것이 좋다.

profile
레거시를 이해하면서도 새로운 기술을 현실적으로 적용할 수 있는 백엔드 개발자가 되는 것이 목표입니다.

0개의 댓글