[kotlin] DSL로 코드를 짧게 만들기

개요

코틀린(kotlin) 언어는 null 처리에 대한 정적 안정성 확보시켜 주는 것외에도 코드를 짧게 관리할 수 있도록 다양한 방법을 제시해 줍니다. 그 중에 코틀린 만의 DSL(Domain-Specific Language)로 Java에서는 생각할 수 없었던 언어적 유용성이 있습니다. 이 글을 통해 그 유용성을을 조금 맛 보는데 도움이 되길 바랍니다.

계획하기

HikariDataSource라는 DB Connection Pool 관리를 해주는 클래스가 있습니다. javax.sql.DataSource의 구현체입니다. Spring boot 2.0부터 기본 JDBC connection pool로 지정되었습니다. 빠르고, 가볍고 신뢰할 수 있어서 많이들 사용한다고 합니다.

이 글에서는 HikariDataSource의 속성 설정을 도와주는 클래스를 만드려고 합니다. 물론 제시하는 예시는 코틀린 DSL를 가능케 하는 리시버(receiver)를 어떻게 활용할 수 있나 쉽게 알려주는데 중점을 두기 때문에 실무적으로 세심하고 유용하게 쓰시려면 더욱 발전시켜야 합니다.

이제 몇 단계를 거쳐서 코틀린스럽게 코드를 정리해 나가는 과정을 보여드리겠습니다.

단계 1 : 초기 코드

아래 코드는 목표를 달성하기 위해 코틀린으로 작성된 첫번째 단계 클래스입니다(예상대로 엉망일거예요~ ^^)


class HikariHelper_1(vararg args: Pair<String,Any>){
    val src = HikariDataSource()?.also {src->
        args.forEach { a->
            val k = a.first
            val v = a.second
            when(k){
            "driverClassName"->
                src.driverClassName =
                    if(v is String) v
                    else throw Throwable("jdbcUrl은 문자열이어야 합니다.")
            "jdbcUrl"->
                src.jdbcUrl =
                    if(v is String) v
                    else throw Throwable("jdbcUrl은 문자열이어야 합니다.")
            "username"->
                src.username =
                    if(v is String) v
                    else throw Throwable("username은 문자열이어야 합니다.")
            "password"->
                src.password =
                    if(v is String) v
                    else throw Throwable("password은 문자열이어야 합니다.")
            "connectionTimeout"->
                src.connectionTimeout =
                    if(v is Long) v
                    else throw Throwable("connectionTimeout은 Long이어야 합니다.")
            "isReadOnly"->
                src.isReadOnly =
                    if(v is Boolean) v
                    else throw Throwable("isReadOnly는 Boolean이어야 합니다.")
            "connectionTestQuery"->
                src.connectionTestQuery =
                    if(v is String) v
                    else throw Throwable("connectionTestQuery은 문자열이어야 합니다.")
            "minimumIdle"->
                src.minimumIdle =
                    if(v is Int) v
                    else throw Throwable("minimumIdle은 Int이어야 합니다.")
            "maximumPoolSize"->
                src.maximumPoolSize =
                    if(v is Int) v
                    else throw Throwable("maximumPoolSize는 Int이어야 합니다.")
            else->throw Throwable("허용하지 않는 키 ${k}입니다.")
            }
        }
    }


   // ... (내부에 다양한 구현을 하겠죠!) 
}

생성자는 Pair<String,Any>를 rest 인자로 받습니다. 그리고 HikariDataSource 객체를 만들어 인자 값들을 forEach로 반복하며 when 조건 분기를 통해 HikariDataSource의 속성값을 설정합니다.

참고로 이 클래스는 내부 변수인 src에 HikariDataSource 객체 속성값을 설정하고 할당만합니다. 여러분은 더욱 기능을 확장해 트랜젝션 매니저나 템플릿 매니저 등을 내장하고 SQL을 실행하기 위한 다양한 함수를 추가할 수 있습니다. 다만, 이 글에서는 주제에 벗어나서 생략되어 있다고 보시면 됩니다.

