해당 게시글은 AWS Amplify 가이드를 따라 진행하며 작성하였습니다.

들어가며

AWS Amplify는 AWS 리소스들을 가용하여 손쉽게 프론트-백엔드, 호스팅, 배포까지 풀스택으로 애플리케이션을 개발하도록 돕는 서비스의 집합입니다.

Amplify에 대한 이야기를 하던 중 하상엽 사원님께서 Google사의 Firebase와 비슷한 것 같다는 말을 하셨는데, 저도 듣자 마자 느낌이 딱! 하고 오더라구요.

자세한 기능들을 프로젝트를 구성해보면서 알아가면 좋을 것 같습니다.

시작하기 전에 : Amplify 가이드를 일부 따라하시더라도 리소스를 가용하셨다면 꼭 삭제가 필요합니다. 과금이 발생할 수 있으니 유의해주세요!

본 게시글은 클라우드를 통해 Todo list를 만들어보고 싶다. 또는, amplify를 빠르게 경험해보고 싶다는 분께 추천드립니다.


개요

무스마에는 뛰어난 개발자분들이 많이 계시기 때문에 Amplify를 전적으로 쓰고 있지는 않습니다만,

유저관리, api 요청 시 인증 등 구현 또는 관리에 공수가 많이 들어가는 부분은 Amplify의 기능을 일부 사용하고 있습니다.

아래에서 곧 다룰 내용이니 기대해 주세요!

혹시 스크롤 압박을 느끼셨을 수도 있는데, 코드 스니펫이니 양해 부탁드립니다.

(코드양이 많은 것은 펼치기 버튼을 눌러서 확인해주세요.)

가이드를 따라 하다보면 3시간 가량 소요됩니다.


본 가이드에서 다룰 토픽들

  • Next.js App
  • Web Application Hosting
  • Authentication
  • GraphQL API
  • Authorization
  • Deleting the resource

구현할 기능들

  • 애플리케이션 호스팅
  • 회원가입 및 로그인
  • 게시글 작성 및 댓글

구성도

위와 같은 기능들로 구현할 애플리케이션의 구성도는 다음과 같습니다.

image

개발 환경

시작하기전에 아래의 패키지가 설치되어 있어야 합니다.

  • Node.js v10.x or later
  • npm v5.x or later
  • git v2.14.1 or later
  • Bash Shell, Browser
    • CLI 환경으로 필요한 리소스를 생성하고, 로컬에서 브라우저를 통해 확인합니다.

Get Strated

Create Next App 을 이용하여 새로운 프로젝트를 생성해봅시다.

npx create-next-app amplify-forum

생성된 디렉토리로 이동해서, aws-amplify 연관 패키지들을 설치해봅시다.

$ cd amplify-forum
$ yarn add aws-amplify @aws-amplify/ui-react

Style

TailwindCSS를 이용하여 스타일을 적용하겠습니다. 개발 환경에서만 사용하기 위해 --dev옵션을 적용해주세요.

$ yarn add --dev tailwindcss@latest postcss@latest autoprefixer@latest @tailwindcss/forms

Tailwind을 다음 명령어을 통해 초기화해주세요.

$ npx tailwindcss init -p

초기화가 잘 진행돠었다면 아래의 파일들이 생성됩니다.

  • tailwind.confing.js
  • postcss.config.js

tailwind.config.js파일의 내용을 다음과 같이 변경합니다.

module.exports = {
-  purge: [],
+  purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {},
  },
  variants: {
    extend: {},
  },
-  plugins: [],
+. plugins: [require('@tailwindcss/forms')],
}

Tailwind의 base, component, utilities 스타일이 사용되도록 next.js에서 생성된 ./styles/globals.css 파일에 아래의 내용을 추가합니다.

