Droidcon jake cover

Retrofit 2과 함께하는 정말 쉬운 HTTP

Retrofit은 여러 해에 걸쳐 HTTP call을 단순화해왔으며, 이번 2.0 버전도 마찬가지입니다. 오랜 불편함을 고쳤을 뿐만 아니라, Retrofit 2.0을 어느 때보다 강력하게 하는 여러 새로운 기능들이 추가되었습니다. Droidcon NYC 2015의 이번 강연에서 Jake Wharton은 HTTP 스택에 대해 깊이 이해할 수 있도록 이런 새 기능들과 OkHttp와 Okio API와의 통합에 관해 이야기했습니다.



내년 3월의 Droidcon SF를 위해 시간을 비워 두세요 — 안드로이드 생태계의 리더들이 제공하는 최고 클래스의 프리젠테이션이 포함된 컨퍼런스입니다.


소개 (0:00)

제 이름은 Jake Wharton이고 Square사에 근무합니다. 한 순진한 사람이 언젠가 이렇게 말했죠, “Retrofit 2는 올해가 가기 전에 출시될 겁니다.” 눈치채셨겠지만 그 사람은 지난해 Droidcon에서의 저입니다. 어쨌거나 Retrofit 2는 올해 말 안에 출시될 것이고, 저는 그 일에 전념하고 있습니다.

Retrofit은 5년 전에 오픈되었고, Square사의 가장 오래된 오픈 소스 프로젝트 중 하나입니다. Retrofit은 우리의 오픈 소스 앱에서 사용하던 여러 가지 다른 툴들을 묶은 것으로 시작했고, 그 안에는 흔들기 감지와 HTTP client, 그리고 현재는 tape 라이브러리가 되었던 도구 등이 포함됐습니다. 이 중 대부분은 Bob Lee가 개발했고, 저는 약 3년 전부터 라이브러리 관리를 맡았습니다. 오픈 소스가 된 지 3년 만에 1.0 버전을 배포했고, 그중의 일부는 2년 전 Google IO의 서곡이 된 오픈 소스의 일주일을 포함한 것입니다. 이후 우리는 2년 동안 배포를 18번 했습니다.

Retrofit 1: 장점 (2:23)

Retrofit에는 여러 훌륭한 장점이 있습니다. 그중 하나는 Retrofit이 인터페이스와 메서드, 그리고 어떻게 요청이 생성됐는지 서술적으로 정의하는 매개 변수 어노테이션을 사용한다는 점입니다. GitHub API에 전달하는 예제를 살펴 볼까요?

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  List<Contributor> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

Retrofit과 JSON이나 XML 프로토콜 버퍼 등의 시리얼라이제이션 메커니즘에 기반을 둔 HTTP client는 플러그형(pluggable)이므로 여러분이 원하는 것을 고를 수 있습니다. Retrofit이 등장했을 때는 Apache HTTP client만을 사용할 수 있었고, 1.0 버전을 배포하기 전에 URL 연결과 OkHttp 지원을 구현했습니다. 커스텀 client를 사용하는 앱 엔진 지원이 가능하므로 이 세 개 이외의 어떤 HTTP client라도 원하는 대로 사용할 수 있다는 점이 Retrofit의 큰 장점입니다.

builder.setClient(new UrlConnectionClient());
builder.setClient(new ApacheClient());
builder.setClient(new OkClient());

builder.setClient(new CustomClient());

시리얼라이제이션 역시 플러그형입니다. 기본으로는 GSON을 사용하지만, JSON을 사용하는 분이라면 Jackson으로 교체할 수도 있습니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  List<Contributor> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

builder.setConverter(new GsonConverter());
builder.setConverter(new JacksonConverter());

프로토콜 버퍼 같은 것을 사용한다면 wire와 구글의 protobuf converter를 사용하거나 XML을 사용할 수도 있습니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  ContributorResponse repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

builder.setConverter(new ProtoConverter());
builder.setConverter(new WireConverter());

builder.setConverter(new SimpleXMLConverter());

builder.setConverter(new CustomConverter());

client 역시 플러그형입니다. 여러분의 시리얼라이제이션 라이브러리를 쓸 수도 있으며, 원하는 대로 커스텀화할 수도 있습니다.

요청을 만들 때도 여러 가지 방법을 지원합니다. 동기적으로 사용하는 예제를 볼까요?

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  List<Contributor> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
} 

List<Contributor> contributors =
    gitHubService.repoContributors("square", "retrofit");

마지막 매개 변수로 콜백을 구현해서 비동기적인 사용도 가능합니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  void repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo,
      Callback<List<Contributor>> cb);
} 

service.repoContributors("square", "retrofit", new Callback<List<Contributor>>() {
  @Override void success(List<Contributor> contributors, Response response) {
    // ...
  }

  @Override void failure(RetrofitError error) {
    // ...
  }
});

1.0 이후에는 인기 높은 RxJava도 지원합니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Observable<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
} 

gitHubService.repoContributors("square", "retrofit")
    .subscribe(new Action1<List<Contributor>>() {
      @Override public void call(List<Contributor> contributors) {
        // ...
      }
    });

Retrofit 1: 아쉬운 점 (4:58)

