Pic-a-Daily: 실습 4—웹 프런트엔드 만들기

1. 개요

이 Codelab에서는 사용자가 웹 애플리케이션에서 사진을 업로드하고 업로드된 사진과 미리보기 이미지를 탐색할 수 있는 웹 프런트엔드를 Google App Engine에 만듭니다.

21741cd63b425aeb.png

이 웹 애플리케이션은 보기 좋은 사용자 인터페이스를 위해 Bulma라는 CSS 프레임워크를 사용하고, 빌드할 애플리케이션의 API를 호출하기 위해 Vue.JS JavaScript 프런트엔드 프레임워크도 사용합니다.

이 애플리케이션은 다음 세 개의 탭으로 구성됩니다.

  • 업로드된 모든 이미지의 썸네일과 사진을 설명하는 라벨 목록 (이전 실습에서 Cloud Vision API가 감지한 라벨)이 표시되는 페이지
  • 업로드된 가장 최근 사진 4장으로 만든 콜라주를 보여주는 콜라주 페이지
  • 사용자가 새 사진을 업로드할 수 있는 업로드 페이지

결과 프런트엔드는 다음과 같습니다.

6a4d5e5603ba4b73.png

이 3개의 페이지는 간단한 HTML 페이지입니다.

  • 페이지 (index.html)는 /api/pictures URL에 대한 AJAX 호출을 통해 썸네일 사진 목록과 라벨을 가져오기 위해 Node App Engine 백엔드 코드를 호출합니다. 홈페이지는 Vue.js를 사용하여 이 데이터를 가져옵니다.
  • 콜라주 페이지 (collage.html)는 4개의 최신 사진을 모은 collage.png 이미지를 가리킵니다.
  • 업로드 페이지 (upload.html)는 /api/pictures URL에 대한 POST 요청을 통해 사진을 업로드할 수 있는 간단한 양식을 제공합니다.

학습할 내용

  • App Engine
  • Cloud Storage
  • Cloud Firestore

2. 설정 및 요구사항

자습형 환경 설정

  1. Google Cloud Console에 로그인하여 새 프로젝트를 만들거나 기존 프로젝트를 재사용합니다. 아직 Gmail이나 Google Workspace 계정이 없는 경우 계정을 만들어야 합니다.

b35bf95b8bf3d5d8.png

a99b7ace416376c4.png

bd84a6d3004737c5.png

  • 프로젝트 이름은 이 프로젝트 참가자의 표시 이름입니다. 이는 Google API에서 사용하지 않는 문자열이며 언제든지 업데이트할 수 있습니다.
  • 프로젝트 ID는 모든 Google Cloud 프로젝트에서 고유해야 하며, 변경할 수 없습니다(설정된 후에는 변경할 수 없음). Cloud Console은 고유한 문자열을 자동으로 생성합니다. 일반적으로 신경 쓰지 않아도 됩니다. 대부분의 Codelab에서는 프로젝트 ID를 참조해야 하며(일반적으로 PROJECT_ID로 식별됨), 마음에 들지 않는 경우 임의로 다시 생성하거나 직접 지정해서 사용할 수 있는지 확인하세요. 프로젝트가 생성되면 프로젝트 ID가 '고정'됩니다.
  • 세 번째 값은 일부 API에서 사용하는 프로젝트 번호입니다. 이 세 가지 값에 대한 자세한 내용은 문서를 참조하세요.
  1. 다음으로 Cloud 리소스/API를 사용하려면 Cloud Console에서 결제를 사용 설정해야 합니다. 이 Codelab 실행에는 많은 비용이 들지 않습니다. 이 튜토리얼을 마친 후 비용이 결제되지 않도록 리소스를 종료하려면 Codelab의 끝에 있는 '삭제' 안내를 따르세요. Google Cloud 새 사용자에게는 미화 $300 상당의 무료 체험판 프로그램에 참여할 수 있는 자격이 부여됩니다.

Cloud Shell 시작

Google Cloud를 노트북에서 원격으로 실행할 수 있지만, 이 Codelab에서는 Cloud에서 실행되는 명령줄 환경인 Google Cloud Shell을 사용합니다.

