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

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

현재까지 진행된 상태를 간략하게 정리하자면, 프로젝트의 build.gradle.ktssettings.gradle.kts 두 파일을 멀티 프로젝트가 가능하도록 설정해 두었습니다. 물론 확장자에서 볼 수 있듯이, 두 파일 모두 Kotlin DSL로 작성되었습니다. kotring-common 모듈을 만들어 프로젝트 내에 첫번째 하위 프로젝트를 두었고, kotring-common 모듈에도 역시 build.gradle.kts 파일을 두어 해당 모듈의 빌드 설정을 할 수 있도록 해두었습니다.

이번 글부터는 Java 그리고 Kotlin을 기반으로 하는 두 개의 하위 모듈을 동시에 진행할 겁니다. 완전히 동일한 API 서버를 각 언어를 이용해 만들어 보면서, 어떤 부분이 다른지, 또 Kotlin의 경우, Java의 경우와 다르다면 왜 그리고 어떻게 다른지를 살펴볼 것입니다.

Java 모듈의 의존성

먼저, Java 모듈을 위해 kotring-java 라는 디렉토리를 프로젝트 루트에 만듭니다.

$ mkdir kotring-java

우선 공통적으로 적용할 내용은 의존성입니다. 좀 더 자세히 말하자면, Gradle 플러그인이 있을 것이고, 또 kotring-common 모듈이 각각 포함될 것입니다(사실 Java 기반의 모듈의 경우 kotring-common 모듈이 딱히 필요없기는 합니다). 그 외에 프로젝트 실행 파일에 함께 포함될 의존성들이 있을 겁니다.

plugins {
    id 'java'
    id 'org.flywaydb.flyway'
    id 'org.asciidoctor.convert'
}

dependencies {
    implementation project(':kotring-common')

    annotationProcessor 'org.projectlombok:lombok'
    implementation 'org.projectlombok:lombok'

    implementation 'org.springframework.boot:spring-boot-starter-web'

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'com.querydsl:querydsl-jpa'
    annotationProcessor (
        'com.querydsl:querydsl-apt:4.2.1:jpa',
        'org.springframework.boot:spring-boot-starter-data-jpa')
    runtime 'mysql:mysql-connector-java'

    implementation 'org.springframework.boot:spring-boot-starter-security'

    testImplementation 'org.flywaydb:flyway-core'
    testImplementation 'com.h2database:h2'
}
build.gradle
Java 기반 모듈의 경우 build.gradle 파일, 즉 Groovy DSL을 사용할 겁니다. 왜냐하면 두 가지 DSL의 차이점을 함께 알아볼 것이기 때문입니다.

플러그인

먼저, Gradle 플러그인을 보면, Java 기반 프로젝트이기 때문에 java 플러그인이 선언되어 있습니다. 또, io.flywaydb.flywayorg.asciidoctor.convert 라는 플러그인도 보입니다. Flyway는 DB 스키마 관리 및 마이그레이션을 위한 도구고, AsciiDoctor는 AsciiDoc이라는 문서 포맷을 HTML 등으로 변환해주는 도구로 Spring REST Docs에서 사용합니다.

하위 모듈 의존성 추가

kotring-common 모듈을 포함시키기 위해 project DSL을 사용한 것을 알 수 있습니다. 모듈의 이름을 명시적으로 지정하지 않는 이상, 모듈의 이름은 해당 모듈의 루트 디렉토리 이름이 됩니다.

어노테이션 프로세싱

예제의 의존성에서는 annotationProcessor DSL이 세 번 사용되었는데, 한번은 Lombok 라이브러리를 위해, 다른 두 개는 QueryDSLQ-Class 생성을 위해 사용되었습니다. Q-Class란, 간단히 말해 쿼리 실행을 위해 QueryDSL이 사용하는 메타클래스를 말하는데, 이 예제에서는 어노테이션 프로세서를 이용해 생성하게 됩니다.

어노테이션 프로세싱은 Java 5부터 도입되었으며, 사용 가능한 API가 나온 것은 Java 6부터였습니다. 어노테이션을 사용하는 분류에는 컴파일러에게 정보를 전달하기 위함, 컴파일 및 디플로이 타임의 프로세싱 그리고 마지막으로 흔하게 사용되는 런타임 프로세싱이 있습니다.

