Skip to content

JSend Data Model 에서 Depth 지옥에서 벗어나보기

Sieun Ju edited this page Jan 28, 2023 · 2 revisions

요약 (Summary)

  • JSend 규칙으로 받는 Data Object 에서 실제로 필요한 “payload” 안에 있는 데이터를 원뎁스로 처리할수 있는 방법을 고민해 봅니다.

고민을 하게된 계기 (Motivation)

  • JSend Format 으로 데이터를 주고 받다 보면 제가 생각했을때 가장 Best 데이터 모델은 아래라고 생각합니다.
{
	status : "success", "fail", "error",
	message: "Error Message",
	data : {
		payload : Array or Object,
		meta : MetaEntity
	}
}
@Serializable
data class JSendBaseResponse<T : Any>(
    @SerialName("status")
    val isSuccess: Boolean = true,
    @SerialName("message")
    val message: String? = null,
    @SerialName("data")
    val data: T? = null
)
// 예시기 때문에 Payload 가 List 형식만 있습니다. Object 형식으로도 만들수 있습니다.
@Serializable
data class JSendListWithMeta<T : Any, M : MetaEntity>(
    @SerialName("payload")
    val list: List<T> = listOf(),
    @SerialName("meta")
    val meta: M? = null
)
RepositoryImpl.kt
// Repository 패턴 혹은 UseCase 패턴에서 status or message 에 대한 값은 
// 에러인경우에만 필요하기 때문에 보통 아래와 같이 map 확장함수를 통해 실제로 쓰일 데이터를 
// 리턴해서 사용합니다.

override fun fetchJSendListMeta(): Single<JSendListWithMeta<String, MetaEntity>> {
        return apiService.fetchJSendListWithMeta()
            .map { it.data ?: throw NullPointerException("Data is Null") }
}
  • 위와 같은 구조를 하는 경우 한가지 딜레마에 빠지게 됩니다. API 를 추가하는 경우
    • API 추가
    • Repository 추가
    • RepositoryImpl 추가
    • Impl 클래스 안에 map 함수를 사용하여 처리
  • 이러한 구조를 하게 되면 map 함수는 꾸준히 추가 해야 하는 함수가 됩니다. 그렇게 되면 보일러코드가 생성이 되고 많은 귀차니즘이 발생합니다. 또한, 불필요한 확장 함수는 제가 좋아하는 스타일이 아니라고 생각합니다.

목표

  • 불필요한 Map 함수를 사용하지 않고 UseCase 패턴에서 ‘알맹이’ 데이터를 바로 가져다 사용할수 있는 방법에 대해서 생각 하도록 합니다.

고민

  • 이러한 과정을 없애기 위해 Retrofit 에서 제공하는 몇가지 클래스에 대해서 심도있게 관찰을 했습니다.
  • CallAdapter, Converter 에 대해서 공부를 했고, 몇가지 솔루션을 생각해 냈습니다.

Converter 에서 재 가공해야 하는 Annotation 를 찾아서 재 가공 하는 방법


  • Converter 를 Custom 하게 사용시 기존에 사용하고 있는 로직에 대해서 이슈가 생기지 않아야 하므로 Converter 할때 특정 Annotation Class 체크 하여 재 가공 할수 있는 방법입니다.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class JSendSimple
Converter.kt

override fun responseBodyConverter(
        type: Type,
        annotations: Array<out Annotation>,
        retrofit: Retrofit
    ): Converter<ResponseBody, *> {
        val loader = format.serializersModule.serializer(type)
        val rawType = getRawType(type)
        Timber.d("RawType $rawType")
        return DeserializationConverter(rawType, loader, format)
}

/**
	* Response 역 직렬화 컨버터
*/
class DeserializationConverter<T>(
        private val rawType: Class<*>,
        private val loader: DeserializationStrategy<T>,
        private val format: Json
    ) : Converter<ResponseBody, T> {
        override fun convert(value: ResponseBody): T? {
            val string = value.string()
            if (rawType.isAnnotationPresent(JSendSimple::class.java)) {
                Timber.d("JSendSimpleResponse 가 있습니다. ")
                val jsonElement = format.decodeFromString<JsonElement>(string)
                val dataBody = jsonElement.jsonObject["data"]
                    ?: return format.decodeFromString(loader, string)

                val status = jsonElement.jsonObject["status"]
                val message = jsonElement.jsonObject["message"]
                val payload = dataBody.jsonObject["payload"]
                val meta = dataBody.jsonObject["meta"]
                val map = ConcurrentHashMap<String, JsonElement>()
                if (status != null) {
                    map["status"] = status
                }
                if (message != null) {
                    map["message"] = message
                }
                if (payload != null) {
                    map["payload"] = payload
                }
                if (meta != null) {
                    map["meta"] = meta
                }
                val json = JsonObject(map)
                Timber.d("재 가공 데이터 $json")
                return format.decodeFromString(loader, json.toString())
            } else {
                Timber.d("JSendSimpleResponse 가 없습니다. ")
                return format.decodeFromString(loader, string)
            }
        }
    }
  • 위 방법 처럼 ‘data’ Depth 를 앞으로 이동후 재 가공해서 처리할수 있게 합니다. 혹시나 message or status 를 UseCase 에서 사용할수 있다고 생각이 들어 해당 값도 넣어서 처리 했습니다.

  • 사용 예시

      ```kotlin
      override fun fetchJSendListWithMeta(): Single<JSendListWithMeta<String, CustomMetaEntity>> {
              return jsendApiService.fetchJSendListWithMetaTest()
          }
      ```
    
    • 위와 같이 따로 JSendResponse 를 안해도 앞단에서 json string 에서 data 를 꺼내서 변환 하기때문에 따로 map 함수를 사용안하고 바로 사용할수 있다는 장점이 있습니다.
    • data 가 null 인경우 Error 로 빠지는 이슈가 있습니다. 하지만, data 가 null 인경우는 에러가 맞다고 생각이 들었고 Response 가 없는 경우는 Void 로 풀어 낼수 있겠습니다.
  • 이슈 사항

    • 재 가공하면서 불필요한 JsonObject 와 HashMap 을 생성 하는 이슈가 있습니다. map 확장함수를 안하겠다고 저런 과정을 하기에는 더 불필요해보였습니다.