Google Cloud Console의 오른쪽 상단 툴바에 있는 Cloud Shell 아이콘을 클릭합니다.

55efc1aaa7a4d3ad.png

환경을 프로비저닝하고 연결하는 데 몇 분 정도 소요됩니다. 완료되면 다음과 같이 표시됩니다.

7ffe5cbb04455448.png

가상 머신에는 필요한 개발 도구가 모두 들어있습니다. 영구적인 5GB 홈 디렉토리를 제공하고 Google Cloud에서 실행되므로 네트워크 성능과 인증이 크게 개선됩니다. 이 실습의 모든 작업은 브라우저만으로 수행할 수 있습니다.

3. API 사용 설정

App Engine에는 Compute Engine API가 필요합니다. 사용 설정되어 있는지 확인합니다.

gcloud services enable compute.googleapis.com

작업이 성공적으로 완료됩니다.

Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.

4. 코드 클론

아직 코드를 체크아웃하지 않은 경우 다음을 실행하여 코드를 체크아웃합니다.

git clone https://github.com/GoogleCloudPlatform/serverless-photosharing-workshop

그런 다음 프런트엔드가 포함된 디렉터리로 이동할 수 있습니다.

cd serverless-photosharing-workshop/frontend

프런트엔드의 파일 레이아웃은 다음과 같습니다.

frontend
 |
 ├── index.js
 ├── package.json
 ├── app.yaml
 |
 ├── public
      |
      ├── index.html
      ├── collage.html
      ├── upload.html
      |
      ├── app.js
      ├── script.js
      ├── style.css

프로젝트 루트에는 다음 3개의 파일이 있습니다.

  • index.js에는 Node.js 코드가 포함되어 있습니다.
  • package.json은 라이브러리 종속 항목을 정의합니다.
  • app.yaml은 Google App Engine의 구성 파일입니다.

public 폴더에는 정적 리소스가 포함되어 있습니다.

  • index.html은 모든 썸네일 사진과 라벨을 표시하는 페이지입니다.
  • collage.html 최근 사진의 콜라주를 보여줍니다.
  • upload.html에는 새 사진을 업로드하는 양식이 포함되어 있습니다.
  • app.js는 Vue.js를 사용하여 index.html 페이지를 데이터로 채우고 있습니다.
  • script.js는 작은 화면에서 탐색 메뉴와 '햄버거' 아이콘을 처리합니다.
  • style.css는 일부 CSS 지시문을 정의합니다.

5. 코드 살펴보기

종속 항목

package.json 파일은 필요한 라이브러리 종속 항목을 정의합니다.

{
  "name": "frontend",
  "version": "0.0.1",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "@google-cloud/firestore": "^3.4.1",
    "@google-cloud/storage": "^4.0.0",
    "express": "^4.16.4",
    "dayjs": "^1.8.22",
    "bluebird": "^3.5.0",
    "express-fileupload": "^1.1.6"
  }
}

애플리케이션은 다음을 사용합니다.

  • firestore: 사진 메타데이터로 Cloud Firestore에 액세스합니다.
  • 저장소: 사진이 저장된 Google Cloud Storage에 액세스
  • express: Node.js용 웹 프레임워크
  • dayjs: 날짜를 사람이 읽기 쉬운 방식으로 표시하는 작은 라이브러리
  • bluebird: JavaScript Promise 라이브러리
  • express-fileupload: 파일 업로드를 쉽게 처리하는 라이브러리

익스프레스 프런트엔드

index.js 컨트롤러의 시작 부분에서 이전에 package.json에 정의된 모든 종속 항목이 필요합니다.

const express = require('express');
const fileUpload = require('express-fileupload');
const Firestore = require('@google-cloud/firestore');
const Promise = require("bluebird");
const {Storage} = require('@google-cloud/storage');
const storage = new Storage();
const path = require('path');
const dayjs = require('dayjs');
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)

그런 다음 Express 애플리케이션 인스턴스가 생성됩니다.

두 개의 Express 미들웨어가 사용됩니다.

  • express.static() 호출은 정적 리소스가 public 하위 디렉터리에서 제공됨을 나타냅니다.
  • fileUpload()은 파일 크기를 10MB로 제한하여 /tmp 디렉터리의 메모리 내 파일 시스템에 파일을 로컬로 업로드하도록 파일 업로드를 구성합니다.
