[Kotlin in Action] 11. DSL 만들기

akim·2023년 1월 26일
0

Kotlin in Action

목록 보기
12/12
post-thumbnail

11장에서는 영역 특화 언어 DSL을 사용해 표현력이좋고 코틀린다운 API를 설계하는 방법을 알아본다.

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

첫 번재 특성은 5장에서 간략하게 살펴본 수신 객체 지정 람다다. 수신 객체 지정 람다를 사용하면 코드 블록에서 이름(변수)이 가리키는 대상을 결정하는 방식을 변경해서 DSL 구조를 더 쉽게 만들 수 있다.

다른 한 특성은 invoke 관례로, 아직 다룬 적이 없다. invoke 관례를 사용하면 DSL 코드 안에서 람다와 프로퍼티 대입을 더 유연하게 조합할 수 있다.

11장에서 위 두 가지 특성을 자세히 살펴보자.


API에서 DSL로

모든 코드 작성의 궁극정인 목표는 코드의 가독성과 유지 보수성을 가장 좋게 유지하는 것이다.

이 목표를 달성하려면 개별 클래스에 집중하는 것만으로는 충분하지 않다. 클래스에 있는 코드 중 대부분은 다른 클래스와 상호작용한다. 따라서 이런 상호작용이 일어나는 연결 지점(인터페이스)를 살펴봐야 한다. 다른 말로 하면, 클래스의 API를 살펴봐야 한다.

라이브러리가 외부 사용자에게 프로그래밍 API를 지원하는 것처럼 애플리케이션 안의 모든 클래스는 다른 클래스에게 자신과 상호작용할 수 있는 가능성을 제공한다. 이런 상호작용을 이해하기 쉽고 명확하게 표현할 수 있게 만들어야 프로젝트를 계속 유지 보수할 수 있다.

그렇다면 API가 깔끔하다는 말은 어떤 뜻일까?

  1. 코드를 읽는 독자들이 어떤 일이 벌어질지 명확하게 이해할 수 있어야 한다.
    이름과 개념을 잘 선택하면 이런 목적을 달성할 수 있다.
    어떤 언어를 사용하든 이름을 잘 붙이고 적절한 개념을 사용하는 것이 매우 중요하다.
  2. 코드가 간결해야 한다.
    불필요한 구문이나 번잡한 준비 코드가 가능한 한 적어야 한다.
    깔끔한 API는 언어에 내장된 기능과 거의 구분할 수 없다.

깔끔한 API를 작성할 수 있게 돕는 코틀린 기능에는 확장 함수, 중위 함수 호출, 람다 구문에 사용할 수 있는 it 등의 문법적 편의, 연산자 오버로딩 등이 있다. 주요 기능들의 예시를 보면 아래와 같다.

//확장 함수
StringUtil.capitalize(s)
-> s.capitalize()

//중위 호출
1.to("one")
-> 1 to "one"

//연산자 오버로딩
set.add(2)
-> set += 2

//get 메서드에 대한 관례
map.get("key")
-> map["key"]

//람다를 괄호 밖으로 빼내는 관례
file.use({ f -> f.read() })
-> file.use{ it.read() }

//수신 객체 지정 람다
sb.append("yes")
-> with(sb) {
		append("yes")
   }

11장에서는 위와 같은 깔끔한 API의 작성에서 한걸음 더 나아가 DSL 구축을 도와주는 코틀린 기능을 살펴본다.

코틀린 DSL은 간결한 구문을 제공하는 기능과 그런 구문을 확장해서 여러 메서드 호출을 조합함으로써 구조를 만들어내는 기능에 의존한다. 그 결과로 DSL은 메서드 호출만을 제공하는 API에 비해 더 표현력이 풍부해지고 사용하기 편해진다.


1. 영역 특화 언어란

DSL이라는 개념은 프로그래밍 언어라는 개념과 거의 마찬가지로 오래된 개념이다.

언어를 두 종류로 구분하면 아래와 같이 나눌 수 있다.

  • 범용 프로그래밍 언어: 컴퓨터로 풀 수 있는 모든 문제를 충분히 풀 수 있는 기능을 제공하는 언어 ex) Kotlin
  • 영역 특화 언어: 특정 과업 또는 영역에 초점을 맞추고 그 영역에 필요하지 않은 기능을 없앤 언어 ex) SQL