그 중에서 컴파일 타임 프로세싱을 보통 어노테이션 프로세싱이라고 하며, Gradle에서는 annotationProcessor DSL를 통해 지원하고 있습니다.

어노테이션 프로세싱의 가장 쉬운 예는 Lombok 입니다. 요즘 Java 개발자라고 하면 모르는 사람이 거의 없다시피한 라이브러리로, Java 프로그래밍의 피로도를 낮춰주는 유용한 라이브러리입니다.

Lombok에서 가장 많이 사용되는 어노테이션 중에는 @getter 가 있는데, 특정 프로퍼티 또는 특정 클래스의 프로퍼티에 대한 Getter 함수를 생성하도록 지시하는 역할을 합니다. 이 때 이를 수행하는 것이 바로 어노테이션 프로세서입니다.

QueryDSL은 앞서 언급한대로 Q-Class라고 하는 메타클래스를 이용해 쿼리를 하는데 마찬가지로 어노테이션 프로세싱을 이용해 해당 클래스들을 만들어 냅니다(JPA 엔티티를 사용해 메타클래스를 만드는 경우에만 해당되며, 이와 관련한 자세한 내용은 다른 섹션에서 다루도록 하겠습니다).

annotationProcessor DSL이 적용된 의존성 중 org.springframework.boot:spring-boot-starter-data-jpa은 이미 implementation DSL로 선언되어 있지만, javax.persistence-api 의존성을 위해 추가되었습니다. Kotlin 모듈을 설명할 때 이에 대해 한번 더 논의할 기회가 있을 겁니다.

그 외의 의존성

그 외에는 Spring Boot 기반의 의존성과 querydsl-jpaimplementation 레벨로 선언되어있고, 데이터베이스 드라이버 제공을 위해 mysql-connector-javaruntime 레벨로 선언되어 있습니다. 런타임 수준의 의존성은 컴파일 타임에는 제공되지 않지만, 런타임에는 필요하기 때문에 함께 패키징 될 것입니다.

앞서 플러그인을 설명할 때 Flyway를 언급한 바가 있는데, 의존성에도 flyway-coretestImplementation 수준으로 선언되어 있습니다. Gradle 플러그인으로 Flyway를 선언한 것은 별도의 환경에 스키마를 마이그레이션하기 위한 용도이고, 의존성으로 포함시킨 이유는 테스트 실행 시 H2 데이터베이스를 in-memory 모드로 구동하여 스키마 마이그레이션을 하여 integration-test를 수행하기 위함입니다. 그렇기에 h2 의존성 또한 testImplementation 수준으로 선언되어 있습니다.

Kotlin 모듈의 의존성

Java 모듈과 마찬가지로 모듈을 위한 디렉토리를 먼저 만듭니다.

$ mkdir kotring-kotlin

다음은 해당 모듈의 플러그인 및 의존성 선언입니다(Java 모듈과 겹치는 부분은 설명을 생략하도록 하겠습니다).

plugins {
    kotlin("kapt")
    kotlin("plugin.jpa")
    id("org.flywaydb.flyway")
    id("org.asciidoctor.convert")
}

dependencies {
    implementation(project(":kotring-common"))

    implementation("org.springframework.boot:spring-boot-starter-web")

    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("com.querydsl:querydsl-jpa")
    kapt("com.querydsl:querydsl-apt:4.2.1:jpa")
    runtime("mysql:mysql-connector-java")

    implementation("org.springframework.boot:spring-boot-starter-security")

    testImplementation("org.flywaydb:flyway-core")
    testImplementation("com.h2database:h2")
}

플러그인

먼저, kapt 플러그인(정규화된 아이디는 org.jetbrains.kotlin.kapt 입니다)이 눈에 띌 겁니다. 우선 APT는 Annotation Processor Tool의 약자로, 앞서 말씀드린 어노테이션 프로세서를 활용하는 도구를 의미합니다. 즉, 어노테이션 프로세싱을 수행할 수 있도록 도움을 주는 도구를 뜻합니다.

kapt는 kotlin-apt의 약자로 Gradle 기반의 어노테이션 프로세서 도구 플러그인입니다. kapt를 활용하는 방법은 다음 섹션에서 설명하도록 하겠습니다.

