Mono-repo, Multi-project를 Gradle 플러그인으로 손쉽게 관리하기

안녕하세요. 구경일입니다. 이번 글에서는 점점 거대해져가는 코드 베이스를 관리 가능한 상태로 유지하는 방법에 대해 이야기하겠습니다.

문제 인식

제가 처음 이 일을 시작했던 팀에서는 Maven과 GitHub을 사용하고 있었습니다. 당시 이야기를 듣자 하니 SVN에서 GitHub으로 이전한 것도 그리 오래되지 않았던 것 같았습니다. 당시에는 하나의 리포지터리에 하나의 프로젝트만 두는 ‘mono-repo mono-project’로 구성하는 게 일반적이었습니다. 다만 실제 애플리케이션은 그보다는 복잡하게 구성되었죠. 그 당시 구조를 간단히 나타내면 아래 그림과 같습니다. 

이 구성이 그리 특이한 것은 아닐 거라고 생각합니다. 실제로 기동되는 애플리케이션과 공용으로 사용하는 라이브러리를 각 특성에 맞게 패키징한 것이죠. 당시의 요구 사항을 충족하기엔 충분한 구조였습니다. 다만 제가 보기에는 몇 가지 문제가 있었습니다. 이런저런 기능이 common에 추가되어 점점 코드가 늘어나고 복잡해지면서 발생하는, 이른바 ‘common의 저주’와 같은 문제에 대해서도 이야기하고 싶지만, 이번 글에서는 의존성이 있는 모듈을 개발하는 프로세스에 대해서 이야기하려고 합니다.

앞서 말씀드린 구조에서 서버 컴포넌트에 API를 하나 추가한다고 가정해보겠습니다. 일단 명시적으로 영향을 받는 아티팩트는 ‘protocol’과 ‘client’와 ‘server’입니다. 변경은 아래와 같은 절차를 거칩니다.

  1. protocol 확장
  2. 확장한 protocol 모듈 버전을 사용하도록 common에 반영
  3. server와 client에 각각 common과 protocol 버전 반영
  4. 추가된 API에 대한 구현을 적용

이를 커밋 단위로 표현하면 다음과 같습니다.

// protocol
commit "expand api model"
commit "protocol v0.0.1"
 
// common
commit "apply protocol v0.0.1"
commit "common v0.0.1"
 
// server
commit "apply common v0.0.1"
commit "add new api"
 
// client
commit "apply protocol v0.0.1"
commit "add new api"

‘서비스에 API를 추가한다’라는 하나의 변경이 서로 다른 리포지터리에 각각 두 개의 커밋을 만들게 되었습니다. 만약 개발 중 변경이었다고 해도 통합할 때 피어(peer) 리뷰를 하는 팀이 있다면, 변경 때마다 커뮤니케이션 비용이 발생하게 되는 것입니다. 하나를 변경하기 위해 같이 변경해야 할 곳이 너무나도 많은, 이른바 응집성이 낮은 패턴이라고 할 수 있습니다. 이 문제를 해결하기 위한 방법으로 제시된 것 중의 하나가 ‘mono-repo, multi-project’ 구성입니다.

 

Mono-repo, multi-project

이 아이디어는 응집성이 높은 모듈들을 하나로 모아서 관리하기 위해 제시되었고, 이를 통해 논리적으로 하나를 변경하기 위해서 필요한 여러 모듈의 변경을 하나의 커밋으로 묶을 수 있게 되었습니다. 이와 관련해 Gradle이 더 발전적인 모델을 소개하기는 했지만, Maven 역시 이를 지원했습니다. 따라서 멀티 프로젝트로 이행하던 당시에 Maven을 사용하고 있던 대부분의 프로젝트는 Maven 멀티 프로젝트 형태로 통합을 진행했습니다. 그런데 그때는 물론이거니와 사실은 지금도, mono-repo, multi-project 구성은 계속 발전하는 중입니다. 다르게 말하자면, 아직 부족한 부분이 많다는 뜻입니다. 그리고 Maven은 이런 부족한 부분을 사용자가 직접 커스터마이징으로 보완해 나갈 수 있는 유연한 도구가 아니었습니다. 이런 이유로 현재 멀티 프로젝트를 사용하고 있는 리포지터리의 대부분은 Gradle을 사용하고 있습니다.

 

