Kotlin - DSL(2): `invoke()` 관례, 실전 DSL


이 포스트에서는 DSL 에 대해 알아본다.

  • invoke() 관례 사용

invoke() 관례를 사용하면 DSL 코드 안에서 람다와 프로퍼티 대입을 더 유연하게 조립할 수 있다.

소스는 github 에 있습니다.


목차


개발 환경

  • 언어: kotlin 1.9.23
  • IDE: intelliJ
  • SDK: JDK 17
  • 의존성 관리툴: Gradle 8.5

1. invoke() 관례를 사용한 블록 중첩

invoke() 관례를 사용하면 함수처럼 호출할 수 있는 객체를 만드는 클래스를 정의하여 객체를 함수처럼 호출할 수 있다.
하지만 이 기능은 일상적으로 사용하기 위한 기능은 아님을 유의해야 한다.
invoke() 관례를 남용하면 1 () 과 같이 이해하기 어려운 코드가 생길 수 있다.

DSL 에서는 invoke() 관례가 유용할 때가 있는데 먼저 invoke() 관례 자체에 대해 먼저 알아본다.


1.1. invoke() 관례: 함수처럼 호출할 수 있는 객체

Kotlin - 연산자 오버로딩, ‘infix’, 가변 컬렉션에 ‘+=’, ‘+’ 적용, Comparable, 구조 분해 연산자 에서 코틀린 관례에 대해 살펴보았다.

invoke() 에 대한 간단한 설명은 1.7. 호출 연산자: invoke() 를 참고하세요.

관례는 특별한 이름이 붙은 함수를 일반 메서드 호출 구문으로 호출하지 않고 더 간단한 다른 구문으로 호출할 수 있게 지원하는 기능이다.

예를 들어 foo 라는 변수에 대해 foo[bar] 라는 식을 사용하면 foo.get(bar) 로 변환된다.
이 때 get()Foo 라는 클래스 안에 정의된 함수이거나, Foo 에 대해 정의된 확장 함수이어야 한다.

operator 변경자가 붙은 invoke() 메서드 정의가 들어있는 클래스의 객체를 함수처럼 호출할 수 있다.

클래스안에서 invoke() 메서드를 정의하는 예시

package com.assu.study.kotlin2me.chap11.invoke

class Greeter(val greeting: String) {
    // Greeter 클래스 안에 invoke() 메서드 정의
    operator fun invoke(name: String) {
        println("$greeting, $name~")
    }
}

fun main() {
    val greeter = Greeter("Hi")
    
    // Greeter 인스턴스를 함수처럼 호출
    greeter("Assu") // Hi, Assu!
}

Greeter 클래스 안에 invoke() 메서드를 정의하였기 때문에 Greeter 인스턴스를 함수처럼 호출할 수 있다.

greeter("Assu") // Hi, Assu!

// 아래처럼 컴파일됨
greeter.invoke("Assu")

invoke() 관례는 미리 정해둔 이름을 사용한 메서드를 통해서 긴 식 대신 더 짧고 간결한 식을 사용할 수 있도록 해준다.

invoke() 메서드의 시그니처에 대한 요구 사항은 없다.
원하는 대로 파라메터 개수나 타입을 지정할 수 있고, 여러 파라메터 타입을 지원하기 위해 invoke() 를 오버로딩할 수도 있다.
이렇게 오버로딩한 invoke() 가 있는 클래스의 인스턴스를 함수처럼 사용할 때는 오버로딩한 여러 시그니처를 모두 다 활용할 수 있다.

이제 이런 관례를 실제로 어떻게 활용할 수 있는지에 대해 알아본다.
일반적인 프로그램을 작성할 때 invoke() 관례를 어떻게 활용하는지 먼저 알아본 후 DSL 에서 활용하는지 알아본다.


1.2. invoke() 관례와 함수형 타입