plugin.jpa 플러그인(정규화된 아이디는 org.jetbrains.kotlin.plugin.jpa 입니다)은 JPA에 대한 Kotlin의 지원을 위한 것으로, no-arg 플러그인과 관련이 있습니다. 'Kotlin과 함께 JPA를 사용하는 경우 필수적이다'라고 보면 됩니다(자세한 내용은 두 플러그인의 링크를 참고하시기 바랍니다).

어노테이션 프로세싱

Kotlin 모듈에서도 어노테이션 프로세싱을 언급하는 것은 그 차이점을 설명하기 위함입니다. 앞서 Java 모듈에서는 annotationProcessor 라는 DSL를 사용해 어노테이션 프로세서를 의존성을 선언했습니다. 그리고 Gradle은 그에 따라 어노테이션 프로세싱을 수행하게 됩니다.

Kotlin을 기반으로 하는 프로젝트에서는 Lombok 라이브러리를 사용할 합리적인 이유가 없기 때문에 의존성에서 빠져있는 것을 볼 수 있습니다.

QueryDSL의 경우에는 여전히 메타클래스을 생성해야 하므로 어노테이션 프로세서를 사용해야 합니다. 이 때 우리는 kapt 라고 하는, 앞에서 추가한 org.jetbrains.kotlin.kapt 플러그인이 제공하는 DSL을 사용할 수 있습니다.

이 DSL로 querydsl-apt 의존성을 선언하면 Java 모듈에서 봤던 annotationProcessor DSL과 동일한 기능을 컴파일 타임에 수행하게 됩니다.

그런데, 한 가지 차이점이 존재합니다. 바로 spring-boot-starter-data-jpa 의존성을 annotationProcessor 수준으로 선언하고 있지 않다는 점입니다. 앞서, 이 의존성은 메타클래스 생성 시 javax.persistence-jpa 의존성을 위해 해당 수준으로 선언해야 한다고 했습니다. 하지만, kapt DSL를 사용하는 경우에는 그럴 필요가 없습니다.

사실, 저는 spring-boot-starter-data-jpa 의존성을 annotationProcessor 수준(혹은 kapt 수준)으로 선언하지 않아도 메타클래스 생성에 문제가 없는 이유를 찾아내지 못했습니다. 그 이유에 대해 알고 계신 분의 제보를 바랍니다.

플러그인의 버전

이 글의 예제를 보고 뭔가 빠진 것을 아신 분들도 계실 겁니다. 네, 바로 플러그인의 버전입니다. 1편을 보신 분이라면, '뭔가 변화가 일어났구나'를 아실 겁니다. 바로 본론으로 들어가겠습니다.

val pluginVersions = mapOf(
    "org.jetbrains.kotlin" to "1.3.31",
    "org.jetbrains.kotlin.plugin" to "1.3.31",
    "org.springframework" to "2.1.6.RELEASE",
    "io.spring" to "1.0.8.RELEASE",
    "org.flywaydb" to "5.2.4",
    "org.asciidoctor" to "1.5.9.2"
)

pluginManagement {
    repositories {
        gradlePluginPortal()
    }
    resolutionStrategy {
        eachPlugin {
            if (requested.id.namespace != null) {
                if (pluginVersions.containsKey(requested.id.namespace)) {
                    useVersion(pluginVersions[requested.id.namespace])
                }
            }
        }
    }
}
settings.gradle.kts

플러그인이 추가됨에 따라 그 버전을 관리하기가 점차 힘들어진다는 것을 느끼고 플러그인 버전을 위한 맵을 만들었습니다.

KotlinMap의 리터럴을 제공합니다. mapOf(...) 라는 함수를 사용하면 손쉽게 Map 인스턴스를 할당할 수 있으며, Pair<A, B> 의 인스턴스를 가변 인자로 받습니다. Pair<A, B> 또한 리터럴을 제공하는데 to 라고 하는 중위 연산자를 이용하면 됩니다. 즉, 위 예제의 pluginVersions 변수는 Map<String, String> 타입이며, 인자로 Pair<String, String> 타입의 인스턴스를 전달하기 위해 문자열로 된 플러그인 아이디와 역시 문자열로 된 플러그인 버전을 중위 연산자인 to 를 사용했습니다.

그리고 pluginManagement.resolutionStrategy.eachPlugin 블록에서 각각의 플러그인의 네임스페이스에 대해 별도로 처리하던 것을 pluginVersions 라고 하는 맵을 사용해 추상화하였습니다.