슬프지만 결점없는 라이브러리가 없듯, Retrofit도 예외는 아닙니다. 플러그형 client의 지원을 위해 라이브러리의 퍼블릭 API에 내장한 클래스들이 골칫거리가 되었는데, 부분적으로는 라이브러리가 매우 연약하게 되었기 때문이고, 퍼블릭 API를 수정할 수 없었기 때문이기도 했습니다. 대신에 URL, 헤더, 응답 코드, 메시지 등을 포함한 요청과 응답 타입을 보유했습니다. 그리고 요청과 응답의 바디 부분 구현을 지원하기 위해 인풋과 아웃풋의 형식, 기본적으로는 content 타입과 바디의 길이 등을 고정해서 읽기 쓰기를 가능하게 했습니다. 퍼블릭 API에는 이렇게 인풋과 아웃풋을 정형화해서 구현한 코드들이 많은데 아쉽게도 변형할 수 없습니다. 헤더나 URL 등의 응답 데이터와 파싱된 바디에 접근하는 것도 불가능합니다.

이런 개발 뉴스를 더 만나보세요

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  List<Contributor> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);

  @GET("/repos/{owner}/{repo}/contributors")
  Response repoContributors2(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

contributor 목록을 보여주는 이 GitHub 예제는 여러분이 어느 converter를 사용하던 디시리얼라이즈될 수 있습니다. 그러나 이 응답 객체의 endpoint를 명시하지 않는 이상, 이 응답의 헤더에 접근하는 것은 불가능합니다. 이 응답 객체가 디시리얼라이즈된 바디를 포함하고 있지 않기 때문에 consuming 코드에서 직접 디시리얼라이제이션을 하지 않는 이상 contributor 리스트를 가지고 올 수 없습니다.

앞서 동기와 비동기, 실행에서의 RxJava 메커니즘에 대해 말하면서 장점이라고 했는데요. 그러나 이들을 구현하는 것은 경직성을 동반합니다. 어떤 부분은 동기적으로 call하고, 다른 부분은 비동기적으로 call하고 싶다면 다음과 같이 두 개의 메서드를 정의해야 합니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  List<Contributor> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);

  @GET("/repos/{owner}/{repo}/contributors")
  void repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo,
      Callback<List<Contributor>> cb);
}

RxJava를 사용하는 것도 유사한 이슈가 있었습니다. RxJava는 하나의 정의로 동기, 비동기 두 가지 방식을 동시에 사용할 수 있지만, Retrofit은 observable 객체들을 반환하기 위해 RxJava를 위한 지원 기능을 라이브러리 코어 내에 가공해 넣어야 했습니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Observable<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

또한 Retrofit 내에 observable을 어떻게 생성하는지 알아야 했습니다. 이 밖의 것은 어떨까요? 예컨대 Guava의 ListenableFuture나, Java 8 사용자를 위한 CompleteableFuture를 지원하지 않을 예정이었습니다. Retrofit 1은 Java 6과 안드로이드를 위해 만들어졌으나 이들 클래스를 참조할 수 없었습니다.

converter의 작동 원리도 조금 비효율적이었습니다. 커스텀 converter를 생성하기 위한 API를 살펴보시죠. 아주 간단합니다.

interface Converter {
  Object fromBody(TypedInput body, Type type);
  TypedOutput toBody(Object object);
}

단지 객체를 선택해서 HTTP representation으로 바꾸기만 하면 되는데, 이는 다시 객체를 되돌려 줍니다. 문제는 이것이 call될 때마다 응답과 변환해야할 타입을 알려줘야 하고, converter는 어떻게 이를 디시리얼라이즈해야 하는지 파악해야 하는데, 이것이 라이브러리 내부에서 몹시도 무거운 프로세스라는 점입니다. 어떻게 시리얼라이제이션해야하는지의 internal representation을 만드는 이 과정은 아주 느립니다. 몇몇 라이브러리가 이들 객체를 캐싱해 주긴 하지만, 매번 디시리얼라이제이션할 때마다 이들 객체를 찾아보는 것은 비효율적인 프로세스입니다.

interface GitHubService {
  @GET("/search/repositories")
  RepositoriesResponse searchRepos(
      @Query("q") String query,
      @Query("since") Date since);
}

/search/repositories?q=retrofit&since=2015-08-27
/search/repositories?q=retrofit&since=20150827

API와 인터페이스를 정의하는 것의 좋은 점은 이들 요청의 생성을 위해 당신이 매일 사용하는 객체를 사용할 수 있다는 점입니다. 당신이 call하는 메서드와도 동일하고, 단지 HTTP call이 돌아온다는 점만이 다릅니다. 그러나 우리가 primitive가 아닌 객체를 사용하도록 엄격히 규제되는 점이 좀 어려운 점입니다.

date를 넘기고 싶은 endpoint가 있다고 가정해 봅시다. date는 당연히 일반 객체이죠. 이들 메서드로 date를 넘기고 싶다면 단 두 개의 스트링만 call할 수 있습니다. 그러나 당신이 call한 URL과 API가 representation을 분리하기를 원할 수 있습니다. date를 받아서 다른 방식으로 포맷하고 싶어할 수도 있는데, date보다 더 복잡한 객체를 원한다면 그럴 가능성이 더 크죠. 이런 작업을 두 개의 스트링만으로 하기는 불가능합니다.

