5 분 소요

코틀린에는 Kotlin DSL을 활용해 코드의 가독성과 작성의 편리함을 가져가는 방식이 있다.
물론 이런 장점을 얻기위해선 코틀린에서 제공하는 문법적인 특성을 이해하고 활용할 수 있어야 한다!
코틀린 강의나 책을 찾아보면 빠지지 않고 등장하는 키워드여서, 활용 사례도 찾아보니 흥미가 생겨 공부도 할겸 작성해보게 됐다.

DSL 이란

Kotlin DSL이 뭔지 다루기 전에 DSL이 무엇인지 부터 정의하고 가자.
DSL은 도메인 특화 언어(Domain-Specific Languages)의 약자다. (당연하게도) 특정 영역(Domain)에서만 사용하며, 그 영역에서 원하는 목적을 달성하는데에 집중해서 만들어진 언어이다. C, Java, Python, Kotlin처럼 도메인에 상관없이 사용할 수 있는 범용 프로그래밍 언어를 지칭하는게 아니라, 특정 도메인에서만 사용할 수 있는 언어를 말한다. 대표적인 예시로는 SQL, HTML이 있다!

SELECT * FROM member WHERE id = 1;

DBMS에 쿼리를 날려서 원하는 데이터를 얻거나 조작하고 싶을 때 우리는 SQL을 사용한다.
SQL은 DB라는 도메인에서만 사용 가능하고, 다른 도메인에서는 사용할 수 없다.

HTML은 웹페이지를 구조화하는데만 사용되고, 브라우저에서는 이 HTML를 보고 렌더링을 수행한다.
하지만 이것 역시 다른 도메인에서는 사용할 수 없다.

이런 DSL을 코틀린 문법을 활용해서 만들어볼 수 있다!

Kotlin DSL

활용 예시 : build.gradle.kts

코틀린 프로젝트를 시작했다면 build.gradle이 아니라 build.gradle.kts를 사용중일 것이다.
dependencies 내용을 보면 대략 아래와 같을 것이다.

dependencies {
    implementation("org.reflections:reflections:0.10.2")
    implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.10")
    testImplementation(kotlin("test"))
    testImplementation("org.assertj:assertj-core:3.24.2")
}

dependencies에서 스코프를 열고 implementation, testImplementation 등을 선언해주고 있는 모습이다.
근데 이걸 어떻게 정의해줘야 dependencies에서 스코프를 열고 implementation, testImplementation를 정의해줄 수 있는 걸까? 다음 코틀린 문법을 확인해보면 간단히 끄덕일 수 있을 것이다.

사용되는 Kotlin 문법

  • 고차함수의 함수형 파라미터는 스코프를 열어서 정의 가능

고차함수는 함수형 타입을 파라미터 또는 반환 타입으로 활용하는 함수를 말한다. 아래는 파라미터에 함수를 넣은 예시다.
Kotlin DSL에서는 이런 스타일로 작성을 자주하게 될 것이다.

// 소괄호에 선언한 경우
fun V1() {
  val param: (Int) -> Int = {
    it + 1
  }
  plusOne(param)
}

// 소괄호에 선언하지 않은 경우 -> 스코프를 열어서 정의해줘도 된다
fun V2() {
  plusOne {
    it + 1
  }
}

private fun plusOne(param: (Int) -> Int) {
  print("result: ${param(1)}") // 2
}


  • 확장함수 (선택)

코틀린에서는 특정 클래스의 함수를 클래스 내부에서 정의해주지 않아도 된다. 이를 확장함수라고 한다.
굳이 해당 클래스를 상속받는 등의 행위를 할 필요가 없어서 편리하다.

class TestClass {
}

fun TestClass.test2() {
}

val testClass = TestClass()
testClass.test2()


  • infix 함수 (선택)

infix 함수는 객체 사이에 선언할 수 있는 함수로, 함수명을 선언해주기만 하면 된다.
코틀린에서는 2개의 값을 쌍으로 표현하고 싶을 때 Pair로 감쌀 수 있는데, 이걸 to라는 infix 함수로 쉽게 표현 가능하다.
파라미터를 표현하기 위해 ()를 사용하지 않아도 되고 함수명만 적어주면 돼서 명확한 이름으로 정의해준다면 가독성이 높아지는 장점이 있다.

