본문 바로가기

스터디/다재다능 코틀린 프로그래밍

13. 내부 DSL 만들기

다재다능 코틀린 프로그래밍 책으로 코틀린 스터디를 진행하면서 발표를 위해 준비했던 글입니다.


DSL의 타입과 특징

외부 DSL

  • 자유도를 즐길 수 있다.
  • DSL 파싱, 처리할 파서를 만드는데 많은 노력이 필요
  • 유연성과 제약사항 사이의 균형
  • ex) CSS, ANT 빌드파일, Make 빌드파일

내부 DSL

  • 언어의 컴파일러와 툴들이 파서 역할을 해줌
  • 파싱하는데 노력 필요 X
  • 자연스러움과 표현력을 위해선 매우 창의력을 발휘해야하고 트릭을 적용해야 함
  • ex) Rake 빌드파일, Gradle 빌드파일

컨텍스트 주도와 유창성

  • 공통된 컨텍스트를 공유하고 있기에 커뮤니케이션이 간결하고 명확하게 만들어주며 표현력을 강화해준다.
  • 표현할 수 있는게 많고 기능이 풍부하기에 노이즈를 줄여주는 동시에 아이디어를 표현하기 쉽다.
  • 컨텍스트는 에러의 가능성을 줄여준다. 암시적 컨텍스트의 합리적 번위 안에서 단어의 의미가 나타난다.
  • 간결하고 표현력이 강한 문법을 선호한다.

내부 DSL을 위한 코틀린

DSL을 디자인할 때 도움이 되는 코틀린의 기능들

  1. 생략 가능한 세미콜론
  2. infix를 이용한 점과 괄호제거
  3. 확장함수를 이용한 도메인 특화
  4. 람다를 마지막 파라미터로 사용하기
  5. 암시적 리시버
  6. Any 클래스의 4가지 메소드(also, apply, let, run)
  7. 현재 객체 참조를 위한 this 키워드
  8. 단일 파라미터 참조 it
  9. 연산자 오버로딩

유창성 확립 시 마주하는 난관

기술은 많기에 여러 기술 중 하나를 택해야 할 수 있고, 때로는 기술끼리 충돌을 일으킬 때도 있다.

확장함수와 infix를 사용한 예제

확장함수와 infix 키워드를 사용해서 이벤트와 날짜를 추적하는 예제를 작성해보자.

package chapter13

import chapter13.DateUtil.Tense.*
import java.util.*

infix fun Int.days(timing: DateUtil.Tense) = DateUtil(this, timing)

class DateUtil(private val number: Int, private val tense: Tense) {
    enum class Tense {
        AGO, FROM_NOW
    }

    override fun toString(): String {
        val today = Calendar.getInstance()
        when (tense) {
            AGO -> today.add(Calendar.DAY_OF_MONTH, -number)
            FROM_NOW -> today.add(Calendar.DAY_OF_MONTH, number)
        }
        return today.time.toString()
    }
}

days 메소드는 DateUtil.Tense enum 값을 받아 DataUtil 인스턴스를 리턴하고

DataUitl 클래스는 days로 넘어온 일 수를 int 인스턴스로 받고 전인지 후인지 알려주는 enum을 받아 이벤트 개최일을 Calendar 인스턴스로 처리한다.

아래와 같이 DateUtil 클래스를 컴파일 하고 실행하면 원하는 결과가 출력된다.

kotlinc-jvm DateUtil.kt -d datedsl.jar
kotlinc-jvm -classpath datedsl.jar -script date-util-use.ws.kts
Sat Oct 21 01:55:33 KST 2021
Wed Oct 25 01:55:33 KST 2021

확장함수로 DSL을 작성했지만 이런 쉬운 상황은 잘 나오지 않는다. 더 많은 것을 요구하는 DSL을 살펴보자.

리시버와 infix를 사용한 예제

다음 회의 스케줄을 쉽게 잡을 수 있는 프로그램을 작성해보자.

"Release Planning" meeting {
    start at 14.30
    end by 15.20
}

회의 스케줄을 잡는 로직을 위처럼 가독성 좋게 짜고 싶다. 리시버와 infix를 이용해 구현해보자.


class Meeting(val title: String) {
    var startTime: String = ""
    var endTime: String = ""
    private fun convertToString(time: Double) = String.format("%.02f", time)
    fun at(time: Double) {
        startTime = convertToString(time)
    }

    fun by(time: Double) {
        endTime = convertToString(time)
    }

    override fun toString() = "$title Meeting starts $startTime ends $endTime"
}
infix fun String.meeting(block: Meeting.() -> Unit) {
    val meeting = Meeting(this)
    meeting.block()
    println(meeting)
}

"Release Planning" meeting {
    at(14.30)
    by(15.20)
}

// Release Planning Meeting starts 14.30 ends 15.20

Meeting 클래스와 String.meeting 확장함수로 구현했고 출력 결과도 기대했던 대로이다.

하지만 "Release Planning" meeting 부분의 람다는 의도와 다르게 구현되었다.