앞서 말한 것들이 Retrofit 1의 단점들입니다. 어떻게 고쳐나갔을까요?

Retrofit 2 (10:18)

이들 문제 모두가 Retrofit 1을 사용하는 사람들에 의해 계속 제기되어 왔는데, 우리가 이 해결에 대해 고심해왔음을 Retrofit 2에서 공표하고 싶습니다.

call (10:30)

새 타입을 만들었다는 점을 먼저 말하고 싶네요. OkHttp로 API call을 만드는 방식에 익숙한 분은 call이라는 클래스를 알 겁니다. Retrofit 2에는 call 클래스가 있는데, 디시리얼라이제이션 등의 방법을 안다는 것 외에는 OkHttp의 call과 같은 시맨틱입니다. OkHttp가 raw 바디를 돌려주는 반면, Retrofit 2의 call은 HTTP 응답을 받는 법과 이를 contributor 리스트로 바꾸는 법을 알고 있습니다.

call은 싱글 요청과 응답의 한 쌍을 모델로 하고 있습니다. 1회 한도의 한 쌍은 각각의 endpoint에 생성되는데 call의 생성을 분리할 수 있어서 편리합니다. 이 call 객체를 만들고 다른 클래스로 건네서 관심을 분리할 수 있습니다.

각각의 call 객체는 한 번만 사용할 수 있으므로 싱글 응답/요청 쌍이 됩니다. 그러나 OkHttp의 call과 마찬가지로, 클론 메서드 - Java clone을 call할 수 있습니다. 간단히 적은 비용으로 뒤따르는 call을 만들 수 있는 새 인스턴스를 만들 수 있도록 이 기능을 구현했습니다. 예를 들어 call 객체를 만든 후 요청을 만들기 전에 clone해서 이미 execute되지 않았음만 보장하면 됩니다.

또다른 장점은 동기, 비동기 실행을 싱글 타입으로 지원한다는 점입니다. 또한 실제로 취소가 가능하다는 점도 멋지죠. HTTP client를 기초로 call되고 취소됩니다. 요청이 전송되고 있다면 서버로부터 자신을 분리합니다. 비동기로 아직 요청이 실행되지 않은 경우에는 아예 실행되지 않습니다. 어떤 식으로 적용하는지 볼까요?

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

Call<List<Contributor>> call =
    gitHubService.repoContributors("square", "retrofit");

이 call 객체는 매개 변수화될 수 있으며, 인터페이스에의 API 메서드에서 반환되는 실제 객체입니다. 메서드 call도 마찬가지로 이 인스턴스를 받게 됩니다. excute도 가능하지만 단 한 번만 사용할 수 있음을 재차 강조합니다.

Call<List<Contributor>> call =
    gitHubService.repoContributors("square", "retrofit");

response = call.execute();

// This will throw IllegalStateException:
response = call.execute();

Call<List<Contributor>> call2 = call.clone();
// This will not throw:
response = call2.execute();

두 번 execute를 시도하면 실패하게 됩니다. 하지만 이들 인스턴스를 복제할 수 있으며, 비용이 아주 적으므로 여러 번 사용하고 싶으면 언제든지 복제하세요. 혹은 매번 해당 매서드를 call하는 방법을 써도 됩니다.

비동기식 사용은 enqueue 메서드를 통해 가능합니다. 동기식으로는 execute를, 비동기식으로는 queue를 제공합니다.

Call<List<Contributor>> call =
    gitHubService.repoContributors("square", "retrofit");

call.enqueue(new Callback<List<Contributor>>() {
  @Override void onResponse(/* ... */) {
    // ...
  }

  @Override void onFailure(Throwable t) {
    // ...
  }
}); 

어떤 것을 비동기식으로 넣기 위해 enqueue한 다음이나, 혹은 동기식으로 execute하더라도, 이들 요청을 취소할 수 있으며 실제로 취소됩니다.

Call<List<Contributor>> call =
    gitHubService.repoContributors("square", "retrofit");

call.enqueue(         );
// or... 
call.execute();

// later...
call.cancel();

응답의 매개 변수화 (13:48)

또 다른 새 기능은 매개 변수화된 응답 타입입니다. 이전에는 제공하지 못하던 응답 코드와 응답 메시지, 헤더 엑세스 등의 메타 데이터를 응답에서 제공할 예정입니다.

class Response<T> {
  int code();
  String message();
  Headers headers();

  boolean isSuccess(); 
  T body();
  ResponseBody errorBody(); 
  com.squareup.okhttp.Response raw();
}

성공적으로 요청되었는지 가늠하는 편의 메서드가 있는데 기본적으로 200 코드를 점검하기 위한 것입니다. 또한 바디와 에러 바디에 접근하기 위한 분리된 메서드를 제공합니다. 이들 메서드 사용은 boolean 리턴 타입과 일치합니다. 응답이 성공적이어야만 실제 디시리얼라이제이션하고 바디의 콜백에 넣습니다. 만약 성공 메서드가 false를 리턴한다면 응답의 타입이 무엇이었는지 알 수 없게 됩니다. 그런 상황에는 content 타입과 길이, raw body를 압축한 ‘ResponseBody’ 타입을 사용자에게 건네서 해석할 수 있게 합니다.

