블로그

Gradle Superpower: Multi-Project

등록일
2024-10-07 13:28:36
조회수
105
너 정말 게으르다!


개발자에게 최고의 칭찬이라고 생각해요.


안녕하세요. 제일 게으른 개발자가 되고자 하는 Softeer 개발자 송제영입니다. 다르게 말하면 저는 DevEx 에 관심이 많아요. 지금부터, 최근에 진행한 프로젝트에서 Gradle 이라는 도구를 통해 DevEx 를 좋게 한 경험을 공유하려고 해요.


DevEx 란? Developer Experience, 개발자 경험입니다. 그래서 그게 뭐냐고요? 아래의 아티클을 읽어보시면 감이 오실 거예요.
Developer experience: What is it and why should you care?(github.blog)
Yes, good DevEx increases productivity. Here is the data.(github.blog)
개발 생산성이 높고, 피드백이 빠르며, 개발자의 만족도가 높으면 그것을 DevEx 가 좋다고 할 수 있습니다.


이야기는 어떤 불만족으로부터 시작되었습니다.


어떻게 하면 반복적으로 사용하는 자원을 모듈화하고 재사용할 수 있을까?
복잡한 Application Properties (앱 구성 변수) 를 어떻게 빨리 파악하고 조작할 수 있을까?


Table of Content



Introduction


저는 Spring Boot 기반의 웹 애플리케이션 개발자입니다. 2년 정도 개발을 하면서 몇 차례 Spring Boot 애플리케이션을 초기화하고 소스 코드를 관리하곤 했죠. 그러다 보니 점점 반복되는 코드와 마주했습니다. DB 연결 설정, 커넥션 풀 설정, 보안 설정, 환경 변수 설정 등이 그것입니다. 게으른 개발자는 이런 반복을 참을 수 없죠.


서로 다른 애플리케이션이라고 해도 어떤 자원(코드, 구성 파일 등)을 공유한다면 그런 자원을 모듈화해서 재사용하면 좋겠죠.



그러면 이제부터 어떻게 그런 문제를 해결할 수 있는지 알아볼게요.


Gradle Multi-Project (Gradle Monorepo)


Gradle 은 Spring Initializr (start.spring.io) 가 기본으로 채택한 Java 패밀리의 빌드 도구예요. Gradle Multi-Project는 Gradle 이 지원하는 Monorepo 솔루션이고요. 여러 프로젝트를 단일 리포지토리 아래에서 다룰 때 유용한 기능입니다. 이 Gradle Multi-Project 를 이용해서 저는 반복적인 구성 모듈을 여러 개의 작은 프로젝트로 독립시키고, 애플리케이션 프로젝트가 그것에 의존하는 방식으로 소스 코드 구조를 짰어요. 구성 A 프로젝트, 도메인 B 프로젝트, 애플리케이션 C 프로젝트 처럼요.



Gradle Multi Project 시작하기


여러 방법이 있겠지만 저는 로컬 PC에 Gradle 을 설치하는 것으로 시작했어요. Installing Gradle(gradle.org)


$ gradle --version

------------------------------------------------------------
Gradle 8.10.2
------------------------------------------------------------

Build time:    2024-09-23 21:28:39 UTC
Revision:      415adb9e06a516c44b391edff552fd42139443f7

Kotlin:        1.9.24
Groovy:        3.0.22
Ant:           Apache Ant(TM) version 1.10.14 compiled on August 16 2023
Launcher JVM:  17.0.10 (Amazon.com Inc. 17.0.10+8-LTS)
Daemon JVM:    /home/song/java/corretto-17 (no JDK specified, using current Java home)
OS:            Linux 5.15.153.1-microsoft-standard-WSL2 amd64


시스템에 JDK 가 설치되어 있음을 전제합니다. PATH 환경 변수를 적절히 설정하여 java -version, javac -version 명령이 잘 실행되는지 확인하세요.


Gradle 을 설치했다면 적당한 경로에 새로 프로젝트 루트 디렉토리를 만들고 그곳으로 이동하여 gradle init 명령을 실행합니다. 그러면 대화형으로 몇 가지 옵션을 제공해주는데 Spring Boot 애플리케이션을 위한 스케폴딩이 잘 갖추어지도록 하려면 아래와 같이 선택하세요. 구현 언어와 버전, Gradle DSL 은 선호에 맞게 고르세요. 저는 저에게 익숙한 조합으로 골랐어요.


$ gradle init

Select type of build to generate:
  1: Application
  2: Library
  3: Gradle plugin
  4: Basic (build structure only)
Enter selection (default: Application) [1..4] 1

Select implementation language:
  1: Java
  2: Kotlin
  3: Groovy
  4: Scala
  5: C++
  6: Swift
Enter selection (default: Java) [1..6] 1

Enter target Java version (min: 7, default: 21): 17

Project name (default: gradle-superpower):

Select application structure:
  1: Single application project
  2: Application and library project
Enter selection (default: Single application project) [1..2] 2

Select build script DSL:
  1: Kotlin
  2: Groovy
Enter selection (default: Kotlin) [1..2] 2

