안드로이드

서버통신 시리즈(오류처리) 3.오류처리를 기깔나게 해보자

강한 맷돼지 2022. 6. 22. 00:03

자 이번에 쓸글은 서버통신 시리즈 3편이다 ??? 2편도 없는데 3편? 2편으로 쓸려했던 "과연 레트로핏,okhttp 를 잘 알고 쓰는것일까?"

에 대해선 글을 쓸양이 정리하기 시작하면 너무 방대하고 그렇다고 요약하기에는 너무 양이 적었다.

 

그래서 이번글을 쓰면서 공부했던 내용인 오류처리를 위해서 당연히 레트로핏,okhttp 에 대해서도 다 공부하고 짚어봤지만 

일단은 당장 프로젝트에 적용해서 정리하기 쉬운 3편부터 글을 작성하고 프리퀄로 2편을 작성해볼까한다.

 

이제 본론으로 들어가보자

 

글을 다쓰고 보니까 글이 너무 두서없다 하지만 진짜 이거 공부하면서 너무어려워서 과부화가 많이왔다. 그리고 내 미천한 글솜씨로는 더이상 간결하게 표현할 자신도 없기에 페르마처럼 나는 경이로운 방법으로 설명했으나 여백이 충분하지않아 여기 적을 수 없는것으로 마무리하겠다. ->  똑똑한 누군가가 내가 잘못설명하거나 잘못이해한 똥들을 치워주는 좋은 사람이 있었으면 좋겠다.


그래서 뭔 오류처리?

뭐 당장 어떻게 했는지 코드만 보려면 이 부분은 넘어가도 좋다.

-> 왜 공부하게 되었는지 주저리 주저리 적어보려한다.

 

일단 이런 오류처리를 공부하게된 계기부터 짚어보고 넘어가야 할 것이다. 

일단 나는 앱잼당시 스파크를  진행할 때 만해도 아예 오류처리에 대한 감각자체가 없었다.

그냥 터지면 고치는거고 내 시야에 잡히는한 터지는 부분은 없어야지 이런 마인드 였다.

그래서 오류처리를 처리했던 목적이

1.서버통신에서 오류메시지를 받아와서 띄워주어 사용자와 상호작용한다.

2.일단 앱이 비정상 종료가 일어날시 굉장히 완성도가 낮아보이고 조악해보이기 때문에 오류가 일어날시 딱히 뭘해주지는 않지만 로그라도 찍어놓고 그냥 그 오류상황만 넘기는 코드를 짜서 앱이 터지지만 않게 하는 방식이였다.

 

물론 지금도 개발을 잘하지 않지만 그 당시에는 더 못했기 때문에 이런거에서 더 이상 공부를 해볼 생각이나 아니면 처리를 해볼 생각을 하지못했다. 심지어 앱잼기간 3주안에 오류처리까지 완벽하게 하려면 아마 헤르미온느를 데려와서 720 학점을 듣는것처럼 몸이 15개는 되어야 가능할것이다. 

 

그래서 앱잼당시에는 그냥 서버통신시 오류가 많이 발생할수있으니 앱이 터지지 않도록 서버통신마다 runCatching 으로 감싸서 실패시 어디서 오류가 났는지 로그를 찍어주는 정도였다.

 

이때 앱잼 멘토분이 오셔서 조언을 해주신것이

코루틴을 사용하면서 오류처리를 어설프게 할거면 차라리 call 을 사용해서 square사에서 제공하는 아주 강력한 오류처리를 사용하는것이 더 코드의 품질을 올릴 수 있는 방법이라고 call 을 쓰는것에 대한 조언을 받았다.

이때 당시 우리팀뿐만 아니라 여러팀이 같은 조언을 받았다. 

이때 당시에는 오류처리에 대한 경각심도 없었고 그때 당시에 코루틴으로 대충짠 코드들도 내 시야에서는 오류없이 돌아갔기 때문에

에이 뭐 그정도까지 해야하나? 이런의문도 들었었고 그냥 나중에 언젠간 공부해야지 이정도의 마인드 였던것 같다.

 

그후 여러가지 스터디도 거치고 많은 사람들과 이야기를 하면서 오류처리의 중요성도 느끼고 특히나 오류가 많이 일어나는 분야인 서버통신에 있어서는 꼭 오류처리를 꼼꼼히 잘해야겠다는 생각이 들었다.

 

장난식으로 오류처리를 안했을때의 내가 겪을수있는 문제를 봐보자(누군가가 들어준 예시)

 

내가 취업을해서 안드개발자로 뭔가 앱(일기라 쳐보자) 을 구현하고 있었다. 근데 일기를 작성하고 저장하는 처리를하는 부분에서 오류처리를 안해준다면 사용자가 1시간넘게 공을들여서 작성한 일기를 서버통신이나 여타 오류로 인해 상태저장없이 한번에 날려 버릴 수 있는 것이다. 그럼 사용자는 아마 너무 화가나서 앱을 지우는건 당연할테고 앱스토어에 별점테러를 할테고 그것은 우리앱의 인기도 하락 그리고 나의 고용주가 나를 자를수있는 좋은 빌미를 제공할테고 그럼 난 기껏 취업했는데 또 백수가 될수있는 이런 시나리오이다.

 

오류처리를 안한나

뭐 극단적으로 이야기했지만 결국 사용성을 위해서 오류처리는 중요하다는것을 느낄수있다.

물론 오류처리도 예쁘게 잘 아이디어 좋게 할때 해당되는 이야기겠지만 말이다.

 

그래서 오류처리에 대한 필요성은 느꼇고 새로운 실험적인 프로젝트인 Read Me 에서 서버통신시 아주 아름답게 오류처리를 해보자는 목표를 가지고 2달동안 서버통신과 싸웠다.

 


이때의 목표는 명확했다

구글에서도 코루틴을 쓰라고 권장했고 여기저기서 잘하는사람들을 봐도 코루틴을 쓰는데 나도 쓰고싶다!!  + call에서 제공하는 오류처리도 다 가져가고싶다

결론 -> 코루틴을 쓰면서 call 을 썻을때의 경우의 수를 다 충족시킬수있는 오류처리를 하는 방법이 뭐가있을까?

 

떠오르는 방법은 꽤나 많다.

 

1. 확장함수를 이용한 call 코드의 간소화(코루틴의 사용은 아니다)

이 방법은 call.enqueue에 고차함수를 통해 원하는 행동을 하는 함수를 역으로 집어넣어서 처리하기 때문에

call객체를 사용하면서도 모든 오류 처리를 간결하게 할수있으며

전달하는 함수의 경우의 수 를 세분화 시킨다면 관련오류처리를 세분화 할수있기에 원했던 방법을 거의 다 이뤘지만 

이 방법을  사용하지 않았던 이유로는

이미 정의 되어있는 onResponse 함수를 overide해서 사용하는것이기에 ->  onResponse 는 return 값이 없는것으로 정의되있음

mapper 통해서 통신결과값(data layer 의 model) 을  가공하여(domain layer 의 entity) return 하고 싶어도 return 으로 값을 내보낼수 없기에 함수밖으로 원하는값을 빼는데 보일러 플레이트 코드가 생길수밖에 없다 그래서 깔끔하지않아서 pass 했다.

문제점 -> 레이어를 나눠서 코드를 짠다면 적합하지않다!!

 

고차함수의 사용예시도 첨부한다.

mbd1217이 만든자료

 

2.try catch, runCatching 의 극한 활용

사실상 보일러플레이트 코드가 엄청 나와서 그렇지 try catch혹은 runCatching 으로도 exception의 경우의 수를 다 나눠서 오류 처리를 한다면 오류처리를 모든 경우의 수에서 다 할수있을 것이다.

문제점 -> 미칠듯한 보일러플레이트 코드 , 그냥 call 쓰는게 좋을수도?

 

3. sealed class 를 이용한 Wrapper Class사용