이 두 가지가 인터페이스 정의에서 크게 변화한 점입니다.

다이내믹 URL 매개 변수 (16:33)

몇 해간 다이내믹 URL 매개 변수라는 큰 문제를 해결하지 못했지만 이번 버전에서 드디어 수정했습니다. 예를 들어 GitHub에 요청을 보낸다면 응답이 되돌아오는데, 그 응답은 다음처럼 정돈되지 않은 모습의 헤더를 포함하고 있습니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
} 

Call<List<Contributor>> call = 
    gitHubService.repoContributors("square", "retrofit");
Response<List<Contributor>> response = call.execute(); 

// HTTP/1.1 200 OK 
// Link: <https://api.github.com/repositories/892275/contributors?
page=2>; rel="next", <https://api.github.com/repositories/892275/
contributors?page=3>; rel="last" 
// ...

이는 당신이 페이지를 만들려면 사용해야 하는 URL들을 알려 줍니다. 전체 리스트를 리턴하지는 않으며 첫 20페이지 정도만을 리턴합니다. GitHub이 데이터를 메모리에 캐싱하는 등 똑똑한 방법을 사용했기 때문에, 이 전에는 이처럼 뒤따르는 여러 개의 요청을 GitHub의 형식과 같은 헤더를 사용해서 실행할 수 없었습니다. 따라서 요청을 해도 같은 서버를 가리킬 뿐이었습니다. GitHub의 방식은 데이터베이스의 모든 정보를 파악해야 하는 비용을 지급하지 않아도 되는 방법이죠.

새 응답 타입을 사용하면 contributor 리스트를 얻을 수 있을 뿐만 아니라 실제로 그 헤더를 보고 다음 페이지로 통하는 링크를 만드는 가상의 메서드를 써넣을 수도 있습니다.

Response<List<Contributor>> response = call.execute();

// HTTP/1.1 200 OK
// Link: <https://api.github.com/repositories/892275/contributors?
page=2>; rel="next", <https://api.github.com/repositories/892275/
contributors?page=3>; rel="last"
// ... 

String links = response.headers().get("Link");
String nextLink = nextFromGitHubLinks(links); 

// https://api.github.com/repositories/892275/contributors?page=2

인터페이스에 사용된 것과는 좀 다른 모양의 URL입니다.

이제까지는 follow up 응답에 사용될 이들 다이내믹 URL을 허용함으로써 이를 보내는 방법을 사용해 왔었지만, 앞으로는 별개의 인터페이스 메서드를 구현해야 합니다. 응답 타입이 달라지므로 기본적 요구사항입니다. 처음에는 사용자와 repo를 선택하고 모델 타입들로 이니셜 요청을 합니다. 한편 follow up은 정보가 이미 follow up 링크에 인코딩돼있기 때문에 근본적으로 다른 요청 방식입니다. 이 같은 메서드의 URL 매개 변수에 이를 넣고 싶지 않았기에 별도의 메서드로 정의했습니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);

  @GET
  Call<List<Contributor>> repoContributorsPaginate(
      @Url String url);
}

URL 내부에서 전달할 수 있는 새로운 ‘@Url’ 어노테이션 기능에서는 Git optional에 있는 간접 경로를 만들었음을 볼 수 있습니다.

이 follow up 메서드를 사용해서 링크를 만들고 뒤따르는 call을 받기 위한 두 번째 paginate 메서드 역시 call할 수 있습니다.

String nextLink = nextFromGitHubLinks(links); 

// https://api.github.com/repositories/892275/contributors?page=2 

Call<List<Contributor>> nextCall = 
    gitHubService.repoContributorsPaginate(nextLink);

이 코드는 2 페이지를 만들기 위한 요청을 하는 동시에 3 페이지를 만들 수 있게 하는 헤더를 포함합니다. 이 paginate 메서드를 사용해서 이런 작업을 계속 이어갈 수 있습니다. 특정 API들이 지원하는 이런 방법을 Retrofit 1에서는 지원하지 않았기 때문에 많은 사람이 불편해했었죠.

다중의 효율적인 converter (19:31)

Retrofit 1에는 converter와 관련된 문제가 있었습니다. 많은 사람에게 그다지 큰 문제가 아니긴 했어도 라이브러리 내부의 문제였습니다. Retrofit 2에서는 이 문제를 해결하기 위해 다중 converter를 허용합니다.

예전에는 JSON 응답 API를 call하고자 하면 분리된 서비스를 선언해서 proto 응답을 지원하는 별도의 API를 call해야 했습니다.

interface SomeProtoService {
  @GET("/some/proto/endpoint")
  Call<SomeProtoResponse> someProtoEndpoint();
}

