AWS 재입문 블로그 – AppSync 편 (한국어)

AppSync 개념 설명 및 serverless-appsync-plugin 을 활용한 튜토리얼 소개 by Classmethod, Inc.
2019.07.29

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

안녕하세요! 클래스메소드의 김태우입니다 :)

이번달에 저희 부서에서 "AWS 재입문 시리즈" 라는 주제로 돌아가면서 글을 쓰고 있는데요, 저는 평소에 관심이 많던 AppSync에 대해 글을 쓰게 되었습니다. 물론 일본어로 쓰는 시리즈입니다만.. 이왕 열심히 쓴 거 아깝잖아요ㅎㅎ 그래서 AppSync 에 관심있는 한국 엔지니어분들과도 공유하기 위해 한국어버전으로도 다시 글을 쓰고 있습니다! 지난번에 AppSync 관련해서 VTL 을 중점적으로 소개했던 글도 있으니 먼저 읽어보셔도 좋을것 같습니다 :)

자, 그럼 시작해보겠습니다!

目次

시작하기전에

AWS AppSync 는 비교적 최근에 출시되어 빠른 속도로 업데이트 되고 있는 서비스이다보니 아직까지 구축사례나 모범사례 등이 많지가 않습니다. 따라서 어디까지나 본 블로그의 글은 AppSync 를 사용하는 Best practice 등의 소개가 아닌 제 개인적인 의견과 생각이 많이 포함되어 있습니다.

AppSync 란?

appsync-architecture

AppSync 는 AWS 에서 제공하는 Managed GraphQL Service 인데요, Apollo 나 Prisma 와는 다르게 AWS 에서 인프라 및 서버까지 제공해주고 관리해주는 서비스라고 보시면 되겠습니다. 그럼 우리는 뭘하면 되느냐하면,

  • GraphQL 의 스키마 정의
  • Resolver 작성
  • DataSource 및 IAM Role 관리

쉽게 생각하면 이 정도가 되겠네요! 즉, 서버리스의 형태로 GraphQL 백엔드를 개발할 수 있는 서비스입니다. AppSync 를 사용하지 않고서도 AWS Lambda (서버리스 아키텍쳐의 핵심) 등을 활용하여 GraphQL 백엔드를 구축하는 것이 가능했으나, 이 경우에는 Lambda 메모리 사이즈, 콜드스타트, DataSource 와의 통신, 인증된 유저 토큰 처리 등등 고민해야하고 직접 개발해야하는 것들이 더 많았습니다. 그러나 AppSync 를 활용하면 GraphQL 스키마를 작성하고 스키마의 각각의 필드에 대한 resolvers 를 작성하는 것만으로도 GraphQL 엔드포인트를 생성할 수 있습니다. 무엇보다도 정말 편리한 것은, resolver 를 VTL 이라고 하는 템플릿언어으로 작성한다는 점인데요, VTL 을 처음 접하시는 분들은 난해하게 느껴지실지도 모르겠습니다. 그런데 막상 VTL 로 resolver 작성을 하면 대부분 그냥 복붙만으로 resolver 생성이 가능하게 됩니다! 이 얼마나 편리한....!! (단, 디버깅은 어렵습니다..'-';)

VTL 로 작성된 resolver 는 Lambda 로 연결되어 커스터마이징도 가능하지만, 그 외에도 DynamoDB, RDB, ElasticSearch, HTTP endpoint 등의 data source 에 직접 연결되기때문에 데이터 핸들링이 매우 간편합니다. 앞서 말씀드린대로 대부분의 경우 Copy and Paste 로 끝나는 경우가 많아서 직접 VTL resolver 를 작성해보시면 편리함을 많이 느끼실 수 있을것 같습니다. (물론, 제대로 활용하기 위해서는 여러가지 노하우가 필요하지만 이런것들에 관해서는 별도의 글로 소개드리도록 하겠습니다!)

여튼, AppSync 에는 정말 편리한 기능들이 많고 백엔드 API 를 개발할 때 엄청난 속도로 개발이 가능해진다는 장점이 있습니다. 물론, Firebase 를 사용해서도 쉽게 백엔드 구축이 가능하지만 Firebase 에 비해 AppSync 가 가지는 장점은 완전한 GraphQL 서비스로서 더욱 월등한 자유도와 유연함을 가지고 있다고 생각할 수 있을 것 같습니다.

따라서 아주 복잡하고 특수한 경우가 아니라면, 개인적으로 대부분의 앱 서비스의 백엔드는 AppSync 로 개발이 가능하다고 생각하고 있습니다. 서버리스 개발이라는 개념의 등장으로 사실 점점 백엔드와 프론트 개발의 경계가 점점 모호해져간다고 생각이 드는데요, AppSync 는 이 흐름에 큰 영향을 끼치고 있는 서비스가 아닌가 생각합니다.

AppSync Sample Project

AppSync 에서는 4종류의 샘플 프로젝트를 제공하고 있습니다. 본 글을 읽고 본격적으로 AppSync 를 살펴봐야겠다고 생각하시는 분들은 꼭 샘플 프로젝트를 하나씩 뜯어보시기를 추천드립니다.

appsync-sample-projects

AppSync 의 기능

AppSync 콘솔에 새로운 API 를 만들면 아래와 같은 화면이 나타납니다. 아래 화면은 본 글에서 설명할 serverless framework 로 AppSync API 를 생성한 후의 캡쳐화면입니다.