복사 후 붙여넣기 문제

Gradle의 멀티 프로젝트는 멀티 프로젝트 구성과 관련된 많은 이슈를 해결해 주었습니다. 이제 더 이상 하나의 변경을 위해 여러 개의 커밋을 만든 뒤 그것들을 묶어서 관리하지 않아도 되었습니다. 하지만 프로젝트가 늘어나면서 또 다른 문제를 만났는데요. 바로 여러 프로젝트에 중복된 설정이 존재하면서 ‘복사 후 붙여넣기 문제’가 발생할 수 있다는 것입니다. 이 문제는 너무나도 잘 알려진 안티 패턴 중 하나인데요. 복사 후 붙여넣기로 적용된 코드는 이후 원본이 변경될 때 같이 변경되지 않고 그 상태로 방치되는 경우가 많으며, 문제로 불거질 때까지 식별하기도 어렵습니다. 아래 ‘beverage shop’을 예제로 설명하겠습니다. 

// beverage shop
├── coffee
│   └── api
│       ├── client
│       ├── protocol
│       └── server
├── juice
│   └── api
│       ├── client
│       ├── protocol
│       └── server
└── shop
    └── server

beverage shop의 각 모듈 간 의존성은 아래와 같습니다.

이제 beverage shop의 모듈 설정을 살펴보겠습니다.

// coffee/api/protocol/build.gradle.kts
apply(plugin="java")
apply(plugin="java-library")
 
dependencies {
  ...
}
 
// coffee/api/client/build.gradle.kts
apply(plugin="java")
apply(plugin="java-library")
apply(plugin="org.springframework.boot")
apply(plugin="io.spring.dependency-management")
 
tasks {
  val bootJar by getting(BootJar::class) {
    enabled = false
  }
 
  val jar by getting(Jar::class) {
    enabled = true
  }
}
 
dependencies {
  ...
}
 
// coffee/api/server/build.gradle.kts
apply(plugin="java")
apply(plugin="java-library")
apply(plugin="org.springframework.boot")
apply(plugin="io.spring.dependency-management")
 
dependencies {
  ...
}

coffee 모듈과 관계된 설정만 해도 이 정도로 많고, juice에도 각 모듈이 똑같은 설정을 갖게 됩니다. 이런 상황에서 Spring의 버전을 올리는 것과 같이 설정을 변경해야 하는 일이 발생한다면, coffee와 juice 두 모듈 전체를 다 업데이트해야 할 수도 있습니다. 현실에서는 보통 이것보다 훨씬 더 많은 모듈을 운용하고 있기 때문에 더욱 많아지는데요. 그런 상황에서 설정 변경이 발생하면 그저 작업하는 사람의 꼼꼼함에 의존해서 전체가 잘 업데이트되길 기대하는 수밖에 없습니다. 과연 이것이 지속 가능한 방법일까요?

 

문제 해결 아이디어

위 문제를 해결하기 위해 팀에서 많은 논의를 거쳤는데요. 그 결과 도출된 아이디어를 소개하겠습니다.

 

configure 메서드

Gradle이 제공하는 커스터마이징 문법 중에 흥미로운 게 하나 있는데요. 바로 configure 메서드입니다. 이 메서드는 객체가 이미 만들어진 후에 데코레이트(decorate)하는 것을 허용하는 메서드입니다. 

