ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Kotlin in Action : ch 11. DSL 만들기
    Book/Kotlin in Action 2023. 1. 25. 18:51

    11. DSL 만들기

    DSL은 영역 특화 언어(Domain-Specific Language)를 의미한다.

    즉, 특정 도메인에 특화된 언어이다.
    "문제 영역의 해결에는 그 영역의 언어를 전제로 둬야하며, 거기에서 프로그래밍 솔루션을 꺼내는 것이 중요하다" 라고 말한 Dave Thomas가 한 말을 생각하면 이해하기 쉽다.

    특정 언어의 문제 해결에는 그 영역에 맞는 특화된 도구를 사용하자라는 것이다. 

    아래에 DSL에 대해 더 자세히 나오니 지금은 이정도만 알고 넘어가자.

    코틀린의 DSL 설계는 코틀린 언어의 여러 특성을 활용한다.

     

    그 중 두가지 특성을 살펴보면

    1. 수신 객체 지정 람다
      • 수신 객체 지정 람다를 사용하면 코드 블록에서 이름(변수)가 가리키는 대상을 정할 수 있었다. 이러한 방식을 변경해서 DSL 구조를 더욱 쉽게 만들 수 있다.
    2. invoke
      • invoke convention을 사용하면 DSL 코드 안에서 람다와 프로퍼티 대입을 더 유연하게 조합할 수 있다.

     

    11.1 API에서 DSL로 

    객체지향 프로그래밍에서 객체와 객체는 서로 상호작용한다. 개발자가 객체지향 프로그래밍에서 지향해야하는 목표는 가독성이 높고 유지보수성이 좋은 코드를 작성하고 객체간의 관계를 잘 설계하는 것이라고 할 수 있다.

     

    코틀린에서는 객체를 위한 API를 깔끔하게 작성하기 위한 여러 코틀린적 특성을 알아보았다.

     

    깔끔한 API의 특성은 다음과 같다.

    • 코드를 읽는 독자가 어떤 일이 벌어질지 명확하게 이해할 수 있어야 한다. 어떤 언어를 사용하건 이름을 잘 붙이고 적절한 개념을 사용하는 것은 매우 중요하다.
    • 코드가 간결해야 한다. 불필요한 구문이나 번잡한 준비 코드가 가능한 한 적어야 한다.

     

    코틀린에서 깔끔한 API를 작성하기 위해 지원하는 기능들에 대해서 다시 한번 복습해보자.

    일반 구문간결한 구문사용한 언어 특성

    Regular syntax Clean syntax Feature in use
    StringUtil.capitalize(s) s.capitalize() 확장 함수
    1.to(“one”) 1 to “one” 중위 호출
    set.add(2) set += 2 연산자 오버로딩
    map.get(“key”) map[“key”] get 메소드에 대한 관례
    file.use({ f -> f.read() }) file.use{ it.read() } 람다를 괄호 밖으로 빼내는 관례
    sb.append(“yes”) with(sb){
    append(“yes”)
    }
    수신 객체 지정 람다

     

    코틀린 DSL은 간결한 구문을 제공하는 기능과 그런 구문을 확장해 여러 메소드 호출을 조합함으로써 구조를 만들어내는 기능에 의존한다.

     

    그 결과로 DSL은 메소드 호출만을 제공하는 API에 비해 더 표현력이 풍부해지고 사용하기 편해진다.

     

    코틀린 언어와 마찬가지로 코틀린 DSL도 온전히 컴파일 시점에 타입이 정해진다. 따라서 타입 지정 언어의 강점을 코틀린 DSL에서도 누릴 수 있다. 

     

    DSL을 사용하면 다음과 같이 하루 전날을 반환 받을 수 있다.

    val yesterday = 1.days.ago

     

    우리는 이번 장에서 이런 예제를 어떻게 구현하는지 살펴본다.

    하지만 구현을 더 자세히 살펴보기에 앞서 DSL이란 무엇인지에 대해 좀더 공부해보자.

     

    11.1.1 영역 특화 언어라는 개념

    범용 프로그래밍 언어를 기반으로 하여 필요하지 않은 기능을 없앤 영역 특화 언어를 DSL이라고 부른다.

     

    우리에게 가장 익숙한 DSL은 SQL과 정규식이다.

    이 두언어는 데이터베이스 조작과 문자열 조작이라는 특정 작업에 가장 적합하다.

     

    그렇다고 해서 우리가 전체 애플리케이션을 정규식이나 SQL을 사용해 작성하는 경우는 없다.

     

    이러한 DSL은 범용 프로그래밍언어와 달리 declarative라는 점이 중요하다. 범용 프로그래밍 언어는 주로 imperative하다.

     

    declarative은 결과를 기술하기만 하고 그 결과를 달성하기 위한 세부 실행은 언어를 해석하는 엔진에 맡겨버린다.

    실행 엔진이 결과를 얻는 과정을 전체적으로 최적화하기 때문에 declarative 언어가 더 효율적인 경우가 종종 있다.

     

    그러나 이러한 declarative 언어에도 한 가지 단점이 존재한다.

    바로 DSL을 범용 언어로 만든 애플리케이션과 조합하기가 어렵다는 점이다.

     

    DSL은 자체 문법이 있기 때문에 다른 언어의 프로그램 안에 직접 포함시킬 수 없다.

     

    DSL로 작성한 프로그램을 다른 언어에서 호출하기 위해서는 DSL 프로그램을 별도의 파일이나 문자열 리터럴로 저장해야 한다.

    하지만 이런 식으로 DSL을 저장하면 호스트 프로그램과 DSL의 상호작용을 컴파일 시점에 제대로 검증하거나 DSL 프로그램을 디버깅하거나 DSL 코드 작성을 돕는 IDE 기능을 제공하기 어려워지는 문제가 있다.

     

    이런 문제를 극복하기 위해 internal DSL이라는 개념이 점점 유명해지고 있다.

     

    11.1.2 internal DSL

    독립적인 문법 구조를 가진 external DSL과 반대로 internal DSL은 범용 언어로 작성된 프로그래밍의 일부이며 범용 언어와 동일한 문법을 사용한다.

     

    따라서 internal DSL은 다른 언어가 아니라 DSL의 핵심 장점을 유지하면서 주 언어를 특별한 방법으로 사용하는 것이다.

    아래는 internal DSL을 이해하기 좋은 예시가 있다.

    SELECT Country.name, COUNT(Customer.id)
        FROM Country
        JOIN Customer
            ON Country.id = Customer.country_id
    GROUP BY Country.name
    ORDER BY COUNT(Customer.id) DESC
        LIMIT 1
    

    위 SQL은 가장 많은 고객이 살고 있는 나라를 알아내는 질의문이다.

     

    질의 언어아 주 애플리케이션 언어 사이에 상호작용할 수 있는 방법을 제공해야 하기 때문에 SQL로 코드를 작성하는 것이 편하지 않을 수 있다.

     

    코틀린으로 작성된 데이터베이스 프레임워크인 Exposed 프레임워크가 제공하는 DSL을 사용하여 같은 질의를 구현하면 아래와 같다.

    (Country join Customer)
        .slice(Country.name, Count(Customer.id))
        .selectAll()
        .groupBy(Country.name)
        .orderBy(Count(Customer.id),isAsc = false)
        .limit(1)
    

    위 프로그램을 실행하면 첫 번째 SQL과 동일한 프로그램이 생성되고 실행된다.

    하지만 두 번째 코드는 일반 코틀린 코드이며 일반 코틀린 메소드를 사용한다.

    또한 두 번째 코드는 SQL 질의가 반환하는 결과 집합을 코틀린 객체로 변환하기 위해 특별히 해줄 것이 없다.

     

    쿼리를 실행한 결과가 네이티브 코틀린 객체이기 때문이다.

     

    두 번째 코드를 internal DSL이라고 부른다.

     

    11.1.3 DSL의 구조

    코틀린 DSL에서는 보통 람다를 중첩시키거나 메소드 호출을 연쇄시키는 방식으로 구조를 만든다.

    그런 구조는 위에서 살펴본 SQL 예제에서 역시 확인할 수 있다.

     

    질의를 실행하기 위해 필요한 메소드들을 조합해야하며, 그렇게 메소드를 조합해서 만든 질의는 질의에 필요한 인자를 메소드 호출 하나에 모두 넘기는 것 보다 훨씬 더 가독성이 높다.

     

    JUnit과 KotlinTest의 예제를 살펴보자.

    //kotlintest
    str should startWith("kot")
    
    // JUnit
    assertTrue(str.startsWith("kot"))
    

    일반 JUnit API를 사용해 테스트를 작성하면 더 읽기 복잡하다.

     

    이에 비해 중위 호출을 사용한 코틀린 코드가 훨씬 더 직관적이고 이해하기 쉬운 것을 확인할 수 있다.

     

    11.1.3 내부 DSL로 HTML 만들기

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

    위 코드는 아래와 같은 HTML을 만들어낸다.

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

    왜 이렇게 HTML을 코틀린을 사용하여 만들까?

     

    이유는 다음과 같다.

    • 코틀린이 타입 안정성 보장
    • DSL 안에서 코틀린 코드를 원하는 대로 사용 가능 (for, if …)

     

    11.2 구조화된 API 구축 : DSL에서 수신 객체 지정 DSL 사용

    수신 객체 지정 람다는 구조화된 API를 만들 때 도움이 되는 강력한 코틀린 기능이다.

     

    11.2.1 수신 객체 지정 람다와 확장 함수 타입

    with나 apply 같은 scope function 에서 수신 객체 람다에 대해 소개했었다.

    이제 buildString 함수를 통해 코틀린이 수신 객체 지정 람다를 어떻게 구현하는지 살펴보자.

     

    buildString은 한 StringBuilder 객체에 여러 내용을 추가할 수 있다.

    람다를 인자로 받는 buildString()을 정의해보자.

    fun buildString(
        builderAction: (StringBuilder) -> Unit
    ) : String {
        val sb = StringBuilder()
        builderAction(sb)
        return sb.toString()
    }
    
    val s = buildString {
        it.append("Hello, ")
        it.append("World!")
    }
    
    println(s)
    // Hello, World!

    이 코드는 이해하기 쉽지만 사용하기 편리하지는 않다.

     

    람다 본문에서 매번 it을 사용해 StringBuilder를 참조해야하기 때문이다.

    이번에는 수신 객체 지정 람다를 사용하여 it이라는 이름을 사용하지 않는 람다를 인자로 넘겨보자.

    fun buildString(
        builderAction: StringBuilder.() -> Unit
    ) : String {
        val sb = StringBuilder()
        sb.builderAction()
        return sb.toString()
    }
    
    val s = buildString {
        this.append("Hello, ")
        append("World!")
    }
    
    println(s)
    // Hello, World!
    

    이전 코드와 현재 코드를 비교해보자.

     

    우선 builderAction의 타입이 달라졌다. (이는 확장 함수 타입을 사용했다.)

     

    확장 함수 타입 선언은 람다의 파라미터 목록에 있는 수신 객체 타입을 파라미터 목록을 여는 괄호 앞으로 빼 놓으면서 중간에 마침표를 붙인 형태다.

    확장 함수 타입 선언 과정
    
    (StringBuilder) -> StringBuilder() -> StringBuilder.()

    확장 함수 타입을 함수의 매개변수 타입으로 지정함으로써 수신 객체 지정 람다를 인자로 받을 수 있게 되었다.

     

    그러면 왜 굳이 확장 함수 타입일까?

     

    확장 함수의 본문에서는 확장 대상 클래스에 정의된 메소드를 마치 그 클래스 내부에서 호출하듯이 사용할 수 있었다.

     

    확장 함수나 수신 객체 지정 람다에서는 모두 함수(람다)를 호출할 때 수신 객체를 지정해야만 하고, 함수(람다) 본문 안에서는 그 수신 객체를 특별한 수식자 없이 사용할 수 있다.

     

    일반 람다를 사용할 때는 StringBuilder 인스턴스를 builderAction(sb)처럼 StringBuilder를 매개변수로 전달해야하지만, 수신 객체 지정 람다를 사용할 때는 sb.builderAction()으로 전달한다.

     

    즉, sb.builderAction()에서 builderAction은 StringBuilder 클래스 안에 정의된 함수가 아니며 StringBuilder 인스턴스인 sb는 확장 함수를 호출할 때와 동일한 구문으로 호출할 수 있는 함수 타입의 인자일 뿐이다.

     

    표준 라이브러리의 buildString 구현은 위의 구현보다 훨씬 짧은데 builderAction을 명시적으로 호출하는 대신 builderAction을 apply 함수에게 인자로 넘긴다.

     

    이렇게 하면 builderString을 단 한줄로 구현할 수 있다.

    fun buildString(builderAction: StringBuilder.() -> Unit): String = StringBuilder().apply(builderAction).toString()
    

     

     

    with와 apply 모두 수신 객체로 확장 함수 타입의 람다를 호출한다.

    inline fun <T> T.apply(block: T.() -> Unit): T {
        block()
        return this
    }
    
    inline fun<T,R> with(receiver: T, block: T.() -> R) : R {
        receiver.block()
    }
    

    차이가 있다면 apply의 경우 수신 객체의 메소드처럼 불리며, 수신 객체를 묵시적 인자(this)로 받는다. 이에 비해 with는 수신 객체를 첫 번째 파라미터로 받는다.

    또 다른 차이는, apply의 경우 수신 객체를 다시 반환하지만 with의 경우 람다를 호출해 얻은 결과를 반환한다.

     

     

Designed by Tistory.