Skip to content

[AN] CallAdapter 적용 및 테스트

Hyeyeon Gong edited this page Jan 13, 2025 · 9 revisions

PR 링크

CallAdapter 적용 및 검증 테스트 작성


적용 계기

DataSource 내의 네트워크 통신 메서드마다 handleApiResponse 로 감싸줘야하는 게 불편하게 느껴졌다.
DataSource가 ApiResponseHandler에 대한 의존성을 가질 뿐만 아니라, API 응답 처리에 대한 책임도 갖고 있었다.

이런 문제점을 해결하기 위해 CallAdapter를 적용하게 되었다.

CallAdapter 적용 전 DataSource

class MemoryRemoteDataSource
    @Inject
    constructor(
        private val memoryApiService: MemoryApiService,
    ) : MemoryDataSource {
        override suspend fun getMemory(memoryId: Long): ResponseResult<MemoryResponse> =
            handleApiResponse { memoryApiService.getMemory(memoryId) }

        override suspend fun getMemories(currentDate: String?): ResponseResult<MemoriesResponse> =
            handleApiResponse { memoryApiService.getMemories(currentDate) }

        override suspend fun createMemory(newMemory: NewMemory): ResponseResult<MemoryCreationResponse> =
            handleApiResponse {
                memoryApiService.postMemory(newMemory.toDto())
            }

        override suspend fun updateMemory(
            memoryId: Long,
            newMemory: NewMemory,
        ): ResponseResult<Unit> =
            handleApiResponse {
                memoryApiService.putMemory(memoryId, newMemory.toDto())
            }

        override suspend fun deleteMemory(memoryId: Long): ResponseResult<Unit> =
            handleApiResponse {
                memoryApiService.deleteMemory(
                    memoryId,
                )
            }
    }


CallAdapter 알아보기

CallAdapter는 Retrofit2에서 지원하는 기능이다. Retrofit은 HTTP 요청 및 응답을 처리하기 위해 기본적으로 Call 타입을 사용한다.
CallAdapter는 이 기본 타입(Call)을 다른 타입으로 변환할 수 있도록 도와주는 역할을 한다.

1️⃣ CallAdapter

class ApiResultCallAdapter(
    private val resultType: Type,
) : CallAdapter<Type, Call<ApiResult<Type>>> {
    override fun responseType(): Type = resultType
    
    override fun adapt(call: Call<Type>): Call<ApiResult<Type>> = ApiResultCall(call)
}

CallAdapter란?

Adapts a Call with response type R into the type of T. Instances are created by a factory which is installed into the Retrofit instance.

  • CallAdapter<R, T>Call<R>을 T로 반환해주는 interface 이다.
    • 따라서 Call<Type>Call<ApiResult<Type>>으로 반환해준다.
  • 인스턴스는 Retrofit 인스턴스 안에 설치된 factory에 의해 생성된다.

fun responseType(): Type 이란?

Returns the value type that this adapter uses when converting the HTTP response body to a Java object. For example, the response type for Call is Repo. This type is used to prepare the call passed to #adapt.

  • Http 응답 본문을 Java 객체로 변환할 때 이 어댑터가 사용하는 값 타입을 반환한다.
  • 예를 들어 Call에 대한 responseType의 반환값은 Repo에 대한 타입이다.
  • 이 타입은 adapter에 전달된 call을 준비하는 데 사용된다.

fun adapt(call: Call<Type>) 이란?

Returns an instance of T which delegates to call. For example, given an instance for a hypothetical utility, Async, this instance would return a new Async which invoked call when run.

  • 메서드의 파라미터로 받은 call에게 작업을 위임하는 T 타입 인스턴스를 반환하는 메서드이다.
  • 즉, adapt는 Call<T>Call<ApiResult<T>>로 변환시켜 주는 역할이다.
  • 이러한 변환 과정은 CallAdapter가 아닌 Call 객체 내부에서 위임받아 진행한다.

2️⃣ CallAdapterFactory