아래 코드는 위에서 만든 클래스를 사용하는 호스트 코드입니다. 키와 값을 담은 Pair<String,Any>쌍을 인자로 주고 있습니다.

val h1= HikariHelper_1(
  "driverClassName" to "com.mysql.cj.jdbc.Driver",
  "jdbcUrl" to "jdbc:mysql://myexample.com:3306/mydb",
  "username" to "jidolstar",
  "password" to "mypassword",
  "connectionTimeout" to 30000L,
  "isReadOnly" to false,
  "connectionTestQuery" to "select 1 from dual",
  "minimumIdle" to 5,
  "maximumPoolSize" to 5
)

이 코드는 딱 봐도 문제가 많습니다. 어떤 문제가 있을까요? 컴파일 언어 임에도 너무 동적으로 쓴게 문제입니다. 조금 더 자세히 문제점을 인식해 보겠습니다.

  1. 키(Key)
    • 키가 그냥 텍스트 : 키의 문자열이 오타나더라도 컴파일시 오류를 감지할 수 없습니다.
    • 어떤 키를 쓰는지 알 수 없음 : 이 클래스의 인자에 키를 알 수 있는 방법이 없습니다. 문서를 보거나 소스를 뒤져보지 않는 이상요.
    • 키에 대한 조건 분기 : 키가 추가되면 when 식에 분기 코드를 계속 작성해야 합니다. 관리가 너무 힘들죠. (참고로 when “식”이라고 하는 이유는 코틀린에서 when은 “값”을 반환해주기 때문입니다. java는 switch “문”이죠. “값”을 반환하지 않기 때문입니다)
  2. 값(value)
    • 값의 형(type)을 알 수 없음 : 값의 형을 알 수 없기 때문에 클래스 내부에서 분기하고 있습니다. 잘못된 형이 입력된 경우, 런타임 에러가 나겠지만, 코딩시나 컴파일시 제대로 된 형을 알 길이 없습니다.
    • 키/쌍이 함께 있음을 강제 : 인자가 항상 키/값 쌍을 강제합니다. 때로는 키가 값을 내포하는 경우도 있을 수 있습니다. 가령, 키 “driverMySQL”가 있다고 생각하고 만들었다면 값은 “com.mysql.cj.jdbc.Driver”를 내포하는 것이므로 호스트 코드에서 인자값으로 driverMySQL to null 처럼 줄 수 밖에 없겠죠. 하지만 뒤에 값인 null은 아무 의미없습니다.

이것은 오히려 클래스를 만들어서 사용성을 더욱 나쁘게 만들었습니다. 이런 클래스를 만드는 주요 목적은 보통 단편적인 속성/값만 주는 인터페이스를 넘어 풍부한 레시피를 제공해 주기 위함인데, 사용할 속성 조차 찾기 어려운 구조입니다.

이를 단계별로 개선해 보겠습니다.

단계 2 : 정적 String으로 키를 확정하기

키를 문자열로 입력하지 않고, 코드 힌트가 가능하도록 바꿔보겠습니다.