sealed class 를 통해 오류 혹은 성공의 경우의 수를 나누고 그에 따른 대응을 할수있도록 미리 try catch 를 적절하게 적용한 함수를 통해 경우의 수 마다 나올수있는 오류 혹은  성공시 받을수있는데이터를 데이터 통신을 호출하는 부분에서 간결하게 경우의 수 별로 분기처리 할수있도록 하는 방법이다.

이 방법은 괜찮은 방법이라 판단되어 실제로 현재 사용하고있는 call adapter 를 대체해도 된다고 생각하여 사용법을 설명하는 예시 블로그 두개를 첨부해 놓겠습니다.

https://bb-library.tistory.com/264

 

[Android] Coroutine, Retrofit을 활용한 비동기 네트워킹 처리 중 에러 핸들링

개요 안드로이드에서 비동기 처리를 하는 대표적인 방법 중 하나는 Retrofit과 Coroutine을 활용하는 것이다. 이 과정에서 다양한 네트워크 오류 상황에 대응하기 위한 다양한 에러 핸들링 방법에 대

bb-library.tistory.com

 

https://medium.com/@douglas.iacovelli/how-to-handle-errors-with-retrofit-and-coroutines-33e7492a912

 

How to handle errors with Retrofit and Coroutines

In this article I’m about to show you a solution for handling errors with Retrofit + Coroutines + Moshi.

medium.com

문제점 -> 좋은 방법이나 원래 의도 했던 call 을 이용하는 것과는 동떨어진 방법( 내가 만든거니까 빼 먹은게 있을수있지 않을까? 라는

의문)

 

4.Coroutine Exception Handler 을 이용한 오류처리

이방법 또한 굉장히 매력적이고 서버통신을 할때 코루틴을 거의 이용하기 때문에 굉장히 효율적으로 오류처리를 하는 방법이라고 생각한다.

코루틴에서 나는 오류들을 Coroutine Context 중 하나인 Coroutine Exception Handler를 통해서 이것을 붙인 코루틴 스코프에서 나는 오류들을 Coroutine Exception Handler에 정의되어있는 방법대로 오류처리를 해주는 방법이다.

코루틴을 이용해서 서버통신을 하려는 목적에도 부합하고 공통적인 처리를 요하는 오류처리는 이 방법을 이용해서 처리해준다면 간단하고 문제없이 그 코루틴 스코프에서 일어나는 오류들을 빈틈없이 처리할 수 있을거라 보여 나중에 코루틴을 더욱더 심화적으로 공부한다면 차용해서 복합적으로 오류처리를 할때 사용할것같다.

간단한 사용법에시

https://kotlinworld.com/148#CoroutineExceptionHandler%--%EC%-D%B-%EC%-A%A-%ED%--%--%EA%B-%B-

 

문제점-> coroutine 은 정말 어렵고 오류문제는 특히 CancellationException 이나 아니면 자식 스코프에서 오류가 나면 부모의 coroutine 까지 모두 꺼버리는등 복잡한 부분이 많아 이해도가 높지않다면 괜히 잘못썻다가 알지도 못하는 오류만 잔뜩 겪고 코루틴한테 상큼하게 터질수있기 때문에 이해도를 더높이고 공부해서 써볼예정이다.

 

코루틴한테 터져볼래?

참고사항

Coroutine Context : job 혹은 dispatcher ,Coroutine Exception Handler와 같이 코루틴이 실행되는 환경 이라고 생각하면된다 -> 

코루틴을 사용하다보면 스코프나 coroutine builder에 CoroutineContext의 자료형을 가진 것들을 매개변수로 넘겨주는 부분을

볼수있다 즉 그 코루틴 스코프에서 어떤 실행환경을 가지고 코루틴 스코프를 어떻게 실행할것인가에 대한 것들을 알려주는 요소들이다.

Coroutine Context 는 저런것들을 포괄하는 부모 자료형이라 생각하면 되지않을까 싶다(개인적인 견해)

coroutine context 를 넘겨주는 예시

 

 

뭐 당장 이정도 방법들이 생각난다 사실 try catch 를 제외한다면 모두 다 매력적인 방법들이라고 생각한다.

 

하지만 결국 나의 선택을 받은 오늘의 주인공은 Call Adpater 를 활용하는 방법이였다.

 

그래서 이제 Call Adapter는 무엇이며 어떻게 사용하는지에 대해서 알아보자

 


Call Adapter

우선 Call Adapter 가 뭐고 장점이 뭔지 부터 살펴보고 가자

 

Call Adapter 를 사용한다면 내가 원했던 3가지 조건을 모두 충족할수 있었다.

 

1.코루틴을 사용하는가? -> 코루틴을 사용하여 비동기처리 코드를 간결하게 작성할수있다. (이제와서 느끼지만 사실 간결하게 처리하는것도 중요하지만 코드가 진짜 가시성 좋게 순서대로 흘러가는 모습을 파악할수있는 부분이 너무 좋다.)

2. Call 의 enqueue를 사용해서 오류처리 하여 빈틈없이 오류처리를 하고있는가? -> Call adapter를 통해서 Call 을 커스텀하므로 enqueue를 사용했을 때의 오류처리 경우의 수를 각각 대응하여 내가 원하는 몇가지 종류로 정의해서 뽑아낼수있다. 좋아좋아

3.보일러 플레이트를 제거할수있는가? -> call 객체의 enqueue를 override해서 처리 + 코루틴을 사용하며 retrofit을 사용할때 내부적으로 enqueue를 사용해서 처리가 일어난다는 점을 이용하기에  예쁘게 내가 원하는 성공 혹은 실패의 경우의수를 sealed class 에 정리한 경우의 수들로 받을수 있어 보일러 플레이트 코드 없이 코드를 적을 수 있게 된다.

 

그래서 Call Adapter 가 정확히 뭐냐고 묻는다면

일단 서버 통신을할때 일어나는 일들을 살펴보자

OkHttp 클라이언트가 서버에서 응답을 받으면 그 응답을 Retrofit으로 전달하게된다. 그 다음 Retrofit 은 컨버터를 이용하여 우리는 알아볼수없는 response byte 를 우리가 사용하고싶은 자바 객체로 바꿔준다. 이작업들은 백그라운드 스레드에서 일어나는데 이 모든 작업이 다 완료된후 UI스레드로 결과값을 리턴하게 되는데 

이때 백그라운드 스레드에서 메인스레드로 값을 전달하는 과정에서 call Adapter 가 관여하는것이다.

이때 값을받아오는 call Adapter 를 내가 내입맛대로 커스텀해서 원하는 결과 값에 오류처리할 장치들을 씌워서 받아내서 결과적으로 오류처리를 수월하게 할수있도록 하는것이다.

 

자 일단 좋은 방법인건 알겠고 실제적으로 필요한 사전 지식과 구현방법을 알아보자.


우선적으로 알아야 할것은 Retrofit 에서 2.6.0 버전 이후로 suspend 를 지원해준다는 점이다.

밑의 링크를 타고들어가면 볼수있다.

https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-260-2019-06-05

 

GitHub - square/retrofit: A type-safe HTTP client for Android and the JVM

A type-safe HTTP client for Android and the JVM. Contribute to square/retrofit development by creating an account on GitHub.

github.com

suspend 지원한다는 업데이트 항목 설명

링크에서 볼수있는 업데이트 목록에도 나와있듯이 suspend 를 붙이면 내부적으로 Call.enqueue를 사용하는것과 같은 일이 벌어진다고한다.

 

 

그래서 알수있는건 그냥 우리가 retrofit에다가 코루틴을 사용하기위해 suspend를 붙이면 마법처럼 비동기 처리가 일어난다고 생각했는데(뭐 코틀린이 알아서 하겠지 이렇게 생각했었음)결과적으로 Retrofit 에서 일어나는 일은 suspend를 붙여서 service 인터페이스를 정의하면 Call 객체의 enqueue를 이용해서 비동기처리를 하는방식을 열심히 내부적으로 돌려주는 것이였다.

이 내용이 나비효과처럼 Call Adapter를 커스텀해서 내가 원하는대로 받아올수있게 해주는 이유중 하나이기도 하다. 

 

이 내용에 대해서 간략하게 정리하고 넘어가 보겠다.

 

