정말 오랜만에 개인 프로젝트를 시작해본다. 그동안 이커머스 회사에서 일하면서 실제로 고객들과 상호작용하는 부분은 아니었지만 나름 짧지 않은 시간동안 백오피스와 플랫폼을 개발하고 유지보수해왔는데 그러면서 해보고 싶었던 것, 아쉬웠던 것이 많았다. 그런 걸 실제 프로젝트에 녹여낼 수 없었던 이유는 많지만(기술적 한계라던가 그냥 그것보다 먼저 해야될 게 많았다던가..) 제일 큰 이유는 검증되지 않은 기술을 다른 사람들과 협업하는 실제 프로젝트에 무작정 적용하긴 부담이 된다는 것이었다.
예를 들어 개발을 시작할 때는 아직 스프링 부트 2.7 버전이 LTS인 시기였지만 이제는 3.4 버전까지 올라왔고 스프링 부트, 시큐리티 등 꽤 메이저한 프로젝트의 버전도 올라갔을 뿐더러 Testcontainers, Docker Compose 등 자주 사용하는 개발 도구에 대한 starter 지원도 추가되서 새로운 게 많아졌다. 그렇지만 오픈하지도 않은 서비스의 프레임워크 버전을 올리는 건 아무리 생각해도 미친 짓이었고 실제로 운영 서비스 중인 옆 팀에서 스프링 부트 3.x 버전으로 올리는데 약 반 년이 걸린 사례를 생각하면 더욱 그랬다. 물론 이 경우는 운영 업무랑 병행했기 때문에 더 시간이 오래 걸리긴 했지만...
어쨌든 그래서 실제 업무와 유사한 도메인의 개인 프로젝트를 하나 진행해보면서 새로운 기술들을 적용해보고자 한다. 그러면서 새롭게 도전해보는 게 있는데 바로 모노레포, 멀티모듈 프로젝트다. 맨 처음 개발을 시작했을 때부터 학교, 부트캠프, 회사에 오기까지 항상 하나의 프로젝트에는 하나의 모듈로 구성된 프로젝트로 개발해왔다. 그런데 마침 저번에 다른 팀에서 신규 프로젝트에 멀티 모듈 구조를 채택해서 성공적으로 오픈한 것도 있고 중형, 대형 프로젝트를 진행할 때는 상호 의존적인 모듈로 구성된 프로젝트 구조가 적합하며 빌드 최적화까지 얻을 수 있다는 것이 눈에 띄어 이번에는 멀티 모듈 프로젝트로 진행해보기로 했다.
It is important to structure your Gradle project to optimize build performance. A multi-project build is the standard in Gradle.
https://docs.gradle.org/current/userguide/multi_project_builds.html
근데 인터넷 예제를 찾아보면 제각각인게 많아서 일단 Gradle 최신 버전 문서에서 제공하는 멀티 모듈 프로젝트 구조를 따라가면서 구성해봤다. 실제로 애플리케이션의 모듈 자체를 어떻게 구분하는지는 같이 프로젝트를 진행하는 사람이랑 의논하면서 진행하고자 한다.
먼저 Gradle 공식 문서의 Multi-Project Builds 에서는 buildSrc 라는 빌드 방식을 소개하고 있다. Multi-Project, 즉 멀티 모듈 프로젝트를 구성하는 방법에는 buildSrc 와 Composite Build 두 가지 방식이 있다고 하는데 둘 다 다른 사람들이 진행한 예제에서는 본 적이 없어서 좀 알아보았다.
먼저 buildSrc는 최상위 프로젝트에 존재하는 디렉토리로 하위 프로젝트들을 빌드할 때마다 이 디렉토리에 있는 파일(buildSrc/src/main/kotlin
하위의 빌드 스크립트)들을 빌드 과정에 포함시키는 역할을 한다. 이 디렉토리 안에서는 커스텀 Gradle Task, 플러그인을 정의하거나 플러그인, 프로젝트 전체에 공유되는 설정값 등을 담을 수 있다.
대부분의 멀티 모듈 구조에서는 여러 하위 프로젝트 간에 의존성이 겹치는 경우가 많기 때문에(Java 버전 등) 이를 모든 하위 프로젝트에 복사, 붙여넣기하는 것보다 한 곳에서 일괄적으로 관리할 수 있도록 Gradle에서 제공하는 것이다. 또한 의존성 뿐 아니라 Custom Task 등 특정 목적으로 작성한 빌드 로직을 여러 프로젝트에서 공유하면서 실제로 프로젝트 내 빌드 파일(build.gradle.kts
)과는 분리시킬 수 있다는 장점이 있다.
그렇지만 모든 하위 프로젝트에서 의존하기 때문에 이 buildSrc 디렉토리 내에 변경사항이 있다면 프로젝트의 모든 Task가 무효화(invalidate)되어 처음부터 다시 실행해야 한다는 단점이 있다.
실제로 IntelliJ IDEA 등으로 멀티모듈 프로젝트를 생성해보면 다음처럼 공통 플러그인이나 Java 버전설정 코드가 담겨있는 것을 볼 수 있다.
// buildSrc/src/main/kotlin/kotlin-development.gradle.kts
package buildsrc.convention
// (중략)
plugins {
kotlin("jvm")
}
kotlin {
jvmToolchain(17)
}
// (후략)
이렇게 정의한 파일은 실제로 이 플러그인을 사용할 하위 프로젝트에서 마치 기존 플러그인처럼 그대로 임포트할 수 있다. 관련 가이드
plugins {
id("kotlin-development")
kotlin("plugin.spring") version "1.9.25"
...
그러나 기대한 것과는 다르게 바로 빌드가 성공하진 않았다. 왜냐면 예제에서는 순수 Java 프로젝트로 설명하고 있지만 우리 프로젝트는 Kotlin 기반 Spring Boot 프레임워크를 사용하고 있기 때문이었다.
// buildSrc/build.gradle.kts
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
////////// 여기까지는 공식 문서에서도 제공하는 예제였지만
////////// 아래 의존성은 https://stackoverflow.com/a/73588962/10242688 에서 참고하였다.
dependencies {
implementation("org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:2.0.21")
}
예를 들어 JVM 환경에서 사용할 Kotlin의 버전을 지정하는 kotlin("jvm")
플러그인은 JetBrains에서 개발한 확장 플러그인이기 때문에 이를 사용하기 위한 의존성을 buildSrc의 빌드 파일에 추가해줘야 했다.
추가로 Gradle 내부에서 자체적으로 버전 관리를 한다면 libs.versions.toml 파일같은 카탈로그가 필요했다. 그러나 Spring Boot의 의존성 관리 플러그인을 사용하는 이상 불필요한 작업이 추가되는 것 같았고 Gradle이라는 빌드 툴에 익숙해지는데 걸리는 비용을 생각하면 불필요한 과정이라 생각하여 이 buildSrc를 실제로 프로젝트에 반영하지 않았다.
만약 Kotlin 프로젝트를 진행하는데 Spring Boot 없이 순수 Kotlin 프로젝트를 진행하는 경우 Kotlin 공식 문서를 참고해서 다시 돌아보게 될 것 같다.
어쨌든 이런 방식으로 멀티 모듈을 구성한다면 하위 프로젝트만 생성한다고 알아서 인식할 수 있는 건 아니고 최상위 프로젝트의 settings.gradle.kts 파일에서 별도로 지정해줘야 한다.
include(":app")
include(":utils")
// 또는
include("app", "utils")
이때 include
의 순서는 상관없다. 만약 모듈이 중첩된 하위 경로안에 들어있는 프로젝트라면 디렉토리를 기준으로 콜론을 붙여서 추가할 수 있다.
include("project1", "project2:child1", "project3:child1")
이때 child1
모듈은 project2
경로 하위에 존재한다. 공식 문서의 include 설명에서도 볼 수 있다.
유의해야 할 점은 하위모듈의 이름이 같으면 Gradle Task 간 순환호출이 발생하는 문제가 있다. 이 경우 모듈 이름을 project2-child1
, project-child3
처럼 변경해야 한다. 모듈의 path 전체를 고유한 이름으로 취급하지 않고 제일 하단에 있는 이름만 취급하도록 되어있는듯.
Gradle은 이후 최상위 프로젝트를 빌드할 때 이 하위 프로젝트들 중 변경이 있는 프로젝트만 빌드하는 최적화를 제공한다. 실제로 하위 프로젝트 중 일부를 변경했을 때랑 변경하지 않았을 때 빌드 로그를 확인하면 다음과 같다.
// 변경 전(240ms)
> Task :utils:processResources NO-SOURCE
> Task :utils:compileKotlin UP-TO-DATE
> Task :utils:compileJava NO-SOURCE
...
> Task :app:compileKotlin UP-TO-DATE
// 변경 후(1079ms)
> Task :utils:processTestResources NO-SOURCE
> Task :utils:compileKotlin
> Task :utils:compileJava NO-SOURCE
...
> Task :app:compileKotlin UP-TO-DATE
실제로 하위 프로젝트 중 일부(예제에서는 utils)의 코드를 변경했을 때 빌드 프로세스를 보면 위처럼 변경된 코드에 대해서는 UP-TO-DATE가 뜨지 않는 것을 볼 수 있다. 그에 반해 변경되지 않은 코드(app 프로젝트)에서는 항상 컴파일 과정이 UP-TO-DATE로 스킵된다.
지금은 app
, utils
같은 간단한 하위 프로젝트 몇 개밖에 없기 때문에 최상위 프로젝트에서 빌드를 수행하면 전체 모듈을 빌드하고 있다. 그러나 하위 프로젝트가 많아지고 그 의존관계가 점점 복잡해질수록 한 모듈에 변경사항이 있을 때마다 모든 모듈을 다시 컴파일하고 테스트하는 것은 리소스 낭비가 된다. 그래서 이 경우 Gradle에서는 buildNeeded
같은 의존성이 있는 모듈만 빌드하는 Task를 제공하고 있다. 반대로 현재 모듈을 의존하는 모듈들을 빌드하려는 경우 buildDependents
같은 Task를 실행할 수 있다.
지금까지는 하나의 모듈이었기 때문에 코드에 변경이 있으면 항상 전체 코드를 컴파일하는 느낌이었는데 이렇게 모듈로 분리해서 필요한 부분만 변경하고 컴파일할 수 있다면 빌드 시간을 단축할 수도 있지 않을까 하는 생각이 든다.
그런데 이렇게 하위 프로젝트를 생성하게 되면 해당 프로젝트에도 프로그램의 진입점이 존재할 수 있다. Kotlin의 경우 최상위에 존재하는 fun main()
함수가 그 역할인데 기존 프로젝트를 멀티 모듈로 변경하는 과정에서 하위 프로젝트를 단순 Kotlin 모듈로 추가했더니 아래처럼 프로젝트가 생성되었다.
기존에 작성한 단일 모듈 애플리케이션의 진입점(아래의 PmsCoreApplication.kt
)과 새로 추가한 seller 모듈의 진입점(위의 Main.kt
)이 동시에 존재하는 것을 볼 수 있다. 평소에 애플리케이션을 작성할 때는 하나의 코드에 하나의 main 함수만 존재하던 것을 생각하면 이상하게 보일 수 있는데 사실 이는 별도의 프로젝트가 같은 공간에 들어있어서 그렇지 두 진입점은 각자의 프로젝트에 대한 진입점 역할을 하고 있는 것이다. 그래서 위의 main 함수를 실행시키든 아래의 main 함수를 실행시키든 정상적으로 동작한다.
우리야 당연히 Spring Boot 기반 애플리케이션을 만들고 있기 때문에 아래쪽 main 함수를 실행시켜야 한다는 것을 알지만 나중에 서버에 올리거나 컨테이너로 만들 때는 어떻게 해야 할까? 이는 결국 JVM이 알아서 할 수 없고 우리가 어떤 프로젝트의 Gradle Task를 실행할지 직접 알려주거나 Gradle 설정 파일에서 지정해줘야 한다. 전자의 경우 애플리케이션 실행 시 ./gradlew :module-name:run
처럼 실행할 모듈을 지정하는 것이고 후자의 경우 Gradle에서 제공하는 application 플러그인을 사용하여 실행할 main 함수의 위치를 지정할 수 있다.
지금은 기존 프로젝트를 멀티 모듈로 바꾸는 과정이기 때문에 최상위 프로젝트가 프로그램 실행 로직을 담고있는데 대개 멀티 모듈 프로젝트에서는 최상위 프로젝트가 빌드 로직만 포함하고 있고 실제로 하위 프로젝트 중 하나를 골라서 실행하는 식으로 동작한다고 한다. 현재같은 Spring Boot 웹 애플리케이션이라면 위 PmsCoreApplication.kt
의 @SpringBootApplication
클래스가 코어나 웹 어댑터 등 별도의 하위 모듈로 들어가야 할 것이다.
멀티 모듈 구조에서 최상위 프로젝트는 콜론(:
), 하위 프로젝트는 그 뒤에 모듈 이름을 적는 :sub-project-1
처럼 지칭할 수 있다. 프로젝트 구조를 보여주는 projects
Gradle Task를 실행시키면 다음과 같은 결과를 볼 수 있다.
➜ ./gradlew projects
> Task :projects
Projects:
------------------------------------------------------------
Root project 'pms'
------------------------------------------------------------
Root project 'pms'
\--- Project ':seller'
여기서 다른 모듈이 seller
모듈을 의존하고자 한다면 일반적인 의존성을 등록하는 것처럼 implementation(project(":seller"))
를 dependencies 블록에 추가할 수 있다. 참고 가이드
dependencies {
implementation(project(":seller"))
...
IntelliJ에서 모듈을 추가할 때 단순 Gradle 모듈이 아니라 스프링 부트로 추가하면 계속 상위 프로젝트로 추가되는 것 같다.
그래서 이런 이상한 빌드 실패 사례가 생기는데 이 스택오버플로우 답변을 참고하면 prepareKotlinBuildScriptModel
Task가 최상위 프로젝트에서 동작하기 때문에 하위 프로젝트가 최상위 프로젝트로 잘못 등록되서 그렇다고 한다. 위의 이미지를 보면 persistence-rdb
모듈이 pms
모듈의 하위 모듈로 들어가야 하는데 잘 들어가긴 했지만 pms
와 동급의 최상위 프로젝트로 등록되어 있다. 그래서 해당 프로젝트를 Gradle 메뉴에서 제거하고 pms
하위의 모듈만 남긴 채 다시 빌드하면 정상 동작하는 것을 확인할 수 있었다.
사실 이 글을 쓰면서 예제를 만들고 지우고 하면서 기존 프로젝트를 @SpringBootApplication
어노테이션이 달린 실행 모듈과 영속성 접근 모듈로 분리하긴 했지만 정확히 어떤 것을 기점으로 모듈을 나눠야 하는지 아직도 기준이 서지 않는다. 멀티모듈 설계 이야기라는 기술 블로그 글을 보면서 시행착오를 최대한 줄여보고자 하지만 일단은 프로젝트 기획도 정해지지 않은 상황에서 모듈을 구분하는 것 자체가 순서가 맞지 않다고 생각한다.
그냥 기존의 계층형 MVC 아키텍처에서 컨트롤러, 서비스, 리포지토리 세 영역으로 모듈을 구분하는 것은 의미가 없을 것 같고 DDD스러운 도메인별로 나누어야 할 것 같다는 막연한 생각만 하고 있다. 이건 프로젝트를 진행하면서 겪어봐야 깨달을 수 있을 것 같다.