아티클 목록으로 가기

Retrofit의 내부 동작 메커니즘과 작동 원리 탐구

skydovesJaewoong Eum (skydoves)||27분 소요

Retrofit의 내부 동작 메커니즘과 작동 원리 탐구

안드로이드 개발에서 REST API 호출은 가장 기본적인 요구 사항 중 하나이지만, HTTP 요청 관리, 직렬화(serialization), 에러 처리, 스레드 관리의 복잡성은 오랫동안 개발자들에게 지속적인 과제였습니다. Retrofit은 Square에서 이 문제를 해결하기 위해 만든 라이브러리로, 장황하고 오류가 발생하기 쉬운 과정을 깔끔하고 우아한 어노테이션 기반 API로 변환해 줍니다. 하지만 Retrofit의 진정한 강점은 단순히 인터페이스를 간결하게 만들어 준다는 점이 아니라, 인터페이스 메서드를 HTTP 호출로 바꾸기 위해 내부적으로 동작하는 정교한 메커니즘에 있습니다.

이 글에서는 Retrofit의 내부 동작 메커니즘을 깊이 있게 살펴봅니다. Java의 동적 프록시(dynamic proxy)가 런타임에 구현 클래스를 생성하는 과정, 어노테이션이 정교한 잠금 전략을 사용하여 파싱 및 캐싱되는 방식, 프레임워크가 메서드 호출을 계층적 아키텍처를 통해 OkHttp 요청으로 변환하는 원리, 그리고 프로덕션 환경에서 안정적으로 동작하게 만드는 섬세한 최적화까지 다룹니다. Retrofit 사용법을 다루는 입문 가이드가 아니라, Retrofit이 실제로 어떻게 동작하는지 내부를 들여다보는 심층 분석이라고 할 수 있습니다.

핵심 추상화 이해하기: Retrofit을 특별하게 만드는 요소

Retrofit의 핵심은 **동적 프록시(dynamic proxy)**와 **어노테이션 처리(annotation processing)**를 활용하여 인터페이스 메서드 선언을 HTTP 요청으로 변환하는 타입 안전(type-safe) HTTP 클라이언트입니다. Retrofit을 수동 HTTP 클라이언트와 구별 짓는 것은 두 가지 근본 원칙, 즉 선언적(declarative) API 정의와 **플러거블 아키텍처(pluggable architecture)**를 충실히 따른다는 점입니다.

선언적 API 정의란 모든 엔드포인트에 대해 HTTP 요청을 수동으로 구성할 필요가 없다는 것을 의미합니다. 대신 Retrofit이 제공하는 어노테이션으로 요청을 기술하면 됩니다.

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

// 구현체는 자동으로 생성됩니다:
val api = retrofit.create<GitHubApi>()
val call = api.listRepos("octocat")

플러거블 아키텍처란 Retrofit이 팩토리 패턴(factory pattern)을 통해 관심사를 분리한다는 뜻입니다. 요청과 응답 처리의 모든 측면을 커스터마이징할 수 있습니다.

  • CallAdapter: Call<T>를 다른 타입으로 변환합니다 (RxJava Observable, 코틀린 suspend fun, Java 8 CompletableFuture)
  • Converter: 요청/응답 본문을 직렬화/역직렬화합니다 (Gson, Jackson, Moshi, Protobuf)
  • Call.Factory: HTTP 호출을 생성합니다 (일반적으로 OkHttp를 사용하지만 교체 가능)

이러한 특성은 단순한 편의 기능이 아니라, 컴파일 타임 타입 안전성과 런타임 유연성을 동시에 구현하기 위한 아키텍처적 제약 조건입니다. 동적 프록시 메커니즘 덕분에 Retrofit은 메서드당 어노테이션을 한 번만 파싱하고 결과를 캐싱할 수 있어, 이후 호출은 매우 빠르게 처리됩니다. 또한 팩토리 체인 구조를 통해 Retrofit 핵심 코드를 전혀 수정하지 않고도 Gson JSON 파싱이나 RxJava 통합을 추가할 수 있습니다.

동적 프록시 패턴: Retrofit이 구현체를 생성하는 방식

retrofit.create(MyApi.class)를 호출하면 수동으로 작성된 구현체를 받는 것이 아닙니다. 런타임에 모든 메서드 호출을 가로채는 **JDK 동적 프록시(JDK dynamic proxy)**를 받게 됩니다. 바로 이 메커니즘이 Retrofit의 "마법"을 실현하는 핵심 기반입니다.

Retrofit.create()에서의 프록시 생성

Retrofit 클래스의 실제 프록시 생성 코드를 살펴보겠습니다.

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

이 코드는 Java의 Proxy.newProxyInstance()를 사용하여 런타임에 인터페이스를 구현하는 클래스를 생성합니다. 모든 메서드 호출은 InvocationHandler.invoke() 메서드를 거치며, 세 가지 디스패치 경로로 분기됩니다.

1. Object 메서드: equals(), hashCode(), toString()과 같은 메서드는 핸들러 자체에 위임됩니다.

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

프록시 인스턴스에서 기본적인 Java 객체 연산이 올바르게 동작하도록 보장하는 코드입니다.

2. Default 메서드 (Java 8 이상): 인터페이스의 default 메서드는 플랫폼별 리플렉션(reflection)을 사용하여 호출됩니다.

return reflection.invokeDefaultMethod(method, service, proxy, args);

Java 8 이상에서 Retrofit은 MethodHandle을 사용하여 default 메서드를 호출합니다. 덕분에 API 인터페이스에 헬퍼 메서드를 추가하더라도 Retrofit이 해당 메서드를 HTTP 엔드포인트로 파싱하지 않습니다.

3. Retrofit 메서드: 나머지 모든 메서드는 HTTP 엔드포인트로 처리됩니다.

return loadServiceMethod(service, method).invoke(proxy, args);

이 부분에서 실질적인 작업이 이루어집니다. loadServiceMethod()가 어노테이션을 파싱하고 결과를 캐싱한 뒤, invoke()가 HTTP 요청을 실행합니다.

인터페이스 유효성 검증

프록시를 생성하기 전에 Retrofit은 Retrofit 클래스에서 엄격한 규칙으로 인터페이스를 검증합니다.

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 프록시로 프록시화할 수 없습니다 (클래스 기반 프록시에는 CGLIB이나 ByteBuddy가 필요합니다)
  2. 제네릭 타입 매개변수 사용 불가: interface Api<T>와 같은 선언은 금지됩니다. 런타임에 제네릭이 소거(erasure)되기 때문입니다

인터페이스 계층 구조를 너비 우선 탐색(BFS)으로 순회하면서, 상속된 인터페이스까지 포함하여 이러한 규칙을 위반하지 않는지 검증합니다.

프록시의 성능적 이점

