해당 연재에서는, Build Tool로  Gradle, Framework는 Spring, Language는 Kotlin을 사용해 Application을 구현하는 과정을 살펴보면서, Kotlin을 사용하면서 어떠한 점이 좋은지, 어떠한 점이 불편한지, 유의할 점은 무엇인지를 공유하려고 합니다.

반드시 Kotlin에 대해서만 다루지 않고 구현 과정에서 필요한 모든 사항과 고민들을 함께 나누려 하니 어떤 의견이든 댓글로 남겨주시기 바랍니다.

이전 글에서 Gradle의 Kotlin DSL을 이용해 플러그인을 설정하고 플러그인의 버전을 설정하는 방법에 대해서 논의해보았습니다. 이번 글에서는 선언된 플러그인을 적용하고 의존성을 선언하는 방법에 대해 논의해보겠습니다.

Project

Gradle 설정 중 상당 수는 Project에 관한 것입니다.  Task에 대한 설정 또한 많지만, Project 자체가 Task의 컬렉션이고, 대부분의 경우 Task는 Project에 한정되기 때문입니다.

Gradle은 이러한 프로젝트를 몇 가지 단위로 나누고 있습니다.

rootProject

rootProject는 가장 기본적인 프로젝트입니다. 프로젝트 내에 단일 모듈만 사용된다면, rootProject만 사용하게 됩니다. 여기서 rootProject는 다루지 않을 예정입니다.

allprojects

allprojects는 루트 프로젝트는 물론 하위 프로젝트들에도 적용하고 싶은 내용을 설정할 때 사용합니다. 즉, 프로젝트 내에 루트 프로젝트가 없다면 딱히 사용할 필요가 없습니다.

예를 들어, 모든 프로젝트에서 JCenter를 의존성 저장소로 사용하고자 한다면 다음과 같이 설정합니다.

allprojects {
    repositories {
        jcenter()
    }
}
build.gradle.kts

subprojects

subprojects는 루트 프로젝트를 제외한 나머지 프로젝트들에 적용하고 싶은 내용을 설정할 때 사용합니다. 프로젝트를 멀티 프로젝트로 만들기 위해서 이 단위를 활용할 것입니다.

이전 글에서 플러그인으로 선언한 org.springframework.bootio.spring.dependency-management는 아마도(?) 모든 하위 프로젝트에서 사용하게 될 것입니다. 그러므로 플러그인을 모든 프로젝트에 개별적으로 적용하는 것보다는 subprojects DSL을 이용해 적용하는 것이 합리적이라고 생각됩니다. 그리고 마침 Gradle은 그러한 방법을 제공하고 있습니다.

subprojects {
    ...
    
    apply(plugin = "org.springframework.boot")
    apply(plugin = "io.spring.dependency-management")

    ...
}
build.gradle.kts

Project 인터페이스는 PluginAware라는 인터페이스를 상속합니다. 이 PluginAware 인터페이스가 apply 라는 메서드를 선언하고 있습니다. 이 메서드를 이용해 앞서 선언했던 플러그인들을 프로젝트에 적용할 수 있습니다.

Kotlin DSL에서는 Kotlin의 확장 기능을 사용해 PluginAware.apply 메서드를 구현하고 있습니다.

자, 이제 우리는 모든 하위 프로젝트에 Spring Boot와 관련된 의존성을 선언할 준비가 되었습니다.

Kotlin

의존성을 선언하기에 앞서 Kotlin이라는 언어에 대해서 상기하고자 합니다. 이 시리즈의 예제에서, 기반 언어로써 Kotlin을 사용하려고 한다는 것을 이 시리즈의 제목에서 알 수 있을 겁니다.

Gradle은 Kotlin이라는 언어를 지원하기 위해 몇 가지 플러그인을 제공합니다. 그런데, 왜 플러그인이 필요한 것일까요? 잘 생각해보면, Java는 그런 것 없이도 잘 됐는데 말이죠. 물론, Gradle이 Java를 주요 플랫폼으로 하고 있기 때문에 플러그인 같은 것은 필요없다는 것을 대부분 아실겁니다.

약간의 힌트를 얻기 위해 Groovy를 등장시켜 보겠습니다. Groovy는 JVM 기반의 언어이기 때문에 친숙한 듯 하면서도 실제로 써본 사람이 그리 많지 않은 언어이기도 합니다. 물론 Gradle DSL을 통해 접하기 때문에 Groovy를 써봤다고 할 수도 있긴 하지만, DSL은 DSL이기 때문에 그런 경우를 제외한다면... 요즘 사용이 늘어난 Spock Framework(이하 Spock)을 프로젝트에 사용하는 경우가 대부분일 겁니다.

