Gradle Plugin 개발 기록
실제 코드를 통한 플러그인의 구성과 동작
2022년 6월 10일Ed

안녕하세요. 오늘의집 플랫폼팀 백엔드 개발자 로버입니다. 플랫폼팀에서는 오늘의집 인하우스 API 빌드 도구(Mortar)를 빌드 과정에 자연스럽게 녹여내기 위해 Mortar Gradle Plugin을 개발하게 되었습니다. 이 글에서는 Gradle Plugin을 만들게 된 이유와 실제 코드를 통한 플러그인의 구성과 동작에 대해 알아봅니다.

왜 Gradle Plugin을 만들게 되었나?

오늘의집에는 Mortar라는 도구가 있습니다. (링크) API 개발 시, Mortar가 제공하는 CLI를 통해 gRPC API 개발 및 배포 과정을 도와주고 있습니다. 이 과정에서 build 디렉토리 내에 Gradle 빌드에 필요한 몇 가지 파일들을 생성하는데요. 이를 전체 프로젝트 빌드 과정에 포함하기 위해 처음에는 아래와 같이 스크립트를 만들도록 가이드하였습니다.

## build.sh #!/usr/bin/env bash set -e mortar prepare ./gradlew $@

하지만 이 스크립트에는 몇 가지 문제점이 있습니다.

  • Mortar 도구만 단독으로 실행시키려면 개발자가 직접 도구를 이해하고 사용해야 합니다.
  • 스크립트의 모든 arguments($@)가 Gradle로만 전달되어 Mortar prepare 명령이 제공하는 다양한 옵션들을 활용할 수 없습니다.
  • gradle script의 구성에 따라 build 디렉토리에 생성되었던 데이터가 사라지는 경우가 있습니다.
  • 생성된 파일들을 사용하기 위해 gradle script에 일부 수정이 필요하며, 빌드 관련 로직이 gradle script와 shell script 두 군데로 분산되어 어떻게 동작하는지 쉽게 알기 어렵습니다.

이러한 문제점을 해소하기 위해 Gradle에 아래와 같은 Best practice 형태로 gradle script를 추가하는 방법을 제시했고, 한동안 이 방식을 유지하여 사용했습니다.

## build.gradle.kts ... dependencies { ... implementation("se.ohou.mortar:mortar-libs-kotlin:1.0.1") } sourceSets.main { java.srcDirs("src/main/java","build/generated/java") withConvention(org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet::class) { kotlin.srcDirs("src/main/kotlin","build/generated/kotlin") } } ... tasks { withType<KotlinCompile> { dependsOn("mortarPrepare") ... } } tasks.register<org.gradle.api.tasks.Exec>("mortarPrepare"){ workingDir = projectDir commandLine = listOf("mortar", "prepare", "--config", "./mortar.yaml") }

이 방법은 shell script를 활용할 때 생기는 문제들을 해결해 초기 MSA 전환 과정에서 활용되었습니다. 또한 많은 프로젝트에서 이 방식을 따라 프로젝트를 구성하고 개발을 진행했습니다. 그러나 다양한 피드백을 통해 Mortar가 점점 개선됨에 따라 이 방법에도 한계가 생기기 시작했습니다.

  • Mortar에 새로 추가된 기능을 적용하려면 모든 gradle script를 찾아 변경해야 했습니다. 특히필요에 의해 제공된 gradle script에 변형을 많이 가한 경우에는 수정이 어려웠습니다.
  • Mortar prepare Task는 최적화되지 않아 느렸습니다. 최적화를 하면 코드가 길어져 Mortar 관련된 코드만으로도 프로젝트의 gradle script를 이해하기가 어려워졌습니다.
  • Mortar 도구의 버전이 올라가면 거기에 맞추어 mortar-libs-kotlin 의존성의 버전을 변경해 주어야 했습니다. 미변경 시에는 버전 간 호환성이 맞지 않아 문제가 발생했습니다.

쉬운 결정, 쉽지 않은 구현과정

Gradle 공식 문서에는 아래와 같은 문장이 있습니다.

Binary plugins should be used whenever logic needs to be reused or shared across independent projects. They allow for properly structuring code into classes and packages, are cacheable, can follow a versioning scheme to enable smooth upgrade procedures and are easily testable.