val v1 = Pair(1, 2)
val v2 = 1 to 2 // infix 함수 to 적용
println(v1 == v2) // true


직접 선언해서 활용할 때는 확장함수 형태로 작성한다.
아래는 Int? 타입일 때 infix 확장함수로 equals를 정의한 예시이다.

infix fun Int?.equals (other: Int?) = if (this != null && other != null) this == other else false

val v1 = 1
val v2 = null
println(v1 equals v2) // false


어떤 원리로 작성하는가?

클래스고차함수를 정의하는 것부터 시작한다. 클래스명은 Person으로 하고, 고차함수는 startFunc()라고 해보자.
그리고 Personwalk(), cook(), sleep() 이라는 메소드를 갖고 있다고 해보자.

class Person {
  fun walk() {}
  fun cook() {}
  fun sleep() {}
}

fun startFunc(personFunc: Person.() -> Unit) {
  val person = Person()
  person.personFunc()
}

startFunc()의 파라미터부터 보자. 파라미터의 타입이 Person.() -> Unit로 정의되어 있다.
Unit은 반환값이 없는 거니까 간단한데, Person.()에서 잠깐 멈칫했을 수도 있다. 왜 저렇게 정의를 해줘야 하는 걸까?

고차함수를 정의할 때 파라미터에 들어가는 함수를 떠올려보자.
만약 () -> Int를 예시로 들면, ()는 파라미터를 의미하고 Int는 반환 타입을 의미한다. 여기서 ()는 파라미터가 없다는 뜻이다.
(Int) -> Int면 파라미터에 Int가 1개 들어온다는 뜻이다. 이 함수를 호출하면 넣어준 파라미터를 it으로 접근할 수 있다.

private fun plusOne(param: (Int) -> Int) {
  print("result: ${param(1)}") // 파라미터로 1을 넣어줌
}

plusOne {
  it + 1 // it은 1
}


그렇다면 Int.() -> Int에서 Int.()는 뭘까? 이건 Int라는 클래스에 정의된 메소드를 호출하겠다는 뜻이다.
그래서 아래처럼 호출이 가능하다. 클래스에 접근하고 싶으면 this를 사용하면 된다. Int의 메소드를 호출한거니까!
여기서 this수신 객체라고 한다.

private fun plusOne(param: Int.() -> Int) {
  print("result: ${1.param()}") // Int 타입인 1을 사용
}

plusOne {
  this + 1 // this는 1
}


다시 돌아와서 Person.() -> Unit을 보면, () -> Unit과 별 다를건 없다.
추가된게 있다면 수신 객체인 Person 클래스에 정의된 메소드만 호출하도록 조건을 걸어둔 것이다.
그래서 startFunc(Person.() -> Unit)을 호출하면 다음과 같은 모습이 된다.

fun startFunc(personFunc: Person.() -> Unit) {
  val person = Person()
  person.personFunc() // Person 타입인 person의 메소드 personFunc를 호출
}

startFunc { // 수신객체 : Person
  walk()
  cook()
  sleep()
}


아까 Kotlin DSL이 가독성이 좋다고 했는데, 바로 위 예시에서 walk(), cook(), sleep()를 보자.
this.walk(), this.cook(), this.sleep()이 아니라 walk(), cook(), sleep()만 적어주고 있다!
클래스 내 변수나 메소드를 참조하는데 this를 굳이 사용하지 않아도 되는 것처럼, 이것 또한 this가 Person 타입이어서 그렇다.
즉, Person.()을 적는게 지저분하게 this를 사용하지 않고 함수명만 볼 수 있다는 점이 가독성 향상으로 이어지는 장치가 된다.

글 초반부에 build.gradle.kts에서 살펴본 dependencies, implementation, testImplementation를 다시 보자.