Gradle을 빌드 툴로 사용해 프로젝트의 주요 언어 혹은 테스트 코드를 위한 언어로 사용하려면 Gradle에서 제공하는 Groovy 플러그인을 사용하는 것이 가장 간결하면서 빠르고 정석적인 방법입니다.

Kotlin의 경우도 이와 같은 맥락이라고 이해하면 될 것 같습니다. 소스셋(source sets)에 관한 사전 정의나 빌드 관리 등을 사용자가 직접해야 하는 번거로움을 덜어주기 위해 제공되는 것입니다.

자, 그러면 Kotlin 플러그인이 필요한 이유를 이해했으니 플러그인을 선언하고 하위 프로젝트 설정에 적용해보죠.

...

plugins {
    id("org.jetbrains.kotlin.jvm")
    id("org.jetbrains.kotlin.plugin.spring")
    id("org.springframework.boot")
    id("io.spring.dependency-management")
    groovy
}

...
build.gradle.kts

위에 선언된 플러그인이 하위 프로젝트들에 공통적으로 적용될 플러그인들입니다. 제일 첫번째 플러그인은 org.jetbrains.kotlin.jvm 이라는 ID를 가지고 있는데 이 플러그인 바로 Gradle의 Kotlin 플러그인입니다.

따로 언급하지는 않았지만, 이전 글에서 pluginManagement DSL을 이용해 플러그인 버전을 정의할 때 네임스페이스가 org.jetbrains.kotlin로 시작하는 플러그인들에 대해 버전을 정의한 바가 있습니다.

이 플러그인 덕분에 Gradle을 통해 Kotlin 언어로 작성된 코드를 손쉽게 컴파일할 수 있습니다.

다음으로는, org.jetbrains.kotlin으로 시작하는 또 다른 플러그인인 org.jetbrains.kotlin.plugin.spring이 있습니다. 이 플러그인은 Kotlin을 기반으로 Spring Framework를 이용할 때 필요한 Task 등을 지원합니다.

다섯번째 플러그인은 조금은 다른 모양새를 가지고 있는데, Gradle이 제공하는 사전 정의되어 제공되는 플러그인에 대한 선언입니다. 즉, groovy 라고 하는 사전 정의된 플러그인 DSL을 사용하면 앞서 id DSL을 통해 플러그인을 선언했던 방식보다는 조금 더 편리하게 선언할 수 있습니다.

Groovy와 마찬가지로 사전에 정의된 Kotlin 플러그인 DSL을 제공합니다.

...

plugins {
    kotlin("jvm")
    kotlin("plugin.spring")
    id("org.springframework.boot")
    id("io.spring.dependency-management")
    groovy
}

...
build.gradle.kts

Groovy 플러그인 DSL과 조금 다른 점이라면 모듈을 인자로 받는다는 것입니다. 여기서는 jvmplugin.spring 두 가지 모듈을 선언한 것입니다.

참고로 Kotlin은 JVM과 Native를 함께 지원합니다. Native의 경우 kotlin("multiplatform") 로 모듈을 선언해야 합니다. 자세한 내용은 A Basic Kotlin/Native Application을 참고하시기 바랍니다.

이제 새롭게 선언한 플러그인을 하위 프로젝트에 적용해보겠습니다.

subprojects {
    ...

    apply(plugin = "kotlin")
    apply(plugin = "org.jetbrains.kotlin.plugin.spring")
    apply(plugin = "org.springframework.boot")
    apply(plugin = "io.spring.dependency-management")
    apply(plugin = "groovy")
    
    ...
}

이제 모든 하위 프로젝트에서 해당 플러그인들을 사용할 수 있습니다.

Dependencies

의존성은 한 프로젝트 내의 하위 프로젝트 간에 공용되는 의존성도 존재하며, 특정 프로젝트에만 국한되는 의존성도 존재합니다. 이번 섹션에서는 subprojects DSL을 이용해 공용 의존성을 설정해보도록 하겠습니다.