interface SomeJsonService {
  @GET("/some/json/endpoint")
  Call<SomeJsonResponse> someJsonEndpoint();

그 이유는 REST adapter 객체에 명시된 하나의 converter만이 허용됐기 때문입니다. 인터페이스 선언은 semantic 해야 하므로 이를 조정하였습니다. 계정 서비스나 사용자 서비스, 트위터 서비스 등 같은 것에 사용되는 API는 하나의 그룹으로 묶여야 합니다. 어떤 URL들이 다른 응답 시리얼라이제이션 포맷을 리턴할 수도 있다는 사실이 서비스를 조직하는 데 방해가 되어서는 안 되지만, 어떤 점들은 고려해야 합니다.

Retrofit 2를 사용하면 이들을 같은 서비스로 융화시킬 수 있습니다.

interface SomeService {
  @GET("/some/proto/endpoint")
  Call<SomeProtoResponse> someProtoEndpoint();

  @GET("/some/json/endpoint")
  Call<SomeJsonResponse> someJsonEndpoint();
}

어떤 converter가 사용되는지 아는 것이 코드 작성에도 영향을 미치므로 한 번 살펴보겠습니다. 가상의 proto 객체를 리턴하는 첫 번째 메서드입니다.

SomeProtoResponse —> Proto? Yes!

먼저 각각의 converter가 특정 type을 다룰 수 있는지 알아보도록 하겠습니다. proto converter에게 SomeProtoResponse를 다룰 수 있는지 문의한다면 proto converter는 가능 여부를 파악하는 데 필요한 행위를 할 것입니다. 사실 프로토콜 버퍼들은 같은 클래스에서 파생되었는데, protobuff 안에서는 message나 message lite로 불리고, wird 안에서는 message라고 불립니다. proto converter는 이 클래스가 message를 상속받았는지 확인해보고 만약 그렇다면 yes라고 응답할 것입니다.

이제 JSON에 대해 proto converter에게 문의해볼까요? proto converter는 JSON이 message를 상속받지 않음을 파악하고 no라고 응답할 겁니다. 이후 다음 converter에게 같은 문의를 하게 되는데, JSON converter입니다. Json converter는 JSON을 다룰 수 있으니 yes라고 응답할테죠.

SomeJsonResponse —> Proto? No! —> JSON? Yes!

계층 구조에 대한 제약이나 요구 사항이 실제로 있지는 않으므로 어떤 것이 JSON이 될 수 있을지 없을지 알 방법은 없고, JSON converter는 항상 yes라고 응답할 겁니다. 이 점을 주의해서 항상 JSON converter에 대한 문의를 마지막에 시행해야 합니다.

다른 주의점은 기본 converter를 더는 지원하지 않는다는 점입니다. 따라서 어떤 conveter들이 사용 가능한지 명시하지 않고서는 Retrofit을 사용할 수 없습니다. 코어 내의 시리얼라이제이션 메커니즘에 대한 dependency가 없으므로 스스로 구현해야 합니다. converter들을 제공하긴 하지만 명시적인 dependency를 추가해야 하고 converter를 사용함을 Retrofit에 명시적으로 알려야 합니다.

다중의 플러그형(pluggable) 실행 메커니즘 (22:38)

이전의 우리는 실행 메커니즘에 대해 엄격히 제한적이었습니다. Retrofit 2에서는 이를 플러그형으로 개선했고, 다중의 실행 메커니즘도 허용합니다. 이는 converter의 작동 원리와도 유사합니다.

예를 들어 call을 리턴하는 메서드가 있다고 가정한다면, 이 call은 내장된 converter, 즉 Retrofit 2의 네이티브 실행 메커니즘입니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);

...

이제 원하는 메커니즘을 가져오던지 제공하는 것을 사용하면 됩니다.

...

  @GET("/repos/{owner}/{repo}/contributors")
  Observable<List<Contributor>> repoContributors2(
      @Path("owner") String owner,
      @Path("repo") String repo);

  @GET("/repos/{owner}/{repo}/contributors")
  Future<List<Contributor>> repoContributors3(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

RxJava 계열 메커니즘도 아직 지원하지만 이제 분리되었습니다. (혹시 Futures를 좋아한다면 커스텀 메커니즘을 작성해도 됩니다) 어떻게 작동하는지 알아볼까요?

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(..);

  @GET("/repos/{owner}/{repo}/contributors")
  Observable<List<Contributor>> repoContributors2(..);

  @GET("/repos/{owner}/{repo}/contributors")
  Future<List<Contributor>> repoContributors3(..);
}

먼저 응답 타입을 살펴보죠. call에 대해 첫 번째 실행 메커니즘에게 call을 다룰 수 있을지 물어봅니다. RxJava 계열이라면 observable이 아니므로 no라고 응답할 겁니다. 그다음으로 internal converter에게 묻고, yes 응답을 받을 수 있습니다.

call —> RxJava? No! —> Call? Yes!

observable에 대해서도 유사하게 작용합니다. RxJava 계열에 묻고, yes라고 응답합니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(..);

  @GET("/repos/{owner}/{repo}/contributors")
  Observable<List<Contributor>> repoContributors2(..);

  @GET("/repos/{owner}/{repo}/contributors")
  Future<List<Contributor>> repoContributors3(..);
}

Observable —> RxJava? Yes!