우선 이 블로그 두개 + 코틀린 공식문서 를 참고하였다.

 

전체적으로 내부가 어떻게 돌아가는지에 대한글

https://thdev.tech/kotlin/2021/01/12/Retrofit-Coroutines/

 

Retrofit2와 Coroutines 사용 시 스케줄러는 어떻게 처리할까? - 내부 코드로 알아보자. |

I’m an Android Developer.

thdev.tech

핵심키워드인 SuspendCancellableCoroutine 에 관한 설명글

https://tourspace.tistory.com/442

 

[Coroutine] suspendCancellableCoroutine - Callback을 coroutine으로 변경

coroutine는 callback을 사용하지 않고 비동기 처리를 가능해주는 장점을 가지고 있습니다. 따라서 비동기 처리를 코드의 순서대로 실행시키며 가독성을 높이고, 보다 심플한 코드를 작성할 수 있도

tourspace.tistory.com

 

 

SuspendCancellable 의 공식문서
https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/suspend-cancellable-coroutine.html

 

suspendCancellableCoroutine

suspendCancellableCoroutine common Suspends the coroutine like suspendCoroutine, but providing a CancellableContinuation to the block. This function throws a CancellationException if the Job of the coroutine is cancelled or completed while it is suspended.

kotlin.github.io

 

사실 위의 글 3개만 정독해도 suspend 키워드를 달았을때 레트로핏 내부에서 어떤일이 벌어지는지 알수있다.

하지만 처음에 난 이글을 보고 벙쪘었기에 미래에 다 잊어버리고와서 다시 이글을 보며 벙쪄있는 나를 위해 주석을 달며 간단요약을 하자면

 

1.매번 의문이였던 레트로핏에서 viewmodel scope를 열어서 비동기 처리를 해주는데 viewmodel scope는 기본이 UI 스케쥴러를 이용하는데 dispatcher를 IO같은거로 변경해주지않고 어떻게 레트로핏에서 비동기 처리를 하는거지?

그냥 레트로핏에서 지원해준다는 무조건적인 믿음을 가지고 사용했었는데 이유를 여기서 찾았다 -> 원래 viewmodel scope 에서 함수를 호출할때까지는 UI 스케쥴러를 이용하는것이 맞고 레트로핏이 원래 Call.enqueue를 사용할떄 자동으로 새로운 IO 스레드를  내부적으로 생성해서 데이터들을 가져오는데 이 작업을 그냥 suspend 키워드가 붙었을때 내부적으로 돌리고 있었던것이다. 그래서 함수를 호출하면 내부적으로 Call.enqueue가 돌아가므로 IO스레드에서 처리되고 이작업이 다끝나면 Call Adapter를 타고 메인스레드로 전달되는것이였다.

 

2.실제적으로 내부 구현방법을 보려면 KotlinExtensions을 확인하면된다.

내부적으로 구현을 enqueue와 suspendCancellableCoroutine를 사용했는데 이를 통해 알수있는것은 enqueue를 이용하여 기존 callback을 통해 결과 값을 처리하던것을 코루틴에서 내부적으로 suspendCancellableCoroutine 이용해서 코루틴 내에서 callback의 반환값을 기다렸다가 return 받아서 사용하는것을 볼수있다.

suspend fun <T : Any> Call<T?>.await()

KotlinExtensions에 Call.await 함수만 제네릭의 종류에 따라 3가지로 나뉘어있는데 다 사용처를 타고가보면

HttpServiceMethod.java파일에서 adapt를 해주는 부분에서 사용된다. -> adapt는 메개변수로 받은 Call 객체에 callAdapter에 작성되어있는 adapt 함수대로(이때 call Adapter를 손봐서 내가 원하는것을 뽑을수있는것이다)  작업을 위임하는 인스턴스를 반환하는 부분이다. 

 

Retrofit의 HttpServiceMethod.java 파일내 adapt의 코드 부분이다.

@Override
protected Object adapt(Call<ResponseT> call, Object[] args) {
  call = callAdapter.adapt(call);

  //noinspection unchecked Checked by reflection inside RequestFactory.
  Continuation<ResponseT> continuation = (Continuation<ResponseT>) args[args.length - 1];

  // Calls to OkHttp Call.enqueue() like those inside await and awaitNullable can sometimes
  // invoke the supplied callback with an exception before the invoking stack frame can return.
  // Coroutines will intercept the subsequent invocation of the Continuation and throw the
  // exception synchronously. A Java Proxy cannot throw checked exceptions without them being
  // declared on the interface method. To avoid the synchronous checked exception being wrapped
  // in an UndeclaredThrowableException, it is intercepted and supplied to a helper which will
  // force suspension to occur so that it can be instead delivered to the continuation to
  // bypass this restriction.
  try {
    if (isUnit) {
      //noinspection unchecked Checked by isUnit
      return KotlinExtensions.awaitUnit((Call<Unit>) call, (Continuation<Unit>) continuation);
    } else if (isNullable) {
      return KotlinExtensions.awaitNullable(call, continuation);
    } else {
      return KotlinExtensions.await(call, continuation);
    }
  } catch (Exception e) {
    return KotlinExtensions.suspendAndThrow(e, continuation);
  }
}

요 코드를 참고하면 KotlinExtensions의  Call.await가 어디서 사용되는지 이해하는데 도움이 될것이다.

 

-suspendCancellableCoroutine 

이걸 간단히 설명하자면 기존의 라이브러리나 안드로이드 에서는 callback 으로 비동기 처리를 하게제공된다 -> 코루틴을 사용하면서도 라이브러리를 사용하기위해 어쩔수없이 callback을 사용해야할수밖에 없는상황이 있는데 이때 호출함수에 await() 같이 기다려주는 형식의 coroutine api 를 사용하여  callback의 종료 시점을 기다리도록 코드를 짜야하기때문에 불편하고 복잡한 코드가 나온다.

 

이를 해결하기 위해서 coroutine 에서 간편하게 사용하도록 제공한것이 suspendCancellableCoroutine 이다 -> 지속적으로 메시지가  전달되어오는경우 (single shot) 이 아닌경우 callbackFlow를 이용하면된다. 

 

즉 callback 구현한후 suspendCancellableCoroutine 내부에서 호출해주게 만들어준다 -> 이떄 구현해놓은 callback code내에 continuation.resume을 작성하게 되는데 

supendCancellableCoroutine 이실행된다면 코드 블록 내부의 모든 코드가 수행되고 난후 suspend 되어 다음 라인이 실행되지 않게 blocking 된다 그리고 continuation.resume 을 만나는 순간 blocking 이 풀린다 고로 callback 를 구현할때 원하는 데이터를 받는다던지 처리가 완료된시점에 continuation.resume를 호출해주는 형태로 오버라이딩 해주는것이다.

 

fail처리를 위해서는 continuation.resumeWithException 을 호출해주면서 Throwable을 넘겨줄수있다.

 

또한 suspendCancellableCoroutine 을 사용하기 위해서는 invokeOnCancellation 을 무조건 작성해줘야하는데 

이 부분은 coroutine 이 중간에 cancel 되는 경우 명시적으로 호출되는 코드이므로 cancel이 방생되었을시 resource의 해제 등등을 하기위한 코드를 작성해주면된다.

 

그래서 결론적으로 한줄요약을 하자면 Retorfit 과 coroutine을 같이 사용할때 suspend 키워드를 사용한다면 내부적으로는 Call.enqueue가 돌고있는것이고 이는 call Adpater 를 커스텀했을때 call 에다가 원하는 처리를해서 원하는 결과값을 뽑을수있는것을 의미하므로 중요한 이야기이며 내가 원하는것은 call에 어떤 처리를 해서 결국 내가 원하는 형태로 Retrofit의 결과물을 받아볼수있는것이다.

-> OkHttp의 인터셉터로 결과 값 가로채서 내가 원하는형태로 맘대로 씹고뜯고 맛보고 즐긴후 내보내는것과 비슷한 맥락이라 보면 좋을것같다.

 


