[kotlin] 델리게이터를 이용해 리플렉션을 제거하기

개요top

굉장히 많은 백엔드 코드나 일부 안드로이드 코드에서도(GSON같은) 자주보이는 것이 리플렉션을 사용하기 위해 Model::class.java 같은걸 인자로 넘겨주는 것입니다.
결국 저렇게 클래스로 넘겨주면 그 함수는 내부에서 클래스를 리플렉션해 json같은 동적인 구조체로부터 정적인 클래스의 인스턴스 필드에 자동으로 대입해주려고 합니다.
리플렉션은 언어 차원의 기능이므로 모든 클래스를 넘겨받아 일반적으로 적용할 수 있다는 잇점이 있습니다만 항상 그렇게 녹녹한 것도 아닙니다.

  1. 제네릭이 관여된 클래스는 제네릭형을 알 수 없으므로 슈퍼타입토큰 같은 우회적인 기법을 사용해야 해서 더욱 복잡한 양상이 됩니다.
  2. 안드로이드 플랫폼의 제네릭은 확연하게 체감성능을 낮추게 되어 일시정지 같은 효과를 만들어내곤 합니다.

더 나아가 편의를 위해서라고는 하나 정적타입언어에서 리플렉션에 기반한 코드를 어디까지 허용해야할까라는 고민도 없는 것은 아닙니다.

이에 범용적이라고는 할 수 없지만 코틀린의 델리게이터를 이용해 엔티티나 모델 클래스를 작성하므로서 리플렉션을 회피하는 방법을 알아보도록 하겠습니다.

필드에 by를 통해 델리게이터 적용하기top

어떤 클래스의 필드를 선언할 때 by를 사용하는 경우 그 뒤에 오는 델리게이션 객체가 해당 필드의 get또는 set을 대신하게 됩니다. 코틀린은 이 경우 평범하게 필드를 사용하는 코드를 작성하면 내부적으로 델리게이터의 getValue또는 setValue를 호출하는 코드로 컴파일 해주는 것이죠. 가장 간단한 델리게이터 객체를 만들어보죠.

class Field<T:Any>(private var value:T){
  operator fun getValue(ref:Any?, prop:KProperty<*>) = value
  operator fun setValue(ref:Any?, prop:KProperty<*>, v:T){
    value = v
  }
}

델리게이터는 딱히 특정 클래스를 상속하거나 인터페이스를 구현해야하는 것은 아닙니다. 모든 클래스에서 사용할 수 있는 getValue, setValue 오퍼레이터를 구현하기만 하면 됩니다. 이제 이걸 일반적인 클래스에 적용하면 됩니다.

class Entity{
  var userid by Field("hika")
  var count by Field(0)
}

이렇게 정의되는 클래스의 필드는 기본값 할당을 =로 하지 않고 by로 하며, 그 뒤에는 델리게이션 객체가 오게 됩니다.
보통 그 때의 형은 자동으로 델리게이션 객체의 getValue 반환 타입이 되므로 앞 서 정의한 Field 델리게이터의 경우 제네릭에 의해 인자로 받은 형이 자동으로 클래스 필드의 형이 되도록 해두었습니다.

리플렉션을 통해 하려는 것은 무엇일까?top

보통 리플렉션을 걸어 클래스의 정보를 얻으면 그것으로 무엇을 하려고 하냐면 필드의 목록을 얻어내서 동적으로 특정 이름의 필드에 값을 대입하거나 메소드를 알아내서 일반적인 형태로 호출하려고 합니다.
사실 코틀린은 자바보다 한층 사용하기 쉽게 KProperty와 KFunction으로 감싸 제공되기 때문에 리플렉션을 사용하기 훨씬 편리한 환경을 제공합니다.
코틀린에서는 다음과 같은 절차로 손쉽게 클래스의 리플렉션 정보를 추출할 수 있습니다.

class Test{
 val a = 3
 fun b(){}
}

val cls = Test::class //코틀린 클래스 객체 획득
val methods = cls.memberFunctions //메소드리스트 확보
val methodNames = methods.map{it.name} //메소드이름 리스트
val fields = cls.memberProperties.filter{ //memberProperties에는 메소드도 포함됨
  it.name !in methodNames //메소드이름이 아닌것만 추출(메소드명과 같은 필드명이라면 망 ^^)
}

이렇게 간단히 필드리스트와 메소드 리스트를 얻을 수 있도록 다양한 kotlin.reflect 패키지의 기능이 제공됩니다.