appsync-first-page

좌측에 보시면 5가지의 메뉴가 있는 것을 확인하실 수 있는데요, 각각에 대해 하나씩 설명드리겠습니다!

Schema

appsync-schema-page

먼저 Schema 화면에 대해 살펴보겠습니다. Schema 화면에서 제공하는 기능은 GraphQL Schema 작성/저장 및 각 필드에 연결될 resolver 를 생성/조회/수정/삭제 하는 것입니다. 즉, 크게 schemaresolver 를 설정할 수 있다는 말이 되겠네요!

우측 상단에 보시면 Create Resources 라는 버튼이 있는데요, 이 버튼을 누르면 schema 에서 사용할 데이터 모델 타입 정의 + DynamoDB 생성 + 정의된 데이터 타입에 관련된 기본적인 Query, Mutation, Subscription 등의 정의 + 심지어는 resolver 까지 자동생성 해줍니다!

Create Resources 버튼을 눌렀을 때의 화면입니다. appsync-create-resources-1

Create Resources 버튼을 눌렀을 때의 화면에서 스크롤을 내렸을 때의 화면입니다. (DynamoDB 테이블 생성은 물론, 인덱스도 같이 생성할 수 있습니다.) appsync-create-resources-2

Create Resources 를 통해 리소스를 생성했을 때 나타나는 화면입니다. (DynamoDB 생성도 끝난 상태입니다.) appsync-create-resources-3

Resolver 에 나타나는 항목중 Mutation.createMyCustomType 을 클릭했을때 자동생성된 resolver 화면입니다. appsync-create-resources-4

Resolver 에 나타나는 항목중 Mutation.updateMyCustomType 을 클릭했을때 자동생성된 resolver 의 일부입니다. 자동생성된 resolver 가 길어서 한 화면에 캡쳐가 안되네요.. resolver 에 대해서는 아래에 좀 더 자세하게 설명합니다. appsync-create-resources-5

이렇듯 AppSync 를 처음 시작하시는 분들을 위해 AppSync 가 알아서 세팅을 다 해줍니다. 이렇게 자동으로 생성된 Schema 가 동작하는 지 테스트 하기 위해서는 바로 Query 메뉴로 이동해서 실제로 GraphQL endpoint 가 동작하는지 확인하실 수 있는데요, Query 메뉴에 관해서는 아래에서 다시 설명드리겠습니다.

여기서 잠깐 짚고 넘어가고 싶은 부분이 있습니다. AppSync 콘솔에서 이렇게 강력한 기능을 제공해줌에도 불구하고, 짐작하셨겠지만 당연히 실제 프로덕션에서는 이 AppSync 콘솔을 사용하여 개발하는 것을 권장하지 않습니다. 스키마 및 리졸버의 버전 관리도 어렵고, 다른 개발자들과 협업하기도 불편하고 여러모로 불편한 점이 많기 때문인데요,

이를 위해 AWS 에서는 Amplify 라는 CLI + SDK 를 제공합니다. Amplify 는 AppSync API 개발하는 것 이외에도 Cognito 와의 연동을 쉽게 해주거나, S3 버킷으로의 파일 업로드를 쉽게 해주거나 하는 등 클라이언트 개발시에 굉장히 간편하게 이용할 수 있는 툴입니다. 정말 강력하고 유용한 기능들을 많이 제공하고 있긴 하지만, 세세한 설정부분에서는 아직까지 어려운 부분이 많아서 주로 대학생 분들이 학교 과제 및 프로젝트로 많이 이용하는 듯 합니다!

그렇다면 실제 실무에서는 어떤 툴을 쓰는것이 좋을까요. 제가 추천드리는 것은 바로 서버리스 프레임워크의 AppSync 플러그인 serverless-appsync-plugin 입니다. 모든 설정을 yaml 파일(serverless.yml) 로 한방에 끝낼 수 있어서 너무나 간편합니다. IaC (Infrastructure as Code) 는 당연히 기본적으로 CloudFormation 을 통해서도 할 수 있겠지만, AppSync 만큼은 CloudFormation 템플릿을 직접 작성하는 것을 별로 권장하고 싶지는 않습니다... 인생이 괴로워질겁니다....

본 글에서는 serverless-appsync-plugin 으로 아주 간단한 AppSync 프로젝트를 만들어보겠습니다. 우선은 schema 의 스칼라 타입에 대해서도 설명할 부분이 있어서 넘어가보겠습니다!

Scalar Types

알고 계시듯이 GraphQL 은 쿼리언어 자체적으로 type check 을 처리해줍니다. 예를 들어, request 나 response 에서 주고받는 데이터 각각이 Int 타입인지 String 타입인지는 더이상 개발자가 검사하지 않아도 된다는 뜻이죠!

GraphQL 에서 정의하고 있는 일반적인 Scalar 타입은 아래와 같습니다.

  • ID
  • String
  • Int
  • Float
  • Double

이에 추가적으로 AppSync 에서 제공하는 Scalar 타입을 활용하면 더욱 편리하게 API 를 개발할 수 있습니다.

  • AWSDate
  • AWSTime
  • AWSDateTime
  • AWSTimestamp
  • AWSEmail
  • AWSJSON
  • AWSURL
  • AWSPhone
  • AWSIPAddress