이제 사용할 플러그인이 늘어나면 pluginVersions 에 해당 플러그인의 네임스페이스에 대한 버전을 추가해준 뒤, 플러그인이 필요한 프로젝트의 빌드 스크립트 내에 버전 없이 선언해주면 됩니다.

querydsl-apt

이 섹션의 제목을 위와 같이 하는 것이 적절한지는 모르겠지만, 특정 의존성에 대한 버전 관리를 별도로 해야한다는 요구 사항을 일으킨 것이 querydsl-apt 이기 때문에 위와 같은 제목으로 정하게 됐습니다.

querydsl-apt의 의존성 선언을 살펴보겠습니다.

...
kapt("com.querydsl:querydsl-apt:4.2.1:jpa")
...
build.gradle.kts

어딘가 모르게 불만족스러운 부분이 있습니다. 어떤 분들은 이 선언이 이해가 가지 않으실 수도 있습니다. 우리가 보통 접하게 되는 의존성 선언은 다음과 같은 형태입니다.

implementation("group:name:version")

그리고 아시는 분도 계시겠지만, 여태껏 Spring Framework와 관련된 의존성은 버전마저 선언하지 않았습니다. 왜냐하면, org.springframework.bootio.spring.dependency-management 라는 플러그인을 사용하고 있기 때문입니다. 두 플러그인을 통해 버전을 지원받을 수 있는 의존성에 관해서는 링크를 참고해주세요.

Maven 혹은 Gradle을 다뤄보신 분이라면 위와 같은 형식에 대해 전혀 위화감을 느끼지 않을 겁니다. 그런데 querydsl-apt의 의존성 노테이션(의존성 선언을 하나의 문자열로 합친 것을 Gradle에서는 dependencyNotation이라고 합니다)은 한개가 더 있습니다. 바로 제일 뒤에 있는 jpa 입니다.

여기서 jpa 라고 하는 부분은 의존성 선언 중 식별자(classifier)라고 합니다. 혹시 잘 모르시는 분들은 Maven의 POM 레퍼런스 문서를 참고하시기 바랍니다.

com.querydsl:querydsl-apt:4.2.1com.querydsl:querydsl-apt:4.2.1:jpa 은 완전히 다른 의존성입니다. 두 의존성이 가진 클래스는 완전히 똑같습니다. 즉, 기능 면에서는 완전히 동일하지만, 다른 점이 하나 있습니다.

바로 META-INF 내의 내용입니다. 우리는 com.querydsl:querydsl-apt:4.2.1:jpa 의존성에 포함되어 있는 java.annotation.processing.Processor 라는 파일에 주목할 필요가 있습니다.

Java Annotation Processing

com.querydsl:querydsl-apt:4.2.1:jpa 의존성은 어노테이션 프로세서 툴을 위해 제공되는 의존성입니다. 사실 저는 querydsl-apt 의존성을 왜 이렇게 별개로 나누어 배포하고 있는지는 이해하고 있지 못합니다. 두 개가 나뉘고 있고 어노테이션 프로세싱을 위해서는 com.querydsl:querydsl-apt:4.2.1:jpa 을 써야 한다는 정도까지 이해하고 있습니다.

이 의존성은 어노테이션 프로세싱을 위해 com.querydsl.apt.jpa.JPAAnnotationProcessor 라고 하는 클래스를 제공하고 있습니다. 이 클래스는 javax.annotation.processing.Processor 라고 하는 인터페이스를 구현합니다(중간에 추상 클래스 몇 개가 있긴 하지만 중요한 부분은 아니라 생략했습니다).

Java의 어노테이션 프로세싱 시스템은 이 인터페이스를 구현한 클래스를 컴파일 타임에 탐색하여 어노테이션 프로세싱을 처리합니다. 그리고 그 발견 지점이 바로 java.annotation.processing.Processor 라는 파일인 것입니다. 해당 의존성에서 이 파일을 열어보면 실제로 com.querydsl.apt.jpa.JPAAnnotationProcessor 클래스가 선언되어 있습니다. javac가 컴파일 타임에 이 선언을 보고 프로세서를 실행하게 되는 것이죠.

