[스프링] (AA vs. Retrofit) 어노테이션 기반 REST 클라이언트를 위한 프레임워크

웹 개발/스프링 프레임워크

2022. 02. 09.

연구 계기

중국 게임사 미호요는 자사 게임 커뮤니티 앱 HoYoLab을 운영한다. 이 앱은 게임 관련 공략을 공유하는 기본적인 커뮤니티 기능과 더불어, 유저의 게임 내 데이터를 수치화 & 통계해 보여주는 편의 기능도 제공한다.

HoYoLab 커뮤니티 및 유저 게임데이터 예시

 

원신 유저로써 관련 API들의 엔드포인트 주소를 파악하고 인증 방식을 파훼하여 자바 클라이언트[각주:1]를 작성하고 싶었기 때문에 HoYoLab 안드로이드 APK 파일을 살펴보게 되었다.

 

그러던던 중 HTTP 클라이언트 호출 서비스가 상당히 깔끔히 작성되어 있음을 발견하였다.

개발자는 클라이언트 서비스의 인터페이스만 작성하면 되었는데, 이들은 어노테이션으로 꾸며져 선언적으로 구성되어 있다. 이런 방식은 스프링 MVC에서 핸들러 매핑을 작성하는 것과도 흡사하다.

package com.mihoyo.hoyolab.apis.api;

import com.mihoyo.hoyolab.apis.HoYoUrlParamKeys;
import com.mihoyo.hoyolab.apis.bean.AchievementsInfo;
import com.mihoyo.hoyolab.apis.constants.ApiType;
import com.mihoyo.hoyolab.restfulextension.HoYoBaseResponse;
import com.mihoyo.hoyolab.restfulextension.HoYoListResponse;
import kotlin.Metadata;
import kotlin.coroutines.Continuation;
import p861n.p871d.p872a.AbstractC10748e;
import p861n.p871d.p872a.NotNull;
import p874o.p877b0.AbstractC10764t;
import p874o.p877b0.GET;
import p874o.p877b0.Headers;

/* compiled from: GameAchievementsApiService.kt */
@Metadata(m5150d1 = {"\u0000 \n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u000e\n\u0002\b\u0002\bf\u0018\u00002\u00020\u0001J)\u0010\u0002\u001a\u000e\u0012\n\u0012\b\u0012\u0004\u0012\u00020\u00050\u00040\u00032\n\b\u0001\u0010\u0006\u001a\u0004\u0018\u00010\u0007H§@ø\u0001\u0000¢\u0006\u0002\u0010\b\u0082\u0002\u0004\n\u0002\b\u0019¨\u0006\t"}, m5149d2 = {"Lcom/mihoyo/hoyolab/apis/api/GameAchievementsApiService;", "", "getAchievements", "Lcom/mihoyo/hoyolab/restfulextension/HoYoBaseResponse;", "Lcom/mihoyo/hoyolab/restfulextension/HoYoListResponse;", "Lcom/mihoyo/hoyolab/apis/bean/AchievementsInfo;", HoYoUrlParamKeys.f89448j, "", "(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", "apis_release"}, m5148k = 1, m5147mv = {1, 5, 1}, m5145xi = 48)
/* loaded from: classes2.dex */
public interface GameAchievementsApiService {
    @Headers({ApiType.f91191c})
    @AbstractC10748e
    @GET("/game_record/app/card/api/getGameRecordCard")
    Object getAchievements(@AbstractC10764t("uid") @AbstractC10748e String str, @NotNull Continuation<? super HoYoBaseResponse<HoYoListResponse<AchievementsInfo>>> continuation);
}

 

사용된 모듈을 추적해보니 비슷한 사용성을 제공하는 두 가지를 발견했다. 

 

- [Android Annotations] : 프레임워크의 목적은 어노테이션으로 안드로이드 앱 코드를 단순화하는 것이다. [jcodemodel]을 사용해 동적으로 자바 코드를 작성하고 있다. REST-API 모듈이 REST 클라이언트 작성 편의를 제공한다.

 

- [Retrofit] : 위 AA 프레임워크와 달리 JVM 보편적으로 가능한 프레임워크이며 스프링과의 통합도 용이하다. 현 시점에서 내가 이해한 부분은 여기까지이다. 한글 참고 자료[각주:2]를 보며 공부를 시작할 예정이다.

 