1.5. 반환 타입이 nullable 타입 vs 함수 전체의 타입이 nullable 에서 null 이 될 수 있는 함수 타입의 변수 호출 시 lambda?.invoke() 처럼 invoke() 를 안전한 호출 구문을 사용하여 호출하였다.

val transform: ((T) -> String)? = null
val str = transform?.invoke(ele) ?: ele.toString()

invoke() 관례

greeter("Assu") // Hi, Assu!

// 아래처럼 컴파일됨
greeter.invoke("Assu")

일반적인 람다 호출 방식(람다 뒤에 괄호를 붙이는 방식)이 실제로는 invoke() 관례를 적용한 것이라는 것을 이제 알 수 있다.

인라인하는 람다를 제외한 모든 람다는 함수형 인터페이스(Function1 등) 을 구현하는 클래스로 컴파일된다.
각 함수형 인터페이스 안에는 그 인터페이스 이름이 가리키는 개수만큼 파라메터를 받는 invoke() 메서드가 들어있다.

// 이 인터페이스는 정확히 인자를 2개 받는 함수를 표현함
interface Function2<in P1, in P2, out R> {
  operator fun invoke(p1: P1, p2: P2): R
}

람다를 함수처럼 호출해도 위 관례에 따라 invoke() 메서드 호출로 변환된다.

이런 사실을 알면 복잡한 람다를 여러 메서드로 분리하면서도 여전히 분리 전의 람다처럼 외부에서 호출할 수 있는 객체를 만들 수 있다.
또한 함수 타입 파라메터를 받는 함수에게 그 객체를 전달할 수 있다.

이런 식으로 기존 람다를 여러 함수로 나눌 수 있으려면 함수 타입 인터페이스를 구현하는 클래스를 정의해야 하는데, 이 때 기반 인터페이스를 FunctionN<P1, ..., PN, R> 타입이나 (P1, ..., PN) -> R 타입으로 명시해야 한다.

KFunction 에 대한 내용은 2.2.1. KFunctionN 인터페이스가 생성되는 시기와 방법 을 참고하세요.

함수 타입을 확장하면서 invoke() 를 오버라이딩하는 예시

package com.assu.study.kotlin2me.chap11.invoke

data class Issue(
    val id: String, val project: String, val type: String,
    val priority: String, val description: String,
)

// 함수 타입을 기반 클래스로 사용
class IssuePredicate(private val project: String): (Issue) -> Boolean {

    // invoke() 메서드 구현
    override fun invoke(issue: Issue): Boolean {
        return issue.project == project && issue.isImportant()
    }

    private fun Issue.isImportant(): Boolean {
        return type == "Bug" && (priority == "Major" || priority == "Critical")
    }
}

fun main() {
    val issue1 = Issue("111", "ONE", "Bug", "Major", "One desc")
    val issue2 = Issue("222", "TWO", "Feature", "Normal", "Two desc")

    val predicate = IssuePredicate("ONE")

    // Predicate 를 filter 로 넘김
    for (issue in listOf(issue1, issue2).filter(predicate)) {
        // Issue(id=111, project=ONE, type=Bug, priority=Major, description=One desc)
        println(issue)
    }
}

위 코드는 Predicate 의 로직이 너무 복잡해서 한 람다로 표현하기 어려울 때 접근할 수 있는 방식이다.

람다를 여러 메서드로 나누고, 각 메서드에 뜻을 명확히 알 수 있는 이름을 붙인다.
람다를 함수 타입 인터페이스를 구현하는 클래스로 변환하고, 그 클래스의 invoke() 메서드를 오버라이드하면 된다.

이런 접근 방법은 람다 본문에서 따로 분리해 낸 메서드가 영향을 끼치는 영역을 최소화할 수 있다는 장점이 있다.
오직 Predicate 클래스 내부에서만 람다에서 분리해 낸 메서드를 볼 수 있다.

