Cloud SQL Node.js 커넥터를 사용하여 PostgreSQL용 Cloud SQL과 함께 Cloud Run에 풀 스택 Angular 애플리케이션 배포

1. 개요

Cloud Run은 Google의 확장 가능한 인프라에서 직접 코드를 실행할 수 있게 해 주는 완전 관리형 플랫폼입니다. 이 Codelab에서는 Cloud SQL Node.js 커넥터를 사용하여 Cloud Run의 Angular 애플리케이션을 PostgreSQL용 Cloud SQL 데이터베이스에 연결하는 방법을 보여줍니다.

이 실습에서는 다음 작업을 수행하는 방법을 배웁니다.

  • PostgreSQL용 Cloud SQL 인스턴스 만들기
  • Cloud SQL 데이터베이스에 연결하는 애플리케이션을 Cloud Run에 배포

2. 기본 요건

  1. 아직 Google 계정이 없다면 Google 계정을 만들어야 합니다.
    • 직장 또는 학교 계정 대신 개인 계정을 사용하세요. 직장 및 학교 계정에는 이 실습에 필요한 API를 사용 설정하지 못하도록 하는 제한이 있을 수 있습니다.

3. 프로젝트 설정

  1. Google Cloud 콘솔에 로그인합니다.
  2. Cloud 콘솔에서 결제를 사용 설정합니다.
    • 이 실습을 완료하는 데 드는 Cloud 리소스 비용은 미화 1달러 미만입니다.
    • 이 실습이 끝나면 단계에 따라 리소스를 삭제하여 추가 요금이 발생하지 않도록 할 수 있습니다.
    • 신규 사용자는 미화$300 상당의 무료 체험판을 사용할 수 있습니다.
  3. 새 프로젝트를 만들거나 기존 프로젝트를 재사용합니다.

4. Cloud Shell 편집기 열기

  1. Cloud Shell 편집기로 이동합니다.
  2. 터미널이 화면 하단에 표시되지 않으면 다음을 실행하여 엽니다.
    • 햄버거 메뉴 햄버거 메뉴 아이콘를 클릭합니다.
    • 터미널을 클릭합니다.
    • 새 터미널을 클릭합니다.Cloud Shell 편집기에서 새 터미널 열기
  3. 터미널에서 다음 명령어를 사용하여 프로젝트를 설정합니다.
    • 형식:
      gcloud config set project [PROJECT_ID]
      
    • 예:
      gcloud config set project lab-project-id-example
      
    • 프로젝트 ID를 기억할 수 없는 경우 다음 단계를 따르세요.
      • 다음 명령어를 사용하여 모든 프로젝트 ID를 나열할 수 있습니다.
        gcloud projects list | awk '/PROJECT_ID/{print $2}'
        
      Cloud Shell 편집기 터미널에서 프로젝트 ID 설정
  4. 승인하라는 메시지가 표시되면 승인을 클릭하여 계속합니다. 클릭하여 Cloud Shell 승인
  5. 다음 메시지가 표시되어야 합니다.
    Updated property [core/project].
    
    WARNING이 표시되고 Do you want to continue (Y/N)?라는 메시지가 표시되면 프로젝트 ID를 잘못 입력한 것일 수 있습니다. N를 누르고 Enter를 누른 후 gcloud config set project 명령어를 다시 실행해 보세요.

5. API 사용 설정

터미널에서 API를 사용 설정합니다.

gcloud services enable \
  sqladmin.googleapis.com \
  run.googleapis.com \
  artifactregistry.googleapis.com \
  cloudbuild.googleapis.com

승인하라는 메시지가 표시되면 승인을 클릭하여 계속합니다. 클릭하여 Cloud Shell 승인

이 명령어를 완료하는 데 몇 분이 걸릴 수 있지만 결국 다음과 비슷한 성공 메시지가 표시됩니다.

Operation "operations/acf.p2-73d90d00-47ee-447a-b600" finished successfully.

6. 서비스 계정 설정