DSL은 스스로 제공하는 기능을 제한함으로써 오히려 더 효율적으로 목표를 달성할 수 있게 한다.

SQL 문장의 경우를 생각해보면 클래스나 함수를 선언하는 것부터 시작할 필요 없이 각 연산이 처리해야 할 작업에 맞춰 각각 서로 다른 문법과 키워드를 사용한다.

이런 압축적인 문법을 사용함으로써 DSL은 범용 언어를 사용하는 경우보다 특정 영역에 대한 연산을 더 간결하게 기술할 수 있다.


또한 DSL은 명령적인 범용 프로그래밍 언어에 비해 더 선언적이다.

명령적 언어는 어떤 연산을 완수하기 위해 필요한 각 단계를 순서대로 정확히 기술하는 반면, 선언적 언어는 원하는 결과를 기술하기만 하고 그 결과를 달성하기 위해 필요한 세부 실행은 언어를 해석하는 엔진에 맡긴다.

실행 엔진이 결과를 얻는 과정을 전체적으로 한꺼번에 최적화하기 때문에 선언적 언어가 더 효율적인 경우가 자주 있다. 반면 명령적 접근법에서는 각 연산에 대한 구현을 독립적으로 최적화해야 한다.

DSL의 단점으로는 DSL을 범용 언어로 만든 호스트 어플리케이션과 함께 조합하기가 어렵다는 점이 있다.

DSL은 자체 문법이 있기 때문에 다른 언어의 프로그램 안에 직접 포함시킬 수가 없다. 따라서 DSL로 작성한 프로그램을 다른 언어에서 호출하려면 DSL 프로그램을 별도의 파일이나 문자열 리터럴로 저장해야 한다.

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

또한 DSL과 호스트 언어의 문법이 서로 다르므로 두 언어를 함께 배워야 하고, 코드를 읽기 어려워지는 경우도 많다.


이러한 DSL의 문제를 해결하면서 이점을 살리는 방법으로 내부 DSL 이라는 개념이 점점 유명해지고 있다. 이제 이 내부 DSL에 대해 알아보자.


2. 내부 DSL

독립적인 문법 구조를 가진 외부 DSL과는 반대로 내부 DSL은 범용 언어로 작성된 프로그램의 일부며, 범용 언어와 동일한 문법을 사용한다.

따라서 내부 DSL은 완전히 다른 언어가 아니라 DSL의 핵심 장점을 유지하면서 주 언어를 특별한 벙법으로 사용하는 것이라 할 수 있다.

코드는 어떤 구체적인 과업을 달성하기 위한 것이지만, 범용 언어의 라이브러리로 구현되는 것이다.


3. DSL의 구조

DSL과 일반 API 사이에 잘 정의된 일반적인 경계는 없다. 종종 그 둘에 대한 평가는 주관적이기 때문에 "내 생각에 그건 DSL이야."와 같은 말을 쉽게 들을 수 있다.

그러나 다른 API에는 존재하지 않지만 DSL에만 존재하는 특징이 한 가지 있다. 바로 구조 또는 문법이다.

명령-질의 API 라고 불리는 전형적인 라이브러리는 여러 메서드로 이뤄지며, 클라이언트는 그런 메서드를 한 번에 하나씩 호출함으로써 라이브러리를 사용한다.
함수 호출 시퀀스에는 아무런 구조가 없으며, 한 호출과 다른 호출 사이에는 아무 맥락도 존재하지 않는다.

반대로 DSL의 메서드 호출은 DSL 문법에 의해 정해지는 더 커다란 구조에 속한다. 코틀린 DSL에서는 보통 람다를 중첩시키거나 메서드 호출을 연쇄시키는 방식으로 구조를 만든다.

질의를 실행하려면 필요한 결과 집합의 여러 측면을 기술하는 메서드 호출을 조합해야 하며, 그렇게 메서드를 조합해서 만든 질의는 질의에 필요한 인자를 메서드 호출 하나에 모두 다 넘기는 것 보다 훨씬 더 읽기 쉽다.