Predicate 클래스 내부와 Predicate 가 사용되는 주변에 복잡한 로직이 있는 경우 이런 식으로 여러 관심사를 분리할 수 있다는 것은 큰 장점이다.


1.3. DSL 의 invoke() 관례: Gradle 에서 의존관계 정의

아래는 모듈 의존 관계를 정의하는 Gradle DSL 예시 코드이다.

블록 구조 허용 예시

dependencies {
    compile("junit:junit:4.11")
}

위 코드처럼 중첩된 블록 구조도 허용하고, dependencies 변수의 compile() 메서드를 바로 호출하는 구조도 허용한다면 설정해야 할 항목이 많은 경우엔 중첩된 블록 구조를 사용하고, 설정할 항목이 적으면 코드를 단순하게 유지하기 위해 간단한 함수 호출 구조를 사용할 수 있다.

간단한 함수 호출 구조 예시

dependencies.compile("junit:junit:4.11")

간단한 함수 호출 구조를 dependencies 변수에 대해 compile() 메서드를 호출한다.

dependencies 안에 람다를 받는 invoke() 메서드를 정의하면 블록 구조 허용 방식으로 사용 가능하다.
invoke() 를 사용하는 경우 호출 구문을 완전히 풀어쓰면 아래와 같다.

dependencies.invoke( { ... } )

dependencies 객체는 DependencyHandler 클래스의 인스턴스이다.
DependencyHandler 안에는 compile(), invoke() 메서드 정의가 들어있다.
invoke() 메서드는 수신 객체 지정 람다를 파라메터로 받는데, 이 람다의 수신 객체는 다시 DependencyHandler 이다.
DependencyHandler 가 묵시적 수신 객체이므로 람다 안에서 compile() 과 같은 DependencyHandler 의 메서드를 직접 호출할 수 있다.

class DependencyHandler {
  // 일반적인 명령형 API 정의
  fun compile(coordinate: String) {
      println("added dependency on $coordinate")
  }
  
  // invoke() 를 정의하여 DSL 스타일의 API 제공
  operator fun invoke(body: DependencyHandler.() -> Unit) {
      // this 는 함수의 수신 객체가 되므로 this.body() 와 동일
      body()
  }
}

DSL 방식의 호출 컴파일 결과

dependencies {
    compile("junit:junit:4.11")
}

// 아래와 같이 컴파일됨
dependencies.invoke({
    this.compile("junit:junit:4.11")
})

dependencies 를 함수처럼 호출하면서 람다를 인자로 넘기는데 이 때 람다의 타입은 확장 함수 타입(= 수신 객체를 지정한 함수 타입)이며, 지정한 수신 객체 타입은 DependencyHandler 이다.
invoke() 메서드는 이 수신 객체 지정 람다를 호출한다.
invoke()DependencyHandler 의 메서드이므로 이 메서드 내부에서 묵시적 수신 객체 thisDependencyHandler 객체이다.
따라서 invoke() 에서 DependencyHandler 타입의 객체를 따로 명시하지 않고도 compile() 를 호출할 수 있다.

이렇게 정의한 invoke() 메서드로 인해 DSL API 의 유연성이 훨씬 커진다.

이런 패턴은 일반적으로 적용할 수 있는 패턴이기 때문에 다른 곳에서도 기존 코드를 크게 변형하지 않고 사용할 수 있다.


2. 실전 DSL

이제 테스팅, 다양한 날짜 리터럴, DB 질의를 예시로 DSL 을 구성해본다.


2.1. 중위 호출 연쇄: 테스트 프레임워크의 should

깔끔한 구문은 internal DSL 의 핵심 특징 중 하나이다.

대부분의 internal DSL 은 메서드 호출을 연쇄시키는 형태로 만들어지기 때문에 메서드 호출 시 발생하는 잡음을 줄여주는 기능이 있다면 크게 도움이 된다.
메서드 호출의 잡음을 줄여주는 기능으로는 람다 호출을 간결하게 해주는 기능이나 중위 함수 호출 이 있다.