이 시리즈를 구상하면서 세운 한 가지 목표는 Gradle을 이용해 언어로는 Kotlin을 사용하고 Spring Boot를 기반으로 한 애플리케이션을 구현하는 것이었습니다. 또 다른 한 가지 목표는, Kotlin을 사용해 만든 애플리케이션과 완전히 동일한, Java 언어를 사용한 애플리케이션을 만들면서 서로 어떤 차이점을 갖는지를 비교해보는 것이었습니다.

(아직은 무엇이 될지는 모르지만) 두 프로젝트에서 사용될 공통 모듈을 하나 둘 예정이고, 앞에서 말한대로 각각 Kotlin과 Java로 작성한 애플리케이션 모듈을 둘 예정입니다. 또, 두 애플리케이션의 테스트 코드는 모두 Spock을 사용해 작성할 계획입니다.

갑자기 프로젝트의 구성을 거론하는 이유는 하위 프로젝트에서 사용할 공용 의존성을 생각해야 하기 때문입니다.

subprojects {
    ...
    
    val spockVersion = "1.3-groovy-2.5"

    dependencies {
        implementation(kotlin("stdlib-jdk8"))
        implementation(kotlin("reflect"))

        implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

        testImplementation("org.spockframework:spock-core:$spockVersion")
        testImplementation("org.spockframework:spock-spring:$spockVersion")
        testRuntime("org.codehaus.groovy:groovy")

        testImplementation("org.springframework.boot:spring-boot-starter-test")
        testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
    }
    
    ...
}
build.gradle.kts
Gradle 3.x 이전의 Gradle 사용하신 분은 implementation DSL이 생소하실 수도 있습니다. 간략하게 설명하자면, 기존에 사용해왔던 compile DSL은 deprecated 되었고(5.x가 되면서 완전히 제거될 줄 알았으나 이 글을 쓰는 시점의 가장 최근 버전은 5.5 버전에서도 사용은 가능한 것 같습니다), api DSL과 implementation DSL로 나뉘어졌습니다. 두 DSL에 따라 의존성의 노출 수준이 달라지게 됩니다. 자세한 내용은 Using dependency configurations 를 참고하시면 도움이 되리라 생각됩니다.

