ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 35장 - 복잡한 객체를 생성하기 위한 DSL을 정의하라
    Kotlin/Effective Kotlin 요약 2023. 3. 21. 00:01

    코틀린을 활용하면 DSL을 직접 만들 수 있습니다.

    DSL을 만드는 것은 수고스러우나, 한 번 만들고 나면 보일러플레이트와 복잡성을 숨기면서 개발자의 의도를 명확하게 표현할 수 있습니다.

     

     

    Spring Kotlin개발자에게는 다음과 같은 DSL이 친숙할 수 있습니다.

    • Kotest를 활용하여 테스트 케이스를 정의할 때
    • Gradle의 설정을 정의할 때

     

    사용자 정의 DSL 만들기

    DSL을 만들기 전 리시버를 사용하는 함수 타입에 대한 개념을 이해해야 합니다.

     

    람다식을 떠올리면 좋으며 함수 타입의 몇 가지 예시입니다.

    • () -> Unit
      • 아규먼트를 갖지 않고 Unit을 리턴
    • (Int, Int) -> Int
      • Int타입 아규먼트 2개를 받고, Int를 리턴하는 함수

     

    함수 타입을 만드는 기본적인 방법들

    • 람다 표현식
    • 익명 함수
    • 함수 레퍼런스

     

    예를 들어 다음과 같은 함수가 있습니다.

    fun plus(a: Int, b: Int) = a + b

     

    유사 함수는 다음과 같은 방법으로 만들 수 있습니다.

    val plus1: (Int, Int) -> Int = {a, b -> a + b}
    val plus2: (Int, Int) -> Int = fun(a,b) = a + b
    val plus3: (Int, Int) -> Int = ::plus

    프로퍼티 타입이 지정되어 있어, 람다 표현식과 익명 함수의 아규먼트 타입을 추론할 수 있습니다.

     

    반대로 아규먼트 타입을 지정하여 함수의 형태를 추론하게 할 수도 있습니다.

    val plus4 = {a: Int, b: Int -> a + b}

     

    확장 함수 예시

    fun Int.myPlus(other: Int) = this + other

     

    확장 함수를 익명 함수로 만드는 예시(이름을 빼버림)

    val myPlus = fun Int.(other: Int) = this + other

    해당 함수의 타입은 확장 함수를 나타내는 특별한 타입이 됩니다.

    이를 리시버를 가진 함수 타입이라 부릅니다.

    일반적인 함수타입과 비슷하지만 파라미터 앞에 리시버가 추가되어 점(.) 기호로 구분되어 있습니다.

     

    즉, 다음과 같은 모습입니다.

    val myPlus: Int.(Int)->Int = fun Int.(other: Int) = this + other

     

    리시버를 가진 익명 확장 함수와 람다 표현식은 다음과 같은 방법으로 호출할 수 있습니다.

    myPlus.invoke(1, 2)
    myPlus(1, 2)
    1.myPlus(2)

     

    이를 활용하여 HTML 표를 표현하는 DSL 만들기

    fun createTable(): TableDsl = table{
    	tr{
    		for(i in 1..2){
    			td{
    				+"This is column $i"
    			}
    		}    
    	}
    }

     

    DSL 앞부분에 table 함수가 존재하는 것을 볼 수 있습니다.

    현재 코드가 톱레벨에 위치하고, 별도의 리시버를 갖지 않으므로, table 함수도 톱레벨에 있어야 합니다.

    내부적으로는 tr을 사용하고 있는 것을 볼 수 있습니다.

    따라서 table 함수의 아규먼트는 tr 함수를 갖는 리시버를 가져야 합니다.

    비슷하게 tr 함수는 td 함수를 갖는 리시버를 가져야 합니다.

    fun table(init: TableBuilder.()->Unit): TableBuiler{
    	//...
    }
    
    class TableBuiler{
    	fun tr(init: TrBuilder.() -> Unit) /*...*/}
    }
    
    class TdBuilder

    +This is row $i"의 경우는 단순히 문자열이므로 다음과 같이 정의하면 됩니다.

    class TdBuilder{
    	var text = ""
    	
    	operator fun String.unaryPlus(){
    		text += this
    	}
    }

    모든 DSL을 모두 정의했으며, 각각의 단계에서 빌더와 파라미터를 활용해 값을 적절하게 초기화하면 됩니다.

     

    fun table(init: TableBuilder.()->Unit): TableBuilder{
    	val tableBuilder = TableBuilder()
    	init.invoke(tableBuilder)
    	return tableBuilder
    }

     

    apply 함수를 활용하면 더 간략하게 만들 수 있습니다.

    fun table(init: TableBuilder.()->Unit): TableBuilder{
    	TableBuilder().apply(init)
    }

     

    다른 부분들도 다음과 같은 형태로 간단하게 만들 수 있습니다.

    class TableBuilder{
    	var trs = listOf<TrBuilder>()
        
    	fun tr(init: TrBuilder.()->Unit){
    		trs = trs + TrBuilder().apply(init)
    	}
    }
    
    class TrBuilder{
    	var tds = listOf<TdBuilder>()
        
    	fun td(init: TdBuilder.()->Unit){
    		tds = tds + TdBuilder().apply(init)
    	}
    }

     

    여기에 DslMarker를 활용하면 코드를 조금 더 좋게 만들 수 있습니다.

     

     

    DSL의 단점

    DSL은 사용자 입장에서 이 정보가 어떻게 활용되는지 명확하지 않습니다

    또한 복잡한 사용법은 찾기 힘들 수 있으며 혼란을 야기할 수 있습니다.

    유지보수도 마찬가지이며 단순한 기능까지 DSL을 사용한다는 것은 닭 잡는 데 소 잡는 칼을 쓰는 꼴입니다.

     

    DSL이 유용한 경우

    • 복잡한 자료 구조
    • 계층적인 구조
    • 거대한 양의 데이터

    좋은 DSL을 만드는 작업은 매우 힘드나, 잘 정의된 DSL은 프로젝트에 굉장히 큰 도움을 줍니다.

     

     

    댓글

Designed by Tistory.