[AWS 입문] AWS Amplify + Android – GraphQL API 추가해보기

Android + AWS Amplify로 GraphQL API 추가를 테스트해보았어요! AWS AppSync를 통해 DynamoDB로 데이터를 읽고 쓰는 과정을 다루고 있습니다.
2020.06.30

안녕하세요! 클래스메소드 신입엔지니어 정하은입니다. 🐣

벌써 6월 끝자락이라니 정말 시간 빠르게 간다는 걸 몸소 느끼고 있는 요즘입니다.. 이번 달은 사내 발표 준비하랴, 시험 준비하랴, 여러모로 바쁜 날들이 계속 될 정도로 정신없이 지나갈 정도였는데요..ㅠㅠ 덕분에 블로그도 몇 편 쓰지 못해 아쉬움이 남네요.

특히 발표 준비라는 말을 해서 떠올랐는데, 7월 1일에 저희 회사 한국 멤버들이 행사를 기획하여 진행하게 되었는데요! 제가 마지막 발표 주자로 참여하게 되었습니다ㅎㅎ 제가 한국어 블로그로 작성했던 내용을 재구성하여 발표할 예정이니 혹시라도 신청하신 분들은 복습차 가벼운 마음으로 들어주시면 될 것 같아요.

아무튼 잡담은 여기까지 하고 본론으로 돌아갈게요!

지난 블로그에서는 Amazon Cognito를 활용한 인증 추가 부분까지 마쳤는데요. 이번 블로그에서는 AWS AppSync를 통한 API 추가 부분을 다루어 보도록 할게요.

API를 추가하면 어떤 동작을 해주나요?

혹시 제가 앞서 작성했던 블로그에서 언급했던 내용을 기억하시나요?

AWS AppSync가 GraphQL을 이용해 데이터를 가져올 수 있는 관리형 서비스이며, 오프라인에서도 데이터 엑세스가 가능하다고 간단하게 설명을 적어두었는데요. 그림을 통해 표현하게 되면 아래와 같이 나타낼 수 있습니다.

저희가 실제 모바일 앱을 사용할 시에 AWS Amplify를 거쳐, GraphQL을 사용해 AWS AppSync가 데이터와 관련된 작업을 수행하게 되는데요.

그런데 그 데이터는 어디에 저장이 될까요? 미리 데이터베이스를 설계해둔 것도 아닌데, 어떻게 데이터를 읽고 쓸 수 있는 건지 신기할 뿐...😦

정답은 AppSync에서 생성된 API가 Amazon DynamoDB라는 데이터베이스에 맞춰 테이블을 생성해주고, 읽어들일 수 있도록 도와주는 것이랍니다! 그렇기 때문에 따로 데이터베이스 엔진을 설치할 필요도 없고, 테이블을 미리 작성해 둘 필요없이 AWS 측에서 편리하게 데이터를 읽고 쓸 수 있도록 지원해줍니다.

혹시라도 AWS AppSync가 할 수 있는 기능에 대해 더 알고 싶으신 분들은 아래의 블로그를 참고해주세요.

GraphQL API 추가하기

그럼 본격적으로 API를 추가하기 위한 과정을 진행해볼게요!

API 설정

API 추가를 위해 cmd에서 amplify add api를 입력하시면 플러그인 스캔을 시작합니다. 스캔을 마치면 질문 공세가 이어지니 아래와 같이 설정을 진행해주세요.

여기서 중간에 Choose the default authorization type for the API 라는 질문을 볼 수 있는데, 왜 여기서 인증이 필요한 가에 대해서 궁금하실 수 있을 것 같네요. 일단 사용하는 목적을 단도직입적으로 이야기하면 보안 때문인데요. 인증을 받은 API Key, Amazon Cognito User Pool, IAM, OpenID Connect를 통해 API에 접근을 허용하고, 각 인증 유형에 맞춰 데이터에 접근할 수 있도록 해준답니다. 저희는 앞서 Auth를 추가하였기 때문에 Amazon Cognito User Pool을 선택해주세요.