class HikariHelper_2(vararg args: Pair<String,Any>){
    companion object {
        val DRIVER_CLASS_NAME = "driverClassName"
        val JDBC_URL = "jdbcUrl"
        val USERNAME = "username"
        val PASSWORD = "password"
        val CONNECTION_TIMEOUT = "connectionTimeout"
        val IS_READY_ONLY = "isReadOnly"
        val CONNECTION_TEST_QUERY = "connectionTestQuery"
        val MINIMUM_IDLE = "minimumIdle"
        val MAXIMUM_POLL_SIZE = "maximumPoolSize"
    }
    val src = HikariDataSource()?.also {src->
        args.forEach { a->
            val k = a.first
            val v = a.second
            when(k){
            DRIVER_CLASS_NAME->
                src.driverClassName =
                    if(v is String) v
                    else throw Throwable("...")
            JDBC_URL->
                src.jdbcUrl =
                    if(v is String) v
                    else throw Throwable("...")
            USERNAME->
                src.username =
                    if(v is String) v
                    else throw Throwable("...")
            PASSWORD->
                src.password =
                    if(v is String) v
                    else throw Throwable("...")
            IS_READY_ONLY->
                src.isReadOnly =
                    if(v is Boolean) v
                    else throw Throwable("...")
            CONNECTION_TIMEOUT->
                src.connectionTimeout =
                    if(v is Long) v
                    else throw Throwable("...")
            CONNECTION_TEST_QUERY->
                src.connectionTestQuery =
                    if(v is String) v
                    else throw Throwable("...")
            MINIMUM_IDLE->
                src.minimumIdle =
                    if(v is Int) v
                    else throw Throwable("...")
            MAXIMUM_POLL_SIZE->
                src.maximumPoolSize =
                    if(v is Int) v
                    else throw Throwable("...")
            else->throw Throwable("...")
            }
        }
    }
}

companion object로 정적인 String형 키를 만들었습니다. 호스트 코드에서는 다음처럼 쓸 수 있습니다.

val h2 = HikariHelper_2(
  HikariHelper_2.DRIVER_CLASS_NAME to "com.mysql.cj.jdbc.Driver",
  HikariHelper_2.JDBC_URL to "jdbc:mysql://myexample.com:3306/mydb",
  HikariHelper_2.USERNAME to "jidolstar",
  HikariHelper_2.PASSWORD to "mypassword",
  HikariHelper_2.CONNECTION_TIMEOUT to 30000L,
  HikariHelper_2.IS_READY_ONLY to false,
  HikariHelper_2.CONNECTION_TEST_QUERY to "select 1 from dual",
  HikariHelper_2.MINIMUM_IDLE to 5,
  HikariHelper_2.MAXIMUM_POLL_SIZE to 5
)

코드 힌트가 되기 때문에 이전 보다는 조금 나아졌습니다. 하지만 여전히 아무 키나 입력이 가능합니다. 다음처럼요.

val h2= HikariHelper_2(
  HikariHelper_2.DRIVER_CLASS_NAME to "com.mysql.cj.jdbc.Driver",
  "jdbcUrl" to "jdbc:mysql://myexample.com:3306/mydb",
   ....(생략)...
)

게다가 when에서 키가 없으면 런타임 에러를 내죠. 사용할 수 있는 키를 확정할 방법이 없을까요? 다음 단계를 가보죠.

단계 3 : Enum을 사용해 올바른 키 적용

올바른 키를 쓸 수 있도록 Enum을 도입해보겠습니다. 이제 생성자 인자는 Pair<DB03,Any>를 받습니다.

enum class HikariParam{
    DRIVER_CLASS_NAME,JDBC_URL,
    USERNAME,PASSWORD,CONNECTION_TIMEOUT,
    IS_READY_ONLY,CONNECTION_TEST_QUERY,
    MINIMUM_IDLE,MAXIMUM_POLL_SIZE
}
class HikariHelper_3(vararg args: Pair<DB03,Any>){
    val src = HikariDataSource()?.also {src->
        args.forEach { a->
            val k = a.first
            val v = a.second
            when(k){
            HikariParam.DRIVER_CLASS_NAME->
                src.driverClassName =
                    if(v is String) v
                    else throw Throwable("...")
            HikariParam.JDBC_URL->
                src.jdbcUrl =
                    if(v is String) v
                    else throw Throwable("...")
            HikariParam.USERNAME->
                src.username =
                    if(v is String) v
                    else throw Throwable("...")
            HikariParam.PASSWORD->
                src.password =
                    if(v is String) v
                    else throw Throwable("...")
            HikariParam.IS_READY_ONLY->
                src.isReadOnly =
                    if(v is Boolean) v
                    else throw Throwable("...")
            HikariParam.CONNECTION_TIMEOUT->
                src.connectionTimeout =
                    if(v is Long) v
                    else throw Throwable("...")
            HikariParam.CONNECTION_TEST_QUERY->
                src.connectionTestQuery =
                    if(v is String) v
                    else throw Throwable("...")
            HikariParam.MINIMUM_IDLE->
                src.minimumIdle =
                    if(v is Int) v
                    else throw Throwable("...")
            HikariParam.MAXIMUM_POLL_SIZE->
                src.maximumPoolSize =
                    if(v is Int) v
                    else throw Throwable("...")
            //else->throw Throwable("...") ->필요 없네요
            }
        }
    }
}