implementation(), testImplementation()DependencyHandler의 메소드고,
dependencies의 파라미터DependencyHandlerScope의 메소드여서 약간 혼동이 있을 수 있지만
DependencyHandlerScope가 DependencyHandler의 자식이어서 호출 가능하다는 걸 알 수 있다.

?


// 패키지 생략
public fun dependencies(configuration: DependencyHandlerScope.() -> Unit): Unit { /* compiled code */ }
public fun DependencyHandler.implementation(dependencyNotation: Any): Dependency? { /* compiled code */ }
public fun DependencyHandler.testImplementation(dependencyNotation: Any): Dependency? { /* compiled code */ }

dependencies {
    implementation("org.reflections:reflections:0.10.2")
    implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.10")
    testImplementation(kotlin("test"))
    testImplementation("org.assertj:assertj-core:3.24.2")
}


스코프를 중첩해서 호출하고 싶으면 어떻게 해야할까?
다시 Person 예시로 돌아가보자. 그리고 cook()의 파라미터를 수정해보자.

class Person {
  fun walk() {}
  fun cook(sauce: Sauce.() -> Unit) {} // Sauce의 메소드를 써보자
  fun sleep() {}
}

class Sauce { // 새로 정의!
  fun put() {}
}


위처럼 정의해주면 cook 스코프에서 수신객체가 Sauce가 되기 때문에 Sauce.put()을 호출할 수 있다!
이런 방식으로 스코프를 중첩해서 호출할 수 있다.

startFunc {
  walk()
  cook { // 수정
    put()
  }
  sleep()
}


infix 함수도 같이 사용해준다면 의도하려는 동작을 명확하게 나타낼 수 있다.

class Sauce {
  val sauce = "칠리" // 추가
  fun put() {}
}

infix fun String.mix(other: String) = "$this with $other"

startFunc {
  walk()
  cook {
    sauce mix "마요네즈" // "칠리" -> "칠리 with 마요네즈"
    put()
  }
  sleep()
}


@DslMarker

지금까지 startFunc -> cook만 작성했는데, startFunc -> cook -> cook으로 작성도 사실은 가능하다.

startFunc {
  cook {
    cook { // cook 안에서 cook 호출이 가능
    }
  }
}


이건 가장 안쪽의 cook을 호출할 때 startFunc의 수신객체Person에 접근할 수 있기 때문이다.
그래서 Person.cook()에 접근할 수 있었던 거고, 위의 표현 방식이 문제가 없는 것이다.

지금처럼 가장 가까운 수신객체(this@cook)가 아닌 바깥쪽의 수신객체(this@startFunc)에 접근하는 경우가 유용한 경우도 있겠지만,
그렇지 않은 케이스도 있을 것이다! 이런 경우 강제로 가장 가까운 수신객체만 택하도록 제약을 줄 수 있다.
이 때 사용하는게 @DslMarker라는 애노테이션이다. 애노테이션에만 적용할 수 있어서 직접 정의한 애노테이션이 있어야 한다.

@Target(ANNOTATION_CLASS) // 애노테이션에만 적용 가능
@Retention(BINARY)
@MustBeDocumented
@SinceKotlin("1.1")
public annotation class DslMarker

@DslMarker
annotation class InnermostReceiver // 애노테이션 정의


이렇게 정의한 애노테이션 @InnermostReceiver은 아래처럼 적용할 수 있다.
그러면 cook 앞에 아무 것도 적지 않으면 가장 가까운 수신객체에만 접근하도록 강제할 수 있다.
만약 외부 수신객체에도 접근하고 싶다면 this@startFunccook 앞에 적어주자.

@InnermostReceiver
class Person

@InnermostReceiver
class Sauce

startFunc {
  cook {
    cook { // cannot be called in this context by implicit receiver
    }
  }
}

startFunc {
  cook {
    this@startFunc.cook { // 외부 수신객체에 접근하고 싶다면 이렇게 써야 함
    }
  }
}

다른 활용예시

Kotlin DSL을 활용해서 기존의 복잡했던 로직을 효과적으로 개선한 국내 기업 사례다.

References

카테고리:

업데이트:

댓글남기기