코드를 대조해보면 호요랩은 Retrofit을 쓰고 있다. AA의 단점은 안드로이드 환경에 의존한다는 점이며 2020년 이후 업데이트가 없는 점이다. 만약 AA와 스프링 간의 통합 사용시 어떤 추가적인 퍼실리티가 필요할지 고민해 보았다.

 

당연히 결과적으로, 나는 Retrofit을 선택해 REST 클라이언트를 작성할 것이다.

Android Annotations 프레임워크

https://github.com/androidannotations/androidannotations

AA REST-API

선언적으로 REST 클라이언트를 작성하도록 어노테이션들이 정의되어 있다.
스프링 MVC가 요청 핸들러들을 어노테이션으로 작성하도록 한 것과 비슷한 개발 경험을 주려는 것 같다.

[AA REST-API]가 작성한 클라이언트 서비스는 내부에서 [Spring Android]의 RestTemplate을 사용한다.

REST Client 작성 예

도큐먼트에서 발췌한 예시이다. 원본 도큐먼트에 모든 어노테이션 설명이 매우 쉽게 작성되어 있으니 참고하자.

@Rest(rootUrl = "<http://company.com/ajax/services>", converters = { MappingJackson2HttpMessageConverter.class })
// if defined, the url will be added as a prefix to every request
public interface MyService extends RestClientHeaders {

    // url variables are mapped to method parameter names.
    @Get("/events/{year}/{location}")
    @Accept(MediaType.APPLICATION_JSON)
    EventList getEvents(@Path String location, @Path int year);

    // The response can be a ResponseEntity<T>
    @Get("/events/{year}/{location}")
    /*
     * You may (or may not) declare throwing RestClientException (as a reminder,
     * since it's a RuntimeException), but nothing else.
     */
    ResponseEntity<EventList> getEvents2(@Path String location, @Path int year) throws RestClientException;

    @Post("/events/")
    @Accept(MediaType.APPLICATION_JSON)
    Event addEvent(@Body Event event);

    @Post("/events/{year}/")
    Event addEvent(@Body Event event, int year);

    @Post("/events/")
    ResponseEntity<Event> addEvent2(@Body Event event);

    @Post("/events/{year}/")
    @Accept(MediaType.APPLICATION_JSON)
    ResponseEntity<Event> addEvent2(@Body Event event, @Path int year);

    @Put("/events/{id}")
    void updateEvent(@Body Event event, @Path int id);

    // url variables are mapped to method parameter names.
    @Delete("/events/{id}")
    @RequiresAuthentication
    void removeEvent(@Path long id);

    @Head("/events/{year}/{location}")
    HttpHeaders getEventHeaders(@Path String location, @Path int year);

    @Options("/events/{year}/{location}")
    Set<HttpMethod> getEventOptions(@Path String location, @Path int year);

    // if you need to add some configuration to the Spring RestTemplate.
    RestTemplate getRestTemplate();

    void setRestTemplate(RestTemplate restTemplate);
}

REST Client 인스턴스화 & 주입 & 사용

@Exxx로 꾸민 안드로이드 액티비티 클래스> 내부에 "REST 클라이언트 서비스" 타입의 필드를 선언함> 이 필드에 @RestService 어노테이션을 붙여 인스턴스를 주입받는다.

@EActivity
public class MyActivity extends Activity {

    @RestService
    MyRestClient myRestClient; //Inject it

    @AfterViews
    void afterViews() {
        myRestClient.getEvents("fr", 2011); //Play with it
    }
}

AA 통합: 해결할 문제

목표: [AA REST-API] 모듈과
[Spring Context]를 결합해 사용하고 싶다.

스프링 프레임워크와 AA REST-API를 함께 사용하고자 한다면, 위의 소개된 DI 방식을 채택할 수 없다.

앱이 올라갈 컨텍스트는 안드로이드 액티비티가 아니고, 이제 스프링 컨텍스트이니까.

 

이 과제의 관심사는 @Service 객체에게 AA의 @Rest 객체를 주입해주는 것이며, 따라서 AA가 생성한 REST 클라이언트 객체들을 스프링 컨텍스트에 빈 등록해 줄 방법을 떠올려야 한다.

 

일종의 오토 컨피규어러가 "AA REST-API를 부트스트랩> AA REST-API는 어노테이션이 데코레이트 된 REST 클라이언트 서비스들을 생성 후 반환> 이 서비스들을 스프링 컨텍스트에 등록" 한다는 절차를 구현해야 할 것 같다.

 