at과 by가 의미하는 바를 파악하기 힘들다. at과 by 메소드에 infix 키워드를 사용해 의도했던 대로 만들 수 있지 않을까?

infix fun at(time: Double) {
        startTime = convertToString(time)
}

infix fun by(time: Double) {
        endTime = convertToString(time)
}
"Release Planning" meeting {
    this at 14.30
    this by 15.20
}

at과 by의 소괄호는 성공적으로 없앴지만 앞에는 start와 end가 아닌 this가 붙어버렸다. infix를 사용하려면 메소드가 호출될 인스턴스가 필요하기 때문이다.

마지막으로 Meeting 클래스에 start, end 속성을 추가해주면 원하던 그대로 로직을 완성할 수 있다.

class Meeting(val title: String) {
    var startTime: String = ""
    var endTime: String = ""
    val start = this
    val end = this
    private fun convertToString(time: Double) = String.format("%.02f", time)
    infix fun at(time: Double) {
        startTime = convertToString(time)
    }

    infix fun by(time: Double) {
        endTime = convertToString(time)
    }

    override fun toString() = "$title Meeting starts $startTime ends $endTime"
}
infix fun String.meeting(block: Meeting.() -> Unit) {
    val meeting = Meeting(this)
    meeting.block()
    println(meeting)
}

"Release Planning" meeting {
    start at 14.30
    end by 15.20
}

람다가 암시적 리시버를 사용하기에 start at 14.30 으로 코드를 작성하면 start.at(14.30)으로 인식하고 컴파일러는 this.start.at(14.30)로 취급하면서 원하는 결과를 얻을 수 있는 것이다.

하지만 이러한 구현방식에는 문제점이 있다.

  1. DSL을 사용하는 유저가 start at 대신 start by로 호출하거나 end by 대신 end at으로 호출하는 걸 막을 수 없다.
  2. start나 end를 타이핑하면 IDE에서 at과 by를 둘 다 추천해서 잘못된 길로 이끌 수 있다.

at과 by 메소드를 분리된 클래스로 옴ㄹ겨서 잠재적인 에러를 예방해보자.

package chapter13

open class MeetingTime(var time: String = "") {
    protected fun convertToString(time: Double) = String.format("%.02f", time)
}

class StartTime: MeetingTime() {
    infix fun at(theTime: Double) {
        time = convertToString(theTime)
    }
}

class EndTime: MeetingTime() {
    infix fun by(theTime: Double) {
        time = convertToString(theTime)
    }
}

class Meeting(val title: String) {
    val start = StartTime()
    val end = EndTime()
    override fun toString() = "$title Meeting starts ${start.time} ends ${end.time}"
}

infix fun String.meeting(block: Meeting.() -> Unit) {
    val meeting = Meeting(this)
    meeting.block()
    println(meeting)
}

"Release Planning" meeting {
    start at 14.30
    end by 15.20
}

잠재적인 에러도 예방하면서 처음 의도했던대로 가독성 좋게 회의를 잡을 수 있게됐다.

스코프 제어를 통한 접근 제한

코틀린을 사용해서 내부 DSL을 만들면 호스트 언어에 탑승하는 이득이 있다. 또한 파서를 구현할 필요가 없다.

이런 장점은 개발을 편하게 해준다. 하지만 이런 자유가 너무 과할 때도 있다.

이럴 때 코틀린은 접근을 제한하기 위해 스코프 컨트롤 어노테이션을 사용한다.

val xmlString = xml {
    root("languages") {
        langsAndAuthors.forEach { name, author ->
            element("language", "name" to name) {
                element("author") { text(author) }
            }
            root("oops") // 말이 안되는 호출
        }
    }
}

xml 생성 코드에 root가 두 번 들어가도 에러를 뱉지 않는다. 두 번째 root 호출도 첫 번째 root 호출을 한 인스턴스와 같은 인스턴스로 라우팅된다. 현재 리시버에서 처리 불가능하면 부모 리시버로 가기 때문.

코틀린에게 말이안되는 호출을 거부하라고 요청해야 할 땐 DSL 마커 어노테이션을 추가해야 한다.

커스텀 어노테이션을 @DslMarker 어노테이션으로 아래와 같이 생성한다.

@DslMarker
annotation class XMLMarker

DSLMarker 어노테이션이 적용된 모든 클래스는 코틀린 컴파일러가 객체 참조가 없는 호출을 제한한다.

다시말하면 현재 리시버에서 처리 불가능하면 컴파일이 실패한다.

원한다면 부모 리시버의 멤버를 명시적으로 호출할 수 있다.

this@xml.root("oops") {}

DSL을 설계할 때는 반드시 DSL 마커 어노테이션을 사용해야한다. 탑레벨 메소드 호출을 거부하거나 렉시컬 스코프의 변수, 멤버에 접근을 거부하지 않고 자동으로 부모 리시버의 메소드를 호출하는 것을 거부할 때 도움이 된다.