앞의 내용들을 통해 단순 Call 객체를 커스텀해서 내가 원하는대로 결과값을 받을수있는것 뿐인데 코루틴과 Retrofit을 함께 이용했을때 내부적으로 Call.enqueue를 사용하기에 그냥 내가 원하는 결과값 형태로 성공/실패 경우의 수를 나눠놓은 sealed class 로 감싼 response data를 받을수있는 이유를 알수있었다. -> 간단히 말해서 그냥 suspend쓰면 Call.enqueue로 오니까 Call 을 커스텀한대로 내가원하는것을 코루틴의 함수 호출부에서 받아낼수있다.

 

 

이제 사용법과 구현 방법을 살펴보자

 


넘버링을해서 순서대로 쫓아가보자

 

1. 어떤형태로 서버통신을 하는것이 목표인가?

interface NaverBookSearchService {

    @GET("book.json")
    suspend fun getNaverBookSearchList(
        @Query("query") query: String,
        @Query("display") display: Int,
        @Query("start") start: Int
    ): NetworkState<NaverBookSearchResponse>
}

서비스의 반환 값을 보면 내가 만든 NetwortState라는 class로 감싸져있다.

class BookSearchRepositoryImpl @Inject constructor(
    private val remoteBookSearchDataSource: RemoteBookSearchDataSource,
    private val naverBookSearchMapper: NaverBookSearchMapper
) : BookSearchRepository {

    override suspend fun getBookSearchList(
        query: String,
        display: Int,
        start: Int
    ): Result<List<BookInfo>> {
        when (val bookSearchList = remoteBookSearchDataSource.getBookSearchList(query, display, start)) {
            is NetworkState.Success -> return Result.success(bookSearchList.body.items.map { naverBookSearchMapper.toBookInfo(it) })
            is NetworkState.Failure -> return Result.failure(RetrofitFailureStateException(bookSearchList.error,bookSearchList.code))
            is NetworkState.NetworkError -> Timber.tag("${this.javaClass.name}_getBookSearchList").d(bookSearchList.error)
            is NetworkState.UnknownError -> Timber.tag("${this.javaClass.name}_getBookSearchList").d(bookSearchList.t)
        }
        return Result.failure(IllegalStateException("NetworkError or UnKnownError please check timber"))
    }
}

repository 에서 이런방식으로 오류처리를 경우의 수에 따라서 가시성 좋게 처리하며 내가 원하는 오류처리를 할수있는것이 목표였다.(이 경우의 수별로 처리해놓은 부분은 고정이 아니다 그냥 딱히 뭐 처리할것도 떠오르지도 않고 당장 + 현재는 기초적인 개발 단계 이기에  NetworkError혹은 UnknownError일때 로그만 찍고 넘어가는 방법으로 만들어놓았다.  기획이나 코드 구현시 요구사항에 대한 것들을 구현하면 될것이다.) 

 

-> 또한 여기서도 data, domain, presenter layer 사이에 어디서 오류처리를 해줄것이고 결과값을 받기위해 NetwortState의 내용을 한번까서 분기처리해주는 시점이 필요한데

clean Architecture의 원칙상 data layer에서 mapping을 통해 entitiy 형태로 변경해서 domain 으로 보내야 하므로

그때 어디에서(data source?,repository?) NetworkState를 까서 오류처리 + Mapping 을 해주고 다시 어떤형태로 묶어서 반환할것인지(여기서도 경우의 수가 많았는데 추후에 글의 마지막 쯔음에 한번더 다뤄보도록 하겠다.)

이때 생기는 보일러플레이트 코드가 많아서 그에 대한 최적의 방법은 무엇일지 엄청 고민하고 생각했다.

 

우선 일단 예시 코드는 그렇게 한 약 2달간 고민한 결과물의 코드를 가지고 작성하고있다.

 

근데 정말 더좋은 방법이 차고 넘칠것 같기도하고 만약 이글을 보면서 답답하시다면 댓글로 피드백 남겨 주세요ㅠㅠ. 너무 행복하게 제 코드를 좋은방향으로 개선할수있고 그분께 큰절 500번도 할수있습니다 기프티콘으로 커피도 보내드릴께요 미천한 실력이라 너무 힘듭니다. 많은 관심 부탁드려요

 

2. 경우의수를 나누는 Sealed Class 부터 정의하자

sealed class NetworkState<out T : Any> {

    //200대 응답 성공한것
    data class Success<T : Any>(val body: T) : NetworkState<T>()
    //isSuccessful 이 false인 경우(200~300대 응답이 아닌경우)
    data class Failure(val code: Int, val error: String?) : NetworkState<Nothing>()
    //onFailure로 넘어간경우(네트워크 오류,timeout 같은거)
    data class NetworkError(val error: IOException) : NetworkState<Nothing>()
    //예상 못한에러(기타 모든 에러처리)
    data class UnknownError(val t: Throwable?, val errorState: String) : NetworkState<Nothing>()
}

sealed class 를 통해서 우선 경우의 수를 나눠준다.

 

기존에 레트로핏 Call.enqueue를 통해서 통신을 할때 경우의수를 생각해보자 

크게 onResponse 와 onFailure 가 있었다.

그리고 세부적으로 onResponse 에서 경우의 수가 나눠지는데 isSuccesful 값으로 분기처리 한번 그리고 그 이후는 값에 따라 처리를 어떻게 해줄것인지 개발자가 각각 경우의수를 분기처리해서 맞닥트리는 상황에따라 원하는대로 상황을 설정하여 처리해줄것이다.

 

그런 상황의 예제로 보여줄수있는 예시로

https://mccoy-devloper.tistory.com/57

 

서버통신 시리즈(레트로핏) 1.서버통신시 에러메시지를 받아보자!!

요즘 글 쓰는 주기가 부쩍 늘어났다 공부하는 내용들이 어려워서 금방금방 정리가 안된다. 그래도 정신차리고 쪼개서라도 써보자 또한 앞으로 레트로핏 관련 오류처리 공부를 시리즈로 적어볼

mccoy-devloper.tistory.com

1편글에 CallBack 을 사용하는것을 보여주는 부분이있다

Call.enqueue를 예시를 들기위해 구현해놓은 것이 있는데  이 부분의 코드를 베이스로 어떤상황에 어떻게 처리할수있을까 생각해볼수있지 않을까 싶다.

 

그래서 내가 나눈 기준은 크게 4가지로 나눠놓았다.

 

  1. Success : 200 대의 응답으로 정상적으로 서버와 통신하여 결과 값을 받아온경우
  2. Failure: isSuccessful 이 false 인 경우로 서버와의 통신은 이루어졌지만 200~300 사이 코드가 아닌경우로 errorbody를 받아서 오류메시지를 받아본다던지 하는 용도로 사용되는 경우의 수이다
  3. NetworkError: 아예 onFailure로 넘어간 경우로 서버와의 통신이 아예 실패한 상황으로 인터넷이 연결안되었거나 서버가 닫혀있거나 같은 오류 사항을 처리하기위해 뽑은 경우의 수이다
  4. UnknownError: 방금 나눈 3가지 경우를 제외한 다른 에러사항들을 퉁쳐서 처리하기위해 뽑은 에러로 기타 모든 에러를  이 상황으로 처리하게된다.

이렇게 나눠놓은것은 내임의대로 만든것이고 원한다면 경우의수를 더뽑을수도 덜뽑을수도 있을테고 각상황에 맞는 세분화도 가능할것이다.

 

이렇게 레트로 핏으로 부터 값을받을때 어떤 상태인지 나눠줄수있는 Sealed class 를 정리해놨다.

 

 

3.레트로 핏의 Call 인터페이스를 구현하는 CustomCall 을 구현하자

이제 추후에 살펴볼 부분들에 의해서 Call<R> 객체를 Call<NetworkState<R>> 로 래핑해서 반환하게 되는데 그것을 위해서 Call 인터페이스를 구현하는 CustomCall 을 정의해야한다.

 

class CustomCall<T : Any>(private val call: Call<T>) : Call<NetworkState<T>> {