/* ./styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

테스트를 위한 index page

기본으로 생성된 pages/index.js를 변경합니다.

pages/index.js
import Head from "next/head";

function Home() {
  return (
    <div>
      <Head>
        <title>Amplify Forum by dyson</title>
        <link
          rel="icon"
          href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🐕</text></svg>"
        />
      </Head>

      <div className="container mx-auto">
        <main className="bg-white">
          <div className="px-4 py-16 mx-auto max-w-7xl sm:py-24 sm:px-6 lg:px-8">
            <div className="text-center">
              <p className="mt-1 text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight lg:text-6xl">
                Amplify Forum
              </p>
              <p className="max-w-xl mx-auto mt-5 text-xl text-gray-500">
                Welcome to Amplify Forum
                Hi, There!, I am dyson.
              </p>
            </div>
          </div>
        </main>
      </div>

      <footer></footer>
    </div>
  );
}

export default Home;

이제 문제없이 로딩이 잘 되는 지 확인해봅시다!

yarn dev 명령어로 로컬에 서버를 띄우고 브라우저로 접속을 시도해봅시다!

http://localhost:3000


Amplify CLI 설치 및 AWS Credential

Amplify CLI는 커맨드-라인-인터페이스(터미널, 쉘등)으로 간편하게 amplify와 관련한 클라우드 리소스 관리할 수 있도록 돕는 인터페이스입니다.

바로 설치해볼까요?

$ npm install -g @aws-amplify/cli

AWS Credential

설치가 완료되었다면, 사용전에 준비해야할 부분이 있습니다.

명령만 내리면 클라우드 리소스가 생성되는데, 그렇다면 AWS도 누가 해당 서비스를 사용하는 지, 신원확인이 필요할 것입니다.

그에 대한 설정이 Credential 입니다.

아래의 참고를 보시고 천천히 따라 설정해주세요.

문서로 확인하셔도 되고, 2분 가량의 비디오로 확인하셔도 됩니다.

아래는 예시화면입니다.

$ amplify configure

- Specify the AWS Region: ap-northeast-2
- Specify the username of the new IAM user: amplify-cli-user
> In the AWS Console, click Next: Permissions, Next: Tags, Next: Review, & Create User to create the new IAM user. Then return to the command line & press Enter.
- Enter the access key of the newly created user:
? accessKeyId: (<YOUR_ACCESS_KEY_ID>)
? secretAccessKey: (<YOUR_SECRET_ACCESS_KEY>)
- Profile Name: amplify-cli-user

참고로 서울리전은 ap-northeast-2입니다. 또 IAM 설정은 리전에 국한되지 않습니다.


Amplify Project 초기화

아래의 명령어로 초기화를 진행할 수 있습니다.

$ amplify init

- Enter a name for the project: amplifyforum
- Enter a name for the environment: dev
- Choose your default editor: Visual Studio Code (or your default editor)
- Please choose the type of app that youre building: javascript
- What javascript framework are you using: react
- Source Directory Path: src
- Distribution Directory Path: out
- Build Command: npm run-script build
- Start Command: npm run-script start
- Do you want to use an AWS profile? Y
- Please choose the profile you want to use: amplify-cli-user

Distribution Directory Path 는 꼭 out 으로 변경해주세요.

(next.js 에서 build 후 export 를 하면 out 디렉토리로 결과물이 들어갑니다.)

amplify init 초기화가 끝나면, amplify 폴더가 생성되고 src 폴더아래 aws-exports.js 파일이 생성됩니다.

src/aws-exports.js 는 amplify 의 설정값들이 들어있습니다.

amplify/team-provider-info.json 파일에는 amplify 프로젝트의 back-end 환경(env) 관련 변수들이 들어가 있습니다.

다른 사람들과 동일한 백엔드 환경을 공유하고 싶다면, 이 파일을 공유하면 됩니다.

만약에 프로젝트를 공개하고 싶은 경우라면 이 파일은 빼주는게 좋습니다. (.gitignore 에 추가)


자, 이제 초기화가 잘 되었는 지 확인해 볼까요?

  • amplify 프로젝트의 상태를 보고 싶다면
    $ amplify status
    
  • 위와 같은 내용을 콘솔에서 확인하고 싶다면
    $ amplify console
    

Next 앱 설정

프로젝트가 생성되고 준비되었으니, 이제 테스트를 해봅시다.

우선 해야할 일은 우리가 만들고 있는 app에서 Amplify project에 대해 인삭하도록 하는 것입니다.

src 폴더 안에 자동생성된 aws-exports.js 파일을 참조하도록 추가해봅시다.

설정을 위해 pages/_app.js 파일을 열고, 다음 코드를 추가합니다.

  import '../styles/globals.css'
+ import Amplify from "aws-amplify";
+ import config from "../src/aws-exports";
+ Amplify.configure(config);

  function MyApp({ Component, pageProps }) {
    return <Component {...pageProps} />
  }

  export default MyApp

위 코드가 추가되면, app에서 AWS service를 이용할 준비가 됩니다.


Hosting

Amplify Console은 배포와 CI를 위한 hosting 서비스입니다.

우선 Build 스크립트 변경을 위해 packages.json 안의 내용 중 scripts 부분을 다음과 같이 변경합니다.

"scripts": {
  "dev": "next dev",
-  "build": "next build",
+  "build": "next build && next export",
  "start": "next start"
},

위에서 보이는 next export는 next.js app을 static HTML 파일로 변환해 주기 때문에 node 서버없이 app을 로딩시켜줍니다.

Hosting을 추가하기 위해, 다음 명령어를 실행해주세요.

$ amplify add hosting

? Select the plugin module to execute: Hosting with Amplify Console (Managed hosting with custom domains, Continuous deployment)
? Choose a type: Manual deployment

다음 명령어로 변경사항(add hosting)을 적용해 봅니다.

$ amplify push

적용이 되었다면 배포까지 해봅시다.

$ amplify publish

배포가 완료 되었다면, 브라우저에서 터미널로 출력된 url로 들어가셔서 next.js앱이 정상적으로 로딩되는 것을 확인해주세요.

image

호스팅까지 잘 완료가 되었네요. 축하드립니다!

이제 원하는 기능을 쇼핑하듯이 하나씩 담아서 사용하시면 됩니다.


로그인(SignUp)

AWS 서비스중 cognito를 사용하여 회원가입, 로그인, 접근 제어등을 관리하는 기능을 추가하겠습니다.

$ amplify add auth

? Do you want to use default authentication and security configuration? Default configuration
? How do you want users to be able to sign in when using your Cognito User Pool? Username
? Do you want to configure advanced settings? No, I am done.

추가하였다면 변경사항을 적용해봅시다.

$ amplify push

? Are you sure you want to continue? Yes

이렇게 cognito를 사용할 준비가 끝이 났습니다. 이제 앱으로 돌아가서 이를 적용해보도록 합시다.

인증 또는 로그인된 사용자 들만 접근했으면 하는 페이지에

withAuthenticator HOC (Higher Order Component) 를 적용하면 됩니다.

예를 들면, /pages/index.js 페이지에 withAuthenticator를 적용하면,

사용자는 반드시 로그인을 해야합니다.

로그인이 되어있지 않다면, 로그인 페이지로 이동하게 됩니다.

그렇다면 한번 테스트해볼까요?

pages/index.js파일을 조금 수정해봅시다.

import Head from "next/head";
+ import { withAuthenticator } from "@aws-amplify/ui-react";

- export default Home;
+ export default withAuthenticator(Home);

파일수정을 완료했다면, 브라우저에서 테스트해봅시다.

$ yarn dev

로그인 페이지가 잘 로딩되나요?

로그인페이지

로그인 창이 뜨는 것으로, Authentication 절차가 app에 추가된 것을 확인할 수 있습니다.

다음으로 추가적인 테스트를 위해 계정생성을 해주세요.

(계정 생성중…)

계정 생성 요청?을 하면 생성 당시 입력했던 이메일로 확인코드(confirmation code)가 전송됩니다.

이를 입력해주면서 비로소, 계정이 생성됩니다.

cognito 서비스를 확인해볼 겸 서비스 콘솔로 들어가서 확인해봅시다.

$ amplify console auth

> Choose User Pool

콘솔에 위와 같이 입력하면 아래와 같이 설정되어 있는 기본 브라우저로 열릴거에요!

유저풀


로그아웃(SignOut)

로그아웃 기능도 구현해보도록 합시다.

기능구현이라는 표현보다는 기능을 배치한다는 것이 맞다고 생각이 될 정도로 간편한 것 같아요.

import { withAuthenticator, AmplifySignOut } from "@aws-amplify/ui-react";

/* UI 어딘가에 넣어주세요. */
<AmplifySignOut />;