설치된 것이 없다면 타입을 확인할 수 없습니다. Future에 관해 묻는다면 모두 no라고 응답할 겁니다.

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(..);

  @GET("/repos/{owner}/{repo}/contributors")
  Observable<List<Contributor>> repoContributors2(..);

  @GET("/repos/{owner}/{repo}/contributors")
  Future<List<Contributor>> repoContributors3(..);
}

Future —> RxJava? No! —> Call? No! —> Throw!

이 시도는 예외를 던질 것이고, 타입을 바꾸던지 메커니즘을 설치해야 해결할 수 있습니다. 메커니즘에 대해서는 추후 더 알아보겠습니다.

OkHttp에 의한 지원 (24:17)

Retrofit 2는 OkHttp에 의존적이며, HTTP client는 더는 사용할 수 없습니다. 논란의 여지가 있지만, 이것이 왜 올바른 결정인지 설명해 드리고자 합니다.

2012년도, 아직 Retrofit 1.0이 배포되기도 전에 우리는 client abstraction의 필요성을 파악했습니다. Apache와 URL connection, 그리고 선택 방법에 대해 논의한 Jesse Wilson의 블로그 포스트에서는 이들에 대한 황당한 경고들도 담겨 있었습니다.

2012년도, 우리에게는 요청/응답 abstraction이 필요했습니다. Apache가 이들을 지원했는데, Apache가 요청 생성에 대한 객체 지향적 모델을 가지고 있었지만 URL connection은 그렇지 못했기 때문입니다. URL connection은 추적이 가능한 많은 API를 갖고 있었고 우리는 client abstraction까지 포함하기 위해 이들 사용법을 보다 객체 지향적인 abstraction으로 만들어야 했습니다.

2012년도, 우리에게는 header abstraction이 필요했습니다. 재차 말하지만 Apache가 이들을 지원했지만 URL connection은 그렇지 못했습니다. URL connection은 대신 API와 스트링들을 사용했고, 우리는 이들 개별적 헤더를 재현할 무언가가 필요했습니다.

2015년 현재 OkHttp는 작고 집약적이며 훌륭한 API를 갖고 있습니다. 우리는 Retrofit 2에서 많은 부분 OkHttp를 반영했는데, OkHttp는 앞서 말한 abstraction을 비롯한 우리에게 필요한 기능들을 모두 지원하기 때문입니다. OkHttp의 도입은 Retrofit의 크기를 줄이는데도 큰 도움이 됐습니다. 60%의 용량을 줄였지만 기능은 더욱 추가됐습니다.

따라서 OkHttp에의 dependancy가 요구되므로 OkHttp를 include해야 합니다. 그러나 이미 많은 분이 OkHttp를 사용하고 있으리라 보며, Retrofit이 이 덕분에 더 개선됐음을 알 수 있을 겁니다.

OkHttp와 Okio에 의한 지원 (26:20)

OkHttp 사용의 좋은 점은 이를 Retrofit 2의 API 안에 드러낼 수 있다는 점입니다. 아마 에러 바디 메서드와 응답의 바디 부분을 본 경험들이 있을 텐데요, Retrofit의 응답 객체에 OkHttp 응답을 그대로 리턴할 수 있습니다. 이들 타입을 드러낼 수 있고, 앞서 말한 것들을 더욱 훌륭하고 명료한 API로 교체할 수 있습니다.

OkHttp를 기반으로 하는 Okio라는 아주 컴팩트한 IO 라이브러리도 있습니다. 제 talk at Droidcon Montreal 강연에서는 왜 이들이 좋은 선택인지 그리고 어떻게 고효율 적으로 작동하는지, 왜 이들을 사용해야 하는지에 대해 말했습니다. 이 강연에서 Retrofit 2에 대해서도 언급했는데 당시에는 대부분 가정에 근거한 이야기였죠. 그 Retrofit 2가 실재하는 지금, 잠시 시간을 내서 강연 비디오를 시청해 보세요.

Retrofit 2의 효율성 (27:31)

이 그래프는 강한 dependancy와 abstraction 덕분에 Retrofit이 Retrofit 1이나 다른 솔루션에 비해 얼마나 효율적일 수 있는지 보여줍니다. 상기 비디오에서 이 그래프를 설명하고 있으니 watch this section of my talk를 봐주세요.

Retrofit Type 설정 (31:24)

REST adapter를 교체하는 실제 Retrofit 타입과 설정법에 대해 살펴보겠습니다. 구 버전의 메서드는 endpoint라고 불렸는데 이제 baseUrl로 바뀌었습니다. 이는 접속할 서버의 URL로, GitHub 서버와의 접속을 예로 들어보겠습니다.

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

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
} 

GitHubService gitHubService = retrofit.create(GitHubService.class);

여기 보이는 우리 인터페이스에서 Retrofit 1에서와 같이 create 메서드를 call 했습니다. 이제 메서드를 콜할 수 있는 인터페이스의 구현체를 생성할 겁니다.