kotest DSL 에서 중위 호출을 어떻게 활용하는지 알아보자.

// s 가 kot 로 시작하지 않으면 단언문 실패
s should startWith("kot")
testImplementation("io.kotest:kotest-runner-junit5:5.5.0")

위 코드가 동작하기 위해선 should() 함수 선언 앞에 infix 변경자가 있어야 한다.

should() 시그니처

public infix fun <T> T.should(matcher: io.kotest.matchers.Matcher<T>): kotlin.Unit = matcher.test(this)

kotest DSL 에 사용하기 위한 Matcher 선언

interface Matcher<T> {
  fun test(value: T)
}
class startWith(val prefix: String): Matcher<String> {
  override fun test(value: String) {
      if (!value.startWith(prefix)) {
          throw AssertionError("String $value does not start with $prefix")
      }
  }
}

should() 함수는 Matcher 의 인스턴스를 요구한다.
Matcher 는 값에 대한 단언문을 표현하는 제네릭 인터페이스이다.
startWith()Matcher 를 구현한다.

startWith 의 경우 일반적이라면 클래스의 첫 글자를 대문자로 해야하지만 DSL 에서는 이런 일반적인 명명 규칙을 벗어나야 할 때가 있다.

아래 코드의 경우 중위 호출이 코드의 잡음을 효과적으로 줄여든다는 사실을 보여준다.

// s 가 kot 로 시작하지 않으면 단언문 실패
s should startWith("kot")

여기서 좀 더 개선하면 코드의 잡음을 더 많이 감소시킬 수 있다.

"kotlin" should start with "kot"

위 코드의 중위 호출을 일반 메서드 호출로 바꾸면 아래와 같다.

"kotlin".should(start).with("kot")

위 코드를 보면 should()with() 라는 두 메서드를 연쇄적으로 중위 호출하고 있고, startshould() 의 인자라는 것을 알 수 있다.
위 코드에서 start (싱글턴) 는 객체 선언을 참조하며, should()with() 는 중위 호출 구문으로 사용된 함수이다.


should() 함수 중에는 start 객체를 파라메터 타입으로 사용하는 특별한 오버로딩 버전이 있는데, 이 오버로딩한 should() 함수는 중간 래퍼 객체를 돌려준다.
이 래퍼 객체 안에는 중위 호출이 가능한 with() 메서드가 들어있다.

중위 호출 연쇄를 지원하기 위한 API 정의

object start

infix fun String.should(x: start): StartWrapper = StartWrapper(this)

class StartWrapper(val value: String) {
  infix fun with(prefix: String) = 
      if (!value.startWith(prefix)) {
          throw AssertionError("String does not start with $prefix: $value")
      } else {
          Unit
      }
}

DSL 이 아니라면 object 로 선언한 타입을 파라메터 타입으로 사용할 이유가 거의 없다.
싱글턴 객체에는 인스턴스가 단 하나밖에 없으므로 굳이 그 객체를 인자로 넘기지 않아도 직접 그 인스턴스에 접근할 수 있기 때문이다.

하지만 여기서는 객체를 파라메터로 넘길만한 타당한 이유가 있다.

여기서 start 객체는 함수에 데이터를 넘기기 위해서가 아니라 DSL 의 문법을 정의하기 위해 사용된다.
start 를 인자로 넘김으로써 should() 를 오버로딩한 함수 중에서 적절한 함수를 선택할 수 있고, 그 함수를 호출한 결과로 StartWrapper 인스턴스를 받을 수 있다.

StartWrapper 클래스에는 단언문의 검사를 실행하기 위해 필요한 값을 인자로 받는 with() 라는 멤버가 있다.


kotest 는 다른 Matcher 도 지원한다.

// "kotlin" 은 "in" 으로 끝나야 함
"kotlin" should end with "in"

// "kotlin" 은 "otl" 이라는 부분 문자열을 포함해야 함
"kotlin" should have substring "otl"