이런 문법이 있기 때문에 내부 DSL을 언어라고 부를 수 있다.

DSL에서는 여러 함수 호출을 조합해서 연산을 만들며, 타입 검사기는 여러 함수 호출이 바르게 조합됐는지를 검사한다. 결과적으로 함수 이름은 보통 동사(groupBy, orderBy) 역할을 하고, 함수 인자는 명사(Country.name) 역할을 한다.

DSL 구조의 장점은 같은 문맥을 함수 호출 시마다 반복하지 않고도 재사용할 수 있다는 점이다.

메서드 호출 연쇄는 DSL 구조를 만드는 또 다른 방법이다. 예를 들어 테스트 프레임워크에서 단언문을 여러 메서드 호출로 나눠서 작성하는 경우가 많다. 그런 단언문은 훨씬 더 읽기 쉽다.

특히 중위 호출 구문을 사용하면 가독성이 더 좋아진다.


4. 내부 DSL로 HTML 만들기

kotlin.html 라이브러리에서 가져온 API를 사용하여 칸이 하나인 표를 만드는 코드를 작성하면 아래와 같다.

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

createSimpleTable 함수는 HTML 조각이 들어있는 문자열을 반환한다.


그렇다면 직접 HTML 코드를 작성하지 않고 코틀린 코드로 만들려는 이유가 뭘까?

첫째로, 코틀린 버전은 타입 안전성을 보장한다.

tdtr 안에서만 사용할 수 있다. 그렇게 하지 않으면 컴파일이 되지 않는다.

다음으로, 코틀린 코드를 원하는 대로 사용할 수 있다.

표를 정의하면서 동적으로 표의 칸을 생성할 수 있다는 뜻이다.


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

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

fun buildString(
        builderAction: StringBuilder.() -> Unit
) : String {
    val sb = StringBuilder()
    sb.builderAction()
    return sb.toString()
}


>>> val s = buildString {
        this.append("Hello, ") //this 키워드는 StringBuilder 객체를 가리킴
        append("World!") //this 를 생략해도 묵시적으로 StringBuilder 객체가 수신 객체로 취급
    }

>>> println(s) 
Hello, World!

위 코드를 보면 buildString 에게 수신 객체 지정 람다를 인자로 넘기기 때문에 람다 안에서 it 을 사용하지 않아도 된다.
it.append() 대신 append() 를 사용한다. (완전한 문장은 this.append() 지만 클래스 멤버 안에서 보통 그렇듯 모호한 경우가 아니라면 this. 를 명시할 필요가 없다.)

다음으로 bulidString 함수의 선언을 보면 파라미터 타입을 선언할 때 일반 함수 타입 대신 확장 함수 타입을 사용했다.
확장 함수 타입 선언은 람다의 파라밑 목록에 있던 수신 객체 타입을 파라미터 목록을 여는 괄호 앞으로 빼 놓으면서 중간에 마침표를 붙인 형태다.

이때 앞에 오는 타입 (StringBuilder) 를 수신 객체 타입이라 부르며, 람다에 전달되는 그런 타입의 객체를 수신 객체라고 부른다.

왜 확장 함수 타입일까?

외부 타입의 멤버를 아무런 수식자 없이 사용한다는 말을 들으면 확장 함수라는 단어가 떠오를 것이다.

확장 함수의 본문에서는 확장 대상 클래스에 정의된 메서드를 마치 그 클래스 내부에서 호출하듯이 사용할 수 있었다. 확장 함수나 수신 객체 지정 람다에서는 모두 함수(람다)를 호출할 때 수신 객체를 지정해야만 하고, 함수(람다) 본문 안에서는 그 수신 객체를 특별한 수식자 없이 사용할 수 있었다.


코틀린에서는 withapply 함수를 자주 사용한다. 기본적으로 두 함수 모두 자신이 제공받은 수신 객체로 확장 함수 타입의 람다를 호출한다.

apply 는 수신 객체 타입에 대한 확장 함수로 선언됐기 때문에 수신 객체의 메서드처럼 불리며, 수신 객체를 묵시적 인자(this) 로 받는다. 또한 수신 객체를 다시 반환한다.

