서버리스 웹 API 워크숍

1. 개요

이 Codelab의 목표는 '서버리스'를 사용해 본 경험을 얻는 것입니다. Google Cloud Platform에서 제공하는 서비스

  • Cloud Functions — 다양한 이벤트 (Pub/Sub 메시지, Cloud Storage의 새 파일, HTTP 요청 등)에 반응하는 함수 형태로 소규모 비즈니스 로직을 배포합니다.
  • App Engine — 빠른 확장 및 축소 기능으로 웹 앱, 웹 API, 모바일 백엔드, 정적 애셋을 배포하고 제공
  • Cloud Run — 모든 언어, 런타임 또는 라이브러리를 포함할 수 있는 컨테이너를 배포 및 확장합니다.

그리고 이러한 서버리스 서비스를 활용하여 웹 및 REST API를 배포 및 확장하는 방법을 알아보고 그 과정에서 유용한 RESTful 설계 원칙도 살펴보세요.

이 워크숍에서는 다음으로 구성된 서가 탐색기를 만들어 봅니다.

  • Cloud 함수: Cloud Firestore 문서 데이터베이스에서 Google 라이브러리에서 사용할 수 있는 도서의 초기 데이터 세트를 가져오려면 다음 단계를 따르세요.
  • Cloud Run 컨테이너: 데이터베이스의 콘텐츠를 통해 REST API를 노출합니다.
  • App Engine 웹 프런트엔드: REST API를 호출하여 도서 목록을 탐색합니다.

이 Codelab을 마칠 때 웹 프런트엔드는 다음과 같습니다.

705e014da0ca5e90.png

학습할 내용

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

2. 설정 및 요건

자습형 환경 설정

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

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • 프로젝트 이름은 이 프로젝트 참가자의 표시 이름입니다. 이는 Google API에서 사용하지 않는 문자열이며 언제든지 업데이트할 수 있습니다.
  • 프로젝트 ID는 모든 Google Cloud 프로젝트에서 고유하며, 변경할 수 없습니다(설정된 후에는 변경할 수 없음). Cloud 콘솔은 고유한 문자열을 자동으로 생성합니다. 일반적으로는 신경 쓰지 않아도 됩니다. 대부분의 Codelab에서는 프로젝트 ID (일반적으로 PROJECT_ID로 식별됨)를 참조해야 합니다. 생성된 ID가 마음에 들지 않으면 다른 임의 ID를 생성할 수 있습니다. 또는 직접 시도해 보고 사용 가능한지 확인할 수도 있습니다. 이 단계 이후에는 변경할 수 없으며 프로젝트 기간 동안 유지됩니다.
  • 참고로 세 번째 값은 일부 API에서 사용하는 프로젝트 번호입니다. 이 세 가지 값에 대한 자세한 내용은 문서를 참고하세요.
  1. 다음으로 Cloud 리소스/API를 사용하려면 Cloud 콘솔에서 결제를 사용 설정해야 합니다. 이 Codelab 실행에는 많은 비용이 들지 않습니다. 이 튜토리얼이 끝난 후에 요금이 청구되지 않도록 리소스를 종료하려면 만든 리소스 또는 프로젝트를 삭제하면 됩니다. Google Cloud 신규 사용자는 300달러(USD) 상당의 무료 체험판 프로그램에 참여할 수 있습니다.

Cloud Shell 시작

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

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

84688aa223b1c3a2.png

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

320e18fedb7fbe0.png

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

3. 환경 준비 및 Cloud API 사용 설정

이 프로젝트 전반에 걸쳐 필요한 다양한 서비스를 사용하기 위해 몇 가지 API를 사용 설정합니다. Cloud Shell에서 다음 명령어를 실행하면 됩니다.

$ gcloud services enable \
      appengine.googleapis.com \
      cloudbuild.googleapis.com \
      cloudfunctions.googleapis.com \
      compute.googleapis.com \
      firestore.googleapis.com \
      run.googleapis.com

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

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

이 과정에서 필요한 환경 변수, 즉 함수, 앱, 컨테이너를 배포할 클라우드 리전도 설정합니다.

$ export REGION=europe-west3

Cloud Firestore 데이터베이스에 데이터를 저장하므로 데이터베이스를 만들어야 합니다.

$ gcloud app create --region=${REGION}
$ gcloud firestore databases create --location=${REGION}

이 Codelab의 후반부에서 REST API를 구현할 때 데이터를 정렬하고 필터링해야 합니다. 이를 위해 세 개의 색인을 만듭니다.

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=language,order=ascending \
      --field-config field-path=updated,order=descending 

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=author,order=ascending \
      --field-config field-path=updated,order=descending 

이 3개의 색인은 업데이트된 필드를 통해 컬렉션의 순서를 유지하면서 작성자 또는 언어별로 실행하는 검색에 해당합니다.

4. 코드 가져오기

다음 GitHub 저장소에서 코드를 가져옵니다.

$ git clone https://github.com/glaforge/serverless-web-apis

애플리케이션 코드는 Node.JS를 사용하여 작성됩니다.

이 실습과 관련된 폴더 구조는 다음과 같습니다.

serverless-web-apis
 |
 ├── data
 |   ├── books.json
 |
 ├── function-import
 |   ├── index.js
 |   ├── package.json
 |
 ├── run-crud
 |   ├── index.js
 |   ├── package.json
 |   ├── Dockerfile
 |
 ├── appengine-frontend
     ├── public
     |   ├── css/style.css
     |   ├── html/index.html
     |   ├── js/app.js
     ├── index.js
     ├── package.json
     ├── app.yaml

관련 폴더는 다음과 같습니다.

  • data - 이 폴더에는 100권의 도서 목록의 샘플 데이터가 포함되어 있습니다.
  • function-import - 이 함수는 샘플 데이터를 가져오기 위한 엔드포인트를 제공합니다.
  • run-crud - 이 컨테이너는 Cloud Firestore에 저장된 도서 데이터에 액세스하기 위한 웹 API를 노출합니다.
  • appengine-frontend - 이 App Engine 웹 애플리케이션은 도서 목록을 탐색할 수 있는 간단한 읽기 전용 프런트엔드를 표시합니다.