    override fun enqueue(callback: Callback<NetworkState<T>>) {
        call.enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                val body = response.body()
                val code = response.code()
                val error = response.errorBody()?.string()

                if (response.isSuccessful) {
                    if (body != null) {
                        callback.onResponse(
                            this@CustomCall, Response.success(NetworkState.Success(body)) //1
                        )
                    } else {
                        callback.onResponse(
                            this@CustomCall,
                            Response.success(NetworkState.UnknownError(IllegalStateException("body값이 null로 넘어옴"), "body값이 null로 넘어옴")) //5
                        )
                    }
                } else {
                    callback.onResponse(
                        this@CustomCall,
                        Response.success(NetworkState.Failure(code, error)) //4
                    )
                }
            }

            override fun onFailure(call: Call<T>, t: Throwable) {
                val errorResponse = when (t) {
                    is IOException -> NetworkState.NetworkError(t)  // 2
                    else -> NetworkState.UnknownError(t,"onFailure에 진입,IoException 이외의 에러") // 3
                }
                callback.onResponse(this@CustomCall, Response.success(errorResponse))
            }
        })
    }

    override fun clone(): Call<NetworkState<T>> = CustomCall(call.clone())

    override fun execute(): Response<NetworkState<T>> {
        throw UnsupportedOperationException("커스텀한 callAdapter에서는 execute를 사용하지 않습니다 ")
    }

    override fun isExecuted(): Boolean = call.isExecuted

    override fun cancel() = call.cancel()

    override fun isCanceled(): Boolean = call.isCanceled

    override fun request(): Request = call.request()

    override fun timeout(): Timeout = call.timeout()
}

 우선 Call 을 구현할때 우리가 중점적으로 봐야할것은 enqueue 메서드 이다. 나머지 메서드들은 파라미터로 받은 기존의 Call<R> 인스턴스에게 작업을 위임시키면 된다( enqueue에서 하는 행동들이 비동기적으로 callback 을 받은것들을 처리하는 로직이기 때문에 이부분을 제외한 것들은 기존의 방식을 따르면된다.)

 

단우리가 custom 한 Call 에서는 동기처리방식인 execute는 사용할일이 없기때문에  execute를 사용하면 아예 throw를 던저버리게 처리한다.

 

이제부터는 본론인 enqueue를 구현해 놓은것을 찬찬히 뜯어보자

사실 이부분의 처리 방법은 공통적인 사항을 알고간다면 

Call.enqueue를 사용해서 처리할때 처리했던 부분과 동일하다 1편의 글에서(서버통신시 에러메시지 뽑기) 우리는 callback 의 경우의 수에 대해서 자세히 살펴보았다.

그래서 이번에는 어떤 경우에 NetworkState의 어떤경우의 수로 처리했는지 에 대해 살펴보자. 

 

우선 전체적으로 공통사항을 보자면 callback.onResponse로 값을 넘길때 항상 Response.success로 넘긴다. 즉 exception 이 나도 모든상황에서 Response.success로 처리하기에 앱이 종료되는 상황을 막고 exception 에 대한 처리는 직접 정의한 NetworkState에 상태에 따라 적절한 처리를 추후에 받아서 처리하게된다.

 

 

이제 순서대로 상황에 맞춰 어떤 대응을 하는지 살펴보자(참고로 경우의수를 나눠놓은건 내맘대로 했으니 추후에 원하는대로 수정한다면 더 유용하게 사용할수도 있을것이다.)

 

 

1.onResponse이며 isSuccessful 이 ture 이고 body 값이 null 이 아닌경우 

-> 모든 분기처리를 통과하고 통신이 성공하여 내가원하는 데이터가 주어졌으므로 NetworkState.Success를 통해서 response값으로 받아온 body 값을 넘겨준다.

 

2.onFailure로 넘어가서 받아온 Throwable이 IoException인 경우

->네트워크가 끊김등의 문제가 있는 경우이므로  NetworkState.Networkerror에 들어오는 Throwable를 그대로 담아서 넘긴다.

 

3.onFailure로 넘어가서 받아온 Throwable이 IoException을 제외한 다른 오류인경우

-> 그야말로 여기서 나올수있는 오류상황의 가지수는 많고 다양하기 때문에 퉁쳐서 애매한 오류들을 한꺼번에 잡을수있게 만들어놓은 경우의수인 NetworkState.UnknownError에 받아온 Throwable과 간단한 메시지 "onFailure에 진입,IoException 이외의 에러"를 함께 넘겨주었다.

 

4.onResponse임에도 isSuccessful 이 false인경우

-> 이 상황은 코드가 200이상 300미만이 아닌경우로 서버와 통신은 오고 가긴했는데 오류가 난것이므로 클라이언트 오류일수도 서버오류일수도있다

여기서는 errorMessage를 뽑아내서 서버와 상호 작용할 때도 있고(회원가입시 중복된 아이디를 요청한경우 응답에 서버에서 200~300 대 코드가 아닌 코드를 주며 에러메시지를 주는경우가 있다 이런 에러메시지를 가져다 사용해야하는 상황이 있을수도 있기 때문에) 

어찌됐건간에 많은 경우의 수가있고 이때 오류처리를 위해서 errorbody값과 error시 code를 가지고 오류를 처리하기위해

NetworkState.Failure에 Http 상태 code와 errorbody를 String값으로 넘긴다(추후에 받아서 쓰는데에서 파싱해야함)

 

5.onResponse 이며 isSuccessful이 true이지만 body 값이 null 인경우

-> 지금글을 쓰며 생각해보니 이경우에는 Success로 처리해야할수도 있다고 생각이 들지만(아예 서버에 전달만하고 body 값이 안넘어오게 서버에서 처리할수도 있으니) 현재 내가 처한 상황에서는 서버에서 무조건 body값에 뭐라도 넣어서 넘겨주게 처리해줬으니 오류가 맞다 그래서 NetworkState.UnknownError에 IllegalStateException을 간단한 메시지와"body값이 null로 넘어옴"와 함께 넘겨주는 방법을 취하고있다 

 

이렇게 각상황별 내가 NetworkState에 정의해놓은 error/success 종류에 따라서 Resopnse.success에 담아서 반환해버리면 된다.

 

 

4. 여기까지 준비물 준비는 다끝났다 이제 CustomCallAdapter를 생성해보자

CallAdapter는 두가지 메서드를 가진다. 

코드를 우선 살펴보고 각각을 분석해보자

class CustomCallAdapter<R : Any>(private val responseType: Type) :
    CallAdapter<R, Call<NetworkState<R>>> {
    override fun responseType(): Type = responseType

    override fun adapt(call: Call<R>): Call<NetworkState<R>> = CustomCall(call)
}

1. responseType: 어댑터가 HTTP 응답을 자바 오브젝트로 변환할 때 반환값으로 지정할 타입을 리턴하는 메서드(어떤 자바객체로 바꿀것인지 자료형을 지정하는 메서드).

예를 들어 Call<Repo>에 대한 responseType의 반환값은 Repo에 대한 타입이다. -> Factory에서 받아온 Call 객체에서 필요없는 부분 걷어내고 매개변수(responseType)로 전달해준다.

 

2. adapt: 메서드의 파라미터로 받은 call에게 작업을 위임하는 T 타입 인스턴스를 반환하는 메서드.

-> 파라미터로 받은 call에 작업을 위임한다 아까 작성해놓은 Customcall에 call을 넣어서 반환함 -> 내가 만들어놓은 Customcall으로 작업을 위임한다 -> 일반적으로 들어오는 Call객체를 통해 우리가 구현해놓은 CustomCall을 사용하여 Call<NetworkState<R>> 형태로 Call 객체를 처리해서 반환한다.

 

 

이렇게 responseType을 통해 어떤타입의 자바 객체로 바꿔줄지 와 adapt를 통해 기존의 call 을 받아서 어떻게 처리할것인지 내가 작성해놓은 Customcall로 일반적으로 들어오는 Call객체를 감싸서 반환할것을 지정해준다. 한마디로 정상적으로 나가던 Call객체를 가로채서 내가원하던 처리를해서 내보내는것이다.

 

 