따라서 이를 적절하게 조합하면, 아래와 같은 schema 를 만들면 Type check 는 더이상 걱정하지 않아도 됩니다. (GraphQL 최고!ㅎㅎ)

각각의 scalar type 에 대하여 더 자세한 설명은 AWS 문서를 참조해주세요.

아래는 이러한 Scalar type 을 활용하여 작성한 타입의 예시입니다 :)

type User {
  id: ID!
  name: String!
  phone: AWSPhone!
  email: AWSEmail!
  myPageUrl: AWSURL!
  createdAt: AWSDateTime!
}

Resolver

Schema 메뉴에서 Create Resources 버튼을 통해 자동으로 생성된 resolver 에 대한 화면도 잠깐 보셨겠지만, resolver 는 VTL 이라고 하는 자바 기반 템플릿 언어로 작성합니다.

appsync-unit-resolver-concept

AppSync 에서는 Mapping Template 이라고도 부르는데요, request 와 response 할 시에 호출될 resolver 를 각각 정의해줘야합니다. 따라서 각각의 필드에 대하여 request mapping template 과 response mapping template 이 한쌍을 이뤄 하나의 resolver 를 이루게 됩니다. AppSync 에서 resolver 를 mapping template 으로 부르는 이유는 실제로 resolver 의 역할이 data 를 mapping 해주기만 하면 되기 때문인데요, 데이터 소스에 접속하고 데이터를 주고 받는 등의 처리는 전부 AppSync 에서 처리해줍니다.

AppSync 에서 사용할 수 있는 resolver 타입은 2종류가 있습니다.

appsync-resolver-types

Unit Resolver

말 그대로 한방에 바로 끝내버리는 resolver 입니다. 한 개의 데이터소스(DynamoDB, RDS 등)와 연결시켜서 request 와 response 를 처리해주는 resolver 입니다.

Pipeline Resolver

실제로 백엔드 API 를 개발하다보면 Unit resolvers 로 해결되지 않는 복잡한 로직들이 많습니다. 예를 들면, Friendship 테이블에서 두 사람이 친구로 등록된 경우에만 해당 로직을 처리한다던지, 포인트를 사용하여 결제하려는 경우 Point 테이블에서 유저의 포인트가 충분한 경우에만 결제 로직을 처리한다던지 등 여러가지 상황들이 있을 것 같습니다. 이럴 때 사용하면 좋은 것이 바로 Pipeline resolvers 입니다.

appsync-pipeline-resolver

Pipeline resolver 타입의 경우 하나하나의 request mapping template + response mapping template 쌍을 Function 으로 등록하여 사용하게 됩니다. 이 Function 은 다른 resolver 에서도 사용할 수 있어서, 공통적인 로직을 만들어두고 다양한 resolver 에서 사용하는 패턴 등의 활용이 가능합니다.

본 글에서는 다루지 않지만 serverless-appsync-plugin 을 통해서도 간단히 pipeline resolver 를 작성할 수 있습니다 :)

Data Sources

이번에는 Data Sources 메뉴에 대해 알아보겠습니다. 사실 설명할 내용이 별로 없긴 합니다만 우선 Data Sources 메뉴를 클릭하면 나오는 화면은 아래와 같습니다.

appsync-datasource-page

제가 미리 등록해둔 데이터 소스가 보이네요. Create data source 버튼을 누르면 데이터 소스를 생성할 수 있습니다. appsync-datasource-create-datasource

현시점 기준 AppSync 에서 데이터 소스로 등록가능한 타입은 아래의 6종류입니다. appsync-datasource-types

Functions

각각의 Pipeline resolver 는 Before Mapping Template, Functions, After Mapping Template 으로 이루어집니다. 이 때, 중간 부분에 위치한 것이 Function 이 됩니다. 하나하나의 Function 은 기본적으로 unit resolver 와 거의 비슷하게 생겼습니다. 본 글에서는 Pipeline resolver 에 대해 자세하게 다루지 않고 있기때문에 Functions 에 대해서는 간략하게만 보고 넘어가도록 하겠습니다.

먼저 Functions 메뉴를 클릭하면 아래와 같은 화면이 보입니다. 지금은 하나의 Function 을 등록해둔 상태입니다. appsync-functions-page

Create function 버튼을 클릭하면 아래와 같은 화면에서 데이터소스 및 function 이름, request mapping template 및 response mapping template 등을 설정할 수 있습니다.

appsync-create-function-1 appsync-create-function-2

Unit resolver 를 Pipeline resolver 로 변경하면, 이렇게 생성된 Function 을 Pipeline resolver 의 function 으로 등록할 수 있습니다.

appsync-convert-unit-to-pipeline

위의 빨간색 버튼을 누르면 Unit resolver 에서 Pipeline Resolver 로 변경됩니다.

appsync-pipeline-resolver-page

방금 만든 Function 을 추가한 모습입니다.

appsync-pipeline-resolver-function-added

Pipeline Resolver 및 Function 에 대한 좀 더 자세한 내용은 AWS 도큐멘트를 참조해주세요.

Queries

Query 메뉴에서는 GraphQL Playground 같은 기능을 제공해줍니다. Cognito 등을 통하여 로그인한 상태에서 쿼리를 날려보거나, 로그아웃한 상태에서 쿼리를 날려보는 등의 테스트를 해볼 수 있습니다.

appsync-query-page

좌측의 패널이 request 를 작성하는 칸이고, 우측의 패널이 response 가 표시되는 칸입니다.