그리고 또 아래로 내려가 보면 What best describes your project 라는 질문이 있어요. 이 항목도 Single object with fields, One-to-many relationship, Objects with fine-grained access control 로 세 가지 선택 옵션이 나옵니다. 이 세 가지 옵션에 간단하게 정리해볼게요!

  • Single object with fields : 여러 개의 필드로 이루어진 객체를 하나의 데이터로 묶어 사용할 때 (Todo 리스트처럼 한 필드에 하나의 값을 가지며, 각 필드의 데이터를 하나의 데이터로 묶어 처리하는 형태)
  • One-to-many relationship : 일대다 관계로 사용할 때 (블로그처럼 하나의 포스트가 있는데 여러 개의 댓글을 가지는 형태)
  • Objects with fine-grained access control : 데이터 소유자가 특정 테이블에 액세스하여 어떤 액션을 하는지 지정하여 사용할 때

어려운 이야기지만, 데이터베이스를 공부하셨던 분들이라면 어느 정도 감이 오시지 않을까 싶네요😉 (죄송합니다.. 단지 제가 설명을 잘 못하는 것 뿐이에요 흑흑)

아무튼 위 사진대로 설정을 진행하시면, GraphQL 스키마 생성이 완료되었다고 하며 로컬 상에 리소스 생성이 성공적으로 마쳤다는 메시지가 뜨게 됩니다!

activity_main.xml 수정

일단 [프로젝트] - [amplify] - [backend] - [api] - [API명] - schema.graphql 경로로 들어가 Visual Studio Code와 같은 편집 프로그램으로 내용을 확인해보시면, 아래와 같은 스키마가 나올 거에요.

Type Todo @model {
   id: ID!
   name: String!
   description: String!
}

하지만 이번에 제가 시험해 볼 앱 상에서는 스키마의 필드들을 모두 사용하지 않고, Main Activity의 디자인에 따라 스키마를 변경할 계획입니다. 일단 스키마는 그대로 놔두고, Android Studio를 실행시켜주세요.

저는 버튼을 누르면 텍스트 입력칸에 입력된 데이터를 DynamoDB로 전송하여 저장된 데이터들을 다시 불러오는 작업을 하는 형태를 구상해보았습니다. 이미지 상으로는 Sub item이라는 항목이 표시되어 있지만, 이번에는 DynamoDB에서 데이터가 어떻게 넘어오는지만 테스트 해보기 위해 Sub item을 사용하지 않도록 할게요. 소스코드는 아래를 참고해주세요!

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/addText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Button" />

    <ListView
        android:id="@+id/TestList"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"/>

</LinearLayout>

스키마 수정

그럼 xml 수정도 마쳤으니 다시 스키마를 띄워 수정해보도록 할게요.

Type Text @model {
   id: ID!
   text: String!
}

본래 name, description이라는 두 가지 String 필드를 갖고 있었지만, 위의 방식대로라면 입력되는 String 값은 하나밖에 없기 때문에 필드 하나를 삭제 해 주었어요.

그럼 다시 cmd로 돌아가서, amplify push로 변경사항을 클라우드 상에 반영시켜주도록 합니다.

GraphQL 스키마 컴파일에 성공했다는 문구가 나온 뒤, 다시 질문 공세가 저희를 괴롭히지만 다 엔터로 넘어가주셔도 무방해요.

이렇게 설정을 마치면 AWS 측에서 열심히 API를 위한 리소스를 생성해주는데, 작업이 완료될 때까지 기다려주세요!

Android 프로젝트 소스코드 추가 및 수정

리소스 생성을 마쳤으면, 다시 Android Studio로 돌아가 소스코드 추가와 수정을 해볼게요.

SDK 추가

먼저, AWS AppSync의 기능을 사용하기 위한 SDK를 build.gradle(:app)의 dependencies에 추가해주세요.

 //For AWSAppSyncClient:
    implementation 'com.amazonaws:aws-android-sdk-appsync:3.0.1'

Client 클래스 생성

그 다음, 화면 좌측의 java 아래에 괄호가 써져있지 않은 폴더를 우클릭하여 자바 클래스 파일을 생성합니다. 그럼 조그맣게 클래스명을 입력하라고 뜰텐데, 'ClientTest' 라고 입력해주세요.

클래스 생성을 하셨으면 아래의 소스코드를 안에 복붙해주세요!

public class ClientTest {
    private static volatile AWSAppSyncClient client;

    public static synchronized void init(final Context context) {
        if (client == null) {
            final AWSConfiguration awsConfiguration = new AWSConfiguration(context);
            client = AWSAppSyncClient.builder()
                    .context(context)
                    .awsConfiguration(awsConfiguration)
                    .cognitoUserPoolsAuthProvider(new CognitoUserPoolsAuthProvider() {
                        @Override
                        public String getLatestAuthToken() {
                            try {
                                return AWSMobileClient.getInstance().getTokens().getIdToken().getTokenString();
                            } catch (Exception e){
                                Log.e("APPSYNC_ERROR", e.getLocalizedMessage());
                                return e.getLocalizedMessage();
                            }
                        }
                    }).build();
        }
    }

