Retrofit Internals - Retrofit은 어떻게 인터페이스의 구현체를 만들까 ?

2025. 6. 16. 13:37·Android

서론

Retrofit을 사용해 네트워크 요청을 만들 때 서비스 인터페이스를 구현하는 것 만드로 API 요청을 구현할 수 있습니다. 어떻게 구현체가 없는 인터페이스를 실행 가능한 형태로 만드는 걸까요?  이 글에서는 Retrofit이 내부적으로 서비스 인터페이스의 구현체를 생성하는 과정과 이를 통해 API 요청을 수행하는지를 단계별로 살펴보겠습니다.


create( ) 메서드는 두 단계를 걸쳐 서비스 인터페이스의 구현체를 생성합니다. 이를 단계별로 분석해 보겠습니다.

Step1. 서비스 인터페이스 검증 (validServiceInterface())

Retrofit은. create()를 통해 전달된 클래스가 정상적인 인터페이스인지 먼저 확인하고 아닐 시 예외를 던집니다.

private void validateServiceInterface(Class<?> service) {
  if (!service.isInterface()) {
    throw new IllegalArgumentException("API declarations must be interfaces.");
  }
  // ...
}

 Step2. 동적 프록시로 구현체 만들기

Retrofit은 프록시와 리플렉션을 활용해 인터페이스 구현체를 자동 생성한다.

 

Retrofit은 내부적으로 프록시(Proxy)와 리플렉션(Reflection)을 사용해 서비스 인터페이스의 구현체를 런타임에 자동으로 생성합니다. 이 과정을 이해하려면 먼저 프록시와 리플렉션이 무엇인지부터 알아야 합니다.

# 프록시란?

프록시(Proxy)란 어떤 객체를 대신해서 동작하는 객체를 말합니다. Retrofit에서는 @GET 같은 애노테이션이 붙은 인터페이스의 추상 메서드를 대신 실행하는 객체가 필요하며 이 때 사용되는 객체가 프록시 입니다. Java에는 다양한 프록시 기법이 있으며 Retrofit은 동적 프록시(Dynamic Proxy)라는 방법을 사용합니다.

# 동적 프록시란?

동적 프록시는 미리 정의해둔 인터페이스의 정보를 통해 런타임에 해당 인터페이스의 구현체(프록시 객체)를 자동으로 생성하는 기술입니다. 일반적으로 인터페이스를 구현하기 위해선 개발자가 직접 클래스를 만들고, 그 안에서 모든 메서드를 일일이 구현해야 합니다.

예를 들어, View.OnClickListener 인터페이스를 구현할 때는 onClick() 메서드를 직접 오버라이드해야 합니다. 만약 이처럼 구현해야 할 인터페이스가 여러 개 존재한다면 각각의 구현체를 모두 작성하는 것은 번거로울 수 있습니다.

동적 프록시(Dynamic Proxy)는 이러한 수고를 줄이고, 인터페이스만 정의하면 런타임에 자동으로 해당 인터페이스를 구현하는 프록시 객체를 메모리 상에서 생성해 줍니다.

 

프록시 객체 메서드를 호출하면 내부에서 InvocationHandler가 모든 메서드 호출을 가로채고 처리하는 구조로 되어 있으며 호출된 메서드가 무엇인지, 어떤 인자가 전달되었는지는 런타임에 invoke() 메서드를 통해 모두 확인할 수 있습니다.

 

자바에서는 java.lang.reflect.Proxy 클래스의 newProxyInstance() 메서드를 통해 동적 프록시를 생성할 수 있으며, 이 메서드는 다음과 같은 세 가지 파라미터를 필요로 합니다.

Proxy.newProxyInstance(
    classLoader,                 // 프록시 클래스 로더
    new Class<?>[]{MyService.class}, // 구현할 인터페이스 목록
    invocationHandler            // 메서드 호출을 위임받을 핸들러
);

# ClassLoader

첫 번째 파라미터인 ClassLoader는 자바 클래스 파일을 JVM 메모리에 로딩해 주는 도구입니다. 컴파일된. class 파일을 읽어서 JVM의 메모리(Runtime Data Areas)에 올려 실행할 수 있도록 해줍니다.

❓ 그런데 왜 프록시를 만들 때 ClassLoader가 필요할까요?