이런 문장을 지원하기 위해 should() 함수에는 end()have() 와 같은 싱글턴 객체 인스턴스를 취하는 오버로딩 버전이 더 존재한다.
이들은 싱글턴 종류에 따라 각각 EndWrapper, HaveWrapper 인스턴스를 반환한다.


중위 호출과 object 로 정의한 싱글턴 객체 인스턴스를 조합하면 DSL 에 상당히 복잡한 문법을 도입할 수 있고, 그런 문법을 사용하면 DSL 구문을 깔끔하게 만들 수 있다.


2.2. primitive 타입에 대한 확장 함수 정의: 날짜 처리

아래는 DSL 을 사용하여 날짜를 조회하는 코드이다.

package com.assu.study.kotlin2me.chap11

import java.time.LocalDate
import java.time.Period
import kotlin.test.Test

// 날짜 조작 DSL 정의
val Int.days: Period
  get() = Period.ofDays(this) // this 는 상수의 값을 가리팀

val Period.ago: LocalDate
  get() = LocalDate.now() - this

val Period.fromNow: LocalDate
  get() = LocalDate.now() + this  // 연산자 구문을 사용하여 LocalDate.plus() 호출함

class DateTest {
  @Test
  fun test1() {
    val yesterday = 1.days.ago
    val tomorrow = 1.days.fromNow

    println(yesterday) // 2024-11-15
    println(tomorrow) // 2024-11-17
  }
}

1.days.ago 와 같은 DSL 을 사용하기 위해 몇 줄의 날짜 조작 DSL 만 정의하면 된다.

kxdate github 에서 하루 단위 뿐 아니라 모든 시간 단위를 지원하는 완전한 구현을 볼 수 있다.


2.3. 멤버 확장 함수: SQL 을 위한 내부 DSL Exposed

2.2. primitive 타입에 대한 확장 함수 정의: 날짜 처리 를 통해 DSL 설계에서 확장 함수가 중요한 역할을 하는 것을 보았다.

이제 클래스 안에서 확장 함수와 확장 프로퍼티를 선언하여 DSL 을 설계하는 법에 대해 알아본다.
이렇게 정의한 확장 함수나 확장 프로퍼티는 그들이 선언된 클래스 멤버인 동시에 그들이 확장하는 다른 타입의 멤버이기도 하다.

이런 함수나 프로퍼티를 멤버 확장이라고 한다.


2.3.1. 멤버 확장을 사용하는 이유

멤버 확장을 DSL 에 이용하는 법을 알아보기 전에 Exposed 에서 DB 구조를 어떻게 정의할 수 있는지 먼저 살펴보자.

implementation("org.jetbrains.exposed:exposed-core:0.56.0")
package com.assu.study.kotlin2me.chap11.exposed

import org.jetbrains.exposed.sql.Table

object Country: Table() {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 50)
    override val primaryKey = PrimaryKey(id, name="PK_id")
}

위 선언은 아래 DDL 과 대응한다.

CREATE TABLE IF NOT EXISTS Country (
    id INT AUTO_INCREMENT NOT NULL,
    name VARCHAR(50) NOT NULL,
    CONSTRAINT PK_id PRIMARY KEY (id)
)

Country 객체에 속한 프로퍼티들의 타입을 살펴보면 각 컬럼에 맞는 타입 인자가 지정된 Column 타입을 볼 수 있다.
idColumn<Int> 타입이고, nameColumn<String> 타입니다.

Exposed 프레임워크의 Table 클래스는 위 타입을 포함하여 DB 테이블에 대해 정의할 수 있는 모든 타입을 정의한다.

class Table {
    public final fun integer(name: String): Column<Int>
    public final fun varchar(name: String, length: Int): Column<String>

    // ...
}

각 컬럼의 속성을 지정할 때 바로 멤버 확장이 사용된다.

  val id = integer("id").autoIncrement()
  val name = varchar("name", 50)
  override val primaryKey = PrimaryKey(id, name="PK_id")