const app = express();
app.use(express.static('public'));
app.use(fileUpload({
    limits: { fileSize: 10 * 1024 * 1024 },
    useTempFiles : true,
    tempFileDir : '/tmp/'
}))

정적 리소스에는 홈페이지, 콜라주 페이지, 업로드 페이지의 HTML 파일이 있습니다. 이러한 페이지는 API 백엔드를 호출합니다. 이 API에는 다음 엔드포인트가 있습니다.

  • POST /api/pictures upload.html의 양식을 통해 사진이 POST 요청을 통해 업로드됩니다.
  • GET /api/pictures 이 엔드포인트는 사진 목록과 라벨이 포함된 JSON 문서를 반환합니다.
  • GET /api/pictures/:name 이 URL은 전체 크기 이미지의 클라우드 스토리지 위치로 리디렉션됩니다.
  • GET /api/thumbnails/:name 이 URL은 썸네일 이미지의 클라우드 스토리지 위치로 리디렉션됩니다.
  • GET /api/collage 이 마지막 URL은 생성된 콜라주 이미지의 클라우드 스토리지 위치로 리디렉션됩니다.

사진 업로드

사진 업로드 Node.js 코드를 살펴보기 전에 public/upload.html를 간단히 살펴보세요.

... 
<form method="POST" action="/api/pictures" enctype="multipart/form-data">
    ... 
    <input type="file" name="pictures">
    <button>Submit</button>
    ... 
</form>
... 

양식 요소는 HTTP POST 메서드와 멀티 파트 형식을 사용하여 /api/pictures 엔드포인트를 가리킵니다. 이제 index.js이 해당 엔드포인트와 메서드에 응답하고 파일을 추출해야 합니다.

app.post('/api/pictures', async (req, res) => {
    if (!req.files || Object.keys(req.files).length === 0) {
        console.log("No file uploaded");
        return res.status(400).send('No file was uploaded.');
    }
    console.log(`Receiving files ${JSON.stringify(req.files.pictures)}`);

    const pics = Array.isArray(req.files.pictures) ? req.files.pictures : [req.files.pictures];

    pics.forEach(async (pic) => {
        console.log('Storing file', pic.name);
        const newPicture = path.resolve('/tmp', pic.name);
        await pic.mv(newPicture);

        const pictureBucket = storage.bucket(process.env.BUCKET_PICTURES);
        await pictureBucket.upload(newPicture, { resumable: false });
    });


    res.redirect('/');
});

먼저 실제로 업로드되는 파일이 있는지 확인합니다. 그런 다음 파일 업로드 Node 모듈에서 제공하는 mv 메서드를 통해 파일을 로컬로 다운로드합니다. 이제 파일을 로컬 파일 시스템에서 사용할 수 있으므로 사진을 Cloud Storage 버킷에 업로드합니다. 마지막으로 사용자를 애플리케이션의 기본 화면으로 다시 리디렉션합니다.

사진 나열

이제 멋진 사진을 표시할 차례입니다.

/api/pictures 핸들러에서 Firestore 데이터베이스의 pictures 컬렉션을 살펴보고 썸네일이 생성된 모든 사진을 생성 날짜의 내림차순으로 정렬하여 가져옵니다.

JavaScript 배열에 각 사진을 이름, 사진을 설명하는 라벨 (Cloud Vision API에서 가져옴), 주 색상, 친근한 생성 날짜 (dayjs 사용 시 '3일 후'와 같은 상대 시간 오프셋)와 함께 푸시합니다.

app.get('/api/pictures', async (req, res) => {
    console.log('Retrieving list of pictures');

    const thumbnails = [];
    const pictureStore = new Firestore().collection('pictures');
    const snapshot = await pictureStore
        .where('thumbnail', '==', true)
        .orderBy('created', 'desc').get();

    if (snapshot.empty) {
        console.log('No pictures found');
    } else {
        snapshot.forEach(doc => {
            const pic = doc.data();
            thumbnails.push({
                name: doc.id,
                labels: pic.labels,
                color: pic.color,
                created: dayjs(pic.created.toDate()).fromNow()
            });
        });
    }
    console.table(thumbnails);
    res.send(thumbnails);
});

