(Android) Retrofit2는 어떻게 동작하는가 — 1. 내부 코드 분석

Retrofit2 Deep Dive #1

Jaesung Lee
jaesung dev
17 min readOct 27, 2023

--

Photo by Clint Adair on Unsplash

[목차 보기]

이 글에서는 Retrofit2의 자세한 사용법에 대해서는 다루지 않습니다. 관련 내용에 대해서는 이전에 포스팅하던 블로그를 통해 확인하시면 좋을 것 같습니다.

Retrofit2

Retrofit2는 서버와 클라이언트(Android)의 HTTP 통신을 위해 Square사에서 제공하는 네트워크 라이브러리입니다. 기본적으로 Type-Safe한 형태로 지원되며, 내부적으로 OkHttp 클라이언트와 함께 동작합니다.

그동안 어떻게 사용해왔었나

우리는 Retrofit2를 사용할 때 반드시 API Interface를 정의했었습니다. 보통 아래와 같은 3가지 방식으로 작성합니다.

Call

가장 기본적인 방식은 1번 방식입니다. Retrofit2의 Call 인터페이스를 사용하게 되면 Call#execute, Call#enqueue를 통해 요청 및 응답 방식을 선택할 수 있습니다.

1. Call#execute

Call#execute를 사용하게 되면 서버에 동기적으로 네트워크 요청을 보내고 응답을 반환하게 됩니다.

동기적으로 요청을 보내는 것이 핵심입니다. 안드로이드는 싱글 스레드(UI Thread)로 동작하기 때문에 요청을 보내고 응답을 받을 때 까지 스레드가 block되게 됩니다. 즉, 요청이 진행되는 동안 UI가 차단되는 것을 의미하며, NetworkOnMainThreadException를 발생시키기 때문에 비권장 메서드로 이해할 수 있습니다.

2. Call#enqueue

Call#enqueue를 사용하면 서버에 비동기적 요청을 하고 응답을 반환하게 됩니다.

Call#execute와 달리 비동기적으로 요청을 하기 때문에 UI 차단이 발생하지 않습니다. 또한, Call#enqueue를 사용할 경우 요청 실패시 발생하던 IOException 대신 Callback을 통한 네트워크 연결 성공, 실패 분기를 통해 발생할 수 있는 예외에 대한 다양한 처리가 가능해집니다.

하지만, 비동기처리에 코루틴을 활용하고 있다면 Call#enqueue를 사용하지 않아도 됩니다. 콜백 형태의 코드를 작성할 경우 단일 코드 블럭에서 순차적인 코드 작성으로 가독성 측면의 이점을 갖는 코루틴의 장점을 활용하지 못하게 됩니다.

이 경우, KotlinExtension.kt의 await을 통해 Call 타입을 코루틴과 함께 활용할 수 있습니다.

Response

3번에 해당하는 Response을 사용하게 되면 요청 이후의 응답을 받을 수 있습니다. 하지만, 앞서 살펴본 Call#enqueue와 달리 응답이 성공적인지, 실패인지 확인할 수 있는 콜백이 없기 때문에 별도의 예외 처리 과정이 필요합니다.

마찬가지로, 코루틴을 함께 활용할 경우 가독성의 이점을 얻을 수 있습니다. Call#enqueue를 사용할 경우보다 더 간결한 코드를 작성할 수 있고 내부적으로 이러한 enqueue과정을 하고있기 때문입니다.

그렇다면 Retrofit은 이 enqueue를 어떻게 구현하고 있을까요?

Retrofit#create

TL;DR
Retrofit.create로 만든 ApiInterface의 구현체인 프록시 객체의 함수를 호출하면 내부적으로 수행될 일이 InvocationHandler.invoke에 위임되고, 여기서 defaultMethod일 경우 그냥 호출되고 defaultMethod가 아닐 경우 loadService를 호출한다. 보통 false가 반환되기 때문에 loadService에서 많은 동작이 이루어진다.

앞서 살펴본 ApiService는 인터페이스입니다. 인터페이스에 정의한 메서드는 반드시 구현체를 정의해야 사용할 수 있지만, 우리는 한번도 이 인터페이스의 구현체를 만든적이 없습니다. 그렇다면 이 인터페이스가 어떻게 활용될까요?

싱글톤 객체에 Retrofit 인스턴스를 직접 생성하여 서비스 로케이터처럼 접근하는 방법도 있지만, 의존성 주입을 많이 활용하는 요즘에는 아래와 같이 코드를 작성할 것입니다.

해당 Hilt 모듈에서는 빌더 패턴을 이용하여 통신에 필요한 구성을 하고Retrofit 인스턴스를 만들어 제공하고 있습니다. 두번째 메서드를 살펴보면 create를 통해 인스턴스를 만들고 있습니다.