5. 샘플 도서 라이브러리 데이터

data 폴더에는 읽을 만한 책 100권의 목록이 포함된 books.json 파일이 있습니다. 이 JSON 문서는 JSON 객체가 포함된 배열입니다. Cloud 함수를 통해 수집할 데이터의 형태를 살펴보겠습니다.

[
  {
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  },
  {
    "isbn": "9781414251196",
    "author": "Hans Christian Andersen",
    "language": "Danish",
    "pages": 784,
    "title": "Fairy tales",
    "year": 1836
  },
  ...
]

이 배열의 모든 도서 항목에는 다음 정보가 포함됩니다.

  • isbn: 도서를 식별하는 ISBN-13 코드입니다.
  • author - 도서 저자의 이름입니다.
  • language - 책에 사용된 음성 언어입니다.
  • pages - 책의 페이지 수입니다.
  • title: 도서 제목입니다.
  • year: 책이 출판된 연도입니다.

6. 샘플 도서 데이터를 가져오는 함수 엔드포인트

이 첫 번째 섹션에서는 샘플 도서 데이터를 가져오는 데 사용할 엔드포인트를 구현합니다. 이를 위해 Cloud Functions를 사용합니다.

코드 살펴보기

먼저 package.json 파일을 살펴보겠습니다.

{
    "name": "function-import",
    "description": "Import sample book data",
    "license": "Apache-2.0",
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9"
    },
    "devDependencies": {
        "@google-cloud/functions-framework": "^3.1.0"
    },
    "scripts": {
        "start": "npx @google-cloud/functions-framework --target=parseBooks"
    }
}

런타임 종속 항목에서 @google-cloud/firestore NPM 모듈만 있으면 데이터베이스에 액세스하고 도서 데이터를 저장할 수 있습니다. 또한 Cloud Functions 런타임은 내부에서 Express 웹 프레임워크를 제공하므로 종속 항목으로 선언할 필요가 없습니다.

개발 종속 항목에서 함수를 호출하는 데 사용되는 런타임 프레임워크인 함수 프레임워크 (@google-cloud/functions-framework)를 선언합니다. 또한 변경할 때마다 배포하지 않고 머신에서 로컬로 (이 경우 Cloud Shell 내에서) 사용할 수 있으므로 개발 피드백 루프를 개선할 수 있는 오픈소스 프레임워크입니다.

종속 항목을 설치하려면 install 명령어를 사용합니다.

$ npm install

start 스크립트는 Functions 프레임워크를 사용하여 다음 안내에 따라 로컬에서 함수를 실행하는 데 사용할 수 있는 명령어를 제공합니다.

$ npm start

HTTP GET 요청에 curl 또는 Cloud Shell 웹 미리보기를 사용하여 함수와 상호작용할 수 있습니다.

이제 도서 데이터 가져오기 함수의 로직이 포함된 index.js 파일을 살펴보겠습니다.

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Firestore 모듈을 인스턴스화하고 도서 컬렉션을 가리킵니다 (관계형 데이터베이스의 테이블과 유사).

functions.http('parseBooks', async (req, resp) => {
    if (req.method !== "POST") {
        resp.status(405).send({error: "Only method POST allowed"});
        return;
    }
    if (req.headers['content-type'] !== "application/json") {
        resp.status(406).send({error: "Only application/json accepted"});
        return;
    }
    ... 
})

parseBooks JavaScript 함수를 내보냅니다. 이 함수는 나중에 배포할 때 선언하게 됩니다.

다음 몇 가지 지침에서는 다음을 확인합니다.

  • Google에서는 HTTP POST 요청만 수락하며, 그 외에는 405 상태 코드를 반환하여 다른 HTTP 메서드가 허용되지 않음을 나타냅니다.
  • application/json 페이로드만 허용되며 그 외에는 허용되는 페이로드 형식이 아님을 나타내기 위해 406 상태 코드를 전송합니다.
    const books = req.body;

    const writeBatch = firestore.batch();

    for (const book of books) {
        const doc = bookStore.doc(book.isbn);
        writeBatch.set(doc, {
            title: book.title,
            author: book.author,
            language: book.language,
            pages: book.pages,
            year: book.year,
            updated: Firestore.Timestamp.now()
        });
    }

그런 다음 요청의 body를 통해 JSON 페이로드를 검색할 수 있습니다. 모든 도서를 대량으로 저장하기 위해 Firestore 일괄 작업을 준비하고 있습니다. 도서 세부정보로 구성된 JSON 배열을 반복하면서 isbn, title, author, language, pages, year 필드를 살펴봅니다. 책의 ISBN 코드가 기본 키 또는 식별자 역할을 합니다.

    try {
        await writeBatch.commit();
        console.log("Saved books in Firestore");
    } catch (e) {
        console.error("Error saving books:", e);
        resp.status(400).send({error: "Error saving books"});
        return;
    };

    resp.status(202).send({status: "OK"});

이제 대량 데이터가 준비되었으므로 작업을 커밋할 수 있습니다. 스토리지 작업이 실패하면 400 상태 코드를 반환하여 실패를 알립니다. 그 외의 경우에는 일괄 저장 요청이 수락되었음을 나타내는 202 상태 코드와 함께 OK 응답을 반환할 수 있습니다.

가져오기 함수 실행 및 테스트

코드를 실행하기 전에 다음을 사용하여 종속 항목을 설치합니다.

$ npm install

함수를 로컬에서 실행하기 위해 함수 프레임워크 덕분에 package.json에서 정의한 start 스크립트 명령어를 사용합니다.

$ npm start

> start
> npx @google-cloud/functions-framework --target=parseBooks

Serving function...
Function: parseBooks
URL: http://localhost:8080/

로컬 함수에 HTTP POST 요청을 보내려면 다음을 실행하면 됩니다.

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       http://localhost:8080/

이 명령어를 실행하면 함수가 로컬에서 실행 중임을 확인하는 다음 출력이 표시됩니다.

{"status":"OK"}

Cloud 콘솔 UI로 이동하여 데이터가 실제로 Firestore에 저장되어 있는지 확인할 수도 있습니다.

409982568cebdbf8.png