컴파일 타임에 어노테이션 프로세싱으로 구현 클래스를 생성하는 대신, 왜 동적 프록시를 사용할까요? 그 이유는 유연성에 있습니다. 프록시 방식을 사용하면 Retrofit은 다음과 같은 이점을 얻습니다.

  • 어노테이션을 지연 파싱(lazy parsing)합니다 (메서드가 처음 호출될 때만 파싱)
  • CallAdapter 메커니즘을 통해 다양한 반환 타입을 지원합니다
  • 컴파일 타임 코드 생성의 복잡성을 회피합니다

트레이드오프는 첫 번째 메서드 호출 시 약간의 런타임 오버헤드(어노테이션 파싱)가 발생한다는 점이지만, 공격적인(aggressive) 캐싱을 통해 이 비용이 상각됩니다.

서비스 메서드 캐시: 지연 초기화

Retrofit의 가장 우아한 최적화 중 하나는 파싱된 어노테이션을 캐싱하는 방식입니다. Retrofit 클래스의 serviceMethodCache는 동시성 파싱을 처리하기 위해 **3상태 상태 머신(three-state state machine)**을 사용합니다.

3상태 캐시 설계

/**
 * 이 맵의 메서드 연관 항목은 세 가지 상태 중 하나입니다:
 * <ol>
 *   <li>값 없음 - 해당 메서드의 어노테이션 파싱을 아직 시작하지 않았습니다.</li>
 *   <li>Lock 객체 - 스레드가 파싱을 시작했습니다. 완료되면 파싱된 모델로
 *       맵이 업데이트됩니다.</li>
 *   <li>{@code ServiceMethod} - 어노테이션 파싱이 완전히 완료되었습니다.</li>
 * </ol>
 */
private final ConcurrentHashMap<Method, Object> serviceMethodCache = new ConcurrentHashMap<>();

여기서 핵심적인 통찰은 맵의 값 자체를 사용하여 파싱 상태를 추적한다는 점입니다.

  • 상태 1: 항목 없음 → 파싱 가능
  • 상태 2: Lock 객체 → 다른 스레드가 파싱 중이므로 대기 필요
  • 상태 3: ServiceMethod → 파싱 완료, 바로 사용 가능

별도의 상태 추적 없이 ConcurrentHashMap의 원자적(atomic) 연산을 활용하는 설계로, 매우 효율적인 동시성 제어가 가능합니다.

loadServiceMethod 구현 분석

내부 코드의 전체 파싱 로직을 살펴보겠습니다.

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

    // 초기 조회 또는 putIfAbsent가 다른 스레드의 잠금을 반환한 경우.
    // 해당 스레드의 파싱 완료를 대기
    synchronized (lookup) {
      Object result = serviceMethodCache.get(method);
      if (result == null) {
        // 다른 스레드가 실패한 경우. 재시도 (아마 동일하게 실패할 가능성이 높음)
        continue;
      }
      return (ServiceMethod<?>) result;
    }
  }
}

이 코드는 **lock-free 빠른 경로(fast path)**와 **동기화된 느린 경로(slow path)**를 결합한 정교한 구현입니다. 각 시나리오별 실행 흐름을 추적해 보겠습니다.

시나리오 1: 메서드의 첫 번째 호출 (캐시 항목 없음)

  1. lookup = serviceMethodCache.get(method)null
  2. lock 객체를 생성하고 동기화
  3. putIfAbsent(method, lock)null (성공)
  4. 잠금을 보유한 상태에서 어노테이션 파싱
  5. 결과를 캐시에 저장하여 lock 객체를 대체
  6. 파싱된 ServiceMethod 반환

시나리오 2: 동일 메서드에 대한 동시 첫 호출

스레드 A:

  1. null을 얻고, lockA를 생성하여 동기화
  2. putIfAbsent(method, lockA)null (성공)
  3. 어노테이션 파싱을 시작합니다...

스레드 B (A의 파싱 중에 실행):

  1. 캐시에서 lockA를 조회
  2. lockA에 대해 동기화 시도
  3. A의 파싱이 끝날 때까지 블로킹(blocking)
  4. A가 완료되면 B가 잠금 획득
  5. 캐시에서 ServiceMethod 조회
  6. 반환

시나리오 3: 이후 호출 (캐시 히트)

  1. lookup = serviceMethodCache.get(method)ServiceMethod
  2. lookup instanceof ServiceMethod<?>true
  3. 즉시 반환 (동기화 불필요)

이 설계의 아름다운 점은 빈번하게 사용되는 경로가 lock-free라는 것입니다. 메서드가 한 번 파싱되면 이후 모든 호출은 ConcurrentHashMap.get()만 수행하고 바로 반환합니다. ConcurrentHashMapvolatile 시맨틱이 스레드 간 가시성(visibility)을 보장하므로 별도의 동기화 없이도 안전합니다.

에러 처리와 재시도 로직

Retrofit 클래스의 에러 처리 부분에 주목하시기 바랍니다.

try {
  result = ServiceMethod.parseAnnotations(this, service, method);
} catch (Throwable e) {
  // 실패 시 잠금을 제거하여 다른 스레드가 재시도할 수 있도록 함
  serviceMethodCache.remove(method);
  throw e;
}

어노테이션 파싱이 실패하면(예를 들어 잘못된 어노테이션으로 인해) 잠금이 캐시에서 제거됩니다. 덕분에 다른 스레드나 동일 스레드가 다시 파싱을 시도할 수 있습니다. 이는 디버깅에 매우 유용합니다. 어노테이션을 수정한 후 앱을 재시작하지 않고도 해당 메서드를 다시 호출할 수 있기 때문입니다.

어노테이션 파싱: 메타데이터에서 실행 가능한 로직으로

이제 Retrofit이 어노테이션을 파싱하는 방식을 살펴보겠습니다. 이 과정은 두 단계로 이루어집니다. 메서드 수준 파싱(HTTP 동사, URL, 헤더)과 파라미터 수준 파싱(경로 파라미터, 쿼리 파라미터, 본문)입니다.

RequestFactory 빌더

진입점은 RequestFactory 클래스의 RequestFactory.parseAnnotations()이며, 빌더 패턴을 사용합니다.

static RequestFactory parseAnnotations(Retrofit retrofit, Class<?> service, Method method) {
  return new Builder(retrofit, service, method).build();
}

Builder.build() 메서드가 파싱을 조율합니다.

RequestFactory build() {
  for (Annotation annotation : methodAnnotations) {
    parseMethodAnnotation(annotation);
  }

  if (httpMethod == null) {
    throw methodError(method, "HTTP method annotation is required (e.g., @GET, @POST, etc.).");
  }

  // 유효성 검증 로직...

  int parameterCount = parameterAnnotationsArray.length;
  parameterHandlers = new ParameterHandler<?>[parameterCount];
  for (int p = 0, lastParameter = parameterCount - 1; p < parameterCount; p++) {
    parameterHandlers[p] =
        parseParameter(p, parameterTypes[p], parameterAnnotationsArray[p], p == lastParameter);
  }

  // 추가 유효성 검증...

  return new RequestFactory(this);
}

