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

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

벌써 세번째 글인데, 의도한대로 코드는 한 줄도 언급하지 않았네요. 이 시리즈는 단순히 코드를 작성하는 것 뿐만 아니라 관련된 모든 내용들을 훑고 지나가는 것이 목적입니다. 그만큼 느릴 수 밖에 없으니 인내심이 필요합니다.

Shot at the New Jersey Institute of Technology
Photo by Mathew Schwartz / Unsplash

Multi Projects

앞선 글들에서 말한대로 멀티 프로젝트를 구성할 계획입니다. 실전에서는 싱글 프로젝트보다는 멀티 프로젝트인 경우가 더 많고, 멀티 프로젝트를 구성하는 방법에 익숙해지면 싱글 프로젝트를 구성하는 것은 일도 아니기 때문입니다.

우선 프로젝트 루트 디렉토리에 공용 하위 프로젝트를 위한 디렉토리를 하나 만듭니다.

$ mkdir kotring-common
만약 IntelliJ를 사용하는 경우, 프로젝트 루트에서 New > Module을 통해서 하위 프로젝트를 만들 수도 있을 겁니다. 하지만, 여기서는 자세한 설명을 위해 의도적으로 원초적인 방법대로 진행하려고 합니다.

하위 프로젝트 디렉토리 내에 build.gradle.kts 파일을 만들어줍니다. Kotlin은 멀티 프로젝트 내 하위 프로젝트에서도 build.gradle.kts 파일을 사용합니다.

dependencies {
    implementation("org.slf4j:slf4j-api")
}
build.gradle.kts

공용 프로젝트에서 사용할 의존성은 이게 전부입니다. 사실, Kotlin과 Java로 구현한 완전히 동일한 사양의 애플리케이션을 만들면서 둘을 비교하는 것이 목적이기 때문에 공용 프로젝트가 필요없지만, 공용 프로젝트를 만드는 방법을 설명하기 위한 것입니다.

하위 프로젝트를 위한 디렉토리도 만들었고, build.gradle.kts 파일도 만들었지만 아직 이 프로젝트가 하위 프로젝트라는 것을 선언한 바가 없습니다. 하위 프로젝트 선언은 플러그인 버전을 설정하기 위해 pluginManagement DSL을 구성했던 settings.gradle.kts 파일을 이용해야 합니다.

include(":kotring-common")
settings.gradle.kts

include DSL은 문자열 가변 인자(varargs)를 받는 메서드로 한 개 이상의 하위 프로젝트를 선언할 수 도와줍니다.

프로젝트 이름 앞에 있는 : 은 경로 구분자(path-separator)입니다(프로젝트 루트 디렉토리 바로 하위 디렉토리에 대해서는 생략 가능합니다). 예제의 :kotring-common 은 사실 $rootDir:kotring-common 과 같습니다. 만약 루트 디렉토리 하위에 a/b와 같은 디렉토리가 있고 b 디렉토리 바로 아래에 하위 프로젝트의 build.gradle.kts 파일이 존재한다면 하위 프로젝트 선언을 위한 경로는 :a:b 또는 a:b 가 됩니다.

여기까지 설명한 내용이 멀티 프로젝트를 구성하는데 필요한 최소한의 설정입니다. 여기서 다룬 내용을 토대로 다음 글에서 본격적으로 각각 Kotlin과 Java를 기반으로 하는 애플리케이션을 구현하면서 어떤 점이 다른지, 유의할 사항은 무엇인지를 알아보도록 하겠습니다. 그전에 공용 의존성으로 사용할 것들을 몇 가지 만들 것입니다.

Finally, Kotlin!

이 시리즈를 작성하면서 가정한 한 가지는 독자가 Java에 어느 정도 익숙하다는 것입니다. 그러므로 Java에 대한 설명은 자세히 하지 않고 필요한 경우에만 곁들이는 정도가 될 것입니다.

kotring-common 하위 프로젝트에 src/main/kotlin 디렉토리를 만듭니다.

이제 Kotlin 언어로 두 가지 개체를 만들 겁니다. 드디어 코드가 등장합니다.

package devcken.kotring.collection

import java.util.*
import java.util.concurrent.ConcurrentHashMap