Generate build using new APIs and behavior (some features may change in the next minor release)? (default: no) [yes, no] no


> Task :init
Learn more about Gradle by exploring our Samples at https://docs.gradle.org/8.10.2/samples/sample_building_java_applications_multi_project.html

BUILD SUCCESSFUL in 13s
1 actionable task: 1 executed


여기에서 중요한 부분은 바로


Select type of build to generate:


에서 1번 Application, 그리고


Select application structure:


에서 2번 Application and library project를 선택해야 다루기 용이한 기본 스케폴딩이 제공된다는 거예요.


이런 스케폴딩이 만들어지죠.


$ tree --filesfirst -L 4
.
├── gradlew
├── gradlew.bat
├── settings.gradle
├── app
│   ├── build.gradle
│   └── src
│       ├── main
│       │   ├── java
│       │   └── resources
│       └── test
│           ├── java
│           └── resources
├── buildSrc
│   ├── build.gradle
│   ├── settings.gradle
│   └── src
│       └── main
│           └── groovy
├── gradle
│   ├── libs.versions.toml
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── list
│   ├── build.gradle
│   └── src
│       ├── main
│       │   ├── java
│       │   └── resources
│       └── test
│           ├── java
│           └── resources
└── utilities
    ├── build.gradle
    └── src
        ├── main
        │   ├── java
        │   └── resources
        └── test
            └── resources


이미 빌드 도구로 Gradle 을 사용하고 계신 분이라면 대부분 익숙한 파일과 디렉토리일 거예요. 프로젝트 루트에 위치한 app, list, utilities 디렉토리가 바로 서브 프로젝트입니다.


Gradle Multi-Project 에서 buildSrc는 특별히 예약된 프로젝트입니다.


스케폴딩을 지원하는 오늘날의 여느 도구와 같이 Gradle Multi Project 의 스케폴딩 역시 기본적으로 예시 코드가 작성되어 있어요. (게으른 개발자의 취향을 저격하는 훌륭한 도구들이죠.) 그러한 예시 코드만 봐도 어떻게 내 요구에 맞게 잘 구성할 수 있을지 그려보기 좋습니다. 한 번 살펴볼까요.


settings.gradle


plugins {
  id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
}

rootProject.name = 'gradle-superpower'
include('app', 'list', 'utilities')


먼저 루트에 위치한 settings.gradle입니다. 여기에서 include 함수에 인자로 'app', 'list', 'utilities' 를 넘겨주고 있는데요. Gradle 은 이렇게 include에서 받은 이름으로부터 서브 프로젝트를 찾아 프로젝트 트리를 만듭니다.


projects Task 를 실행하면 Gradle 이 이 settings.gradle을 잘 읽었는지 확인할 수 있습니다.


$ ./gradlew projects
Downloading https://services.gradle.org/distributions/gradle-8.10.2-bin.zip
.............10%.............20%.............30%.............40%.............50%.............60%.............70%.............80%.............90%.............100%

> Task :projects

Projects:

------------------------------------------------------------
Root project 'gradle-superpower'
------------------------------------------------------------

Root project 'gradle-superpower'
+--- Project ':app'
+--- Project ':list'
\--- Project ':utilities'

To see a list of the tasks of a project, run gradlew <project-path>:tasks
For example, try running gradlew :app:tasks

BUILD SUCCESSFUL in 58s
8 actionable tasks: 8 executed


settings.gradle 파일은 자주 다루게 됩니다. 이따가 다시 한 번 보고요.


app/build.gradle


plugins {
  id 'buildlogic.java-application-conventions'
}

dependencies {
  implementation 'org.apache.commons:commons-text'
  implementation project(':utilities')
}

application {
  mainClass = 'org.example.app.App'
}


app 프로젝트에 있는 build.gradle 파일은 많은 Spring Boot 개발자에게 친숙한 모습일 것 같습니다. 그런데 뭔가 많이 생략되었죠? buildlogic 덕분인데요. 이렇게 서브 프로젝트에서 공통적으로 사용되는 프로젝트 구성 역시 Gradle 의 스케폴딩이 미리 모듈화를 해두었죠! 끼얏호~


그러한 프로젝트 공통 구성의 세부사항은 buildSrc 프로젝트의 src/main/groovy/buildlogic.java-* 파일을 살펴보면 어렵지 않게 파악할 수 있습니다.


여기에서 눈여겨 볼 점은 dependencies 블럭 안에 있는 implementation project(':utilities')입니다. 여기에서의 utilities는 서브 프로젝트예요. settings.gradle에 있는 그것이고요, 프로젝트 루트에 있는 그것입니다. 바로 app이 utilities에 의존한다는 것을 알 수 있죠. utilities 안에 공통적인 기능이 들어 있고 다른 프로젝트가 그걸 재사용한다는 걸 알 수 있습니다.


dependencies Task 를 실행하면 어떤 프로젝트의 의존성을 파악할 수 있는데요. 한 번 app 프로젝트의 의존성을 파해쳐봅시다.


$ ./gradlew :app:dependencies

> Task :app:dependencies

------------------------------------------------------------
Project ':app'
------------------------------------------------------------