Settings

Settings 메뉴에서는 API 정보 (엔드포인트 URL, API ID, API KEY), 인증 방법(API Key, Cognito, IAM, OpenID), 로깅 등을 설정할 수 있습니다. Settings 에 관해서는 너무 직관적이라 더이상 설명이 필요없을것 같네요ㅎㅎ

자, 이제부터는 serverless-appsync-plugin 을 이용하여 AppSync 프로젝트를 어떻게 만들 수 있는지 간단한 튜토리얼을 진행해보겠습니다!

AppSync Tutorial with Serverless Framework

이번에는 Serverless Frameworkserverless-appsync-plugin 을 활용하여 간단한 AppSync 프로젝트를 구축해보도록 하겠습니다.

본 튜토리얼에서 사용할 데이터 모델은 아래의 3가지 타입입니다.

type User {
    id: ID!
    name: String!
    email: AWSEmail!
    posts: [Post!]!
    createdAt: AWSDateTime!
}

type Post {
    id: ID!
    user: User!
    title: String!
    content: String!
    likes: [Like!]!
    createdAt: AWSDateTime!
}
    
type Like {
    id: ID!
    user: User!
    post: Post!
    createdAt: AWSDateTime!
}

테스트해 볼 시나리오는 아래와 같습니다.

  • 신규 유저 생성
  • 신규 포스트 작성
  • 포스트에 좋아요 누르기
  • 신규 포스트가 생성되면 subscription 을 통해 전달받기
  • 특정 포스트에 좋아요가 달리면 subscription 을 통해 전달받기

본 튜토리얼의 완성된 코드는 Github Repository 에서 확인하실 수 있습니다.

프로젝트 초기세팅

본 튜토리얼을 실행하기 위해서는 아래의 툴을 먼저 설치해주세요.

제가 사용하고 있는 버전은 아래와 같습니다.

yarn-and-serverless-version

이제 프로젝트 디렉토리를 구성해보겠습니다. 저는 appsync-tutorial 이라는 이름의 디렉토리에서 작업합니다.

mkdir appsync-tutorial
cd appsync-tutorial

touch serverless.yml
mkdir schema
mkdir resolvers

그리고 먼저 serverless-appsync-plugin 을 설치해줍시다.

yarn add serverless-appsync-plugin

마지막에 Done in 10.12s 같은 식으로 출력되면 인스톨이 완료된 것입니다.

Schema

이제 스키마를 작성해보도록 하겠습니다. serverless-appsync-plugin 에서 Schema stitching 이라는 기능을 제공하고 있기때문에 모듈별로 분리해서 스키마를 작성하는 것이 가능합니다.

cd schema
touch user.graphql
touch post.graphql
touch like.graphql

3가지 스키마 파일을 생성했다면 각각 아래와 같이 스키마를 작성해줍시다.

user.graphql

type User {
    userId: ID!
    name: String!
    email: AWSEmail!
    posts: [Post!]!
    createdAt: AWSDateTime!
}

input CreateInputUser {
    name: String!
    email: AWSEmail!
}

type Query {
    listUser: [User!]!
    getUser(userId: ID!): User
}

type Mutation {
    createUser(input: CreateInputUser!): User
}

유저의 경우 이름과 이메일을 input 으로 받아서 생성하고, id 및 createdAt 필드는 resolver 에서 자동으로 생성해줄 것입니다. 위에서 살펴본 AWSEmail 이나 AWSDateTime 등의 AppSync 에서 제공해주는 스칼라 타입이 사용되고 있음을 보실 수 있습니다. 또한 posts 필드의 경우, 다른 필드와 달리 User 테이블이 아닌 Post 테이블에서 값을 가져오게 됩니다. 이 부분에 관해서는 serverless.yml 파일을 작성할 때에 다시 설명하겠습니다.

post.graphql

type Post {
    postId: ID!
    user: User!
    title: String!
    content: String!
    likes: [Like!]!
    createdAt: AWSDateTime!
}

input CreatePostInput {
    userId: ID!
    title: String!
    content: String!
}

type Query {
    listPost: [Post!]!
    listPostByUser(userId: ID!): [Post!]!
    getPost(postId: ID!): Post
}

type Mutation {
    createPost(input: CreatePostInput!): Post
}

type Subscription {
    onNewPostCreated: Post @aws_subscribe(mutations: ["createPost"])
}

포스트의 경우 유저와 비교했을 때 Subscription 이 등장했습니다. Subscription 을 활용하면 AppSync 에서 Mutation 이 실행될 때 관련 데이터를 클라이언트에게 실시간으로 전달해줍니다. 본 튜토리얼의 경우에는 createPost 가 실행되면 onNewPostCreated 라는 subscription 을 등록한 클라이언트에게 전부 값을 실시간으로 전달해주게 됩니다. AppSync 에서의 subscription 은 WebSocket 을 통해 이루어지며, MQTT 프로토콜로 통신합니다. Subscription 과 관련한 자세한 내용은 AWS 도큐먼트를 참조해주세요.

like.graphql

type Like {
    likeId: ID!
    userId: ID!
    postId: ID!
    createdAt: AWSDateTime!
}

type Query {
    listLike(postId: ID!): [Like!]!
}

type Mutation {
    likePost(userId: ID!, postId: ID!): Like
    cancelLikePost(likeId: ID!): Like
}

