Kotlin

Kotlin DSL

dev_roach 2022. 3. 28. 19:25
728x90

코틀린이란 언어는 상당히 문법적으로 코드를 간결하게 작성할 수 있는 기능을 많이 제공한다. "중위 연산자", "확장 함수", "람다구문의 it" 등 을 지원하고 있다. 우리는 이러한 방법을 이용해 좀 더 간결하게 코드를 작성할 수 있다. 예를 들면 아래와 같이 말이다.

1.to("one")

1 to "one"

확장함수를 이용하면 아래처럼 조금 더 객체지향 적인 코드를 짤 수 있다고 생각한다.

StringUtil.capitalize(s) // java

s.capitalize() // kotlin

내부 DSL

또한 코틀린에서는 좀 더 가독성 좋은 코드를 작성할 수 있도록 내부 DSL 또한 지원한다. 여기서 짚고 넘어가야 할 점이 있는데 DSL 에 대해서 조금 알아보자. 우리가 보통 잘 알고 있는 DSL 은 SQL 문이 있다. 아래와 같이 많이 작성을 해보았을 것이다.

SELECT * FROM User;

위와 같은 문장으로 우리는 특정 어플리케이션을 만들기는 힘들다. 다만 SQL 저장소에 있는 어떠한 정보를 가져오고, 정보를 수정하는 연산에는 최적화되어 이용 가능하다. 여기서 DSL 의 특성을 알 수 있는데, DSL 은 특정 영역에 제한적이다. 위의 예를 다시보면 SQL 이라는 DSL 은 데이터베이스에서 정보를 가져오고 추출하는 연산 도메인에만 특화되어 있다는 것을 알 수 있다.

DSL 과 API 사이의 경계

DSL 과 API 사이에 잘 정의된 경계는 존재하지 않는다. 다만 큰 차이점이 있다면 DSL 이라는 하나의 거대한 문법 구조를 맞춰야만 호출이 가능할 수도 있다는 것이다. 즉, 우리가 DSL 을 통해서 특정 결과를 도출해내기 위해서는 DSL 문법(grammer) 에 맞춰서 질의를 해야만 가능하다는 것이다. 우리가 사용하는 SQL 을 생각해보면 쉽게 이해할 수 있을 것 이다.

이런 특성때문에 DSL 을 Domain-Specific Language 라고 부른다고 생각한다. 즉, 하나의 언어로서 취급한다는 것이다.

DSL 을 통하여 HTML 만들기

아래와 같이 코틀린 내부 DSL 을 통하여 HTML 구조를 만드는 방법을 한번 살펴보자.

fun createSimpleTable() = createHTML() .
	table {
    	tr {
        	td { +"cell" }
        }
    }

위와 같은 구조가 어떤 형태로 HTML 을 만들어 줄지는 예측하기 쉬워 보인다고 생각한다.

<table>
	<tr>
    	<td>cell</td>
    </tr>
</table>

위와 같이 DSL 을 이용하면 좀 더 Domain 에 대한 이해도를 높일 수 있다고 생각한다.

또한 중요한건 여기서 계층 구조를 만들 수 있다는 것 또한 느낄 수 있을 것이다. tr 은 반드시 table 아래에만 적힐 수 있는 등, 컴파일 시점에 해당 부분을 검사 가능하다.

수신 객체 람다를 이용하여 DSL 만들기

위와 같이 코틀린의 수신 객체 람다를 이용하면 좀 더 가독성 좋게 DSL 을 구성할 수 있다.

백문이 불여일타 라고 코드로 보는것이 훨씬 더 나을 것이다.

 

아래는 아직 수신 객체 람다를 이용하지 않은 예제이다.

현재는 it 이라는 것을 붙여야 해서 사실 가독성이 엄청좋다? 라고 말하기는 힘들것 같다.

fun buildString(
    builderAction: (StringBuilder) -> Unit
): String {
    val sb = StringBuilder()
    builderAction(sb)
    return sb.toString()
}

val s = buildString {
    it.append("Hello, ")
    it.append("World!")
}

fun main() {
    println(s)
}

이제 한번 수신 객체 람다를 이용해보자. 수신 객체 람다의 개념을 이해하지 못한 사람이 있을까봐 간단히 설명하면 람다의 인자 중 하나에 수신 객체라는 상태를 부여하면 람다에서 해당 객체를 바로 이용할 수 있는 개념이다. 즉, 람다에 객체를 사용할 수 있도록 수신시켜주는 있는 기능이다.