일반적으로는 메서드에 주석처리한 것 처럼 파라미터를 전달하여 명시적으로 사용할 수 있지만, KotlinExtension.kt에는 create 메서드가 inline으로 선언됨과 동시에 reified를 활용하여 런타임에 타입을 추론할 수 있게 됩니다.

create 메서드는 내부적으로 리플렉션을 통해 전달하는 ApiService 인터페이스를 Java Class로 변환합니다. 내부 코드를 한번 살펴보겠습니다.

먼저, validateServiceInterface를 통해 전달받은 ApiService java Class가 인터페이스 형태인지 확인합니다. 만약 인터페이스 타입이 아닐 경우 IllegalArgumentException을 throw하게 됩니다.

이 후, 프록시 패턴을 통해 Retrofit 인스턴스를 제공해주고 있습니다. 간단하게 프록시 패턴에 대해 알아본다면, 프록시 패턴은 특정 객체에 일부 기능을 추가하거나 수정하려고 할 때 원본 객체 대신 프록시 객체를 사용하는 디자인 패턴입니다. 프록시 패턴에 대한 자세한 내용은 기회가 된다면 따로 정리해보도록 하겠습니다.

Proxy#newProxyInstance에 전달하는 파라미터 중, InvocationHandler의 구현체를 살펴보겠습니다. InvocationHandler는 invoke 메서드 하나만 가지고 있으며, 동적으로 생성된 프록시 인스턴스의 어떤 메서드가 호출되더라도 invoke가 호출되며, 이 부분에서 메서드 확장이 이루어집니다.

이해를 돕기위해 부연 설명을 해보겠습니다. 앞서 정의한 ApiService를 create하게 되면 ApiService 인터페이스의 구현체를 제공하는 프록시 인스턴스가 생성되며 이 안에도 ApiService의 메서드들이 포함되게 됩니다. 따라서, getDailyBoxOffice를 호출하면 프록시 인스턴스의 getDailyBoxOffice도 호출될 것이며 이에 따라 InvocationHandler의 invoke도 반드시 호출되게 됩니다.

InvocationHandler#invoke의 마지막에서는 호출된 프록시 인스턴스의 메서드가 DefaultMethod인지 아닌지에 따라 처리되고 있습니다. 아래 코드를 통해 살펴보겠습니다.

javaDocs에서는 인터페이스에 public하고 non-abstract한 메서드로 정의된 함수 body가 존재하는 메서드를 Default Method로 설명하고 있습니다. 우리가 ApiService 인터페이스를 정의할 때 함수의 구현 블록은 따로 정의 하지 않습니다. 그렇기 때문에 isDefaultMethod는 false를 반환할 것입니다.

platform은 말 그대로 플랫폼을 의미합니다. 내부 코드를 확인해보면 Android를 반환하고 있습니다.

결국, loadServiceMethod의 invoke 부분에서 실제 Retrofit의 내부 동작이 이루어진다는 것을 파악할 수 있습니다.

Retrofit#loadServiceMethod

TL;DR
serviceMethodCache를 활용하여 요청되는 메서드들을 재사용한다. 캐싱된 메서드가 없다면 직접 해당 메서드 정보를 파싱하여 serviceMethodCache에 저장한다.

Retrofit#loadServiceMethod에서는 serviceMethodCache라는 캐시를 활용합니다. serviceMethodCache는 Map으로 구현되어 있으며, 호출되는 메서드들의 캐싱이 이루어집니다. 즉, 캐싱된 메서드가 없으면 parseAnnotation을 수행하여 캐시에 저장하고, 동일한 메서드가 재요청되면 캐시에 저장된 메서드를 재사용하는 것을 확인할 수 있습니다.

초기 빌드 후 네트워크 요청시에는 당연히 캐싱된 메서드가 없을 것이기 때문에, 첫 요청을 가정하고 ServiceMethod#parseAnnotation을 살펴보겠습니다.

ServiceMethod#parseAnnotation

TL;DR
요청된 메서드의 정보들을 1차적으로 검사하여 파싱하는 역할을 수행한다. 선언한 HTTP 어노테이션, 메서드 반환 타입 등을 검사한다.

ServiceMethod#parseAnnotation은 static 메서드로 구현되어 있습니다. 코드를 살펴보면 RequestFactory#parseAnnotation에 Retrofit 인스턴스와 해당 메서드를 전달하기 때문에 실질적인 메서드 정보 파싱은 여기서 이루어지는 것을 확인할 수 있습니다. 즉, @GET, @POST와 같은 Retrofit에서 제공하는 HTTP 어노테이션들의 파싱이 이루어진다고 유추할 수 있습니다.