type Subscription {
    onPostLiked(postId: ID!): Like @aws_subscribe(mutations: ["likePost"])
    onPostLikeCanceled(postId: ID!): Like @aws_subscribe(mutations: ["cancelLikePost"])
}

Like 의 경우 Subscription 이 Post 와는 다르게 특정한 포스트 ID에 대한 subscription 을 받아오고 있는 것을 확인하실 수 있습니다.

본 튜토리얼에서는 AppSync 를 좀 더 직관적으로 쉽게 이해할 수 있도록 돕기 위해 pagination 이나 복잡한 쿼리를 위한 filter 등은 설명하지 않지만, AppSync 에서 제공하는 샘플 프로젝트에서는 pagination 이나 filter 등의 설정을 어떻게 하는지 자세히 살펴볼 수 있습니다.

Resolvers (Mapping Template)

이번에는 resolvers 폴더로 이동합니다. 꽤 많은 resolver 파일을 생성해야합니다. 어찌보면 제일 귀찮을 작업일 수도 있겠지만, 이 부분은 Amplify 를 활용하면 작성한 schema 를 기준으로 필요한 resolver 파일 및 코드를 자동으로 생성해주는 기능이 있어서 필요시에 적절히 활용하면 좋을 듯 합니다. 본 튜토리얼에서는 그냥 하나씩 다 만들어보겠습니다. 파일명은 serverless-appsync-plugin 에서 default 로 인식하는 파일명이므로 왠만하면 이대로 지켜주는 것이 좋습니다.

{type}.{field}.request.vtl
{type}.{field}.respose.vtl

스칼라 타입이나 Subscription 에 대한 resolver 는 따로 만들지 않아도 됩니다.

cd ../resolvers

# User
touch User.posts.response.vtl
touch User.posts.request.vtl
touch Query.getUser.request.vtl
touch Query.getUser.response.vtl
touch Query.listUser.request.vtl
touch Query.listUser.response.vtl
touch Mutation.createUser.request.vtl
touch Mutation.createUser.response.vtl

# Post
touch Post.user.request.vtl
touch Post.user.response.vtl
touch Post.likes.request.vtl
touch Post.likes.response.vtl
touch Query.getPost.request.vtl
touch Query.getPost.response.vtl
touch Query.listPost.request.vtl
touch Query.listPost.response.vtl
touch Query.listPostByUser.request.vtl
touch Query.listPostByUser.response.vtl
touch Mutation.createPost.request.vtl
touch Mutation.createPost.response.vtl

# Like
touch Query.listLike.request.vtl
touch Query.listLike.response.vtl
touch Mutation.likePost.request.vtl
touch Mutation.likePost.response.vtl
touch Mutation.cancelLikePost.request.vtl
touch Mutation.cancelLikePost.response.vtl

튜토리얼치고 굉장히 많은 파일이 생겨버려서 이걸 언제 다 작성하나 싶을수 있지만, 사실 거의 다 복사 붙여넣기한 형태입니다. 모두 작성된 resolver 는 Github respository 를 참조해주세요.

본 글에서는 이중에 몇 개만 살펴보도록 하겠습니다.

Mutation.createUser.request.vtl

$util.qr($context.args.input.put("createdAt", $util.defaultIfNull($ctx.args.input.createdAt, $util.time.nowISO8601())))

{
    "version": "2017-02-28",
    "operation": "PutItem",
    "key": {
        "userId":   $util.dynamodb.toDynamoDBJson($util.defaultIfNullOrBlank($ctx.args.input.userId, $util.autoId()))
    },
    "attributeValues": $util.dynamodb.toMapValuesJson($context.args.input),
    "condition": {
        "expression": "attribute_not_exists(#userId)",
        "expressionNames": {
            "#userId": "userId"
        }
    }
}

Mutation.createUser.response.vtl

$util.toJson($context.result)

VTL 문법을 처음 보시는 분이라면 아마 조금 난잡하게 느껴지실 수 있을 것 같습니다. AppSync 에서 사용하는 VTL 과 관련해서는 AWS 도큐먼트에 아주 쉽게 설명이 되어 있어서 한번만 읽어보시면 바로 이해하시리라 생각합니다.

여기서는 문법을 제외하고 코드의 흐름의 관점에서 살펴보도록 하겠습니다. 우선 각각의 resolver 혹은 mapping template 을 등록할 때 어떤 데이터 소스와 연결될 것인지를 함께 선택하게 됩니다. 본 튜토리얼에서 사용하는 serverless-appsync-plugin 을 사용하면 이를 serverless.yml 파일에 작성하게 됩니다.

따라서 mapping template 을 볼 때에는 Data Source 가 정해진 상태라고 생각하면 됩니다. Mutation.createUser.request.vtl 및 Mutation.createUser.response.vtl 에서는 DynamoDB 의 User 테이블과 연결되어 있습니다.

"version" 은 그냥 항상 "2017-02-28" 값을 가진다고 생각하시면 됩니다. "operation" 은 DynamoDB 의 경우 createUser 에 해당하는 operation 이 "PutItem" 이 됩니다. 이후의 필드에 대해서는 어떤 operation 에 해당하냐에 따라 달라질 수 있겠으나, 여기서는 DynamoDB 의 PutItem 에서는 Key 와 Attribute 를 요구하기 때문에 위와 같이 작성하게 됩니다.

