본문 바로가기
프로그래밍

[번역] 반복적인 DTO-Domain 변환 처리하기 (feat. Kotlin Flow)

by kdohyeon (김대니) 2023. 3. 31.
반응형

클린 아키텍처를 사용해 보면서 큰 단점으로 느꼈던 부분이 각 계층간 반복적인 DTO 변환이었습니다. 이 단점을 어떻게 해결할 수 있을까 라는 고민을 가지고 있었는데, 약간의 갈증을 해소시켜 주는 글을 읽게 되어 정리해두고자 합니다. Redundant DTO-domain Mapping in Kotlin Flow 글을 읽고 번역(+ 개인 의견)한 내용입니다. (참고로 오역이 있을 수 있으니, 원문을 참고하시길 권장드립니다.)


Photo
Photo by Ivan Bandura on Unsplash

Intro.

계층을 분리하는 것은 높은 품질의 코드를 작성하고, 에러와 예상치 못한 동작을 방지하기 위한 좋은 관행 중 하나이다. API로 부터 데이터를 조회할 때, DTO 클래스에 결과 값을 파싱하여 받아오게 된다. 이를 Mapper 를 사용하여 Domain 클래스로 변환한다. 다른 계층에서는 Domain 객체를 활용하게 되며, 이를 통해 각 계층간 데이터 분리를 확실하게 할 수 있다.

map 을 사용해서 DTO를 Domain 객체로 변환하고 다운스트림에서 활용하게 된다. 매번 객체 변환을 위해 map 을 반복적으로 수행하는 것이 아니라 좀 더 나이스한 방법으로 별도의 커스텀한 operator 를 만들어 DTO 와 Domain 간 변환을 한 줄로 해보고자 한다.

Mapping DTO-Domain

코틀린에서는 이런 맵핑은 map operator 와 간단한 클래스 extension 을 활용해 해결할 수 있다. 외부 API로부터 데이터를 받을 DTO객체에는 모든 필드를 nullable 하게 만들어야 한다. 이유는 외부 API가 특정 필드에 대한 값을 내려주지 않는 상황에서도 예외가 발생하지 않아야 하기 때문이다 (예외가 발생한다면 외부 API에 대한 의존도가 너무 높아져버리기 때문). 예를 들어, 사용자의 데이터를 받아 아래에 보이는 DTO객체로 변환할 수 있다.

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

반면 Domain 객체에서는 optional 필드를 제거하고 만들 수 있다 (= 모두 not null). 이는 프로젝트 내 모든 계층에 nullable 한 필드가 퍼지는 것을 방지한다. 또한 ? operator 를 null 을 체크하기 보다 기본 값을 활용하는 것이 낫다. (물론 null 이 필요한 상황이 있을 수 있다.)

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

코틀린에서는 아래처럼 DTO extension 을 만들어 DTO 에서 Domain 으로 변환할 수 있다.

fun UserDto.toDomain() = User(
    id = this.id.orEmpty(), // id 값이 없으면 "" 으로 대체
    name = this.name.orEmpty(),
    age = this.age ?: 0
)

그러면 map operator 를 활용하는 곳에서는 이렇게 사용할 수 있다.

fun fetchUserData()
    .map { return@map it.toDomain() }
    .collect { println(it) }

이제 DTO 에서 Domain 으로 변환할 수 있다.

Fetch, Map, Repeat

위 작업을 기반으로 DTO 에서 Domain 으로 변환을 하면 아래와 같은 코드가 생겨날 수 있다.

fun fetchUserData()
    .map { return@map it.toDomain() }
    .onEach { .. }
    .map { .. }
    ...

fun fetchStatsData()
    .map { return@map it.toDomain() }
    .onEach { .. }
    .map { .. }
    ...

fun fetchArticleData()
    .map { return@map it.toDomain() }
    .onEach { .. }
    .map { .. }
    ...

이 코드 블럭도 반복적으로 작성이 되어야 한다. 이 한줄의 코드 블럭을 숨기고 .toDomain() 을 다운스트림에서 바로 호출할 수 있다면 좀 더 좋지 않을까?