/**
     * <p>Configures an object via a closure, with the closure's delegate set to the supplied object. This way you don't
     * have to specify the context of a configuration statement multiple times. <p> Instead of:</p>
     * <pre>
     * MyType myType = new MyType()
     * myType.doThis()
     * myType.doThat()
     * </pre>
     * <p> you can do:
     * <pre>
     * MyType myType = configure(new MyType()) {
     *     doThis()
     *     doThat()
     * }
     * </pre>
     *
     * <p>The object being configured is also passed to the closure as a parameter, so you can access it explicitly if
     * required:</p>
     * <pre>
     * configure(someObj) { obj -&gt; obj.doThis() }
     * </pre>
     *
     * @param object The object to configure
     * @param configureClosure The closure with configure statements
     * @return The configured object
     */
    Object configure(Object object, Closure configureClosure);

Gradle 스크립트의 실행 순서는 대략 다음과 같습니다.

  1. settings.gradle에서 모듈 리스트를 등록
  2. root의 build.gradle 실행
  3. 각 모듈의 build.gradle이 존재하면 실행

따라서, configure 메서드를 사용하면 각 모듈의 빌드 설정이 실행되기 전에 아래와 같은 방법으로 공통 설정을 추가할 수 있습니다.

configure(project(":A")) {
  repositories {
        mavenLocal()
        mavenCentral()
    }
}
 
configure(project(":B")) {
  ...
}

 

복사 후 붙여넣기 문제 해결

이런 특징을 이용해서 Spring Boot를 사용할 때 걸리적거리던 것을 정리해 보겠습니다. 먼저 위 beverage shop 예제에서 문제로 언급했던 부분을 configure를 이용해 간략화하겠습니다.

// build.gradle.kts
 
