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

1. 개요

이 Codelab에서는 사용자가 웹 애플리케이션에서 사진을 업로드하고 업로드된 사진과 썸네일을 둘러볼 수 있게 하는 Google App Engine에 웹 프런트엔드를 만듭니다.

21741cd63b425aeb.png

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

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

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

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

6a4d5e5603ba4b73.png

이 세 페이지는 간단한 HTML 페이지입니다.

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

학습할 내용

  • 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에 액세스하기 위해
  • storage: 사진이 저장된 Google Cloud Storage에 액세스하기 위해
  • express: Node.js용 웹 프레임워크
  • dayjs: 사람이 읽을 수 있는 방식으로 날짜를 표시하는 작은 라이브러리
  • bluebird: JavaScript 프로미스 라이브러리
  • 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.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('/');
});

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

사진 나열하기

이제 멋진 사진을 보여주세요.

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

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

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에서 확인할 수 있는 것처럼 그림의 주요 색상에 해당하는 멋진 색상의 테두리가 있으며 링크 및 이미지 소스의 썸네일과 전체 너비 사진을 가리킵니다.

마지막으로, 사진을 설명하는 라벨을 나열합니다.

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

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. 로컬에서 테스트

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

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

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 콘솔의 App Engine 섹션을 방문하여 앱이 배포되었는지 확인하고 버전 관리 및 트래픽 분할과 같은 App Engine의 기능을 살펴볼 수 있습니다.

db0e196b00fceab1.png

8. 앱 테스트

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

6a4d5e5603ba4b73.png

9. 정리(선택 사항)

앱을 유지하지 않으려면 전체 프로젝트를 삭제하여 리소스를 정리하여 비용을 절감하고 전반적으로 클라우드를 효율적으로 활용할 수 있습니다.

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

10. 축하합니다.

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

학습한 내용

  • App Engine
  • Cloud Storage
  • Cloud Firestore

다음 단계