$util, $context (혹은 $ctx), $arguments (혹은 $args) 등은 AppSync 에서 resolver 작성을 위해 제공해주는 오브젝트로서 Context ReferenceUtility Reference 페이지에서 자세하게 살펴보실 수 있습니다.

User.posts.request.vtl

{
    "version" : "2017-02-28",
    "operation" : "Query",
    "index" : "userId-index",
    "query" : {
        "expression": "userId = :userId",
        "expressionValues" : {
            ":userId" : $util.dynamodb.toDynamoDBJson($ctx.source.userId)
        }
    }
}

User.posts.response.vtl

$util.toJson($context.result.items)

User 타입에는 posts 라는 필드가 있었는데요, 이 필드만큼은 User 테이블이 아닌 Post 테이블에서 가져와야합니다. User.posts.request.vtl 를 보시면 아까는 없었던 "index" 라는 항목이 추가가 되었습니다. User.posts.request.vtl 및 User.posts.response.vtl 의 데이터소스는 DynamoDB 의 Post 테이블로 설정해두고, 이 Post 테이블의 GSI(Global Secondary Index) 인 userId-index 를 참조하여 해당하는 userId 에 따른 모든 Post 를 리스트로 가져오는 것입니다. 여기서 GSI 역시 serverless.yml 에서 정의할 수 있습니다.

serverless.yml

이번에는 앞서 언급했던 모든 리소스들이 yaml 파일로서 관리되는 serverless.yml 에 대해 알아보겠습니다.

service: classmethod-appsync-tutorial

frameworkVersion: ">=1.48.0 <2.0.0"

provider:
  name: aws
  runtime: nodejs10.x
  stage: dev
  region: ap-northeast-2

plugins:
  - serverless-appsync-plugin

