아티클 목록으로 가기

Retrofit의 Proxy 패턴: 인터페이스에서 어떻게 인스턴스가 만들어지는 걸까? 그 마법의 비밀

skydovesJaewoong Eum (skydoves)||20분 소요

Retrofit의 Proxy 패턴: 인터페이스에서 어떻게 인스턴스가 만들어지는 걸까? 그 마법의 비밀

REST API는 현대 안드로이드 애플리케이션의 근간을 이루지만, Retrofit이 단순한 인터페이스 정의만으로 어떻게 실제 동작하는 구현체를 만들어 내는지 궁금해하신 분이 많을 것입니다. retrofit.create(GitHubApi.class)를 호출하고 곧바로 동작하는 API 클라이언트를 받아 올 수 있는 이유는 무엇일까요? 내부적으로 보면, HTTP 로직과 매개변수 처리, 응답 파싱까지 갖춘 완전한 구현 클래스가 런타임에 생성됩니다. 이렇게 마법처럼 보이는 변환은 Java의 동적 프록시(dynamic proxy) 메커니즘과 Retrofit의 정교한 어노테이션 파싱 및 캐싱 전략이 결합되어 가능한 것입니다.

이 글에서는 Retrofit 프록시 시스템의 내부 메커니즘을 심층적으로 살펴봅니다. Java의 Proxy.newProxyInstance()가 런타임에 구현 클래스를 생성하는 원리, InvocationHandler가 메서드 호출을 가로채 올바른 실행 경로로 라우팅하는 방식, Retrofit의 3상태 캐시(three-state cache)가 lock-free 빠른 경로(fast path)를 통해 스레드 안전한 지연 초기화를 달성하는 구조, 그리고 어노테이션 메타데이터가 실행 가능한 HTTP 요청으로 변환되는 과정까지 다룹니다. 이 글은 Retrofit의 사용법을 안내하는 글이 아니라, 인스턴스 생성의 마법이 실제로 어떻게 동작하는지를 깊이 파헤치는 글입니다.

근본적인 과제 이해하기: 인터페이스에서 인스턴스로

Retrofit이 해결하는 문제의 핵심은 언뜻 불가능해 보이는 과제, 즉 인터페이스로부터 인스턴스를 생성하는 것입니다. 표준 Java에서는 인터페이스를 직접 인스턴스화할 수 없습니다.

interface GitHubApi {
    @GET("users/{user}/repos")
    fun listRepos(@Path("user") user: String): Call<List<Repo>>
}

// 컴파일 불가
val api = GitHubApi() // ERROR: Cannot create an instance of an interface

인터페이스는 계약(contract)을 정의하는 것이지, 구현을 제공하는 것이 아닙니다. 그런데 Retrofit을 사용하면 다음과 같이 작성할 수 있습니다.

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .build()

val api = retrofit.create(GitHubApi::class.java)
val repos = api.listRepos("octocat").execute()

위 코드의 api 객체는 마치 누군가 HTTP 요청 처리, 매개변수 인코딩, 응답 파싱을 모두 수행하는 구현 클래스를 직접 작성한 것처럼 동작합니다. Retrofit은 어떻게 명시적인 코드 없이 이 구현체를 생성하는 걸까요?

그 답은 바로 **Java의 동적 프록시 패턴(dynamic proxy pattern)**에 있습니다. 이 패턴은 런타임 코드 생성 메커니즘으로, 인터페이스의 구현체를 실행 중에 즉석으로 만들어 냅니다. 이 패턴을 이해하는 것이 Retrofit 아키텍처를 이해하는 핵심입니다.

동적 프록시 패턴: 런타임 클래스 생성

Java의 java.lang.reflect.Proxy 클래스는 지정된 인터페이스를 런타임에 구현하는 프록시 인스턴스를 생성하는 메커니즘을 제공합니다. Retrofit의 구체적인 구현을 살펴보기 전에, 이 메커니즘이 어떻게 동작하는지 먼저 알아보겠습니다.

기본 프록시 메커니즘

Proxy.newProxyInstance() 메서드는 세 개의 매개변수를 받아 인터페이스를 구현하는 객체를 반환합니다.

public static Object newProxyInstance(
    ClassLoader loader,      // 프록시 클래스를 정의할 ClassLoader
    Class<?>[] interfaces,   // 구현할 인터페이스 목록
    InvocationHandler h      // 메서드 호출을 처리하는 핸들러
)

프록시 인스턴스에서 어떤 메서드를 호출하든, Java는 자동으로 해당 호출을 InvocationHandler.invoke() 메서드로 라우팅합니다. 이 메서드는 다음 정보를 전달받습니다.

  • 프록시 인스턴스 자체
  • 호출된 메서드를 나타내는 Method 객체
  • 메서드에 전달된 인자 배열

이 방식이 강력한 이유는, 하나의 핸들러로 인터페이스의 모든 메서드를 처리할 수 있기 때문입니다. 핸들러는 메서드의 어노테이션, 매개변수, 반환 타입을 검사하여 어떤 동작을 수행할지 결정할 수 있습니다.

다음은 간단한 예제입니다.

interface HelloService {
    String sayHello(String name);
    String sayGoodbye(String name);
}

HelloService service = (HelloService) Proxy.newProxyInstance(
    HelloService.class.getClassLoader(),
    new Class<?>[] { HelloService.class },
    new InvocationHandler() {
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) {
            String methodName = method.getName();
            String name = (String) args[0];
            return methodName + " called with: " + name;
        }
    }
);

service.sayHello("Alice");    // 반환값: "sayHello called with: Alice"
service.sayGoodbye("Bob");    // 반환값: "sayGoodbye called with: Bob"

