선생님, 개발을 잘하고 싶어요.

[아틱 프로젝트 구조] Repository와 callback을 활용한 데이터 코드와 UI 코드의 분리 본문

개발/android 개발

[아틱 프로젝트 구조] Repository와 callback을 활용한 데이터 코드와 UI 코드의 분리

알고싶은 승민 2019. 7. 13. 03:04

전에 글이랑 이어지는 글이다. (앱잼 끝났다! 같이 고생한 아틱크루들에게 감사를)

 

[프로젝트 구조] Repository 를 활용한 데이터 코드와 UI 코드의 분리

필자가 최근에 artic이라는 앱을 개발하기 위해 프로젝트 구조를 설계한 내용을 공유해보고자 한다. 어떤 프로젝트 구조이고 왜 이 프로젝트 구조를 생각했으며 어떻게 목적을 달성하는지 보여주고자 한다. 목적..

greedy0110.tistory.com

시작은 사뿐하게

목적, 패키지 구조 자체는 이전 글과 유사한 구성을 채택하여서 바로 본론으로 들어가겠다.

 

결론적으로 현재 아틱 version1.0에서는 rxjava를 사용하지 않았다. 그 이유는 다음과 같다.

  1. 2주 라는 기간 동안 접하기에는 학습 커브가 높아서 무리하게 라이브러리 사용을 주장하기 어렵다.

  그래서 선택한 차선책은 Retrofit이 제공하는 Call 객체이다. 서버 통신의 결과로 Call 을 주고 등록된 Callback<*> 인터페이스를 사용해서 비동기를 처리하던 기본 retrofit 방식에서 착안했다.

 

로컬 데이터 구성

 