    public static synchronized AWSAppSyncClient appSyncClient() {
        return client;
    }
}

여기서 ClientTest라는 클래스를 따로 만든 이유는 AWS AppSync를 사용하기 위해서는 Client가 필요하기 때문인데요. 앞서 API에 접근을 하기 위해서는 보안 때문에 인증이 필요하다고 언급을 했었죠. 실제로 위의 소스코드를 보시면 CognitoUserPoolAuthProvider를 이용하여 접근한다는 것을 확인할 수 있답니다.

Main Activity 수정

그럼 마지막으로 데이터를 읽고 쓰기 위한 코드를 추가하기 위해 아래의 소스코드를 복붙해주세요!

public class MainActivity extends AppCompatActivity {

    ListView mListView;
    Button mButton;
    EditText mEditText;

    private ArrayList<ListTextsQuery.Item> mText;
    private ArrayAdapter<ListTextsQuery.Item> Adapter;
    private final String TAG = MainActivity.class.getSimpleName();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mListView = findViewById(R.id.TestList);
        mButton = findViewById(R.id.addText);
        mEditText = findViewById(R.id.editText);

        ClientTest.init(this);

        mButton.setOnClickListener(new View.OnClickListener(){
            @Override
            public void onClick(View v) {
                save();

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                query();
            }
        });
    }

    @Override
    protected void onStart() {
        super.onStart();

        // Query list data when we start application
        query();
    }

    public void query() {
        ClientTest.appSyncClient().query(ListTextsQuery.builder().build())
                .responseFetcher(AppSyncResponseFetchers.CACHE_AND_NETWORK)
                .enqueue(queryCallback);
    }

    private GraphQLCall.Callback<ListTextsQuery.Data> queryCallback = new GraphQLCall.Callback<ListTextsQuery.Data>() {
        @Override
        public void onResponse(@Nonnull Response<ListTextsQuery.Data> response) {

            mText = new ArrayList<>(response.data().listTexts().items());

            Log.i(TAG, "Retrieved list items: " + mText.toString());

            Adapter = new ArrayAdapter<>(MainActivity.this, android.R.layout.simple_list_item_1, mText);

            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    mListView.setAdapter(Adapter);
                    Toast.makeText(MainActivity.this, "Print text", Toast.LENGTH_SHORT).show();
                }
            });
        }

        @Override
        public void onFailure(@Nonnull ApolloException e) {
            Log.e(TAG, e.toString());
        }
    };

    private void save() {
        final String text = mEditText.getText().toString();

        CreateTextInput input = CreateTextInput.builder().text(text).build();
        CreateTextMutation addTextMutation = CreateTextMutation.builder().input(input).build();

        ClientTest.appSyncClient().mutate(addTextMutation).enqueue(mutateCallback);

        mEditText.setText("");
    }

    // Mutation callback code
    private GraphQLCall.Callback<CreateTextMutation.Data> mutateCallback = new GraphQLCall.Callback<CreateTextMutation.Data>() {
        @Override
        public void onResponse(@Nonnull final Response<CreateTextMutation.Data> response) {
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    Toast.makeText(MainActivity.this, "Added text", Toast.LENGTH_SHORT).show();
                }
            });
        }

        @Override
        public void onFailure(@Nonnull final ApolloException e) {
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    Log.e("", "Failed to perform AddTextMutation", e);
                    Toast.makeText(MainActivity.this, "Failed to add text", Toast.LENGTH_SHORT).show();
                    MainActivity.this.finish();
                }
            });
        }
    };
}

(여기서 저의 지저분한 소스코드 작성 습관이 들통나버린😅 저는 복붙하기 편하게 Activity 안에 다 넣어버렸지만, GraphQLCallback 메소드 부분은 따로 클래스 작성해서 사용하는 걸 추천드려요!)

이 소스코드도 살펴보면, onClick이라는 이벤트가 발생할 시에 save()를 거쳐 데이터를 저장하고, query()를 통해 데이터를 불러오고 있는 것을 알 수 있는데요. 중간에 sleep을 준 것은 save()의 반영시간이 조금 걸린다는 부분 때문입니다ㅠㅠ 실제로 여러분께서 sleep 부분을 지워서 실행해보시면 리스트에 가장 최근에 등록한 데이터가 나오지 않는 걸 확인할 수 있어요.