반면 with 는 수신 객체를 첫 번째 파라미터로 받고, 람다를 호출해 얻은 결과를 반환한다.

결과를 받아서 쓸 필요가 없다면 이 두 함수를 서로 바꿔 쓸 수 있다.


2. 수신 객체 지정 람다를 HTML 빌더 안에서 사용

HTML을 만들기 위한 코틀린 DSL을 보통은 HTML 빌더라고 부른다. HTML 빌더는 더 넓은 범위의 개념인 타입 안전한 빌더 의 대표적인 예다.

빌더를 사용하면 객체 계층 구조를 선언적으로 정의할 수 있다. 특히 XML이나 UI 컴포넌트 레이아웃을 정의할 때 빌더가 매우 유용하다.


3. 코틀린 빌더: 추상화와 재사용을 가능하게 하는 도구

프로그램에서 일반 코드를 작성하는 경우 중복을 피하고 코드를 더 멋지게 만들기 위해 반복되는 코드를 새로운 함수로 묶어서 이해하기 쉬운 이름을 붙일 수 있다.

하지만 외부 DSL인 SQL이나 HTML을 별도 함수로 분리해 이름을 부여하기는 어렵다. 그러나 내부 DSL을 사용하면 일반 코드와 마찬가지로 반복되는 내부 DSL 조각을 새 함수로 묶어서 재사용할 수 있다.


invoke 관례를 사용한 더 유연한 블록 중첩

invoke 관례를 사용하면 객체를 함수처럼 호출할 수 있다.

invoke 관례를 사용하면 함수처럼 호출할 수 있는 객체를 만드는 클래스를 정의할 수 있다. 그러나 이 기능이 일상적으로 사용하라고 만든 기능은 아니라는 점에 유의하자. invoke 관례를 남용하면 1() 과 같이 이해하기 어려운 코드가 생길 수 있다.

하지만 DSL에서는 invoke 관례가 아주 유용할 때가 자주 있다. 우선 그 관례에 대해 먼저 알아보자.

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

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

class Greeter(val greeting: String) {
    operator fun invoke(name: String) {
        println("$greeting, $name!")
    }
}

>>> val bavarianGreeter = Greeter("Servus")
>>> bavarianGreeter("Dmitry")
output
Servus, Dmitry!

이 코드는 Greeter 안에 invoke 메서드를 정의한다. 따라서 Greeter 인스턴스를 함수처럼 호출할 수 있다.

invoke 메서드의 시그니처에 대한 요구 사항은 없다. 따라서 원하는 대로 파라미터 개수나 타입을 지정할 수 있다.

심지어 여러 파라미터 타입을 지원하기 위해 invoke 를 오버로딩할 수도 있다. 이렇게 오버로딩한 invoke 가 있는 클래스의 인스턴스를 함수처럼 사용할 때는 오버로딩한 여러 시그니처를 모두 다 활용할 수 있다.


2. invoke 관례와 함수형 타입

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

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

람다를 함수처럼 호출하면 이 관례에 따라 invoke 메서드 호출로 변환된다.

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


만약 코드에서 술어의 로직이 너무 복잡해서 한 람다로 표현하기 어렵다면 여러 메서드로 나누고 각 메서드에 뜻을 명확히 알 수 있는 이름을 붙이고 싶을 수도 있다.

이때 람다를 함수 타입 인터페이스를 구현하는 클래스로 변환하고 그 클래스의 invoke 메서드를 오버라이드하면 그런 리팩토링이 가능하다.

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

술어 클래스 내부와 술어가 쓰이는 주변에 복잡한 로직이 있는 경우 이런 식으로 여러 관심사를 깔끔하게 분리할 수 있다는 사실은 큰 장점이다.


실전 코틀린 DSL

테스팅, 다양한 날짜 리버럴, 데이터베이스 질의, 안드로이드 UI 구성과 같은 실용적인 DSL 구성을 볼 수 있는데, 이 중 안드로이드와 관련된 내용에는 Anko를 이용한 UI 생성이 있다. 안코를 활용하는 내용에 대해서는 더 자세하게 정리하여 따로 포스팅하도록 하겠다.

profile
학교 다니는 개발자

0개의 댓글