class ApiResultCallAdapterFactory : CallAdapter.Factory() {
    override fun get(
        returnType: Type,
        annotations: Array<out Annotation>,
        retrofit: Retrofit,
    ): CallAdapter<*, *>? {
	// returnType의 기본 클래스 타입이 Call인지 확인
        if (getRawType(returnType) != Call::class.java) {
            return null
        }

	// returnType에서 첫 번째 제네릭 인자를 얻어 ApiResult 타입인지 확인
        val callType = getParameterUpperBound(0, returnType as ParameterizedType)
        if (getRawType(callType) != ApiResult::class.java) {
            return null
        }
        
	// ApiResult의 제네릭 인자를 얻어서 CallAdapter를 생성
        val resultType = getParameterUpperBound(0, callType as ParameterizedType)
        return ApiResultCallAdapter(resultType)
    }

    companion object {
        fun create(): ApiResultCallAdapterFactory = ApiResultCallAdapterFactory()
    }
}

CallAdapterFactory란?

Creates CallAdapter instances based on the return type of the service interface methods.

  • CallAdapter의 인스턴스를 생성한다.

fun get(returnType: Type, annotations: Array<out Annotation>, retrofit: Retrofit) 이란?

Returns a call adapter for interface methods that return returnType, or null if it cannot be handled by this factory.

  • returnType을 반환하는 인터페이스 메서드에 대한 CallAdapter를 반환한다.
  • 해당 팩토리에서 처리할 수 없는 경우 null을 반환해준다.

fun getRawType(type: Type)이란?

Extract the raw class type from type. For example, the type representing List<? extends Runnable> returns List.class.

  • type의 기본 클래스 타입을 추출한다.

fun getParameterUpperBound(type: Type)란?

Extract the upper bound of the generic parameter at index from type. For example, index 1 of Map<String, ? extends Runnable> returns Runnable.

  • type에서 특정 위치(index)의 제네릭 타입이 어떤 타입까지 확장(상한)될 수 있는지를 알려준다.
  • 예를 들어, Map<String, ? extends Runnable>에서 두 번째 제네릭 타입(인덱스 1)은 Runnable을 상한으로 가지고 있으니, 결과적으로 Runnable 타입을 반환한다.

3️⃣ Call