Retrofit은 서비스 인터페이스를 기반으로 프록시 객체를 만듭니다. 이 프록시도 결국 하나의 새로운 클래스이기 때문에 JVM에 로드되어야 합니다.  즉, 프록시 객체 또한 JVM에 새로운 클래스처럼 등록해야 하기 때문에 ClassLoader가 필요합니다.

 

ClassLoader를 생성하기 위해선 다음과 같이 getClassLoader() 메서드를 사용하면 됩니다.

fun main(){
    val name = "Peto"
    val person = Person(name)

    println(name.javaClass.classLoader)
    // Null
    
    println(person.javaClass.classLoader) 
    // jdk.internal.loader.ClassLoaders$AppClassLoader
}

 

# Class<?>[] interfaces

프록시가 구현해야 할 인터페이스 목록입니다.

# InvocationHandler

InvocationHandler는 프록시 객체의 메서드 호출 시 실제로 작동하도록 위임받은 객체입니다. 프록시 객체의 어떤 메서드가 호출되더라도 내부적으로는 항상 invoke 메서드가 호출됩니다.

 

이 인터페이스는 SAM(Single Abstract Method) 인터페이스로 정의되어 있습니다. invoke 메서드는 프록시 인스턴스, 호출된 메서드 정보, 그리고 인자 배열을 전달받아 실제 로직을 처리하며 Retrofit에선 실제 네트워크 통신을 수행하게 됩니다.

/**
 * @param proxy 메서드가 호출된 프록시 인스턴스
 * @param method 호출된 메서드 (메서드 이름, 리턴 타입 등 포함)
 * @param args 메서드 호출 시 전달된 인자 배열. 인자가 없다면 null일 수 있음
 *
 * @return 프록시 메서드 호출의 결과. 반환값은 `method`의 리턴 타입과 호환되어야 함
 *
 * @throws Throwable 호출된 메서드에서 발생할 수 있는 예외.
 * 인터페이스 메서드에 선언된 예외 또는 런타임 예외이어야 함
 */
public interface InvocationHandler {
  public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

그렇다면 어떤 메서드가 호출되었는지 어떻게 구분할까요? 바로 두 번째 파라미터인 Method 객체를 통해 구분합니다.

 

Method 객체에는 호출된 메서드의 이름, 시그니처 등 모든 정보가 담겨 있어 if (method.name == "doSomething") 같은 방식으로 어떤 메서드가 호출되었는지 분기 처리할 수 있습니다.

# 리플렉션이란 ?

런타임에 객체의 프로퍼티와 메서드에 접근할 수 있는 방법입니다. Retrofit은 리플렉션 기능을 사용해 프록시가 구현한 메서드가 어떤 HTTP 요청인지, 어떤 시그니처를 사용하는지 분석합니다.

Retrofit.create 메서드의 내부 구현 중 174번째 라인을 보면 Platform.reflection을 통해 현재 실행 환경(Android, JVM 등)에 맞는 Reflection 구현체를 가져옵니다. 현재는 내부 구현을 보면 안드로이드를 반환하고 있습니다.

 

이후 리플렉션을 통해 메서드의 정보를 확인해 호출된 메서드가 인터페이스의 Default Method인지 판단합니다.

  • Default Method인 경우 : 리플렉션의 invokeDefaultMethod로 인터페이스에 구현된 코드 실행
  • Default Method가 아닌 경우: loadServiceMethod 메서드로 호출

# Retrofit.loadServiceMethod

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