이 과정을 통해 요청을 구성하는 데 필요한 모든 메타데이터를 담은 불변(immutable) RequestFactory가 생성됩니다.

메서드 어노테이션 파싱

RequestFactory 클래스에서 HTTP 동사가 파싱되는 과정을 살펴보겠습니다.

private void parseMethodAnnotation(Annotation annotation) {
  if (annotation instanceof DELETE) {
    parseHttpMethodAndPath("DELETE", ((DELETE) annotation).value(), false);
  } else if (annotation instanceof GET) {
    parseHttpMethodAndPath("GET", ((GET) annotation).value(), false);
  } else if (annotation instanceof HEAD) {
    parseHttpMethodAndPath("HEAD", ((HEAD) annotation).value(), false);
  } else if (annotation instanceof PATCH) {
    parseHttpMethodAndPath("PATCH", ((PATCH) annotation).value(), true);
  } else if (annotation instanceof POST) {
    parseHttpMethodAndPath("POST", ((POST) annotation).value(), true);
  } else if (annotation instanceof PUT) {
    parseHttpMethodAndPath("PUT", ((PUT) annotation).value(), true);
  } else if (annotation instanceof OPTIONS) {
    parseHttpMethodAndPath("OPTIONS", ((OPTIONS) annotation).value(), false);
  } else if (annotation instanceof HTTP) {
    HTTP http = (HTTP) annotation;
    parseHttpMethodAndPath(http.method(), http.path(), http.hasBody());
  } else if (annotation instanceof retrofit2.http.Headers) {
    // 정적 헤더 파싱...
  } else if (annotation instanceof Multipart) {
    // 멀티파트 인코딩 설정...
  } else if (annotation instanceof FormUrlEncoded) {
    // 폼 인코딩 설정...
  }
}

hasBody 파라미터에 주목하시기 바랍니다. 이 값은 HTTP 메서드에 따라 결정됩니다. GET, DELETE, HEAD, OPTIONS는 기본적으로 요청 본문이 없고, POST, PUT, PATCH는 본문이 있습니다. @HTTP 어노테이션을 사용하면 이를 커스터마이징할 수 있습니다.

RequestFactory 클래스의 parseHttpMethodAndPath() 메서드는 URL 파라미터도 검증합니다.

private void parseHttpMethodAndPath(String httpMethod, String value, boolean hasBody) {
  if (this.httpMethod != null) {
    throw methodError(
        method,
        "Only one HTTP method is allowed. Found: %s and %s.",
        this.httpMethod,
        httpMethod);
  }
  this.httpMethod = httpMethod;
  this.hasBody = hasBody;

  if (value.isEmpty()) {
    return;
  }

  // 상대 URL 경로와 기존 쿼리 문자열 추출 (존재하는 경우)
  int question = value.indexOf('?');
  if (question != -1 && question < value.length() - 1) {
    // 쿼리 문자열에 이름 기반 파라미터가 없는지 확인
    String queryParams = value.substring(question + 1);
    Matcher queryParamMatcher = PARAM_URL_REGEX.matcher(queryParams);
    if (queryParamMatcher.find()) {
      throw methodError(
          method,
          "URL query string \"%s\" must not have replace block. "
              + "For dynamic query parameters use @Query.",
          queryParams);
    }
  }

  this.relativeUrl = value;
  this.relativeUrlParamNames = parsePathParameters(value);
}

이 검증은 쿼리 문자열에 {paramName} 플레이스홀더를 넣을 수 없도록 강제합니다. 동적 쿼리 파라미터는 반드시 @Query를 통해 추가해야 합니다. 흔히 발생하는 실수를 런타임이 아닌 앱 시작 시점에 잡아낼 수 있어 디버깅에 매우 유리합니다.

파라미터 어노테이션 파싱: 핸들러 패턴

각 메서드 파라미터에는 해당 파라미터를 요청에 적용하는 방법을 알고 있는 ParameterHandler가 할당됩니다. 파싱 로직은 RequestFactory 클래스에 있습니다.

private ParameterHandler<?> parseParameterAnnotation(
    int p, Type type, Annotation[] annotations, Annotation annotation) {
  if (annotation instanceof Url) {
    // @Url 파라미터 처리
    validateResolvableType(p, type);
    if (gotUrl) {
      throw parameterError(method, p, "Multiple @Url method annotations found.");
    }
    // ... 추가 유효성 검증
    gotUrl = true;
    return new ParameterHandler.RelativeUrl(method, p);

  } else if (annotation instanceof Path) {
    // @Path 파라미터 처리
    validateResolvableType(p, type);
    Path path = (Path) annotation;
    String name = path.value();
    validatePathName(p, name);

    Converter<?, String> converter = retrofit.stringConverter(type, annotations);
    return new ParameterHandler.Path<>(method, p, name, converter, path.encoded());

  } else if (annotation instanceof Query) {
    // @Query 파라미터 처리
    validateResolvableType(p, type);
    Query query = (Query) annotation;
    String name = query.value();
    boolean encoded = query.encoded();

    Class<?> rawParameterType = Utils.getRawType(type);
    if (Iterable.class.isAssignableFrom(rawParameterType)) {
      // List<String> 쿼리 파라미터 처리 - 반복 파라미터 생성
      ParameterizedType parameterizedType = (ParameterizedType) type;
      Type iterableType = Utils.getParameterUpperBound(0, parameterizedType);
      Converter<?, String> converter = retrofit.stringConverter(iterableType, annotations);
      return new ParameterHandler.Query<>(name, converter, encoded).iterable();
    } else if (rawParameterType.isArray()) {
      // String[] 쿼리 파라미터 처리 - 반복 파라미터 생성
      Class<?> arrayComponentType = boxIfPrimitive(rawParameterType.getComponentType());
      Converter<?, String> converter = retrofit.stringConverter(arrayComponentType, annotations);
      return new ParameterHandler.Query<>(name, converter, encoded).array();
    } else {
      // 단일 쿼리 파라미터 처리
      Converter<?, String> converter = retrofit.stringConverter(type, annotations);
      return new ParameterHandler.Query<>(name, converter, encoded);
    }
  } else if (annotation instanceof Body) {
    // @Body 파라미터 처리
    // ...
  }
  // ... 기타 파라미터 타입
  return null;
}

컬렉션에 적용되는 **데코레이터 패턴(decorator pattern)**을 눈여겨보시기 바랍니다. @Query("tag") List<String> tags로 선언하면 Retrofit은 기본 Query 핸들러를 .iterable() 데코레이터로 감쌉니다. 호출 시 리스트를 순회하며 각 요소에 기본 핸들러를 적용하여 ?tag=kotlin&tag=java&tag=android와 같은 반복 쿼리 파라미터를 생성합니다.