최초 Data Model 에서 payload 와 meta 를 가져오도록 처리하는 방법

  • 위 방법은 너무 어렵게만 생각했던거 같아 위에 방법을 처리하다가 갑자기 쉽게 풀어 낼수 있겠다는 생각이 들었습니다.
  • Data Class 로 쉽게 해결할수 있을거 같다는 생각이 들어서 Data Class 구조를 변경했습니다. 그리고 Data 가 Non Null 를 보장하기 위해 CallAdapter 에서 풀어낼수 있을거 같습니다.
    • data 가 null 인경우 → Error 로 판단 Nothing 이 아닌이상 성공하면 무조건 data 라는 키값 안에 데이터가 존재 해야 한다는 가정
    • CallAdapter 를 통해 data 가 Null 인경우 Error 로 리턴한다.
DataModel.kt

@Serializable
data class JSendListWithMeta<T : Any, M : MetaEntity>(
    @SerialName("status")
    val isSuccess: Boolean = true,
    @SerialName("message")
    val message: String? = null,
    @SerialName("data")
    private val depthData: Payload<T, M>? = null
) {
    @Serializable
    data class Payload<T : Any, M : MetaEntity>(
        @SerialName("payload")
        val list: List<T> = listOf(),
        @SerialName("meta")
        val meta: M? = null
    )

    val payload: List<T>
        get() = depthData?.list ?: listOf()
    val meta: M?
        get() = depthData?.meta
}

Repository.kt
fun fetchJSendListWithMeta(): Single<JSendListWithMeta<String, MetaEntity>>
  • 위에 코드 처럼 Repository 에서 여러 Depth 를 처리 하지 않고 한개의 Depth 로 간단히 처리 할수 있겠습니다. as-is-to-be

JSend 규칙에 맞게 OkHttp CallAdapter.Factory 에러 핸들링 처리

참고 클래스

class RxErrorHandlingCallAdapter : CallAdapter.Factory() {
    private val original = RxJava3CallAdapterFactory.createWithScheduler(Schedulers.io())

    companion object {
        fun create(): CallAdapter.Factory {
            return RxErrorHandlingCallAdapter()
        }
    }

    override fun get(
        returnType: Type,
        annotations: Array<out Annotation>,
        retrofit: Retrofit
    ): CallAdapter<*, *>? {
        val adapter = original.get(returnType, annotations, retrofit)
        return if (adapter != null) {
            RxJavaCallAdapterWrapper(adapter)
        } else {
            null
        }
    }

    class RxJavaCallAdapterWrapper<R>(
        private val original: CallAdapter<R, *>
    ) : CallAdapter<R, Any> {

        override fun responseType(): Type = original.responseType()

        override fun adapt(call: Call<R>): Any {
            return when (val res = original.adapt(call)) {
                is Single<*> -> {
                    res.map { it.performErrorHandling() }
                }
                is Flowable<*> -> {
                    res.map { it.performErrorHandling() }
                }
                else -> {
                    throw IllegalArgumentException("Not Invalid Type")
                }
            }
        }

        @Throws(JSendInvalidPayloadException::class, JSendEmptyDataException::class)
        private fun Any.performErrorHandling(): Any {
            return if (checkDataPayload()) {
                this
            } else {
                if (this is BaseJSend) {
                    throw JSendInvalidPayloadException(message)
                } else {
                    throw JSendInvalidPayloadException("Invalid Exception")
                }
            }
        }

        /**
         * data 가 없거나 안에 payload 가 유효하지 않는 경우
         */
        @Throws(JSendEmptyDataException::class)
        private fun Any.checkDataPayload(): Boolean {
            return when (this) {
                is JSendObj<*> -> {
                    if (this.isValid) {
                        true
                    } else if (isSuccess) {
                        throw JSendEmptyDataException(message)
                    } else {
                        false
                    }
                }
                is JSendObjWithMeta<*, *> -> {
                    if (this.isValid) {
                        true
                    } else if (isSuccess) {
                        throw JSendEmptyDataException(message)
                    } else {
                        false
                    }
                }
                is JSendList<*> -> {
                    if (this.isValid) {
                        true
                    } else if (isSuccess) {
                        throw JSendEmptyDataException(message)
                    } else {
                        false
                    }
                }
                is JSendListWithMeta<*, *> -> {
                    if (this.isValid) {
                        true
                    } else if (isSuccess) {
                        throw JSendEmptyDataException(message)
                    } else {
                        false
                    }
                }
                // 규격화된 방식이 아닌경우 true 리턴
                else -> true
            }
        }
    }
}
  • 위에 클래스대로 하게 되면 더이상 불필요하게 Repository 패턴에서 map( data ?: throw NPR) 없이 바로 UseCase 에서 처리 할수 있습니다.
  • 이미 CallAdapter.Factory 에서 JSend Format 규칙을 확인 해서 UseCase 에서 페이로드값에 대한 보장을 확실하게 받을수 있습니다. :)

예시

class GetGoodsUseCase @Inject constructor(
    private val repository: GoodsRepository
) {
    operator fun invoke(params: GoodsParamMap): Single<List<GoodsEntity>> {
        return repository.fetchGoods(params)
            .map { it.payload }
    }
}

참고 (References)

Clone this wiki locally