본문 바로가기

프로그래밍 언어 노트/JAVA | Kotlin

Kotlin SQL DSL 을 구축해보자! 쓸 수 있는 방법을 전부 동원해봐서!

라이브러리를 설계하는 일은 사실 언어를 설계하는 일이다 (벨 연구소 격언)



C# 언어에는 LINQ 라는 문법이 존재한다. SQL의 Query 문과 유사한 문법으로 map/reduce/filter 를 수행 할 수 있으며, ORM (엔티티 프레임워크) 에서도 지원되서 진짜 쿼리짜듯이 C# 코드내 작성이 가능하다.

 

당연히 언어에 내장된 DSL 이기 떄문에, 컴파일체크도 되고, IDE 의 도움도 받을수 있고, 원하는 대로 조작할수도 있다. (함수 조합이 가능하다.)

 

C# 으로 일하다가 Kotlin (JAVA 계열) 로 넘어와서 느끼는 가장 불편한 점중 하나는

이런 SQL 을 위한 DSL 이 없다는점이다.

 

Spring boot 의 JPA 방식인 function name 으로 sql 을 생성하는 기괴한 방식은 말할것도없고.. 이런 문제를 해결하기위하여 JAVA 시절부터 자주 사용하는 라이브러리인 QueryDsl 나 JOOQ 가 있는데

  1. 어노테이션 기반의 메타프로그래밍의 한계.. 미리 컴파일 해야한다. 언어 내장적인 자연스러운 연결이 되지 않는다
  2. Java 언어에 한계내에서 구축한 DSL 이라 JAVA 식 DSL 형태를 띈다 (플루언트 API 와 자바식 Builder 패턴등)

 

그 이유는 Kotlin 이 워낙 JAVA 와 통합이 잘 되어 있다보니 (그게 목표이기도 하고) 그냥 JAVA 라이브러리를 고대로 가져다 쓰는 경향이 크기때문이다.

그래도 Line kotlin-jdsl 이나 JetBrains/Exposed 같이 Kotlin 을 타케팅으로 하는 DSL 이 존재하기도 하지만, 둘다 국내 Top 기업인 Line 과 Kotlin 을 만든 JetBrains 에서 만든 훌륭한 DSL 임에도 불구하고 아직까지는 QueryDSL 이 SQL DSL 을 구축할때 가장 널리 사용되는 방식인듯 하다. (필자도 회사에서는 JPA 랑 QueryDSL 사용)

어쨋든 C# 의 LINQ, Clojure 의 honeysql , Scala의 Quill 처럼 언어의 특징을 십분 활용해서 우아하게 짜는 DSL 을 찾고 있던중 발견한게 앞서 말한 [Kotlin JDSL: Kotlin을 이용해 좀 더 쉽게 JPA Criteria API를 작성해 봅시다 (linecorp.com)] 이다.

Line 의 DSL

링크를 통해서 살펴보면 알수 있듯이, 어노테이션 기반 메타프로그래밍이 아니기 때문에 사용하기 훨씬 수월하다. 지원하는 Endpoint 도 많은듯 하고..

다만 다만 다만.... 한가지 아쉬운 부분이 DSL 의 형태인데, Fluent API 형태로 구축되어 있다는 것이다. 사용성만 보면 QueryDsl 과 비슷하다.

Fluent API 가 나쁜것도 아니고 세상에서 아마 가장 많이쓰이는 DSL 형태 겠지만... 필자는 Fluent API 가 DSL 이라는 것을 머리로는 이해해도 가슴속에서 인정하고 있지 않기때문에..

아마 

  1. 기존 DSL 형태와 가장 유사한 형태를 채용함으로써 접근성을 높임
  2. 리플렉션과 같은 방법을 최대한 배재하여 런타임 성능을 최대화
  3. 일관된 DSL 형식 제공

등등.. 의 이유가 있지 않을까 싶다.

 

어쨋든, 이런 SQL DSL 을 보다가, 과연 어디까지 SQL 을 흉내 낼수 있을것인가 에 대한 호기심과 좀더 멋들어진 DSL 을 구축하는 다른 Kotlin 의 Feature 가 뭐가 있을까 싶어서 이것저것 실험 해 보았고

Lee-WonJun/klos: kotlin layer of sql (github.com)

 

GitHub - Lee-WonJun/klos: kotlin layer of sql

kotlin layer of sql. Contribute to Lee-WonJun/klos development by creating an account on GitHub.