ParameterHandler 추상화

모든 파라미터 핸들러는 ParameterHandler 추상 클래스 ParameterHandler<T>를 상속합니다.

abstract class ParameterHandler<T> {
  abstract void apply(RequestBuilder builder, @Nullable T value) throws IOException;

  final ParameterHandler<Iterable<T>> iterable() {
    return new ParameterHandler<Iterable<T>>() {
      @Override
      void apply(RequestBuilder builder, @Nullable Iterable<T> values) throws IOException {
        if (values == null) return; // null 값은 건너뜀

        for (T value : values) {
          ParameterHandler.this.apply(builder, value);
        }
      }
    };
  }

  final ParameterHandler<Object> array() {
    return new ParameterHandler<Object>() {
      @Override
      void apply(RequestBuilder builder, @Nullable Object values) throws IOException {
        if (values == null) return; // null 값은 건너뜀

        for (int i = 0, size = Array.getLength(values); i < size; i++) {
          ParameterHandler.this.apply(builder, (T) Array.get(values, i));
        }
      }
    };
  }
}

**전략 패턴(strategy pattern)**이 실제로 적용된 모습입니다. 각 어노테이션 타입에는 파라미터 값을 요청 빌더에 적용하는 고유한 전략이 있습니다. ParameterHandler 클래스의 Path 핸들러 구체 구현을 살펴보겠습니다.

static final class Path<T> extends ParameterHandler<T> {
  private final Method method;
  private final int p;
  private final String name;
  private final Converter<T, String> valueConverter;
  private final boolean encoded;

  Path(Method method, int p, String name, Converter<T, String> valueConverter, boolean encoded) {
    this.method = method;
    this.p = p;
    this.name = Objects.requireNonNull(name, "name == null");
    this.valueConverter = valueConverter;
    this.encoded = encoded;
  }

  @Override
  void apply(RequestBuilder builder, @Nullable T value) throws IOException {
    if (value == null) {
      throw Utils.parameterError(
          method, p, "Path parameter \"" + name + "\" value must not be null.");
    }
    builder.addPathParam(name, valueConverter.convert(value), encoded);
  }
}

이 핸들러는 에러 리포팅을 위한 method와 파라미터 인덱스 p, 경로 파라미터 name(예: @Path("user")에서의 "user"), 그리고 파라미터 값을 문자열로 변환하는 Converter에 대한 참조를 보유합니다. apply()가 호출되면 값이 null이 아닌지 검증한 후(경로 파라미터는 null이 될 수 없습니다) RequestBuilder에 위임합니다.

요청 구성: 파라미터에서 OkHttp까지

어노테이션이 RequestFactory로 파싱되고 파라미터 값이 제공되면, Retrofit은 OkHttp Request를 구성합니다. 이 과정은 RequestFactory.create() 메서드에서 이루어집니다.

okhttp3.Request create(@Nullable Object instance, Object[] args) throws IOException {
  @SuppressWarnings("unchecked")
  ParameterHandler<Object>[] handlers = (ParameterHandler<Object>[]) parameterHandlers;

  int argumentCount = args.length;
  if (argumentCount != handlers.length) {
    throw new IllegalArgumentException(
        "Argument count (" + argumentCount + ") doesn't match expected count ("
        + handlers.length + ")");
  }

  RequestBuilder requestBuilder =
      new RequestBuilder(
          httpMethod,
          baseUrl,
          relativeUrl,
          headers,
          contentType,
          hasBody,
          isFormEncoded,
          isMultipart);

  if (isKotlinSuspendFunction) {
    // Continuation은 마지막 파라미터이며, handlers 배열에서 해당 인덱스는 null입니다.
    argumentCount--;
  }

  List<Object> argumentList = new ArrayList<>(argumentCount);
  for (int p = 0; p < argumentCount; p++) {
    argumentList.add(args[p]);
    handlers[p].apply(requestBuilder, args[p]);
  }

  return requestBuilder
      .get()
      .tag(Invocation.class, new Invocation(service, instance, method, argumentList))
      .build();
}

RequestBuilder(Retrofit이 OkHttp의 Request.Builder를 감싼 래퍼)를 생성하고, 모든 파라미터 핸들러를 적용한 뒤 최종 요청을 빌드합니다. 마지막의 .tag(Invocation.class, new Invocation(...))을 살펴보시기 바랍니다. 이 코드는 Retrofit 메서드 호출에 대한 메타데이터를 OkHttp 요청에 첨부합니다. 어떤 API 메서드가 해당 요청을 생성했는지 알고 싶은 로깅 인터셉터(interceptor)에서 유용하게 활용할 수 있습니다.

RequestBuilder 클래스

RequestBuilder 클래스는 URL 구성, 헤더 관리, 본문 인코딩의 세부 작업을 담당합니다.

final class RequestBuilder {
  private static final char[] HEX_DIGITS = {
    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
  };
  private static final String PATH_SEGMENT_ALWAYS_ENCODE_SET = " \"<>^`{}|\\?#";

  /**
   * 완전한 경로 세그먼트로 {@code .} 또는 {@code ..}을 포함하는 문자열과 매칭됩니다.
   * 경로 탐색(path traversal) 공격을 방지하기 위한 패턴입니다.
   */
  private static final Pattern PATH_TRAVERSAL = Pattern.compile("(.*/\)?\(\\.|%2e|%2E\){1,2}\(/.*)?");

  private final String method;
  private final HttpUrl baseUrl;
  private @Nullable String relativeUrl;
  private @Nullable HttpUrl.Builder urlBuilder;

  private final Request.Builder requestBuilder;
  private final Headers.Builder headersBuilder;
  private @Nullable MediaType contentType;

  private final boolean hasBody;
  private @Nullable MultipartBody.Builder multipartBuilder;
  private @Nullable FormBody.Builder formBuilder;
  private @Nullable RequestBody body;

  // ... 생성자 및 메서드
}

여기서 핵심적인 보안 기능은 PATH_TRAVERSAL 패턴입니다. 경로 파라미터를 추가할 때 Retrofit은 ..이나 . 세그먼트(퍼센트 인코딩된 형태인 %2E도 포함)를 검사하여 예외를 던집니다. 악의적인 사용자가 경로 파라미터로 ../../admin/delete와 같은 값을 전달하는 공격을 방지하기 위한 조치입니다.

경로 파라미터 치환

RequestBuilder 클래스의 addPathParam() 메서드에서 경로 파라미터가 치환되는 과정을 확인할 수 있습니다.

