ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Android/Retrofit] 204 응답에 NPE 발생할 때 대처법
    안드로이드 2024. 2. 4. 16:40

    Retrofit과 Coroutine을 함께 사용하던 중 204응답이 왔을 때 NPE가 발생하는 현상을 겪었습니다.

    이를 위해 임시로 대응하는 코드를 작성하다가 제너럴하게 처리할 수 있는 코드를 구현했고

    라이브러리까지 배포했습니다.

     

    이 글에서 임시로 대응했던 방법을 소개하고 제가 직접 만든 라이브러리 링크를 소개합니다.

    라이브러리를 사용하고 싶거나 github에서 보고 싶다면 https://github.com/sodp5/retrofit-unit-adapter 에서 확인해주세요!

     

    NPE가 언제 발생하는지

    일반적으로 Retrofit을 사용하다가 마주하는 에러는 400, 500 번대의 응답의 에러입니다.

    하지만 204 (no-content) 응답의 경우는 성공했어도 에러가 발생할 수 있습니다.

    다음은 에러가 발생하는 예시의 코드입니다.

    interface MyApi {
        /**
        * 204 응답을 내려주는 endpoint
        */
        @GET("/path")
        suspend fun get()
    }

     

    성공하더라도 204 응답의 body는 없으므로 NPE가 발생합니다.

     

    임시 대응 방법

    위 문제는 쉽게 해결이 가능합니다.

     

    올바른 타입을 지정해줍니다.

    interface MyApi {
        /**
        * 204 응답을 내려주는 endpoint
        */
        @GET("/path")
        suspend fun get(): Unit?
    }

    😄: 의도한대로 코드가 동작합니다.

    🤔: 불필요하다고 느껴지는 코드를 작성해야 합니다.

     

    Response 객체로 랩핑

    interface MyApi {
        /**
        * 204 응답을 내려주는 endpoint
        */
        @GET("/path")
        suspend fun get(): Response<Unit>
    }

    😄: Response 객체를 통해 성공, 실패 여부를 체크할 수 있습니다.

    🤔: 실패하는 경우에도 Error가 발생하지 않아서 사이드이펙트가 발생할 수 있습니다.

     

    해결 방법

    기본적으로 Retrofit을 통해 받는 응답은 갖고 있는 CallAdapterFactory를 순서대로 거치게 됩니다.

    따라서 Unit 타입이 지정되어있다면 CallAdapterFactory를 통해 Unit을 반환하도록 변경해주었습니다.

     

    이로써 204 응답에 대해 별도의 응답 타입을 지정하지 않더라도 예상한대로 동작할 수 있게 되었습니다.

    class UnitCallAdapterFactory : CallAdapter.Factory() {
        override fun get(
            returnType: Type,
            annotations: Array<out Annotation>,
            retrofit: Retrofit,
        ): CallAdapter<*, *>? {
            if (Call::class.java != getRawType(returnType)) {
                return null
            }
    
            check(returnType is ParameterizedType) { "Call<T> must be a ParameterizedType" }
    
            val responseType = getParameterUpperBound(0, returnType)
    
            if (responseType != Unit::class.java) {
                return null
            }
    
            return UnitCallAdapter()
        }
    }
    internal class UnitCallAdapter : CallAdapter<Unit, Call<Unit>> {
        override fun responseType(): Type = Unit::class.java
    
        override fun adapt(call: Call<Unit>): Call<Unit> {
            return UnitCall(call)
        }
    
        private class UnitCall(
            private val delegate: Call<Unit>,
        ) : Call<Unit> by delegate {
            override fun enqueue(callback: Callback<Unit>) {
                delegate.enqueue(object : Callback<Unit> {
                    override fun onResponse(call: Call<Unit>, response: Response<Unit>) {
                        if (response.isSuccessful) {
                            callback.onResponse(this@UnitCall, Response.success(Unit))
                        } else {
                            callback.onResponse(this@UnitCall, response)
                        }
                    }
    
                    override fun onFailure(call: Call<Unit>, t: Throwable) {
                        callback.onFailure(call, t)
                    }
                })
            }
        }
    }

     

     

    이 코드를 dependency를 추가해서 사용하고 싶다면

    // settings.gradle
    repositories { 
        mavenCentral()
        maven { url 'https://jitpack.io' } 
    }
    // build.gradle(app) latest_version = 1.0.1 (2024/02/04)
    dependencies {
        implementation "com.github.sodp5:retrofit-unit-adapter:$latest_version" 
    }

     

     

    테스트

    테스트는 두 가지 방법으로 진행 했습니다.

    1. 포스트맨을 통해 Mock 서버 생성

    2. Retrofit Mock Test

     

    포스트맨을 통해 Mock 서버 생성

    아래 블로그에 정리가 잘 되어 있어서 해당 글을 참고했습니다!

    https://darrenlog.tistory.com/44

    포스트맨을 활용하면 다양한 응답과 에러 관련 테스트를 진행할 수 있습니다.

     

    Retrofit Mock Test

    실행 원리는 다음과 같습니다.

    - MockResponse를 정의합니다. 이 때 response code나 body와 같은 값들을 지정할 수 있습니다.

    - Mock 서버 queue에 Response를 넣습니다.

    - 서버의 아무 엔드포인트에 요청 시 queue에 넣어둔 응답이 내려옵니다.

     

    따라서 204 응답을 올바르게 처리할 수 있다는 테스트와 200, 404, 500 등의 에러에는 영향을 끼치지 않는다는 테스트를 작성했습니다.

     

    테스트에 사용한 응답 예시입니다.

    object MockServerResponseUtil {
        val response200 = MockResponse()
            .setResponseCode(200)
            .setBody("{\"data\":\"anything\"}")
    
        val response204 = MockResponse()
            .setResponseCode(204)
    
        val response404 = MockResponse()
            .setResponseCode(404)
            .setBody("{ reason:\"bad request\" }")
    
        val response500 = MockResponse()
            .setResponseCode(500)
            .setBody("{ reason:\"internal server error\" }")
    }

     

    204 응답에 대한 테스트입니다.

    @Test
    fun `Success 204 response`() = runTest {
        val response = MockServerResponseUtil.response204
            
        server.enqueue(response)
            
        assertDoesNotThrow {
            api.get204()
        }
    }
        
    @Test
    fun `Throw NPE if without UnitCallAdapterFactory`() = runTest {
        val response = MockServerResponseUtil.response204
            
        server.enqueue(response)
            
        assertThrows<NullPointerException> {
            apiWithoutUnitCallAdapter.get204()
        }
    }

     

    이를 통해 예상한대로 동작함을 검증할 수 있었습니다.


     

    매번 라이브러리를 배포해보고 싶다는 생각이 있었음에도 막연한 두려움으로 배포하지 못했지만

    사소한 기능임에도 배포하는 경험을 쌓아보고자 배포해봤습니다.

    배포하는 과정이 순탄치는 않았지만 나름 재미있던 경험이었던것 같습니다!

    https://github.com/sodp5/retrofit-unit-adapter

     

    반응형

    댓글

Designed by Tistory.