하나의 invoke() 메서드가 sayHello()sayGoodbye() 모두를 처리합니다. 메서드 이름과 인자를 검사하여 동작을 결정하는 것입니다.

Retrofit도 정확히 이와 동일한 방식으로 동작합니다. 프록시 인스턴스를 생성하면 모든 메서드 호출이 InvocationHandler로 라우팅되고, 이 핸들러가 어노테이션을 파싱하여 HTTP 요청을 구성합니다.

Retrofit의 프록시 생성: create() 메서드

이제 Retrofit 클래스의 create() 실제 구현을 살펴보겠습니다.

@SuppressWarnings("unchecked")
public <T> T create(final Class<T> service) {
  validateServiceInterface(service);
  return (T)
      Proxy.newProxyInstance(
          service.getClassLoader(),
          new Class<?>[] {service},
          new InvocationHandler() {
            private final Object[] emptyArgs = new Object[0];

            @Override
            public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args)
                throws Throwable {
              // Object 클래스의 메서드인 경우 일반적인 호출로 위임
              if (method.getDeclaringClass() == Object.class) {
                return method.invoke(this, args);
              }
              args = args != null ? args : emptyArgs;
              Reflection reflection = Platform.reflection;
              return reflection.isDefaultMethod(method)
                  ? reflection.invokeDefaultMethod(method, service, proxy, args)
                  : loadServiceMethod(service, method).invoke(proxy, args);
            }
          });
}

이 코드는 Retrofit의 우아한 아키텍처를 여실히 보여줍니다. 각 구성 요소를 하나씩 분석해 보겠습니다.

1단계: 인터페이스 검증

프록시를 생성하기 전에, Retrofit은 validateServiceInterface() 메서드를 통해 인터페이스를 검증합니다.

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());
  }
  // ...
}

이 검증은 두 가지 중요한 제약 조건을 강제합니다.

1. 반드시 인터페이스여야 합니다. JDK 동적 프록시는 인터페이스에서만 동작합니다. 클래스를 전달하면 즉시 에러가 발생합니다. 이는 JVM 수준의 제약으로, 구체 클래스를 프록싱하려면 CGLIB이나 ByteBuddy 같은 바이트코드 생성 라이브러리가 필요합니다. Retrofit은 런타임 의존성의 규모를 최소화하기 위해 의도적으로 이러한 라이브러리 사용을 피하고 있습니다.

2. 제네릭 타입 매개변수를 가질 수 없습니다. interface Api<T> 형태의 인터페이스는 사용할 수 없습니다. 왜일까요? Java의 타입 소거(type erasure)로 인해 타입 매개변수는 런타임에 삭제됩니다. Api<User>가 있더라도 런타임 Class 객체는 User에 대한 정보가 전혀 없는 단순한 Api일 뿐입니다. 이렇게 되면 반환 타입을 올바르게 파싱하고 컨버터를 생성하는 것이 불가능해집니다. 검증 단계에서 이 제약을 미리 확인함으로써, 혼란스러운 런타임 동작 대신 명확한 에러를 조기에 제공합니다.

인터페이스 계층 구조를 너비 우선 탐색(BFS)으로 순회하는 Collections.addAll(check, candidate.getInterfaces()) 코드는 상속받은 인터페이스까지 이 규칙을 위반하지 않도록 보장합니다. 가령 GitHubApi extends BaseApi<User>로 선언했다면, 검증기가 BaseApi의 제네릭 매개변수를 감지하여 에러를 발생시킵니다.

2단계: 프록시 인스턴스 생성

검증을 마치면, Retrofit은 Proxy.newProxyInstance()로 프록시를 생성합니다. InvocationHandler 구현에는 세 가지 서로 다른 디스패치 경로를 갖는 핵심 라우팅 로직이 담겨 있습니다.

디스패치 경로 1: Object 메서드

if (method.getDeclaringClass() == Object.class) {
  return method.invoke(this, args);
}

Object로부터 상속받은 메서드, 즉 equals(), hashCode(), toString(), getClass() 등을 처리합니다. 이 메서드들은 HTTP 엔드포인트로 처리하는 대신 핸들러 자체에 위임됩니다.

이 검사가 왜 필요할까요? 이 검사가 없으면 api.toString()을 호출했을 때 Retrofit의 어노테이션 파싱 로직이 작동하게 되고, toString()에는 HTTP 어노테이션이 없으므로 파싱에 실패하여 예외가 발생합니다. this에 위임함으로써 프록시 인스턴스가 일반 Java 객체처럼 자연스럽게 동작할 수 있습니다.

한 가지 알아둘 점은, 동일한 Retrofit 인스턴스에서 생성한 두 프록시 인스턴스도 서로 동등하지 않다는 것입니다. 즉, api1.equals(api2)false를 반환합니다. 각 InvocationHandler가 별도의 익명 클래스 인스턴스이기 때문입니다.

디스패치 경로 2: default 인터페이스 메서드

Reflection reflection = Platform.reflection;
return reflection.isDefaultMethod(method)
    ? reflection.invokeDefaultMethod(method, service, proxy, args)
    : // ...

Java 8에서 도입된 default 메서드, 즉 구현이 포함된 인터페이스 메서드를 처리합니다. 예를 들어 다음과 같은 경우입니다.

interface GitHubApi {
    @GET("users/{user}/repos")
    fun listRepos(@Path("user") user: String): Call<List<Repo>>

    // default 메서드: 구현이 포함되어 있음
    fun getOctocatRepos(): Call<List<Repo>> {
        return listRepos("octocat")
    }
}

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기