Cloud SQL에 연결할 수 있는 올바른 권한이 있도록 Cloud Run에서 사용할 Google Cloud 서비스 계정을 만들고 구성합니다.

  1. 다음과 같이 gcloud iam service-accounts create 명령어를 실행하여 새 서비스 계정을 만듭니다.
    gcloud iam service-accounts create quickstart-service-account \
      --display-name="Quickstart Service Account"
    
  2. 다음과 같이 gcloud projects add-iam-policy-binding 명령어를 실행하여 방금 만든 Google Cloud 서비스 계정에 Cloud SQL 클라이언트 역할을 추가합니다.
    gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
      --member="serviceAccount:quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
      --role="roles/cloudsql.client"
    
  3. 다음과 같이 gcloud projects add-iam-policy-binding 명령어를 실행하여 방금 만든 Google Cloud 서비스 계정에 Cloud SQL 인스턴스 사용자 역할을 추가합니다.
    gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
      --member="serviceAccount:quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
      --role="roles/cloudsql.instanceUser"
    
  4. 다음과 같이 gcloud projects add-iam-policy-binding 명령어를 실행하여 방금 만든 Google Cloud 서비스 계정에 로그 작성자 역할을 추가합니다.
    gcloud projects add-iam-policy-binding ${GOOGLE_CLOUD_PROJECT} \
      --member="serviceAccount:quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
      --role="roles/logging.logWriter"
    

7. Cloud SQL 데이터베이스 만들기

  1. gcloud sql instances create 명령어를 실행하여 Cloud SQL 인스턴스를 만듭니다.
    gcloud sql instances create quickstart-instance \
        --database-version=POSTGRES_14 \
        --cpu=4 \
        --memory=16GB \
        --region=us-central1 \
        --database-flags=cloudsql.iam_authentication=on
    

이 명령어를 완료하는 데 몇 분이 걸릴 수 있습니다.

  1. gcloud sql databases create 명령어를 실행하여 quickstart-instance 내에 Cloud SQL 데이터베이스를 만듭니다.
    gcloud sql databases create quickstart_db \
        --instance=quickstart-instance
    
  2. 앞서 만든 서비스 계정으로 데이터베이스에 액세스할 수 있도록 PostgreSQL 데이터베이스 사용자를 만듭니다.
    gcloud sql users create quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam \
        --instance=quickstart-instance \
        --type=cloud_iam_service_account
    

8. 애플리케이션 준비