autoIncrement() 의 경우 각 컬럼의 속성을 지정하는데 Column 에 대해 이런 메서드를 호출할 수 있다.
각 메서드는 자신의 수신 객체를 다시 반환하기 때문에 메서드를 연쇄 호출할 수 있다.

class Table {
  fun <T> Column<T>.primaryKey(): Column<T>
  
  // 숫자 타입의 컬럼만 자동 증가 컬럼으로 지정 가능
  fun Column<Int>.autoIncrement(): Column<Int>
}

이것이 바로 이런 메서드들을 멤버 확장으로 정의해야 하는 이유이다.
멤버 확장으로 정의하여 메서드가 적용되는 범위를 제한한다.

테이블이라는 맥락이 없으면 컬럼의 프로퍼티를 정의해도 아무런 의미가 없으므로 테이블 밖에서는 이런 메서드를 찾을 수 없어야 한다.

여기서 활용한 확장 함수의 다른 속성은 바로 수신 객체 타입을 제한하는 기능이다.
테이블 안의 어떤 컬럼이든 기본 키가 될 수 있지만, 자동 증가 컬럼은 정수 타입인 컬럼 뿐이다.


2.3.2. select() 멤버 확장 함수

SELECT 질의에서 볼 수 있는 다른 멤버 확장 함수에 대해 알아보자.

아래 코드는 Customer, Country 테이블이 있고 각 Customer 마다 그 고객의 국적을 나타내는 Country 레코드에 대한 FK 가 있는 상태에서 한국에 사는 모든 고객의 이름을 출력한다.

val result = (Country join Customer)
  .select { Country.name eq "Korea" }   // WHERE Country.name = "Korea"

result.forEach { println(it[Customer.naem]) }

select() 메서드는 Table 에 대해 호출되거나, 두 Table 을 조인한 결과에 대해 호출될 수 있다.
select() 의 인자는 데이터를 선택할 때 사용할 조건을 기술하는 람다이다.

eqColumn 을 확장하는 한편 다른 클래스에 속한 멤버 확장이라서 적절한 맥락에서만 쓸 수 있는 확장 함수이다.
eq 가 사용될 수 있는 맥락은 select() 메서드의 조건을 지정하는 경우이다.


2.3.3. 멤버 함수 정리

위에서 컬럼에 대한 2 종류의 확장에 대해 알아보았다.

하나는 Table 안에 선언해야만 하는 확장이고, 다른 하나는 where 조건에서 값을 비교할 때 쓰는 확장이다.

멤버 확장이 없다면 이 모든 함수를 Column 의 멤버나 확장으로 정의해야 하는데 그렇게 하면 맥락과 관계없이 아무데서나 그 함수들을 사용할 수 있다.

멤버 확장을 사용하면 각 함수를 사용할 수 있는 맥락을 제어할 수 있다.


정리하며..

  • 수신 객체 지정 람다는 람다 본문 안에서 메서드를 결정하는 방식을 재정의함으로써 여러 요소를 중첩시킬 수 있는 구조를 만들어 줌
  • 수신 객체 지정 람다를 파라메터로 받은 경우 그 람다의 타입은 확장 함수 타입임
  • 람다를 파라메터로 받아서 사용하는 함수는 람다를 호출하면서 람다에 수신 객체를 제공함
  • 중위 호출 인자로 특별히 이름을 붙인 객체를 사용하면 특수 기호를 사용하지 않는 실제 영어처럼 보이는 DSL 을 만들 수 있음
  • primitive 타입에 대한 확장을 정의하면 날짜 등 여러 종류의 상수를 가독성좋게 만들 수 있음
  • invoke() 관례를 사용하면 객체를 함수처럼 다룰 수 있음

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 드리트리 제메로프, 스베트라나 이사코바 저자의 Kotlin In Action 을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10