위의 스크린샷에서 생성된 books 컬렉션, 도서 ISBN 코드로 식별된 도서 문서 목록, 오른쪽에서 특정 도서 항목의 세부정보를 볼 수 있습니다.

클라우드에 함수 배포

Cloud Functions에 함수를 배포하기 위해 function-import 디렉터리에서 다음 명령어를 사용합니다.

$ gcloud functions deploy bulk-import \
         --gen2 \
         --trigger-http \
         --runtime=nodejs20 \
         --allow-unauthenticated \
         --max-instances=30
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

bulk-import의 기호화된 이름을 사용하여 함수를 배포합니다. 이 함수는 HTTP 요청을 통해 트리거됩니다. Node.JS 20 런타임을 사용합니다. 함수를 공개적으로 배포합니다. 해당 엔드포인트를 보호하는 것이 가장 이상적입니다. 함수를 배치할 리전을 지정합니다. 로컬 디렉터리의 소스를 가리키고 parseBooks (내보낸 JavaScript 함수)를 진입점으로 사용합니다.

몇 분 이내에 함수가 클라우드에 배포됩니다. Cloud 콘솔 UI에 함수가 표시되는 것을 확인할 수 있습니다.

c910875d4dc0aaa8.png

배포 출력에서 특정 이름 지정 규칙 (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME})을 따르는 함수의 URL을 볼 수 있어야 합니다. 물론 Cloud 콘솔 UI의 트리거 탭에서도 이 HTTP 트리거 URL을 찾을 수 있습니다.

380ffc46eb56441e.png

gcloud로 명령줄을 통해 URL을 검색할 수도 있습니다.

$ export BULK_IMPORT_URL=$(gcloud functions describe bulk-import \
                                  --region=$REGION \
                                  --format 'value(httpsTrigger.url)')
$ echo $BULK_IMPORT_URL

배포된 함수를 테스트하는 데 재사용할 수 있도록 BULK_IMPORT_URL 환경 변수에 저장하겠습니다.

배포된 함수 테스트

로컬에서 실행되는 함수를 테스트하기 위해 이전에 사용한 유사한 curl 명령어를 사용하여 배포된 함수를 테스트합니다. URL만 변경됩니다.

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       $BULK_IMPORT_URL

다시 성공하면 다음과 같은 출력이 반환됩니다.

{"status":"OK"}

이제 가져오기 함수가 배포되고 준비되었으므로 샘플 데이터를 업로드했으므로 이 데이터 세트를 노출하는 REST API를 개발할 차례입니다.

7. REST API 계약

예를 들어 Open API 사양을 사용하여 API 계약을 정의하지는 않지만 REST API의 다양한 엔드포인트를 살펴보겠습니다.

API 교환은 다음으로 구성된 JSON 객체를 예약합니다.

  • isbn (선택사항): 유효한 ISBN 코드를 나타내는 13자 String
  • author: 도서 저자의 이름을 나타내는 비어 있지 않은 String입니다.
  • language: 책의 작성 언어가 포함된 비어 있지 않은 String입니다.
  • pages: 도서 페이지 수의 양수 Integer입니다.
  • title: 도서 제목이 있는 비어 있지 않은 String입니다.
  • year: 도서 발행 연도의 Integer 값입니다.

도서 페이로드의 예:

{
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  }

/books 받기

저자나 언어별로 필터링되고 한 번에 10개 결과 창이 열리며 페이지가 매겨진 모든 도서의 목록을 가져옵니다.

본문 페이로드: 없음

쿼리 매개변수:

  • author (선택사항) — 저자별로 도서 목록을 필터링합니다.
  • language (선택사항) — 언어별로 도서 목록을 필터링합니다.
  • page (선택사항, 기본값 = 0) - 반환할 결과 페이지의 순위를 나타냅니다.

반환: 도서 객체의 JSON 배열

상태 코드:

  • 200: 요청이 도서 목록을 가져오는 데 성공하면
  • 400: 오류가 발생하는 경우

/books/{isbn} 게시하기

isbn 경로 매개변수와 함께 (isbn 코드가 도서 페이로드에 필요하지 않은 경우) 또는 포함하지 않은 (isbn 코드가 도서 페이로드에 있어야 함) 새 도서 페이로드를 게시합니다.

본문 페이로드: 도서 객체

쿼리 매개변수: 없음

반환: 아무것도 없습니다.

상태 코드:

  • 201: 도서가 성공적으로 저장되면
  • 406: isbn 코드가 유효하지 않은 경우
  • 400: 오류가 발생하는 경우

/books/{isbn} 받기

isbn 코드로 식별되며 경로 매개변수로 전달된 도서를 라이브러리에서 검색합니다.

본문 페이로드: 없음

쿼리 매개변수: 없음

반환: 도서 JSON 객체 또는 도서가 없는 경우 오류 객체.

상태 코드:

  • 200: 도서가 데이터베이스에서 발견되면
  • 400: 오류가 발생하면
  • 404: 도서를 찾을 수 없는 경우
  • 406: isbn 코드가 유효하지 않은 경우

PUT /books/{isbn}

path 매개변수로 전달된 isbn로 식별되는 기존 도서를 업데이트합니다.

본문 페이로드: 도서 객체 업데이트가 필요한 필드만 전달할 수 있으며 나머지 필드는 선택사항입니다.

쿼리 매개변수: 없음

반환: 업데이트된 책.

상태 코드:

  • 200: 도서가 성공적으로 업데이트되면
  • 400: 오류가 발생하면
  • 406: isbn 코드가 유효하지 않은 경우

/books/{isbn} 삭제

path 매개변수로 전달된 isbn로 식별된 기존 도서를 삭제합니다.

본문 페이로드: 없음

쿼리 매개변수: 없음

반환: 아무것도 없습니다.

상태 코드:

  • 204: 도서가 성공적으로 삭제된 경우
  • 400: 오류가 발생하는 경우

8. 컨테이너에 REST API 배포 및 노출

코드 살펴보기

Dockerfile

먼저 애플리케이션 코드를 컨테이너화하는 Dockerfile를 살펴보겠습니다.

FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . ./
CMD [ "node", "index.js" ]