class ApiResultCall<T : Any>(
    private val proxy: Call<T>,
) : Call<ApiResult<T>> {
    override fun enqueue(callback: retrofit2.Callback<ApiResult<T>>) {
        proxy.enqueue(
            object : retrofit2.Callback<T> {
                override fun onResponse(
                    call: Call<T>,
                    response: Response<T>,
                ) {
                    val networkResult = handleApiResponse { response }
                    callback.onResponse(this@ApiResultCall, Response.success(networkResult))
                }

                override fun onFailure(
                    call: Call<T>,
                    t: Throwable,
                ) {
                    val networkResult = Exception<T>(t)
                    callback.onResponse(this@ApiResultCall, Response.success(networkResult))
                }
            },
        )
    }

    override fun execute(): Response<ApiResult<T>> = throw NotImplementedError()

    override fun clone(): Call<ApiResult<T>> = ApiResultCall(proxy.clone())

    override fun isExecuted(): Boolean = proxy.isExecuted

    override fun cancel() {
        proxy.cancel()
    }

    override fun isCanceled(): Boolean = proxy.isCanceled

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

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

Call이란?

An invocation of a Retrofit method that sends a request to a webserver and returns a response.

  • 웹 서버에 요청을 보내고 응답을 반환하는 객체이다.
  • CallAdapter의 adapt 메서드는 기존의 Call<T>을 파라미터로 받아 Call<ApiResult<T>>로 래핑한 인스턴스를 반환한다.
  • 이를 위해 Call<ApiResult<T>>을 구현하는 Custom Call을 정의해야 한다.

Call 객체는 enqueue, execute, clone, isExecuted 등 다양한 메서드를 가지고 있다. 이중에서 enqueue, execute만 주의 깊게 보면 된다.
clone, isExecuted 등과 같은 메서드는 기존 Call 객체가 제공하는 메서드들을 활용하면 된다.

    override fun enqueue(callback: retrofit2.Callback<ApiResult<T>>) {
        proxy.enqueue(
            object : retrofit2.Callback<T> {
                override fun onResponse(
                    call: Call<T>,
                    response: Response<T>,
                ) {
                    // 중요!
                    val networkResult: ApiResult<T> = handleApiResponse { response }
                    callback.onResponse(this@ApiResultCall, Response.success(networkResult))
                }

                override fun onFailure(
                    call: Call<T>,
                    t: Throwable,
                ) {
                    val networkResult = Exception<T>(t)
                    callback.onResponse(this@ApiResultCall, Response.success(networkResult))
                }
            },
        )
    }

응답 분석 결과에 따라 ApiResult를 생성해주던 작업을 CustomCall의 enqueue 내에서 직접해서 callback에 전달하면 된다.
여기서 DataSource 내의 네트워크 통신 메서드마다 handleApiResponse를 사용해야하는 불편함을 해소할 수 있다!

Call, CallAdapter, CallAdapterFactory 구현이 끝났다면 Retrofit에 구현한 CallAdapterFactory를 적용해주면 된다.

private val provideRetrofit =
    Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(provideHttpClient)
        .addConverterFactory(
            jsonBuilder.asConverterFactory("application/json".toMediaType()),
        )
        .addCallAdapterFactory(ApiResultCallAdapterFactory.create()) // CallAdapterFactory 적용
        .build()

CallAdapter 동작 검증 테스트

dev 앱에서 오류가 발생하는 상황을 만들어 CallAdapter가 정상적으로 동작하는 지 확인하고 싶었으나 오류가 발생하는 상황을 만들어내기가 쉽지 않았다.

유효하지 않은 형식은 서버로 요청을 보낼 수 없도록 최대한 막아뒀기 때문이다.
ex) 필수 입력값 미입력 시 버튼 비활성화 등..

그래서 테스트가 필요하다고 느꼈다.

MockWebServer를 활용해 테스트를 작성한 이유

서버의 응답을 CallAdapter가 올바르게 처리하는지만 확인하면 되기 때문에 실제 서버에 요청을 보낼 필요는 없다고 판단했다.
또한, 실제 서버로 요청을 보내면 데이터가 계속 쌓이게 되어 이를 피하고자 MockWebServer를 활용했다.

응답을 가상으로 설정한 후에 ApiService 메서드의 반환값이 응답 결과에 따라 올바르게 반환 되는지 검증했다.
테스트는 HTTP status code별로 시나리오를 생각해 작성했다.

CallAdapter 테스트

@ExperimentalCoroutinesApi
@ExtendWith(CoroutinesTestExtension::class)
class ApiResultCallAdapterTest {
    private val mockWebServer = MockWebServer()

    private lateinit var memoryApiService: MemoryApiService
    private lateinit var imageApiService: ImageApiService
    private lateinit var commentApiService: CommentApiService

    @BeforeEach
    fun setUp() {
        mockWebServer.start()

        val retrofit = buildRetrofitFor(mockWebServer)
        memoryApiService = retrofit.create(MemoryApiService::class.java)
        imageApiService = retrofit.create(ImageApiService::class.java)
        commentApiService = retrofit.create(CommentApiService::class.java)
    }

    @Test
    fun `존재하는 카테고리를 조회하면 카테고리 조회에 성공한다`() {
        val success: MockResponse =
            createMockResponse(
                code = 200,
                body = createMemoryResponse(),
            )
        mockWebServer.enqueue(success)

        runTest {
            val actual: ApiResult<MemoryResponse> =
                memoryApiService.getMemory(memoryId = 1)

            assertTrue(actual is Success)
        }
    }

