# Next.js 및 Amplify로 메시지 애플리케이션 구축

이 실습에서는 Amplify를 사용하여 AWS에서 풀스택 서버리스 애플리케이션을 구축합니다.
* 우리가 만들 애플리케이션은 Reddit과 같은 메시지 포럼이 될 것입니다.
* 사용자가 이 실습을 진행하면 AWS에서 실행되는 애플리케이션이 만들어집니다.
* 이 실습은 2~4시간 내에 완료될 것으로 예상됩니다.
---
## 소개
### 개요
이 실습은 Reddit과 유사한 포럼을 구축하는 과정을 진행합니다.
* [Create Next App](https://nextjs.org/docs/api-reference/create-next-app) 을 사용하여 새 프로젝트를 만듭니다.
* AWS Cloud 환경 설정 [Amplify CLI](https://github.com/aws-amplify/amplify-cli) 및 [Amplify JS 라브러리](https://github.com/aws-amplify/amplify-js)를 연결하기 위해 AWS 클라우드의 백엔드에 대한 Next.js 앱을 만듭니다.
이 프로젝트는 다음 아키텍처를 사용하는 완전 서버리스 애플리케이션입니다.

### 필수 배경/레벨
이 실습은 프론트엔드 및 백엔드 개발자를 위해 만들어졌습니다. AWS에서 전체 스택 서버리스 애플리케이션을 구축하는 방법에 대해 자세히 알아보십시오.
:::info
React와 GraphQL에 대한 지식이 있으면 도움이 되지만 필수는 아닙니다.
:::
### 실습 주제
* Next.js 애플리케이션
* 웹 애플리케이션 호스팅
* 인증
* GraphQL API : query, mutation, subscription, filtered subscription
* 권한 부여
* 리소스 삭제
### 우리가 구현할 기능
* 애플리케이션 호스팅
* 인증 : 회원가입, 로그인, 로그아웃
* 데이터 모델링
* N개의 주제(topic)가 있을 수 있습니다.
* 주제에는 N개의 댓글(comment)이 있을 수 있습니다.
* 권한 부여
* 인증된(로그인) 사용자는 주제 및 의견을 생성, 읽기, 업데이트, 삭제할 수 있습니다. 자신의 업데이트 및 삭제만 가능합니다.
* Moderator 그룹의 사용자는 주제를 읽고 업데이트하고 삭제할 수 있습니다. 코멘트.
* 인증된(로그인) 사용자는 모든 주제 및 댓글을 읽을 수 있습니다.
* 애플리케이션 UI
* 주제 나열
* 댓글이 있는 주제 보기
* 레코드 추가 및 삭제(주제, 댓글)
* 구독을 통한 실시간 업데이트
---
## 환경 설정
### 개발 환경
시작하기 전에 설치하십시오
* Node.js v10.x 이상
* npm v5.x 이상
* git v2.14.1 이상
터미널에서 Amplify CLI를 실행하여 인프라를 생성하고 시작하고, 로컬 시스템의 Next.js 애플리케이션 및 테스트 애플리케이션 브라우저를 사용할 것입니다.
:::warning
이 실습을 진행하는 동안 애플리케이션을 실행하면 프리 티어 제한(AppSync, Amplify Hosting, DynamoDB, Lambda)을 초과하여 비용이 발행할 수 있습니다.
:::
:::info
이 실습이 끝나기 전에 프로젝트를 정리하는 방법이 포함되어 있습니다.
:::
---
## AWS Event Engine
:::info
AWS 호스팅 이벤트인 경우에만 이 섹션을 진행하세요. 해당 페이지는 AWS가 주최하는 교육에 참석했을 때, 실습 시작 전 준비 단계를 가이드하는 페이지입니다. (예 : re:Invent, Kubecon, Immersion Day 또는 AWS 직원이 주최하는 기타 이벤트)
:::
### AWS Workshop 포탈에 로그인하기
이 워크샵은 AWS 계정과 Cloud9 환경을 만듭니다. 입장시 제공되는 참가자 해시 및 고유 한 세션을 진행하려면 이메일 주소가 필요합니다.
1. AWS Wokshop Portal에 로그인하여 실습을 진행하실 경우, Team Hash 값이 필요합니다. [여기](https://dashboard.eventengine.run/login)를 클릭한 후, 이벤트 주최자로부터 받은 12자리 Participant Hash 값을 입력하면 오른쪽 하단 버튼이 Accept Terms & Login으로 변경됩니다. 다음 단계로 넘어가기 위해, 해당 버튼을 누릅니다.

1. OTP 버튼을 클릭 합니다.

1. OTP passcode를 받을 email 주소를 입력 후 Send passcode 버튼을 클릭 합니다.

1. 입력한 Email 주소로 passcode가 발송 되었을 것 입니다.

1. Passcode 입력 후 Sign in 버튼을 클릭 합니다.

1. Team Dashboard 화면에서 AWS Console 버튼을 클릭 합니다.

1. 다음 화면에서 AWS Console 버튼을 누르면 콘솔에 로그인할 수 있는 로그인 링크를 받을 수 있습니다. Open AWS Console 버튼을 누르면 AWS Console 창으로 접속할 수 있습니다. 또한, CLI 환경을 위한 Access Key와 Secret Access Key도 확인할 수 있습니다.

1. 새 브라우저 탭에서 AWS 콘솔이 열립니다.
---
## AWS 계정
이미 AWS 계정이 있는 경우 이 실습을 즉시 수행할 수 있습니다.그렇지 않으면 먼저 AWS 계정을 생성해야 합니다.
AWS 계정을 생성하고 활성화하려면 [다음](https://aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/)을 참조하십시오.
---
## CLOUD9 WORKSPACE 생성하기
AWS Cloud9은 cloud-based의 통합 개발 환경(IDE)입니다. 브라우저에서 바로 코드를 작성하고 실행시키고, 디버깅할수 있습니다. Cloud9은 코드 편집기, 디버거와 터미널을 제공합니다. 또한 Javascript, Python, PHP 등의 인기 있는 프로그래밍언어를 위한 필수적인 도구들이 미리 패키징 되어 제공됩니다. 무엇보다도 새로운 프로젝트 시작을 위해 설치 파일이 필요하거나, 개발 환경 설정이 필요하지 않습니다.
:::warning
The Cloud9의 작업영역은 AWS root 계정이 아닌, Administrator 권한을 가진 IAM user에 의해서 작성 되어야 합니다. root 계정이 아닌 IAM User로 로그인 하여 작업 중인게 맞는지 꼭 확인하세요.
:::
:::info
광고 차단기, Javascript 비활성화 도구 및 차단 추적기 등은 Cloud9 에서는 비활성화 하세요. 작업 영역에 영향을 줄 수 있습니다.
:::
### 새 환경 만들기
1. [Cloud9 web console](https://ap-northeast-2.console.aws.amazon.com/cloud9/home)로 이동합니다.
1. Create environment 선택합니다.
1. workshop으로 이름을 붙이고 Next step으로 넘어갑니다.
1. Create a new instance for environment (EC2) 선택하고 t3.small을 선택합니다.
1. 모든 환경 설정을 있는 그대로 두고 Next step로 이동합니다.
1. Create environment를 클릭합니다.
### EBS 볼륨 크기 조정
1. 크기를 조정할 Amazon EBS 볼륨에 대해 Amazon EC2 인스턴스와 연결된 환경을 엽니다.
1. 환경에 대한 AWS Cloud9 IDE에서 다음 내용의 파일을 생성한 다음 해당 파일을 확장명(.sh)으로 저장합니다.(예:resize.sh)
```bash=
#!/bin/bash
# Specify the desired volume size in GiB as a command line argument. If not specified, default to 20 GiB.
SIZE=${1:-20}
# Get the ID of the environment host Amazon EC2 instance.
INSTANCEID=$(curl http://169.254.169.254/latest/meta-data/instance-id)
# Get the ID of the Amazon EBS volume associated with the instance.
VOLUMEID=$(aws ec2 describe-instances \
--instance-id $INSTANCEID \
--query "Reservations[0].Instances[0].BlockDeviceMappings[0].Ebs.VolumeId" \
--output text)
# Resize the EBS volume.
aws ec2 modify-volume --volume-id $VOLUMEID --size $SIZE
# Wait for the resize to finish.
while [ \
"$(aws ec2 describe-volumes-modifications \
--volume-id $VOLUMEID \
--filters Name=modification-state,Values="optimizing","completed" \
--query "length(VolumesModifications)"\
--output text)" != "1" ]; do
sleep 1
done
#Check if we're on an NVMe filesystem
if [ $(readlink -f /dev/xvda) = "/dev/xvda" ]
then
# Rewrite the partition table so that the partition takes up all the space that it can.
sudo growpart /dev/xvda 1
# Expand the size of the file system.
# Check if we are on AL2
STR=$(cat /etc/os-release)
SUB="VERSION_ID=\"2\""
if [[ "$STR" == *"$SUB"* ]]
then
sudo xfs_growfs -d /
else
sudo resize2fs /dev/xvda1
fi
else
# Rewrite the partition table so that the partition takes up all the space that it can.
sudo growpart /dev/nvme0n1 1
# Expand the size of the file system.
# Check if we're on AL2
STR=$(cat /etc/os-release)
SUB="VERSION_ID=\"2\""
if [[ "$STR" == *"$SUB"* ]]
then
sudo xfs_growfs -d /
else
sudo resize2fs /dev/nvme0n1p1
fi
fi
```
3. IDE의 터미널 세션에서 resize.sh 파일을 생성합니다.
4. 다음 명령을 실행하여 Amazon EBS 볼륨의 크기를 다음과 같이 조정하려는 GiB 단위로 바꿉니다. 아래 예는 20 GB로 변경하는 방법입니다.
```bash=
chmod +x resize.sh
./resize.sh 20
```
:::success
자세한 내용은 다음 [문서](https://docs.aws.amazon.com/ko_kr/cloud9/latest/user-guide/move-environment.html#move-environment-resize)에서 확인하실 수 있습니다.
:::
### Amplify 기본 region 설정하기
가장 좋은 방법은 인프라를 고객과 가까운 지역에 구성하는 것입니다. (Amplify는 서울 리전도 지원합니다.)
Amplify CLI를 설치합니다.
```bash=
npm install -g @aws-amplify/cli
```
AWS config file이 없다면 Workshop 진행의 편의를 위해서 생성합니다. 원하는 리전을 선택해서 하나만 진행하시면 됩니다.
```bash=
cat <<END > ~/.aws/config
[default]
region=ap-northeast-2
END
```
:::info
AWS Amplify CLI는 모바일과 웹 어플리케이션을 개발을 심플하게 해주는 강력한 기능들을 제공하는 툴체인 입니다. 위의 단계에서는 설치만 진행했기 때문에 설정 단계가 추가적으로 필요합니다. AWS Amplify CLI는 ~/.aws/config을 찾아 작업할 Region 정보를 판별합니다. Cloud9은 유효한 Administrator credentials이 ~/.aws/credentials 파일안에 있는지 확인만 할 뿐 ~/.aws/config을 생성하지 않습니다.
:::
---
## Next.js
### Next.js 애플리케이션 만들기
[Create Next App](https://nextjs.org/docs/api-reference/create-next-app)을 사용하여 새 프로젝트를 생성합니다.
```
npx create-next-app amplify-forum
```
생성된 디렉토리로 이동해서, aws-amplify 연관 패키지들을 설치해봅시다.
```
cd amplify-forum
yarn add aws-amplify @aws-amplify/ui-react
```
:::info
cloud9에 yarm이 설치되어 있지 않다면, 아래 명령으로 설치하세요.
```
npm install yarn -g
```
:::
### TailwindCSS로 스타일링하기
TailwindCSS를 사용하여 응용 프로그램의 스타일을 지정합니다.
devDependencies에 TailwindCSS 관련 패키지를 설치해 보겠습니다.
```
yarn add --dev tailwindcss@latest postcss@latest autoprefixer@latest @tailwindcss/forms
```
Tailwind 구성 파일을 만들려면(```tailwind.config.js postcss.config.js```), 다음을 실행해 보자.
```
npx tailwindcss init -p
```
이제 업데이트를 해보자 tailwind.config.js다음과 같이.
> 이것은 프로덕션 빌드에서 사용하지 않는 트리 쉐이크 스타일링을 하기 위한 것입니다.
```javascript=
// tailwind.config.js
module.exports = {
purge: ['./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [require('@tailwindcss/forms'),],
}
```
Tailwind의 기본, 구성 요소 및 유틸리티 스타일을 사용하려면 ./styles/globals.css
```css=
/* ./styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
```
> TailwindCSS 설치에 대해 더 알고 싶다면 [여기](https://tailwindcss.com/docs/guides/nextjs)를 확인하세요
### / Page(Home)
루트 페이지(/)를 렌더링하는 pages/index.js 업데이트 합니다.
```javascript=
/* pages/index.js */
import Head from "next/head";
function Home() {
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>
</div>
</div>
</main>
</div>
<footer></footer>
</div>
);
}
export default Home;
```
yarn dev로컬 서버를 시작하고 페이지가 localhost:3000의 브라우저에서 문제 없이 로드됩니다.
```
yarn dev
```
---
## 리포지토리 구성
이 실습에서는 CodeCommit 또는 git을 이용하여 리포지토리를 구성할 수 있습니다. 아래 두 항목중 하나만 선택하시면 됩니다.
### AWS CodeCommit 리포지토리 구성
AWS Console의 Services에서 [CodeCommit](https://ap-northeast-2.console.aws.amazon.com/codesuite/codecommit/repositories)을 선택합니다.
**Create Repository** 버튼을 클릭합니다.

Repository Name 을 입력하고 **Create** 버튼을 클릭합니다.

:::info
AWS CodeCommit은 안전한 Git 기반 리포지토리를 호스팅하는 완전관리형 소스 제어 서비스입니다. 이 서비스를 사용하면 뛰어난 확장성의 안전한 에코시스템에서 여러 팀이 협업하여 코드 작업을 수행할 수 있습니다. CodeCommit을 사용하면 자체 소스 제어 시스템 운영이나 인프라 확대/축소에 대해 염려할 필요가 없습니다. CodeCommit을 사용하면 소스 코드에서 바이너리까지 모든 항목을 안전하게 저장할 수 있고 기존 Git 도구와 원활하게 연동됩니다.
:::
리포지토리를 생성했으면 폴더에서 CodeCommit을 초기화하고 생성된 저장소 URL을 추가합니다.
### git 리포지토리 구성
:::warning
AWS CodeCommit을 사용하는 경우 git 리포지토리를 생성하지 않습니다.
:::
이 프로젝트에 대한 git 저장소를 [생성](https://github.com/new)합니다.
### 리포지토리 초기화
리포지토리를 생성했으면 폴더에서 git을 초기화하고 생성된 저장소 URL을 추가합니다.
```
git init
git remote add origin git@github.com\:username/project-name.git
git add .
git commit -m 'initial commit'
git push origin main
```
---
## Amplify 설정
### Amplify CLI 설치
Amplify CLI를 설치합니다.
```bash=
npm install -g @aws-amplify/cli
```
이제 AWS 자격 증명을 사용하도록 CLI를 구성합니다.
> 자격 증명을 만드는 단계에 대해 더 알고 싶다면, 이 [영상](https://www.youtube.com/watch?v=fWbM5DLh25U)을 확인해주세요.
```bash=
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
```
### Amplify 프로젝트 초기화
Amplify 프로젝트를 초기화합니다.
```bash=
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
```
:::warning
* ***배포 디렉토리 경로를 out 으로 변경해야 합니다.*** Next.js를 빌드하고 내보내면 빌드 아티팩트가 out 폴더에 생성됩니다.
:::
* 한 번 amplify init완료되면 **amplify** 폴더가 생성되고 aws-exports.js파일은 **src** 폴더에 생성됩니다.
* **src/aws-exports.js** 는 Amplify 구성 정보를 찾을 수 있는 곳입니다.
* **amplify/team-provider-info.json** 에는 Amplify 프로젝트의 변수가 포함되어 있습니다. 백엔드 환경. 동일한 백엔드 환경을 공유하려는 경우 공유해야 합니다. 이 파일. 그렇지 않은 경우(예: 이 프로젝트를 대중에게 공개), 다음을 수행해야 합니다. 이 파일을 공유하지 않음(예: .gitignore)
> 자세한 내용은 다음 [문서](https://docs.amplify.aws/cli/teams/shared)에서 확인할 수 있습니다.
다음 amplify status명령을 사용하여 Amplify 프로젝트의 상태를 확인할 수 있습니다.
```bash=
amplify status
```
Amplify 콘솔로 확인하려면, ```amplify console``` 브라우저에서 콘솔을 실행해야 합니다.
```bash=
amplify console
```
### Amplify로 다음 애플리케이션 구성
Amplify 프로젝트가 준비되면 이제 Next.js 앱을 만들어야 합니다. Amplify 프로젝트에 대해 알고 있습니다. Amplify를 구성하는 최상위 구성 요소(src/aws-exports.js)를 만들어 이를 수행할 수 있습니다.
pages/_app.js 파일을 열고 다음을 추가합니다.
```javascript=
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
```
완료되면 Next.app은 Amplify에서 관리하는 AWS를 사용할 준비가 됩니다.
---
## CI/CD 구성
CI/CD 자동화 구성을 위해서 필요한 내용입니다. 자동화 구성을 하지 않는 분은 이 부분은 넘어가시면 됩니다.
1. Amplify 콘솔에 로그인합니다.
2. 이번 워크샵에서 만든 프로젝트를 선택합니다.
3. 리포지토리를 연결합니다.

4. 리포지토리 서비스 공급자를 연결한 후 리포지토리를 선택한 다음 빌드하고 배포할 해당 브랜치를 선택합니다.

5. 백엔드의 빌드 설정을 확인합니다.

:::info
소스 커밋시 자동으로 배포하기 위해서는 다음 항목을 선택합니다.
Deploy updates to backend resources with your frontend on every code commit.
:::
6. 백엔드 기능을 배포하려면 IAM 서비스 역할을 생성하거나 설정해야 합니다. Amplify CLI는 IAM 서비스 역할이 활성화되지 않으면 실행되지 않습니다.
7. 빌드 설정을 확인합니다.

:::info
자세한 내용은 YML 구조를 참조하십시오.
:::
8. 모든 설정이 올바른지 확인합니다.
9. 저장 및 배포를 선택하여 웹 앱을 글로벌 콘텐츠 전송 네트워크(CDN)에 배포합니다. 프런트엔드 빌드는 일반적으로 1~2 분이 소요되지만 앱 크기에 따라 다를 수 있습니다.
---
## 인증
### 인증 추가
이제 인증을 추가합니다.
인증 기능을 추가하려면 다음을 실행하십시오.
```bash=
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.
```
변경 사항을 적용하려면 다음을 실행하십시오.
```bash=
amplify push
? Are you sure you want to continue? Yes
```
### withAuthenticator
Amplify-UI에서 제공하는 withAuthenticator 사용하면 웹 페이지를 인증으로 보호할 수 있습니다.
일단 인증이 적용되면 사용자는 페이지에 액세스하기 위해 로그인해야 합니다. 로그인하지 않았다면 로그인 페이지로 리디렉션됩니다.
이 UX 흐름은 모두 withAuthenticator에서 관리됩니다.
테스트를 위해 **/pages/index.js**를 수정합니다.
```javascript=
/* pages/index.js */
import Head from "next/head";
import { withAuthenticator } from "@aws-amplify/ui-react";
export default withAuthenticator(Home);
```
> Authenticator UI 구성 요소 문서는 [여기](https://docs.amplify.aws/ui/auth/authenticator/q/framework/react)서 화인이 가능합니다.
개발 서버를 시작하고 브라우저에서 테스트합니다.
```bash=
yarn dev
```
루트/페이지를 로드하려고 하면 로그인으로 리디렉션됩니다.
회원가입으로 새로운 계정을 만들어 봅시다.
가입하면 이메일로 확인 코드를 받게 됩니다.
확인 코드를 입력하면 신규 사용자 가입이 완료됩니다.
Auth 콘솔에서 새로 생성된 사용자를 확인할 수 있습니다.
```bash=
amplify console auth
> Choose User Pool
```
### 로그아웃
Signout UI 컴포넌트를 이용하여 사인아웃을 추가합니다.
AmplifySignout을 페이지 컴포넌트에 추가합니다. (예: page/index.js)
```javascript=
import '@aws-amplify/ui-react/styles.css';
import { withAuthenticator } from "@aws-amplify/ui-react";
import { Button } from '@aws-amplify/ui-react';
```
로그 아웃 버튼을 추가합니다.
```javascript=
/* UI 어딘가에 넣어주세요. */
<Button onClick={signOut} variation="primary">Sign out</Button>
```
> 로그아웃 UI 구성 요소 문서는 [여기](https://docs.amplify.aws/ui/auth/sign-out/q/framework/react)에서 확인합니다.
로그아웃 버튼을 클릭하고 성공적으로 로그아웃할 수 있는지 확인합니다.
### 사용자 데이터 액세스
로그인시 ```Auth.currentAuthenticatedUser()```을 통해 인증된 사용자의 정보에 접근할 수 있습니다.
page/index.js 파일을 수정하여 콘솔에서 사용자 정보를 인쇄해 보겠습니다.
```javascript=
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);
}
/* Same as before */
}
```
브라우저 콘솔을 연상태에서 페이지를 로드하면, 콘솔에서 인증된 사용자의 정보 및 속성이 표시됩니다.
---
## UI 구현
UI 개발에 필요한 패키지를 설치합니다.
```bash=
yarn add --dev @headlessui/react @heroicons/react
```
### 모의 데이터로 UI 구현
토픽을 표시하고 새로운 토픽을 추가하는 UI를 구현합니다. 지금은 하드 코딩된 모의 데이터를 사용하여 주제를 표시합니다.
다음과 같이 pages/index.js를 업데이트 합니다.
```javascript=
import Head from "next/head";
import '@aws-amplify/ui-react/styles.css';
import { withAuthenticator } from "@aws-amplify/ui-react";
import { Button } from '@aws-amplify/ui-react';
import { useEffect, useState, Fragment } from "react";
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>Add a new Topic.</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"
>
​
</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({ isPassedToWithAuthenticator, signOut, user }) {
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState({ title: "" });
const topics = TOPICS;
if (!isPassedToWithAuthenticator) {
throw new Error(`isPassedToWithAuthenticator was not provided`);
}
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>
<Button onClick={signOut} variation="primary">Sign out</Button>
</div>
<footer></footer>
</div>
);
}
export async function getStaticProps() {
return {
props: {
isPassedToWithAuthenticator: true,
},
};
}
export default withAuthenticator(Home);
```
코드를 변경했으면 dev 서버를 실행하고 브라우저에서 테스트합니다.
```bash=
yarn dev
```
---
## GraphQL API 추가
### GraphQL API(AppSync) 추가
GraphQL API를 추가하기 위해서 ```amplify add api```를 실행합니다.
```bash=
$ 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)
```
:::warning
기본 권한 부여 유형으로 **Cognito UserPool**를 선택했는지 확인하십시오.
:::
* GraphQL에 대해 더 알고 싶으십니까? [GraphQL 공식 사이트](https://graphql.org/)
* [100초 안에 설명하는 GraphQL](https://www.youtube.com/watch?v=eIQh02xuVw4)
* [GraphQL로 최신 API 구축](https://www.youtube.com/watch?v=bRnu7xvU1_Y)
### 새 모델 추가 : 주제 및 댓글
다음 승인 규칙이 적용됩니다.
* 인증된 사용자는 자신의 주제와 댓글을 소유자로 CRUD할 수 있습니다.
* 중재자 그룹은 주제 및 댓글을 읽고/업데이트/삭제할 수 있습니다.
* 인증된 모든 사용자는 주제 및 댓글 읽기만 가능합니다.
**amplify/backend/api/petstagram/schema.graphql**을 다음으로 수정합니다.
```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] @hasMany(indexName: "byTopicId", fields: ["id"])
}
type Comment
@model
@auth(
rules: [
{ allow: owner }
{
allow: groups
groups: ["Moderator"]
operations: [read, update, delete]
}
{ allow: private, operations: [read] }
]
) {
id: ID!
topicId: ID! @index(name:"byTopicId", sortKeyFields: ["content"])
content: String!
topic: Topic @belongsTo(fields: ["topicId"])
}
```
변경 사항을 적용하려면 다음을 실행하십시오.
```bash=
amplify push --y
```
---
## 앱에서 GraphQL API 사용
이제 GraphQL API를 호출하여 데이터를 가져와 UI에 표시해 보겠습니다.
### 주제 목록 가져오기
다음 코드는 GraphQL API로 데이터를 가져오는 곳입니다.
```javascript=
const data = await API.graphql({ query: queries.listTopics });
```
> 데이터 가져오기 [쿼리 API 문서](https://docs.amplify.aws/lib/graphqlapi/query-data/q/platform/js)
**page/index.js**를 업데이트 합니다.
```javascript=
import { API } from "aws-amplify";
import * as queries from "../src/graphql/queries";
/* Same as before */
function Home() {
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState({ title: "" });
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 });
}
}
}
```
### 새 주제 추가
GraphQL API로 새로운 토픽을 만듭니다.
다음 코드는 GraphQL API로 새 주제를 생성하는데 사용됩니다.
```javascript=
const newData = await API.graphql({
query: mutations.createTopic,
variables: { input: formData },
});
```
> Data Mutation [Query API 문서](https://docs.amplify.aws/lib/graphqlapi/mutate-data/q/platform/js)
**pages/index.js** 파일을 수정합니다.
```javascript=
import * as queries from "../src/graphql/queries";
import * as mutations from "../src/graphql/mutations";
/* Same as before */
function Home() {
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState({ title: "" });
const [topics, setTopics] = useState([]);
const [createInProgress, setCreateInProgress] = useState(false);
/* Same as before */
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 = 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>
);
}
```
개발 서버를 시작하고 브라우저에서 테스트합니다. 주제가 성공적으로 생성되었는지 확인하십시오.
```bash=
yarn dev
```
새 주제가 생성되지만 UI에서 업데이트되지 않습니다.
우리는 무엇을 할 수 있을까요? 2개의 답변이 있습니다.
(1) 전체 페이지를 새로고침하고 데이터를 다시 패치합니다.
(2) 구독을 통해 업데이트를 받고 그에 따라 UI를 업데이트합니다.
GraphQL API는 구독을 제공합니다. 그럼 활용해야겠죠?
---
## 구독
### 구독으로 UI 업데이트
새 주제가 생성되면 ```onCreateTopic```구독을 통해 이벤트를 보고 그에 따라 UI를 업데이트합니다.
다음 코드는 구독의 핵심입니다.
```javascript=
const subscription = API.graphql({
query: subscriptions.onCreateTopic,
variables : {
owner: user.username
}
}).subscribe({
next: ({ provider, value }) => {
console.log({provider, value});
const item = value.data.onCreateTopic;
setTopics((topics)=> [item, ...topics]);
},
error: (error) => console.warn(error),
});
```
> 구독관련 [문서]( https://docs.amplify.aws/lib/graphqlapi/subscribe-data/q/platform/js)
구독을 생성해 보겠습니다.
page/index.js파일에 **onCreatePost**의 이벤트를 생성합니다.
```javascript=
import * as queries from "../src/graphql/queries";
import * as mutations from "../src/graphql/mutations";
import * as subscriptions from "../src/graphql/subscriptions";
/* Same as before */
function Home() {
/* Same as before */
useEffect(() => {
checkUser();
fetchTopics();
const subscription = subscribeToOnCreateTopic();
return () => {
subscription.unsubscribe();
};
}, []);
function subscribeToOnCreateTopic() {
const subscription = API.graphql({
query: subscriptions.onCreateTopic,
variables : {
owner: user.username
}
}).subscribe({
next: ({ provider, value }) => {
console.log({ provider, value });
const item = value.data.onCreateTopic;
setTopics((topics) => [item, ...topics]);
},
error: (error) => console.warn(error),
});
return subscription;
}
/* Same as before */
}
```
새 주제를 만들고 UI가 올바르게 업데이트되는지 확인합니다.
다른 브라우저를 열어 변경 사항을 확인하세요.
page/index.js 전체 소스
```javascript=
import Head from "next/head";
import '@aws-amplify/ui-react/styles.css';
import { withAuthenticator } from "@aws-amplify/ui-react";
import { Button } from '@aws-amplify/ui-react';
import { Auth, API } from "aws-amplify";
import * as queries from "../src/graphql/queries";
import * as mutations from "../src/graphql/mutations";
import * as subscriptions from "../src/graphql/subscriptions";
import { useEffect, useState, Fragment } from "react";
import { DotsVerticalIcon } from "@heroicons/react/solid";
import { ViewGridAddIcon } from "@heroicons/react/solid";
import { Dialog, Transition } from "@headlessui/react";
import Link from "next/link";
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>Add a new Topic.</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"
>
​
</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({ isPassedToWithAuthenticator, signOut, user }) {
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState({ title: "" });
const [topics, setTopics] = useState([])
const [createInProgress, setCreateInProgress] = useState(false);
if (!isPassedToWithAuthenticator) {
throw new Error(`isPassedToWithAuthenticator was not provided`);
}
useEffect(() => {
checkUser(); // new function call
fetchTopics();
const subscription = subscribeToOnCreateTopic();
return() => {
subscription.unsubscribe();
};
}, []);
console.log(user.attributes.sub + "::" + user.username);
function subscribeToOnCreateTopic() {
const subscription = API.graphql({
query: subscriptions.onCreateTopic,
variables : {
owner: user.username
}
}).subscribe({
next: ({ provider, value }) => {
console.log({provider, value});
const item = value.data.onCreateTopic;
setTopics((topics)=> [item, ...topics]);
},
error: (error) => console.warn(error),
});
return subscription;
}
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);
}
async function fetchTopics() {
try {
const data = await API.graphql({query: queries.listTopics});
setTopics(data.data.listTopics.items);
} catch(err) {
console.log({err});
}
}
async function checkUser() {
const user = await Auth.currentAuthenticatedUser();
console.log("user: ", user);
console.log("user attributes: ", user.attributes);
}
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>
<Button onClick={signOut} variation="primary">Sign out</Button>
</div>
<footer></footer>
</div>
);
}
export default withAuthenticator(Home);
export async function getStaticProps() {
return {
props: {
isPassedToWithAuthenticator: true,
},
};
}
```
---
## Dynmaic Routes
### 동적 경로가 있는 주제 페이지
사용자가 주제를 선택하면, 다음과 같이 topic/1234567 페이지로 리디렉션되고 주제의 모든 댓글을 포함하여 주제에 대한 세부 정보를 보여줍니다.
Next.js에서 [동적 경로](https://nextjs.org/docs/routing/dynamic-routes)를 이용하여 해당 페이지를 구현합니다.
페이지에는 다음과 같은 내용이 있습니다.
* 주제의 제목과 댓글이 표시
* 새 댓글을 추가하는 양식 표시
pages/topic/[id].js 파일을 생성합니다.
```javascript=
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">
No Comment.
</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 CommentForm({ 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" />
<CommentForm
formData={formData}
setFormData={setFormData}
disableSubmit={disableSubmit}
handleSubmit={createNewComment}
/>
</>
)}
</div>
</main>
</div>
</div>
);
}
export default TopicPage;
```
개발 서버를 시작하고 브라우저에서 테스트합니다.
```bash=
yarn dev
```
새 댓글이 올바르게 생성되었는지 확인하세요.
---
## 필터링된 구독
### 필터링된 구독
새 댓글이 생성되고 UI를 업데이트할 때 업데이트를 받을 수 있도록 구독을 추가해 보겠습니다.
그러나 TopicId와 관련된 새 댓글이 생성된 경우에만 업데이트를 받아야 합니다.
**amplify/backend/api/petstagram/schema.graphql**에 새 구독 유형을 생성합니다.
```graphql=
type Subscription {
onCreateCommentByTopicId(topicId: String!): Comment
@aws_subscribe(mutations: ["createComment"])
}
```
> 참고 : id 문서로 GraphQL [구독](https://docs.amplify.aws/guides/api-graphql/subscriptions-by-id/q/platform/js)하기
변경 사항을 적용하려면 다음을 실행하십시오.
```bash=
amplify push -y
```
page/topic/[id].js 를 업데이트합니다
```javascript=
import * as mutations from "../../src/graphql/mutations";
import * as subscriptions from "../../src/graphql/subscriptions";
/* Same as before */
function TopicPage() {
/* Same as before */
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;
}
/* Same as before */
}
```
새 댓글을 만들고 UI가 올바르게 업데이트되었는지 확인하세요.
주제 페이지가 다른 여러 브라우저를 엽니다.
동일한 주제가 있는 페이지만 업데이트 되도록 합니다.
page/topic/[id].js 전체 소스
```javascript=
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect, useState } 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";
import * as subscriptions from "../../src/graphql/subscriptions";
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">
No Comment.
</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 CommentForm({ 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();
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;
}
const fetchTopic = async () => {
console.log("fetching with topicId = ", topicId);
const data = await API.graphql({
query: queries.getTopic,
variables: { id: topicId },
});
console.log(data.data);
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" />
<CommentForm
formData={formData}
setFormData={setFormData}
disableSubmit={disableSubmit}
handleSubmit={createNewComment}
/>
</>
)}
</div>
</main>
</div>
</div>
);
}
export default TopicPage;
```
---
## 댓글 삭제
댓글을 삭제하는 기능을 추가합니다.
다른 구독을 추가해야 합니다.
**amplify/backend/api/petstagram/schema.graphql** 파일에서 ```onDeleteCommentByTopicId```를 추가합니다.
```graphql=
type Subscription {
onCreateCommentByTopicId(topicId: String!): Comment
@aws_subscribe(mutations: ["createComment"])
onDeleteCommentByTopicId(topicId: String!): Comment
@aws_subscribe(mutations: ["deleteComment"])
}
```
다음을 사용하여 변경 사항을 적용하십시오.
```bash=
amplify push -y
```
**page/topic/[id].js**에 삭제 버튼을 추가합니다.
```javascript=
import { Button } from '@aws-amplify/ui-react';
/* Same as before */
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} variation="primary">deletet</Button>;
}
function TopicPage() {
/* Same as before */
useEffect(() => {
if (topicId) {
fetchTopic();
const onCreateSubscription = subscribeToOnCreateComment();
const onDeleteSubscription = subscribeToOnDeleteComment();
return () => {
onCreateSubscription.unsubscribe();
onDeleteSubscription.unsubscribe();
};
}
}, [topicId]);
/* Same as before */
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;
}
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>
);
/* Same as before */
}
```
댓글을 삭제하고 UI가 올바르게 업데이트되는지 확인하십시오.
더 나은 테스트를 위해 다양한 주제가 로드된 여러 브라우저를 엽니다.
page/topic/[id].js 전체 소스
```javascript=
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { API } from "aws-amplify";
import { Button } from '@aws-amplify/ui-react';
import { ChatAltIcon, UserCircleIcon } from "@heroicons/react/solid";
import * as queries from "../../src/graphql/queries";
import * as mutations from "../../src/graphql/mutations";
import * as subscriptions from "../../src/graphql/subscriptions";
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">
No Comment.
</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 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 CommentForm({ 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 DeleteCommentButton({ comment }) {
async function deleteComment() {
if(!confirm("Are you sure?")) {
return;
}
const deleteComment = await API.graphql({
query: mutations.deleteComment,
variables: { input: {id: comment.id}},
});
alert("Deleted a comment");
console.log("deleteComment = ", deleteComment);
}
return <button onClick={deleteComment} variation="link">delete</button>;
}
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();
const onCreateSubscription = subscribeToOnCreateComment();
const onDeleteSubscription = subscribeToOnDeleteComment();
return() => {
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;
}
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;
}
const fetchTopic = async () => {
console.log("fetching with topicId = ", topicId);
const data = await API.graphql({
query: queries.getTopic,
variables: { id: topicId },
});
console.log(data.data);
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" />
<CommentForm
formData={formData}
setFormData={setFormData}
disableSubmit={disableSubmit}
handleSubmit={createNewComment}
/>
</>
)}
</div>
</main>
</div>
</div>
);
}
export default TopicPage;
```
---
## 추가로 할 수 있는 일
### 팀 작업
Amplify 호스팅은 기능 브랜치와 함께 작동하도록 설계되었습니다. GitFlow 워크플로. Amplify 는 개발자가 리포지토리의 새 브랜치를 연결할 때마다 Git 브랜치를 활용하여 새 배포를 생성합니다. 첫 번째 브랜치를 연결한 후 다음과 같이 브랜치를 추가하여 새 기능 브랜치 배포를 만들 수 있습니다.
1. 브랜치 목록 페이지에서 브랜치 연결을 선택합니다.
1. 리포지토리에서 브랜치를 선택합니다.
1. 앱을 저장한 후 배포합니다.
이제 앱에 다음 두 가지 배포를 사용할 수 있습니다.https://main.appid.amplifyapp.com과https://dev.appid.amplifyapp.com. 이는 팀에 따라 다를 수 있지만 일반적으로메인 브랜치릴리스 코드를 추적하고 프로덕션 지점입니다. 개발 브랜치는 새 기능을 테스트하는 데 통합 브랜치로 사용됩니다. 이를 통해 베타 테스터는 기본 브랜치 배포와 관련하여 어떠한 프로덕션 최종 사용자에게도 영향을 주지 않고, 개발 브랜치 배포와 관련하여 릴리스되지 않은 기능을 테스트할 수 있습니다.

> 자세한 내용은 다음 [문서](https://docs.aws.amazon.com/ko_kr/amplify/latest/userguide/team-workflows-with-amplify-cli-backend-environments.html)를 확인하세요.
### 로컬 테스트
프로젝트의 모든 변경 사항을 클라우드로 푸시하지 않고 빠르게 테스트하고 디버그하기 위해 Amplify는 API(AWS AppSync), 스토리지(Amazon DynamoDB 및 Amazon S3), 함수(AWS Lambda)를 비롯한 특정 범주에 대해 **로컬 Mocking 및 테스트**를 지원합니다. 여기에는 GraphQL Transformer의 지시문 사용, 편집 및 디버그 Resorvers, hot reloading, 인증 검사의 JWT Mocking, 콘텐츠 업로드 및 다운로드와 같은 S3 작업 수행이 포함됩니다.
:::info
Amplify에서 로컬 Mocking을 사용하려면 개발 워크스테이션에 Java가 필요합니다.
:::
> 참고: 현재 Apple Silicon Mac에서 Amplify는 ARM 프로세서용으로 빌드된 특정 JDK 버전을 사용할 때 모의를 시작하지 못하는 경우가 있습니다.
> 자세한 내용은 다음 [문서](https://docs.amplify.aws/cli/usage/mock/)를 확인하세요.
---
## 리소스 정리
### Amplify 삭제하기
Amplify는이 워크샵에 프로비저닝 한 대부분의 클라우드 리소스를 제거하는데 매우 효과적입니다 (프로비저닝 된 CloudFormation 중첩 스택을 삭제하는 것만으로). 그러나 일부 항목들은 삭제하지 않으며 수동으로 처리합니다.
* 프로젝트 디렉토리에서 `amplify delete`를 실행하고 Enter를 눌러 삭제를 확인하십시오.
* Amplify가 이 워크샵에서 생성한 리소스를 삭제하는 동안 몇 분 정도 기다립니다.
## Cloud9 삭제하기
* Cloud9 콘솔로 이동합니다.
* environments 에서 삭제하려는 IDE를 선택하고 Delete를 선택하십시오.
* 확인란에 ‘Delete’ 를 입력하고 삭제를 클릭하십시오.