본문으로 바로가기
반응형

이 글은 Medium의 "Redundant DTO-Domain Mapping in Kotlin Flow"를 번역 / 의역한 글입니다.[1]

 

Project 내부에서 깔끔하고 side effect이 없는 코드를 만들기 위해 각 기능별 또는 모듈별로 Layer를 만들어 사용합니다. 예를들어 데이터를 fetching하는 네트워크 API나, DB의 정보를 가져오는 Room같은 경우 전달받는 날것 그대로의 데이터를 담기 위해 DTO 클래스를 만듭니다.

다만 실제 데이터를 가용하는 부분에서 이 DTO 클래스를 직접 넘겨받아 access하지 않습니다.

이 데이터를 실제로 사용되는 코드에서는 날것의 정보가 아닌 원하는 형태로 변환된 Domain class를 따로 정의하여 사용합니다. 즉 사용하는 부분의 layer를 나누어 DTO class와 Domain class각가 사용하기에 서로에 대한 mapping이 필요하며 보통 Mapper를 만들어 두개의 class를 converting 합니다.

이렇게 layuer를 나눠 놓으면 api 변경으로 DTO class의 구조나 필드가 변경되더라도 실제 Domain class에는 영향을 미치지 않습니다. 필요에 따라 중간의 Mapper 로직을 변경하면 됩니다.

Mapping DTO class to Domain class

만약 데이터가 flow형태로 전달되는 형태라면 변환을 위해서 쉽게 map operator를 사용할 수 있습니다.

먼저 아래와 같은 DTO 클래스가 있다고 가정합니다.

data class UserDto(
    val id: Int? = null,
    val name: String? = null,
    val age: Int? = null
)

id, name, age 모두 Nullable 합니다. 이는 api에서 전달되는 값이 혹시 누락되는경우 exception을 막기위함입니다. 하지만 실제 domain 영역에서 이를 사용할 경우 ? 같은 null 처리가 추가적으로 필요하므로 이런 불필요한 작업들을 막기 위해 domain class는 아래와 같이 지정 했다고 가정 합니다.

data class User(
    val id: Int,
    val name: String,
    val age: Int
)

그럼 이 둘을 변경하는 Mapper를 Kotlin의 extension function을 이용하여 만들어 봅니다.

fun UserDto.asDomain() = User(
    id = this.id ?: -1,
    name = this.name.orEmpty(),
    age = this.age ?: 0
)

이제 asDomain()을 아래와 같이 flow에 사용하면 이후 downStream에서는 DTO -> Domain class로 converting된 객체를 사용할 수 있습니다.

suspend fun getUserData() =
        userDtoFlow
            .map { it.asDomain() }
            .collect { println("$it") }

 

Interface와 flow의 transform을 사용.

만약 아래와 같이 여러개의 api가 flow로 적용될 경우 아래와 같이 동일하게 map으로 처리하여 clas간 mapping을 처리하게됩니다.

suspend fun getUserData() =
    userDtoFlow
        .map { it.asDomain() }
        .collect { println("$it") }

suspend fun getStateData() =
    stateDtoFlow
        .map { it.asDomain() }
        .collect { println("$it") }
            
suspend fun getScheduleData() =
    sheduleDtoFlow
        .map { it.asDomain() }
        .collect { println("$it") }
...

map 연산자가 여러개 있는게 예쁘지 않아 보이니 이를 flow의 extension 함수로 확장하여 좀더 예쁘게 만들어 보겠습니다.

public inline fun <T, R> Flow<T>.transform(
    @BuilderInference crossinline transform: suspend FlowCollector<R>.(value: T) -> Unit
): Flow<R> = flow { // Note: safe flow is used here, because collector is exposed to transform on each operation
    collect { value ->
        // kludge, without it Unit will be returned and TCE won't kick in, KT-28938
        return@collect transform(value)
    }
}

Flow에는 transform operator가 존재합니다. 이는 filtermap을 일반화시킬수 있는 operator로 여러 연산을 할수 있도록 customizing할 수 있습니다.

