본문 바로가기
320x100
320x100

백엔드를 개발하다 보면 기존 객체를 DTO(Data Transfer Object)로 변환하거나 다른 클래스 형태로 매핑해야 하는 일이 자주 발생한다

예를 들어, 클라이언트 요청에 맞춘 데이터 포맷 변경, API 응답을 위한 직렬화 객체 변환, 또는 엔티티와 DTO 간 데이터 매핑 등...

이 글에서는 Kotlin에서 객체를 변환하는 다양한 방법을 소개하고, 각 방법의 장단점을 비교해 보려한다

목표

  1. Person -> PersonDto
    기본적으로 동일한 필드 구조를 가진 클래스 간 변환
  2. Person -> PersonExtraDto
    추가 필드가 포함된 클래스에 데이터를 매핑
  3. PrivatePerson(private 필드) -> PersonDto
    접근제어자가 설정된 필드를 매핑
  4. 성능 테스트

예제 클래스

먼저, 예제에서 사용할 간단한 클래스를 만들어봤다

예시 클래스

data class Person(val name: String, val age: Int)
data class PersonDto(val name: String, val age: Int)
data class PersonExtraDto(
    val name: String,
    val age: Int,
    val extraString: String,
    val extraNullableString: String?,
    val height: Double = 180.0
)
class PrivatePerson(private val name: String, protected val age: Int)

 

 

  • Person: 기본 데이터를 가진 클래스
  • PersonDto: Person 데이터를 전송하기 위한 DTO
  • PersonExtraDto: 추가 필드가 포함된 DTO
  • PrivatePerson: private와 protected 필드를 가진 클래스

Top-level에서 여러개의 class를 만들어서 테스트를 해봤다

예제 Mapper

수동 매핑방식

수동매핑방식

fun passiveMapping(person: Person) = PersonDto(person.name, person.age)
fun Person.toDto(): PersonDto = PersonDto(name = this.name, age = this.age)
fun copyMapping(person: Person): PersonDto {
    return person.let { PersonDto(it.name, it.age) }
}
fun withMapping(person: Person): PersonDto = with(person) { PersonDto(name, age) }
class PersonMapper {
    operator fun invoke(person: Person): PersonDto {
        return PersonDto(person.name, person.age)
    }
}
  1. 직접 매핑
  2. 확장 함수
  3. scope 함수 let
  4. scope 함수 with
  5. Mapper Class

 

자동/간접 매핑방식

자동/간접 매핑

val objectMapper = jacksonObjectMapper().apply {
    findAndRegisterModules()
    enable(com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT)
    disable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
}
fun objectMapping(person: Person): PersonDto {
//    val objectMapper = jacksonObjectMapper()
    val json = objectMapper.writeValueAsString(person)
    return objectMapper.readValue(json, PersonDto::class.java)
}

@Mapper(
    componentModel = MappingConstants.ComponentModel.SPRING,
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
    nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT,
    unmappedTargetPolicy = ReportingPolicy.IGNORE,
    unmappedSourcePolicy = ReportingPolicy.IGNORE
)
interface PersonMapStructMapper {
    companion object {
        val INSTANCE: PersonMapStructMapper = Mappers.getMapper(PersonMapStructMapper::class.java)
    }

    fun toDto(person: Person): PersonDto
}

inline fun <reified T : Any, reified R : Any> T.mapTo(dtoClass: Class<R>): R {
    val dtoConstructor = dtoClass.kotlin.constructors.first() // 기본 생성자
    val args = dtoConstructor.parameters.associateWith { param ->
        this::class.memberProperties.firstOrNull { it.name == param.name }
            ?.getter
            ?.apply { isAccessible = true }
            ?.call(this)
            ?: when {
                param.isOptional -> null
                param.type.isMarkedNullable -> null
                param.type.classifier == String::class -> ""
                param.type.classifier in listOf(Int::class, Long::class, Short::class, Byte::class) -> 0
                param.type.classifier == Float::class || param.type.classifier == Double::class -> 0.0
                param.type.classifier == Boolean::class -> false
                else -> throw IllegalArgumentException("Unsupported parameter type: ${param.type}")
            }
    }
    return dtoConstructor.callBy(args.filterKeys { !it.isOptional })
}

의존성 추가

  1. ObjectMapper
  2. Mapstruct
  3. Reflection