이 컨트롤러는 다음 모양의 결과를 반환합니다.

[
   {
      "name": "IMG_20180423_163745.jpg",
      "labels": [
         "Dish",
         "Food",
         "Cuisine",
         "Ingredient",
         "Orange chicken",
         "Produce",
         "Meat",
         "Staple food"
      ],
      "color": "#e78012",
      "created": "a day ago"
   },
   ...
]

이 데이터 구조는 index.html 페이지의 작은 Vue.js 스니펫에서 사용됩니다. 다음은 해당 페이지의 마크업을 단순화한 버전입니다.

<div id="app">
        <div class="container" id="app">
                <div id="picture-grid">
                        <div class="card" v-for="pic in pictures">
                                <div class="card-content">
                                        <div class="content">
                                                <div class="image-border" :style="{ 'border-color': pic.color }">
                                                        <a :href="'/api/pictures/' + pic.name">
                                                                <img :src="'/api/thumbnails/' + pic.name">
                                                        </a>
                                                </div>
                                                <a class="panel-block" v-for="label in pic.labels" :href="'/?q=' + label">
                                                        <span class="panel-icon">
                                                                <i class="fas fa-bookmark"></i> &nbsp;
                                                        </span>
                                                        {{ label }}
                                                </a>
                                        </div>
                                </div>
                        </div>
            </div>
        </div>
</div>

div의 ID는 동적으로 렌더링될 마크업의 일부임을 Vue.js에 나타냅니다. 반복은 v-for 지시어 덕분에 이루어집니다.

Cloud Vision API에서 찾은 사진의 주요 색상에 해당하는 멋진 컬러 테두리가 사진에 표시되고 링크 및 이미지 소스의 썸네일과 전체 너비 사진을 가리킵니다.

마지막으로 그림을 설명하는 라벨을 나열합니다.

다음은 Vue.js 스니펫의 JavaScript 코드입니다 (index.html 페이지 하단에서 가져온 public/app.js 파일에 있음).

var app = new Vue({
  el: '#app',
  data() {
    return { pictures: [] }
  },
  mounted() {
    axios
      .get('/api/pictures')
      .then(response => { this.pictures = response.data })
  }
})

Vue 코드는 Axios 라이브러리를 사용하여 /api/pictures 엔드포인트에 AJAX 호출을 수행합니다. 그런 다음 반환된 데이터는 앞에서 본 마크업의 뷰 코드에 바인딩됩니다.

사진 보기

index.html에서 사용자는 사진의 썸네일을 보고, 썸네일을 클릭하여 전체 크기의 이미지를 볼 수 있으며, collage.html에서 사용자는 collage.png 이미지를 볼 수 있습니다.

이러한 페이지의 HTML 마크업에서 이미지 src와 링크 href는 이러한 3개의 엔드포인트를 가리키며, 이는 사진, 썸네일, 콜라주의 Cloud Storage 위치로 리디렉션됩니다. HTML 마크업에서 경로를 하드 코딩할 필요가 없습니다.

app.get('/api/pictures/:name', async (req, res) => {
    res.redirect(`https://storage.cloud.google.com/${process.env.BUCKET_PICTURES}/${req.params.name}`);
});

app.get('/api/thumbnails/:name', async (req, res) => {
    res.redirect(`https://storage.cloud.google.com/${process.env.BUCKET_THUMBNAILS}/${req.params.name}`);
});

app.get('/api/collage', async (req, res) => {
    res.redirect(`https://storage.cloud.google.com/${process.env.BUCKET_THUMBNAILS}/collage.png`);
});

Node 애플리케이션 실행

모든 엔드포인트가 정의되면 Node.js 애플리케이션을 실행할 준비가 된 것입니다. Express 애플리케이션은 기본적으로 포트 8080에서 수신 대기하며 들어오는 요청을 처리할 준비가 되어 있습니다.

const PORT = process.env.PORT || 8080;

app.listen(PORT, () => {
    console.log(`Started web frontend service on port ${PORT}`);
    console.log(`- Pictures bucket = ${process.env.BUCKET_PICTURES}`);
    console.log(`- Thumbnails bucket = ${process.env.BUCKET_THUMBNAILS}`);
});