또한, ApiService에 선언한 메서드들의 반환 타입을 확인합니다. 확인이 불가능한 반환타입 또는 Void와 같이 반환타입을 명시하지 않을 경우 예외를 발생시키고 있습니다.

메서드가 정상적으로 동작이 가능함이 확인되면 Retrofit 인스턴스와 선언한 메서드, 메서드 정보가 담긴 requestFactory를 전달하여 HttpServiceMethod#parseAnnotation을 실행하게 됩니다. 그에 앞서, 메서드 정보를 파싱하는 역할을 수행하는 RequestFactory#parseAnnotation을 살펴보겠습니다.

RequestFactory#parseAnnotation

TL;DR
ApiService 메서드의 Request 정보 파싱을 수행하는 메서드이다. 파싱된 메서드의 Request 정보들을 RequestFactory에 저장하여 인스턴스를 반환한다.

RequestFactory에는 요청에 필요한 Retrofit 관련 정보들이 포함되어있고, 이를 빌더 패턴으로 구성합니다.

RequestFactory.Builder#build를 호출하게 되면 RequesetFactory#parseAnnotation을 통해 전달된 메서드들의 정보들을 set하고 RequestFactory 인스턴스를 반환하게 됩니다. 해당 메서드에서는 대표적으로 메서드에 선언한 HTTP 어노테이션 파싱, 헤더 및 바디 파싱, Multi-Part 선언 여부등 다양한 정보들을 파싱하고 있습니다.

메서드를 구성하는 정보들이 워낙 많기 때문에 코드 전문이 궁금하신 분은 RequestFactory.java 파일을 확인해보시면 좋을 것 같습니다.

HttpServiceMethod#parseAnnotation

TL;DR
메서드가 suspend 가능한 메서드인지에 대한 확인 및 메서드 반환 타입 검사에 따른 분기 처리를 담당한다.

앞서 살펴봤던 ServiceMethod#parseAnnotation에서 요청 메서드의 Request 정보들이 담긴 RequestFactory 인스턴스와 함께 Retrofit 인스턴스, 선언한 요청 메서드를 파라미터로 전달받게 되면 static으로 선언된HttpServiceMethod#parseAnnotation이 수행됩니다.

코드 라인수가 굉장히 길기도 하고 메서드가 갖는 책임이 굉장히 크다고 생각하기 때문에, 저의 주관적인 견해대로 메서드의 책임을 분리하여 하나씩 살펴보겠습니다.

1. suspend 체크 및 이에 따른 필요 변수 구성

static 메서드 블록 상단을 보면 눈에 띄는 네이밍을 갖는 변수들이 있습니다. isKotlinSuspendFunction을 통해 직관적으로 해당 메서드가 suspend 가능한지 확인하고 있습니다. 이 정보는 RequestFactory에서 확인하여 전달해줍니다.

잠시, RequestFactory로 돌아가 관련 코드를 살펴보겠습니다.

RequestFactory에서 메서드의 parameter를 확인할 때 Continuation 객체가 있는지 확인하여 isKotlinSuspendFunction을 true로 만들어주고 있습니다. 코루틴을 공부해보신 분들은 아시겠지만 suspend는 메서드의 시그니처를 변경하면서 동작합니다. suspend 메서드의 마지막 파라미터로 Continuation 객체가 추가되기 때문에 메서드가 일시중단 되어 다른 task를 수행하더라도 다시 중단점으로 돌아갈 수 있었습니다.

정리하면, 해당 요청 메서드가 suspend 가능한 메서드인지 확인하고, 반환 타입이 Response 타입인지 확인하여 변수들을 구성하게 됩니다.

2. CallAdapter 확인

두번째로는 CallAdapter를 생성하게 됩니다. Hilt 모듈을 정의하는 과정에서 Retrofit을 구성하는 provide 메서드를 만들때 CallAdapter를 커스텀하여 추가할 수 있습니다. 별도로 추가하지 않더라도 동작할 수 있었던 이유는 Retrofit은 기본적으로 정의된 DefaultCallAdapterFactory를 사용하기 때문입니다.

해당 구현부에서는 직접 커스텀한 CallAdapter를 사용할 경우 발생될 수 있는 예외들을 throw하는 것으로 보입니다.

3. 메서드 반환 타입에 따른 분기 처리

세번째로는 메서드 반환 타입에 따른 분기처리를 하고 있습니다. 우선적으로 ResponseConverter를 생성하고 있으며 이 부분도 직접 커스텀이 가능합니다.

요청 메서드가 suspend 가능하지 않다면 CallAdapted를 생성하여 리턴하고 있으며, suspend 가능하다면 요청 메서드의 반환 타입이 Response 인지 아닌지에 따라 SuspendForResponse, SuspendForBody를 반환하게 됩니다.

ServiceMethod#invoke

