본문 바로가기

카테고리 없음

[Kotlin] [Auto Mapping] 1. Reflection으로 Class의 프로퍼티들을 자동으로 매핑해보자

개발하면서 아키텍처를 적용하다보면 각 계층 사이에서 데이터를 주고 받을 일이 생겨난다.

이때 사용되는 데이터 객체들을 DTO(Data Transfer Object)라 부르는데

data class Entity(
    val seq: Long,
    val title: String,
    val content: String,
) {

}

data class Model(
    val seq: Long,
    val title: String,
    val content: String,
) {
    companion object {
        fun from(entity: Entity): Model {
            return Model(
                seq = entity.seq,
                title = entity.title,
                content = entity.content
            )
        }
    }
}

 

위처럼 변환 할 데이터 객체에 하나하나 프로퍼티들을 매핑시켜주는 작업들을 한다.

근데, 딱 봐도 위같은 작업이 귀찮아 보인다. 보통 DTO를 만들고 매핑하는 코드를 작성하다보면 "내가 왜 이걸 해야하지..?" 하며 의문이 드는 경우가 생겨 파기하고 싶은 고민에 빠지거나 DTO자체에 의문이 생겨 구글링이나 다른 개발자들에게 묻는 경험이 있을 것이다.

 

이같은 동작을 자동으로 수행하는 함수를 만들순 없을까?


나 또한 상당히 보일러 플레이트 코드라 느껴 궁시렁 거리던 도중, 몇개의 해답을 찾게되어 포스팅해본다.

Reflection?

리플렉션(Reflection)이란, 영단어 해석으론 '투영'이라는 의미로, 프로그램에서 컴파일 단계가 아닌 런타임 단계에서 수행되는 기능으로, 클래스, 메서드, 필드 등의 정보를 동적으로 가져오는 기능을 말한다.

 

처음보면 상당히 생소하고 알 필요 없는 기능일 수도 있지만 Android 개발을 하는 개발자들은 필히 써봤을 기능이다

fun launchActivity() {
    val intent = Intent(this, MainActivity::class.java)
    startActivity(intent)
}

 

위처럼 다른 액티비티로 이동할 때 이동할 액티비티에 더블콜론(::)을 사용하는데, 이것이 바로 리플렉션을 사용하기 위한 참조역할을 하는 연산자이다.

 

Intent의 생성자를 따라가보면 위 그림처럼 Class타입의 변수에서 getName()함수를 호출하는 것을 볼 수 있다.

 

java에서 사용하는 리플렉션과 kotlin에서 사용하는 리플렉션은 조금 다르다. java는 위처럼 Class타입으로 이뤄졌지만,  kotlin은 java의 리플렉션을 kotlin에서 사용하기 편하기 위해 한단계 추상화하여 사용하기 때문에 KClass타입을 사용한다.

 

본격적으로 매핑을 해보자

사실 getName()마냥 클래스의 이름을 가져오는 함수만 보고 동적으로 정보를 가져온다는 생각이 들진 않는다.

본격적으로 kotlin에서 리플렉션을 사용하기 위해선 추가적인 라이브러리가 필요하다

groovy
implementation "org.jetbrains.kotlin:kotlin-reflect:{kotlin-version}" // 자신이 사용하는 kotlin 버전을 기입하면 된다.

kotlin.kts
implementation(kotlin("reflect"))

 

이제 위 라이브러리를 통해 런타임 도중에 클래스의 인스턴스 상태 값을 가져와 원하는 클래스에 매핑하는 작업을 알아보자.

data class ReflectionEntity(
    val seq: Long,
    val title: String,
    val content: String,
)

data class ReflectionModel(
    val seq: Long,
    val title: String,
    val content: String,
)

object ReflectionLinker {
    fun <T : Any> from(input: Any, output: KClass<T>): T? {
    	// 1
        val inputClass = input::class
        // 2
        val inputProperties = inputClass.memberProperties
        // 3
        val outputProperties = output.primaryConstructor?.parameters
		
        // 4
        val constructor = mutableMapOf<KParameter, Any?>()
        for (outputProperty in outputProperties.orEmpty()) {
            val outputPropertyName = outputProperty.name
            val matchParam = inputProperties.find { it.name == outputPropertyName}
            matchParam?.getter?.call(input)?.let { constructor[outputProperty] = it }
        }
        
        // 5
        return output.primaryConstructor?.callBy(constructor)
    }
}

@Test
fun main() {
    val entity = ReflectionEntity(
        seq = 19290L,
        title = "원룸은 동그란 형태의 방아님?",
        content = "저녁 메뉴 추천좀 ㅠㅠ"
    )
    val model = ReflectionLinker.from(entity, ReflectionModel::class)
    println(model)
}

 

ReflectionLinker의 from함수의 파리미터를 살펴보면

input으로 변환할 인스턴스를, output으로 변환 타입을 받는 KClass가 보인다. 주석 번호로 설명해보겠다

 

// 1

변수 inputClass에 input의 동적 정보를 얻기위해 더블콜론을 사용하여 KClass로 변환한다.

 

// 2, 3

변수 inputProperties와 변수 outputProperties는 각각 초기화되는 방식이 다르다. 이 이유는 inputProperties같은경우 input에서 뽑아오는 정보기에 인스턴스화 되어있다. 즉, 값이 할당되어 있다는 의미이기에 할당된 값을 불러올 수 있는 getter.call을 사용할 수 있기에 memeberProperties를 사용한다.

추가적으로 declaredMemberProperties라는 함수도 있다. memeberPropertiesdeclaredMemberProperties의 차이는 super class의 프로퍼티까지 가져올꺼면 memeberProperties, 아니면 declaredMemberProperties을 사용하면 된다. 나는 데이터 매핑을 위해 사용하니 super class의 프로퍼티도 필요하다고 느껴 memeberProperties를 사용했다.

 