이 문장이 우리가 원하는 포인트를 정확히 설명하고 있기에, 플랫폼팀은 Mortar용 custom gradle binary plugin을 만들기로 결정했습니다.

그러나 custom plugin을 만드는 것은 그리 쉬운 일이 아니었습니다. 막상 plugin을 만들려고 하니 어디서부터 시작해야 될지 난감했습니다.  레퍼런스가 많지 않은 데다, Gradle 버전이 올라가면서 사용법에도 많은 변화가 있어 대부분의 코드 예제들은 재사용이 힘들거나, 현재 버전에 맞추기 위해 많은 시간이 소요되었습니다.

또한 사용할 때는 크게 신경 쓰지 않았던 세세한 Gradle의 동작 방식과 명칭을 잘 이해해야만 했습니다. 그래서 일단은 잘 아는 것들부터 구현해 나가기 시작했습니다.

  • Plugin을 만듭니다.
  • Mortar를 실행해 줄 수 있는 Task를 만들고 KotlinCompile 전 실행해 주도록 합니다.
  • Task에 다양한 설정값을 전달해 줄 Extension을 만듭니다.
  • Mortar를 통해 생성된 파일을 SourceSet에 추가해 줍니다.

Applying plugin

Gradle의 plugin은 apply할 때의 모든 작업들을 진행합니다. apply는 아래와 같이 plugin을 사용하겠다고 명시만 하면 Configuration Phase(두 번째 Gradle Build Lifecycle, 다음 섹션에 설명)에 호출됩니다. 만약 나중에 apply를 하고 싶다면 apply false 옵션을 명시해주면 됩니다.

plugins { id("se.ohou.mortar.plugin") }

Plugin 은 org.gradle.api.Plugin 을 상속받아 아래와 같이 apply 함수를 구현해 주었습니다.

abstract class MortarGradlePlugin : Plugin<Project> { override fun apply(project: Project) { createMortarExtension(project) registerMortarPrepareTask(project) project.afterEvaluate { linkMortarToKotlinTasks(project) addGeneratedClassesToSourceSets(project) } } }

Build Lifecycle

Gradle의 build lifecycle은 이번에 처음 알게 되었고 앞으로의 설명에도 등장할 예정이라 먼저 짚어 보려고 합니다. 물론 공식 문서에 모두 설명이 되어 있으니 보다 자세한 내용이 궁금하다면 링크를 참조하시기 바랍니다.

Gradle은 아래의 세 가지 Lifecycle phase의 순서로 실행됩니다.

  • Initialization Phase : 프로젝트 구조 (특히 multi module로 구성되어 있을 때)를 파악하는 단계입니다. setting.gradle.kts 파일은 이 단계에서 실행되며 앞으로 계속 사용할 project 객체를 생성합니다.
  • Configuration Phase : build.gradle.kts 파일이 실행되어 Task를 실행할 준비를 하는 단계입니다. 각 Task 간 의존 관계를 통해 Task graph를 구성하고 plugin의 apply가 실행됩니다.
  • Execution Phase : 요청된 Task를 실행합니다. 이때 Task graph를 활용하여 의존하는 Tasks가 실행됩니다.

Plugin의 대부분의 코드는 Configuration Phase에서 준비하는 과정과 실제 Task 내의 Action을 정의하여 Execution Phase에서 실행되는 로직 간의 순서를 고려하여 잘 구성하는 것이 꼭 필요합니다.

Task, 내가 그래도 너는 잘 안다.

Gradle의 Task는 Gradle을 한 번이라도 사용해 보신 분들이라면 알 것이라고 생각합니다. 아래의 명령에서 build가 Task입니다.

$ ./gradlew build #build가 task

이전 gradle script에서 MortarPrepare라는 Task를 만들었고, 이번에도 동일한 이름으로 Task를 만들고자 했습니다.

이를 위해 아래와 같이 MortarPrepareTask 를 구현합니다.