Mapstruct를 쓰기 위해서 의존성을 추가했다

 


테스트

테스트1

테스트1

    @Test
    fun mappingTest() {
        val person = Person("boki", 30)

        val personDto1 = passiveMapping(person)
        val personDto2 = person.toDto()
        val personDto3 = copyMapping(person)
        val personDto4 = withMapping(person)
        val personMapper = PersonMapper()
        val personDto5 = personMapper(person)

        val personDto6 = objectMapping(person)
        val personMapStructMapper = PersonMapStructMapper.INSTANCE
        val personDto7 = personMapStructMapper.toDto(person)

        val personDto8 = person.mapTo(PersonDto::class.java)
        val personDto9 = person.mapTo(PersonExtraDto::class.java)
        val privatePerson = PrivatePerson("sam", 20)
        val personDto10 = privatePerson.mapTo(PersonDto::class.java)

        mutableListOf(
            personDto1,
            personDto2,
            personDto3,
            personDto4,
            personDto5,
            personDto6,
            personDto7,
            personDto8,
            personDto9,
            personDto10,
        ).forEach { println(it) }
    }

 

결과

결과

 

분석

위에서 말한 것처럼

personDto1~5까지는 Style의 차이일뿐 큰 차이는 없다

1. 직접 매핑

2. 확장 함수

3. scope 함수 let

4. scope 함수 with

5. Mapper Class

다만 personDto6~10까지는 다르다

6. ObjectMapper 사용

7. MapStruct 사용

8. Reflection 단순 사용

9. Reflection 복잡 사용

10. Reflection Private 필드 사용

 

 

- ObjectMapper

사실 ObjectMapper는 주로 API통신에 사용되며 직렬화/역직렬화에 사용된다

중첩객체를 갖고있거나 복잡한 매핑에 유용하다

json을 Tree형태로 읽을 수 있다

또한 Map으로 변환한다던지, JsonNode등을 사용해서 필요한 객체만 추출할 수 있다

 

- MapStuct

컴파일 타임에 코드를 만들어준다(자동으로 매핑 코드 생성)

런타임에 오버헤드를 최소화해주기때문에 빠르다

kapt

KAPT(Kotlin Annotation Processing Tool)을 통해 생성된 PersonMapStructMapper구현 클래스

 

- Reflection

런타임에 동적으로 필드에 접근이 가능하다

제네릭하게 사용이 가능하다

private 필드에도 접근이 가능하다

런타임 성능이 상대적으로 느리다

런타임 에러가 발생할 수 있어 디버깅이 어렵다

반드시 필요한 경우에만 사용하며, 남용/오용 금지

 

또한 Reflection 코드를 보면, isAccessible=true를 통해 private 필드에도 접근이 가능하게 만들었고, optional 또는 nullable한 파라미터의 경우 null로 처리를 했고, 파라미터의 타입에 따른 기본값을 지정했다

마지막으로 R타입(DTO)의 생성자를 호출하여 객체를 생성하고 필수 파라미터에 대해서만 값을 전달하는 로직을 만들었다

 


테스트2

테스트2

각각 백만번 테스트를 반복했다(1,000,000)

 

결과

결과

 

분석

자, 이 결과로 찾을 수 있는 3가지가 존재한다

1. 최적화를 할 수 있는 부분 2가지

2. 이미 최적화가 되어있는 부분

 

Q-1. 수동매핑방식에서 왜 맨 처음에 105ms가 발생하고 이후에는 매우 짧은 시간이 소요됐다(거의 10배가 넘는 시간 차이)

개선할 수 있는 부분이 있을까?

A-1. JVM Warm-UP

JVM은 맨 처음 코드를 실행할 때는 인터프리터방식으로 동작하고, 메서드나 코드블록이 반복 호출되면 HotSpot으로 간주하고 해당 바이트코드를 Native Machine Code로 변환하여 실행속도를 향상시키는 JIT(Just-In-Time) 컴파일 최적화를 할 수 있다

그리고 테스트도 사실 따져보면 잘못됐다

두번째인 toDto를 맨 처음 실행으로 올려보자

순서 변경
결과

사실상 맨 처음 백만번 호출되는 놈이 총대 메고 컴파일러 최적화를 하면서 실행하는 것과 다름없었다