앞서 우리는 몇 가지 플러그인을 선언하여 적용했습니다. 그 중 하나가 Kotlin을 위한 것이고, 덕분에 kotlin DSL을 사용할 수 있습니다.

  • implementation(kotlin("stdlib-jdk8")) : Kotlin을 사용하려는 경우, Kotlin 표준 라이브러리를 필요로 합니다. 예제에서는 그 중에서 JDK 8을 기반으로 하는 라이브러리를 선언하고 있습니다.
  • implementation(kotlin("reflect")) : Kotlin은 기본 표준 라이브러리에서 Reflection에 대한 아티팩트를 별도로 분리했습니다. 그 이유를 간단히 설명하자면, 초기의 Kotlin이 Android를 대응하는 언어로 설계되었다는 것은 널리 알려진 사실입니다. Android라는 환경이 전통적인 서버 환경과는 달리 메모리는 물론, 스토리지 용량 등의 제약이 상당적으로 크다는 것은 너무나도 당연한 사실입니다. Reflection이라는 기능은 환경 리소스를 많이 점유할 수 밖에 없고, 이러한 기능이 Android 환경에서는 필요없는 경우가 많은 것 같습니다.

    스토리지 용량 면에서 특히 그러한데, Reflection을 사용하지도 않는 Android 앱이라면 Reflection 기능 부분이 함께 배포되지 않아도 될 것입니다. 그래서 Kotlin의 기본 표준 라이브러리에서는 Reflection 기능을 별도의 아티팩트로 분리해 배포 용량을 줄인 것입니다.

    하지만, 예제에서 사용할 Spring Framework 5(Spring Boot 2.x)가 Reflection을 필요로 하기에 의존성으로 추가했습니다.
  • implementation("com.fasterxml.jackson.module:jackson-module-kotlin") : FasterXML에서는 Kotlin을 위한 Jackson 모듈을 제공하고 있습니다. 이 모듈은 Kotlin 클래스 및 데이터 클래스의 직렬화 및 역직렬화에 대한 지원 모듈인데, Spring Framework에서는 잘 알려진(well-known) 모듈로 지정하여 classpath 내에 해당 모듈이 존재한다면 등록하도록 되어 있습니다(Jackson2ObjectMapperBuilder#registerWellKnownModulesIfAvailable 을 참고하세요).
  • testImplementation("org.spockframework:spock-*:$spockVersion") : 앞서 예제에서 테스트 코드를 위해 Spock을 사용할 것을 얘기한 바 있습니다. 해당 프레임워크 의존성을 테스트 스코프에 추가하기 위해 testImplementation DSL을 사용하고 있습니다.

    참고로 Spock 의존성은 Spring Boot에서 지원하지 않는 의존성입니다(Spring Boot의 Dependency Version 문서를 참고하세요). 그러므로 버전을 따로 명기해줘야 합니다. 예제에서는 Spock의 버전을 변수로 선언하고 해당 변수를 String Interpolation으로 지정해주었습니다.
  • testRuntime("org.codehaus.groovy:groovy") : Spock은 Groovy를 기반 언어로 하는 테스트 프레임워크입니다. Groovy로 작성된 테스트 코드를 런타임에 실행할 수 있도록 하려면 Groovy 표준 라이브러리가 필요합니다.
  • testImplementation("org.springframework.boot:spring-boot-starter-test") : Spring Framework를 사용해 애플리케이션을 작성할 예정이므로 Spring Framework의 기반 안에서 테스트를 진행해야 하는 경우가 많습니다. 그런 경우를 위해 필요한 의존성입니다.
  • testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc") : Spring REST Docs은 Spring MVC를 토대로 작성된 RESTful 서비스에 대한 문서화 도구입니다. 보통 Swagger의 대안으로 많이 거론되곤 합니다. 이 의존성을 사용하면 RESTful 서비스에 대한 테스트와 문서화를 동시에 처리할 수 있습니다.

Tasks

2.x 버전의 Spring Boot과 Kotlin을 사용하게 되면서 사전에 제공되는 Task에 약간의 수정이 필요합니다.

JSR 305

subprojects {
    ...
    
    tasks {
        withType<KotlinCompile> {
            kotlinOptions {
                freeCompilerArgs = listOf("-Xjsr305=strict")
                jvmTarget = "1.8"
            }
        }
    }

    ...
}
build.gradle.kts

Kotlin 코드를 컴파일할 때 compileKotlin 이라는 Task가 실행되는데, 이 때 컴파일러가 사용할 인자와 JVM 버전을 명시해줄 수 있습니다. 아시다시피, Kotlin은 null-safety를 제공하는 언어지만, Java는 그렇지 못합니다. 그래서, Java 언어를 기반으로 하는 Spring Framework는 자체적으로 null-safety를 제공하기 위해 도구 친화적인(tooling-friendly) 애노테이션을 제공합니다. Kotlin 언어 기반에서 Spring이 제공하는 null-safety를 함께 적용받으려면 -Xjsr305 컴파일 옵션을 strict 로 설정해야 합니다(현재 strict 옵션 값은 실험 상태로 제공되고 있습니다. 자세한 내용은 Spring Kotlin Support 문서 중 Null-safety를 참고하시기 바랍니다. 또, 이와 관련한 Spring Initializr 이슈도 참고하시기 바랍니다).

Packaging

Spring Boot 2.x부터 packaging과 관련된 Task에 변화가 있었습니다. 원래 패키징과 관련된 Task는 bootRepacking 이라는 Task 였는데, 2.x 부터는 bootJarbootWar 로 나뉘게 되었습니다. 그리고 Gradle이 기본적으로 제공하는 jarwar 는 비활성화됩니다.

만약, jar Task가 반드시 필요하다면, 이를 명시적으로 활성화해야 합니다.

tasks {
    ...

    withType<Jar> {
        enabled = true
    }
    
    ...
    
}
build.gradle.kts

그런데, jar Task가 활성화될 경우, bootJar 가 만들어내는 jar 파일과 jar 가 만들어내는 jar 파일이 겹치게 됩니다. 그러면, 나중에 실행되는 Task의 jar 파일이 앞서 만들어지는 jar 파일을 덮어쓰게 되므로 이를 오동작으로 오인할 수도 있습니다.

이를 방지하려면, bootJar 설정에 아카이브 식별자를 추가해줘야 합니다.

tasks {
    ...

    withType<BootJar> {
        archiveClassifier.set("boot")
    }

    withType<Jar> {
        enabled = true
    }
    
    ...
}

위와 같이 수정한 뒤, buildbootJar Task를 실행하면, <project_name>.jar 파일과 <project_name>-boot.jar 파일이 패키징됩니다.

Gradle을 이용한 패키징과 관련된 자세한 내용은 Packaging executable archives를 참고하시기 바랍니다.