본문 바로가기
Android

[Android/Kotlin] Retrofit2은 어떻게 Interface의 구현 없이 사용할 수 있을까?

by 옹구스투스 2022. 12. 11.
반응형


제가 가진 의문과 생각을 정리한 글입니다~!

틀린 내용이 있다면 알려주세요 :)


TL;DR

- 프로그래머는 Interface에 추상적인 API 함수들을 Retrofit Annotaion과 함께 정의해둔다.
- Retrofit.create(Class<T!>) 함수에 정의한 Interface의 Java Class 타입을 넘긴다. 반환 값은 해당 Interface의 구현체이다.
- 우리는 Interface의 구현체 내용을 작성하지 않았다. 이는 Retrofit이 담당한다.
- Java의 Reflection API에서 제공하는 newProxyInstance() 메서드를 이용해 동적으로 Proxy 인스턴스를 만든다.(Dynamic Proxy)
- 이 Proxy Instance는 우리가 정의한 Interface의 구현체이다.
- newProxyInstance의 3번째 인자인 InvocationHandler의 invoke함수는 프로그래머가 Interface에 정의한 함수들을 사용하고자 할 때 거치게 되는 함수로, 여기서 우리가 딸랑 Annotation만 붙여놓은 함수들의 실제 동작을 Retrofit이 정의한다. 그 부분이 LoadServiceMethod이다.
- LoadServiceMethod는 Annotation을 파싱하여 함수들의 실제 동작을 정의하고, 만들어진 함수는 캐싱 처리된다. 또한, 실제 POST, GET 등의 어노테이션을 파싱하는 것은 RequestFactory, Response와 Return 타입을 정의하는 것은 HttpsServiceMethod에 위임하는 등 세부적인 작업은 분리되어있다.
Retrofit2은 어떻게 Interface의 구현 없이 사용할 수 있을까? 직접 코드를 보면서 비밀을 파헤쳐보자.

Retrofit2을 사용할 때 APIService Interface를 정의한다.

하지만 이를 사용할 때 이 API Service Interface의 구현체를 직접 만든 적이 있었나??

없다.

우리는 Interface를 정의하고 사용할 때에는 Retrofit.create을 이용해서 Api Service의 구현체를 받아온다.

//Api Interface 정의
interface MyPageApiService {
    @DELETE(MEMBERS)
    suspend fun signOut(): Response<BaseResponse<String>>
}

    @Provides
    @Singleton
    fun provideMyPageApiService(
        @RetrofitModule.BearerRetrofit retrofit: Retrofit
    ): MyPageApiService {
        return retrofit.create(MyPageApiService::class.java)
    }
/** Make a DELETE request. */
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface DELETE {

Delete 어노테이션을 보면 RUNTIME까지 유지된다. 런타임에 파싱해서 써먹겠구나~!

Retrofit.create 함수의 매개변수를 보면 Class<T!> 라는 Java Class를 받는다.

여기에 우리는 리플렉션을 이용해 MyPageApiService::class.java로 java클래스로 바꾸어준다.

여기서 말하는 리플렉션(::)은 런타임의 클래스를 참조하는 기술이다.

클래스 리플렉션 : className::class
인스턴스를 통한 리플렉션 : instanceName::class

이렇게 KClass를 참조하여 .java를 이용해 create함수에서 원하는 자바 클래스를 넘겨준다. 

아래 Kotlin 공식 문서 내용처럼 KClass를 java Class로 변환해주는 확장 프로퍼티를 제공해 준다.

자 이제 Retrofit.create(Class<T!>)가 우리가 정의한 Interface의 구현체를 만들어주는 것을 알았다.

그럼 Retrofit.create(Class<T!>) 함수 내에서 어떤 일들이 일어날까??  

Create an implementation of the API endpoints defined by the service interface.

create 함수 코드에 달린 설명 첫 번째 줄의 내용이다.

API endpoints를 정의한 service interface의 구현체를 만들어준다!

위에서 알아본 내용과 동일하다.

그럼 create 함수를 뜯어보자.

public <T> T create(final Class<T> service) { // 우리가 넘겨준 MyPageApi Interface의 java Class
  validateServiceInterface(service);
  return (T) // T (MyPageApiService)로 타입 변환 후 반환 - 어떤 것을? 밑에서 Proxy로 만든 것을
      Proxy.newProxyInstance( // Proxy 인스턴스 생성
          service.getClassLoader(), // MyPageApi Class가 로드된 클래스로더 넘겨주기
          new Class<?>[] {service}, // MyPageApi Class 넘겨주기
          new InvocationHandler() { // 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);
            }
          });
}