SignOut 버튼을 눌러서 로그아웃이 잘 되는지도 확인해보세요.


유저 데이터 접근

로그인된 사용자이지만 사용자마다 권한이 다른 경우들이 있습니다.

이럴땐 또 어떻게 해야할까요…?

예를 들어 영화예매 앱을 만든다고 가정했을 때 관람연령기준에 따라 예매가능여부를 결정해야합니다.

이럴 때 유저 데이터에 접근하여 생년월일을 확인하여 다음 절차를 밟아야 합니다.

각설하고 이러한 기능을 사용하려면 아래와 같이 코드를 수정하면 됩니다.

+ import { useEffect } from "react";
+ import { Auth } from "aws-amplify";

function Home() {
+ useEffect(() => {
+ checkUser(); // new function call
+ }, []);
+
+ async function checkUser() {
+   const user = await Auth.currentAuthenticatedUser();
+   console.log("user: ", user);
+   console.log("user attributes: ", user.attributes);
+ }

  /* 이전과 동일 */
}

브라우저 콘솔을 열고 페이지를 로딩하면, 접근한 유저정보의 출력들을 볼 수 있습니다.

유저정보접근


UI 적용

하드코딩된 mocking 데이터인 (TOPICS) 로 Topic 목록과 새로운 Topic 을 추가하는 화면을 구현해봅시다.

pages/index.js 를 다음과 같이 변경합니다.

pages/index.js
import Head from "next/head";
import { withAuthenticator, AmplifySignOut } from "@aws-amplify/ui-react";
import { useEffect, useState, Fragment } from "react";
import { Auth } from "aws-amplify";
import { DotsVerticalIcon } from "@heroicons/react/solid";
import { ViewGridAddIcon } from "@heroicons/react/solid";
import { Dialog, Transition } from "@headlessui/react";
import Link from "next/link";

const TOPICS = [
  {
    title: "Graph API",
    comments: { nextToken: null, items: [] },
  },
  {
    title: "Component Design",
    comments: { nextToken: null, items: [] },
  },
  {
    title: "Templates",
    comments: { nextToken: null, items: [] },
  },
  {
    title: "React Components",
    comments: { nextToken: null, items: [] },
  },
];

