안드로이드 통신에 자주 사용하는 Retrofit 알아보기 (2) - create 뜯어보기
이전 글에서 이어집니다.
https://sh1mj1-log.tistory.com/167
이번에는 Retrofit 을 사용해서 서버와 통신을 할 때 Retrofit 클래스 내부가 어떻게 구현이 되어 있는지에 대해 조금 더 자세히 알아보겠습니다.
Retrofit 클래스
이전 글(안드로이드 통신에 ... Retrofit 장점을 중심으로...) 에서 마지막에 FreindApi
를 DI 하는 코드가 아래와 같았습니다.
@Singleton
@Provides
fun provideFriendeApi(
@BabaRetrofit retrofit: Retrofit
): FriendApi = retrofit.create(FirendApi::class.java)
여기서 create
함수의 구현에 대해 살펴봅시다.
create
의 패러미터로 FriendApi::class.java
라는 독특한 형태를 받고 있는 것을 볼 수 있습니다.
먼저 Retrofit 클래스로 가봅시다.
해석하면 이렇습니다.
'Retrofit은 선언된 메서드에 대한 애노테이션(@GET 혹은 @POST 등..)을 사용하여 request 방법을 정의함으로써 Java 인터페이스를 HTTP 호출에 적용한다.
Builder를 사용하여 인스턴스를 만들고 인터페이스를 전달하여 생성하여 구현을 생성한다. 예를 들면 아래처럼 생성한다.Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://api.example.com/ ") .addConverterFactory(GsonConverterFactory.create()) .build() MyApi api = retrofit.create(MyApi.class); Response <User> user = api.getUser().execute();
Retrofit 의 기본적인 사용 방법에 대해 설명하고 있습니다. 여기까지는 이전 글에서의 내용과 겹치는 내용이 많죠?
이제 Retrofit
클래스의 create
함수의 구현에 대해 살펴보겠습니다.
Retrofit 의 create 함수
create
함수의 주석문에서는 이렇게 설명하고 있습니다.
Create an implementation of the API endpoints defined by the service interface.
서비스 인터페이스에서 정의된 API 엔드포인트의 구현을 생성한다.
여기서 API 엔드포인트는 API 가 서버의 리소스에 접근할 수 있도록 하는 주소(URL)를 말합니다.
Retrofit 은 API 엔드포인트에 상세 주소를 인터페이스에 작성해 놓으면 create 함수로 그 인터페이스에 대한 구현체를 만들어서 api 에 요청을 보내는 것으로 동작합니다.
즉, 서비스 인터페이스에서는 오직 api 호출 함수(일종의 퍼블릭 인터페이스 함수)만 노출되고 실제 구현은 레트로핏의 create 에서 수행되는 것입니다.
여기까지도 이전 글에서 간단히 다루었죠.
create
함수의 내부 구현은 아래와 같습니다.
public <T> T create(final Class<T> service) {
validateServiceInterface(service);
return (T)
Proxy.newProxyInstance(
service.getClassLoader(),
new Class<?>[] {service},
new InvocationHandler() {
private final Platform platform = Platform.get();
private final Object[] emptyArgs = new Object[0];
@Override
public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args)
throws Throwable {
// If the method is a method from Object then defer to normal invocation.
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
}
args = args != null ? args : emptyArgs;
return platform.isDefaultMethod(method)
? platform.invokeDefaultMethod(method, service, proxy, args)
: loadServiceMethod(method).invoke(args);
}
});
}
먼저 create
의 파라미터로 서비스 인터페이스를 받고 있습니다. 반환 타입은 서비스 인터페이스 T 의 구현체입니다.
여기서는 자바의 Reflection(리플렉션)을 활용하고 있습니다.
Reflection 은 간단히 말하자면 런타임에 동적으로 객체의 프로퍼티와 메서드에 접근할 수 있도록 해주는 방법입니다.
Class<T> service
의 구체적인 구현체 타입을 직접 명시하지 않아도 런타임에 동적으로 객체의 프로퍼티와 메서드에 접근할 수 있도록 해주는 방법입니다.
validateServiceInterface
라는 메서드 후에 또 요상한 형태로 Proxy.newProxyInstance
객체를 리턴하고 있습니다.
Proxy 익명 객체의 오버라이딩 함수 invoke
에서는
- 서비스 인터페이스에 정의된 메서드들을 반복하면서 메서드가 디폴트 메서드인지 검사하여 만약 그렇다면 Default Method 호출( `platform.invokeDefaultMethod(...)` ),
- 디폴트 메서드가 아니라면 Retrofit 에서 정의한 `ServiceMethod` 를 로드( `loadServiceMethod(method)` )하고 해당 메서드를 호출해서 Http 요청을 처리하도록 합니다.
여기서 Default Method 는
interface MyService { // 이 메서드는 일반 메서드입니다. @GET("/api/data") fun getData(): Call<Data> // 이 메서드는 디폴트 메서드입니다. fun defaultMethod() { // 기본 구현 } }
위 코드 블록에 나온 것처럼 함수의 바디 부분에 구현이 되어있는 메서드입니다.
이렇게 인터페이스 내에 기본 구현을 가진 함수를 가지는 것은 Java 8 이상에서 도입된 것입니다.
일반적으로는 Retrofit 서비스 인터페이스 내에서는 디폴트 메서드는 구현하지 않죠.
결국 대부분의 경우에는 우리가 레트로핏을 사용하여 서버와 통신을 하기 위해 정의한 인터페이스의 메서드가 호출되게 되는 것입니다.
이제 Proxy 가 무엇인지 궁금하실 겁니다. 그 전에 먼저 loadServiceMethod
쪽 부터 보고 갑시다.
create 함수 내부 loadServiceMethod 함수
ServiceMethod<?> loadServiceMethod(Method method) {
ServiceMethod<?> result = serviceMethodCache.get(method);
if (result != null) return result;
synchronized (serviceMethodCache) {
result = serviceMethodCache.get(method);
if (result == null) {
result = ServiceMethod.parseAnnotations(this, method);
serviceMethodCache.put(method, result);
}
}
return result;
}
이 메서드는 Retrofit 이 서비스 인터페이스의 메서드에 대한 정보를 로드하고 캐시에서 검색하는 데 사용됩니다.
method
에 대한 ServiceMethod
객체를 캐싱하고 이미 캐시된 경우 캐시에서 검색하여 재사용, 그렇지 않다면 새로 생성해서 캐시에 저장하는 방식으로 메모리 효율성을 높이고 동일한 메서드에 대한 반복 작업을 방지하고 있습니다.
그리고 ServieMethod
는 아래처럼 구성되어 있습니다.
abstract class ServiceMethod<T> {
static <T> ServiceMethod<T> parseAnnotations(Retrofit retrofit, Method method) {
RequestFactory requestFactory = RequestFactory.parseAnnotations(retrofit, method);
// 예외 처리하는 부분 생략...
return HttpServiceMethod.parseAnnotations(retrofit, method, requestFactory);
}
마지막으로 RequestFactory
의 parseAnnotation
부분을 봅시다.
final class RequestFactory {
static RequestFactory parseAnnotations(Retrofit retrofit, Method method) {
return new Builder(retrofit, method).build();
}
private final Method method;
private final HttpUrl baseUrl;
final String httpMethod;
...
RequestFactory(Builder builder) {
method = builder.method;
baseUrl = builder.retrofit.baseUrl;
httpMethod = builder.httpMethod;
...
}
static final class Builder {
...
final Retrofit retrofit;
final Method method;
final Annotation[] methodAnnotations;
final Annotation[][] parameterAnnotationsArray;
final Type[] parameterTypes;
...
Builder(Retrofit retrofit, Method method) {
this.retrofit = retrofit;
this.method = method;
this.methodAnnotations = method.getAnnotations();
this.parameterTypes = method.getGenericParameterTypes();
this.parameterAnnotationsArray = method.getParameterAnnotations();
}
...
}
....
}
이렇게 ServiceMethod
객체 또한 해당 API endpoint 에 대한 모든 정보를 담고 있습니다.
우리가 서비스 인터페이스에서 명시한 것들을 포함해서요!!
그리고 역시나 Builder, Factory 패턴을 사용해서 구현되고 있네요...
Method 객체에서 annotation 과 parameter 에 대한 정보를 가져올 때 getAnnotations()
, getGenericParameterTypes()
, getParameterAnnotations()
함수를 사용하고 있는 모습을 볼 수 있습니다.
이 함수들은 java.lang.reflect
패키지에 있는 함수들이죠.
Proxy 가 뭐야
다시 Retrofit
클래스의 create
함수로 돌아가봅시다.
이 메서드 내부에서 반환되는 Proxy.newProxyInstance
부분은 익명 객체(Anonymous Object)를 생성하는 코드입니다.
이 코드는 Java의 리플렉션(Reflection)과 동적 프록시(Dynamic Proxy)를 사용하여 인터페이스에 대한 구현을 동적으로 생성합니다.
위의 코드에서 익명 객체는 InvocationHandler
인터페이스를 구현하고, 이를 통해 프록시 객체의 메서드 호출을 처리합니다.
InvocationHandler
는 자바의 리플렉션을 활용하여 동적으로 메서드 호출을 가로채고 커스텀한 동작을 수행할 수 있도록 해주는 인터페이스입니다.
이 인터페이스를 구현하는 익명 객체를 사용하면 Proxy.newProxyInstance
메서드로 생성된 프록시 객체는 인터페이스의 모든 메서드 호출을 해당 익명 객체의 invoke
메서드로 전달하게 됩니다.
따라서 InvocationHandler
를 사용하면 동적으로 메서드 호출을 처리하고 커스텀 로직을 삽입할 수 있습니다.
이렇게 동적 프록시 객체를 생성하고 이를 통해 메서드 호출을 가로채는 패턴은 자바에서 AOP(Aspect-Oriented Programming)와 같은 기술을 구현하는 데 자주 사용됩니다.
이를 통해 메서드 호출 전, 후에 로깅, 보안 체크 등과 같은 부가 기능을 추가할 수 있습니다.
프록시 패턴은 디자인 패턴에서도 굉장히 유명하므로 따로 다시 정리해야 겠네요.
create 함수 내부의 validateServiceInterface
create 함수의 바디에서는 인자로 받은 service 가 유효한지에 대해 validationServiceInterface()
함수로 확인합니다.
private void validateServiceInterface(Class<?> service) {
if (!service.isInterface()) {
throw new IllegalArgumentException("API declarations must be interfaces.");
}
Deque<Class<?>> check = new ArrayDeque<>(1);
check.add(service);
while (!check.isEmpty()) {
Class<?> candidate = check.removeFirst();
if (candidate.getTypeParameters().length != 0) {
StringBuilder message =
new StringBuilder("Type parameters are unsupported on ").append(candidate.getName());
if (candidate != service) {
message.append(" which is an interface of ").append(service.getName());
}
throw new IllegalArgumentException(message.toString());
}
Collections.addAll(check, candidate.getInterfaces());
}
if (validateEagerly) {
Platform platform = Platform.get();
for (Method method : service.getDeclaredMethods()) {
if (!platform.isDefaultMethod(method) && !Modifier.isStatic(method.getModifiers())) {
loadServiceMethod(method);
}
}
}
}
서비스 인터페이스(service)가 유효한지 여부를 확인하고, Retrofit에서 처리 가능한 형태인지를 검사합니다.
Retrofit은 인터페이스에 타입 파라미터가 없어야 처리할 수 있습니다. 서비스 인터페이스 service 와 그 상위 인터페이스를 확인하고, 타입 파라미터가 있으면 예외를 던집니다.
위 코드가 조금 이해가 안 될 수도 있는데요, 먼저 while (!check.isEmpty())
쪽에서는 아래와 같이 동작합니다.
만약 validateServiceInterface
의 패러미터가 serviceA
이고 serviceA
인터페이스의 상위 계층이 위 왼쪽 그림과 같다면 오른쪽 Deque 그림처럼 동작합니다.
그리고 나서 Retrofit 이 호출되는 환경(Platform) 에 따라 다른 동작을 하도록 구현되어 있네요.
Platform 은 또 뭔데?
Platform 은 Retrofit 내부에서 사용되는 유틸리티 클래스입니다.
사실 Retrofit 은 안드로이드 뿐 아니라 Java 데스크톱 App. 처럼 다양한 Platform 에서 사용됩니다.
하지만 각 Platform 마다 네트워크 통신과 Reflection 관련 동작에 약간씩 차이가 있을 수 있습니다.
그래서 각 Platform 별 차이에 따라 동작을 다르게 조정하기 위해 사용됩니다. Platform 별 차이점을 캡슐화해서 Retrofit 을 여러 Platform 에서 일관되게 동작하도록 보장하는 데 사용되는 것이죠.
즉, Platform 클래스는 Retrofit의 내부에서 플랫폼별 작업을 추상화하고 Retrofit이 여러 플랫폼에서 효율적으로 동작하도록 돕는 역할을 합니다.
Retrofit 에서 서비스 인터페이스의 타입 패러미터가 없어야 하는 이유
Retrofit 가 서비스 인터페이스에 타입 파라미터가 없어야 처리할 수 있게끔 만들어진 것은 Retrofit이 타입 안정성을 유지하고 일관된 방식으로 HTTP 요청 및 응답을 처리하기 위한 결정이라고 합니다
이게 무슨 말이냐면
만약 서비스가 아래처럼 타입 패러미터를 가지고 있다면
interface FriendApi<T> {
@GET("friends")
suspend fun getAllFriends(
): Response<T>
}
우리는 이 getAllFriends
함수를 보고 어떠한 데이터가 리턴될지 예상할 수가 없습니다. 클라이언트 부분에서 다시 타입을 명시해주어야 하죠.
하지만 레트로핏 사용의 장점은 api 서비스 인터페이스만 보고도 어떤 동작을 하고 어떤 데이터를 리턴할지 쉽게 예상할 수 있는 것이라고 했죠!
즉, 레트로핏 라이브러리 개발에서는 아래처럼 인터페이스가 타입 패러미터를 가지지 않고, 리턴 타입을 명시하도록 만든 것입니다.
interface FriendApi {
@GET("friends")
suspend fun getAllFriends(
): Response<FriendList>
}
결론
이렇게 Retrofit
의 create
에 대한 구현을 천천히 뜯어보았습니다.
물론 Retrofit 는 매우 큰 라이브러리이고, 잘 만들어진 라이브러리이기 때문에 이렇게 내부 구조를 자세히 뜯어보지 않아도 매우 원활하게 필요한 기능을 추가하면서 사용할 수 있습니다.
하지만 이렇게 큰 라이브러리에 대해 뜯어보면서 공부하면 분명히 좋은 점이 있을 것 같아요.
훌륭한 라이브러리나 프레임워크에서 쓰였던 대부분의 디자인 패턴은 과거에서부터 소프트웨어 설계 시 반복적으로 발생하는 문제에 대해 반복적으로 적용할 수 있는 해결 방법이며 객체지향적으로, 유지보수성이 검증된 것입니다.
그래서 유명한 라이브러리에서 쓰인 패턴으로 참고하면서 디자인 패턴을 공부하려고 합니다.
그런 의미에서 다음 글에서는 프록시 패턴에 대해 공부해서 포스팅 해보려고 합니다. 아마 이 글에서의 Retrofit 의 코드도 참조할 것 같네요.
참고링크
chatgpt
모던 자바 인 액션
Retrofit 공식 문서