위의 주석을 보면 일단 InvocationHandler의 구현 내용은 제쳐두고, newProxyInstance를 봐야 할 거 같다.

newProxyInstance 함수는 Java의 Refelction API에서 제공해주는 프록시 클래스를 만드는 함수이다.

우선 전체 코드이다.

/*
Returns an instance of a proxy class for the specified interfaces that
dispatches method invocations to the specified invocation handler.
Proxy.newProxyInstance throws IllegalArgumentException for the same reasons that Proxy.getProxyClass does.

Params:
	loader – the class loader to define the proxy class
	interfaces – the list of interfaces for the proxy class to implement
	h – the invocation handler to dispatch method invocations to

Returns:
a proxy instance with the specified invocation handler of a proxy class that is defined by the specified class loader and that implements the specified interfaces
*/

@CallerSensitive
public static Object newProxyInstance(ClassLoader loader,
                                      Class<?>[] interfaces,
                                      InvocationHandler h)
    throws IllegalArgumentException
{
    ~~~
}

이전 내용에서 넘긴 3가지 파라미터를 각각 loader, interfaces, InvocationHandler로 받아서 이 파라미터들을 이용해 동적으로 인스턴스를 만들어낸다.

         service.getClassLoader(), // MyPageApi Class가 로드된 클래스로더 가져오기
          new Class<?>[] {service}, // MyPageApi Class 넘겨주기
          new InvocationHandler() { // InvocationHandler 정의하여 넘겨주기

newProxyInstance 함수의 블럭 내용을 보자.

//1. java class 타입의 interface를 클론
final Class<?>[] intfs = interfaces.clone();

//2. getProxyClass : Proxy(java.lang.class) 클래스를 리턴
/*
 * Look up or generate the designated proxy class.
 */
Class<?> cl = getProxyClass0(loader, intfs);

/*
* Invoke its constructor with the designated invocation handler.
*/
try {

//3. 위에서 선언한 cl에 대한 생성자 받아오기
final Constructor<?> cons = cl.getConstructor(constructorParams);

//4. 배개변수로 받은 InvocationHandler를 ih 변수에 할당
final InvocationHandler ih = h;

//5. reflected된 객체에 접근 가능하게 설정
if (!Modifier.isPublic(cl.getModifiers())) {
    cons.setAccessible(true);
}

//6. 생성자를 통해 앞에서 정의한 InvocationHandler를 가진 인스턴스 반환
return cons.newInstance(new Object[]{h});

위 코드는 netProxyInstance의 전체 코드는 아니고코드 자체에 주석이 쳐져있는 부분, 예외 처리 부분은 제외하여 가독성을 높였다.

대략적으로 이해는 되지만 모든 코드의 의미를 파악하긴 어렵다.

그래도 클래스로더를 참조해 런타임에 해당 인터페이스의 구현체(Proxy Instance)를 만들어내는 것은 알겠다.

 

이제 아까 제껴두었던 newProxyInstance의 마지막 인자인 InvocationHandler의 구현부를 보자.

InvocationHandler는 invoke()라는 메소드 하나만 가지고 있는 인터페이스로, 동적으로 생성될 Proxy의 어떤 메소드가 호출되더라도 이 Invoke를 거치게 되고, 여기서 메소드의 기능을 확장할 수 있다.
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);
  }
});

가장 아래 return platform.isDefaultMethod 부분을 보자.

InvocationHandler의 invoke시에 method가 defaultMethod라면 invokeDefaultMethod를 반환하고,

아니라면 loadServiceMethod.invoke를 반환하고 있다.

 

그럼 Default Method는 뭐고 Platform은 뭘까??

Default Method란?
public non-abstract한 instance method로, 인터페이스에 정의되어있는 body가 있는 non-static method.
Java 8부터 Interface에도 body를 가진 Default Method를 지원한다.
Kotlin의 경우 default 키워드를 붙이지 않고도 body를 정의할 수 있다.
/**
 * Returns {@code true} if this method is a default
 * method; returns {@code false} otherwise.
 *
 * A default method is a public non-abstract instance method, that
 * is, a non-static method with a body, declared in an interface
 * type.
 *
 * @return true if and only if this method is a default
 * method as defined by the Java Language Specification.
 * @since 1.8
 */