function Grid({ topics }) {
  return (
    <div>
      <ul className="grid grid-cols-1 gap-5 mt-3 sm:gap-6 sm:grid-cols-2 lg:grid-cols-4">
        {topics.map((topic) => (
          <li
            key={topic.title}
            className="flex col-span-1 rounded-md shadow-sm"
          >
            <div className="flex items-center justify-between flex-1 truncate bg-white border-t border-b border-r border-gray-200 rounded-r-md">
              <div className="flex-1 px-4 py-2 text-sm truncate">
                <Link href={`/topic/${topic.id}`}>
                  <a className="font-medium text-gray-900 hover:text-gray-600">
                    {topic.title}
                  </a>
                </Link>
                <p className="text-gray-500">{topic.updatedAt}</p>
              </div>
              <div className="flex-shrink-0 pr-2">
                <button className="inline-flex items-center justify-center w-8 h-8 text-gray-400 bg-transparent bg-white rounded-full hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                  <span className="sr-only">Open options</span>
                  <DotsVerticalIcon className="w-5 h-5" aria-hidden="true" />
                </button>
              </div>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

function Form({ formData, setFormData, handleSubmit, disableSubmit }) {
  const handleChange = (e) => {
    setFormData({ ...formData, [e.target.id]: e.target.value });
  };

  return (
    <div className="bg-white sm:rounded-lg">
      <div className="px-4 py-5 sm:p-6">
        <h3 className="text-lg font-medium leading-6 text-gray-900">
          New Topic
        </h3>
        <div className="max-w-xl mt-2 text-sm text-gray-500">
          <p>새로운 주제를 생성해주세요.</p>
        </div>
        <form className="mt-5 sm:flex sm:items-center">
          <div className="w-full sm:max-w-xs">
            <label htmlFor="title" className="sr-only">
              Title
            </label>
            <input
              type="text"
              name="title"
              id="title"
              className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
              placeholder="제목"
              value={formData.title}
              onChange={handleChange}
            />
          </div>
          <button
            onClick={handleSubmit}
            type="button"
            className={`disabled:opacity-50 inline-flex items-center justify-center w-full px-4 py-2 mt-3 font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm ${
              disableSubmit && "cursor-not-allowed"
            }`}
          >
            Create
          </button>
        </form>
      </div>
    </div>
  );
}

function Modal({ open, setOpen, children }) {
  return (
    <Transition.Root show={open} as={Fragment}>
      <Dialog
        as="div"
        static
        className="fixed inset-0 z-10 overflow-y-auto"
        open={open}
        onClose={setOpen}
      >
        <div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
          <Transition.Child
            as={Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0"
            enterTo="opacity-100"
            leave="ease-in duration-200"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
          >
            <Dialog.Overlay className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" />
          </Transition.Child>

          {/* This element is to trick the browser into centering the modal contents. */}
          <span
            className="hidden sm:inline-block sm:align-middle sm:h-screen"
            aria-hidden="true"
          >
            &#8203;
          </span>
          <Transition.Child
            as={Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
            enterTo="opacity-100 translate-y-0 sm:scale-100"
            leave="ease-in duration-200"
            leaveFrom="opacity-100 translate-y-0 sm:scale-100"
            leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
          >
            <div className="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
              {children}
            </div>
          </Transition.Child>
        </div>
      </Dialog>
    </Transition.Root>
  );
}

function AddNewTopicButton({ onClick }) {
  return (
    <button
      onClick={onClick}
      type="button"
      className="inline-flex items-center px-6 py-3 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
    >
      <ViewGridAddIcon className="w-5 h-5 mr-3 -ml-1" aria-hidden="true" />
      Add New Topic
    </button>
  );
}

function Home() {
  const [open, setOpen] = useState(false);
  const [formData, setFormData] = useState({ title: "" });
  const topics = TOPICS;

  useEffect(() => {
    checkUser(); // new function call
  }, []);

  async function checkUser() {
    const user = await Auth.currentAuthenticatedUser();
    console.log("user: ", user);
    console.log("user attributes: ", user.attributes);
  }

  const disableSubmit = formData.title.length === 0;

  console.log("topics = ", topics);

  return (
    <div>
      <Head>
        <title>Amplify Forum</title>
        <link
          rel="icon"
          href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22></text></svg>"
        />
      </Head>

      <div className="container mx-auto">
        <main className="bg-white">
          <div className="px-4 py-16 mx-auto max-w-7xl sm:py-24 sm:px-6 lg:px-8">
            <div className="text-center">
              <p className="mt-1 text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight lg:text-6xl">
                Amplify Forum
              </p>
              <p className="max-w-xl mx-auto mt-5 text-xl text-gray-500">
                Welcome to Amplify Forum
              </p>
              <Grid topics={topics} />
              <div className="mt-10" />
              <AddNewTopicButton onClick={() => setOpen(true)} />
            </div>
          </div>
          <Modal open={open} setOpen={setOpen}>
            <Form
              formData={formData}
              setFormData={setFormData}
              disableSubmit={disableSubmit}
            />
          </Modal>
        </main>
        <AmplifySignOut />
      </div>

      <footer></footer>
    </div>
  );
}

export default withAuthenticator(Home);

적용하였다면 바뀐 UI를 봐야겠죠?

$ yarn dev

API 추가

GraphQL 서비스를 사용하여 api를 구성해보도록 합시다.

$ amplify add api

? Please select from one of the below mentioned services: GraphQL
? Provide API name: amplifyforum
? Choose the default authorization type for the API Amazon Cognito User Pool
Use a Cognito user pool configured as a part of this project.
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)

기본 인증 방식은 Cognito UserPool(로그인 사용자)입니다.


Topic & Comment 모델 추가

이제 api 호출을 위한 정의를 해줘야합니다.

  • 로그인된 사용자(owner)는 Topic과 comment CRUD 가능
  • Moderator group 은 Topic 과 Comment Read/Update/Delete 가능
  • 나머지 로그인 사용자들은 Topic 과 Comment Read 가능

amplify/backend/api/amplifyforum/schema.graphql 파일을 열어 다음 내용을 추가해줍니다.

amplify/backend/api/amplifyforum/schema.graphql
type Topic
  @model
  @auth(
    rules: [
      { allow: owner }
      {
        allow: groups
        groups: ["Moderator"]
        operations: [read, update, delete]
      }
      { allow: private, operations: [read] }
    ]
  ) {
  id: ID!
  title: String!
  comments: [Comment] @connection(keyName: "topicComments", fields: ["id"])
}

type Comment
  @model
  @key(name: "topicComments", fields: ["topicId", "content"])
  @auth(
    rules: [
      { allow: owner }
      {
        allow: groups
        groups: ["Moderator"]
        operations: [read, update, delete]
      }
      { allow: private, operations: [read] }
    ]
  ) {
  id: ID!
  topicId: ID!
  content: String!
  topic: Topic @connection(fields: ["topicId"])
}

변경 사항 적용을 위해 amplify push --y 명령어를 실행합니다.

$ amplify push --y

GraphQL API와 연동

GraphQL API 를 이용하여 데이터를 가져와서 UI에 보여줍시다.


Topic 목록 가져오기

pages/index.js를 다음과 같이 변경합니다.

+ import { API } from "aws-amplify";
+ import * as queries from "../src/graphql/queries";

/* 이전과 동일 */

function Home() {
  const [open, setOpen] = useState(false);
  const [formData, setFormData] = useState({ title: "" });
- const topics = TOPICS
+ const [ topics, setTopics ] = useState([]);

  useEffect(() => {
    checkUser();
+   fetchTopics();
  }, []);

+ async function fetchTopics() {
+   try {
+     const data = await API.graphql({ query: queries.listTopics });
+     setTopics(data.data.listTopics.items);
+   } catch (err) {
+     console.log({ err });
+   }
+  }
}

위 코드 중 Topic 목록을 가져오는 핵심적인 코드는 아래와 같습니다.

const data = await API.graphql({ query: queries.listTopics });

새로운 Topic 생성

새로운 Topic 을 생성하는 API를 연동해봅시다.

pages/index.js를 다음과 같이 변경합니다.

pages/index.js
import * as queries from "../src/graphql/queries";
+ import * as mutations from "../src/graphql/mutations";

/* 이전과 동일 */

function Home() {
  const [open, setOpen] = useState(false);
  const [formData, setFormData] = useState({ title: "" });
  const [topics, setTopics] = useState([]);
+ const [createInProgress, setCreateInProgress] = useState(false);

  /* 이전과 동일 */

+ async function createNewTopic() {
+   setCreateInProgress(true);
+   try {
+     const newData = await API.graphql({
+       query: mutations.createTopic,
+       variables: { input: formData },
+     });
+
+     console.log(newData);
+     alert("New Topic Created!");
+     setFormData({ title: "" });
+   } catch (err) {
+     console.log(err);
+     const errMsg = err.errors
+       ? err.errors.map(({ message }) => message).join("\n")
+       : "Oops! Something went wrong!";
+     alert(errMsg);
+   }
+   setOpen(false);
+   setCreateInProgress(false);
+ }

- const disableSubmit = formData.title.length === 0;
+ const disableSubmit = createInProgress || formData.title.length === 0;

  console.log("topics = ", topics);

  return (
    <div>
      <Head>
        <title>Amplify Forum</title>
        <link
          rel="icon"
          href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22></text></svg>"
        />
      </Head>

      <div className="container mx-auto">
        <main className="bg-white">
          <div className="px-4 py-16 mx-auto max-w-7xl sm:py-24 sm:px-6 lg:px-8">
            <div className="text-center">
              <p className="mt-1 text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight lg:text-6xl">
                Amplify Forum
              </p>
              <p className="max-w-xl mx-auto mt-5 text-xl text-gray-500">
                Welcome to Amplify Forum
              </p>
              <Grid topics={topics} />
              <div className="mt-10" />
              <AddNewTopicButton onClick={() => setOpen(true)} />
            </div>
          </div>
          <Modal open={open} setOpen={setOpen}>
            <Form
              formData={formData}
              setFormData={setFormData}
              disableSubmit={disableSubmit}
+             handleSubmit={createNewTopic}
            />
          </Modal>
        </main>
        <AmplifySignOut />
      </div>

      <footer></footer>
    </div>
  );
}

위 코드 중 Topic을 생성하는 핵심적인 코드는 아래와 같습니다.

const newData = await API.graphql({
  query: mutations.createTopic,
  variables: { input: formData },
});

이제 개발 서버를 띄우고(yarn dev), 브라우저에서 테스트 해봅시다.

(…테스트 중)

토픽생성테스트

새로운 Topic 생성이 잘 되지만, 화면에 업데이트 되지는 않습니다.

어떻게 하면 좋을까요? 두가지 방법이 있습니다.

(1) 화면 리로딩을 하고 전체 데이터 로딩

(2) Subscription 을 통한 업데이트.

GraphQL API에서는 Subscription 기능도 제공합니다. 따라서 Subsription 을 이용해보도록 하겠습니다.


Subscription 을 통한 업데이트

pages/index.js 에서 onCreatePost 이벤트에 subscription 을 생성합니다.

onCreatePost이벤트를 구독한다.’라고 표현합니다. 디자인패턴 중 옵저버패턴을 읽어보면 도움이 됩니다.

import * as queries from "../src/graphql/queries";
import * as mutations from "../src/graphql/mutations";
+ import * as subscriptions from "../src/graphql/subscriptions";

/* 이전과 동일 */

function Home() {
  /* 이전과 동일 */
  useEffect(() => {
    checkUser();
    fetchTopics();
+   const subscription = subscribeToOnCreateTopic();
+     return () => {
+       subscription.unsubscribe();
+     };
  }, []);
+ function subscribeToOnCreateTopic() {
+   const subscription = API.graphql({
+     query: subscriptions.onCreateTopic,
+   }).subscribe({
+     next: ({ provider, value }) => {
+       console.log({ provider, value });
+       const item = value.data.onCreateTopic;
+       setTopics((topics) => [item, ...topics]);
+     },
+     error: (error) => console.warn(error),
+   });

+   return subscription;
+ }

  /* 이전과 동일 */
}

yarn dev로 서버를 띄우고, 브라우저로 로컬에 접속해봅시다.

Topic을 생성하고 잠시 기다리면,

(Boom!)

토픽자동갱신

화면이 자동으로 갱신되는 것을 확인할 수 있습니다.

구독(subscription)을 하는 부분은 아래의 코드입니다.

const subscription = API.graphql({
  query: subscriptions.onCreateTopic,
}).subscribe({
  next: ({ provider, value }) => {
    console.log({ provider, value });
    const item = value.data.onCreateTopic;
    setTopics((topics) => [item, ...topics]);
  },
  error: (error) => console.warn(error),
});

Topic Page

토픽 목록 페이지에서 토픽을 선택하면 topic/12311231231 와 같은 상세페이지로 넘어가고,

Comment 들을 포함한 상세 내용을 보여줘야 합니다.

이번에는 Next.js 의 Dynamic Routes(https://nextjs.org/docs/routing/dynamic-routes) 을 이용하여,

(1) 토픽의 제목과 토픽내 코멘트들을 보여주고 (2) 새로운 토픽을 추가할수 있는 페이지를 만들어보겠습니다.

pages/topic/[id].js 파일을 생성하고, 다음과 같이 채워줍시다.

[id].js
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect, useState, Fragment } from "react";
import { API } from "aws-amplify";
import { ChatAltIcon, UserCircleIcon } from "@heroicons/react/solid";
import * as queries from "../../src/graphql/queries";
import * as mutations from "../../src/graphql/mutations";

function CommentList({ commentsItems }) {
  if (commentsItems.length === 0) {
    return (
      <div className="flow-root">
        <div className="text-center">
          <p className="max-w-xl mx-auto mt-5 text-xl text-gray-500">
            등록된 글이 없습니다.
          </p>
        </div>
      </div>
    );
  }

  return (
    <div className="flow-root">
      <ul className="-mb-8">
        {commentsItems.map((commentItem, commentItemIdx) => (
          <li key={commentItem.id}>
            <div className="relative pb-8">
              {commentItemIdx !== commentItem.length - 1 ? (
                <span
                  className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-gray-200"
                  aria-hidden="true"
                />
              ) : null}
              <div className="relative flex items-start space-x-3">
                <>
                  <div className="relative">
                    <UserCircleIcon
                      className="items-center justify-center w-10 h-10 text-gray-500"
                      aria-hidden="true"
                    />

                    <span className="absolute -bottom-0.5 -right-1 bg-white rounded-tl px-0.5 py-px">
                      <ChatAltIcon
                        className="w-5 h-5 text-gray-400"
                        aria-hidden="true"
                      />
                    </span>
                  </div>
                  <div className="flex-1 min-w-0">
                    <div>
                      <div className="text-sm">
                        <span className="font-medium text-gray-900">
                          {commentItem.owner}
                        </span>
                      </div>
                      <p className="mt-0.5 text-sm text-gray-500">
                        Commented at {commentItem.createdAt}
                      </p>
                    </div>
                    <div className="mt-2 text-sm text-gray-700">
                      <p>{commentItem.content}</p>
                    </div>
                  </div>
                </>
              </div>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

function TopicForm({ formData, setFormData, handleSubmit, disableSubmit }) {
  const handleChange = (e) => {
    setFormData({ ...formData, [e.target.id]: e.target.value });
  };

  return (
    <div>
      <label
        htmlFor="content"
        className="block text-sm font-medium text-gray-700"
      >
        Comment
      </label>
      <div className="mt-1">
        <textarea
          id="content"
          name="content"
          rows={5}
          className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
          value={formData.content}
          onChange={handleChange}
        />
      </div>
      <div className="mt-2" />
      <button
        type="button"
        onClick={handleSubmit}
        className={`inline-flex items-center px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
          disableSubmit && "cursor-not-allowed"
        }`}
      >
        Add New Comment
      </button>
    </div>
  );
}

function TopicPage() {
  const router = useRouter();
  const { id: topicId } = router.query;
  const [topic, setTopic] = useState();
  const [formData, setFormData] = useState({ content: "" });
  const [createInProgress, setCreateInProgress] = useState(false);
  const [comments, setComments] = useState([]);
  const [commentNextToken, setCommentNextToken] = useState();

  useEffect(() => {
    if (topicId) {
      fetchTopic();
    }
  }, [topicId]);

  const fetchTopic = async () => {
    console.log("fetching with topicId = ", topicId);
    const data = await API.graphql({
      query: queries.getTopic,
      variables: { id: topicId },
    });
    setTopic(data.data.getTopic);
    setComments(data.data.getTopic.comments.items);
    setCommentNextToken(data.data.getTopic.comments.nextToken);
  };

  async function createNewComment() {
    setCreateInProgress(true);
    try {
      const newData = await API.graphql({
        query: mutations.createComment,
        variables: { input: { ...formData, topicId: topicId } },
      });

      console.log(newData);
      alert("New Comment Created!");
      setFormData({ content: "" });
    } catch (err) {
      console.log(err);
      const errMsg = err.errors
        ? err.errors.map(({ message }) => message).join("\n")
        : "Oops! Something went wrong!";

      alert(errMsg);
    }

    setCreateInProgress(false);
  }

  const disableSubmit = createInProgress || formData.content.length === 0;

  return (
    <div>
      <Head>
        <title>Amplify Forum</title>
        <link
          rel="icon"
          href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22></text></svg>"
        />
      </Head>

      <div className="container mx-auto">
        <main className="bg-white">
          <div className="px-4 py-16 mx-auto max-w-7xl sm:py-24 sm:px-6 lg:px-8">
            <div className="text-center">
              <p className="mt-1 text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight lg:text-6xl">
                {!topic && "Loading..."}
                {topic && topic.title}
              </p>
            </div>
            {topic && (
              <>
                <div className="mt-10" />
                <CommentList commentsItems={comments} />
                <div className="mt-20" />
                <TopicForm
                  formData={formData}
                  setFormData={setFormData}
                  disableSubmit={disableSubmit}
                  handleSubmit={createNewComment}
                />
              </>
            )}
          </div>
        </main>
      </div>
    </div>
  );
}

export default TopicPage;

yarn dev로 서버를 띄우고 , 브라우저에서 Topic의 상세페이지가 잘 나오는 지 확인해봅시다.

토픽상세페이지


Filtered Subscription 추가

이번에도 역시 새로운 데이터가 (Comment) 추가될때 화면이 업데이트 될수 있도록 Subscription 을 추가하도록 하겠습니다.

하지만 이번에는 필터링 기능을 사용하여 TopicId 와 관련된 Comment 들만 업데이트 받도록 해보겠습니다.

amplify/backend/api/amplifyforum/schema.graphql 파일을 열어 다음 내용을 추가해줍니다

type Subscription {
  onCreateCommentByTopicId(topicId: String!): Comment
    @aws_subscribe(mutations: ["createComment"])
}

이제 amplify push --y 명령어로 변경사항을 적용합니다.

pages/topic/[id].js 을 다음과 같이 변경해주세요.

import * as mutations from "../../src/graphql/mutations";
+ import * as subscriptions from "../../src/graphql/subscriptions";

/* 이전과 동일 */

function TopicPage() {
  /* 이전과 동일 */

  useEffect(() => {
    if (topicId) {
      fetchTopic();
+     const subscription = subscribeToOnCreateComment();
+     return () => {
+       subscription.unsubscribe();
+      };
    }
  }, [topicId]);

+  function subscribeToOnCreateComment() {
+    const subscription = API.graphql({
+      query: subscriptions.onCreateCommentByTopicId,
+      variables: {
+        topicId: topicId,
+      },
+    }).subscribe({
+      next: ({ provider, value }) => {
+        console.log({ provider, value });
+        const item = value.data.onCreateCommentByTopicId;
+        console.log("new comment = ", item);
+        setComments((comments) => [item, ...comments]);
+      },
+      error: (error) => console.warn(error),
+    });
+
+    return subscription;
+  }

  /* 이전과 동일 */
}

이제 적용한 것들을 테스트해봅시다.

Comment 를 생성해보고, 화면 업데이트가 잘 되는지 확인해주세요.

브라우저를 여러개 띄우고 여러개의 토픽 페이지를 띄우고 테스트 해보세요.

다중브라우저


comment 삭제

Comment 삭제기능도 추가해봅시다.

amplify/backend/api/amplifyforum/schema.graphql 파일을 열어

onDeleteCommentByTopicId이벤트를 구독해줍니다.

type Subscription {
  onCreateCommentByTopicId(topicId: String!): Comment
    @aws_subscribe(mutations: ["createComment"])
+ onDeleteCommentByTopicId(topicId: String!): Comment
+   @aws_subscribe(mutations: ["deleteComment"])
}

amplify push --y 명령어로 변경사항을 적용해줍시다.


pages/topic/[id].js 에 delete button 을 추가해줍니다.

[id].js
/* 이전과 동일 */

function CommentList({ commentsItems }) {
  /* 이전과 동일 */

  return (
    <div className="flow-root">
      <ul className="-mb-8">
        {commentsItems.map((commentItem, commentItemIdx) => (
          <li key={commentItem.id}>
            <div className="relative pb-8">
              {commentItemIdx !== commentItem.length - 1 ? (
                <span
                  className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-gray-200"
                  aria-hidden="true"
                />
              ) : null}
              <div className="relative flex items-start space-x-3">
                <>
                  <div className="relative">
                    <UserCircleIcon
                      className="items-center justify-center w-10 h-10 text-gray-500"
                      aria-hidden="true"
                    />

                    <span className="absolute -bottom-0.5 -right-1 bg-white rounded-tl px-0.5 py-px">
                      <ChatAltIcon
                        className="w-5 h-5 text-gray-400"
                        aria-hidden="true"
                      />
                    </span>
                  </div>
                  <div className="flex-1 min-w-0">
                    <div>
                      <div className="text-sm">
                        <span className="font-medium text-gray-900">
                          {commentItem.owner}
+                          <span className="float-right">
+                            <DeleteCommentButton comment={commentItem} />
+                          </span>
                        </span>
                      </div>
                      <p className="mt-0.5 text-sm text-gray-500">
                        Commented at {commentItem.createdAt}
                      </p>
                    </div>
                    <div className="mt-2 text-sm text-gray-700">
                      <p>{commentItem.content}</p>
                    </div>
                  </div>
                </>
              </div>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

+ function DeleteCommentButton({ comment }) {
+   async function deleteComment() {
+     if (!confirm("Are you sure?")) {
+       return;
+     }
+
+     const deletedComment = await API.graphql({
+       query: mutations.deleteComment,
+       variables: { input: { id: comment.id } },
+     });
+
+     alert("Deleted a comment");
+     console.log("deletedComment = ", deletedComment);
+   }
+
+   return <button onClick={deleteComment}>delete</button>;
+ }

function TopicPage() {
  /* 이전과 동일 */

  useEffect(() => {
    if (topicId) {
      fetchTopic();
-      const subscription = subscribeToOnCreateComment();
+      const onCreateSubscription = subscribeToOnCreateComment();
+      const onDeleteSubscription = subscribeToOnDeleteComment();
+      return () => {
-        subscription.unsubscribe();
+        onCreateSubscription.unsubscribe();
+        onDeleteSubscription.unsubscribe();
+      };
    }
  }, [topicId]);

  /* 이전과 동일 */

+  function subscribeToOnDeleteComment() {
+    const subscription = API.graphql({
+      query: subscriptions.onDeleteCommentByTopicId,
+      variables: {
+        topicId: topicId,
+      },
+    }).subscribe({
+      next: ({ provider, value }) => {
+        console.log({ provider, value });
+        const item = value.data.onDeleteCommentByTopicId;
+        console.log("deleted comment = ", item);
+        setComments((comments) => comments.filter((c) => c.id !== item.id));
+      },
+      error: (error) => console.warn(error),
+    });
+
+    return subscription;
+  }

  /* 이전과 동일 */
}

이제 마지막 테스트를 해볼까요?

Comment 를 삭제해보고, 화면 업데이트가 잘 되는지 확인해주세요.

브라우저를 여러개 띄우고 여러개의 토픽 페이지를 띄우고 테스트 해보시면 됩니다.

코맨트삭제

추가적으로 여러개의 브라우저를 띄워놓고 한 쪽에서 로그아웃을 하면 어떻게 되는지 등을 테스트해봅시다.

사용자세션


배포

이제 모든 테스트를 완료했습니다.

바로 배포해보도록 합시다.

$ amplify publish

수 분만 기다리면 배포가 완료됩니다.

amplify로 프로젝트를 만드신 것을 축하드립니다.

길고 긴 과정을 오느라 수고하셨습니다.

(풀스택 개발자는 참… 어렵네요.)


앗! 과금!

네, 과금이 되니 프로젝트에서 사용한 리소스는 꼭 삭제해주셔야합니다.

$ amplify delete

이렇게 한 줄로 모든 리소스를 삭제할 수 있습니다.

적용된 명령이 잘 실행되었는지 한번 체크해주시면 가이드는 마무리됩니다.


소감

Amplify로 프로젝트를 구성해보면서 느낀 점은 정말 빠르게 개발의 한 사이클을 경험해보았다는 것입니다.

물론, AWS 서비스가 다 해주니 가능한 것입니다만 cognito, graphql 등의 서비스들을 경험해본 뒤,

차후에 직접 구현할 기회가 온다면, 오늘의 프로젝트가 Best Practice가 될 수 있다고 생각합니다.

또한 본 가이드에서 S3를 사용하는 예시도 있지만 해당 게시글에는 담지 않습니다.

아쉬워할 분들을 위해 몇몇 아티틀을 소개하고 저는 이만 떠나가 보도록 하겠습니다.

기회가 된다면 여러 종류의 스토리지를 다루는 내용으로 포스팅을 해볼까 합니다.

긴 글 읽어주셔서 감사합니다.

References