    @Test
    fun `유효한 형식의 카테고리로 생성을 요청하면 카테고리 생성에 성공한다`() {
        val success: MockResponse =
            createMockResponse(
                code = 201,
                body = createMemoryCreationResponse(),
            )
        mockWebServer.enqueue(success)

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createValidMemoryRequest())

            assertTrue(actual is Success)
        }
    }

    @Test
    fun `유효하지 않은 형식의 카테고리로 생성을 요청하면 오류가 발생한다`() {
        val serverError: MockResponse =
            createMockResponse(
                code = 400,
                body = createErrorBy400(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createInvalidMemoryRequest())

            assertTrue(actual is ServerError)
        }
    }

    @Test
    fun `인증되지 않은 사용자가 카테고리 생성을 요청하면 오류가 발생한다`() {
        val serverError: MockResponse =
            createMockResponse(
                code = 401,
                body = createErrorBy401(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createValidMemoryRequest())

            assertTrue(actual is ServerError)
        }
    }

    @Test
    fun `댓글 삭제를 요청한 사용자와 댓글 작성자의 인증 정보가 일치하지 않으면 오류가 발생한다`() {
        val serverError: MockResponse =
            createMockResponse(
                code = 403,
                body = createErrorBy403(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<Unit> =
                commentApiService.deleteComment(commentId = 1)

            assertTrue(actual is ServerError)
        }
    }

    @Test
    fun `20MB를 초과하는 사진을 업로드 요청하면 오류가 발생한다`() {
        val serverError =
            createMockResponse(
                code = 413,
                body = createErrorBy413(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<ImageResponse> =
                imageApiService.postImage(imageFile = createFakeImageFile())

            assertTrue(actual is ServerError)
        }
    }

    @Test
    fun `카테고리 생성 요청 중 서버 장애가 생기면 오류가 발생한다`() {
        val serverError =
            createMockResponse(
                code = 500,
                body = createErrorBy500(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createValidMemoryRequest())

            assertTrue(actual is ServerError)
        }
    }

    @Test
    fun `카테고리 생성 요청 중 서버의 응답이 없다면 예외가 발생한다`() {
        mockWebServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createValidMemoryRequest())

            assertTrue(actual is Exception)
        }
    }

    @AfterEach
    fun tearDown() {
        mockWebServer.shutdown()
    }
}

결과

DataSource가 ApiResponseHandler에 대한 의존성을 가질 뿐만 아니라, API 응답 처리에 대한 책임도 갖고 있다.

  • 위 책임을 CallAdapter에게 위임하여 DataSource와 ApiResponseHandler 사이의 의존성을 제거했다.

CallAdapter 적용 후 DataSource

class MemoryRemoteDataSource
    @Inject
    constructor(
        private val memoryApiService: MemoryApiService,
    ) : MemoryDataSource {
        override suspend fun getMemory(memoryId: Long): ApiResult<MemoryResponse> = memoryApiService.getMemory(memoryId)

        override suspend fun getMemories(currentDate: String?): ApiResult<MemoriesResponse> = memoryApiService.getMemories(currentDate)

        override suspend fun createMemory(newMemory: NewMemory): ApiResult<MemoryCreationResponse> =
            memoryApiService.postMemory(
                newMemory.toDto(),
            )

        override suspend fun updateMemory(
            memoryId: Long,
            newMemory: NewMemory,
        ): ApiResult<Unit> = memoryApiService.putMemory(memoryId, newMemory.toDto())

        override suspend fun deleteMemory(memoryId: Long): ApiResult<Unit> =
            memoryApiService.deleteMemory(
                memoryId,
            )
    }

참고자료

💠 스타카토 💠

Home: 스타카토 소개

⚙️ 기술 문서

🖐️ Common

🅰️ Android

🅱️ Backend


🤪 우리들의 스타카토

개발카토

  • 🫶 WooDangTang!Tang! HuruHuru~ Pair

낭만카토

🚀 트러블 슈팅

AN

  • 활성화 상태인 키보드 숨기기

BE

  • something_trouble
Clone this wiki locally