그럼 어떻게 할 수 있을까?

컴파일러 최적화(HotSpot 만들기)

repeat(10_000) { passiveMapping(Person("warm-up", 0)) }

단순히 이후에 호출될 함수를 만번정도 미리 호출해봤다

우리한테 10,000은 엄청 큰 횟수일지 몰라도 컴퓨터한테는 순삭이다ㅋ

이제 다시 테스트를 돌려보면....

개선 완료

86ms -> 18ms로 4~5배나 더 빨라진 모습을 볼 수 있다

참고로 스프링의 경우도 JVM위에서 돌기때문에 달궈진 이슈.. 즉 맨 처음 서버가 뜨고 난 다음의 요청에 의한 응답속도는 느리다

하지만 이후 점점 빨라지게 된다!

Heap 메모리를 많이 먹지만... 그만큼 GC와 캐싱이 열일하기때문에 귀엽고 깜찍하게 넘어가주자..ㅠ

 

 

Q-2. 불필요한 모듈을 찾아서 추가하거나, 불필요한 속성이 켜져서 사용되고 있지는 않을까?

A-2. ObjectMapper를 보면 findAndRegisterModules()를 호출해서 등록이 가능한 전체 모듈을 탐색하고 등록한다. 그리고 출력할때 이쁘게 보이라고 INDENT_OUTPUT 옵션을 굳이 enable 해놨다. 테스트할때 빼고는 성능을 느려지게하는 코드들이다. 개선해보자

리팩토링

val objectMapper = jacksonObjectMapper().apply {
//    findAndRegisterModules()
    registerModule(
        KotlinModule.Builder().build()
    )
//    enable(com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT)
    disable(com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT)
    disable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
}

주석 친 부분은 기존 코드이다

결과는?

결과

1012ms에서 788ms로 개선됐다

1초에서 0.7초가 걸렸다는 소리다. 크게 개선 안된 것 같지만....이게 만약에 사용자한테 화면을 보여주는 경우였다면?

사람은 정말 미세한 시간차이로 불편하다고 느낀다. 특히 데이터가 보이거나 안보이거나 그럴때말이다

절대적인 시간 차이는 0.3초이지만 개선은 30%나 됐다

만약 100가 걸리는 화면이었다면 70초로 줄이는 엄청난 성과다

코드를 잘 읽어보기만 하면 findAndRegisterModules()... 모듈들을 찾아서 등록한다 인데, 딱봐도 여기서 오래걸릴것 같이 생기지 않았는가? 공식문서를 보고 필요한 모듈만 등록해서 쓰자

 

 

Q-3. 이미 개선되어 있는 부분이 있을까?

A-3. ObjectMapper의 객체생성하는 부분을 메서드 외부로 뺐다

위의 예제 Mapper를 소개하는 부분에서 보면 주석처리한 부분을 봤을 것이다

여기

되게 사소한 부분같지만.... 외부에서 ObjectMapper의 객체를 미리 만들어놓고 쓰는게 아니라, 메서드 내에서 계속 생성하게된다면..?

자바로 치면 new ObjectMapper()랑 같은 코드인 것이다

주석 풀고 테스트

한번 테스트해보자

결과

조금 위에서 테스트했을때 ObjectMapper의 백만번 실행시간은 788ms였다

val objectMapper = jacksonObjectMapper()

하지만, 이 한줄이 메서드 내부에 침투함으로 인해서 무려 30배 가까이 느려졌다

이런 점에서 Spring이 Bean을 왜 Singleton으로 처리했는지 피부로 느낄 수 있을 것이다

뭐 필요하면 Prototype으로 만들어 쓰던가.. 내부에 상태공간이 필요하면 ThreadLocal을 만들어 쓰다가 Pool에 반납하기전에 상태초기화를 해주던가 하면 된다

 

+

추가로 AI한테 조금 물어본 결과

 

Reflection을 사용할때 클래스, 생성자, 프로퍼티를 한번 생성해서 캐싱한 후 재사용하는 방법

(KClass, KFunction, KProperty1)

 

가 있다고 한다.. 내가 미리 생각한 방법은 아니므로 Pass!

 

이외에 더 좋은 방법이나 내가 실수한 부분이 있다면 댓글로 알려주면 좋을 것 같다

 

320x100

댓글