Node.JS 20 "slim" 이미지를 사용하고 있습니다. /usr/src/app 디렉터리에서 작업하고 있습니다. 종속 항목을 정의하는 package.json 파일 (아래 세부정보 참고)을 복사합니다. npm install로 종속 항목을 설치하여 소스 코드를 복사합니다. 마지막으로 node index.js 명령어를 사용하여 이 애플리케이션의 실행 방법을 지정합니다.

package.json

다음으로 package.json 파일을 살펴보겠습니다.

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 20.0.0"
    },
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "scripts": {
        "start": "node index.js"
    }
}

Dockerfile의 경우와 마찬가지로 Node.JS 14를 사용하도록 지정합니다.

웹 API 애플리케이션은 다음에 의존합니다.

  • 데이터베이스의 도서 데이터에 액세스하는 Firestore NPM 모듈
  • App Engine 웹 애플리케이션 프런트엔드의 클라이언트 코드에서 REST API가 호출되므로 CORS (교차 출처 리소스 공유) 요청을 처리하는 cors 라이브러리
  • API 설계를 위한 웹 프레임워크인 Express 프레임워크는
  • 그런 다음 isbn3 모듈을 통해 도서 ISBN 코드를 검증합니다.

start 스크립트도 지정합니다. 이 스크립트는 개발 및 테스트 목적으로 로컬에서 애플리케이션을 시작할 때 유용합니다.

index.js

코드의 핵심으로 넘어가 index.js를 자세히 살펴보겠습니다.

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Firestore 모듈이 필요하며 도서 데이터가 저장되는 books 컬렉션을 참조합니다.

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.json());

const querystring = require('querystring');

const cors = require('cors');
app.use(cors({
    exposedHeaders: ['Content-Length', 'Content-Type', 'Link'],
}));

REST API를 구현하기 위해 웹 프레임워크로 Express를 사용하고 있습니다. body-parser 모듈을 사용하여 API와 교환된 JSON 페이로드를 파싱합니다.

querystring 모듈은 URL을 조작하는 데 유용합니다. 페이지로 나누기 목적으로 Link 헤더를 만들 때 그렇습니다 (나중에 자세히 설명).

그런 다음 cors 모듈을 구성합니다. 대부분의 경우 일반적으로 제거되므로 CORS를 통해 전달될 헤더를 명시하지만 여기서는 페이지로 나누기를 위해 지정할 Link 헤더뿐만 아니라 일반적인 콘텐츠 길이와 유형을 유지하려고 합니다.

const ISBN = require('isbn3');

function isbnOK(isbn, res) {
    const parsedIsbn = ISBN.parse(isbn);
    if (!parsedIsbn) {
        res.status(406)
            .send({error: `Invalid ISBN: ${isbn}`});
        return false;
    }
    return parsedIsbn;
}

Google에서는 isbn3 NPM 모듈을 사용하여 ISBN 코드를 파싱하고 확인하며, ISBN 코드가 잘못된 경우 ISBN 코드를 파싱하고 응답에 406 상태 코드로 응답하는 작은 유틸리티 함수를 개발할 예정입니다.

  • GET /books

GET /books 엔드포인트를 부분별로 살펴보겠습니다.

app.get('/books', async (req, res) => {
    try {
        var query = new Firestore().collection('books');

        if (!!req.query.author) {
            console.log(`Filtering by author: ${req.query.author}`);
            query = query.where("author", "==", req.query.author);
        }
        if (!!req.query.language) {
            console.log(`Filtering by language: ${req.query.language}`);
            query = query.where("language", "==", req.query.language);
        }

        const page = parseInt(req.query.page) || 0;

        // - -  - -  - -  - -  - -  - -

    } catch (e) {
        console.error('Failed to fetch books', e);
        res.status(400)
            .send({error: `Impossible to fetch books: ${e.message}`});
    }
});

쿼리를 준비하여 데이터베이스를 쿼리할 준비를 하고 있습니다. 이 쿼리는 선택적 쿼리 매개변수에 따라 저자 또는 언어를 기준으로 필터링합니다. 또한 도서 10권 단위로 도서 목록을 반환합니다.

책을 가져오는 동안 오류가 발생하면 400 상태 코드와 함께 오류가 반환됩니다.

엔드포인트에서 잘린 부분을 자세히 살펴보겠습니다.

        const snapshot = await query
            .orderBy('updated', 'desc')
            .limit(PAGE_SIZE)
            .offset(PAGE_SIZE * page)
            .get();

        const books = [];

        if (snapshot.empty) {
            console.log('No book found');
        } else {
            snapshot.forEach(doc => {
                const {title, author, pages, year, language, ...otherFields} = doc.data();
                const book = {isbn: doc.id, title, author, pages, year, language};
                books.push(book);
            });
        }

이전 섹션에서는 authorlanguage를 기준으로 필터링했지만 이 섹션에서는 도서 목록을 마지막으로 업데이트한 날짜 순으로 정렬합니다 (마지막 업데이트가 먼저 표시됨). 또한 제한 (반환할 요소 수)과 오프셋 (다음 도서 배치를 반환할 시작점)을 정의하여 결과를 페이지로 나눕니다.

쿼리를 실행하고 데이터의 스냅샷을 가져온 후 함수의 끝에서 반환되는 JavaScript 배열에 결과를 넣습니다.

Link 헤더를 사용하여 데이터의 첫 번째, 이전, 다음 또는 마지막 페이지에 대한 URI 링크를 정의하는 좋은 연습을 통해 이 엔드포인트에 관한 설명을 마무리하겠습니다. 여기서는 이전 및 다음 페이지만 제공합니다.

        var links = {};
        if (page > 0) {
            const prevQuery = querystring.stringify({...req.query, page: page - 1});
            links.prev = `${req.path}${prevQuery != '' ? `?${prevQuery}` : ''}`;
        }
        if (snapshot.docs.length === PAGE_SIZE) {
            const nextQuery = querystring.stringify({...req.query, page: page + 1});
            links.next = `${req.path}${nextQuery != '' ? `?${nextQuery}` : ''}`;
        }
        if (Object.keys(links).length > 0) {
            res.links(links);
        }

        res.status(200).send(books);