HTTP 요청에 응답하는 Next.js 애플리케이션을 준비합니다.

  1. task-app라는 새 Next.js 프로젝트를 만들려면 다음 명령어를 사용합니다.
    npx --yes @angular/cli@19.2.5 new task-app \
        --minimal \
        --inline-template \
        --inline-style \
        --ssr \
        --server-routing \
        --defaults
    
  2. 디렉터리를 task-app으로 변경합니다.
    cd task-app
    
  1. PostgreSQL 데이터베이스와 상호작용하려면 pg 및 Cloud SQL Node.js 커넥터 라이브러리를 설치합니다.
    npm install pg @google-cloud/cloud-sql-connector google-auth-library
    
  2. TypeScript Next.js 애플리케이션을 사용하려면 @types/pg를 개발 종속 항목으로 설치하세요.
    npm install --save-dev @types/pg
    
  1. Cloud Shell 편집기에서 server.ts 파일을 엽니다.
    cloudshell edit src/server.ts
    
    이제 화면 상단에 파일이 표시됩니다. 여기에서 이 server.ts 파일을 수정할 수 있습니다. 코드가 화면 상단 섹션에 표시됨
  2. server.ts 파일의 기존 콘텐츠를 삭제합니다.
  3. 다음 코드를 복사하여 열린 server.ts 파일에 붙여넣습니다.
    import {
      AngularNodeAppEngine,
      createNodeRequestHandler,
      isMainModule,
      writeResponseToNodeResponse,
    } from '@angular/ssr/node';
    import express from 'express';
    import { dirname, resolve } from 'node:path';
    import { fileURLToPath } from 'node:url';
    import pg from 'pg';
    import { AuthTypes, Connector } from '@google-cloud/cloud-sql-connector';
    import { GoogleAuth } from 'google-auth-library';
    const auth = new GoogleAuth();
    
    const { Pool } = pg;
    
    type Task = {
      id: string;
      title: string;
      status: 'IN_PROGRESS' | 'COMPLETE';
      createdAt: number;
    };
    
    const projectId = await auth.getProjectId();
    
    const connector = new Connector();
    const clientOpts = await connector.getOptions({
      instanceConnectionName: `${projectId}:us-central1:quickstart-instance`,
      authType: AuthTypes.IAM,
    });
    
    const pool = new Pool({
      ...clientOpts,
      user: `quickstart-service-account@${projectId}.iam`,
      database: 'quickstart_db',
    });
    
    const tableCreationIfDoesNotExist = async () => {
      await pool.query(`CREATE TABLE IF NOT EXISTS tasks (
          id SERIAL NOT NULL,
          created_at timestamp NOT NULL,
          status VARCHAR(255) NOT NULL default 'IN_PROGRESS',
          title VARCHAR(1024) NOT NULL,
          PRIMARY KEY (id)
        );`);
    }
    
    const serverDistFolder = dirname(fileURLToPath(import.meta.url));
    const browserDistFolder = resolve(serverDistFolder, '../browser');
    
    const app = express();
    const angularApp = new AngularNodeAppEngine();
    
    app.use(express.json());
    
    app.get('/api/tasks', async (req, res) => {
      await tableCreationIfDoesNotExist();
      const { rows } = await pool.query(`SELECT id, created_at, status, title FROM tasks ORDER BY created_at DESC LIMIT 100`);
      res.send(rows);
    });
    
    app.post('/api/tasks', async (req, res) => {
      const newTaskTitle = req.body.title;
      if (!newTaskTitle) {
        res.status(400).send("Title is required");
        return;
      }
      await tableCreationIfDoesNotExist();
      await pool.query(`INSERT INTO tasks(created_at, status, title) VALUES(NOW(), 'IN_PROGRESS', $1)`, [newTaskTitle]);
      res.sendStatus(200);
    });
    
    app.put('/api/tasks', async (req, res) => {
      const task: Task = req.body;
      if (!task || !task.id || !task.title || !task.status) {
        res.status(400).send("Invalid task data");
        return;
      }
      await tableCreationIfDoesNotExist();
      await pool.query(
        `UPDATE tasks SET status = $1, title = $2 WHERE id = $3`,
        [task.status, task.title, task.id]
      );
      res.sendStatus(200);
    });
    
    app.delete('/api/tasks', async (req, res) => {
      const task: Task = req.body;
      if (!task || !task.id) {
        res.status(400).send("Task ID is required");
        return;
      }
      await tableCreationIfDoesNotExist();
      await pool.query(`DELETE FROM tasks WHERE id = $1`, [task.id]);
      res.sendStatus(200);
    });
    
    /**
    * Serve static files from /browser
    */
    app.use(
      express.static(browserDistFolder, {
        maxAge: '1y',
        index: false,
        redirect: false,
      }),
    );
    
    /**
    * Handle all other requests by rendering the Angular application.
    */
    app.use('/**', (req, res, next) => {
      angularApp
        .handle(req)
        .then((response) =>
          response ? writeResponseToNodeResponse(response, res) : next(),
        )
        .catch(next);
    });
    
    /**
    * Start the server if this module is the main entry point.
    * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
    */
    if (isMainModule(import.meta.url)) {
      const port = process.env['PORT'] || 4000;
      app.listen(port, () => {
        console.log(`Node Express server listening on http://localhost:${port}`);
      });
    }
    
    /**
    * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
    */
    export const reqHandler = createNodeRequestHandler(app);
    
  1. Cloud Shell 편집기에서 app.component.ts 파일을 엽니다.
    cloudshell edit src/app/app.component.ts
    
    이제 화면 상단에 기존 파일이 표시됩니다. 여기에서 이 app.component.ts 파일을 수정할 수 있습니다. 코드가 화면 상단 섹션에 표시됨
  2. app.component.ts 파일의 기존 콘텐츠를 삭제합니다.
  3. 다음 코드를 복사하여 열린 app.component.ts 파일에 붙여넣습니다.
    import { afterNextRender, Component, signal } from '@angular/core';
    import { FormsModule } from '@angular/forms';
    
    type Task = {
      id: string;
      title: string;
      status: 'IN_PROGRESS' | 'COMPLETE';
      createdAt: number;
    };
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [FormsModule],
      template: `
        <section>
          <input
            type="text"
            placeholder="New Task Title"
            [(ngModel)]="newTaskTitle"
            class="text-black border-2 p-2 m-2 rounded"
          />
          <button (click)="addTask()">Add new task</button>
          <table>
            <tbody>
              @for (task of tasks(); track task) {
                @let isComplete = task.status === 'COMPLETE';
                <tr>
                  <td>
                    <input
                      (click)="updateTask(task, { status: isComplete ? 'IN_PROGRESS' : 'COMPLETE' })"
                      type="checkbox"
                      [checked]="isComplete"
                    />
                  </td>
                  <td>{{ task.title }}</td>
                  <td>{{ task.status }}</td>
                  <td>
                    <button (click)="deleteTask(task)">Delete</button>
                  </td>
                </tr>
              }
            </tbody>
          </table>
        </section>
      `,
      styles: '',
    })
    export class AppComponent {
      newTaskTitle = '';
      tasks = signal<Task[]>([]);
    
      constructor() {
        afterNextRender({
          earlyRead: () => this.getTasks()
        });
      }
    
      async getTasks() {
        const response = await fetch(`/api/tasks`);
        const tasks = await response.json();
        this.tasks.set(tasks);
      }
    
      async addTask() {
        await fetch(`/api/tasks`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            title: this.newTaskTitle,
            status: 'IN_PROGRESS',
            createdAt: Date.now(),
          }),
        });
        this.newTaskTitle = '';
        await this.getTasks();
      }
    
      async updateTask(task: Task, newTaskValues: Partial<Task>) {
        await fetch(`/api/tasks`, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ ...task, ...newTaskValues }),
        });
        await this.getTasks();
      }
    
      async deleteTask(task: any) {
        await fetch('/api/tasks', {
          method: 'DELETE',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(task),
        });
        await this.getTasks();
      }
    }
    

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