public boolean isDefault() {
    // Android-changed: isDefault() implemented using Executable.
    return super.isDefaultMethodInternal();
}
Platform이란?
말 그대로 platform.
Android Platform 레벨에 접근하기 위한 클래스인 듯하다.
class Platform {
  private static final Platform PLATFORM = findPlatform();

  static Platform get() {
    return PLATFORM;
  }

  private static Platform findPlatform() {
    return "Dalvik".equals(System.getProperty("java.vm.name"))
        ? new Android() //
        : new Platform(true);
  }

즉, 우리가 Retrofit.create로 만든 API Interface의 구현체 (Proxy 클래스)의 함수를 호출하면 내부적으로 수행될 일이 InvocationHandler의 invoke에 위임되는데, default Method의 경우 그냥 호출, default Method가 아닌 경우 loadServiceMethod가 호출된다. 그럼 LoadServiceMethod에서 Interface에 어노테이션으로 의미를 부여해놓은 함수들의 실제 동작을 정의해놓았겠구나!

 

이제 LoadServiceMethod를 확인해 보자.

public final class Retrofit {
  private final Map<Method, ServiceMethod<?>> serviceMethodCache = new ConcurrentHashMap<>();

   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;
  }

loadServiceMethod는 Retrofit에 속한 함수이다.

첫 번째 줄에 serviceMethodCache라는 프로퍼티를 가지고 있는데, Retrofit 객체는 map을 이용해 메소드들을 캐싱 처리하여 loadServiceMethod 호출 시 캐시에 해당 메서드가 있다면 이를 사용하고, 없으면 parseAnnotations로 메소드를 만들어 set해주고 있다.

즉, 우리가 사용할 때 API 첫 번째 호출에는 새로 메서드를 생성하고, 두 번째 호출부터는 캐싱된 메서드를 가져온다.

 

자 이젠 serviceMethod.parseAnnotations를 들어가 보자.

abstract class ServiceMethod<T> {
  static <T> ServiceMethod<T> parseAnnotations(Retrofit retrofit, Method method) {
    RequestFactory requestFactory = RequestFactory.parseAnnotations(retrofit, method);

    Type returnType = method.getGenericReturnType();
    if (Utils.hasUnresolvableType(returnType)) {
      throw methodError(
          method,
          "Method return type must not include a type variable or wildcard: %s",
          returnType);
    }
    if (returnType == void.class) {
      throw methodError(method, "Service methods cannot return void.");
    }

    return HttpServiceMethod.parseAnnotations(retrofit, method, requestFactory);
  }