처음에는 로직이 약간 복잡하게 보일 수 있지만 데이터의 첫 번째 페이지에 있지 않다면 이전 링크를 추가하는 것입니다. 또한 데이터 페이지가 가득 차면 다음 링크를 추가합니다. 즉, 더 많은 데이터를 산출하는 다른 페이지가 있다고 가정할 때 PAGE_SIZE 상수로 정의된 최대 권의 책을 포함합니다. 그런 다음 Express의 resource#links() 함수를 사용하여 올바른 문법으로 올바른 헤더를 만듭니다.

참고로 링크 헤더는 다음과 같이 표시됩니다.

link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
  • POST /booksPOST /books/:isbn

두 엔드포인트 모두 새 도서를 만듭니다. 하나는 도서 페이로드의 ISBN 코드를 전달하는 반면, 다른 하나는 경로 매개변수로 ISBN 코드를 전달합니다. 두 경우 모두 createBook() 함수를 호출합니다.

async function createBook(isbn, req, res) {
    const parsedIsbn = isbnOK(isbn, res);
    if (!parsedIsbn) return;

    const {title, author, pages, year, language} = req.body;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            title, author, pages, year, language,
            updated: Firestore.Timestamp.now()
        });
        console.log(`Saved book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} created`});
    } catch (e) {
        console.error(`Failed to save book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to create book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
}

isbn 코드가 유효한지 확인합니다. 유효하지 않으면 함수에서 반환됩니다 (406 상태 코드를 설정). 요청 본문에 전달된 페이로드에서 도서 필드를 가져옵니다. 그런 다음 Firestore에 도서 세부정보를 저장합니다. 성공 시 201, 실패 시 400를 반환합니다.

성공적으로 반환되면 새로 생성된 리소스가 있는 API의 클라이언트에 신호를 제공할 수 있도록 위치 헤더도 설정합니다. 헤더는 다음과 같이 표시됩니다.

Location: /books/9781234567898
  • GET /books/:isbn

ISBN을 통해 식별된 도서를 Firestore에서 가져와 보겠습니다.

app.get('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        const docSnapshot = await docRef.get();

        if (!docSnapshot.exists) {
            console.log(`Book not found ${parsedIsbn.isbn13}`)
            res.status(404)
                .send({error: `Could not find book ${parsedIsbn.isbn13}`});
            return;
        }

        console.log(`Fetched book ${parsedIsbn.isbn13}`, docSnapshot.data());

        const {title, author, pages, year, language, ...otherFields} = docSnapshot.data();
        const book = {isbn: parsedIsbn.isbn13, title, author, pages, year, language};

        res.status(200).send(book);
    } catch (e) {
        console.error(`Failed to fetch book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to fetch book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Google에서는 항상 ISBN이 유효한지 확인합니다. Firestore에 쿼리를 실행하여 도서를 가져옵니다. snapshot.exists 속성을 사용하면 실제로 도서가 발견되었는지 쉽게 확인할 수 있습니다. 그렇지 않으면 오류 및 404 찾을 수 없음 상태 코드를 반환합니다. 도서 데이터를 검색하고 도서를 나타내는 JSON 객체를 생성하여 반환합니다.

  • PUT /books/:isbn

PUT 메서드를 사용하여 기존 도서를 업데이트합니다.

app.put('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            ...req.body,
            updated: Firestore.Timestamp.now()
        }, {merge: true});
        console.log(`Updated book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} updated`});
    } catch (e) {
        console.error(`Failed to update book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to update book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
});

레코드가 마지막으로 업데이트된 시간을 기억하기 위해 updated 날짜/시간 필드를 업데이트합니다. 기존 필드를 새 값으로 바꾸는 {merge:true} 전략을 사용합니다. 그렇지 않으면 모든 필드가 삭제되고 페이로드의 새 필드만 저장되어 이전 업데이트 또는 초기 생성에서 기존 필드가 지워집니다.

또한 Location 헤더가 도서의 URI를 가리키도록 설정합니다.

  • DELETE /books/:isbn

책을 삭제하는 방법은 매우 간단합니다. 문서 참조에 대해 delete() 메서드를 호출하기만 하면 됩니다. 반환되는 콘텐츠가 없으므로 204 상태 코드가 반환됩니다.

app.delete('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.delete();
        console.log(`Book ${parsedIsbn.isbn13} was deleted`);

        res.status(204).end();
    } catch (e) {
        console.error(`Failed to delete book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to delete book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Express / Node 서버 시작

마지막으로, 기본적으로 포트 8080에서 수신 대기하는 서버를 시작합니다.

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Books Web API service: listening on port ${port}`);
    console.log(`Node ${process.version}`);
});

애플리케이션을 로컬로 실행

애플리케이션을 로컬에서 실행하려면 먼저 다음을 사용하여 종속 항목을 설치합니다.

$ npm install

그리고 나서 다음을 시작할 수 있습니다.

$ npm start

서버는 기본적으로 localhost에서 시작되고 포트 8080에서 리슨합니다.

또한 다음 명령어를 사용하여 Docker 컨테이너를 빌드하고 컨테이너 이미지도 실행할 수 있습니다.

$ docker build -t crud-web-api .

$ docker run --rm -p 8080:8080 -it crud-web-api

Docker 내에서 실행하는 것은 Cloud Build로 클라우드에서 애플리케이션을 빌드할 때 애플리케이션의 컨테이너화가 제대로 실행되는지 다시 확인할 수 있는 좋은 방법입니다.

API 테스트

REST API 코드를 (노드를 통해 직접 또는 Docker 컨테이너 이미지를 통해 직접) 실행하는 방법에 관계없이, 이제 몇 가지 쿼리를 실행할 수 있습니다.

  • 새 책을 만듭니다 (본문 페이로드의 ISBN).
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • 새 책 만들기 (path 매개변수의 ISBN):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • Google에서 만든 책을 삭제하려면 다음 단계를 따르세요.
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • ISBN으로 책을 검색하려면 다음 단계를 따르세요.
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • 제목만 변경하여 기존 도서를 업데이트하려면 다음 단계를 따르세요.
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • 도서 목록 (처음 10개)을 가져옵니다.
$ curl http://localhost:8080/books
  • 특정 작가가 쓴 책을 찾습니다.
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • 영어로 작성된 도서 나열:
$ curl http://localhost:8080/books?language=English
  • 책의 4번째 페이지 로드:
$ curl http://localhost:8080/books?page=3

author, language, books 쿼리 매개변수를 결합하여 검색 범위를 좁힐 수도 있습니다.

컨테이너화된 REST API 빌드 및 배포

REST API가 계획에 따라 작동하게 되어 기쁘기 때문에 지금이 바로 Cloud Run의 Cloud에 배포하기에 적절한 시점입니다.

이 작업은 두 단계로 진행됩니다.

  • 먼저 다음 명령어를 사용하여 Cloud Build로 컨테이너 이미지를 빌드합니다.
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • 그런 다음 이 두 번째 명령어를 사용하여 서비스를 배포합니다.
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

첫 번째 명령어로 Cloud Build가 컨테이너 이미지를 빌드하고 Container Registry에서 호스팅합니다. 다음 명령어는 레지스트리에서 컨테이너 이미지를 배포하고 클라우드 리전에 배포합니다.

Cloud 콘솔 UI에서 이제 Cloud Run 서비스가 목록에 표시되는지 다시 한번 확인할 수 있습니다.

f62fbca02a8127c0.png

여기서 마지막으로 할 일은 다음 명령어를 사용하여 새로 배포된 Cloud Run 서비스의 URL을 가져오는 것입니다.

$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \
                                       --region=${REGION} \
                                       --platform=managed \
                                       --format='value(status.url)')

App Engine 프런트엔드 코드가 API와 상호작용하므로 다음 섹션에서는 Cloud Run REST API의 URL이 필요합니다.

9. 웹 앱을 호스팅하여 라이브러리 탐색

이 프로젝트에 빛을 더할 수 있는 마지막 퍼즐 조각은 REST API와 상호작용할 웹 프런트엔드를 제공하는 것입니다. 이를 위해 Google App Engine과 함께 AJAX 요청 (클라이언트 측 Fetch API 사용)을 통해 API를 호출하는 클라이언트 JavaScript 코드를 사용합니다.

이 애플리케이션은 Node.JS App Engine 런타임에 배포되지만 대부분 정적 리소스로 구성됩니다. 사용자 상호작용의 대부분이 클라이언트 측 JavaScript를 통해 브라우저에서 이루어지므로 백엔드 코드가 많지 않습니다. 고급 프런트엔드 JavaScript 프레임워크는 사용하지 않고 Shoelace 웹 구성요소 라이브러리를 사용하는 UI용 웹 구성요소와 함께 몇 가지 'vanilla' JavaScript만 사용합니다.

  • 도서 언어를 선택하는 선택 상자:

6fb9f741000a2dc1.png

  • 특정 도서에 관한 세부정보를 표시할 카드 구성요소 (JsBarcode 라이브러리를 사용하여 도서의 ISBN을 나타내는 바코드 포함):

3aa21a9e16e3244e.png

  • 데이터베이스에서 더 많은 책을 로드하는 버튼이 있습니다.

3925ad81c91bbac9.png

이러한 모든 시각적 구성요소를 함께 결합하면 라이브러리를 탐색할 수 있는 결과 웹페이지가 다음과 같이 표시됩니다.

18a5117150977d6.png

app.yaml 구성 파일

app.yaml 구성 파일을 확인하여 이 App Engine 애플리케이션의 코드베이스를 자세히 살펴보겠습니다. 이 파일은 App Engine에만 해당하는 파일로, 환경 변수나 애플리케이션의 다양한 '핸들러' 등을 구성하거나 일부 리소스가 App Engine의 기본 제공 CDN에 의해 제공될 정적 애셋임을 지정할 수 있게 해줍니다.

runtime: nodejs14

env_variables:
  RUN_CRUD_SERVICE_URL: CHANGE_ME

handlers:

- url: /js
  static_dir: public/js

- url: /css
  static_dir: public/css

- url: /img
  static_dir: public/img

- url: /(.+\.html)
  static_files: public/html/\1
  upload: public/(.+\.html)

- url: /
  static_files: public/html/index.html
  upload: public/html/index\.html

- url: /.*
  secure: always
  script: auto

애플리케이션이 Node.JS 애플리케이션이고 버전 14를 사용하려고 합니다.

그런 다음 Cloud Run 서비스 URL을 가리키는 환경 변수를 정의합니다. CHANGE_ME 자리표시자를 올바른 URL로 업데이트해야 합니다 (변경하는 방법은 아래 참고).

그런 다음 다양한 핸들러를 정의합니다. 처음 3개는 public/ 폴더와 하위 폴더 아래에 있는 HTML, CSS, JavaScript 클라이언트 측 코드 위치를 가리킵니다. 네 번째는 App Engine 애플리케이션의 루트 URL이 index.html 페이지를 가리켜야 함을 나타냅니다. 이렇게 하면 웹사이트의 루트에 액세스할 때 URL에 index.html 접미사가 표시되지 않습니다. 마지막은 다른 모든 URL (/.*)을 Node.JS 애플리케이션으로 라우팅하는 기본 URL입니다 (앞서 설명한 정적 애셋과 달리 애플리케이션의 '동적' 부분).

이제 Cloud Run 서비스의 웹 API URL을 업데이트해 보겠습니다.

appengine-frontend/ 디렉터리에서 다음 명령어를 실행하여 Cloud Run 기반 REST API의 URL을 가리키는 환경 변수를 업데이트합니다.

$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml

또는 app.yamlCHANGE_ME 문자열을 올바른 URL로 수동으로 변경합니다.

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

Node.JS package.json 파일

{
    "name": "appengine-frontend",
    "description": "Web frontend",
    "license": "Apache-2.0",
    "main": "index.js",
    "engines": {
        "node": "^14.0.0"
    },
    "dependencies": {
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "devDependencies": {
        "nodemon": "^2.0.7"
    },
    "scripts": {
        "start": "node index.js",
        "dev": "nodemon --watch server --inspect index.js"
    }
}

Node.JS 14를 사용하여 이 애플리케이션을 실행해야 한다는 점을 다시 한번 강조합니다. Google에서는 Express 프레임워크뿐 아니라 isbn3 NPM 모듈을 사용하여 도서를 확인합니다. ISBN 코드입니다.

개발 종속 항목에서 nodemon 모듈을 사용하여 파일 변경사항을 모니터링합니다. npm start를 사용하여 로컬에서 애플리케이션을 실행하고 코드를 일부 변경하고 ^C로 앱을 중지했다가 다시 실행할 수 있지만 이는 약간 지루한 작업입니다. 대신 다음 명령어를 사용하여 변경 시 애플리케이션이 자동으로 다시 로드 / 다시 시작되도록 할 수 있습니다.

$ npm run dev

index.js Node.JS 코드

const express = require('express');
const app = express();

app.use(express.static('public'));

const bodyParser = require('body-parser');
app.use(bodyParser.json());

Express 웹 프레임워크가 필요합니다. 공개 디렉터리에는 static 미들웨어가 제공할 수 있는 (적어도 개발 모드에서 로컬로 실행하는 경우) 정적 애셋이 포함되어 있다고 지정합니다. 마지막으로 body-parser가 JSON 페이로드를 파싱해야 합니다.

정의한 몇 가지 경로를 살펴보겠습니다.

app.get('/', async (req, res) => {
    res.redirect('/html/index.html');
});

app.get('/webapi', async (req, res) => {
    res.send(process.env.RUN_CRUD_SERVICE_URL);
});

/와 일치하는 첫 번째 항목은 public/html 디렉터리의 index.html로 리디렉션됩니다. 개발 모드에서는 App Engine 런타임을 실행하지 않기 때문에 App Engine의 URL 라우팅이 실행되지 않습니다. 따라서 여기서는 단순히 루트 URL을 HTML 파일로 리디렉션합니다.

/webapi에서 정의한 두 번째 엔드포인트는 Cloud RUN REST API의 URL을 반환합니다. 이렇게 하면 클라이언트 측 JavaScript 코드가 도서 목록을 가져오기 위해 호출할 위치를 알 수 있습니다.

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Book library web frontend: listening on port ${port}`);
    console.log(`Node ${process.version}`);
    console.log(`Web API endpoint ${process.env.RUN_CRUD_SERVICE_URL}`);
});