그리고 MainActivity가 실행될 시에도 기존에 저장된 데이터는 나타나야하니 onStart()에서도 query()를 호출하고 있답니다.

build.gradle 버전 수정과 플러그인 추가

"자 이제 소스코드도 추가 했으니 실행해서 확인해봐야지ㅎㅎㅎ" 라고 하며 가볍게 테스트를 끝내려고 한 엠린이. 하지만 여기서 문제가 발생합니다...

빌드가 안 된다니!! 😱😱😱 아니, 하라는대로 했는데 빨간 글씨는 왜 뜨는 것이죠...

라고 생각하며, 오류의 원인을 열심히 구글링 한 결과, 앞서 저희가 API에서 생성한 리소스를 사용해야하는데 플러그인을 추가 하지 않으면 제대로 불러오지를 못한답니다..

그럼 build.gradle(:AmplifyTest) 라는 파일로 가서 dependencies의 내용을 수정해줄게요!

dependencies {
        classpath "com.android.tools.build:gradle:3.2.0"

        classpath 'com.amazonaws:aws-android-sdk-appsync-gradle-plugin:2.6.+'
}

그리고 아마 여러분께서 최신 버전의 Android Studio를 사용하고 계시다면 build gradle 버전이 4.0.0이실텐데, 이 버전에서 플러그인을 추가하려고 하면 또 에러메시지가 뜨면서 추가를 할 수 없게 되는데요. 이때문에 버전을 3.2.0으로 낮추어야 사용이 가능합니다.

다음으로 gradle-wrapper.properties 파일에 있는 distrivutionUrl을 아래와 같이 수정해주세요.

distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip

마지막으로 build.gradle(:app)에서 맨 첫 줄에 apply plugin: 'com.amazonaws.appsync'을 추가해주신 뒤, 우측에 뜨는 Sync Now를 누르시고 잠시 기다리시면 빌드가 된 것을 확인할 수 있답니다!

이렇게 많은 과정을 거쳐야 AppSync를 쓸 수 있다니... 최신 버전이라고 항상 좋은 게 아니라는 걸 다시 느꼈어요.

실행해보기

그렇다면 이제 애뮬레이터로 실행을 하여 제대로 작동하는지 확인해볼게요!

드디어 실행 화면을 볼 수 있다니 감격스럽네요ㅠㅠ 아마 이전 블로그에서 만들어진 앱을 지우시고 다시 하신 경우라면 로그인 화면이 나타나고, 전에 한 번 로그인한 이력이 있으시면 자동으로 Main Activity가 나올 거에요.

이번 테스트에서는 실제로 데이터 입력 시에 item의 전체적인 정보를 불러오도록 했기 때문에 text 값만 깔끔히 나오게 하지는 않았지만, 위의 코드를 수정하시면 입력한 데이터값만 출력해내도록 수정도 가능하답니다. 이 부분은 추후에 다시 다루어보도록 할게요.

그리고 위 gif를 자세히 보시면 lion이 dog 아래에 추가가 되는 것을 확인할 수 있는데요. id에서 숫자가 가장 먼저 나올 경우, 그 값을 기준으로 정렬을 하는 것 같아 이 부분도 분석의 여지가 있다고 느꼈습니다.

마무리하며

여기까지 잘 따라오신 여러분 정말 수고하셨습니다!👏👏 이번 과정은 앞의 다른 글을 쓸 때보다 시행착오가 많았었네요.. API가 정착화 되지 못한 탓인지 SDK의 문제인지까지는 알 수 없지만 이 부분은 더 분석이 필요하다고 느꼈어요..

그래도 실제로 데이터가 움직이는 과정에 대한 부분을 확인해 볼 수 있었던 것에 대해서는 큰 수확이었다고 생각합니다!

최근에 Amplify 라이브러리도 정식적으로 출시가 되어서 계속 발전하고 있는 것이 확연히 보이고 있답니다. (드디어 Windows 유저도 라이브러리를 쓸 수 있다니!) 저도 계속해서 Amplify 블로그 시리즈를 써나갈 수 있도록 노력할테니 많은 관심 부탁드려요ㅎㅎ

다음 Amplify 블로그에서는 라이브러리 사용 후기에 대한 내용을 다루어보도록 하겠습니다😄

참고 사이트

AWS Amplify를 이용한 Android 앱 개발 실습 - 1부