  abstract @Nullable T invoke(Object[] args);
}

여기에서 볼 것들은 requestFactory를 이용해 (PUT, POST, GET 등)어노테이션을 수집하고, 이를 HttpServiceMethod.parseAnnotations에 넘겨준다.

HttpServiceMethod.parseAnnotations에선 최종적으로 메서드의 Response, Return 타입들을 정의한다.

requestFactory 내부에는 실제로 PUT, POST 등의 어노테이션을 파싱하는 코드들이 있는데, 글에 코드들이 너무 많아서 스킵.

/** Adapts an invocation of an interface method into an HTTP call. */
abstract class HttpServiceMethod<ResponseT, ReturnT> extends ServiceMethod<ReturnT> {
  /**
   * Inspects the annotations on an interface method to construct a reusable service method that
   * speaks HTTP. This requires potentially-expensive reflection so it is best to build each service
   * method only once and reuse it.
   */
  static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
      Retrofit retrofit, Method method, RequestFactory requestFactory) {

주석을 보면 어노테이션을 검사하여 HTTP 서비스 메서드를 재사용 가능하게 만들고(캐싱), 이러한 과정은 고비용의 작업이기 때문에 한 번만 빌드하고 이후엔 재사용한다고 한다.

잘 찾아왔다.. 위에서 코드들을 보며 생각한 내용이 얼추 맞다. 

 

Retrofit2은 Dynamic Proxy 패턴을 이용하고 위에서 본 내용들이 해당 패턴을 구현한 내용이라고 볼 수 있다.

코드에서 동적으로 프록시 클래스를 생성하고 invocationHandler를 정의하는 모습을 볼 수 있었다.

만약 클래식한 Proxy 패턴이라면 명시적으로 프록시 클래스를 정의해야 한다..

 

우리가 만들 API Interface에 Proxy 패턴을 이용한다고 생각해 보자.

각각의 API Interface마다 Proxy 클래스를 만들어야 하고, API Interface의 구현체가 필요하기 때문에 API Interface의 함수들을 일일이 정의해준 다음 Proxy 클래스에서 사용하게 된다. 이걸 100번 해야 한다면? 10번만 넘어가도 피곤하다.

 

우리가 ViewModel에서 바로 DataSource를 호출하지 않고 Repository를 통해 실제 DataSourceImpl (DI로 Repository에 넣어준)을 사용하는 것도 요청을 DataSourceImpl에 위임하기 전에 추가 동작을 할 수 있으므로 일종의 Proxy 패턴이라 볼 수 있을 것 같다.

이 경우에는 Repository에 정의하는 추가 동작은 보통 각각 다른 일을 한다.

 

하지만 Retrofit의 경우? API Interface에 정의하는 함수들은 웬만해선 모두 API Call을 위한 API Endpoints 함수들이다.

따라서 각각의 Interface 구현체들을 구현할 필요 없이, Annotation으로 각 함수들이 할 일을 알려주고 이를 런타임에 Reflection을 통해 얻어와서 비슷한(API 함수들) 함수들을 찍어내는 일종의 팩토리를 이용해 모든 API 함수에 대응하는 InvocationHandler의 invoke를 정의하면서, 깔끔하게 Dynamic Proxy를 만들어내게 된다.

위에 코드에서 봤듯이 Retrofit.create 함수는 이렇게 많은 일을 함에도 몇 줄 되지 않는다. 엄청난 관심사 분리이고, 너무나도 적절한 솔루션이다.

Dynamic proxy classes are useful to an application or library that needs to provide type-safe reflective dispatch of invocations on objects that present interface APIs
Oracle Dynamic Proxy Docs

번외로 Dynamic Proxy는 Spring에서 많이 사용되고, 토비의 스프링에도 나오는 내용이라 한다.

 

또 추가적으로 Reflection은 동적으로 타입 분석하고 정보를 가져오므로 JVM 최적화를 할 수 없어 런타임 성능이 떨어진다고 한다.

본인도 Reflection : 런타임에 사부작사부작 -> 런타임 성능 bad라고 생각했으나 아래 글들에 따르면 단순히 Java Reflection API가 성능이 떨어진다라고만 생각할 건 아닌 것 같다.

 

https://jayjaylab.tistory.com/50

https://lob-dev.tistory.com/entry/Java%EC%9D%98-Reflection-API

 

마치며..

Retrofit2에 관한 궁금증을 해소하는 과정에서 Dynamic Proxy를 사용하여 적절하게 문제를 해결하는 Best Practice를 보는 과정에서 많은 인사이트를 얻을 수 있었다.

사실 내부 코드를 처음 볼 때는 하나도 이해할 수 없었지만, 시간을 두고 다시 보고, 레벨업하고 또다시 보고 하면서 점점 이해됐다.

다음에 다시 보면 더 깊고 다양한 인사이트를 얻을 수 있겠지. 이게 성장인가..?

마지막으로 내가 얻은 인사이트, 참조한 레퍼런스를 정리하며 글을 마친다.

 

인사이트

- 라이브러리는 이렇게 만드는구나.. 언젠가 오픈 소스 라이브러리를 만들고자 하는 목표에 한 발짝 내디딘 느낌

- skydoves님께서 오픈소스 작업에 가장 많은 리소스를 투자하는 게 전체적인 API에 대한 디자인이라 했던 말이 이해된다.

- 이것이 바로 문제 해결 능력.. 어떠한 문제를 해결하기 위해 적절한 솔루션을 적용하는 것을 보고 나도 조금 업그레이드되지 않았을까

- Annotation은 학습 우선순위가 낮았기에 대충 컴파일 타임에 어떤 작업에 써먹을 수 있겠고, 런타임에는 이렇게 써먹을 수 있겠구나 정도만 생각했다. 추후에 Annotation을 적용하기 위해 학습할 때 이 경험이 도움이 될 것이다.

- Default Method는 분명 처음 Kotlin 공부할 때 본 내용일 텐데 완전히 까먹고 있었다. Java는 오래돼서 그렇다 쳐도 Kotlin 처음 공부한지는 1년밖에 안됐는데..

- Reflection

- Proxy, Dynamic Proxy

- 남의 코드 분석 능력😎

- Java와 Kotlin을 같이 사용하는 프로젝트 경험이 없어서 아직 우선순위는 낮지만 언젠간 알아야겠지.. KClass, Java Class도 공부하자.

 

References

- https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.jvm/java.html 

- https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/proxy.html

- http://cris.joongbu.ac.kr/course/java/api/java/lang/reflect/Proxy.html

 

반응형

댓글