완료하기 위해 Express 웹 앱을 실행하고 기본적으로 포트 8080에서 수신 대기합니다.

index.html 페이지

이 긴 HTML 페이지의 모든 줄을 살펴보지는 않습니다. 대신 몇 가지 핵심 사항을 강조해 보겠습니다.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/themes/base.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/shoelace.js"></script>

<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/dist/barcodes/JsBarcode.ean-upc.min.js"></script>

<script src="/js/app.js"></script>
<link rel="stylesheet" type="text/css" href="/css/style.css">

처음 두 줄은 Shoelace 웹 구성요소 라이브러리 (스크립트 및 스타일시트)를 가져옵니다.

다음 행에서는 JsBarcode 라이브러리를 가져와서 도서 ISBN 코드의 바코드를 만듭니다.

마지막 줄은 public/ 하위 디렉터리에 있는 자체 자바스크립트 코드와 CSS 스타일시트를 가져옵니다.

HTML 페이지의 body에서 다음과 같이 맞춤 요소 태그와 함께 신발끈 구성요소를 사용합니다.

<sl-icon name="book-half"></sl-icon>
...

<sl-select id="language-select" placeholder="Select a language..." clearable>
    <sl-menu-item value="English">English</sl-menu-item>
    <sl-menu-item value="French">French</sl-menu-item>
    ...