repoContributors 메서드를 call할 때 Retrofit은 이 같은 URL을 생성합니다. 우리가 이를 소유자와 저장소로서 Square나 Retrofit으로 넘기면, 제각기 이런 URL로 돌아옵니다. https://api.github.com/repos/square/retrofit/contributors 내부적으로 Retrofit은 OkHttp의 HTTP URL 타입을 base URL로 사용하고, resolve 메서드가 이 상대 경로를 받아서 실제 요청 생성이 가능하도록 전체 URL로 resolve 합니다. 이들 상대 URL을 작성하는데 영향을 미칠만한 semantic 상의 큰 변화가 있으므로 이를 이해하는 것이 중요합니다. v3이라는 suffix가 있는 API를 바꾸는 것을 보여드리겠습니다.

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

interface GitHubService {
  @GET("/repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

이 예제가 GitHub의 실제 API는 아니지만, 이와 유사하게 suffix와 path를 사용하는 API들이 많습니다. 이런 메서드를 call할 때 resolve 된 URL은 다음과 같은 모양입니다. https://api.github.com/repos/square/retrofit/contributors 호스트 뒤에 v3가 없는데 그 이유는 상대 URL이 /로 시작하기 때문입니다. Retrofit 1은 semantic 목적 때문에 /로 시작하기를 강제하지만, 우리는 endpoint 뒤에 이를 덧붙입니다. 이후 이들 base와 상대 URL들을 가지고 resolve 메서드를 사용하는데, HTML의 HREF에서 anchor tag와 정확히 같은 방식으로 작동합니다. 시작 부분에 /를 넣으면 이것이 호스트에서부터 시작하는 절대 경로가 됨을 의미합니다. /를 붙이지 않는다면 어떨까요?

interface GitHubService {
  @GET("repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

온전히 상대 경로가 되고 현재 경로에서 시작하는 경로에서부터 resolve 됩니다. 이 시작 URL을 지우면 v3 path를 포함한 온전한 URL을 얻을 수 있습니다.

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

interface GitHubService {
  @GET("repos/{owner}/{repo}/contributors")
  Call<List<Contributor>> repoContributors(
      @Path("owner") String owner,
      @Path("repo") String repo);
}

// https://api.github.com/v3/repos/square/retrofit/contributors

OkHttp에 의존적이기 때문에 client abstraction은 없지만, client 객체를 만들 수는 있습니다. OkHttp의 타입인 OkHttp client가 그것입니다. 이를 통해 configure interceptor, SSL socket factory, timeout 등의 일을 할 수 있습니다. (OkHttp에는 기본 timeout이 있으므로 커스터마이징할 필요가 없다면 이를 사용하면 됩니다. 설정하는 방법은 다음과 같습니다)

OkHttpClient client = new OkHttpClient();
client.interceptors().add(..);

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

또한 여기서 converter와 RxJava 등의 실행 메커니즘을 특정할 수 있습니다. converter는 다중으로 설정할 수 있는데, GSON을 위한 converter를 추가한 후 protocol buffer를 위한 converter를 추가할 수도 있습니다.

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com")
    .addConverterFactory(GsonConverterFactory.create())
    .addConverterFactory(ProtoConverterFactory.create())
    .build();

이런 경우 순서가 중요하다는 점을 강조합니다. 어떤 타입을 다룰 수 있는지 묻는 순서 그대로 설정해야 하는데, 위 예제는 사실 순서가 잘못됐습니다. proto를 지정했다면 JSON으로 인코딩될 것이므로 JSON으로서 응답을 시도하고 디시리얼라이즈할 것입니다. 이런 방식을 의도한게 아니므로 이들 순서를 바꿔야 합니다. 즉 protocol buffer를 먼저 add하고 GSON을 통한 JSON을 뒤에 배치합니다.

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com")
    .addConverterFactory(ProtoConverterFactory.create())
    .addConverterFactory(GsonConverterFactory.create())
    .build();

사실 Retrofit에 이런 점이 문서로 잘 정리되지는 않았지만, 몇몇 팁이 있긴 합니다. call 대신 RxJava를 쓰려면 call adapter factory를 사용하면 좋습니다.

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com")
    .addConverterFactory(ProtoConverterFactory.create())
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
    .build();

이는 call 객체를 받아서 다른 것으로 변환하는 방식을 알고 있고, 다른 타입으로 변환도 해줍니다. RxJava의 새 타입은 observable이 가능하고 experimental single만을 가집니다. 즉, RxJava에서 observable을 재현하는 새로운 타입은 하나의 유일한 아이템만을 발행하는데, 이 두 작업 모두 call adapter factory로 수행할 수 있습니다.

확장 가능성 (36:50)

converter factory는 플러그형(pluggable)인데 즉 여러분이 필요한 것을 가져올 수 있다는 뜻입니다. converter factory는 싱글 메서드로 구현되며, 타입을 넘기면 null이나 no, 혹은 converter 객체를 되돌려 줍니다.

SomeJsonResponse

		class ProtoConverterFactory {
  			Converter<?> create(Type type);		null
		}

		class GsonConverterFactory {
			Converter<?> create(Type type);		Converter<?>
		}

따라서 proto를 상속하지 않은 JSON 응답 타입을 넘기면 이 메서드를 다루는 방법을 모르니 null을 리턴할 겁니다. 그러나 GSON converter라면 다루는 방법을 안다는 의미로 객체를 리턴할 테죠. converter 객체를 생성하도록 요청할 수 있으므로 converter factory라는 명칭을 붙였습니다. 다른 것을 적용하여 구현하는 것도 어렵지 않습니다. converter의 실제 구현은 타입화된 인풋과 아웃풋 대신 OkHttp의 요청 바디와 응답 바디를 사용한다는 점 외엔 예전 방식과 유사합니다.

interface Converter<T> {
  interface Factory {
	Converter <?> create(Type type);
  }

  T fromBody(ResponseBody body);
  RequestBody toBody(T value);
}

이들 adapter를 실제로 찾아볼 수 있다는 점에서 더욱 효율적이 되었습니다. 예를 들어 GSON은 type adapter를 갖고 있으므로 GSON converter factory에 어떤 것을 다룰 수 있을지 묻는다면 adapter를 찾아보고 캐싱한 후 변환할 때마다 미리 캐싱된 것을 사용하기 때문입니다. 작은 변화지만 매번 call마다 사용하던 과정을 줄였다는 점에서 훌륭합니다.

call adapter도 같은 패턴입니다. call adapter factory에 어떤 타입을 다룰 수 있는지 묻는다면 null 대신 no를 리턴하는 것 외엔 앞서와 같이 동작합니다. 정말 간단명료한 API죠.

interface CallAdapter<T> {
  interface Factory {
    CallAdapter<?> create(Type type);
  }

  Type responseType(); 
  Object adapt(Call<T> value);
}

adaption을 하는 메서드도 있습니다. call의 객체를 받아서 observable, single, future, 등등을 리턴합니다. 또한 response type을 받는 메서드도 있습니다. 예를 들어 contributor 리스트의 call을 선언할 때 이 매개 변수화된 타입을 자동으로 pull할 방법이 없으므로 call adapter에게 response type을 리턴해달라고 요청해야 합니다. 따라서 observable을 위해 객체를 만들 경우 객체에 요청하면 객체가 contibutor type의 리스트를 돌려줄 수 있습니다.

개발 중인 사항들 (40:05)

Retrofit 2은 아직 개발 중으로 완성 버전이 아니지만 사용할 만 합니다. 앞서 말한 모든 기능이 포함되어 있고 잘 작동합니다. 그렇다면 어떤 점들을 개발 중일까요?

parameter handler라고 불리는 기능이 아직 없지만 추후 이런 기능을 지원하고자 합니다. Guava로부터 multi map을, 혹은 date type과 enum을 받을 수 있는 기능도 생각 중입니다. (안드로이드의 enum을 사용해야 한다는 뜻은 아닙니다.😉)

log 기능도 아직 지원되지 않습니다. Retrofit 1에서는 지원했었지만, Retrofit 2에서는 지원되지 않습니다. 아마 log 기록 기능이 필요해지리라 생각합니다. OkHttp를 의존하는 것의 장점은 interceptor를 요청과 응답을 log로 기록할 수 있다는 점이므로 raw 요청과 응답 부문의 log 기능이 필요하지는 않겠지만, Java 타입들을 log로 기록할 무언가가 필요할 겁니다.

mock module 역시 아직 지원되지 않지만 앞으로 지원할 예정입니다.

한편 Documentation은 현재 개발이 지연되고 있습니다.

마지막으로 Retrofit 2에 WebSocket을 넣고 싶은 생각이 간절해서, 제 여유 시간에 OkHttp의 WebSocket 부분을 작업하고 있습니다. 2.0 버전에서 완성되지 않을지도 모르지만 2.1 버전에는 넣을 생각입니다.

배포 (41:31)

올해 저는 Retrofit 2 개발에 전념했고, Retrofit 2는 이번 해에 배포될 것입니다. 날짜를 약속하지 못하겠네요. 다만 2016년 Droidcon New York에서 똑같은 농담을 하고 싶지는 않으므로 이번 해에 꼭 해낼 거라고 다짐하고 있습니다. 2015년 8월 27일에 2.0 베타버전을 배포했으니 앱에 적용할 수 있습니다.

dependencies {
  compile 'com.squareup.retrofit:retrofit:2.0.0-beta1'
  compile 'com.squareup.retrofit:converter-gson:2.0.0-beta1'
  compile 'com.squareup.retrofit:adapter-rxjava:2.0.0-beta1'
}

잘 작동하니 믿고 사용하셔도 되고 API도 비교적 안정적입니다. 이후에 converter와 converter factory method에 변화가 있을 수도 있겠지만 베타 버전도 충분히 사용 가능합니다. 한 번 살펴보시고 마음에 들지 않는 점이나 이슈가 있으면 알려주세요. 감사합니다.

컨텐츠에 대하여

2015년 8월에 진행한 Droidcon NYC 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

Jake Wharton

Jake Wharton은 Square의 Square Cash에서 일하는 안드로이드 개발자입니다. 지난 5년간 나쁜 API와 틀에 박힌 상용구 코드를 보면서 괴로워 해왔죠. 마치 전염병처럼 퍼져 많은 개발자를 괴롭히는 이런 현상에 대한 경각심을 알리기 위해 전 세계 컨퍼런스에서 강연을 하고 있습니다.

4 design patterns for a RESTless mobile integration »

close