void addPathParam(String name, String value, boolean encoded) {
  if (relativeUrl == null) {
    // 첫 번째 쿼리 파라미터가 설정되면 상대 URL이 초기화됩니다.
    throw new AssertionError();
  }
  String replacement = canonicalizeForPath(value, encoded);
  String newRelativeUrl = relativeUrl.replace("{" + name + "}", replacement);
  if (PATH_TRAVERSAL.matcher(newRelativeUrl).matches()) {
    throw new IllegalArgumentException(
        "@Path parameters shouldn't perform path traversal ('.' or '..'): " + value);
  }
  relativeUrl = newRelativeUrl;
}

canonicalizeForPath() 메서드는 RFC 3986에 따라 특수 문자를 퍼센트 인코딩합니다. 예를 들어 공백은 %20으로, #%23으로 변환됩니다. encoded 플래그를 사용하면 이미 퍼센트 인코딩된 값의 경우 추가 인코딩을 건너뛸 수 있습니다. 슬래시를 포함하는 동적 경로 세그먼트를 처리할 때 유용합니다.

폼 인코딩과 멀티파트

@FormUrlEncoded 요청에서 Retrofit은 OkHttp의 FormBody.Builder를 사용합니다.

if (isFormEncoded) {
  formBuilder = new FormBody.Builder();
}

// 이후 @Field 파라미터가 적용될 때:
void addFormField(String name, String value, boolean encoded) {
  if (encoded) {
    formBuilder.addEncoded(name, value);
  } else {
    formBuilder.add(name, value);
  }
}

폼 빌더는 username=john&password=secret과 같은 application/x-www-form-urlencoded 본문을 생성합니다.

@Multipart 요청에서는 MultipartBody.Builder를 사용합니다.

if (isMultipart) {
  multipartBuilder = new MultipartBody.Builder();
  multipartBuilder.setType(MultipartBody.FORM);
}

// 이후 @Part 파라미터가 적용될 때:
void addPart(Headers headers, RequestBody body) {
  multipartBuilder.addPart(headers, body);
}

MIME 경계(boundary)가 포함된 multipart/form-data 본문을 생성하며, 이는 파일 업로드에 필수적입니다.

Call 실행: 동기, 비동기, 코루틴 경로

요청이 구성되면 실행이 필요합니다. Retrofit은 OkHttpCall 클래스에서 구현된 Call<T> 인터페이스를 통해 세 가지 실행 모드를 제공합니다.

OkHttpCall 구현

final class OkHttpCall<T> implements Call<T> {
  private final RequestFactory requestFactory;
  private final Object instance;  // 서비스 인터페이스 프록시 인스턴스
  private final Object[] args;    // 메서드 호출 인자
  private final okhttp3.Call.Factory callFactory;  // 일반적으로 OkHttpClient
  private final Converter<ResponseBody, T> responseConverter;

  private volatile boolean canceled;

  @GuardedBy("this")
  private @Nullable okhttp3.Call rawCall;

  @GuardedBy("this")
  private @Nullable Throwable creationFailure;

  @GuardedBy("this")
  private boolean executed;

  // ... 생성자
}

스레드 안전성 어노테이션과 volatile boolean canceled를 눈여겨보시기 바랍니다. 이 클래스는 안전한 동시 접근을 위해 설계되어 있으며, 한 스레드에서 cancel()을 호출하는 동안 다른 스레드에서 요청을 실행할 수 있습니다.

동기 실행

OkHttpCall 클래스의 execute() 메서드는 블로킹 HTTP 요청을 수행합니다.

@Override
public Response<T> execute() throws IOException {
  okhttp3.Call call;

  synchronized (this) {
    if (executed) throw new IllegalStateException("Already executed.");
    executed = true;

    call = getRawCall();
  }

  if (canceled) {
    call.cancel();
  }

  return parseResponse(call.execute());
}

동기화를 통해 OkHttpCall 인스턴스당 execute()가 한 번만 호출될 수 있도록 보장합니다. getRawCall() 메서드는 OkHttp 호출을 지연 생성합니다.

@GuardedBy("this")
private okhttp3.Call getRawCall() throws IOException {
  okhttp3.Call call = rawCall;
  if (call != null) return call;

  // 첫 번째 시도가 아닌 경우 이전 실패를 다시 던짐
  if (creationFailure != null) {
    if (creationFailure instanceof IOException) {
      throw (IOException) creationFailure;
    } else if (creationFailure instanceof RuntimeException) {
      throw (RuntimeException) creationFailure;
    } else {
      throw (Error) creationFailure;
    }
  }

  // 성공 또는 실패를 생성하고 기억
  try {
    return rawCall = createRawCall();
  } catch (RuntimeException | Error | IOException e) {
    throwIfFatal(e); // 치명적 에러는 creationFailure에 할당하지 않음
    creationFailure = e;
    throw e;
  }
}

이 코드는 성공과 실패 모두에 대한 **메모이제이션(memoization)**을 구현합니다. requestFactory.create()가 예외를 던지면(예를 들어 파라미터 변환 실패 시) 해당 예외가 저장되어, 이후 getRawCall() 호출 시 요청 생성을 재시도하지 않고 동일한 예외를 다시 던집니다. 에러 리포팅에 중요한 설계로, "Already executed" 에러가 아닌 원래의 예외를 받을 수 있습니다.

비동기 실행

OkHttpCall 클래스의 enqueue() 메서드는 논블로킹(non-blocking) HTTP 요청을 수행합니다.

@Override
public void enqueue(final Callback<T> callback) {
  Objects.requireNonNull(callback, "callback == null");

  okhttp3.Call call;
  Throwable failure;

  synchronized (this) {
    if (executed) throw new IllegalStateException("Already executed.");
    executed = true;

    call = rawCall;
    failure = creationFailure;
    if (call == null && failure == null) {
      try {
        call = rawCall = createRawCall();
      } catch (Throwable t) {
        throwIfFatal(t);
        failure = creationFailure = t;
      }
    }
  }

  if (failure != null) {
    callback.onFailure(this, failure);
    return;
  }

  if (canceled) {
    call.cancel();
  }

  call.enqueue(
      new okhttp3.Callback() {
        @Override
        public void onResponse(okhttp3.Call call, okhttp3.Response rawResponse) {
          Response<T> response;
          try {
            response = parseResponse(rawResponse);
          } catch (Throwable e) {
            throwIfFatal(e);
            callFailure(e);
            return;
          }

          try {
            callback.onResponse(OkHttpCall.this, response);
          } catch (Throwable t) {
            throwIfFatal(t);
            t.printStackTrace(); // TODO this is not great
          }
        }

        @Override
        public void onFailure(okhttp3.Call call, IOException e) {
          callFailure(e);
        }

        private void callFailure(Throwable e) {
          try {
            callback.onFailure(OkHttpCall.this, e);
          } catch (Throwable t) {
            throwIfFatal(t);
            t.printStackTrace();
          }
        }
      });
}