custom:
  appSync:
    name: AppSyncTutorialByClassmethod
    authenticationType: AMAZON_COGNITO_USER_POOLS
    userPoolConfig:
      awsRegion: ap-northeast-2
      defaultAction: ALLOW
      userPoolId: { Ref: AppSyncTutorialUserPool }
    region: ap-northeast-2
    mappingTemplatesLocation: resolvers
    mappingTemplates:
      
      # User
      - 
        type: User
        field: posts
        dataSource: Post
      - 
        type: Query
        field: listUser
        dataSource: User
      - 
        type: Query
        field: getUser
        dataSource: User
      - 
        type: Mutation
        field: createUser
        dataSource: User

      # Post
      - 
        type: Post
        field: user
        dataSource: User
      - 
        type: Post
        field: likes
        dataSource: Like
      -
        type: Query
        field: listPost
        dataSource: Post
      - 
        type: Query
        field: listPostByUser
        dataSource: Post
      - 
        type: Query
        field: getPost
        dataSource: Post
      - 
        type: Mutation
        field: createPost
        dataSource: Post

      # Like
      - 
        type: Query
        field: listLike
        dataSource: Like
      - 
        type: Mutation
        field: likePost
        dataSource: Like
      - 
        type: Mutation
        field: cancelLikePost
        dataSource: Like

        
    schema:
      - schema/user.graphql
      - schema/post.graphql
      - schema/like.graphql
    
    #serviceRole: # if not provided, a default role is generated
    dataSources:
      - type: AMAZON_DYNAMODB
        name: User
        description: User Table
        config:
          tableName: User
          iamRoleStatements:
            - Effect: Allow
              Action:
                - dynamodb:*
              Resource:
                - arn:aws:dynamodb:${self:provider.region}:*:table/User
                - arn:aws:dynamodb:${self:provider.region}:*:table/User/*

      - type: AMAZON_DYNAMODB
        name: Post
        description: Post Table
        config:
          tableName: Post
          iamRoleStatements:
            - Effect: Allow
              Action:
                - dynamodb:*
              Resource:
                - arn:aws:dynamodb:${self:provider.region}:*:table/Post
                - arn:aws:dynamodb:${self:provider.region}:*:table/Post/*
      
      - type: AMAZON_DYNAMODB
        name: Like
        description: Like Table
        config:
          tableName: Like
          iamRoleStatements:
            - Effect: Allow
              Action:
                - dynamodb:*
              Resource:
                - arn:aws:dynamodb:${self:provider.region}:*:table/Like
                - arn:aws:dynamodb:${self:provider.region}:*:table/Like/*


resources:
  Resources:
    AppSyncTutorialUserPool:
      Type: AWS::Cognito::UserPool
      DeletionPolicy: Retain
      Properties:
        UserPoolName: AppSyncTutorialUserPool
        AutoVerifiedAttributes:
          - email
        Policies:
          PasswordPolicy:
            MinimumLength: 8
        UsernameAttributes:
          - email

    AppSyncTutorialUserPoolWebClient:
      Type: AWS::Cognito::UserPoolClient
      Properties:
          ClientName: Web
          GenerateSecret: false
          RefreshTokenValidity: 30
          UserPoolId: { Ref: AppSyncTutorialUserPool }


    UserTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: User
        KeySchema:
          -
            AttributeName: userId
            KeyType: HASH
        AttributeDefinitions:
          -
            AttributeName: userId
            AttributeType: S
        BillingMode: PAY_PER_REQUEST

    PostTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: Post
        KeySchema:
          -
            AttributeName: postId
            KeyType: HASH
        AttributeDefinitions:
          -
            AttributeName: postId
            AttributeType: S
          -
            AttributeName: userId
            AttributeType: S
        BillingMode: PAY_PER_REQUEST

        # GSI - userId
        GlobalSecondaryIndexes:
          -
            IndexName: userId-index
            KeySchema:
              - AttributeName: userId
                KeyType: HASH
              - AttributeName: postId
                KeyType: RANGE
            Projection:
              ProjectionType: ALL

    LikeTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: Like
        KeySchema:
          - AttributeName: likeId
            KeyType: HASH
        AttributeDefinitions:
          - AttributeName: likeId
            AttributeType: S
          - AttributeName: userId
            AttributeType: S
          - AttributeName: postId
            AttributeType: S
        BillingMode: PAY_PER_REQUEST

        GlobalSecondaryIndexes:

          # GSI - userId
          - IndexName: userId-index
            KeySchema:
              -
                AttributeName: userId
                KeyType: HASH
              -
                AttributeName: likeId
                KeyType: RANGE
            Projection:
              ProjectionType: ALL
          
          # GSI - postId
          - IndexName: postId-index
            KeySchema:
              -
                AttributeName: postId
                KeyType: HASH
              -
                AttributeName: likeId
                KeyType: RANGE
            Projection:
              ProjectionType: ALL

사용하고 있는 serverless 프레임워크의 "frameworkVersion" 이 다른 경우 반드시 값을 조정해주세요. "provider" 에서 "stage" 나 "region" 등을 설정하여 CloudFormation 이 동작할 region 을 설정합니다. serverless.yml 파일 1개당 CloudFormation 스택 1개에 대응합니다. "plugins" 에는 본 튜토리얼에서 사용할 "serverless-appsync-plugin" 을 작성해줍니다.

"custom" 의 "appSync" 하위에 작성된 것들을 serverless-appsync-plugin 에서 읽어들여서 처리하게 됩니다.

하나씩 살펴보면, "authenticationType" 의 경우 API Key 나 Cognito 등을 설정할 수 있는데, 본 튜토리얼에서는 Cognito User Pool 을 생성해서 작업해보겠습니다. "userPoolConfig" 아래에 있는 "userPoolId" 의 "Ref" 는 "custom" 항목 아래의 "resources" 의 AWS::Cognito::UserPool 의 UserPoolName 과 일치해야합니다.

다시 "appSync" 의 "region" 으로 넘어가보겠습니다. 이 항목은 직접 입력하지 않아도 자동으로 "provider" 의 "region" 으로 설정됩니다. "mappingTemplatesLocation" 은 VTL 파일이 위치한 디렉토리의 경로를 의미합니다. default 값으로 mapping-templates 가 설정되지만, 본 튜토리얼에서는 resolvers 라는 폴더 밑에 VTL 파일을 위치시켰기 때문에 resolvers 라고 적어둡니다.

이제 "mappingTemplates" 로 넘어가보겠습니다.

"type" 은 GraphQL schema 의 type 을 의미합니다. User 타입의 경우 posts 라는 Post 타입의 필드가 있었는데, posts 는 스칼라 필드가 아니므로 별도로 resolver 를 만들어줘야합니다.

# User
- 
    type: User
    field: posts
    dataSource: Post

하나만 더 예를 들어보면, listUser 의 경우 type 이 Query 였고, listUser 가 연결될 데이터소스는 DynamoDB 의 User 이므로 아래와 같습니다.

- 
    type: Query
    field: listUser
    dataSource: User

여기서 request 와 response 에 대한 mapping templates 파일명을 입력하지 않았습니다. 사실 "request" 및 "response" 라는 항목에 파일명을 적어도 괜찮지만, 위에서도 언급했듯이 serverless-appsync-plugin 에서는 VTL 파일명의 컨벤션에 따라

request: {type}.{field}.request.vtl
response: {type}.{field}.response.vtl

를 default 값으로 지정해줍니다. 본 튜토리얼에서는 위의 컨벤션을 지키고 있기때문에 별도로 기입해주지 않아도 됩니다.

"appSync" 하위의 "dataSource" 는 실제로 테이블을 생성하는 것이 아니라, 생성된 테이블을 참조하는 역할과 참조를 위한 IAM Role 을 생성해주는 역할을 담당합니다. 실제로 테이블을 생성하는 것은 "custom" 필드 아래의 "resources" 항목 아래에서 생성해줍니다.

여기서는 IAM Role 의 Action 을 편의상

Action:
  - dynamodb:*

로 주었는데, 실제 프로젝트에서는 필요한 기능만을 넣는 것을 권장합니다.

이제 "resources" 항목으로 넘어가보겠습니다.

"resources" 항목에서는 Cognito User Pool 및 DynamoDB 테이블을 생성하고 있습니다. DynamoDB 의 GSI (Global Secondary Index) 를 생성하는 예시로서 참고해주시면 될 것 같습니다.

배포하기

여기까지 완료되었으면 배포는 매우 간단합니다.

serverless deploy -v

위의 명령어를 입력하면 아래와 같이 CloudFormation 을 통해 배포가 시작됩니다. 참고로 sls 는 serverless 의 alias 입니다.

sls-deploy-1 sls-deploy-2

Query 날려보기

이제 AWS 콘솔에 접속해서 직접 Query 메뉴를 통해 실제로 동작하는지 확인해보겠습니다.

그전에, 우선 Cognito User 를 생성해줍시다. Cognito 서비스로 이동합니다. 아래 화면에서 Manage User Pool 버튼을 클릭합니다.

appsync-cognito-intro

생성된 UserPool 을 클릭합니다.

appsync-cognito-select-userpool

좌측 메뉴에서 Users and Groups 를 선택합니다.

appsync-cognito-user-groups

Create User 버튼을 클릭합니다.

appsync-cognito-create-user

serverless.yml 에서 Cognito User Pool 을 생성할 때 설정했던 대로 정보를 채워넣어줍니다.

Properties:
  UserPoolName: AppSyncTutorialUserPool
  AutoVerifiedAttributes:
    - email
  Policies:
    PasswordPolicy:
      MinimumLength: 8
  UsernameAttributes:
    - email

본 튜토리얼에서는 email 을 유저명으로 사용하고 비밀번호는 그냥 8자 이상이면 됩니다.

appsync-cognito-create-user-input

Cognito 유저가 생성되었습니다.

appsync-cognito-user-created

AppSync 로 넘어가기 전에 로그인에 필요한 App Client ID 를 확인하기 위해 App Client Settings 메뉴로 이동합니다.

appsync-cognito-app-client-settings

여기서 Client ID 를 확인하고 클립보드로 복사해둡니다.

appsync-cognito-app-client-id

이제 AppSync 콘솔로 넘어갑니다. Query 메뉴를 클릭하여 아래와 같은 화면으로 이동합니다.

appsync-query-ready

쿼리를 실행시키기 전에 로그인이 필요하므로 로그인 버턴을 눌러서 로그인합니다.

appsync-query-cognito-login

처음 로그인을 하는 경우에는 패스워드를 변경해야합니다. 얼른 바꿔줍시다.

appsync-query-cognito-change-password

먼저 유저부터 생성해보겠습니다. 왼쪽의 mutation 을 작성하고 실행해보면 우측의 결과가 나옵니다.

appsync-query-mutation-createuser

다음은 포스트를 생성해보겠습니다.

appsync-query-mutation-createpost

새로운 유저 및 포스트를 만든 후, 리스트 쿼리를 실행합니다.

appsync-query-listpost

이번에는 특정 유저의 포스트만 가져올 수 있도록 listPostByUser 쿼리를 실행한 결과입니다. 예상한대로 잘 동작합니다. appsync-query-listpostbyuser

다음은 subscription 이 잘 동작하는지 확인해보기 위해, 크롬창을 하나더 열어줍시다. 새로운 포스트가 생성되었을 때 subscription 이 동작하는지를 확인해보겠습니다.

appsync-subscription-test-createPost-ready

오른쪽에서는 subscription 이 실행중이고, 크롬창에서는 왼쪽의 mutation 이 실행되기만을 기다리고 있는 화면입니다. 왼쪽의 mutation 을 실행시키자, 아래화면과 같이 우측의 결과가 즉시 표시되며 subscription 이 제대로 동작함을 확인할 수 있습니다.

appsync-subscription-test-createPost-subscription-work

네, 잘 동작하네요!

마지막으로 특정한 포스트에 좋아요가 달렸을때 subscription 이 동작하는 지도 확인해보겠습니다. 우측의 mutation 이 실행되기를 기다리는 화면입니다.

appsync-subscription-like-post-start

좌측의 postId 와 우측의 postId 가 다른것을 확인해줍시다. 예상되는 결과는 당연히 아무것도 전달되지 말아야합니다. 좌측의 mutation 을 실행시키면, 좌측에는 결과가 즉시 표시되지만, 우측에는 30초 이상을 기다려도 아무런 데이터도 표시되지 않습니다.

appsync-subscription-like-post-nothing-happened

이번에는 같은 postId 로 설정해보겠습니다.

appsync-subscription-like-post-success-expected

좌측의 mutation 을 실행시키자마자 바로 결과가 표시됩니다. 네, 성공입니다!

appsync-subscription-like-post-success

본 글에서의 테스트는 여기서 마무리하겠지만, 좀 더 이것저것 살펴보고 싶으신 분들은 완성된 코드를 직접 AppSync 에 배포해서 실험해주세요ㅎㅎ Github Repository

마무리

개인적으로 이렇게 긴 블로그는 한국어로도 적어본 적이 없는데, 부족한 일본어로 이렇게 긴 기술 블로그를 적느라 정말 힘들었습니다(ㅋㅋ) 끝까지 읽어주신 분들이 계시다면 정말 감사하다고 말씀드리고 싶습니다! AppSync 를 공부하시는 분들에게 아주 조금이라도 도움이 되었다면 저도 기쁠 것 같습니다!

요즘 서버리스로 개발하는 것이 트렌드인 것 같기도 하고, 저도 서버리스의 매력에 흠뻑 빠져있는데요. 아직까지는 개발사례도 부족하고, 다양한 베스트프랙티스에 대한 소개도 많이 부족하지만 이런 것들이 점점 보충이 되어진다면, 개인적으로 AWS AppSync 가 서버리스의 흐름(?)에서 중요한 무언가의 터닝포인트를 제공해 줄 수 있을 것으로 기대하고 있습니다.

다음번에는 serverless framework 을 활용하여 실제 프로젝트에 적용할 수 있는 다양한 팁들을 정리해볼까합니다.

...

그럼, 다들 화이팅입니다!!!ㅎㅎ (마지막을 어떻게 끝내야할지 몰라서 5분간 멍때림..'-')