annotationProcessor - Annotation processors and their dependencies for source set 'main'.
No dependencies

compileClasspath - Compile classpath for source set 'main'.
+--- org.apache.commons:commons-text -> 1.12.0
|  \--- org.apache.commons:commons-lang3:3.14.0
+--- project :utilities
|  \--- project :list
\--- org.apache.commons:commons-text:1.12.0 (c)

...


이렇게 터미널에서 ./gradlew :foo:bar와 같이 입력하여 foo 프로젝트의 bar Task 를 실행할 수 있습니다.


너무 길어서 중략했습니다. 중간에 보면 utilities 프로젝트가 보이고요. 그 밑에는 list 프로젝트가 보이네요. 그렇다는 건 utilities 프로젝트는 list 프로젝트에 의존한다는 거겠죠.


여기까지만 알아도 이제 웬만큼 눈치 있는 개발자들은 소스 코드 구조를 멋있게 짤 수 있을 건데요. 마음에 안 드는 부분이 하나 있습니다. 바로 서브 프로젝트가 많아질수록 루트에 점점 디렉토리가 많아질 것이라는 거예요. 프로젝트를 열자마자 엄청난 스크롤이 등장한다면 그런 경험은 별로 좋지 않겠죠.


껍데기 서브 프로젝트


프로젝트의 경로를 바꾸려면 어떻게 해야 할까요? settings.gradle 파일에서


//...
include('app', 'list', 'utilities')

project(':app').projectDir = file('path/to/app')
project(':list').projectDir = file('path/to/list')
project(':utilities').projectDir = file('path/to/utilities')


이와 같이 구성하는 것도 가능하지만 좀 지저분하네요. 그래서 저는 아예 프로젝트의 이름을 바꾸기로 했습니다.


//...
include ':s:app'
include ':s:list'
include ':s:utilities'


원래의 include(...) 대신에 이와 같이 작성할게요.


Groovy 문법에서 include('a', 'b', 'c')와 include 'a', 'b', 'c'는 같고요.
그런 include를 여러 번 써도 되기 때문에 위와 같이 작성할 수 있는 것입니다.


그리고 루트에 s라는 이름의 디렉토리를 새로 만들고 기존 app, list, utilities를 그 하위로 옮기면 Gradle 이 제대로 인식합니다. 그렇게 하고 다시 한 번 projects Task 를 실행해봅시다.


$ ./gradlew projects

FAILURE: Build failed with an exception.

* Where:
Build file '/home/song/workspaces/experiment/gradle-superpower/s/app/build.gradle' line: 11

* What went wrong:
A problem occurred evaluating project ':s:app'.
> Project with path ':utilities' could not be found in project ':s:app'.

* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.

BUILD FAILED in 1s
7 actionable tasks: 7 up-to-date


어라; 실패하네요. 이런 접근은 프로젝트의 식별자 자체를 바꾼 것이기 때문에 프로젝트의 build.gradle 또한 업데이트해야 합니다!


# s/app/build.gradle
implementation project(':utilities') -> implementation(':s:utilities')

# s/utilities/build.gradle
api project(':list')         -> api project(':s:list')


이렇게 업데이트하고 다시 실행합시다.


$ ./gradlew projects

> Task :projects

Projects:

------------------------------------------------------------
Root project 'gradle-superpower'
------------------------------------------------------------

Root project 'gradle-superpower'
\--- Project ':s'
   +--- Project ':s:app'
   +--- Project ':s:list'
   \--- Project ':s:utilities'

To see a list of the tasks of a project, run gradlew <project-path>:tasks
For example, try running gradlew :s:tasks

BUILD SUCCESSFUL in 1s
8 actionable tasks: 1 executed, 7 up-to-date


s 디렉토리 안에는 서브 프로젝트 디렉토리들이 있을 뿐인데 s라는 프로젝트도 생겼네요? 저는 s라는 프로젝트를 include 하지 않았는데도 말이죠. 이것은 Gradle 의 기본 동작인데요. s에 실질적인 프로젝트 구성이 있는지에 관계없이 :으로 구분된 중간 경로에 해당하는 모든 지점을 프로젝트로 간주합니다. s라는 이름은 Sub Project의 앞글자로 제가 임의로 정한 것이고 별다른 의미는 없습니다. (얼마든지 다른 이름을 써도 됩니다.) 껍데기 서브 프로젝트라고 받아들여 주세요. 어쨋든 소기의 목적을 달성했습니다. 이제 프로젝트를 열자마자 엄청난 스크롤이 반겨주지는 않겠네요.


제가 현재 관리하는 소스 코드에는 23개의 서브 프로젝트가 있는데요. 미리 이런 것을 고려해두지 않았다면 좀 곤란했을 것 같죠.


지금까지 Gradle 의 스케폴딩을 살펴보고 가볍게 수정해보면서 자연스럽게 핵심적인 Gradle Task 를 알아보았습니다.


실제 애플리케이션에 적용하는 것은 이제 시간 문제네요. 다음 시간에는 실제 Softeer 시스템 중 일부를 담당하고 있는 소스코드를 보면서 Use case 몇 가지를 소개해드릴게요.


최신 블로그