2016/03/15 - [java] - benchmarking 코드를 작성해 보자 #1

2016/06/06 - [java] - Dynamic Proxy를 꼭 써야해? #1


이 글에서는 Dynamic Proxy를 꼭 써야하나? 라는 고민을 해봅시다. 


안드로이드 대표적인 HTTP 라이브러리를 꼽자면 Retrofit2, Volley, Okhttp3 입니다. Retrofit2는 안드로이드 개발자들이 HTTP 통신을 쉽게 사용하도록 Okhttp3 기반의 상위 레이어에 불과합니다. Volley는 구글에서 만들었는데, 2013년 어떤 분이 마이크로 벤치마킹 한 결과가 별로 좋지 않아서(참조 http://instructure.github.io/blog/2013/12/09/volley-vs-retrofit/) 외국에서는 거의 Retrofit을 주로 사용하는 것으로 알고 있습니다.(오픈소스로 된 샘플앱들이 HTTP 라이브러리를 Retrofit으로 된걸로 보면요.) 티몬, 쿠팡, 위메프, 11번가등 apk를 까보면 Volley를 쓰고 있습니다. Volley의 HurlStack을 Okhttp로 사용하게 하면 그나마 성능 이슈는 해결되긴 합니다만, 굳이 사용성 측면에서나 okhttp3나 Retrofit2가 성능이나 메모리 사용에서 더 효율적이기 때문에 새로운 앱을 만들때 Volley를 선택할 필요는 없을 것입니다.


결국엔 서비스앱을 만들 때 Retrofit2를 사용할지 Okhttp3를 사용할지가 문제입니다. 앱의 가독성 측면을 보면 Retrofit2가 더 좋아 보이는데, 한가지 걸리는 것이 있습니다. 그건 바로 Dynamic Proxy를 내부적으로 런타임시 사용하고 있다는 것입니다. 개발자가 선언한 Interface를 런타임에 읽어서 그 인터페이스에서 선언한 메소드 및 annotation 등을 Reflection을 사용해서 처리하여 Proxy class를 만들어 그 구현체를 개발자가 사용하는 방식입니다. 실제로 서버에 HTTP 요청을 보내고 응답을 받는 작업은 Okhttp3가 하고있는 상황입니다. 이 사실만으로도 Reflection의 오버헤드를 피해서 조그이나마 성능 이점을 얻기 위해서는 개발자가 Retrofit2를 피하고 Okhttp3를 직접 사용하는 것이 나을것이라는 막연한 생각이 들수 있습니다. 그러면 어느 정도 성능 이점이 있을지 살펴 보도록 하겠습니다.


JMH를 이용해서 Retrofit2, Okhttp3를 이용하여 각각 동일한 GET 요청을 보내는 코드를 작성하도록 합니다. 동일한 요청을 보내는데 각 라이브러리가 얼마나 시간이 걸리는지 비교하도록 합니다. Benchmarking 코드와 결과는 이 Github[https://github.com/jayjaylab-benchmark/retrofitvsokhttp] repository에 저장하였으니 확인하세요.


하기는 벤치마킹코드입니다. 혹시나 보시고 제가 실수한 사항이 있으면 지적해 주시면 감사하겠습니다. 서버에 GET 요청을 보낸 후 응답으로 오는 json 스트링을 POJO 객체로 unmarshalling하는 지점까지를 성능 측정하는 범위로 정하였습니다. 본 벤치마킹을 실행한 환경은 MAC OS X yosemite version 10.10.5(14F27), 2.7GHz Intel core i5, 8GB 1867MHz DD3이고 JDK 1.8.0_45, VM 25.45-b02 자바환경에서 JVM warm up없이 cold start인 상황에서 측정하였습니다. JMH에서 코드 warm up한 상태에서 벤치마킹할 각 메소드를 각 iteration당 1번만 실행하는 옵션은 없어서 부득히하게 cold start인 경우에만 측정하게 되었습니다. 안드로이드의 경우 client mode로 dalvik이나 ART가 실행될 것으로 추측이 되고 warm up 과정은 없으리라 추측이 되는데, AOSP에서 런타임 까봐야 알겠죠?ㅋ...

public class AndroidHttpLibraryBenchmark {
    @org.openjdk.jmh.annotations.State(Scope.Benchmark)
    public static class State {
        public Retrofit retrofit;
        public OkHttpClient okHttpClient;
        public ObjectMapper objectMapper;

        @Setup(Level.Trial)
        public void setUp() {
            System.out.println("########## setUp() ##########");
            retrofit = new Retrofit.Builder()
                    .baseUrl("https://api.github.com/")
                    .addConverterFactory(JacksonConverterFactory.create())
                    .build();
            okHttpClient = new OkHttpClient();
            objectMapper = new ObjectMapper();
        }

        @TearDown(Level.Trial)
        public void tearDown() {
            System.out.println("########## tearDown() ##########");
            retrofit = null;
            objectMapper = null;
            okHttpClient = null;
        }
    }

    @Benchmark
    @BenchmarkMode({Mode.SingleShotTime})
    @OutputTimeUnit(TimeUnit.MILLISECONDS)
    public void testRetrofit2(State state, Blackhole blackhole) {
        GitHubService service = state.retrofit.create(GitHubService.class);
        Call<List<Repo>> repos = service.listRepos("octocat");
        List<Repo> result = null;

        try {
            retrofit2.Response<List<Repo>> response = repos.execute();
            result = response.body();
        } catch(Exception e) {
            e.printStackTrace();
        }

        blackhole.consume(result);
    }

    @Benchmark
    @BenchmarkMode({Mode.SingleShotTime})
    @OutputTimeUnit(TimeUnit.MILLISECONDS)
    public void testOkhttp3(State state, Blackhole blackhole) {
        HttpUrl url = new HttpUrl.Builder()
                .scheme("https")
                .host("api.github.com")
                .addPathSegment("users")
                .addPathSegment("octocat")
                .addPathSegment("repos")
                .build();

        Request request = new Request.Builder()
                .url(url)
                .build();
        List<Repo> result = null;

        try {
            Response response = state.okHttpClient.newCall(request).execute();
            result = state.objectMapper.readValue(response.body().charStream(),
                    new TypeReference<List<Repo>>(){});
        } catch (Exception e) {
            e.printStackTrace();
        }

        blackhole.consume(result);
    }
}


우선 성능만 보자면... 어떤걸 택하시겠나요? Retrofit2, Okhttp3를 사용하는 메소드를 10번씩 돌린 결과입니다. 네트워크 상황이 벤치마킹 돌릴때마다 일정하고 안정된 상황이라는 가정하에 아래 결과를 보면 Okhttp3를 사용하는게 성능상 이점이 있다고 볼 수 있습니다. 특히나 앱 시작할 때 몇개의 Restful API를 사용하는 경우라면 XXXms의 이점이 있을것으로 보입니다. reddit androiddev subreddit에 이 결과를 올려서 공유를 했습니다.(참고 : https://www.reddit.com/r/androiddev/comments/4rpdlv/retrofit2_vs_okhttp3/).

MicroBenchmark Result #1(2016-07-07T22:41:40+09:00)

Benchmark                                  Mode  Cnt     Score     Error  Units
AndroidHttpLibraryBenchmark.testOkhttp3      ss   10  2389.107 ± 550.925  ms/op
AndroidHttpLibraryBenchmark.testRetrofit2    ss   10  2476.903 ± 327.647  ms/op
MicroBenchmark Result #2(2016-07-08T00:47:12+09:00)

Benchmark                                  Mode  Cnt     Score     Error  Units
AndroidHttpLibraryBenchmark.testOkhttp3      ss   10  2472.586 ± 245.455  ms/op
AndroidHttpLibraryBenchmark.testRetrofit2    ss   10  2920.377 ± 760.955  ms/op
MicroBenchmark Result #3(2016-07-08T12:39:23+09:00)

Benchmark                                  Mode  Cnt     Score      Error  Units
AndroidHttpLibraryBenchmark.testOkhttp3      ss   10  2427.628 ±  556.801  ms/op
AndroidHttpLibraryBenchmark.testRetrofit2    ss   10  3110.812 ± 1659.656  ms/op

Jake wharton이 proxy class instance를 State class에서 만들어 써야 한다는 댓글을 남겼습니다. 그러면 결과는 좀 달라지겠지요. 그렇다면 Okhttp3의 Request instance 또한 State class에서 만들어서 써야하는데 그렇게 되면 제 예상으로는 수치상 차이는 없어질 가능성이 많아 보입니다. 제가 지적하고자 하는 점은 dynamic proxy 사용이 염려되어 proxy class의 인스턴스를 얻는 코드를 벤치마킹에 포함하였고 그와 동일한 행위를 하는 Okhttp3 코드 또한 벤치마킹 코드에 포함하였습니다. Retrofit2는 한번 만들어진 인스턴스를 내부적으로 캐쉬하는 것으로 보이고 Okhttp3에서 사용하는 Request 인스턴스는 개발자가 직접 캐쉬하도록 코드를 작성해야 하는 번거러움이 있습니다. 저에게는 직접 캐쉬하는 코드 작성이 별로 어렵지 않은 터라 다른 경쟁사 앱들과 조금이나마 차이를 만들고자 Okhttp3 선택하고 싶네요. 


마치기에 앞서 이 벤치마킹 수치에 지대한 영향을 미치는 통신 overhead에 관해 언급을 해야겠습니다. Retrofit2의 내부에서 Okhttp3를 사용하기에 HTTP 통신 로직의 오버헤드는 둘 다 동일할 것이고 나머지 Retrofit2 자체의 오버헤드와 네트워크 상태(?)가 벤치마크 수치를 다르게 만들 수 있습니다. 네트워크 상태가 각 벤치마크를 돌릴때마다 다르다고 해도 위 수치는 OkHttp3가 좀더 좋은 결과를 나타내고 있다고 말할 수 있는 수준이라고 보입니다. 또한 Retrofit2, Okhttp3는 안드로이드용 이기 때문에 Hotspot JVM에서 벤치마킹을 하는 JMH에서의 결과를 신뢰할 수 있을지 의문이 들긴 합니다. 그런데 Dalvik이나 ART에서 동일한 벤치마킹을 수행한다고 할 때 결과 양상이 바뀔까? 라는 질문을 했을 때, 글쎄요 수치는 바뀔 수 있을지언정 위 JMH에서 나온 결과 패턴은 바뀔 것 같아 보이진 않아 보입니다. 아무튼 안드로이드상에서 벤치마킹을 수행할 수 있는 툴이 현재로선 제가 없는걸로 알고 있어서 위 결과로 유추할 수 밖에 없겠습니다.


요약 : Retrofit2의 오버헤드가 반드시 Dynamic proxy 사용 때문이라고는 결론을 내릴 순 없습니다. 위 결과로부터 okhttp3을 직접 사용하는 것이 retrofit2를 사용하는 것보다 좀더 나은 성능을 줄 수 있다라고 볼 수 있을것 같습니다. 

Posted by 제이제이랩
,