when 식에서 else가 필요 없게 되었습니다. 왜냐하면 더 이상 when 식에서 다른 키를 받을 수 없도록 확정했기 때문입니다. 이를 사용하는 호스트 코드는 여전히 코드 힌트가 됩니다. 또한 다른 키를 입력할 수도 없습니다.

val h3 = HikariHelper_3(
        HikariParam.DRIVER_CLASS_NAME to "com.mysql.cj.jdbc.Driver",
        HikariParam.JDBC_URL to "jdbc:mysql://myexample.com:3306/mydb?autoReconnect=true",
        HikariParam.USERNAME to "jidolstar",
        HikariParam.PASSWORD to "mypassword",
        HikariParam.CONNECTION_TIMEOUT to 30000L,
        HikariParam.IS_READY_ONLY to false,
        HikariParam.CONNECTION_TEST_QUERY to "select 1 from dual",
        HikariParam.MINIMUM_IDLE to 5,
        HikariParam.MAXIMUM_POLL_SIZE to 5
)

아직 문제가 남아 있습니다. Enum이 추가 될 때마다 when 식에도 계속 추가하는 형태이고, 값의 형(type)은 여전히 런타임에만 알아낼 수 있습니다.

단계 4 : Sealed 클래스로 값의 형태 확정하기

코틀린에서는 Sealed(봉인된) 클래스를 제공합니다.

Sealed 클래스를 만들면 해당 클래스의 내부에서만 확장 클래스를 만들 수 있습니다. 외부에서 참조는 할 수 있지만 확장할 수 없도록 강제하기 때문에 더욱 안정된 코드를 작성하기 유리합니다.

Sealed 클래스는 내부용 추상클래스입니다. 그러므로 객체를 만드려면 반드시 내부에서 Sealed클래스를 확장한 클래스를 만들어야 합니다.

우리는 Sealed 클래스를 가지고 생성자의 인자값을 만들어 보겠습니다.

class HikariHelper_4(vararg args:Param){
    sealed class Param{
        open class driverClassName(val v:String) : Param()
        object driverMysql : driverClassName("com.mysql.cj.jdbc.Driver")
        class jdbcUrl(val v:String) : Param()
        class username(val v:String) : Param()
        class password(val v:String) : Param()
        class isReadOnly(val v:Boolean) : Param()
        class connectionTimeout(val v:Long) : Param()
        class connectionTestQuery(val v:String) : Param()
        class minimumIdle(val v:Int) : Param()
        class maximumPoolSize(val v:Int) : Param()
    }
    val src = HikariDataSource()?.also {src->
        args.forEach {a->
            when(a){
                is Param.driverClassName->
                    src.driverClassName = a.v
                is Param.jdbcUrl -> 
                    src.jdbcUrl = a.v
                is Param.username -> 
                    src.username = a.v
                is Param.password -> 
                    src.password = a.v
                is Param.isReadOnly -> 
                    src.isReadOnly = a.v
                is Param.connectionTestQuery -> 
                    src.connectionTestQuery = a.v
                is Param.minimumIdle -> 
                    src.minimumIdle = a.v
                is Param.maximumPoolSize -> 
                    src.maximumPoolSize = a.v
            }
        }
    }
}