fun buildString(
    builderAction: StringBuilder.() -> Unit
): String {
    val sb = StringBuilder()
    builderAction(sb)
    return sb.toString()
}

val s = buildString {
    append("Hello, ")
    append("World!")
}

fun main() {
    println(s) // "Hello, World!"
}

이제는 수신 객체 람다를 이용해서 좀 더 가독성이 좋아진 느낌이다.

우리는 buildString 을 하는데 내부에서 append 를 통해서 문자열을 붙인다는 것을 추측할 수 있다.

사실 코틀린의 apply 를 공부했다면 apply 자체는 수신객체 자체를 반환하므로 이게 apply 를 이용하면 조금 더 쉽게 풀릴 수 있다는 생각을 한 사람도 있을 것이다. 

val z = StringBuilder().apply {
    append("Hello, ")
    append("World! ")
}.toString()

with 로도 간단하게 구현 가능하다.

val c = with(StringBuilder()) {
    append("Hello, ")
    append("World! ")
    toString()
}

 Invoke 를 이용한 좀 더 유연한 블록 중첩

코틀린에는 여러 관례가 존재한다. 예를 들면 "foo.get(bar)" 의 경우에는 foo[bar] 로 관례 처럼 선언 가능하다.

invoke 함수는 아래 예제 처럼 이용 가능하다.

class Greeter(private val greeting: String) {
    operator fun invoke(name: String) {
        println("$greeting: $name")
    }
}

fun main() {
    val roach = Greeter("Roach")
    roach("Roach!!!") // invoke
}

코틀린의 인라인 하는 람다를 제외한 모든 람다는 아래와 같은 함수형 인터페이스로 컴파일 된다.

public interface Function1<in P1, out R> : Function<R> {
    /** Invokes the function with the specified argument. */
    public operator fun invoke(p1: P1): R
}

public interface Function2<in P1, in P2, out R> : Function<R> {
    /** Invokes the function with the specified arguments. */
    public operator fun invoke(p1: P1, p2: P2): R
}

Lambda 의 경우 invoke 를 통해서 호출한다는 것이다. 위와 같은 형태로 변환된다는 것을 알게 되면 장점이 하나 있다.

복잡한 Lambda 를 invoke 를 통해서 분리가능하다는 것이다.

data class Issue(
    val id: String,
    val project: String,
)

class ImportantIssuesPredicate(
    val project: String
) : (Issue) -> Boolean {

    override fun invoke(p1: Issue): Boolean {
        return p1.project == project && p1.isImportant()
    }

    private fun Issue.isImportant(): Boolean {
        return id == "1"
    }
}

fun main() {
    val issue1 = Issue(
        id = "1",
        project = "roach's side"
    )

    val issue2 = Issue(
        id = "2",
        project = "roach's side2"
    )

    val predicate = ImportantIssuesPredicate("roach's side")

    val result = listOf(issue1, issue2).filter(predicate)

    println(result) // [Issue(id=1, project=roach's side)]
}

위의 코드를 보면 filter 에서 predicate 의 invoke 를 호출한다는 것을 알 수 있다.

Java 로 컴파일 된 부분의 일부를 보여주면 아래와 같다.

      while(var9.hasNext()) {
         Object element$iv$iv = var9.next();
         if ((Boolean)((Function1)predicate).invoke(element$iv$iv)) {
            destination$iv$iv.add(element$iv$iv);
         }
      }

Gradle 의 경우 invoke 를 통하여 아래 처럼 이용 가능하다.

class DependencyHandler {

    fun compile(coordinate: String) {
        println("Add Dependency on $coordinate")
    }

    operator fun invoke(
        body: DependencyHandler.() -> Unit
    ) {
        body()
    }
}

fun main() {
    val dependencies = DependencyHandler()

    dependencies {
        compile("spring.boot.jpa")
    }
}

후기

이렇게 코틀린에서 DSL 을 통해서 좀 더 도메인에 특화되도록 가독성이 높은 코드를 작성하는 방법에 대해서 공부해봤다. 사실 예전 부터 ExposedU 라이브러리 처럼 기성 QueryDSL 을 Kotlin 스럽게 Wrapping 할 수는 없을까? 에 대한 고민이 많았다. Line 에서 만든 KotlinDSL 라이브러리도 써볼까 했는데, 나중에 따로 사이드에서 오늘 익힌 방법들로 QueryDSL 을 Wrapping 해봐야겠다.

 

참고한 도서

https://book.naver.com/bookdb/book_detail.nhn?bid=12685155