class Sets {
    companion object {
        fun <E> ConcurrentSet(): MutableSet<E> = Collections.newSetFromMap(ConcurrentHashMap<E, Boolean>())
    }
}
devcken.kotring.collection.Sets.kt

사실 ConcurrentSet() 을 쓸 일이 있을지는 모르겠으나, 스레드에 안전한 Set 을 Kotlin으로 구현하는 방법을 예로 들기 위한 것입니다. 사실 이 코드는 GuavanewConcurrentHashSet() 과 완전히 동일한 Kotlin 코드입니다.

package devcken.kotring.logger

import org.slf4j.Logger
import org.slf4j.LoggerFactory

/**
 * Get a [org.slf4j.Logger] for current class lazily.
 *
 * Usage:
 * ```
 * val logger by logger()
 * ```
 *
 * @sample `val logger by logger()`
 */
fun <T: Any> T.logger(): Lazy<Logger> = lazy { LoggerFactory.getLogger(this.javaClass) }
devcken.kotring.logger.Logger.kt

앞서 org.slf4j:slf4j-api 의존성을 추가한 것을 기억하고 있을 겁니다. 해당 의존성은 바로 이 코드를 위해 추가된 것으로, Kotlin으로 작성할 코드에서 org.slf4j.Logger 의 인스턴스를 초기화하기 위한 함수입니다.

여기서 눈 여겨 볼 부분은 확장과 lazy { ... } 블록입니다. 이에 대해서는 다음 섹션에서 설명하도록 하겠습니다.

Extensions

Kotlin은 클래스 계층의 루트로 Any 를 두고 있습니다. 즉, Kotlin의 모든 클래스는 Any 를 슈퍼 클래스로 두고 있습니다. 이는 Java의 Object 와 동일한 역할을 합니다.

위 예제의 logger() 함수는 Any 를 확장한 타입 파라메터 T 를 선언하고 있습니다. 아마 이 부분은 Java(혹은 이와 유사한 기능을 제공하는 언어)를 알고 있다면 충분히 이해할 수 있는 내용이라고 생각됩니다.

Kotlin의 저네릭이나 타입 파라메터 등에 대해서 다룰 수 있는 기회가 있으리라 생각됩니다.

Kotlin은 함수나 프로퍼티를 확장할 수 있는 기능을 제공하는데, 여기서 설명하고자 하는 것은 어떤 클래스에 대한 함수 확장입니다. 이 기능은 매우 편리한데, 사실 약간의 눈속임에 불과합니다.

public final class LoggerKt {
   @NotNull
   public static final Lazy logger(@NotNull final Object $this$logger) {
      Intrinsics.checkParameterIsNotNull($this$logger, "$this$logger");
      return LazyKt.lazy((Function0)(new Function0() {
         // $FF: synthetic method
         // $FF: bridge method
         public Object invoke() {
            return this.invoke();
         }

         public final Logger invoke() {
            return LoggerFactory.getLogger($this$logger.getClass());
         }
      }));
   }
}

위 코드는 확장 함수인 logger() 함수의 바이트코드를 디컴파일한 것입니다. 요점만 말하자면, 확장 함수의 실체는 static 메서드에 불과합니다. Kotlin 컴파일러가 확장 함수를 static 메서드로 변경한 뒤 실행 시에는 해당 메서드를 실행해주는 것입니다. 즉, 대상 클래스(예제의 타입 파라메터 A)는 변경되지 않습니다.

실제로 대상 클래스를 변경하는 방법(만약 존재한다면)을 사용할 경우 이와 같이 타입 파라메터를 대상으로 함수 확장을 하지는 못할 겁니다. 대상이 누구인지 알 수 없기 때문이죠(디컴파일된 코드를 보면 $this$logger 의 타입이 Object 인 것을 볼 수 있습니다).

logger() 확장 함수 덕분에 Kotlin 클래스 어디에서든 org.slf4j.Logger 의 인스턴스를 간결하고 손쉽게 불러올 수 있습니다.

Lazy

logger() 확장 함수는 fun <A: Any> A.logger(): Logger = LoggerFactory.getLogger(this.javaClass) 이 될 수도 있습니다. 그러면 val logger = logger() 이런 식으로 사용 가능하겠죠?