 ... //
 
ServiceMethod<?> loadServiceMethod(Class<?> service, Method method) {
    while (true) {
      Object lookup = serviceMethodCache.get(method);

      if (lookup instanceof ServiceMethod<?>) {
        return (ServiceMethod<?>) lookup;
      }

      if (lookup == null) {
        Object lock = new Object();
        synchronized (lock) {
          lookup = serviceMethodCache.putIfAbsent(method, lock);
          if (lookup == null) {
            ServiceMethod<Object> result;
            try {
              result = ServiceMethod.parseAnnotations(this, service, method);
            } catch (Throwable e) {
              serviceMethodCache.remove(method);
              throw e;
            }
            serviceMethodCache.put(method, result);
            return result;
          }
        }
      }

      synchronized (lookup) {
        Object result = serviceMethodCache.get(method);
        if (result == null) {
          // The other thread failed its parsing. We will retry (and probably also fail).
          continue;
        }
        return (ServiceMethod<?>) result;
      }
    }
  }

Retrofit은 한 번 파싱 된 메서드 정보를 ConcurrentHashMap에 캐싱하여 재사용합니다.

 

ConcurrentHashMap은 멀티 스레드 환경에서 Thread-Safe 하게 사용할 수 있도록 만든 Map입니다. 내부적으로 동기화 처리가 되어있어 put, get, putIfAbsent 등의 기본 연산이 Thread-safe 하게 작동합니다.

 

loadServiceMethod 메서드에서는 캐싱된 메서드가 있으면 바로 반환하고, 캐싱된 정보가 없을 경우 ServiceMethod.parseAnnotations를 호출하여 파싱 한 뒤 캐시에 저장합니다.

 

이 과정에서 동시에 여러 스레드가 같은 Method에 접근하더라도 중복 파싱이 일어나지 않도록 잠금 객체(lock)를 사용해 동기화 처리합니다.

# ServiceMethod.parseAnnotations

parseAnnotations 메서드에선 3단계를 걸쳐 수행합니다.

 

1. RequestFactory 생성

RequestFactory requestFactory = RequestFactory.parseAnnotations(retrofit, service, method);

서비스 인터페이스 메서드의 어노테이션을 분석해 HTTP 메서드(GET, POST 등), URL, 쿼리/경로 파라미터 등을 모두 파악해 HTTP 요청을 위한 객체(RequestFactory)를 만듭니다.

 

2. 메서드 타입 검사

제네릭 타입 파라미터나 와일드카드가 포함되어 있으면 methodError()를 발생시키며 예외를 던지는데 런타임에 실제 응답을 어떤 타입으로 변환할지 명확하지 않기 때문입니다. 예를 들어, 오른쪽 예시 코드와 같이 Void나 Call<List<*>>, Call<T> 와 같이타입을 정확히 알 수 없는 경우에는 에러가 발생하게 됩니다.

또한 Retrofit은 void 반환 타입도 허용하지 않습니다. 이는 ‘반드시 응답 결과가 존재하고, 명시적으로 타입이 확정되어야 한다’는 Retrofit의 설계 원칙 때문입니다.

그런데 한 가지 의문이 드는 코드를 발견했습니다. method.getGenericReturType()는 리플렉션을 사용해 메서드의 제네릭 리턴 타입을 가져옵니다. 하지만 제네릭은 런타임에 타입 정보가 지워지는데 어떻게 제네릭 리턴 타입을 가져온다는 걸까요?

예시 코드를 통해 확인해 본 결과 런타임에도 메서드의 반환 타입에 포함된 제네릭 타입 파라미터 정보를 가져올 수 있습니다. 인터페이스나 클래스의 Class 객체를 가져오고 getMethod()를 통해 반환 타입을 알고 싶은 메서드를 가져온 다음, returnType이나 genericReturnRype을 사용하면 제네릭 타입 파라미터에 대한 정보를 가져올 수 있습나다.

 

returnType은 제네릭 정보가 소거된 raw type만 보여주고, genericReturnType은 Call <ProductResponse>처럼 제네릭 타입 전체를 포함한 정보를 제공합니다.

제네릭 타입 소거(Type Erasure)에도 불구하고 리플렉션을 통해 제네릭 타입을 알 수 있는 이유는, 자바의 리플렉션 API가 .class 파일의 Signature 메타 데이터를 읽을 수 있기 때문입니다. 자바에서는 컴파일된 .class 파일 안에 Signature라는 숨겨진 메타데이터 영역이 있습니다.

 

여기에 제네릭 타입 정보가 따로 보존되기 때문에, 런타임에 타입이 소거된다 하더라도 리플렉션을 사용해서 이 Signature 정보를 통해 제네릭 타입을 알 수 있습니다.

 

Signature는 javap이라는 도구를 사용해서 컴파일된 .class 파일을 분석할 수 있습니다.

 

터미널에 javap -v -s YourClass.class을 입력하면 확인할 수 있는데, 여기서 -v는 ‘verbose’의 약자로 클래스의 모든 내부 정보를 자세히 보여주겠다는 뜻이고, -s는 메서드의 시그니처(Signature) — 즉, 우리가 정의한 제네릭 타입 정보도 함께 보여달라는 옵션입니다.

 

이 명령어를 실행하면, 우리가 코드에서 작성한 제네릭 타입이 실제로는 클래스 파일에 Signature 항목으로 남아 있다는 걸 확인할 수 있습니다.

 

3. HttpServiceMethod 생성

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

마지막으로 Retrofit 인스턴스, 호출된 메서드, 그리고 모든 요청 정보를 담은 RequestFactory를 기반으로 HttpServiceMethod 객체를 생성합니다. HttpServiceMethod는 ServiceMethod를 상속하고 있으며 

 

이 객체는 서비스 인터페이스의 각 메서드에 하나하나에 대해서 실제 네트워크 요청을 수행하는 로직을 담당합니다. 즉, 동적 프록시가 메서드를 invoke 할  때 내부적으로 위임하는 대상입니다.

RequestFactory와 HttpServiceMethod의 이해를 위해 쉽게 비유하자면 RequestFactory가 어떤 음식을 주문할까?'를 적은 일종의 주문서라면, HttpServiceMethod는 이 주문서를 주방에 전달하고, 음식이 나오면 손님에게까지 전달해 주는 웨이터 같은 역할을 한다고 이해하시면 됩니다.

또한 코루틴을 사용했을 때와 사용하지 않았을 때를 분기하여 처리하게 되는데, 이 내용에 대해선 다음 글에서 소개하겠습니다.

최종 정리