6. 로컬에서 테스트

클라우드에 배포하기 전에 코드가 작동하는지 로컬에서 테스트합니다.

두 Cloud Storage 버킷에 해당하는 두 환경 변수를 내보내야 합니다.

export BUCKET_THUMBNAILS=thumbnails-${GOOGLE_CLOUD_PROJECT}
export BUCKET_PICTURES=uploaded-pictures-${GOOGLE_CLOUD_PROJECT}

frontend 폴더 내에서 npm 종속 항목을 설치하고 서버를 시작합니다.

npm install; npm start

모든 것이 잘 진행되면 포트 8080에서 서버가 시작됩니다.

Started web frontend service on port 8080
- Pictures bucket = uploaded-pictures-${GOOGLE_CLOUD_PROJECT}
- Thumbnails bucket = thumbnails-${GOOGLE_CLOUD_PROJECT}

이러한 로그에는 버킷의 실제 이름이 표시되므로 디버깅에 유용합니다.

Cloud Shell에서 웹 미리보기 기능을 사용하여 로컬에서 실행되는 애플리케이션을 탐색할 수 있습니다.

82fa3266d48c0d0a.png

종료하려면 CTRL-C을 사용합니다.

7. App Engine에 배포

애플리케이션을 배포할 준비가 되었습니다.

App Engine 구성

App Engine의 app.yaml 구성 파일을 검사합니다.

runtime: nodejs16
env_variables:
  BUCKET_PICTURES: uploaded-pictures-GOOGLE_CLOUD_PROJECT
  BUCKET_THUMBNAILS: thumbnails-GOOGLE_CLOUD_PROJECT

첫 번째 줄은 런타임이 Node.js 10을 기반으로 한다고 선언합니다. 원본 이미지와 썸네일의 두 버킷을 가리키도록 두 환경 변수가 정의됩니다.

GOOGLE_CLOUD_PROJECT를 실제 프로젝트 ID로 바꾸려면 다음 명령어를 실행하면 됩니다.

sed -i -e "s/GOOGLE_CLOUD_PROJECT/${GOOGLE_CLOUD_PROJECT}/" app.yaml

배포

App Engine의 선호 리전을 설정합니다. 이전 실습에서 사용한 것과 동일한 리전을 사용해야 합니다.

gcloud config set compute/region europe-west1

다음 명령어를 실행하여 배포합니다.

gcloud app deploy

1~2분 후 애플리케이션이 트래픽을 처리하고 있다는 메시지가 표시됩니다.

Beginning deployment of service [default]...
╔════════════════════════════════════════════════════════════╗
╠═ Uploading 8 files to Google Cloud Storage                ═╣
╚════════════════════════════════════════════════════════════╝
File upload done.
Updating service [default]...done.
Setting traffic split for service [default]...done.
Deployed service [default] to [https://GOOGLE_CLOUD_PROJECT.appspot.com]
You can stream logs from the command line by running:
  $ gcloud app logs tail -s default
To view your application in the web browser run:
  $ gcloud app browse

Cloud Console의 App Engine 섹션을 방문하여 앱이 배포되었는지 확인하고 버전 관리 및 트래픽 분할과 같은 App Engine 기능을 살펴볼 수도 있습니다.

db0e196b00fceab1.png

8. 앱 테스트

테스트하려면 앱의 기본 App Engine URL (https://<YOUR_PROJECT_ID>.appspot.com/)로 이동하면 프런트엔드 UI가 실행 중인 것을 확인할 수 있습니다.

6a4d5e5603ba4b73.png

9. 정리(선택 사항)

앱을 유지하지 않으려면 전체 프로젝트를 삭제하여 비용을 절감하고 전반적으로 우수한 클라우드 시민이 될 수 있습니다.

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

10. 축하합니다.

축하합니다. App Engine에서 호스팅되는 이 Node.js 웹 애플리케이션은 모든 서비스를 함께 바인딩하고 사용자가 사진을 업로드하고 시각화할 수 있도록 지원합니다.

학습한 내용

  • App Engine
  • Cloud Storage
  • Cloud Firestore

다음 단계