여튼 정적 언어에서는 정적으로 필드와 메소드에 접근해야하는데 만약 자바스크립트처럼

obj["a"] = 3

문자열 “a”로 어떤 인스턴스의 필드에 접근하고 싶기 때문에 리플렉션을 쓴다 할 수 있습니다.

즉 정적인 인스턴스의 속성을 동적으로 접근하고 싶어서 리플렉션을 쓰는 것이죠.
메소드야 그렇다치고 만약 필드를 동적으로 접근하고 싶어서라면 처음부터 인스턴스가 map인 경우를 생각해볼 수 있습니다.

val test = mutableMapOf<String, Int>("a" to 3, "b" to 5)
log(test["a"])

이 경우 원하는 필드에 접근하기 위해 문자열 “a”를 사용하므로 리플렉션을 쓰려는 이유와 비슷하긴 합니다.
하지만 반대로 test.a 라는 식으로 정적인 접근이나 컴파일러의 정합성은 포기해야하죠.

요컨데 리플렉션을 쓰는 진정한 이유는 특정 시점에는 동적으로 접근해서 장난치고 싶지만 그 외의 상황에서는 정적인 형태로 컴파일이 되었으면 좋겠다는 니즈인 것입니다.

이는 코틀린 입장에서 map을 인스턴스와 공유하는 델리게이터로 해결할 수 있습니다.

내부에 map을 동시에 업데이트하는 델리게이터top

만약 정적 필드의 모든 내용이 map에 갱신되는 클래스를 getter와 setter로 작성한다면 다음과 같을 것입니다.

class Entity{
  //모든 필드 값을 모을 map
  val map = mutableMapOf<String, Any>()

  //String형의 userid를 정의함
  var userid get() = if("userid" in map) map["userid"] as String else "hika".also{map["userid"] = it}
             set(v){map["userid"] = v}

  //Int형의 count를 정의함
  var count get() = if("count" in map) map["count"] as Int else 0.also{map["count"] = 0}
            set(v){map["count"] = v}
}

이 방식의 구현은 수많은 문제를 내포하고 있습니다.

  1. 우선 하나의 필드를 정의하는 수고가 너무 들어갑니다.
  2. Any타입의 map값을 임의의 Int나 String으로 변환하게 됩니다.

사실 코틀린의 Map은 내부에 이미 getValue와 setValue를 구현해두고 있습니다. 따라서 필드에 직접 map을 대입하는 방법도 없는 것은 아닙니다.

class Entity{
  val map = mutableMapOf<String, Any>()
  var userid:String by map
  var count:Int by map
  init{
    map["userid"] = "hika"
    map["count"] = 0
  }
}

물론 이 코드는 동작하고 앞에 예제보다는 훨씬 간단하지만 여전히 문제를 많이 내포합니다.

  1. 코틀린 내장 map의 구현은 제네릭을 통해 델리게이터의 형을 일치시키려고 하기 때문에 키가 없거나 값의 형이 다르면 예외가 발생합니다.
  2. init나 생성자 등에서 클래스 내부 필드 사정에 맞게 값을 넣어줘야 합니다.

요컨데 이 스타일에서는 필드의 선언부와 초기화 코드가 다른 곳에 위치한다는 게 가장 큰 문제인 셈이죠.
그럼 이를 약간 더 개조해보죠.

대상 클래스의 map을 받는 델리게이터top

우선 코틀린 내장 map의 델리게이터는 어떻게 생겼을까 예상해보면 대충 다음과 같을 것입니다.

class Map{
  ..map의 구현
  operator fun <T> getValue(ref:Any?, prop:KProperty<*>) = get(property.name) as T
  operator fun <T> setValue(ref:Any?, prop:KProperty<*>, value:T?) = put(prop.name, value)
}

이 구현에서는 게터 세터의 이름에 맞춰 미리 지정된 T형으로 강제 변환까지를 하고 있으므로 map의 내용이 딱 맞지 않으면 예외가 발생할 것이고 실제 발생합니다.
이를 좀 완화해보자는 거죠. map자체에 구현하지는 않고, 외부에서 map과 초기 값을 받는 형태로 개조해봅시다.