github.com

요런식으로 활용해보았다.

단순하게 DSL 형태로 표현되는것에만 집중했기 때문에 현재 아무 쓰달데기 없는 레포지만

과연 Kotlin 스타일의 DSL은 어디까지 가능할것인가.. 를 내손으로 확인해보기 위한 레포지토리 이다.

 

확장 가능한 DSL을 구축하는 방법중 유명한 방법은 다음과 같다.

  • 나만의 언어 (즉 DSL) 의 ADT 를 설계하고,
  • 해당 ADT 를 구축할수 있는 Helper 함수등을 모아서 DSL Builder를 제공한뒤
  • 해당 ADT 는 이제 그냥 데이터들의 집합이기 떄문에, 이를 해석하는 인터프리터를 만들어서 실제 동작을 하도록 하는것

이다

즉 [라이브러리를 설계하는 일은 사실 언어를 설계하는 일이다] 라는 벨 연구소의 격언처럼 실제 언어 만들때 사용한 방법을 거의 그대로 사용할 수 있다.

필자는 이전에 [LolChatLang] 롤챙 프로그래밍 언어를 만들어보자 (tistory.com) 에서 언어를 구축하는 것을 해봤으므로 비슷한 방식으로 ADT 를 구성 할 수 있다.

다만 DSL 표현에 대한 포스팅 이기 때문에 이번에는 인터프리터는 생성하지 않았고, DSL Builder를 구성하였다.

 

ADT 는 다음과 같다. (딱 눈에보이는 "select distinct * from table where cond1 and cond2"  이정도까지만 구축할수있는 ADT이다 , SQL 문 워낙 요소가 방대해서 전부 할수는 없었다 ㅜㅜ)

sealed interface Expr

sealed interface Comparable : Expr

sealed class ColumnExpr<T> : Comparable {
    data class Column<T>(val col:KProperty1<T,Any>) : ColumnExpr<T>()
    data class Columns<T>(val cols: List<KProperty1<T, Any>>) : ColumnExpr<T>()
    data class Asterisk<T : Any>(val entity:KClass<T>) : ColumnExpr<T>()
}

data class LiteralExpr(val value: Any) : Comparable

enum class CompareOperator : Expr {
    EQ, NEQ, GT, GTE, LT, LTE
}

enum class LogicalOperator: Expr  {
    AND, OR
}

sealed class BinaryOp : Expr {
    data class Compare(val left: Comparable, val op: CompareOperator, val right: Comparable) : BinaryOp()
    data class Logical(val left: BinaryOp, val op: LogicalOperator, val right: BinaryOp) : BinaryOp()
}

sealed class WhereExpr : Expr {
    data class Where(val expr: BinaryOp) : WhereExpr()
    object Empty : WhereExpr()
}

sealed class DistinctExpr : Expr {
    object Distinct : DistinctExpr()
    object All : DistinctExpr()
}
data class FromExpr<T:Any>(val entity: KClass<T>)

data class SelectExpr<T:Any>(val columns: ColumnExpr<T>, val distinct: DistinctExpr, val from: FromExpr<T>, val where: WhereExpr)

 

해당 ADT 를 수동으로 구축하면 다음과 같이 구출 할 수 있다.

 SelectExpr<Person>(
            columns = ColumnExpr.Asterisk(Person::class),
            distinct = DistinctExpr.Distinct,
            from = FromExpr(Person::class),
            where = WhereExpr.Where(
                BinaryOp.Logical(
                    left = BinaryOp.Logical(
                        left = BinaryOp.Logical(
                            left = BinaryOp.Compare(
                                left = ColumnExpr.Column(Person::name),
                                op = CompareOperator.EQ,
                                right = LiteralExpr("John")
                            ),
                            op = LogicalOperator.AND,
                            right = BinaryOp.Compare(
                                left = ColumnExpr.Column(Person::age),
                                op = CompareOperator.GT,
                                right = LiteralExpr(10)
                            )
                        ),
                        op = LogicalOperator.AND,
                        right = BinaryOp.Compare(
                            left = ColumnExpr.Column(Person::age),
                            op = CompareOperator.LT,
                            right = LiteralExpr(20)
                        )
                    ),
                    op = LogicalOperator.OR,
                    right = BinaryOp.Compare(
                        left = ColumnExpr.Column(Person::name),
                        op = CompareOperator.EQ,
                        right = LiteralExpr("Jane")
                    )
                )
            )
        )