execute()와의 핵심적인 차이점은 요청 생성 실패가 예외를 던지는 대신 콜백을 통해 보고된다는 것입니다. 콜백은 기본적으로 OkHttp의 디스패처 스레드 풀에서 실행되지만, Retrofit에서 콜백 실행자(callback executor)를 커스터마이징할 수 있습니다. 안드로이드에서는 일반적으로 메인 스레드로 설정됩니다.

응답 파싱

OkHttpCall 클래스의 parseResponse() 메서드는 OkHttp의 원시 응답을 Retrofit의 타입 지정 응답으로 변환합니다.

Response<T> parseResponse(okhttp3.Response rawResponse) throws IOException {
  ResponseBody rawBody = rawResponse.body();

  // 응답을 전달할 수 있도록 본문의 소스(유일한 상태 객체)를 제거
  rawResponse =
      rawResponse
          .newBuilder()
          .body(new NoContentResponseBody(rawBody.contentType(), rawBody.contentLength()))
          .build();

  int code = rawResponse.code();
  if (code < 200 || code >= 300) {
    try {
      // 향후 I/O를 방지하기 위해 전체 본문을 버퍼링
      ResponseBody bufferedBody = Utils.buffer(rawBody);
      return Response.error(bufferedBody, rawResponse);
    } finally {
      rawBody.close();
    }
  }

  if (code == 204 || code == 205) {
    rawBody.close();
    return Response.success(null, rawResponse);
  }

  ExceptionCatchingResponseBody catchingBody = new ExceptionCatchingResponseBody(rawBody);
  try {
    T body = responseConverter.convert(catchingBody);
    return Response.success(body, rawResponse);
  } catch (RuntimeException e) {
    // 기본 소스에서 예외가 발생한 경우, 런타임 예외가 아닌
    // 해당 예외를 전파
    catchingBody.throwIfCaught();
    throw e;
  }
}

여기에는 몇 가지 세밀한 설계 포인트가 있습니다.

  1. 응답 본문 교체: 원본 ResponseBody가 읽기를 시도하면 예외를 던지는 NoContentResponseBody로 교체됩니다. 이렇게 하면 컨버터가 응답 스트림을 소비한 이후에 실수로 본문을 다시 읽는 것을 방지할 수 있습니다.

  2. 에러 본문 버퍼링: 에러 응답(2xx가 아닌 상태 코드)의 경우, 전체 본문이 메모리에 버퍼링됩니다. 에러 본문이 여러 번 읽힐 수 있기 때문입니다(예를 들어 로깅에 한 번, 에러 처리에 한 번). OkHttp 응답 본문은 일회성 스트림이므로 이러한 버퍼링이 필수적입니다.

  3. 204/205 처리: HTTP 204 No Content와 205 Reset Content 응답에는 본문이 없습니다. Retrofit은 이러한 상태 코드에 대해 자동으로 null을 반환하여, 컨버터 예외를 미연에 방지합니다.

  4. 예외 캐칭 래퍼: ExceptionCatchingResponseBody는 응답 본문 스트림을 래핑하고 읽기 도중 발생하는 IOException을 캡처합니다. 덕분에 컨버터 예외(예: 잘못된 형식의 JSON)와 네트워크 예외(예: 읽기 중 연결 재설정)를 구분할 수 있습니다.

ExceptionCatchingResponseBody

OkHttpCall 클래스 내부에 정의되어 있습니다.

static final class ExceptionCatchingResponseBody extends ResponseBody {
  private final ResponseBody delegate;
  private final BufferedSource delegateSource;
  @Nullable IOException thrownException;

  ExceptionCatchingResponseBody(ResponseBody delegate) {
    this.delegate = delegate;
    this.delegateSource =
        Okio.buffer(
            new ForwardingSource(delegate.source()) {
              @Override
              public long read(Buffer sink, long byteCount) throws IOException {
                try {
                  return super.read(sink, byteCount);
                } catch (IOException e) {
                  thrownException = e;
                  throw e;
                }
              }
            });
  }

  // ... 위임 메서드

  void throwIfCaught() throws IOException {
    if (thrownException != null) {
      throw thrownException;
    }
  }
}

데코레이터 패턴이 적용된 모습입니다. 래퍼는 모든 호출을 원본 응답 본문에 위임하면서 읽기 도중 발생하는 IOException을 가로챕니다. 컨버터가 RuntimeException을 던지면, Retrofit은 throwIfCaught()를 호출하여 해당 예외가 변환 에러가 아닌 I/O 에러로 인한 것인지 확인합니다.

컨버터 팩토리 체인: 직렬화와 역직렬화

Retrofit의 컨버터 시스템은 **책임 연쇄 패턴(chain of responsibility pattern)**을 사용하여 각 타입에 맞는 컨버터를 찾습니다. 컨버터는 Retrofit.Builder에 등록되며, 하나가 해당 타입을 처리할 때까지 순서대로 시도됩니다.

내장 컨버터

Retrofit은 BuiltInConverters 클래스에서 기본 타입에 대한 컨버터를 포함하고 있습니다.

final class BuiltInConverters extends Converter.Factory {
  @Override
  public @Nullable Converter<ResponseBody, ?> responseBodyConverter(
      Type type, Annotation[] annotations, Retrofit retrofit) {
    if (type == ResponseBody.class) {
      // 스트리밍 또는 버퍼링된 ResponseBody
      return Utils.isAnnotationPresent(annotations, Streaming.class)
          ? StreamingResponseBodyConverter.INSTANCE
          : BufferResponseBodyConverter.INSTANCE;
    }
    if (type == Void.class) {
      return VoidResponseBodyConverter.INSTANCE;
    }
    if (Utils.isUnit(type)) {
      return UnitResponseBodyConverter.INSTANCE;
    }
    return null;
  }

  @Override
  public @Nullable Converter<?, RequestBody> requestBodyConverter(
      Type type,
      Annotation[] parameterAnnotations,
      Annotation[] methodAnnotations,
      Retrofit retrofit) {
    if (RequestBody.class.isAssignableFrom(Utils.getRawType(type))) {
      return RequestBodyConverter.INSTANCE;
    }
    return null;
  }

  @Override
  public @Nullable Converter<?, String> stringConverter(
      Type type, Annotation[] annotations, Retrofit retrofit) {
    return null; // Retrofit의 toString() 폴백 사용
  }
}

각 컨버터 팩토리가 해당 타입을 처리할 수 없으면 null을 반환하는 방식을 눈여겨보시기 바랍니다. 이 반환값이 Retrofit에 체인의 다음 팩토리를 시도하라는 신호가 됩니다.

컨버터 조회 로직

컨버터 조회는 Retrofit 클래스에 구현되어 있습니다.