class Dele<T:Any>(private var v:T, private val map:MutableMap<String, Any>){
  operator fun getValue(ref:Any?, prop:KProperty<*>):T{
    if(prop.name !in map) map[prop.name] = v
    return map[prop.name] as T
  }
  operator fun setValue(ref:Any?, prop:KProperty<*>, v:T){
    map[prop.name] = v
  }
}
  1. 생성자에서 초기값 v를 받아들이는 것으로 T형을 확정짓습니다.
  2. 또한 생성자에서 외부에 map을 받아들입니다.
  3. getValue에서는 과격한 예외가 발생하지 않도록 map에 키가 없는 경우를 부드럽게 처리해주고 있습니다.
  4. 물론 마지막에 T형으로 형 변환하는 부분은 어쩔 수 없는 부분이죠.
  5. setValue는 평범하게 map의 값을 갱신합니다.

핵심은 생성자에서 v값을 받아들여 map에 키가 없는 경우에도 자연스럽게 v값을 넣어줄 수 있는 기능을 추가한 것이죠.
단지 이것만으로 이 Dele기반의 클래스는 보다 쉽게 작성됩니다.

class Entity{
  val map = mutableMapOf<String, Any>()
  val userid by Dele("hika", map)
  val count by Dele(0, map)
}

이를 통해 훨씬 깔끔하면서도 필드 선언 시점에 초기값을 넣을 수 있는 형태가 되었습니다. 이제 편의기능을 몇 가지 추가해보죠.

Dele관련 편의함수top

우선 매번 Dele생성자에 map을 보내기 귀찮으니 이를 자동화하는 v메소드를 추가해보죠.

class Entity{
  val map = mutableMapOf<String, Any>()
  private fun <T:Any> v(v:T) = Dele(v, map)

  val userid by v("hika")
  val count by v(0)
}

v메소드 추가를 통해 각 필드의 값할당이 훨씬 간단해졌습니다.
보통은 이 정도에 문제가 없겠지만 이런 lazy한 map에 적용으로 인해 map에 실제 키가 생성되는 시점이 실제 인스턴스의 필드를 조회할 때까지 미뤄지게 됩니다.
즉 v(“hika”)의 값은 델리게이터의 필드인 v에 잡혀있을 뿐이지 아직 map에는 반영되어있지 않은 것이죠.

val entity = Entity()
log(entity.userid)

이렇게 직접 필드를 조회할 시점이나 되어서 간신히 userid가 map에 들어가는 것입니다. 따라서 시점에 따라서는 map에 count는 물론이고 userid키도 없는 상태가 존재합니다.

val entity = Entity()
//생성 후 필드를 조회한 적이 없는 상태라면
log(entity.map["userid"]) //이런 키가 존재하지 않는다.

//한 번 조회했다면
log(entity.userid)
log(entity.map["userid"]) //그 다음에 가서야 "hika"를 얻을 수 있다.

생성하자마자 필드리스트를 받으려는 입장에서는 map이 리플렉션을 대체할 수 없게 되는 상황인거죠. 그래서 빠른 할당을 위해 강제로 초기화까지 처음에 해주는 kv메소드도 추가합니다.

class Entity{
  val map = mutableMapOf<String, Any>()
  private fun <T:Any> v(v:T) = Dele(v, map)
  private fun <T:Any> kv(k:String, v:T) = Dele(v, map.also{it[k] = v})

  val userid by kv("userid", "hika")
  val count by kv("count", 0)
}

위에 추가된 kv메소드는 Dele생성시점부터 map에 값을 넣어주고 시작하기 때문에 약간 번거롭게 필드 생성시 필드이름을 인자로 줄 필요가 생겼습니다.
하지만 덕분에

val entity = Entity()
//생성만 해도 이미 키리스트를 얻을 수 있다.
log(entity.map.keys) //userid, count

즉시 필드리스트를 얻을 수 있게 되어 리플렉션을 쓰지 않아도 됩니다.

결론top

물론 일반적인 클래스처럼 작성할 수 없다는 점과 모든 필드를 반드시 by를 통한 델리게이터로 적용해야한다는 점 등이 장점만 있는 것은 아닙니다.
하지만 보통 리플렉션에 의뢰하는 클래스는 엔티티나 데이터성 클래스들이고 그 클래스들이라면 위의 Entity등을 추상클래스로 하여 만든다면 충분히 커버할 수 있는 범위라고 생각합니다.

추가적인 노력은 들었지만 코틀린이 내부에서 생성해주는 델리게이터 처리 덕분에 리플렉션 의존성을 완전히 제거하고 레코드 셋이나 json으로부터 정적 컨텍스트를 유지하는 데이터성 클래스에 매핑할 수 있게 되었습니다.