</sl-select>
...

<sl-button id="more-button" type="primary" size="large">
    More books...
</sl-button>
...

또한 HTML 템플릿과 슬롯 채우기 기능을 사용하여 책을 나타냅니다. 이 템플릿의 사본을 만들어 도서 목록을 채우고 슬롯의 값을 책의 세부정보로 바꿉니다.

    <template id="book-card">
        <sl-card class="card-overview">
        ...
            <slot name="author">Author</slot>
            ... 
        </sl-card>
    </template>

HTML만 작성했으므로 코드 검토가 거의 완료되었습니다. 마지막으로 중요한 부분은 REST API와 상호작용하는 app.js 클라이언트 측 JavaScript 코드입니다.

app.js 클라이언트 측 JavaScript 코드

DOM 콘텐츠가 로드될 때까지 기다리는 최상위 이벤트 리스너부터 시작합니다.

document.addEventListener("DOMContentLoaded", async function(event) {
    ...
}

준비가 되면 주요 상수와 변수를 설정할 수 있습니다.

    const serverUrlResponse = await fetch('/webapi');
    const serverUrl = await serverUrlResponse.text();
    console.log('Web API endpoint:', serverUrl);
    
    const server = serverUrl + '/books';
    var page = 0;
    var language = '';

먼저 app.yaml에서 처음에 설정한 환경 변수를 반환하는 App Engine 노드 코드 덕분에 REST API의 URL을 가져옵니다. JavaScript 클라이언트 측 코드에서 호출되는 환경 변수인 /webapi 엔드포인트 덕분에 프런트엔드 코드에 REST API URL을 하드코딩하지 않아도 되었습니다.

페이지로 나누기 및 언어 필터링을 추적하는 데 사용할 pagelanguage 변수도 정의합니다.

    const moreButton = document.getElementById('more-button');
    moreButton.addEventListener('sl-focus', event => {
        console.log('Button clicked');
        moreButton.blur();

        appendMoreBooks(server, page++, language);
    });

도서 로드를 위해 버튼에 이벤트 핸들러를 추가합니다. 이 버튼을 클릭하면 appendMoreBooks() 함수를 호출합니다.

    const langSelect = document.getElementById('language-select');
    langSelect.addEventListener('sl-change', event => {
        page = 0;
        language = event.srcElement.value;
        document.getElementById('library').replaceChildren();
        console.log(`Language selected: "${language}"`);

        appendMoreBooks(server, page++, language);
    });

선택 상자의 경우에도 언어 선택 변경에 대한 알림을 받는 이벤트 핸들러를 추가합니다. 또한 버튼과 마찬가지로 appendMoreBooks() 함수를 호출하여 REST API URL, 현재 페이지, 언어 선택을 전달합니다.

이제 도서를 가져오고 추가하는 함수를 살펴보겠습니다.

async function appendMoreBooks(server, page, language) {
    const searchUrl = new URL(server);
    if (!!page) searchUrl.searchParams.append('page', page);
    if (!!language) searchUrl.searchParams.append('language', language);
        
    const response = await fetch(searchUrl.href);
    const books = await response.json();
    ... 
}

위에서 REST API를 호출하는 데 사용할 정확한 URL을 작성해 보겠습니다. 일반적으로 지정할 수 있는 쿼리 매개변수는 세 가지이지만 이 UI에서는 2개만 지정합니다.

  • page: 도서의 페이지로 나누기의 현재 페이지를 나타내는 정수
  • language: 문자 언어로 필터링할 언어 문자열입니다.

그런 다음 Fetch API를 사용하여 도서 세부정보가 포함된 JSON 배열을 가져옵니다.

    const linkHeader = response.headers.get('Link')
    console.log('Link', linkHeader);
    if (!!linkHeader && linkHeader.indexOf('rel="next"') > -1) {
        console.log('Show more button');
        document.getElementById('buttons').style.display = 'block';
    } else {
        console.log('Hide more button');
        document.getElementById('buttons').style.display = 'none';
    }

응답에 Link 헤더가 있는지에 따라 [More books...] 버튼을 표시하거나 숨깁니다. Link 헤더는 여전히 로드할 도서가 더 있는지 알려주는 힌트이기 때문입니다 (Link 헤더에 next URL이 있음).

    const library = document.getElementById('library');
    const template = document.getElementById('book-card');
    for (let book of books) {
        const bookCard = template.content.cloneNode(true);

        bookCard.querySelector('slot[name=title]').innerText = book.title;
        bookCard.querySelector('slot[name=language]').innerText = book.language;
        bookCard.querySelector('slot[name=author]').innerText = book.author;
        bookCard.querySelector('slot[name=year]').innerText = book.year;
        bookCard.querySelector('slot[name=pages]').innerText = book.pages;
        
        const img = document.createElement('img');
        img.setAttribute('id', book.isbn);
        img.setAttribute('class', 'img-barcode-' + book.isbn)
        bookCard.querySelector('slot[name=barcode]').appendChild(img);

        library.appendChild(bookCard);
        ... 
    }
}