Param 이름의 Sealed 클래스를 만들고 그 내부에 Param을 확장하는 구상 클래스를 만들었습니다. 각 구상클래스의 생성자 인자값은 각 속성에 맞는 형(type)을 받도록 합니다. when 식에서는 Param을 구상한 클래스의 객체가 키값이 될 것이기 때문에 is 연산자로 키를 구분하고 값의 형은 이미 확정되서 해당 src의 속성에 할당만 하면 됩니다. 더이상 값의 형을 검사하는 로직이 없음을 확인하세요.

이제 호스트 코드를 볼까요?

var h4a = HikariHelper_4(
        HikariHelper_4.Param.driverClassName("com.mysql.cj.jdbc.Driver"),
        HikariHelper_4.Param.jdbcUrl("jdbc:mysql://myexample.com:3306/mydb?autoReconnect=true"),
        HikariHelper_4.Param.username("jidolstar"),
        HikariHelper_4.Param.password("mypassword"),
        HikariHelper_4.Param.isReadOnly(false),
        HikariHelper_4.Param.connectionTimeout(30000L),
        HikariHelper_4.Param.connectionTestQuery("select 1 from dual"),
        HikariHelper_4.Param.minimumIdle(5),
        HikariHelper_4.Param.maximumPoolSize(5)
)

Enum과 같이 다른 키를 입력할 수도 없는데다가 값 조차도 엉뚱한 형이 들어오는 것을 원천 봉쇄했습니다. 더욱 안정화된 코드로 발전했네요.
기존에는 키/값 쌍인 Pair이기 때문에 키만 있고 값이 없는 driverMysql을 만들 경우 driverMysql to null 형태로 인자값을 줄 수 밖에 없는 인지적 문제가 있다고 했습니다.

위 클래스를 다시 잘 살펴보면 아래 코드가 있습니다. driverClassName을 확장한 driverMysql이 있지요. 이것은 값으로 “com.mysql.cj.jdbc.Driver”가 확정된 것입니다.

class HikariHelper_4(vararg args:Param){
    sealed class Param{
        open class driverClassName(val v:String) : Param()
        object driverMysql : driverClassName("com.mysql.cj.jdbc.Driver")

     .....

자세히 보면 driverClassName을 확장하기 위해서 open 키워드를 썼습니다. 코틀린은 자바와 다르게 클래스만 정의하면 final입니다. 확장이 가능하게 하려면 open을 클래스 앞에 써야 합니다.

그리고 생소한 object가 driverMysql정의시 있는데요. object로 클래스를 상속해 정의하면 이것 자체가 바로 객체입니다. 위 코드의 경우 “com.mysql.cj.jdbc.Driver”값이 확정이기 때문에 class로 만들지 않고 object를 쓴 것이지요.

이것을 사용하면 더이상 driver를 문자열로 줄 필요가 없게 됩니다.

var h4b = HikariHelper_4(
        HikariHelper_4.Param.driverMysql,
        HikariHelper_4.Param.jdbcUrl("jdbc:mysql://myexample.com:3306/mydb?autoReconnect=true"),
        HikariHelper_4.Param.username("jidolstar"),
        HikariHelper_4.Param.password("mypassword"),
        HikariHelper_4.Param.isReadOnly(false),
        HikariHelper_4.Param.connectionTimeout(30000L),
        HikariHelper_4.Param.connectionTestQuery("select 1 from dual"),
        HikariHelper_4.Param.minimumIdle(5),
        HikariHelper_4.Param.maximumPoolSize(5)
)

정말 많은 부분이 개선된 것 같습니다. 하지만 여전히 문제가 있지요. when식은 Param의 만들어질 때마다 계속 분기해야 합니다.

단계 5 : when 분기 없애기

지금까지 구현된 클래스 내부를 보면 when으로 분기하고 있습니다.. 자세히 보면 계속 같은 패턴입니다.