outputProperties는 주 생성자의 프로퍼티들을 가져오기 위해 primaryConstructor.parameters를 호출한다. 차이점은 추후 primaryConstructor의 callby의 파라미터에 넣을 수 있는 타입인 KParameter을 뽑아내기 위해 해당 메서드를 호출한다.

 

// 4

KParameter와 그 값을 모아놓기 위한 map을 생성한다. 그 후 루프문으로 outputProperties를 순회하여 반환할 타입의 클래스의 프로퍼티들을 가져온다.

순회하며 가져온 프로퍼티들을 input으로 들어온 프로퍼티의 이름과 대조한다. 그 후 대조한 이름이 같다면 해당 프로퍼티의 값을 리플렉션을 통해 동적으로 값을 호출하여 map에 저장해 놓는다.

 

// 5

반환할 타입의 KClass 생성자를 직접적으로 callby를 통해 호출한다. 

여기서 map을 통한 방법과 varag를 통한 방법 2가지가 있는데 map을 통해 호출하게 되면 내부적으로 알아서 map의 key에 해당하는 KParamter를 constructor의 프로퍼티와 매칭시켜준다. 이게 왜 중요하냐면, 우리가 매핑 시킬 2개의 클래스의 프로퍼티들이 같은 변수명으로 나열하지 않을수도 있기 때문이다.

data class ReflectionEntity(
    val seq: Long,
    val title: String,
    val content: String,
)

// 변환 할 객체와 순서가 다르다.

data class ReflectionModel(
    val title: String,
    val seq: Long,
    val content: String,
)

 

하지만 call을 통해 호출하게 된다면 가변인자로 들어오는 순서대로 생성자들 매핑시키기에 버그가 생길 경우가 다분하다.

 

실제로 동작하는지 테스트 해보자

@Test
fun main() {
    val entity = ReflectionEntity(
        seq = 19290L,
        title = "원룸은 동그란 형태의 방인가?",
        content = "저녁 메뉴 추천좀 ㅠㅠ"
    )
    val model = ReflectionLinker.from(entity, ReflectionModel::class)
    println(model)
    assertEquals(
        model, ReflectionModel(
            seq = 19290L,
            title = "원룸은 동그란 형태의 방인가?",
            content = "저녁 메뉴 추천좀 ㅠㅠ"
        )
    )
}

 

 

아주 마법처럼 매핑이 자동적으로 이뤄졌다.

 

근데 간혹 매핑시킬 변수명이 같지 않는 경우가 있지 않을까?

이를 위해 Gson의 @SerializeName처럼 변수명까지 매핑시켜주는 기능이 필요하다 느꼈다.

 

@Target(AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER)
annotation class ReflectionSwap(
    val name: String
)

object ReflectionLinker {
    fun <T : Any> from(input: Any, output: KClass<T>): T? {
        val inputClass = input::class
        val inputProperties = inputClass.memberProperties
        val outputProperties = output.primaryConstructor?.parameters

        val constructor = mutableMapOf<KParameter, Any?>()

        for (outputProperty in outputProperties.orEmpty()) {
            val outputPropertyName = outputProperty.name
            val annotationName = outputProperty.findAnnotation<ReflectionSwap>()?.name
            val matchParam = inputProperties.find { it.name == annotationName || it.name == outputPropertyName}
            matchParam?.getter?.call(input)?.let { constructor[outputProperty] = it }
        }

        return output.primaryConstructor?.callBy(constructor)
    }
}

data class ReflectionEntity(
    val seq: Long,
    val title: String,
    val content: String,
)

data class ReflectionModel(
    val seq: Long,
    val title: String,
    @ReflectionSwap("content")
    val subTitle: String,
)

@Test
fun main() {
    val entity = ReflectionEntity(
        seq = 19290L,
        title = "원룸은 동그란 형태의 방인가?",
        content = "저녁 메뉴 추천좀 ㅠㅠ"
    )
    val model = ReflectionLinker.from(entity, ReflectionModel::class)
    println(model)
}

 

 

단순하다. 어노테이션 클래스를 하나 생성해서 파라미터로 변환시킬 변수명을 기입할수 있게 만들어놓고,

윗글의 4번 주석 설명처럼 [순회하며 가져온 프로퍼티들을 input으로 들어온 프로퍼티의 이름과 대조한다.]에 어노테이션이 달려있다면 해당 어노테이션의 name 파라미터와 우선적으로 비교하면 된다.

 

결론: 리플렉션 그거 누가씀?

예전 Serialize와 Pacelize를 비교하는 글에서 "리플렉션은 속도가 느려 터졌다"라는 글을 본적이 있는 기억이 있어 하드코딩을 통한 매핑과 속도 측정 테스트를 해보았다.

 

1. Reflection

 

2. Hard coding

 

뭬? 0.320 초..?.. us는 뭐지?

 

 

보통 api를 request하고 response받는 루틴 속 사용자가 체감할 수 있는 최소 시간이 0.1초 정도라 생각한다.

 

근데 0.32초가 나와버린다라.. 심지어 프로퍼티가 3개인 클래스로 테스트했는데 이 정도면 실질적인 운영단계에서는

사실상 못쓰는 매핑기능이라 할수 있다.

 

 

 

멍만 하염없이 때리다가 혹시나 GPT(G.O.A.T)에게 성능 개선을 여쭤봤는데 Annotation Processor라는 키워드를 던져주었다.

 

 

이를 통해 kapt, 나아가 ksp를 통해 매핑 시간을 획기적으로 줄이는 데 성공하여 운영단계에서도 충분히 사용할수 있는 오토매퍼를 만들었고 2편 포스팅으로 이어보겠다.