함수의 위 섹션에서는 REST API에서 반환한 각 도서에 대해 도서를 나타내는 일부 웹 구성요소로 템플릿을 클론하고 도서의 세부정보로 템플릿의 슬롯을 채웁니다.

JsBarcode('.img-barcode-' + book.isbn).EAN13(book.isbn, {fontSize: 18, textMargin: 0, height: 60}).render();

ISBN 코드를 더 멋지게 만들기 위해 JsBarcode 라이브러리를 사용하여 실제 책의 뒷면과 같은 멋진 바코드를 만듭니다.

로컬에서 애플리케이션 실행 및 테스트

지금은 코드면 충분합니다. 이제 애플리케이션이 작동하는 모습을 볼 차례입니다. 먼저 Cloud Shell 내에서 로컬로 실행한 후 실제 배포하겠습니다.

다음을 사용하여 애플리케이션에 필요한 NPM 모듈을 설치합니다.

$ npm install

그리고 평소처럼 앱을 실행합니다.

$ npm start

또는 다음과 같이 nodemon 덕분에 변경사항을 자동으로 새로고침할 수 있습니다.

$ npm run dev

애플리케이션이 로컬에서 실행 중이며 브라우저의 http://localhost:8080에서 액세스할 수 있습니다.

App Engine 애플리케이션 배포

이제 애플리케이션이 로컬에서 잘 실행되는 것을 확신했으므로 App Engine에 배포해 보겠습니다.

애플리케이션을 배포하기 위해 다음 명령어를 실행해 보겠습니다.

$ gcloud app deploy -q

약 1분 후에 애플리케이션이 배포됩니다.

애플리케이션은 https://${GOOGLE_CLOUD_PROJECT}.appspot.com 형태의 URL에서 사용할 수 있습니다.

App Engine 웹 애플리케이션의 UI 살펴보기

추가된 기능은 다음과 같습니다.

  • 더 많은 책을 로드하려면 [More books...] 버튼을 클릭하세요.
  • 특정 언어를 선택하면 해당 언어로 된 책만 볼 수 있습니다.
  • 선택 상자의 작은 십자가를 사용하여 선택 항목을 지우면 전체 도서 목록으로 돌아갈 수 있습니다.

10. 삭제(선택사항)

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

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. 축하합니다.

Cloud Functions, App Engine, Cloud Run 덕분에 다양한 웹 API 엔드포인트와 웹 프런트엔드를 노출하고, REST API 개발을 위한 몇 가지 훌륭한 설계 패턴에 따라 도서 라이브러리를 저장, 업데이트, 찾아볼 수 있는 서비스 세트를 만들었습니다.

학습한 내용

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

추가 정보

이 구체적인 예를 더 살펴보고 확장하려면 다음과 같은 사항을 조사할 수 있습니다.

  • API 게이트웨이를 활용하여 데이터 가져오기 기능 및 REST API 컨테이너에 공통 API 파사드를 제공하거나, API 키를 처리하기 위해 API 키 처리와 같은 기능을 추가하거나, API 소비자의 비율 제한을 정의합니다.
  • App Engine 애플리케이션에 Swagger-UI 노드 모듈을 배포하여 REST API를 위한 테스트 플레이그라운드를 문서화하고 제공합니다.
  • 프런트엔드에서는 기존 찾아보기 기능을 넘어 데이터를 편집하기 위한 추가 화면을 추가하고 새로운 책 항목을 만들 수 있습니다. 또한 Cloud Firestore 데이터베이스를 사용하고 있으므로 실시간 기능을 활용하여 변경사항이 있을 때 표시되는 도서 데이터를 업데이트합니다.