  1.  Retrofit.create(MyService::class.java) 호출
  2. 전달된 클래스가 인터페이스인지 검사(validateServiceInterface)
  3. 동적 프록시를 사용해 서비스 인터페이스의 구현체 역할을 하는 프록시 객체 생성
  4. API 호출시 프록시 객체가 이를 가로채 InvocationHandler.invoke() 실행
  5. loadServiceMethod()를 통해 해당 메서드에 대한 처리 로직 결정
    5-1. 메서드가 호출된 적이 있다면, 캐싱된 ServiceMethod(HttpServiceMethod)를 그대로 사용
    5-2. 메서드가 호출된 적이 없다면, 어노테이션과 메타 정보들을 파싱 하여 HttpServiceMethod 객체를 생성 후 캐시에 저장

References

  • https://docs.oracle.com/javase/8/docs/api/java/lang/reflect/Proxy.html
  • https://github.com/square/retrofit
  • https://medium.com/jaesung-dev/android-retrofit2%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%98%EB%8A%94%EA%B0%80-1-%EB%82%B4%EB%B6%80-%EC%BD%94%EB%93%9C-%EB%B6%84%EC%84%9D-964f4b5d0a5d

'Android' 카테고리의 다른 글

안드로이드에서 네트워크 상태에 따라 API를 재호출해보자  (0) 2025.09.27
Retrofit Internals - Retrofit In Coroutine  (0) 2025.06.20
ViewModel의 One Time Event를 다루는 다양한 솔루션  (0) 2025.02.07
안드로이드 클린아키텍처 에러 핸들링 (부제: 상속을 지양하자)  (2) 2024.07.28
[Android]프로젝트를 클린 아키텍처로 마이그레이션해보자  (0) 2024.07.12
'Android' 카테고리의 다른 글
  • 안드로이드에서 네트워크 상태에 따라 API를 재호출해보자
  • Retrofit Internals - Retrofit In Coroutine
  • ViewModel의 One Time Event를 다루는 다양한 솔루션
  • 안드로이드 클린아키텍처 에러 핸들링 (부제: 상속을 지양하자)
빨주노초잠만보
빨주노초잠만보
  • 빨주노초잠만보
    과거의 나를 통해 미래의 나를 성장시키자
    빨주노초잠만보
  • 전체
    오늘
    어제
    • 분류 전체보기 (108)
      • 우아한테크코스 (6)
      • TEKHIT ANDROID SCHOOL (4)
      • Android Architecture (8)
      • Android (38)
      • PROJECT (11)
      • KOTLIN (10)
        • 코루틴의 정석 (3)
      • BACK END (12)
      • CS (4)
      • 컨퍼런스 (4)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    callbackflow
    coroutine Context Switching
    sealed class
    value class
    DataSource
    flow
    process Context Switching
    DI
    의존성 주입
    android clean architecture
    android Room
    Repository Pattern
    컴포즈 디자인 시스템
    MVI
    orbit
    repository
    안드로이드 디자인 시스템
    STATEFLOW
    thread Context Switching
    Room
    Compose Typography
    Throttle
    Two pass process
    코틀린 코루틴의 정석
    2025 우아콘 후기
    retrofit call
    ThrottleFirst
    view 생명주기
    Clean Architecture
    android view lifecylce
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
빨주노초잠만보
Retrofit Internals - Retrofit은 어떻게 인터페이스의 구현체를 만들까 ?
상단으로

티스토리툴바