여기서 <T>는 upstream에서 들어오는 data type이고 <R>은 transform 이후에 downstream으로 전달되는 data type을 말합니다.

fun Flow<UserDto>.toDomain1(): Flow<User> = transform { value ->
    emit(value.asDomain())
}

transform을 이용하여 위와 같은 toDomian1()이라는 flow extension function을 정의했습니다.

그럼 원래의 User 데이터 함수는 아래와 같이 map 없이 호출할 수 있습니다.

    suspend fun getUserData1() =
        userDtoFlow
            .toDomain1()
            .collect { println("$it") }

좀더 깔끔해 졌습니다만 이는 UserDtoUser class로 converting하는데만 쓸수 있는 특수 정의된 extension function입니다. 따라서 좀더 general하게 사용하기 위해 아래와 같이 interface를 지정합니다.

interface Domain

interface Dto {
    fun asDomain(): Domain
}

자 이 두개의 ineterface를 상속받아 처리하도록 UserDto와 User 클래스를 아래와 같이 변경합니다.

data class UserDto(
    val id: Int? = null,
    val name: String? = null,
    val age: Int? = null) : Dto {
    override fun asDomain(): Domain {
        User(
            id = this.id ?: -1,
            name = this.name.orEmpty(),
            age = this.age ?: 0
        )
    }
}

data class User(
    val id: Int,
    val name: String,
    val age: Int) : Domain

이제 transfrom 함수도 interface를 처리하도록 generalize 해봅니다.

fun Flow<Dto>.toDomain2(): Flow<Domain> = transform { value ->
    emit(value.asDomain())
}

이제 DomainDto interface를 상속받는 어떤 클래스에 대해서도 toDomain2()를 사용할 수 있습니다.

suspend fun getUserData() =
    userDtoFlow
        .toDomain2()
        .collect { println("$it") }

suspend fun getStateData() =
    stateDtoFlow
        .toDomain2()
        .collect { println("$it") }
            
suspend fun getScheduleData() =
    sheduleDtoFlow
        .toDomain2()
        .collect { println("$it") }
...

이렇게 할 경우 toDomain2() 함수를 범용적으로 사용할수 있지만 각각의 class들이 DtoDomain interface를 꼭 상속받아야 합니다. 게다가 mapper 역할을 하는 함수가 Dto class에 귀속됩니다.

Extension의 활용

사실 interface를 만든다 하더라도 내부에서 만들어놓은 규약으로 여러명의 개발자가 다 인지하기는 어렵습니다. 나중에는 interface를 쓰는사람, 그냥 Mapper를 만들어 사용하는 사람, map으로 단순 치환만해서 사용하는 사람등, 공용화 form을 만들어 놓았다 하더라도 지켜지지 않을수 있습니다.

따라서 mapper 만드는 작업이 각 프로젝트별로 따로따로 작성되거나 이렇게 generalize할 필요가 없다면, 아래와 같이 간단하게 바꿀수 있습니다. 

fun UserDto.asDomain() = User(
    id = this.id ?: -1,
    name = this.name.orEmpty(),
    age = this.age ?: 0
)


fun Flow<UserDto>.toDomain3(): Flow<User> = map { it.asDomain() }

suspend fun getUserData3() =
    userDtoFlow
        .toDomain3()
        .collect { println("$it") }

Interface를 사용하지 않아 일반화가 되지는 않지만 mapper 동작을 class 내부로 한정짓지 않을 수 있고, 호출부분에서도 transform 대신 간단하게 map을 wrapping하여 toDomain3() 함수로 대체할 수 있습니다.

원문에서는 세번째 방법으로 Generic을 사용하는 방법도 소개합니다.[1] 이 방법도 역시 장단점이 존재하나, 원문을 보면서 "아..이건 아닌것 같다..."싶어서 옴기지는 않습니다. 궁금하신 분은 원문 링크를 확인하시면 됩니다.

References

[1] https://florentblot.medium.com/redundant-dto-domain-mapping-in-kotlin-flow-bffbd1d28fc8

 

반응형