로컬 데이터를 나타내기 위해 Retrofit-Mock을 사용했다. (https://github.com/square/retrofit/tree/master/retrofit-mock)

fun getSomedata(): Call<UI-DATA> {
    return Calls.response(
        UI-DATA.create()
    )
}

  다음 처럼 더미 데이터를 만들면 해당 반환 값을 사용해서 ui 코드에서 enqueue가 가능하다. 따라서 로컬 더미데이터를 사용한 구현과 서버 데이터 (Call 객체 반환)을 일관적으로 구성하여 개발할 수 있었다. 

 

서버 데이터 구성

 

서버 데이터를 표현하기 위해 retrofit에서 기본 제공하는 Call 객체를 사용하였다. 따라서 Retrofit 공식 튜토리얼 문서만 봐도 쉽게 서버 코드를 작성할 수 있다.

 

fun getSomedata(): Call<SERVER-DATA> {
    return retrofit.getSomedata()
}

고민의 시작

 

위의 코드에서 문제점이 보이는가? 바로 로컬 데이터는 UI 데이터를 반환하도록 구현하였지만 서버 데이터는 retrofit 통신을 위해 SERVER 데이터를 감싼 Call 객체를 반환한다는 점이다. 이게 왜 고민이였는지 같이 한번 초기 enqueue 코드를 보자.

 

// repository 측 코드
fun getCategoryList(): Call<Category> {
    return local.getCategoryList()
}


// ui 측 코드
repository.getCategoryList().enqueue(
    object : Callback<List<Category>> {
        override fun onFailure(call: Call<List<Category>>, t: Throwable) {
            toast("카테고리 로드 실패")
        }

        override fun onResponse(call: Call<List<Category>>, response: Response<List<Category>>) {
            response.body()?.let {
                categoryLayout.categories = it
                frame_category.removeAllViews()
                frame_category.addView(categoryLayout.render())
            }
        }
    }
)

이 코드는 repository에서 Category 리스트를 받아오는 함수다. 이 코드를 작성하고 다른 UI 코드를 짤 때만 해도 고민은 없었는데 서버 통신을 하려고 API 문서를 보는 순간 머리가 아파졌다. 서버에서 보내는 정보가 Category 와는 너무나도 다른 스키마를 가지고 있던 것이다.

 

그래서 Category를 변경하려면 기존의 목적이었던 서버 변화와 무관한 UI 코드 작성의 의미가 더뎌지는 것이다.

 

또 다른 문제는 enqueue 호출을 계속하면 Callback 익명 객체 생성이라는 끔찍한 보일러 플레이트 코드 생성을 해야 한다는 것이다.

 

레트로핏 반환 값을 변경하고 싶다.

그러면 레트로핏에서 Call 객체를 반환할 때 mapper 같은 것이 없나 하고 찾아봤다. (rxjava에서는 map operator) 하지만 시간의 촉박함 때문인지 없는 것인지 찾기가 어려웠다. 그래서 고민한 결과 레트로핏에서 반환 값을 변경하는 것이 아니라 Repository에서 반환값을 변경하자는 생각을 했다.

 

그래. enqueue를 repository에서 하자 (이 생각을 앱잼 첫 주차 끝나가는 주말에 생각했다.)

 

그래서 변경된 코드는 다음과 같다.

 

// repository 측 코드
fun getCategoryList(
    successCallback: (Category) -> Unit,
    failCallback: (Throwable) -> Unit
) {
    return remote.getCategoryList().enqueue(
        object : Callback<List<CategoryResponse>> {
            override fun onFailure(call: Call<List<CategoryResponse>>, t: Throwable) {
                failCallback(t)
            }

            override fun onResponse(call: Call<List<CategoryResponse>>, response: Response<List<CategoryResponse>>) {
                response.body()?.let {
                    successCallback(it.map {
                        // CategoryResponse to Category
                    })
                }
            }
        }
    )
}


// ui 측 코드
repository.getCategoryList(
    successCallback = {
        categoryLayout.categories = it
        frame_category.removeAllViews()
        frame_category.addView(categoryLayout.render())
    },
    failCallback = {
        toast("카테고리 로드 실패")
    }
)

코드는 더 길어졌으나 UI 측 코드에서 서버 측 데이터인 CategoryResponse 참조가 없다는 점을 알 수 있다.

이런 구조로 변경하여서 첫 번째 문제점인 서버 변화와 무관한 UI 작성은 해결되었다.

 

그래서 Repository 짜기 귀찮아요.

안다 안다. 나도 Repository 코드를 짜야하는 입장에서 엄청나게 번거로워졌다는 사실을 인정한다. 그래서 필자는 팀원들 개발의 편의를 위해 두 번째 문제점인 Callback 객체 생성 보일러 플레이트 코드를 제거를 시도했다.

 

그러기 위해서 아틱 서버에서 보내는 정보들을 정리했다.

data class BaseResponse<Data> (
    val status: Int,
    val success: Boolean,
    val message: String,
    val data: Data?
)

아틱에서는 서버 통신이 완료되었을 때 항상 status 상태 값, success 여부, 서버에서 전달하는 message를 제공한다.

이를 토대로 다양한 상황에 적용 가능한 Callback 익명 클래스 제조 함수를 만들었다.

 

/**
* @param mapper transform server data to UI data
* @param successCallback will be called when server interaction success
* @param failCallback will be called when server interaction fail
* @param statusCallback will be called when server interaction with no error
* @param errorCallback will be called when called onFailure (network error)
* @author greedy0110
* */
private fun <UI, SERVER: BaseResponse<*>>createFromRemoteCallback(
    mapper: (SERVER) -> UI,
    successCallback: ((UI) -> Unit)? = null,
    failCallback: ((String) -> Unit)? = null,
    statusCallback: ((Int, Boolean, String) -> Unit)? = null,
    errorCallback: ((Throwable) -> Unit)? = null
): Callback<SERVER>
{
    return object : Callback<SERVER> {
        override fun onFailure(call: Call<SERVER>, t: Throwable) {
            errorCallback?.invoke(t)
        }

        override fun onResponse(
            call: Call<SERVER>,
            response: Response<SERVER>
        ) {
            response.body()?.let {
                statusCallback?.invoke(it.status, it.success, it.message)
                if (it.success)
                    successCallback?.invoke(mapper(it))
                else if (!it.success) {
                    failCallback?.invoke(it.message)
                }
            }
        }

    }
}

서버에서 항상 전달해주는 정보들을 사용해서 ui 코드에서 원하는 행동을 수행할 수 있도록 callback을 설정하였다.

success 여부에 따라 success callbakc을 발동시키거나, fail callback을 발동시킨다.

status에 따른 처리를 커스텀하게 하고 싶은 경우 status callback을 등록하면 된다.

네트워크 에러로 통신을 처리하려면 error callback을 등록하면 된다.

 

또한 mapper를 제공해서 서버 데이터를 UI 데이터로 변경하는 로직을 인자로 전달시켰다. (참고로 이 코드는 repository 함수를 쉽게 제작하기 위해 만들어진 함수다!)

 

그래서 만들어진 최종 repository 함수는 다음과 같다.

 

fun getRecommendWordList(
    successCallback: (List<RecommendWordData>) -> Unit,
    failCallback: ((String) -> Unit)?=null,
    statusCallback: ((Int, Boolean, String) -> Unit)?=null,
    errorCallback: ((Throwable) -> Unit)? = null
) {
    remote.getSearchRecommendation().enqueue(
        createFromRemoteCallback(
            mapper = {
                if (it.data == null) listOf()
                else it.data.map { res ->
                    RecommendWordData(res.search_word)
                }
            },
            successCallback = successCallback,
            failCallback = failCallback,
            statusCallback = statusCallback,
            errorCallback = errorCallback
        )
    )
}

이제 우리는 repository 함수를 구현할 때 mapper 함수만 적절히 작성해주면 된다!!! 물론 콜백을 매개변수로 받고 그걸 넘기는 보일러 플레이트가 있다... ㅠㅠㅠ

 

결론

팀플레이는 생각대로 되지 않는다.

처음에 블로깅한 설계는 혼자서한 뇌내 설계였다. 해당 설계가 좋은지 안 좋은지 알지 못하는 상태에서 개인 프로젝트라면 과감히 시도해 볼 수 있겠으나 팀원들과 함께 개발하는 팀 프로젝트에서는 서로의 생각의 차이가 있기 때문에 나만의 생각으로 진행할 수 없더라.

 

생각부터 하고 코딩하자.

제플린 보고 UI를 정신없이 짜다 보니 초기에 설계했던 구조에서 많이 벗어나더라. 그래서 그거 수정하느라고 엄청 시간을 많이 썼다. 다음에는 단순히 설계만 하는 게 아니라 개발 순서를 생각하는 프로그래밍을 하자.

 

팀플레이는 재밌다.

 

아참 그리고 우리 아틱 github 주소

 

artic-development/artic_android

Contribute to artic-development/artic_android development by creating an account on GitHub.

github.com

 

Comments