public <T> Converter<ResponseBody, T> responseBodyConverter(Type type, Annotation[] annotations) {
  return nextResponseBodyConverter(null, type, annotations);
}

public <T> Converter<ResponseBody, T> nextResponseBodyConverter(
    @Nullable Converter.Factory skipPast, Type type, Annotation[] annotations) {
  Objects.requireNonNull(type, "type == null");
  Objects.requireNonNull(annotations, "annotations == null");

  int start = converterFactories.indexOf(skipPast) + 1;
  for (int i = start, count = converterFactories.size(); i < count; i++) {
    Converter<ResponseBody, ?> converter =
        converterFactories.get(i).responseBodyConverter(type, annotations, this);
    if (converter != null) {
      //noinspection unchecked
      return (Converter<ResponseBody, T>) converter;
    }
  }

  StringBuilder builder =
      new StringBuilder("Could not locate ResponseBody converter for ")
          .append(type)
          .append(".\n");
  // ... 에러 메시지 구성
  throw new IllegalArgumentException(builder.toString());
}

skipPast 파라미터 덕분에 **위임 컨버터(delegating converter)**를 구현할 수 있습니다. 예를 들어 암호화를 추가하는 컨버터가 체인의 다음 컨버터에 위임할 수 있습니다.

@Override
public Converter<ResponseBody, ?> responseBodyConverter(...) {
  Converter<ResponseBody, ?> delegate = retrofit.nextResponseBodyConverter(this, type, annotations);
  return (ResponseBody body) -> decrypt(delegate.convert(body));
}

이 패턴은 긴밀한 결합 없이 컨버터를 조합(compose)할 수 있도록 해 줍니다.

파라미터용 문자열 컨버터

경로 파라미터, 쿼리 파라미터, 헤더의 경우, Retrofit은 값을 문자열로 변환해야 합니다. Retrofit 클래스의 stringConverter() 메서드를 살펴보겠습니다.

public <T> Converter<T, String> stringConverter(Type type, Annotation[] annotations) {
  Objects.requireNonNull(type, "type == null");
  Objects.requireNonNull(annotations, "annotations == null");

  for (int i = 0, count = converterFactories.size(); i < count; i++) {
    Converter<?, String> converter =
        converterFactories.get(i).stringConverter(type, annotations, this);
    if (converter != null) {
      //noinspection unchecked
      return (Converter<T, String>) converter;
    }
  }

  // 매칭되는 컨버터가 없으면 toString()을 호출하는 기본 컨버터로 폴백
  //noinspection unchecked
  return (Converter<T, String>) BuiltInConverters.ToStringConverter.INSTANCE;
}

폴백(fallback) 로직을 살펴보시기 바랍니다. 어떤 컨버터 팩토리도 해당 타입을 처리하지 못하면 Retrofit은 toString() 호출로 폴백합니다. 따라서 적절한 toString() 구현이 있는 타입이라면 경로/쿼리 파라미터에 어떤 타입이든 사용할 수 있습니다.

CallAdapter 팩토리 체인: 반환 타입 변환

Retrofit의 진정한 힘은 서비스 메서드에서 다양한 타입을 반환할 수 있는 능력에서 나옵니다. CallAdapter 인터페이스가 이러한 유연성을 실현합니다.

CallAdapter 계약

public interface CallAdapter<R, T> {
  /**
   * HTTP 응답 본문을 Java 객체로 변환할 때 이 어댑터가 사용하는 값 타입을 반환합니다.
   * 예를 들어 {@code Call<Repo>}의 응답 타입은 {@code Repo}입니다.
   */
  Type responseType();

  /**
   * {@code call}에 위임하는 {@code T}의 인스턴스를 반환합니다.
   */
  T adapt(Call<R> call);
}

두 타입 파라미터의 역할은 다음과 같습니다.

  • R: 어댑터가 기대하는 응답 타입 (예: Call<Repo>에서의 Repo)
  • T: 서비스 메서드가 반환하는 변환된 타입 (예: Observable<Repo>)

내장 CallAdapter

Retrofit은 DefaultCallAdapterFactory 클래스에서 Call<T>를 그대로 반환하는 기본 CallAdapter를 제공합니다.

final class DefaultCallAdapterFactory extends CallAdapter.Factory {
  private final @Nullable Executor callbackExecutor;

  DefaultCallAdapterFactory(@Nullable Executor callbackExecutor) {
    this.callbackExecutor = callbackExecutor;
  }

  @Override
  public @Nullable CallAdapter<?, ?> get(
      Type returnType, Annotation[] annotations, Retrofit retrofit) {
    if (getRawType(returnType) != Call.class) {
      return null;
    }
    if (!(returnType instanceof ParameterizedType)) {
      throw new IllegalArgumentException(
          "Call return type must be parameterized as Call<Foo> or Call<? extends Foo>");
    }

    final Type responseType = getParameterUpperBound(0, (ParameterizedType) returnType);

    final Executor executor =
        Utils.isAnnotationPresent(annotations, SkipCallbackExecutor.class)
            ? null
            : callbackExecutor;

    return new CallAdapter<Object, Call<?>>() {
      @Override
      public Type responseType() {
        return responseType;
      }

      @Override
      public Call<Object> adapt(Call<Object> call) {
        return executor == null ? call : new ExecutorCallbackCall<>(executor, call);
      }
    };
  }
}

ExecutorCallbackCall 래퍼는 콜백이 지정된 실행자(executor)에서 실행되도록 보장합니다. 안드로이드에서는 일반적으로 메인 스레드가 실행자로 설정됩니다. 비동기 API 호출 콜백에서 UI를 안전하게 업데이트할 수 있도록 Retrofit이 제공하는 메커니즘이 바로 이것입니다.

코틀린 코루틴 지원

Retrofit은 코틀린 suspend 함수에 대한 일급(first-class) 지원을 제공합니다. 감지는 HttpServiceMethod.parseAnnotations() 메서드에서 이루어집니다.

static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
    Retrofit retrofit, Method method, RequestFactory requestFactory) {
  boolean isKotlinSuspendFunction = requestFactory.isKotlinSuspendFunction;
  boolean continuationWantsResponse = false;
  boolean continuationIsUnit = false;

  Annotation[] annotations = method.getAnnotations();
  Type adapterType;
  if (isKotlinSuspendFunction) {
    Type[] parameterTypes = method.getGenericParameterTypes();
    Type responseType =
        Utils.getParameterLowerBound(
            0, (ParameterizedType) parameterTypes[parameterTypes.length - 1]);
    if (getRawType(responseType) == Response.class && responseType instanceof ParameterizedType) {
      // suspend fun getUser(): Response<User>
      responseType = Utils.getParameterUpperBound(0, (ParameterizedType) responseType);
      continuationWantsResponse = true;
    } else {
      continuationIsUnit = Utils.isUnit(responseType);
    }

    adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
    annotations = SkipCallbackExecutorImpl.ensurePresent(annotations);
  } else {
    adapterType = method.getGenericReturnType();
  }

  // ... CallAdapter와 컨버터 생성
}