그런데 Logger 의 특성을 보면... 사실 Logger 의 특성이라기 보다는 사람의 특성(?)인데, 선언해놓고 사용하지 않을 수도 있다는 것입니다. 또는, 빈번하게 실행되지 않고 애플리케이션 시작 이후에 오랜 시간에 걸쳐 한 번 정도만 실행될 수도 있습니다. 그런 경우, 당장 (혹은 영영) 사용되지 않는 객체가 너무 빨리 초기화되어 리소스를 점유하게 되는 것이죠.

이를 위해 Kotlin은 초기화 지연(lazy initialization)이라는 방법을 제공하고 있습니다. lazy 라는 이름에서 봤을 때 그 동작에 대해 대부분의 개발자들은 쉽게 유추하리라 생각됩니다. 특정 프로퍼티에 대해 초기화 지연을 적용할 경우, 해당 프로퍼티가 실제로 사용되기 전까지 초기화를 미루는 것입니다. 즉, 실제로 사용되기 전까지는 프로퍼티의 인스턴스가 생성되지도, 할당되지도 않습니다.

Kotlin은 lazy 함수에 대한 세 가지 시그니처를 제공합니다.

fun <T> lazy(initializer: () -> T): Lazy<T>
fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T>
fun <T> lazy(lock: Any?, initializer: () -> T): Lazy<T>

세 가지 시그니처 모두 initializer 라고 하는 인자를 받습니다. 우리의 예제에서는 { LoggerFactory.getLogger(this.javaClass) } 가 될 것입니다(중괄호를 포함하는 이유는 initializer 인자의 타입을 보면 쉽게 이해가 될 겁니다). 이 인자가 바로 초기화 지연의 대상이 되는 것입니다.

두번째 시그니처의 첫번째 인자는 LazyThreadSafetyMode 타입인데, SYNCHRONIZED, PUBLICATION, NONE 이라는 열거자 중 하나를 선택 가능합니다.

만약 첫번째 시그니처를 사용할 경우, SYNCHRONIZED 와 동일하게 SynchronizedLazyImpl 클래스의 인스턴스가 초기화됩니다. 그 이름에서 알 수 있듯이 synchronized 키워드를 통해 잠금을 걸고 프로퍼티 값을 초기화하게 됩니다.

여기서 초기화한다고 했지만, 사실 프로퍼티의 값은 UNINITIALIZED_VALUE 라고 하는 객체로 이미 초기화되어 있습니다. 이 객체는 JVM 환경이 만들어지는 순간 만들어지는 객체라서 프로퍼티 값의 실제 초기화에 있어 객체가 만들어지는 부담이 없다는 것이 핵심입니다.

PUBLICATION 의 경우, 잠금을 걸지 않습니다. 모든 스레드로부터의 동시적인 접근을 허용하는데, 대신 가장 처음에 반환된 값이 이후의 모든 접근에 대한 반환값으로 사용됩니다. 이 방법은 java.util.concurrent.atomic.AtomicReferenceFieldUpdater#compareAndSet(...) 함수를 이용해 동시적인 접근에 대해서도 스레드에 안전한 접근을 허용합니다.

NONE 은 스레드 안전성을 제공하지 않는 것을 의미합니다. 만약 해당 프로퍼티를 여러 스레드가 동시에 접근할 가능성이 있다면 절대 사용해서는 안됩니다.

마지막 세번째 시그니처는 SYNCHRONIZED 와 동일하게 동작하는데, 대신 잠금의 대상을 인자(lock)로 받습니다. 첫번째 시그니처의 경우, lock 인자가 null이 되는데, SynchronizedLazyImpl 클래스의 인스턴스가 만들어질 때 해당 인스턴스가 lock 인자에 할당되게 됩니다. 즉, 초기화 지연을 위해 SynchronizedLazyImpl 클래스의 인스턴스에 잠금을 걸게 됩니다.

이번 글에서는 간단하게 멀티 프로젝트를 구성하는 방법 일부를 알아봤습니다. 그리고 드디어 Kotlin으로 코드를 작성해보면서 함수 확장과 초기화 지연에 대해서도 살펴보았습니다. 다음 글에서는 Kotlin과 함께 Spring Framework를 사용하는 방법에 대해서 알아보도록 하겠습니다.