시도 #1. Interfaces

Kotlin Flow 에서의 Transform 은 커스텀한 operator 를 만들 수 있게 해준다.

This operator generalizes filter and map operators and can be used as a building block for other operators.

Flow 의 extension 중 하나로써 두 개의 generic types 을 받는다. 초기값과 최종값으로 TR 을 각각 받는다. 사용하는 방법은 아래와 같다.

public inline fun <T, R> Flow<T>.transform(
    @BuilderInference crossinline transform:
    suspend FlowCollector<R>.(value: T) -> Unit
): Flow<R> = flow {
    collect {
        return@collect transform(value)
    }
}

이를 잘 활용해서 우리만의 operator 를 만들 수 있다.

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

참고로 Flow operator 의 toDomain() 과 이름이 헷갈리지 않게 하기 위해서 UserDto 에 만들어두었던 toDomain()asDomain() 으로 이름을 변경하였다. 이를 통해 좀 더 간결한 코드를 작성할 수 있게 된다.

fun fetchUserData()
    .toDomain()
    .onEach { .. }
    .map { .. }
    ...

이 방법의 단점으로는 User 에 대해서만 적용이 된다는 점이다. Generic 이 아니기 때문에 각 Domain 별 만들어줘야 한다는 단점이 있다. 이를 위해서는 DTO 와 Domain 에 대한 interface 를 각각 만들고 상속받아 사용하면 된다.

interface Dto {
    fun asDomain(): Domain
}

interface Domain

UserDtoUser 객체는 아래와 같이 수정하면 된다.

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

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

각 모델을 넣어 커스텀한 Flow toDomain() 메소드를 만드는 것이 아니라 인터페이스를 활용하여 만들면 된다.

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

이제 map operator 를 없앨 수 있다!

장점은 asDomain() 을 강제로 구현해야 하기 때문에 까먹을 일이 없고, generic 하기 때문에 모든 DTO 를 Domain 으로 변경할 수 있다.

단점으로는 DTO 클래스 내부에 mapping 메소드가 존재하기 때문에 코드가 지저분해질 수 있고 "모든" 데이터 클래스가 인터페이스를 구현해야 한다.

시도 #2. Extensions

시도 #1 은 성공적이었지만 mapping 메소드와 DTO 클래스를 분리할 수 없었다. asDomain() 을 DTO 클래스에서 분리하기 위해서는 해당 로직을 Flow 를 구현하는 곳으로 옮기면 된다.

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

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

이렇게 했을 때의 장점은 mapping 메소드를 별도 파일로 관리할 수 있고 가독성도 좀 더 좋다. 하지만 거의 대부분이 별도 파일로 만들어야 하기 때문에 파일이 좀 많이 생길 수 있다.

시도 #3. Generics

두 번째 시도도 좋지만 매번 새로운 파일을 만드는 것은 마음이 좀 불편하다.

Any 를 활용하면 하나의 Flow extension 으로 관리할 수 있다. Any 로 데이터를 받고 When 으로 관리할 수 있다. 예시를 보자.

fun <T> Flow<Any>.toDomain(): Flow<T> = map { value ->
    val domain: Any = when (value) {
        is UserDto -> value.asDomain()
        isStatsDto -> value.asDomain()
        ...
        else -> throw NotImplementedError(
            "value ($value)) does not implement asDomain() function."
        )
    }
    return@map domain as T
}

이렇게 구현하면 아래처럼 사용할 수 있다.

fetchUserData()
    .toDomain<User>()
    .onEach { .. }
    .map { .. }
    ...

fetchStatsData()
    .toDomain<Stats>()
    .onEach { .. }
    .map { .. }
    ...

각 계층간 객체 변환을 위해서 별도 Converter 를 만들어 mapstruct 를 기반으로 활용하고 있었습니다. Kotlin Flow 에서 이렇게 활용할 수 있는 방법에 대해 처음 알게 되어 정리해보았고 개인 프로젝트를 할 때 한번 사용해보고 느낀 점에 대해서는 추후 다시 기록해보려고 합니다.

반응형

댓글