9. Cloud Run에 애플리케이션 배포

  1. 아래 명령어를 실행하여 애플리케이션을 Cloud Run에 배포합니다.
    gcloud run deploy to-do-tracker \
        --region=us-central1 \
        --source=. \
        --service-account="quickstart-service-account@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
        --allow-unauthenticated
    
  2. 메시지가 표시되면 YEnter를 눌러 계속 진행할지 확인합니다.
    Do you want to continue (Y/n)? Y
    

몇 분 후 애플리케이션에서 방문할 URL을 제공합니다.

URL로 이동하여 애플리케이션이 작동하는지 확인합니다. URL을 방문하거나 페이지를 새로고침할 때마다 작업 앱이 표시됩니다.

10. 축하합니다

이 실습에서는 다음 작업을 수행하는 방법을 배웠습니다.

  • PostgreSQL용 Cloud SQL 인스턴스 만들기
  • Cloud SQL 데이터베이스에 연결하는 애플리케이션을 Cloud Run에 배포

삭제

Cloud SQL에는 무료 등급이 없으며 계속 사용하면 요금이 청구됩니다. 추가 비용이 청구되지 않도록 Cloud 프로젝트를 삭제할 수 있습니다.

Cloud Run에서는 서비스를 사용하지 않을 때 비용이 청구되지 않지만 Artifact Registry에 컨테이너 이미지를 저장하는 데 요금이 부과될 수 있습니다. Cloud 프로젝트를 삭제하면 해당 프로젝트 내에서 사용되는 모든 리소스에 대한 청구가 중단됩니다.

원하는 경우 프로젝트를 삭제합니다.

gcloud projects delete $GOOGLE_CLOUD_PROJECT

cloudshell 디스크에서 불필요한 리소스를 삭제할 수도 있습니다. 다음과 같은 작업을 할 수 있습니다.

  1. Codelab 프로젝트 디렉터리를 삭제합니다.
    rm -rf ~/task-app
    
  2. 경고 이 작업은 실행취소할 수 없습니다. 공간을 확보하기 위해 Cloud Shell의 모든 항목을 삭제하려면 전체 홈 디렉터리를 삭제하면 됩니다. 보관하려는 모든 항목이 다른 곳에 저장되어 있는지 확인하세요.
    sudo rm -rf $HOME
    

계속 학습하기