코틀린 suspend 함수는 추가 Continuation 파라미터가 있는 일반 Java 메서드로 컴파일됩니다. Retrofit은 이를 감지하여 세 가지 HttpServiceMethod 구현 중 하나를 반환합니다.

  1. SuspendForResponse: suspend fun getUser(): Response<User>의 경우, 전체 Response 래퍼를 반환
  2. SuspendForBody: suspend fun getUser(): User의 경우, 본문만 반환
  3. CallAdapted: suspend가 아닌 메서드의 경우, Call<User> 또는 변환된 타입을 반환

실제 일시 중단(suspension)은 코틀린의 suspendCancellableCoroutine을 사용하여 KotlinExtensions에서 수행됩니다.

suspend fun <T> Call<T>.await(): T {
  return suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
      cancel()
    }
    enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        if (response.isSuccessful) {
          val body = response.body()
          if (body == null) {
            val invocation = call.request().tag(Invocation::class.java)!!
            val method = invocation.method()
            val e = KotlinNullPointerException(
                "Response from " +
                    method.declaringClass.name +
                    "." +
                    method.name +
                    " was null but response body type was declared as non-null")
            continuation.resumeWithException(e)
          } else {
            continuation.resume(body)
          }
        } else {
          continuation.resumeWithException(HttpException(response))
        }
      }

      override fun onFailure(call: Call<T>, t: Throwable) {
        continuation.resumeWithException(t)
      }
    })
  }
}

이 코드는 콜백 기반 Call.enqueue() API를 suspend 함수로 변환합니다. invokeOnCancellation 훅을 통해 코루틴을 취소하면 HTTP 요청도 함께 취소되도록 보장합니다. 코루틴의 구조화된 동시성(structured concurrency) 원칙에 부합하는 설계입니다.

성능 특성과 설계 트레이드오프

Retrofit은 네트워크 코드를 최적화하거나 성능 이슈를 디버깅할 때 이해해 두면 유용한 몇 가지 성능 관련 트레이드오프를 가지고 있습니다.

어노테이션 파싱 비용

어노테이션 파싱은 메서드가 처음 호출될 때 지연 실행됩니다. 일반적인 서비스 메서드의 경우, 현대 기기에서 파싱에 약 1~5ms가 소요됩니다. 이 비용은 다음 이유로 상각됩니다.

  1. 파싱 결과가 serviceMethodCache에 메서드별로 캐싱됩니다
  2. 동일 메서드에 대한 이후 호출은 lock-free 조회로 수행됩니다 (나노초 단위)
  3. 대부분의 앱은 동일한 엔드포인트를 반복적으로 호출하므로 캐싱이 효과적입니다

서로 다른 수백 개의 엔드포인트를 각각 한 번씩만 호출하는 앱(드문 경우)에서는 즉시 검증(eager validation)을 활성화할 수 있습니다.

Retrofit retrofit = new Retrofit.Builder()
    .validateEagerly(true)  // create() 시점에 모든 메서드 파싱
    .build();

파싱 비용을 앱 시작 시점으로 옮기는 방법으로, 어노테이션 관련 에러를 조기에 발견하는 데 유용합니다.

동적 프록시 오버헤드

JDK 동적 프록시는 모든 메서드 호출에 약간의 오버헤드를 추가하며, 프록시 디스패치에 일반적으로 1050나노초가 소요됩니다. 네트워크 지연 시간(일반적으로 50500밀리초)에 비하면 무시할 수 있는 수준이지만, 완전히 무료는 아닙니다.

그러나 이 트레이드오프는 유연성과 컴파일 타임 타입 안전성을 위해 충분히 감수할 만합니다. 어노테이션 프로세서와 같은 대안을 사용하면 API 정의를 변경할 때마다 재컴파일이 필요하기 때문입니다.

불변성을 통한 스레드 안전성

대부분의 Retrofit 클래스는 **생성 후 불변(immutable)**입니다.

  • Retrofit: Builder.build() 이후 불변
  • RequestFactory: 파싱 이후 불변
  • ServiceMethod: 파싱 이후 불변
  • ParameterHandler 인스턴스: 생성 이후 불변

가변 상태는 OkHttpCall에만 존재합니다.

private volatile boolean canceled;
@GuardedBy("this") private @Nullable okhttp3.Call rawCall;
@GuardedBy("this") private @Nullable Throwable creationFailure;
@GuardedBy("this") private boolean executed;

@GuardedBy 어노테이션과 volatile을 활용한 가시성 보장으로, 핫 패스(hot path)에서 고비용 동기화 없이도 스레드 안전한 접근이 가능합니다.

컨버터 및 어댑터 팩토리 조회

컨버터와 어댑터 팩토리 모두 팩토리 리스트를 **선형 탐색(linear search)**하여 조회합니다. 2~5개 팩토리를 사용하는 일반적인 앱에서는 전혀 문제가 되지 않습니다. 만약 수십 개의 팩토리를 사용한다면, 사용 빈도가 높은 순서대로 배치하는 것이 좋습니다.

팩토리 조회는 어노테이션 파싱 시 메서드당 한 번만 발생하므로, 캐싱을 통해 비용이 상각됩니다.

결론

이 글에서는 Retrofit의 내부 동작 메커니즘을 살펴보며, 동적 프록시, 지연 어노테이션 파싱, 플러거블 팩토리의 정교한 조합을 통해 인터페이스 메서드가 HTTP 요청으로 변환되는 과정을 분석했습니다. 런타임에 구현 클래스를 생성하는 Proxy.newProxyInstance() 호출부터, lock-free 캐시 조회를 가능하게 하는 3상태 serviceMethodCache, 메서드 파라미터를 요청에 적용하는 ParameterHandler 전략 패턴에 이르기까지, Retrofit은 개발자 경험과 런타임 성능 모두에 대한 세심한 배려를 보여줍니다.

내부 구현을 분석하면 몇 가지 핵심 설계 원칙이 드러납니다. 빈번한 반복 호출의 성능을 높이기 위한 **지연 초기화(lazy initialization)**와 공격적 캐싱, 동기화 오버헤드 없이 스레드 안전성을 확보하기 위한 불변성(immutability), 플러거블 직렬화와 적응을 위한 팩토리 체인(factory chain), 그리고 조합 가능한 기능을 위한 **데코레이터 패턴(decorator pattern)**이 그것입니다. 어노테이션 파싱은 런타임 리플렉션에 의존하는 대신 첫 호출 시점에 에러를 검증하며, 동적 프록시 메커니즘은 코드 생성 없이도 컴파일 타임 타입 안전성을 실현합니다.

아티클 목록으로 가기