결과적으로 스프링 컨텍스트에 "REST 클라이언트 서비스" 객체들이 등록된다면

어려움 없이 DI로 이들을 가져다 쓰면 될 일이다.

@Service
public class MySericve extends IMyService {

    @Autowired
    MyRestClient myRestClient;
}

한 편, @Rest는 "인터셉터, 컨버터의 타입"을 주입 받아 이를 내부적으로 생성하는 걸로 보이는데

@Rest(rootUrl = "<http://company.com/ajax/services>", converters = { MappingJackson2HttpMessageConverter.class })
// if defined, the url will be added as a prefix to every request
public interface MyService extends RestClientHeaders {

}

스프링 컨텍스트에 올리는 앱이라면, 인터셉터와 컨버터들은 새로 생성해서 쓰면 안 되고

먼저 스프링 컨텍스트를 탐색해 동일 타입의 빈이 없을 경우에만 새로 생성하여 사용하는 방식이 적절하다.

AA 통합: 세부 과제

“REST 클라이언트 객체”의 부트스트랩
[AA REST-API]가 어노테이션을 발견/처리/클라이언트 작성하는 방식을 모사하자.

AA 관련 자료

AA> 어노테이션 처리 담당자

AA의 어노테이션 처리는 AnnotationHandler류가 도맡는다. 어노테이션 메타 데이터 인식에 이용되는 퍼실리티는 Element이다. (https://docs.oracle.com/javase/8/docs/api/javax/lang/model/element/Element.html)

 

AA> 어노테이션 핸들러의 생성

AA 플러그인들은 내부적으로 어노테이션 핸들러들을 부트스트랩 하여 보유하고 있다. AA Env에 플러그인이 추가될 때 플러그인의 어노테이션 핸들러들도 AA Env에 같이 등록된다.

 

AA REST-API> 플러그인과 어노테이션 핸들러들

[AA REST-API] 기능을 담당하는 AA 플러그인은 RestSpringPlugin이다. 플러그인 코드를 보면 REST 클라이언트 작성에 사용되는 어노테이션 핸들러들이 부트스트랩 되어 있다.

public class RestSpringPlugin extends AndroidAnnotationsPlugin {

    private static final String NAME = "REST-Spring";

    @Override
    public String getName() {
        return NAME;
    }

    @Override
    public List<AnnotationHandler<?>> getHandlers(AndroidAnnotationsEnvironment androidAnnotationEnv) {
        List<AnnotationHandler<?>> annotationHandlers = new ArrayList<>();
        annotationHandlers.add(new RestHandler(androidAnnotationEnv));
        annotationHandlers.add(new FieldHandler(androidAnnotationEnv));
        annotationHandlers.add(new PartHandler(androidAnnotationEnv));
        annotationHandlers.add(new BodyHandler(androidAnnotationEnv));
        annotationHandlers.add(new GetHandler(androidAnnotationEnv));
        annotationHandlers.add(new PostHandler(androidAnnotationEnv));
        annotationHandlers.add(new PutHandler(androidAnnotationEnv));
        annotationHandlers.add(new PatchHandler(androidAnnotationEnv));
        annotationHandlers.add(new DeleteHandler(androidAnnotationEnv));
        annotationHandlers.add(new HeadHandler(androidAnnotationEnv));
        annotationHandlers.add(new OptionsHandler(androidAnnotationEnv));
        annotationHandlers.add(new PathHandler(androidAnnotationEnv));
        annotationHandlers.add(new HeaderHandler(androidAnnotationEnv));
        annotationHandlers.add(new HeadersHandler(androidAnnotationEnv));
        annotationHandlers.add(new RestServiceHandler(androidAnnotationEnv));
        return annotationHandlers;
    }
}

AA REST-API> RestSpringPlugin의 등록 시점

AndroidAnnotationProcessor 객체는 init() 이 호출되는 시점에 AA 플러그인들을 찾아내어 부트스트랩하고 AA Env에 등록한다. 이 때가 RestSpringPlugin 및 REST-API 쪽 어노테이션 핸들러가 등록되는 시점인 것이다.

public class AndroidAnnotationProcessor extends AbstractProcessor {
    ...
    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        ...
            List<AndroidAnnotationsPlugin> plugins = loadPlugins();
            plugins.add(0, corePlugin);
            androidAnnotationsEnv.setPlugins(plugins);
        ...
    }

    private List<AndroidAnnotationsPlugin> loadPlugins() throws FileNotFoundException, VersionNotFoundException {
        ServiceLoader<AndroidAnnotationsPlugin> serviceLoader = ServiceLoader.load(AndroidAnnotationsPlugin.class, AndroidAnnotationsPlugin.class.getClassLoader());
        List<AndroidAnnotationsPlugin> plugins = new ArrayList<>();
        for (AndroidAnnotationsPlugin plugin : serviceLoader) {
            plugins.add(plugin);

            if (plugin.shouldCheckApiAndProcessorVersions()) {
                plugin.loadVersion();
            }
        }
        LOGGER.info("Plugins loaded: {}", Arrays.toString(plugins.toArray()));
        return plugins;
    }

InternalAndroidAnnotationEnvironment의 setPlugins() 과정에서 어노테이션 핸들러들도 같이 등록해주는 걸 확인할 수 있다.

public class InternalAndroidAnnotationsEnvironment implements AndroidAnnotationsEnvironment {
  ...
    public void setPlugins(List<AndroidAnnotationsPlugin> plugins) {
        this.plugins = plugins;
        for (AndroidAnnotationsPlugin plugin : plugins) {
            options.addAllSupportedOptions(plugin.getSupportedOptions());
            for (AnnotationHandler<?> annotationHandler : plugin.getHandlers(this)) {
                annotationHandlers.add(annotationHandler);
            }
        }
    }
}

호출 흐름을 정리하자면

AndroidAnnotationProcessor.init()
→ plugins=ServiceLoader.load(AndroidAnnotationsPlugin.class, ...)
→ InternalAndroidAnnotationEnvironment.setPlugins(plugins)

※ 자바 ServiceLoader의 패턴

AndroidAnnotationProcessor 코드에서 ServiceLoader라는 친구가 플러그인 인스턴스를 만들어 주고 있어 흥미롭다. (https://docs.oracle.com/javase/7/docs/api/java/util/ServiceLoader.html)

  • ServiceLoader.load(AndroidAnnotationsPlugin.class ...)
  • SPI 패턴이라는데, 이 개념을 ‘하이버네이트 방언’ 챕터에서 얼핏 봤던 것 같다.
  • 다만 내용을 깊이 공부하지 않았으므로 추가 학습을 진행하도록 하자.

 

스프링 DI를 사용하여
AA가 작성한 “REST 클라이언트 객체”의 주입

  1. REST 클라이언트 서비스들은 @Autowired@Inject 를 통해 필드 주입될 수 있어야한다.
  2. 즉 스프링 컨텍스트에 [AA REST-API]가 생성한 REST 클라이언트 서비스들이 빈으로서 등록되어야 한다.
  3. @Service 객체 부트스트랩 이전에 오토 컨피규어러가 AA REST-API 모듈의 기능을 먼저 호출하여 “REST 클라이언트 객체”들을 미리 빈 등록해 둘 필요가 있다.

 

스프링 관습에 최적화

@Rest 어노테이션에서 선언한 컨버터인터셉터 타입은 스프링 컨텍스트에 등록된 빈을 검색하는 데 사용되어야 한다.

연관 빈이 없을 경우에야 새 인스턴스를 생성하여 RestTemplate에 주입하는 방식이 되어야 한다.

이 목표를 달성하기 위해 @Rest 핸들러를 래핑하여 기능을 확장한 새로운 핸들러를 작성하여야 하겠다.

 

 

AA 통합: 결론

살펴 보았듯이 AA의 REST-API 기능은 AndroidAnnotations 프레임워크의 AnnotationProcessor의 플러그인 형태로 추가되어 제공되기 때문에 AA 의존적이라 스프링과 통합이 난해하다. 대조적으로 Retrofit은 스프링과 잘 결합되고 있는 사례들을 확인할 수 있었다[각주:3].

따라서 본인의 호요랩 REST 클라이언트 작성 과제는 Retrofit을 선택한다.

파생 이슈

  • 자바 타입 시스템과 Element에 대한 공부
  • ServiceLoader SPI 패턴에 대한 학습
  • 스프링 오토 컨피규어레이션 작성을 위한 학습