5.이제 마지막 단계인 CustomCallAdapterFactory 를 준비해보자

이제 마지막으로 CallAdapter.Factory를 상속받은 CustomCallAdapterFactory를 작성해줘야하는데

 

우선 CallAdapterFactory는 CallAdapter의 인스턴스를 생성하는 팩토리 클래스로 

레트로핏의 서비스 메서드에서 리턴타입으로 뭘주냐에따라(Call,Response,그냥 자체 데이터클래스,내가 커스텀한 SealedClass로 담겨있냐에따라) 적절한 CallAdapter인스턴스를 생성하여 반환하는 class이다.

한마디로 서비스 인터페이스의 반환값에 뭘 지정해놨냐에 따라 판단해서 알잘딱깔센하게 기본 CallAdapter라던지 내가 만든 CustomCallAdapter의 인스턴스를 반환할것인지 이런 판단을 하는 함수인데

이 판단하는 기준도 내가 Factory를 직접 커스텀해서 어떤조건일때 내가만든 CustomCallAdapter의 인스턴스를 생성해줄지 결정해준다. 

 

이 또한 코드를 우선적으로 보고 각각의 함수들을 이해해보자

class CustomCallAdapterFactory : CallAdapter.Factory() {

    override fun get(
        returnType: Type,
        annotations: Array<out Annotation>,
        retrofit: Retrofit
    ): CallAdapter<*, *>? {
        if(Call::class.java != getRawType(returnType)){  //1
            return null 
        }

        check(returnType is ParameterizedType) { //2
            "return type must be parameterized as Call<NetworkState<Foo>> or Call<NetworkState<out Foo>>"
        }

        val responseType = getParameterUpperBound(0, returnType) //3

        if (getRawType(responseType) != NetworkState::class.java) { //4
            return null
        }

        check(responseType is ParameterizedType) { //5
            "Response must be parameterized as NetworkState<Foo> or NetworkState<out Foo>"
        }

        val bodyType = getParameterUpperBound(0,responseType) //6

        return CustomCallAdapter<Any>(bodyType)

    }
}

우선 CallAdapter.Factory 은 get이라는 함수를 오버라이딩 해줘야한다.

 