abstract class MortarPrepareTask : DefaultTask() { companion object { const val NAME = "mortarPrepare" } @TaskAction fun prepare() { val relBuildPath = project.buildDir.toRelativeString(project.projectDir) project.exec { it.workingDir = project.rootDir val command = listOf( mortarPath, "prepare", "--build-dir=$relBuildPath", ) project.logger.info("execute command: $command") it.commandLine(command) } }

이 함수는 플러그인에서 registerMortarPrepareTask 함수를 호출할때 Task로 등록해 주었습니다. Task를 만드는 방법 중 register를 사용했습니다. 이를 통해 Task 객체를 Configuration Phase에 생성하는 대신 Action을 register만 해서 Execution Phase에 실제 실행될 예정인 Task들만 객체를 생성할 수 있도록 하였습니다.

private fun registerMortarPrepareTask(project: Project): TaskProvider<MortarPrepareTask> { return project.tasks.register(MortarPrepareTask.NAME, MortarPrepareTask::class.java) }

앞서 설명드린 것처럼 Gradle Task는 서로 의존성을 가지고 실행됩니다. MortarPrepare는 필요 시에 gRPC stubs class들을 생성하여 KotlinCompile 시에 제공이 되어야 합니다. 따라서 linkMortarToKotlinTask로 MortarPrepare Task를 compileKotlin Task 보다 앞서 실행되도록 설정합니다.

private fun linkMortarToKotlinTasks(project: Project) { project.tasks.named("compileKotlin") { it.dependsOn(MortarAddSourceTask.NAME) } }

혹시, 아까 plugin에서 이 함수를 호출할 때 조금 이상한 점을 눈치채셨나요? 이 함수는 그냥 호출된 것이 아니라 project.afterEvaluate를 이용해 실행 시점을 evaluation 이후로 옮겼습니다. 왜 이렇게 해야 하는 걸까요?

그 이유는 plugin 작성 시점에 apply 되는 순서를 결정할 수 없기 때문입니다. 우리가 의존성을 가져가고자 하는 compileKotlin Task도 org.jetbrains.kotlin.jvm 플러그인이 apply 되는 시점이 되어서야 생성이 됩니다. 만약 우리 plugin이 먼저 실행된다면, 해당 Task가 아직 없기 때문에 의존성 설정이 무시되어 버립니다. 그래서 모든 apply 및 evaluation이 끝난 시점에 의존성을 맺어줌으로써 kotlin 플러그인이 사용되었다면 해당 Task가 적재되었음을 보장할 수 있습니다. 일반적으로 build script를 만들 때에 비해 플러그인을 만들 때는 이런 점들을 더욱 세세히 생각해야 하는 점이 가장 어려웠습니다.

드디어 Task가 만들어졌습니다. 지금부터는 ./gradlew compileKotlin 을 실행하면 자동으로 mortarPrepare도 실행되는 것을 볼 수 있습니다. 그런데 이쯤 되니 욕심이 생겨서, 몇 가지 추가 설정을 받아서 Task에 반영할 수 있으면 좋겠다는 생각이 들었습니다.

예를 들어, Mortar는 아무 변화가 없으면 내부적으로 빌드를 skip 하는 기능이 있는데요. 이 기능을 끄기 위해 force 라는 이름의 boolean 변수를 받아와 \—force=true\\ 형태로 옵션을 사용하고 싶다고 가정해 보겠습니다. 이때는 어떻게 해야 할까요?

이럴 때 사용되는 것이 바로 Extension 입니다.\
<br>

Extension?

쉽게 생각하면 Gradle에서 먼저 정의한 몇 가지(Plugin, Dependencies, 등)를 제외하고 build script의 root에 등장하는 모든 것이 extension 입니다. build script에 아래와 같이 \xxx {\\ 로 시작하는 뭔가가 있다면, xxx라는 extension이 존재하는 것입니다.

... xxx { hello.set("world") } ...

Mortar plugin은 mortar라는 이름으로 설정을 제공하고자 했습니다. 그래서 아래와 같은 Extension 클래스를 생성하고

abstract class MortarExtension @Inject constructor(project: Project) { companion object { const val NAME = "mortar" } private val objects = project.objects val force: Property<Boolean> = objects.property(Boolean::class.java) }

이 클래스를 아래 apply에서 아래 함수를 실행해주면

private fun createMortarExtension(project: Project) { project.extensions.create(MortarExtension.NAME, MortarExtension::class.java, project) }

build script에서 아래처럼 extension의 값을 변경할 수 있습니다.

mortar { force.set(true) }

그런데 아직 extension의 값이 task로 전달되지는 않고 있습니다. 값을 전달하기 위해 기존의 task를 아래처럼 변경합니다.

abstract class MortarPrepareTask : DefaultTask() { companion object { const val NAME = "mortarPrepare" } @get:Input @get:Option(option = "force", description = "자료의 변경 유무와 상관없이 prepare 실행") @get:Optional abstract val force: Property<Boolean> @TaskAction fun prepare() { val relBuildPath = project.buildDir.toRelativeString(project.projectDir) project.exec { it.workingDir = project.rootDir val command = listOf( mortarPath, "prepare", "--build-dir=$relBuildPath", "--force=${force.getOrElse(false)}", ) project.logger.info("execute command: $command") it.commandLine(command) }

그리고 registerMortarPrepareTask에서 extension의 값을 task로 전달해 줍니다.

private fun registerMortarPrepareTask(project: Project): TaskProvider<MortarPrepareTask> { val extension = project.extensions.getByType(MortarExtension::class.java) return project.tasks.register(MortarPrepareTask.NAME, MortarPrepareTask::class.java) { it.force.set(extension.force) } } }

자 이제 이런 방식으로 동적 설정을 쉽게 할 수 있게 되었습니다.

SourceSets

마지막으로 생성된 코드를 Source Set에 추가하는 작업을 진행하였습니다. 이를 통해 mortar로 생성된 class들을 바로 코드에서 사용할 수 있게 됩니다.

private fun addGeneratedClassesToSourceSets(project: Project) { project.plugins.withId("org.jetbrains.kotlin.jvm"){ project.extensions.getByType(KotlinProjectExtension::class.java).sourceSets.named("main"){ it.kotlin.srcDir("${project.buildDir}/generated/kotlin") } } }

이제 생성된 코드가 컴파일 때 소스에 잘 포함되어 문제없이 컴파일이 됩니다. 설명을 위해 간단히 함수로 구현했지만, 실제 구현체에서는 개별 Tasks로 전환했습니다. Configuration Phase에서 Source Sets에 넣어두면 ktlint나 detekt가 formatting 검사에 포함시키게 된다는 문제가 있기 때문입니다.

정리하며

이 글에서는 간략한 설명을 위해 코드를 단순화하여 보여 드렸지만 실제 구현체는 이보다 훨씬 다양한 옵션과 다른 플러그인과의 상호작용을 위한 기능, 다양한 상태 체크 등을 반영해 구현했습니다. 이외에도 complex multi-module structure 대응, incremental task로 변환, build scan을 통한 성능 최적화, intelliJ IDEA의 설정을 자동으로 등록해 주는 등의 여러 가지 작업이 진행되었으며 그 결과 현재는 상용 서비스 빌드에서 활용되고 있습니다.

Gradle plugin을 만들면서 개인적으로는 Gradle의 동작과 빌드 과정에 대해 상세하게 배울 수 있는 좋을 기회였습니다. 또한 저와 같은 팀 동료 모두에게 생소했던 플러그인 개발이라는 분야에서 리뷰를 통해 적극적으로 아이디어를 주고 받으며 개선해 나갈 수 있는 소중한 경험이었습니다.

👨‍💻 오늘의집 개발팀을 더 자세히 알고 싶다면? (클릭)

오늘의집에서 당신을 찾고 있습니다!
Technical Lead & Manager, BackendTechnical Lead & Manager, FrontendTechnical Lead & Manager, GrowthTechnical Lead & Manager, AndroidSenior Software Engineer, BackendSenior Software Engineer, FrontendSenior Software Engineer, Machine LearningSenior Software Engineer, Machine Learning, XRSoftware Engineer, BackendSoftware Engineer, Backend, XRSoftware Engineer, Backend, AdsSoftware Engineer, DataSoftware Engineer, FrontendSoftware Engineer, Frontend, XRSoftware Engineer, Machine LearningQA Engineer, Automation
목록으로 돌아가기