그러므로, com.querydsl:querydsl-apt:4.2.1 의존성을 kaptannotationProcessor 로 선언해봐야 우리가 원하는 어노테이션 프로세싱은 일어나지 않습니다. 즉, com.querydsl:querydsl-apt:4.2.1:jpa 의존성을 사용해야 합니다. 이제 그 방법을 알아보도록 하겠습니다.

의존성 속성의 개별적인 선언

먼저, 의존성 속성을 개별적으로 선언하는 방법을 알아보도록 하죠.

음... 어쨌든 우리가 선언하려고 식별자를 선언해줄 수만 있으면 되는 겁니다. 지금으로써는 제가 왜 이렇게 하는지 이해하기 어려우실 수 있습니다.

많은 분들이 아시겠지만 Maven의 POM에서 의존성 노테이션은 의존성 속성 각각을 세미콜론을 구분자로 하여 하나의 문자열로 결합하도록 되어 있습니다. 하지만 속성들을 하나의 문자열이 아닌 각각 선언할 수도 있습니다.

kapt(group = "com.querydsl", name = "querydsl-apt", version = "4.2.1", classifier = "jpa")

변경된 것은 하나의 문자열 내에 결합되어 있던 속성들을 각각 따로 선언한 것 뿐입니다.

버전 생략

kapt(group = "com.querydsl", name = "querydsl-apt", classifier = "jpa")

차이점을 아시겠나요? 의존성 노테이션의 경우 각 속성의 순서를 지켜서 하나의 문자열로 결합해야 하기 때문에 식별자 또는 확장자(ext)를 선언해야 하는 경우 버전을 생략할 수가 없습니다.

제가 원한 것이 바로 생략 가능한 의존성 버전은 생략하자는 것입니다. 물론, 너무 Spring Framework에 의존하는 거 아니야? 라는 말은 들을 수 있지만, 글쎄요... Spring Framework를 사용할거라면 프레임워크가 지원해주는 기능을 최대한 이용하자는게 저의 생각입니다. 정말 특별한 경우가 아니라면 서드 파티 라이브러리도 프레임워크가 인증한 버전을 사용해서 나쁠 것은 없을 겁니다.

io.spring.dependency-management

의존성 속성을 개별적으로 선언하는 방법 말고 다른 방법도 있습니다. 버전을 선언하되 우리가 직접적으로 버전을 정하지 않는 것이죠.

앞서 io.spring.dependency-management 플러그인을 사용할 경우, Spring Framework가 인증한 서드 파티 라이브러리들의 버전을 가져올 수 있다고 했습니다. org.springframework.boot 플러그인의 버전에 따라 Spring Boot의 버전이 정해지고 그에 따라 서드 파티 라이브러리의 버전이 정해지는데, 이렇게 정해진 버전을 가져와 동적으로 연결해주면 의존성 속성을 개별적으로 선언하지 않아도 됩니다.

그렇다면, 이렇게 Spring Boot, 또는 그와 관련된 라이브러리들의 버전은 어떻게 가져올 수 있을까요?

def querydslAptVersion = dependencyManagement.managedVersions['com.querydsl:querydsl-apt']

io.spring.dependency-management 플러그인이 선언되면 dependencyManagement 확장을 사용 가능한데, 이를 통해 관리되고 있는 라이브러리의 버전을 가져올 수 있습니다.

annotationProcessor("com.querydsl:querydsl-apt:${querydslAptVersion}:jpa")

앞서 가져온 querydslAptVersion 을 querydsl-apt 의존성 노테이션에 문자열 보간을 했습니다.

알아차렸겠지만, 개별 속성으로 나누어 선언하는 방법은 Kotlin 모듈에, dependencyManagement 확장을 사용한 방법은 Java 모듈에 적용했습니다.

두 방법 모두 결과는 완전히 동일합니다. 차이점은 전자의 방식의 경우, 의존성 선언을 개별적으로 해서 의존성 노테이션을 사용한 다른 의존성 선언들과 이질감이 든다는 점과 후자의 방식의 경우, 버전을 변수에 할당하기 위해 코드 라인을 한 줄 더 쓴다는 점 뿐입니다.

이로써, 두 하위 모듈에 애플리케이션을 작성할 준비가 완료됐습니다. 다음 글에서는 Spring Boot 기반의 애플리케이션 작성을 두 버전으로 비교해가며 진행해보겠습니다.