보시다시피 ADT 를 한땀 한땀 만드는것이기 때문에 굉장히 장황하다.

아래 SQL 을 표기한것이다.

    SELECT DISTINCT * 
    FROM Person 
    WHERE 
        (name = 'John' AND age > 10 AND age < 20) 
        OR name = 'Jane'

 

그 다음은 저런 형태의 ADT 를 구축하는것을 도와주는 Helper 를 짜면, DSL 완성이다.

DSL Builder Code 는 지금 아주 길고, 좀 억지로 구현한 부분이 있어서, 링크로만  삽입.

 

DSL Builder 에서는 크게 kotlin 의 labmda + receiver 기능, infix 연산자, 클래스와 함수를 적절하게.. 조합하여 구현 하였고,

위의 코드는 아래와 같이 생성 할 수 있다.

    Query(Person::class) {
        Select Distinct (Person::class)
        From (Person::class)
        Where {
            ((col(Person::name) `==` lit("John")) And
                    (col(Person::age) gt lit(10)) And
                    (col(Person::age) lt lit(20))) Or
                    (col(Person::name) `==` lit("Jane"))
    	}
    }

와 진짜 SQL 문과 아주 유사하게 작성되어있다! 깔끔 깔금

저기서 Distinct 를 빼고 싶으면, 그냥 빼면 된다.

    Query(Person::class) {
        Select (Person::class)
        From (Person::class)
        Where {
            ((col(Person::name) `==` lit("John")) And
                    (col(Person::age) gt lit(10)) And
                    (col(Person::age) lt lit(20))) Or
                    (col(Person::name) `==` lit("Jane"))
        }
    }

 

이게 가능한 이유는 좀 트리키한 구현 방법이긴 하지만 QueryBuilder 내에 아래와 같이 구현되어 있기 때문이다.

fun Select(entity: KClass<T>) {
    //블라 블라
}

val Select: SelectBuilder<T> = SelectBuilder(entity)

class SelectBuilder<T : Any>(private val entity: KClass<T>) {
    infix fun Distinct(entity: KClass<T>) {
      // 블라블라
    }
}

 

Builder 내에서 Select (Person::class) 하면 그냥 (Person::class) 을 받는 function 이 불리는것이고

Select Distinct (Person::class) 하면 Select 맴버 변수를 사용해서 Infix 연산자인 Distinct 가 불리고 Distinct 가 (Person::class) 을 받는 함수가 되는것이다.

 

좀 고민하다가 이방법 말고는 없는것 같아서 이렇게 했다, 이름이 곂쳐서 될지 몰랐는데 되더라, Kotlin 컴파일러 가 잘 짜여있는듯..

 

kotin은 JAVA 와 달리 DSL 을 만드는데 필요한 Feature 가 풍부한 편 이기 때문에,  이런 방식을 십분 활용하는게 보다 자연스러운 DSL 을 구축 할수있다고 생각한다. 

 

다만 kotlin 에서 DSL 을 만들어보며서 느낀 한계는 다음과 같다.

  • 오퍼레이션 오버로딩이 가능하지만, 좀 제한적이다. 그래서 그냥 새 infix 연산자를 정의했다.

  • 한계라고 하기는 좀 그렇긴 한데. < 랑 > 가 함수명으로 불가능하더라 (그래서 >= 대신 `gt=` 식으로 했다) 

  • 메타프로그래밍이 제한적임, Java 와 비슷하게 어노테이션정도. Macro는 없고, Moand 문법이나 빌더도 제공되지 않는다.
    SQL 은 좀 방대한 DSL 이고, 내가 원하던 방식은 Monad 문법으로 커버되지 않았을것 같기는 한데, 간단한 DSL 은 Monad 문법으로 하는게 제일 심플하기 때문에 아쉬워서 적어봄..

  • 커링 안됨 + 언어 style 상 당연하지만 괄호 생략 당연히 안됨 (람다/리시버인 경우만 {} 으로 대체 가능)
  • HKT 안됨 (여기서 쓸 필요는 없었지만..)

  • Sealed class 로 ADT 구현하는게 좀 장황한 편이다, 자바와 비교하면 선녀인데, F# 같은 ML 계열 언어가 이런건 최고인듯

 

 

여담으로 kotest 에서 컴파일되는지 안되는지를 확인하는 테스트가 되길래 사용해봣다.

 

728x90