Kotlin DSL 간단히 알아보기
나만의 언어 만들기
[ English | 한국어 ]
DSL 이란 무엇일까요? DSL은 Domain Specific Language의 약자로 특정 도메인에 국한해 사용하는 언어입니다. 반대 개념으로는 General Purpose Language가 있으며 우리가 일반적으로 사용하는 C, C++, Kotlin, Swift 등의 프로그래밍 언어들이 이에 해당합니다.
정확한 예는 아니지만 실생활을 예로 들자면, 우리가 일상적으로 사용하는 언어가 General Purpose Language라면 의사, 변호사, 개발자 등의 전문 직업군 내에서 사용되는 전문용어는 Domain Specific Language라고 할 수 있습니다. 이 언어 만으로 일상생활은 어렵겠지만 해당 분야에 대해 이야기할 때는 보다 효율적으로 이야기할 수 있습니다.
개발자로서 우리는 프레임워크나 라이브러리들을 사용하면서 제공되는 DSL을 사용하게 됩니다. 이러한 DSL들은 General Purpose Language가 아니기 때문에 사용법을 찾아보기 위해서는 특정 언어의 명세가 아니라 해당 DSL을 정의한 프레임워크나 라이브러리의 가이드에서 찾아야 합니다.
우리는 Kotlin으로 안드로이드를 개발하면서도 꽤나 자주 이러한 DSL들을 접하고 있습니다. 예를 들면 다음과 같이 빌드 스크립트에서 안드로이드 관련 옵션을 명시한다거나,
Coil (이미지 로딩 라이브러리 중 하나)을 사용하면서 이미지 로딩에 필요한 옵션들을 명시할 때 사용합니다.
위 예제들을 살펴보면 빌더나 팩토리를 이용하여 필요한 옵션을 명시하고 객체를 생성하는 구현 패턴과 비교하여 더 간략하고 읽기 쉽게 작성되어 있다는 것을 알 수 있습니다. 이처럼 라이브러리 개발 시 DSL을 적절히 활용하여 제공하면 라이브러리 사용자는 보다 쉽고 간결하게 호출 코드들 작성할 수 있고, 잘 정의된 DSL은 상대적으로 자연어에 가까워 가독성도 높일 수 있습니다.
우리는 Kotlin 언어가 제공하는 다음의 몇 가지 기능들을 조합하여 간단히 DSL을 정의하고 사용할 수 있습니다.
- 람다 표현식 (Lambda Expression)
- 고계함수 (Higher-order Function)
- 확장함수 (Extension Function)
람다 표현식
어떤 함수를 파라미터와 반환값 만으로 나타낸 표현식으로, 식의 형태는 언어마다 조금씩 다르지만 Kotlin에서는 다음과 같이 add
함수를 나타낼 수 있습니다.
함수 표현 : fun add(num1: Int, num2: Int): Int람다 표현 : (Int, Int) -> Int
고계함수
어떤 함수가 하나 이상의 함수를 파라미터로 갖거나, 함수를 반환하는 경우 고계함수라고 부릅니다. 그리고 보통 이렇게 전달되거나 반환되는 함수는 람다 표현식으로 나타내게 됩니다. 다음과 같이 calculate
함수를 정의하면 세 번째 파라미터로 수행하고자 하는 연산을 구현하는 함수를 전달하여 동적으로 다른 연산이 수행되도록 할 수 있습니다.
고계 함수와 람다 표현식에 대한 자세한 내용은 공식 가이드에서 확인하실 수 있습니다.
확장함수
확장 함수는 이미 정의된 클래스나 인터페이스에 상속 없이 함수를 추가 정의할 수 있도록 Kotlin에서 제공되는 기능입니다. 예를 들어 Calculator 클래스에 clear() 함수를 추가 정의한다면 다음과 같이 정의하고 Calculator 클래스의 함수처럼 호출할 수 있습니다.
fun Calculator.clear() { ... }
확장 함수에 대한 자세한 내용은 공식 가이드에서 확인하실 수 있습니다.
자, 이제 이 재료들을 이용해서 간단한 DSL을 하나 정의해 봅시다. 우리는 결과적으로 다음과 같이 Http API 호출을 할 수 있는 DSL을 정의하고자 합니다 (그리 유용해 보이지 않을 수 있지만 DSL을 알아보이는 것이 목표 입니다).
코드를 살펴보면 전달된 url로 Get방식으로 Http 호출을 수행하는데, 전달된 헤더 값들(headers)도 요청 헤더로 포함합니다. 추가적으로 500, 501, 503 오류에 대해서는 최대 3회 재시도를 수행합니다.
위와 같은 Http API 호출을 가능하게 하기 위해서 먼저 url이나 method, headers 등의 입력된 값을 저장할 데이터 클래스를 정의합니다.
코드를 보면 HttpRequest라는 데이터 클래스를 정의하는데 내부 변수들이 var
로 선언되었으며 모두 기본값
을 가지고 있습니다. 이는 잠시후 알게되겠지만 HttpRequest 객체를 빈 객체로 먼저 생성 후 입력 값을 바인딩하기 때문입니다.
위 데이터 클래스에서 사용된 데이터 타입들은 다음과 같이 정의되어 있습니다 (MyResponse 클래스는 Http API 호출 후 결과 데이터가 바인딩 될 데이터 모델 클래스 입니다).
이제 본격적으로 HttpRequest 객체 생성을 위한 DSL을 정의해 봅시다.
inline
과 reified
키워드는 함수를 인라인화 한 후 제네릭 반환 타입을 알아내어 API 호출 결과를 파싱하는데 사용하기 위한 것 입니다.
중요한 부분은 1번 라인의 httpRequest 함수 파라미터입니다. 파라미터와 반환 값이 없는 람다 함수가 정의되어 있는데 HttpRequest의 확장함수로 정의되어 있습니다. 이런경우 우리는 HttpRequest를 () -> Unit 람다 함수의 리시버 클래스
라고 하며, 람다 함수는 해당 클래스 컨텍스트에서 실행되므로 클래스의 내부 속성이나 함수에 접근 가능합니다.
그래서 2번 라인과 같이 빈 HttpRequest 객체를 생성하고 해당 람다 함수를 호출하면서 전달하면 람다 내부에서는 HttpRequest 객체를 this로 접근할 수 있게 되는 것 입니다.
예제에 간결성을 위해서 실제 Http 호출이나 파싱 코드는 제외하였습니다.
그러면 retryPolicy DSL은 어떻게 정의되어 있을까요?
앞서 설명한 httpRequest DSL과 너무 흡사하여 추가 설명을 필요 없을 것이라 생각 됩니다.
이를 실제 호출하는 IDE에서 보면 각 람다 함수의 리시버(this) 힌트를 통해 보다 손쉽게 람다 블록의 리시버 클래스를 확인할 수 있습니다.
지금까지 Kotlin DSL을 작성하는 방법에 대해 간략히 알아보았습니다. 제가 실제 프로젝트에서 활용했던 예로는 프로젝트에서 사용되는 수많은 API들의 Endpoint url을 Build Phase(alpha, beta, real) 별로 작성할 수 있도록 DSL을 정의한 후 필요한 다양한 옵션도 추가하고 동적으로 Phase 변경도 가능하도록 구현했었습니다.
모든 것은 항상 중간이 어려운데 과하지 않게 꼭 필요한 곳에 적절이 사용한다면 코드를 더 가독성 있고 유연하게 해 줄 것이라 생각합니다. 😀
끝.