get함수에서 해야하는일은 간단하다 retrofit에서 매개변수로 넘어오는 returntype 을 이용하여 이 팩토리에서 처리할수있다면(내가 지금현재 가로채려하는 처리가 되어있는 Call 이라면 즉 레트로핏 서비스 메서드의 반환값이 NetworkState<R> 이라면 return 으로 CustomCallAdapter에 javaclass로 변환될 타입(callAdapter에서 받는 그 완전 순수하게 변환될 java class의 타입)을 넣어서 인스턴스를 생성 반환하면된다.

 

그리고 null 값을 반환한다면 이 Factory에서 처리하지 못하는 형태의 return 값이라 판단하여 다른 Factory에서 처리하게 될것이다.

 

 

그래서 내부의 코드에서 쓰이는 함수들과 그에대한 사용법 그리고 이게 어서 튀어나왔는지 보자

 

코드 구현해놓은것을 보면 getParameterUpperBound 와 getRawType 를 사용하는데 이는 자바에서 기본제공하는 

java reflection이다 이는 runtime에 타입을 다루기위한 자바 유틸리티인데

컴파일 타임에 타입을 알지만 런타임에는 그런것들을 알기 어렵기때문에 사용하는것이고

이런것들을 이용해서 Dagger,Gson같은 라이브러리 들이 만들어졌다고 어느 천재가 말해줬다.(출처표기)

-> 근데 이런거 아직모르겠다 아니 나는 너무 빡대가리다 하지만 뭐 정복해나가야할 대상이라고 생각하고 언젠가는 다 흡수할거다.

 

그래서 이런함수들을 사용할때 유의할점이 난독화같은거 제대로 세팅안해놓고 난독화하면 릴리즈 빌드시에 들어가는 클래스 이름이 바뀌어서 못찾는 등등의 문제가 있을수있다고 한다. 

 

어쩃든 이런 어려운이야기는 집어치우고 그래서 각각함수가 무슨 용도로 쓰이는지 알아보자

 

-getParameterUpperBound : type의 index 위치의 제네릭 파라미터에 대한 upper bound type을 반환한다. 예를 들어 getParameterUpperBound(1, Map<String, ? extends Runnable>)은 Runnable Type을 반환한다.

한마디로 제너릭의 내부에 자료형이 어떤 것들이 작성되어있는지 까볼수있는것이다.

 

-getRawType : type의 raw type을 반환한다. (raw type: 제네릭 파라미터가 생략된 타입. List<? extends Runnable>의 raw type은 List를 말한다.)

한마디로 제너릭을 제외한 타입 을 받아볼수있는 함수이다.

 

 

이제 주석의 순서에 따라 무슨일을 하고있는지 쫓아가보자

 

1.매개변수로 들어온 returnType 즉 서비스 메서드에서 반환하는 자료형이 Call로 감싸져있는지 판단한다.

-> 어짜피 Call이 아니라면 CallAdapter를 거치고 뭐하고 할일도 없기에 여기서 조건을 충족하지 않는다면 null을 반환하여 이 팩토리에서 처리할수없음을 알린다

 

2.리턴타입이 제네릭 인자를 가지는지 확인한다. 리턴 타입은 Call<?>가 되어야 한다
-> Call 이 제너릭으로 NetworkState<?>를 가져야 하기때문에 여기서도 만약  조건을 만족하지 못한다면 check 함수를 통해
IllegalStateException 를 던져버린다.

 

3.getParameterUpperBound 를 통해 제너릭의 자료형이 뭔지 까본다.

 

4.그렇게 가져온 제너릭 자료형의 타입이 내가 사용하려는 NetworkState인지 확인한다 (내가 쓰려는것인지 확인하는작업)

-> 아니라면 또 null을 반환해서 이 팩토리에서 처리할수없음을 알린다.

 

5.또한 그렇게 까놓은 자료형인 responseType이 제너릭 인자를 갖는 지 확인해서 아니라면 Check 를 통해 IllegalStateException 를 던져버린다.

 

6.결국 이 조건을 모두 통과했으면 내가 원하는 (커스텀하려는) return 값을 갖는 서비스 메서드 이기 때문에 

마지막으로 getParameterUpperBound 를 통해 retrofit에 의해 javaClass로 변환될 타입을 받는다.

 

마지막으로 이렇게 각 조건들로 맞는 내가 처리하려는 상황인지 판단한후 

CustomCallAdapter의 인스턴스에 bodyType을 담아서 반환한다.

 

6.레트로핏 Builder에다가 callAdapterFactory 추가하기

이제 다 했다 이제 레트로핏을 생성할때 addCallAdapterFactory를 통해 callAdapterFactory를 추가하면 되는 것이다.

 

@Module
@InstallIn(SingletonComponent::class)
object RetrofitModule {

    @Provides
    @Singleton
    @NaverBookSearchServer
    fun providesRetrofit(@NaverBookSearchServer okHttpClient: OkHttpClient): Retrofit =
        Retrofit.Builder()
            .baseUrl(NAVER_BOOK_SEARCH_BASE_URL)
            .client(okHttpClient)
            .addCallAdapterFactory(CustomCallAdapterFactory())
            .addConverterFactory(GsonConverterFactory.create())
            .build()
}

 

여태까지 만든것들을 레트로핏에 추가하는것으로 끝났다.

 

이제 이렇게하면 서비스 메서드를 호출하면 내가 만든 NetworkState형태로 감싸져서 나오고 이에 대한 오류 처리를 해주면될것이다.

 

진짜 정말 대장정이였다.

 

2달간 이부분만 공부하고 글만 4일째 쓰고있다 미친거같다. 하지만 아직 아키텍쳐를 처음 접했을때 처럼 이해된거같으면서도 모호한 부분들이 있어서 계속 자료들을 찾아보고 이해하고있다. 근데 아키텍쳐도 일단쓰다보니 이해되는것처럼 이 부분도 점점 이해도가 높아지지않을까 싶다 또한 이렇게 글로 정리해놓지 않으면 어짜피 잃어버리기에 조금 부족한 이해도에도 글을 작성해보았다. 아 그리고 글이 끝나지는 않았다. callAdapter의 구현이 끝났을뿐이지 이제 또 그래서 layer간에 오류처리 넘기는것에 대한 고민 그리고 그래서 내린 결론을 정리해볼것이다.

 

명주가 이야기해줬다 이거 잘되어있는 라이브러리 많다고 그거써도 될거 같다고 근데 왜 난 삐뚜러져서 잘하지도 못하면서 라이브러리를 쓰기 싫은지 모르겠다 .

아 그리고 대부분의 라이브러리도 callAdapter를 사용하는것들이 많은거같다.

 

명주가 추천한 라이브러리 

https://github.com/haroldadmin/NetworkResponseAdapter

 

GitHub - haroldadmin/NetworkResponseAdapter: Retrofit call adapter to model success/failed responses as sealed types

Retrofit call adapter to model success/failed responses as sealed types - GitHub - haroldadmin/NetworkResponseAdapter: Retrofit call adapter to model success/failed responses as sealed types

github.com

이거는 내가 구글링하다 찾은 callAdapter를 사용한 라이브러리

https://velog.io/@skydoves/retrofit-api-handling-sandwichhttps://velog.io/@skydoves/retrofit-api-handling-sandwich

 

안드로이드 Retrofit + Coroutines의 API 응답 및 에러 핸들링 - Sandwich

데이터 커뮤니케이션 횟수가 증가함에 따라 애플리케이션 아키텍처의 복잡성도 함께 증가합니다. 오픈소스 라이브러리 Sandwich를 활용하여 multi-layered 아키텍처에서 API 응답 및 에러 핸들링을 하

velog.io

 

여기 까지 CallAdapter 끝!!!!!! 아아아아아아아악


그래서 오류처리 어디서 해주고 layer사이 왔다갔다 할때는 어떻게 포장해서 보내줄래?

이제 또 큰거 하나온다.

아 그래서 CallAdapter를 Readme에다가 적용해놓고 막상 사용하려니 또 문제가 생겼다,

문제가 무엇이냐? 이번에 Readme에서 멀티모듈을 적용하였고 그에따라 맞춰서 Clean Architecture에 맞춰서 3 layer를 구성해서 모듈을 나눠놓았다.

 

멀티모듈 특성상 나눠놓으면 접근도 안될뿐더러 layer에 맞춰서 구조있게 코드를짜다보니 더욱더 고민이 많아졌다.

-> 어디서 뭘해야하는거지?

클린아키텍쳐는 대원칙일뿐 어떤 예시코드를 제공하는것이 아니기 때문에

그렇다고 진짜 구글에서 제공하는 예시코드처럼 보고 따라할수 있는 좋은 예제가 있는것도 아니고

대원칙을 지키는한 뭐 틀린말은 없기 때문에 구글링을해서 좋은코드를 찾아보려해도 코드들이 다 제각기 스타일도 다르고 작업하는 위치도 다르고 난리버거지였다.

그래서 내린결론은 내가 많이 자료를 보고 판단하고 내가 생각하는 하에서 좋은결론을 도출해서 나만의 코드스타일을 만들자 였다.

->물론 회사 취업하면 그회사 스타일 따라하면되니까 너무좋다 ㅎㅎ

근데 아직 실력이 여물지 않은나에게 주관을 갖고 판단해서 코드를 짜야하는건 생각보다 어려웠다. 누가 태클을 걸면 뭐라고 대답하지? 누가 이런건 어떻냐고 하면 뭐라고 판단했다고 이야기하지?

이러한 의문과 두려움에서 벗어나기위해 진짜 미친듯이 많은 예시코드들을 그냥 깃에다가 키워드로 검색해서 보기도했고 구글링으로 많은 자료들을 진짜 10페이지까지 좋은글부터 똥글까지 다본거같다. 근데 사실 callAdapter를 적용한 예시 프로젝트들은 자료가 많이 없다.(아쉽)

그래서 일단은 부끄럽지만 내의 스타일을 만들어내긴했다.

 

어쩃든 그렇게 적립한 멀티모듈 clean Architecture를 곁들인 calladapter를 통한 오류처리 이다(layer사이 이동하는 형태위주로 보면될거같다.)

이렇게 써놓고보니 무슨 비싼 프랑스 식당의 전채요리 이름같다.

(멀티모듈과 클린아키텍쳐에 대한이야기는 할이야기가 차고 넘치지만 일단 오류처리에 관한것만 다루겠다.)

 

클린아키텍쳐를 적용함에 따라 layer가 나눠지고 각 layer에서 해야하는일이 있으므로  기존 spark 처럼 뷰모델로 단순히 끌고와서 호출해주고(viewModel에서 오류처리 해주기 전까지는 reqository와 datasource는 단순히 진짜 호출했었다.) 거기서 오류를 잡을수가 없었다.

 

-> data layer를 벗어나기전에 data layer의 model을 domain layer의 entity로 변형(mapping) 해서 보내줘야했고 그떄

무조건 Network response의 각 경우의 수 별로 분기처리를 해줘야했다.

 

애초에 mapping을 data source 에서 하든지 repositoty구현체에서 하든지 그건 data layer 에서 벗어나기 전에만 하면된다는 점을 지킨다면 취향차이라고 생각한다.

그래서 처음에는 data source에서 Mapping을 해고 repository에서는 단순 local에서 온다던지 remote에서 온다던지 이런 로직들의 순서도를 정리하는 코드만 남겨서 Repository가 거대화되는거를 피하고 싶었다.

 

근데 막상 dataSource에서 mapping을 해주려면 너무 repository에서도 추가 적인 코드 처리가 필요하고 보일러 플레이트 코드가 너무많이 만들어져 dataSource에서는 단순 호출만해주고 mapping또한 repository에서 해주는거로 결정했다 + repository에서 mapping 한다면 장점으로 로컬이든 리모트이던 캐시이던 어딘가에서 나온 소스중 하나만 mapping해주기 때문에 datasource에서 mapping 했다면 몇번씩 반복해야하는 mapping을 한번에 처리할수있다.


 

이제부터 내가겪은 시행착오(가능성은 있는데 결론이 더 좋은 방법인거같아 버려버린) 부터 쭉 살펴보자

 

일단 서비스 인터페이스를 살펴보자면 이러했다

앞에 입이 닳도록 이야기했던 callAdapter를 커스텀해서 NetworkState로 감싸진 형태로 retrofit에서 받아온 데이터클래스를 받는것이였다

 

뭐 usecase는 당장 데이터를 복잡하게 처리해주거나 도메인 로직이랄것이 없어서 사용하지는 않지만 어찌됐건간에 layer 사이를 건너면서 return 해주는 값의 자료형이 일치하지않아 문제가 생겼었다 일단 내가 생각하는 목표는 오류처리를 뷰모델에서 하고싶었다 (화면에 오류내용이나 그에따라 받은것들을 화면에 띄워줘야하는 경우의 수가 분명있을꺼라고 생각했기 떄문이다.)

 

 

그래서 첫번째로 시도한방법이 이거였다.

살펴보자면 나의 목적은 datasource에서 mapping을 해주기위해 통신을 한후 받아온 NetworkState의Success에서는 mapping을해서 domain 레이어의 entity인 BookInfo의 리스트를 반환해주려했고

 

그를 제외한 오류 상황의 경우 그냥 다른 작업을 처리하지않고 그대로 넘겨주어서 reposiory에서 받고 그걸 다시 viewModel에서 받아서 거기서 분기처리를 해주고 싶었다.

 

하지만 인생은 그렇게 호락호락하지 않다.

dataSource의 반환형의 type이 NetworkState<List<BookInfo>> 이였고 else를 통해 아무처리도 안하고 return한다면 

자료형이 NetworkState<NaverBookSearchResponse> 가 되어서 이런방식으로는 처리할수 없었다.

 

 

그래서 이방법을 고수하고 싶었던나는 두번째 방법으로 그냥 dataSource의 반환형으로 NetworkState<Any> 로 바꾸어서 오류를 잡아서 사용하는것이였다.

이방법은 오류가 없고 어떻게 보면 문제없을거 같기도 하여 이방법을 쓸까 정말 고민 많이 했다.

하지만 항상 코딩을 하다보면 typesafe한것이 굉장히 중요한것을 느낄수있는데 Any? Any를 남발하다 괜히 골로갈것 같아서

일단 다른 방법을 찾아보았다.

 

3번쨰로 각 경우의 수마다 분기처리할때 NetworkState로 다시 포장해서 보내는 방법이였다.

이방법은 가능하기는 하지만 보일러플레이트 코드가 너무많이 생기고 효율적이라고도 생각되지않으며

repository에서 뭔가 처리해야한다 싶으면 또 이행위를 또 반복해야하고 또 usecase 를 쓴다면 또 이행위를 반복해야하고 차라리 안하고 마는게 나은거같아서 이방법도 버렸다.

 

 


진짜 개봉박두 실제로 사용한코드

그래서 이렇게 고민하고 또 고민하고 또 고민한 진짜 적용한 코드를 살펴보자

class RemoteBookSearchDataSourceImpl @Inject constructor(
    private val naverBookSearchService: NaverBookSearchService
) : RemoteBookSearchDataSource {
    override suspend fun getBookSearchList(
        query: String,
        display: Int,
        start: Int
    ): NetworkState<NaverBookSearchResponse> = naverBookSearchService.getNaverBookSearchList(query, display, start)
}

일단은 datasource에서는 단순 서비스 메서드를 호출만해준다.

mapping 이런건 repository에서 ^^ 앞쪽에 이유를 주저리 설명해놓았다.

 

class BookSearchRepositoryImpl @Inject constructor(
    private val remoteBookSearchDataSource: RemoteBookSearchDataSource,
    private val naverBookSearchMapper: NaverBookSearchMapper
) : BookSearchRepository {

    override suspend fun getBookSearchList(
        query: String,
        display: Int,
        start: Int
    ): Result<List<BookInfo>> {
        when (val bookSearchList =
            remoteBookSearchDataSource.getBookSearchList(query, display, start)) {
            is NetworkState.Success -> return Result.success(bookSearchList.body.items.map {
                naverBookSearchMapper.toBookInfo(
                    it
                )
            })
            is NetworkState.Failure -> return Result.failure(
                RetrofitFailureStateException(
                    bookSearchList.error,
                    bookSearchList.code
                )
            )
            is NetworkState.NetworkError -> Timber.tag("${this.javaClass.name}_getBookSearchList")
                .d(bookSearchList.error)
            is NetworkState.UnknownError -> Timber.tag("${this.javaClass.name}_getBookSearchList")
                .d(bookSearchList.t)
        }
        return Result.failure(IllegalStateException("NetworkError or UnKnownError please check timber"))
    }
}

이제 repository에서 각 경우의 수를 분기처리해서 간단한 오류처리를 해주고 return 값으로 넘겨야 하는경우

Result 객체를 이용해서 자료형을 넘겨줄수도,혹은 Exception을 넘겨줄수도 있도록 하였다.

 

즉 내가 생각한 위쪽의 시행착오의 해결방법의 핵심은 Result객체를 이용하는것이다.

 

그래서 찬찬히 살펴보면

NetworkState.Success일 경우 넘어오는 데이터 값을 mapping해서 Result.success에다 담아서 return해준다.

 

그리고 Failure의 경우 error message를 화면에 가져가서 표시해줘야 한다던지 등등의 경우의 수가 있기 때문에

error과 code를 받을수있게 RetrofitFailureStateException 을 만들어주었고 이를 Result.failure에 담아서 return해준다.

class RetrofitFailureStateException(error: String ?, val code: Int) : Exception(error) {
}

 

그리고 NetworkError와 UnknownError의 경우에는 지금당장은 딱히 처리를 해주지 않고있기 때문에 그냥 timbe로 로그를찍어주는 처리를 했다.

 

그리고 최종적으로 어짜피 NetworkState의 4가지 경우의 수안에 잡히겠지만 코틀린에서는 모르니까 마지막에 오류를 피하기위한 방법으로 else처럼 Result.failureㄴ에다가 IllegalStateException 을 담아서 리턴해준다.

 

자그럼 이제 실제로 뷰모델 혹은 유즈케이스에서 받아다 쓸떄는 이런식으로 받아볼수있을것이다

 

fun getBookSearchList() {
    viewModelScope.launch {
        bookSearchRepository.getBookSearchList(
            searchWord.value ?: "",
            BOOK_SEARCH_DISPLAY,
            BOOK_SEARCH_START
        )
            .onSuccess {
                _bookSearchList.postValue(it)
                if (it.isEmpty()) {
                    _bookSearchTextState.postValue(true)
                    _bookSearchVisibilityState.postValue(true)
                } else {
                    _bookSearchVisibilityState.postValue(false)
                }
            }
            .onFailure {
                it as RetrofitFailureStateException
                Timber.tag("${this.javaClass.name}_getBookSearchList")
                    .d("message :${it.message} , code :${it.code}")
            }
    }
}

이러면 성공시에는 onsuccess로 깨끗이 처리할수있고

실패시에도 onFailure로 받아서 code와 에러 메시지를 깔끔하게 받아볼수있을것이다 -> errormessage 파싱해줘야하긴함

 

자 이렇게 내 두달동안의 여정을 보았다 이글을 꼼꼼히 보았다면 나의 두달의 시간을 산거나 마찬가지다.정말 열심히 공부했고 열심히썻다 정말 뿌듯하다.

 


출처:https://proandroiddev.com/create-retrofit-calladapter-for-coroutines-to-handle-response-as-states-c102440de37a

 

Create Retrofit CallAdapter for Coroutines to handle response as states

Handling API responses as states with Retrofit and Kotlin Coroutines.

proandroiddev.com

 

https://blog.canopas.com/retrofit-effective-error-handling-with-kotlin-coroutine-and-result-api-405217e9a73d

 

Retrofit — Effective error handling with Kotlin Coroutine and Result API

Centralised and effective error handling with Retrofit, Kotlin Coroutine and Kotlin result API.

blog.canopas.com

 

https://medium.com/shdev/retrofit%EC%97%90-calladapter%EB%A5%BC-%EC%A0%81%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B2%95-853652179b5b

 

Retrofit에 CallAdapter를 적용하는 법

개요

medium.com

 

https://kotlinworld.com/152

 

[Coroutine] 11. Coroutine CoroutineContext를 다루는 방법 : Coroutine Dispatcher과 ExceptionHandler을 CoroutineContext를

CoroutineContext 앞서 우리는 다음의 내용들을 배웠다. Dispatcher: 코루틴이 실행될 스레드 풀을 잡고 있는 관리자 CoroutineExceptionHandler: 코루틴에서 Exception이 생겼을 때의 처리기 그런데 이 두 가지..

kotlinworld.com

https://kotlinworld.com/148

 

[Coroutine] 9. Coroutine Job에서 Exception이 발생했을 때 Exception Handling을 하는 방법

이번 글에서는 Job의 Exception을 Handling하는 방법을 살펴볼 것이다. Exception을 Handling하는 방법은 invokeOnCompletion을 이용한 방법과 CoroutineExceptionHandler 을 이용하는 방법 이 있다. 먼저 invoke..

kotlinworld.com

https://thdev.tech/kotlin/2021/01/12/Retrofit-Coroutines/

 

Retrofit2와 Coroutines 사용 시 스케줄러는 어떻게 처리할까? - 내부 코드로 알아보자. |

I’m an Android Developer.

thdev.tech

https://tourspace.tistory.com/442

 

[Coroutine] suspendCancellableCoroutine - Callback을 coroutine으로 변경

coroutine는 callback을 사용하지 않고 비동기 처리를 가능해주는 장점을 가지고 있습니다. 따라서 비동기 처리를 코드의 순서대로 실행시키며 가독성을 높이고, 보다 심플한 코드를 작성할 수 있도

tourspace.tistory.com