configure(listOf(project(":coffee:api:protocol"), project(":juice:api:protocol")) {
  apply(plugin="java")
  apply(plugin="java-library")
}

하지만 위와 같은 형태라면 앞으로 모듈이 새로 추가될 때마다 configure에 들어갈 프로젝트 목록이 길어집니다. 이를 막기 위해 구분자를 추가하겠습니다. 아래와 같이 각 프로젝트에 type이라는 이름의 프로퍼티를 추가하여 조금 더 일반적인 방식으로 설정을 작성할 수 있게 만들었습니다.

configure(allprojects.find { (it.property["type"] as String?)?.startsWith("java") ?: false }) {
  apply(plugin="java")
}
 
configure(allprojects.find { (it.property["type"] as String?)?.endsWith("lib") ?: false }) {
  apply(plugin="java-library")
}
 
configure(allprojects.find { (it.property["type"] as String?)?.contains("boot") ?: false }) {
  apply(plugin="org.springframework.boot")
  apply(plugin="io.spring.dependency-management")
}
 
configure(allprojects.find { (it.property["type"] as String?)?.endsWith("boot-lib") ?: false }) {
    tasks {
      val bootJar by getting(BootJar::class) {
        enabled = false
      }
     
      val jar by getting(Jar::class) {
        enabled = true
      }
    }
}

위 코드를 이용해 아래와 같이 각 프로젝트의 특성을 type으로 추출할 수 있습니다.

// coffee/api/protocol/gradle.properties
type=java-lib
// coffee/api/client/gradle.properties
type=java-boot-lib
// coffee/api/server/gradle.properties
type=java-boot-application
 
// juice/api/protocol/gradle.properties
type=java-lib
// juice/api/client/gradle.properties
type=java-boot-lib
// juice/api/server/gradle.properties
type=java-boot-application
 
// shop/server/gradle.properties
type=java-boot-application

이제 모든 설정이 추출되어 각자의 단위로 재사용할 수 있게 되었습니다. 또한 각 모듈의 구분자가 이런 구체적인 설정의 내용을 대변하기 때문에 추후 확장하거나 변경할 때도 쉽게 대응할 수 있습니다. 다만 현재 상태는 사람이 읽으면서 직관적으로 인식할 수 있는 형태가 아닙니다. 그래서 조금 더 사람에게 친숙한 표현으로 바꾸기 위해 저희가 개발한 build-recipe-plugin을 이용해 보겠습니다. 

configureByTypePrefix("java") [
  apply(plugin="java")
}
 
configureByTypeSuffix("lib") {
  apply(plugin="java-library")
}
 
configureByTypeHaving("boot") {
  apply(plugin="org.springframework.boot")
  apply(plugin="io.spring.dependency-management")
}
 
configure(byTypeHaving("boot") and byTypeSuffix("lib")) {
    tasks {
      val bootJar by getting(BootJar::class) {
        enabled = false
      }
     
      val jar by getting(Jar::class) {
        enabled = true
      }
    }
}

어떤가요? 조금 더 읽기 편해지지 않았나요?

 

gradle-multi-project-support 플러그인

저희는 위에서 말씀드린 문제를 해결하기 위해 ‘gradle-multi-project-support’라는 Gradle 플러그인을 만들었고, 이를 OSS(Open Source Software)로 공개했습니다. 

위와 같은 형태를 프리셋(preset)으로 제공하는 플러그인이 아니라, 사용자가 자유롭게 멀티 프로젝트의 틀을 잡을 수 있도록 하는 것이 핵심 아이디어입니다. 만약 그렇지 않았다면 플러그인의 이름은 ‘gradle multi project spring recipe’와 같았겠죠. 더 상세한 내용은 프로젝트의 README를 참고하시기 바랍니다.

 

하나 더, recursive-git-log-plugin

Gradle build-recipe-plugin을 도입하면서, 이젠 새로운 모듈을 만들 때 Gradle 빌드 스크립트 하나를 복사해서 붙여 넣는 것이 아니라 그저 모듈의 타입을 정하기만 하면 간단하게 추가할 수 있게 되었습니다. 덕분에 작업의 허들이 크게 내려가 팀원 모두가 자유롭게 추가할 수 있게 되었는데요. 그 효과로 모듈 개수가 빠르게 늘어났습니다. 그런데 모듈이 많아질수록 구체적으로 어떤 변경이 실제로 애플리케이션에 탑재되어 프로덕션에 나가는지 추적하기 어려워졌습니다. 직접 Git 로그를 보면서 커밋 단위로 영향을 받을만한 컴포넌트를 눈으로 찾아내곤 했는데요. 그다지 지속 가능한 방법이 아니었습니다. 예를 들어 아래와 같은 변경이 발생하는 시나리오를 생각해 봅시다.

변경은 두 가지입니다. 하나는 coffee:api:client가 의존하고 있는 Armeria라는 외부 라이브러리의 버전을 올리는 것, 다른 하나는 juice:api:protocol에 새로운 juice 타입이 추가되는 것입니다. 그림을 보면 각각의 변경이 실제로 영향을 미치는 모듈이 표현되어 있습니다. recursive-git-log-plugin은 사용자가 지정한 두 시점 사이에 발생한 변경을 모듈 단위로 모아서 보여주기 때문에 더 이상 사람이 눈으로 찾아내지 않아도 됩니다. 이 플러그인을 통해 모듈이 많아지면서 발생한 문제도 해결할 수 있었습니다.

 

마치며

지금까지 특별한 테크닉이 아닌, 극히 단순한 아이디어를 발전시켜 생산성을 향상한 사례를 소개하며 Gradle 멀티 프로젝트 사용방법에 대해서 말씀드렸습니다. 수많은 Gradle 모듈로 구성된 MSA(Micro Service Architecture)로 서비스하고 있는 팀이라면, 이번 글에서 소개해 드린 gradle-multi-project-support 플러그인을 도입해 보는 것은 어떨까요?

 

참고

이번 글에서 사용한 예제는 아래 리포지터리에 공개해 놓았습니다. 필요하신 분들은 참고하시기 바랍니다.

플러그인에 대한 문의나 개선사항에 대한 건의는 GitHub 이슈나 Slack으로 연락주시면 성심성의껏 답변드리겠습니다. 

Related Post