    ....(기존코드)
           when(a){
                is Param.driverClassName->
                    src.driverClassName = a.v
                is Param.jdbcUrl -> 
                    src.jdbcUrl = a.v
    ....

게다가 sealed 클래스가 추가될 때마다 when도 추가해야 합니다. 결국 src에 값을 할당하는 책임을 when이 가질 필요 없이 sealed 클래스 내부에서 소화해도 큰 문제가 없어 보입니다. 값 할당의 책임을 Sealed 클래스에 주겠습니다.

class HikariHelper_5(vararg args:Param){
    sealed class Param{
        abstract fun perform(src:HikariDataSource)
        open class driverClassName(val v:String) : Param(){
            override fun perform(src: HikariDataSource) {
                src.driverClassName = v
            }
        }
        object driverMysql : driverClassName("com.mysql.cj.jdbc.Driver")
        class username(val v:String) : Param(){
            override fun perform(src: HikariDataSource) {
                src.username = v
            }
        }
        class password(val v:String) : Param(){
            override fun perform(src: HikariDataSource) {
                src.password = v
            }
        }
        class isReadOnly(val v:Boolean) : Param(){
            override fun perform(src: HikariDataSource) {
                src.isReadOnly = v
            }
        }
        class connectionTimeout(val v:Long) : Param(){
            override fun perform(src: HikariDataSource) {
                src.connectionTimeout = v
            }
        }
        class connectionTestQuery(val v:String) : Param(){
            override fun perform(src: HikariDataSource) {
                src.connectionTestQuery = v
            }
        }
        class minimumIdle(val v:Int) : Param(){
            override fun perform(src: HikariDataSource) {
                src.minimumIdle = v
            }
        }
        class maximumPoolSize(val v:Int) : Param(){
            override fun perform(src: HikariDataSource) {
                src.maximumPoolSize = v
            }
        }
        class jdbcUrl(val v:String) : Param(){
            override fun perform(src: HikariDataSource) {
                src.jdbcUrl = v
            }
        }
    }
    val src = HikariDataSource()?.also {src->
        args.forEach {a->a.perform(src)}
    }
}

Sealed 클래스는 추상클래스라고 했습니다. 그래서

abstract fun perform(src:HikariDataSource)

를 만들고 구상 클래스들은 override 해서 값 할당의 책임을 주었습니다. 이제 더 이상 when 식으로 조건분기 하지 않습니다. 여러분은 그저 필요하면 구상 클래스만 만들면 그만입니다!

호스트 코드는 변화 없습니다.

var t5 = HikariHelper_5(
        HikariHelper_5.Param.driverMysql,
        HikariHelper_5.Param.jdbcUrl("jdbc:mysql://myexample.com:3306/mydb?autoReconnect=true"),
        HikariHelper_5.Param.username("jidolstar"),
        HikariHelper_5.Param.password("mypassword"),
        HikariHelper_5.Param.isReadOnly(false),
        HikariHelper_5.Param.connectionTimeout(30000L),
        HikariHelper_5.Param.connectionTestQuery("select 1 from dual"),
        HikariHelper_5.Param.minimumIdle(5),
        HikariHelper_5.Param.maximumPoolSize(5)
)

키/값에 대한 정적 안정성도 확보했고 속성값 설정을 위한 레시피도 마음대로 추가할 수 있게 되었으니 다 된 것 같습니다. 그러나 위 호스트 코드를 보면 뭔가 중복이 너무 많지 않나요? 속성 값을 전달하는데 HikariHelper_5.Param. 이 너무 많이 나옵니다. 즉, 코딩량이 너무 많습니다. 게다가 속성 지정에 클래스 생성을 남발합니다. 목표는 달성했지만 역시 석연치 않은 부분이 많습니다. 이것을 개선해 보겠습니다. 이제부터 조금 더 코틀린스럽게 바꿔보겠습니다.

단계 6 : 람다 함수로 속성 설정하기

먼저 앞서 지적한 문제를 개선한 클래스를 보겠습니다.

class HikariHelper_6(block:(Param)->Unit){
    class Param{
        val src = HikariDataSource()
        fun driverMysql(){
            src.driverClassName = "com.mysql.cj.jdbc.Driver"
        }
        var driverClass
            get() = src.driverClassName
            set(v){src.driverClassName = v }
        var jdbcUrl
            get() = src.jdbcUrl
            set(v){src.jdbcUrl = v}
        var username
            get() = src.username
            set(v){src.username = v}
        var password
            get() = src.password
            set(v){src.password = v}
        var connectionTimeout
            get() = src.connectionTimeout
            set(v){src.connectionTimeout = v}
        var isReadOnly
            get() = src.isReadOnly
            set(v){src.isReadOnly = v}
        var connectionTestQuery
            get() = src.connectionTestQuery
            set(v){src.connectionTestQuery = v}
        var minimumIdle
            get() = src.minimumIdle
            set(v){src.minimumIdle = v}
        var maximumPoolSie
            get() = src.maximumPoolSize
            set(v){src.maximumPoolSize = v}
    }
    var src = Param()?.let {
        block(it)
        it.src
    }
}

속성처리를 담당하는 Param이라는 클래스 하나만 만들었습니다. Param 내부에는 HikariDataSource 객체를 아에 가지고 있습니다. 그리고 속성 지정을 위한 getter/setter들을 만들었습니다. setter 덕분에 괄호를 쓰지 않고 할당 연산자(=)를 통해 src의 속성값을 설정합니다. 그리고 이 코드에서는 없지만 setter에서 입력 값에 대한 유효성 검사도 함께 할 수 있는 타이밍이 생깁니다.

생성자는

block:(Param)->Unit

입니다. 람다 함수를 받습니다. 인자로 Param을 제공합니다.

이제 호스트 코드를 보겠습니다.

var h6 = HikariHelper_6{
    it.driverMysql()
    it.jdbcUrl = "jdbc:mysql://myexample.com:3306/mydb?autoReconnect=true"
    it.username = "jidolstar"
    it.password = "mypassword"
    it.isReadOnly = false
    it.connectionTimeout = 30000L
    it.connectionTestQuery = "select 1 from dual"
    it.minimumIdle = 5
    it.maximumPoolSie = 5
}

뭔가 코틀린 답지 않나요? 앞의 it이 조금 거슬리지만, 이 it은 Param 클래스의 객체입니다. Param 객체를 생성하는 것조차 외부에서 안해도 되는 것이지요. it만으로 코드 힌트가 되기 때문에 훨씬 쓰기 용이해졌습니다.

게다가 람다 함수 내부이므로 인자 설정 뿐 아니라 다른 로직을 넣어도 됩니다! for, if, when 아무거나 써도 됩니다!

하지만 이게 다가 아닙니다. 코틀린이라면 더 개선할 수 있습니다. 코틀린의 리시버(receiver) 개념을 도입하면 중복으로 보이는 it 조차도 없앨 수 있습니다.

단계 7 : 리시버<Receiver>로 it 없애기

일단 구현 클래스부터 보겠습니다.

class HikariHelper_7(block:Param.()->Unit){
    class Param{
        val src = HikariDataSource()
        fun driverMysql(){
            src.driverClassName = "com.mysql.cj.jdbc.Driver"
        }
        var driverClass
            get() = ""
            set(v){src.driverClassName = v }
        var jdbcUrl
            get() = ""
            set(v){src.jdbcUrl = v}
        var username
            get() = ""
            set(v){src.username = v}
        var password
            get() = ""
            set(v){src.password = v}
        var connectionTimeout
            get() = 30000L
            set(v){src.connectionTimeout = v}
        var isReadOnly
            get() = false
            set(v){src.isReadOnly = v}
        var connectionTestQuery
            get() = ""
            set(v){src.connectionTestQuery = v}
        var minimumIdle
            get() = 5
            set(v){src.minimumIdle = v}
        var maximumPoolSie
            get() = 5
            set(v){src.maximumPoolSize = v}
    }
    var src = Param()?.let {
        block(it)
        it.src
    }
}

이전 클래스와 차이점을 아시겠나요?

이전 클래스에서는 생성자 인자로

block:(Param)->Unit

를 주었습니다. 그런데 여기선

block:Param.()->Unit

입니다. 괄호 위치가 달라졌죠? (Param)대신 Param.() 입니다. 이는 코틀린 리시버 개념이 도입된 것인데요. 이렇게 쓰면 블록 함수내의 실행 컨텍스트(this)는 Param 객체 그 자체가 됩니다.

결국 호스트 코드는 아래처럼 it을 없앴습니다. this.jdbcUrl = … 이렇게 써도 됩니다.

var h7 = HikariHelper_7{
    driverMysql()
    jdbcUrl = "jdbc:mysql://myexample.com:3306/mydb?autoReconnect=true"
    username = "jidolstar"
    password = "mypassword"
    isReadOnly = false
    connectionTimeout = 30000L
    connectionTestQuery = "select 1 from dual"
    minimumIdle = 5
    maximumPoolSie = 5
}

진정으로 코틀린답게 나온 것 같습니다. 어떤가요? 처음 1단계부터 마지막 단계까지 작성된 호스트코드를 다시 보세요. 정적 안정성을 유지하고 충분한 속성 설정 레시피를 쉽게 만들 수 있으면서도 코드량이 엄청나게 줄었습니다. 코틀린의 매력이 아닐 수 없습니다.

코틀린의 리시버와 람다 함수를 이용하면 아래 코드처럼 DSL을 쉽게 만들어 낼 수 있겠죠.

var html = HTML{
    table{
       tr{  
           td
           td
       }
    }
    img
}

기존 html과는 다른 점은 코드 힌트가 되고 정적 안정성을 확보했다는데 있습니다. tr 내부에는 td 외에 다른 태그를 그냥 넣을 수 없을 테니깐요.

빌더 패턴

이쯤해서 빌더 패턴에 대한 생각이 들 것입니다. 빌더 패턴을 쓴다면 다음처럼 호스트 코드를 작성할 수 있습니다.

val a = Builder()
val b = a.action1().action2().action3()
val c = b.action4().build()

빌더 패턴은 몇가지 단점이 있습니다.

  1. build()함수 존재 여부를 판단하기 어려움
    코드만 보고서는 알 수 없고, 문서나 세부 스팩을 공부해야 학습 뒤 알게 됩니다. 또한 build해주는 함수가 build()인지 create()인지 그냥은 알 수 없죠.

  2. build 전까지 트랜잭션 보장 불가
    위 코드를 보면 a에 대한 action 수행 결과를 b에 할당하고 또 b로부터 action 수행 후 build 결과를 c에 할당합니다. 중간에 b를 다른 것으로 대체하거나 다른 처리를 해도 코드상으로 막을 방법이 없습니다. 즉, build()하기 전까지 객체 생성에 대한 트랜잭션을 보장할 수 없습니다.

    하지만 마지막 코틀린 DSL 기반으로 만든 예시에서는 트렌잭션을 완벽하게 보장합니다(꼭 DSL을 썼기 때문에 트랜잭션을 보장했다는 의미는 아닙니다). 중간에 생성을 시도하면 반드시 생성되고 중간에 다른 생성 객체로 대체 되는 식의 간섭이 있을 수 없습니다.

그러니 빌더 패턴이 등장하는 것 같으면 처음부터 그 목적을 충분히 달성하고도 깔끔한 코드를 유도해주는 DSL을 떠올리시는게 좋을 것 같습니다.

결론

지금까지 정적 안정성을 깨트리고 많은 코딩량을 만드는 나쁜 코드로부터, 단계별로 개선해서 결국 람다 함수와 리시버 조합으로 극적으로 정적안정성을 확보하고 짧고 확장성이 좋은 코드로 개선해 보았습니다. 비록 하나의 예시지만 코틀린의 매력을 알아가는데 조금이나마 도움이 되지 않았을까 생각합니다.