다시 Retrofit 인스턴스를 create하는 부분을 살펴보겠습니다. 앞서 살펴봤던 것 처럼 Retrofit#loadServiceMethod를 통해 요청 메서드에 대한 모든 정보들을 확인하고 나면 ServiceMethod#invoke가 호출되게 됩니다.

ServiceMethod#invoke가 호출되고 나면 콜스택에 차례대로 HttpServiceMethod#invoke가 호출되고 OkHttpCall 인스턴스와 함께 HttpServiceMethod#adapt를 호출하게 됩니다. 이 메서드는 추상 메서드이기 때문에 어디선가 상속하여 사용할 것입니다. 이 메서드를 사용하는 부분은 바로 앞서 살펴봤던 CallAdapted, SuspendForResponse, SuspendForBody 입니다.

suspend 가능한 메서드 일때 동작 가능한 SuspendForResponse, SuspendForBody 에 대해서만 확인해보겠습니다.

코루틴과 함께 Retrofit2를 사용할 때, enqueue를 어떻게 자동으로 구현하고 있는지에 대한 궁금증이 지금부터 풀리게 됩니다.

HttpServiceMethod.SuspendForResponse#adapt

adapt하는 과정을 살펴보면 KotlinExtensions#awaitResponse를 수행하면서 예외 처리를 하고 있습니다.

awaitResponse (KotlinExtensions.kt)

여기서부터는 익숙한 코드들이 등장합니다. 우선, Call 타입의 확장함수로서 동작하는 awaitResponse는 enqueue를 수행할 수 있습니다.

코드에서는 해당 콜백을 코루틴처럼 동작시키고 취소 가능하도록 suspendCancellableCoroutine 블럭을 사용하고 있습니다. 또한, 정상 응답을 받았을 때 Response와 함께 continuation을 resume 시키고, 실패했을 경우 resumeWithException을 수행하여 발생된 예외를 전달하게 됩니다.

continuation 객체의 역할이 중요한데, 이 continuation 객체에는 실제 ApiService의 메서드를 요청한 콜사이트의 정보가 저장되어 있습니다.

보통 ApiService의 메서드를 RemoteDataSource에서 요청할 것이기 때문에 continuation에는 RemoteDataSource에서 해당 메서드를 요청한 위치가 저장될 것입니다. 말 그대로 이 지점이 Suspension Point입니다. 따라서, 네트워크 요청이 정상적으로 수행되었는지 혹은 실패하였는지에 따라 결과가 전달될 것이고, 만약 정상적으로 수행되지 못해 resumeWithException이 수행된다면 Suspension Point로 예외를들고가 해당 지점에서 발생된 예외를 throw합니다.

HttpServiceMethod.SuspendForBody#adapt

SuspendForBody도 마찬가지로 Body가 Nullable한지에 따라 KotlinExtensions의 메서드들을 호출하고 있습니다.

await, awaitNullable (KotlinExtensions.kt)

SuspendForBody의 경우 Body의 타입이 Non-Null할 경우 await을 수행하게 됩니다. 따라서, 내부에서 요청 메서드의 응답에 대한 body를 검사하는 로직이 추가됩니다. 만약 body가 null일 경우에는 Suspension Point에서 NPE를 발생시키게 됩니다.

SuspendForBody#adapt를 수행하는 과정에서 isNullable이 true일 경우 awaitNullable이 수행되게 됩니다. 이미 nullable함을 확인했기 때문에 내부적으로 별도의 체크 로직은 없습니다.

suspendAndThrow (KotlinExtensions.kt)

앞서 살펴본 SuspendForResponse, SuspendForBody 모두 내부적으로 enqueue를 수행하며 발생될 수 있는 Exception에 대한 처리는 하고있습니다. 하지만, 그 전에 Retrofit 요청을 구성하는 과정에서에 대한 예외처리는 suspendAndThrow를 통해 하게 됩니다.

suspendAndThrow는 발생하는 예외를 throw하기 전에 강제로 suspend 하도록 설계되었습니다. 따라서 예외가 발생할 경우 enqueue를 통한 비동기 요청을 시도하지 않고 해당 Suspension Point로 돌아가 예외를 throw하게 됩니다.

정리

Retrofit2 코드를 분석하면서 코루틴과 함께 활용할 경우 내부적으로 enqueue를 수행하는 과정까지 확인할 수 있었습니다. 아직 확인해보지 않은 부분은 실제로 이 enqueue를 어떻게 수행하는지 입니다.

다음 글에서는 enqueue를 통해 실제 비동기처리를 어떻게 하고있는지 확인해보겠습니다. 아래에는 내부 동작을 학습하면서 많은 도움이 되었던 자료들을 남겨놨습니다. 우선순위별로 있으니 함께 읽어보시면 좋을 것 같습니다.

--

--