본문 바로가기
🔓 영구 노트

복잡한 객체 생성 with kotlin DSL

by 파랭이가 룰루랄라 2022. 10. 19.

DSL은 복잡한 객체, html 태그 생성, sql 쿼리 생성 등에서 많이 사용한다. spring boot를 사용하면 queryDsl을 많이 사용한다. 여기서 뒤에 붙은 dsl도 같은 의미이다.

참고 자료

DSL을 사용하면 흔히 표현력과 가독성이 좋아진다고 한다. 그 차이를 느껴보기 위해 같은 객체를 DSL과 일반적인 객체 구현 코드로 확인해보겠다. 다음은 DSL을 사용한 객체 생성 코드이다.

>> val village1 = village {  
        name = "기본 마을"  
        address {  
            street = "기본 거리"  
            city = "기본 시티"  
        }  
        npcs {  
            npc {  
                id = 1L  
                name = "기본 npc 1"            
                type = Type.NORMAL  
            }  
            npc {  
                id = 2L  
                name = "기본 npc 2"            
                type = Type.QUEST  
            }  
        }
    }
>> println(village1)
>> village1 = Village(name=기본 마을, address=Address(street=기본 거리, city=기본 시티), npcs=[Npc(id=1, name=기본 npc 1, type=NORMAL), Npc(id=2, name=기본 npc 2, type=QUEST)]

많은 예약어들이 있지만 코드를 확인했을 때 마을에 주소와 npc들이 있고, npc들은 id, name, type을 가진 다는 것을 어렵지 않게 확인할 수 있다.

다음은 위 코드를 일반적인 객체 구현 코드이다.

>> val village2 = Village(  
        name = "기본 마을",  
        address = Address(  
            street = "기본 거리",  
            city = "기본 시티"  
        ),  
        npcs = listOf(  
            Npc(  
                id = 1L,  
                name = "기본 npc 1",  
                type = Type.NORMAL  
            ),  
            Npc(  
                id = 2L,  
                name = "기본 npc 2",  
                type = Type.QUEST  
            )  
        )  
    )
>> println(village2)
>> village2 = Village(name=기본 마을, address=Address(street=기본 거리, city=기본 시티), npcs=[Npc(id=1, name=기본 npc 1, type=NORMAL), Npc(id=2, name=기본 npc 2, type=QUEST)]

조그마한 차이들이 존재한다. npcs를 배열로 선언하기 위해 listOf를 사용하여 npc의 구현 내용을 안에 넣은 것, address = Address()로 시작하는 것 등을 미뤄 보았을 때 확실히 코드를 더 많이 사용한다는 것을 알 수 있다.

이제 DSL을 통해 마을 생성 코드에 대해 알아보겠다. 요구사항을 확인하고 한번 만들어보면 좋을 것 같다.

마을은 이름과 주소가 있고, 다수의 npc를 가질 수 있다.
주소에는 도로명 주소와 도시 이름이 들어간다.
npc는 id와 이름을 가지고, 목적에 맞는 타입을 가진다.
npc의 타입에는 UNDEFINED, NORMAL, QUEST, MERCHANT 중 하나를 가진다.

먼저, 각 객체들을 생성하는 코드를 작성해보자.

class Village(  
    val name: String,  
    val address: Address,  
    val npcs: List<Npc>  
) {  
    override fun toString(): String {  
        return "Village(name=$name, address=$address, npcs=$npcs"  
    }  
}  

class Address(  
    val street: String,  
    val city: String  
) {  
    override fun toString(): String {  
        return "Address(street=$street, city=$city)"  
    }  
}  

class Npc(  
    val id: Long,  
    val name: String,  
    val type: Type  
) {  
    override fun toString(): String {  
        return "Npc(id=$id, name=$name, type=$type)"  
    }  
}

enum class Type {  
    UNDEFINED, NORMAL, QUEST, MERCHANT  
}

요구 사항에 맞게 마을(Villeage), 주소(Address), 논-플레이어-캐릭터(Npc), npc의 타입(Type)을 구현하였다. 일반 구현은 위 코드로 충분히 재현할 수 있다. 이제 DSL을 통해 구현하는 방법을 알아보자.

 

유의 깊게 봐야 할 부분은 원본 코드를 전혀 손대지 않고, DSL을 적용시킬 것이라는 거다. 기존의 구현을 전혀 건드리지 않고, 유연하게 생성 기능을 추가할 수 있는 것이다.

Npc 생성

DSL을 이해하게 된다면 수신 객체 지정 람다를 들어봤을 것이다. 그렇다면 왜 그렇게 많은 언급 이뤄지는지 람다를 인자로 받는 함수와 비교하여 알아보겠다.

 

다음은 람다를 인자로 받는 npc 함수와 npc를 생성하기 위한 클래스 NpcBuilder이다.

class NpcBuilder {  
    var id: Long = 0L  
    var name: String = ""  
    var type: Type = Type.UNDEFINED  

    fun build() = Npc(id, name, type)  
}

fun npc(builder: (NpcBuilder) -> Unit) {  
    val npcBuilder = NpcBuilder()  
    val npc = npcBuilder.apply(builder)  
    npcList.add(npc.build()) 
}

다음은 그에 따른 npc DSL의 변화이다.

npc {  
    it.id = 1L  
    it.name = "기본 npc 1"    
    it.type = Type.NORMAL  
}

처음 봤던 DSL과 형식이 다른 것을 눈치챘을 것이다. 이렇듯 람다를 인자로 받으면 객체 생성을 위한 인자를 넘겨줄 때 it. 을 붙여야 한다. 불필요한 코드를 작성하는 것은 간결함을 목표로 하는 DSL과 맞지 않는다. 따라서 it. 을 제거하기 위해 수신 객체 지정 람다를 사용한다.

 

수신 객체 지정 람다는 수신 받을 객체를 지정해주어 객체의 이름이나 it. 과 같은 예약어를 사용하지 않아도 해당 인자의 멤버를 사용할 수 있는 것을 말한다.

 

아래 코드는 NpcBuilder를 수신 객체 지정 람다로 지정해준 코드이다.

fun npc(builder: NpcBuilder.() -> Unit) {  
    val npcBuilder = NpcBuilder()  
    val npc = npcBuilder.apply(builder)  
    npcList.add(npc.build())
}

수신 객체 지정 람다가 DSL을 설명할 때마다 거론되는 이유는 바로 간결함과 편리함을 증가시키기 위해서다. it. 3글자를 줄이기 위해 사용하지만 훨씬 복잡한 객체가 있다고 가정한다면 줄일 수 있는 글자의 수는 적지 않을 것이다.

 

직접 NpcBuilder를 생성하여 초기화하는 대신에 builder를 NpcBuilder().apply 함수의 인자로 넘겨줄 수 있다. 이렇게 하면 3글자를 줄여주는 것에서 더 나아가 3줄의 코드를 1줄로 바꿀 수 있다.

fun npc(builder: NpcBuilder.() -> Unit) {  
    npcList.add(NpcBuilder().apply(builder).build())  

최종적으로 npc 함수는 다음 순서를 따르게 된다.

  1. builder의 값으로 초기화된 NpcBuilder를 얻는다.
  2. build 함수를 통해 npc 객체를 만든다.

복잡한 객체 생성

class VillageBuilder {  
    var name: String = ""  
    var address: Address? = null  
    var npcList: MutableList<Npc> = mutableListOf()  

    fun address(builder: AddressBuilder.() -> Unit) {  
        address = AddressBuilder().apply(builder).build()  
    }  

    fun npcs(builder: NpcListBuilder.() -> Unit) {  
        npcList.addAll(NpcListBuilder().apply(builder).build())  
    }  

    fun build() = Village(  
        name,  
        address ?: throw Exception("address object is null"),  
        npcList  
    )  
}  
//...

이제 복잡한 객체 마을을 생성하기 위한 코드를 알아보겠다. VillageBuilder에는 각 프로퍼티를 초기화하기 위한 함수 address와 npcs는 각각 AddressBuilder, NpcListBuilder를 수신 객체 지정 람다를 인자로 받는다. 두 함수는 npc의 구현과 마찬가지로 다음의 기능이 가능하게 한다.

village { // 아직 village는 구현하지 않음
    address {  
        //...
    }  
    npcs {  
        //...   
    }
}

마지막 함수 build는 초기화된 name, address, npcs로 Village 객체를 생성한다. 여기서 address에 엘비스 연산자를 통해 null이 아님을 확인받는다. 이유는 원본 Village의 경우 address가 null을 허용하지 않는 것에 반해 VillageBuilder는 address가 null을 허용하기 때문이다. 이처럼 DSL을 통해 null 체크를 할 수 있다.

 

마을을 생성하기 위한 DSL 예약어 village는 어떻게 구현해야 할까? 코틀린에서 클래스 밖에 함수를 구현하는 것이 가능하므로 village를 VilleageBuilder 밖에 다음과 같이 선언한다.

fun village(builder: VillageBuilder.() -> Unit) = VillageBuilder().apply(builder).build()

이렇게 하면 village 예약어 안에서만 address, npcs 예약어를 사용하게 만들 수 있다. 순서로 따지자면

(village -> (name), (address), (npcs -> npc))

와 같이 나눌 수 있다. 객체 생성에 있어 계층이 있다면 계층에 맞게 Builder 클래스에 넣어주면 된다.

전체 코드는 다음의 링크를 참고하면 된다. Github

결론

DSL은 분명 목적에 따라 편의성을 제공해줄 것이다. 하지만 구현에 있어 추가적인 코드가 적지 않다. 다른 기능들도 마찬가지지만 팀에서 충분한 논의를 한 후에 적용하는 것을 추천한다.

댓글