Angular 및 Firebase를 사용한 웹 애플리케이션 빌드

1. 소개

최종 업데이트: 2020년 9월 11일

빌드할 항목

이 Codelab에서는 Angular와 Firebase를 사용하여 웹 간반 보드를 빌드해보겠습니다. 최종 앱에는 밀린 작업, 진행 중, 완료라는 작업 카테고리 3개가 만들어집니다. 작업을 만들고 삭제하며 드래그 앤 드롭을 사용하여 한 카테고리에서 다른 카테고리로 작업을 전송할 수 있게 됩니다.

Angular를 사용하여 사용자 인터페이스를 개발하고 Firestore를 영구 스토리지로 사용하겠습니다. Codelab을 마치면 Angular CLI를 사용하여 Firebase 호스팅에 앱을 배포하게 됩니다.

b23bd3732d0206b.png

학습할 내용

  • Angular Material과 CDK를 사용하는 방법
  • Angular 앱에 Firebase 통합을 추가하는 방법
  • Firestore에 영구 데이터를 유지하는 방법
  • 단일 명령어로 Angular CLI를 사용하여 Firebase 호스팅에 앱을 배포하는 방법

필요한 항목

이 Codelab에서는 개발자에게 Google 계정이 있고 Angular와 Angular CLI를 기본적으로 이해하고 있다고 가정합니다.

이제 시작해 볼까요?

2. 새 프로젝트 만들기

먼저 다음과 같이 새 Angular 작업공간을 만듭니다.

ng new kanban-fire
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS

몇 분 정도 걸릴 수 있습니다. Angular CLI는 프로젝트 구조를 만들고 모든 종속 항목을 설치합니다. 설치 프로세스가 완료되면 kanban-fire 디렉터리로 이동하여 Angular CLI의 개발 서버를 시작합니다.

ng serve

http://localhost:4200을 열면 다음과 같은 출력이 표시됩니다.

5ede7bc5b1109bf3.png

편집기에서 src/app/app.component.html을 열고 전체 내용을 삭제합니다. http://localhost:4200으로 다시 돌아가면 빈 페이지가 표시됩니다.

3. 머티리얼 및 CDK 추가

Angular는 머티리얼 디자인을 준수하는 사용자 인터페이스 구성요소의 구현과 함께 제공되며 @angular/material 패키지의 일부입니다. @angular/material의 종속 항목 중 하나는 구성요소 개발 키트(CDK)입니다. CDK는 a11y 유틸리티, 드래그 앤 드롭, 오버레이와 같은 프리미티브를 제공합니다. Google에서는 @angular/cdk 패키지로 CDK를 배포합니다.

앱에 머티리얼을 추가하려면 다음을 실행하세요.

ng add @angular/material

이 명령어는 개발자가 전역 머티리얼 서체 스타일을 사용하고 Angular Material의 브라우저 애니메이션을 설정하려는 경우 테마를 선택하라고 요청합니다. '인디고/핑크'를 선택하여 이 Codelab과 동일한 결과를 얻고 마지막 두 질문에 '예'라고 답변합니다.

ng add 명령어는 @angular/material 및 그 종속 항목을 설치하고 AppModule에서 BrowserAnimationsModule을 가져옵니다. 다음 단계에서는 이 모듈에서 제공하는 구성요소를 사용할 수 있습니다.

먼저 툴바와 아이콘을 AppComponent에 추가해보겠습니다. app.component.html을 열고 다음 마크업을 추가합니다.

src/app/app.component.html

<mat-toolbar color="primary">
  <mat-icon>local_fire_department</mat-icon>
  <span>Kanban Fire</span>
</mat-toolbar>

여기서는 머티리얼 디자인 테마의 기본 색상을 사용하는 툴바를 추가한 다음 그 안에 local_fire_depeartment 아이콘을 'Kanban Fire' 라벨 옆에 사용합니다. 지금 콘솔을 살펴보면 Angular에서 몇 가지 오류가 발생하는 것을 확인할 수 있습니다. 이 문제를 해결하려면 AppModule에 다음 가져오기를 추가해야 합니다.

src/app/app.module.ts

...
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatToolbarModule,
    MatIconModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Angular Material 툴바와 아이콘을 사용하므로 AppModule에서 상응하는 모듈을 가져와야 합니다.

이제 화면이 다음과 같이 표시됩니다.

a39cf8f8428a03bc.png

HTML 4줄과 가져오기 2개만 사용했는데도 나쁘지 않습니다.

4. 작업 시각화

다음 단계로 간반 보드에서 작업을 시각화하는 데 사용할 수 있는 구성요소를 만들어보겠습니다.

src/app 디렉터리로 이동하여 다음 CLI 명령어를 실행합니다.

ng generate component task

이 명령어는 TaskComponent를 생성하고 AppModule에 선언을 추가합니다. task 디렉터리 내부에 task.ts라는 파일을 만듭니다. 이 파일을 간반 보드에서 작업 인터페이스를 정의하는 데 사용해보겠습니다. 각 작업에는 모두 문자열 유형인 id, title, description 필드가 선택사항으로 있습니다.

src/app/task/task.ts

export interface Task {
  id?: string;
  title: string;
  description: string;
}

이제 task.component.ts를 업데이트하겠습니다. TaskComponentTask 유형의 객체를 입력으로 허용하고 'edit' 출력을 내보낼 수 있어야 합니다.

src/app/task/task.component.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from './task';

@Component({
  selector: 'app-task',
  templateUrl: './task.component.html',
  styleUrls: ['./task.component.css']
})
export class TaskComponent {
  @Input() task: Task | null = null;
  @Output() edit = new EventEmitter<Task>();
}

TaskComponent의 템플릿을 수정합니다. task.component.html을 열고 콘텐츠를 다음 HTML로 바꿉니다.

src/app/task/task.component.html

<mat-card class="item" *ngIf="task" (dblclick)="edit.emit(task)">
  <h2>{{ task.title }}</h2>
  <p>
    {{ task.description }}
  </p>
</mat-card>

이제 콘솔에 오류가 표시됩니다.

'mat-card' is not a known element:
1. If 'mat-card' is an Angular component, then verify that it is part of this module.
2. If 'mat-card' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.ng

위 템플릿에서는 @angular/materialmat-card 구성요소를 사용하고 있지만 앱에서 이에 상응하는 모듈을 가져오지 않았습니다. 위와 같은 오류를 해결하려면 AppModule에서 MatCardModule을 가져와야 합니다.

src/app/app.module.ts

...
import { MatCardModule } from '@angular/material/card';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatCardModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

이제 AppComponent에서 몇 가지 작업을 만들고 TaskComponent를 사용하여 이를 시각화합니다.

AppComponent에서 todo라는 배열을 정의하고 그 안에 작업 2개를 추가합니다.

src/app/app.component.ts

...
import { Task } from './task/task';

@Component(...)
export class AppComponent {
  todo: Task[] = [
    {
      title: 'Buy milk',
      description: 'Go to the store and buy milk'
    },
    {
      title: 'Create a Kanban app',
      description: 'Using Firebase and Angular create a Kanban app!'
    }
  ];
}

이제 app.component.html 하단에 다음 *ngFor 지시어를 추가합니다.

src/app/app.component.html

<app-task *ngFor="let task of todo" [task]="task"></app-task>

브라우저를 열면 다음과 같이 표시됩니다.

d96fccd13c63ceb1.png

5. 작업 드래그 앤 드롭 구현

이제 재미있는 부분을 할 차례입니다. 작업이 배치될 수 있는 각기 다른 상태의 구획 3개를 만들고 Angular CDK를 사용하여 드래그 앤 드롭 기능을 구현합니다.

app.component.html에서 상단에 *ngFor 지시어가 있는 app-task 구성요소를 삭제하고 다음으로 바꿉니다.

src/app/app.component.html

<div class="container-wrapper">
  <div class="container">
    <h2>Backlog</h2>

    <mat-card
      cdkDropList
      id="todo"
      #todoList="cdkDropList"
      [cdkDropListData]="todo"
      [cdkDropListConnectedTo]="[doneList, inProgressList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
      <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>

  <div class="container">
    <h2>In progress</h2>

    <mat-card
      cdkDropList
      id="inProgress"
      #inProgressList="cdkDropList"
      [cdkDropListData]="inProgress"
      [cdkDropListConnectedTo]="[todoList, doneList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="inProgress.length === 0">Empty list</p>
      <app-task (edit)="editTask('inProgress', $event)" *ngFor="let task of inProgress" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>

  <div class="container">
    <h2>Done</h2>

    <mat-card
      cdkDropList
      id="done"
      #doneList="cdkDropList"
      [cdkDropListData]="done"
      [cdkDropListConnectedTo]="[todoList, inProgressList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="done.length === 0">Empty list</p>
      <app-task (edit)="editTask('done', $event)" *ngFor="let task of done" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>
</div>

많은 작업이 진행되고 있습니다. 이 스니펫의 개별 부분을 단계별로 살펴보겠습니다. 다음은 템플릿의 최상위 구조입니다.

src/app/app.component.html

...
<div class="container-wrapper">
  <div class="container">
    <h2>Backlog</h2>
    ...
  </div>

  <div class="container">
    <h2>In progress</h2>
    ...
  </div>

  <div class="container">
    <h2>Done</h2>
    ...
  </div>
</div>

여기에서는 세 구획을 모두 래핑하는 div를 만듭니다. 클래스 이름은 'container-wrapper'입니다. 각 구획에는 클래스 이름 'container'와 h2 태그 내부의 제목이 있습니다.

이제 첫 번째 구획의 구조를 살펴보겠습니다.

src/app/app.component.html

...
    <div class="container">
      <h2>Backlog</h2>

      <mat-card
        cdkDropList
        id="todo"
        #todoList="cdkDropList"
        [cdkDropListData]="todo"
        [cdkDropListConnectedTo]="[doneList, inProgressList]"
        (cdkDropListDropped)="drop($event)"
        class="list"
      >
        <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
        <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
      </mat-card>
    </div>
...

먼저 cdkDropList 지시어를 사용하는 mat-card로 구획을 정의합니다. mat-card를 사용하는 이유는 이 구성요소가 제공하는 스타일 때문입니다. 나중에 cdkDropList를 통해 요소 내부에 작업을 드롭할 수 있습니다. 다음 두 가지 입력도 설정합니다.

  • cdkDropListData: 데이터 배열을 지정할 수 있는 드롭다운 목록의 입력입니다.
  • cdkDropListConnectedTo: 현재 cdkDropList가 연결된 다른 cdkDropList 참조입니다. 이 입력을 설정하면 항목을 드롭할 수 있는 다른 목록을 지정합니다.

또한 cdkDropListDropped 출력을 사용하여 드롭 이벤트를 처리해야 합니다. cdkDropList에서 이 출력을 내보내면 AppComponent 내에서 선언된 drop 메서드를 호출하고 현재 이벤트를 인수로 전달합니다.

이 컨테이너의 식별자로 사용할 id를 지정하고 스타일을 설정할 class 이름도 지정합니다. 이제 mat-card의 하위 콘텐츠를 살펴보겠습니다. 다음과 같은 두 가지 요소가 있습니다.

  • 단락: todo 목록에 항목이 없을 때 '빈 목록' 텍스트를 표시하는 데 사용합니다.
  • app-task 구성요소: 여기서는 목록 이름과 $event 객체를 사용하여 editTask 메서드를 호출해 처음에 선언한 edit 출력을 처리합니다. 이렇게 하면 올바른 목록에서 수정된 작업을 바꾸는 데 도움이 됩니다. 그런 다음 위와 마찬가지로 todo 목록 작업을 반복하고 task 입력을 전달합니다. 하지만 이번에는 cdkDrag 지시어도 추가합니다. 이렇게 하면 개별 작업이 드래그 가능해집니다.

이 모든 작업을 실행하려면 app.module.ts를 업데이트하고 DragDropModule의 가져오기를 포함해야 합니다.

src/app/app.module.ts

...
import { DragDropModule } from '@angular/cdk/drag-drop';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    DragDropModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

또한 inProgressdone 배열과 editTaskdrop 메서드도 선언해야 합니다.

src/app/app.component.ts

...
import { CdkDragDrop, transferArrayItem } from '@angular/cdk/drag-drop';

@Component(...)
export class AppComponent {
  todo: Task[] = [...];
  inProgress: Task[] = [];
  done: Task[] = [];

  editTask(list: string, task: Task): void {}

  drop(event: CdkDragDrop<Task[]|null>): void {
    if (event.previousContainer === event.container) {
      return;
    }
    if (!event.container.data || !event.previousContainer.data) {
      return;
    }
    transferArrayItem(
      event.previousContainer.data,
      event.container.data,
      event.previousIndex,
      event.currentIndex
    );
  }
}

drop 메서드에서 먼저 작업이 발생한 것과 동일한 목록에 드롭하는지 확인합니다. 제대로 되고 있다면 즉시 돌아갑니다. 동일한 목록에 드롭하지 않으면 현재 작업을 대상 구획으로 전송합니다.

결과가 다음과 같이 표시됩니다.

460f86bcd10454cf.png

이제 두 목록 간에 항목을 전송할 수 있습니다.

6. 새 작업 만들기

이제 새 작업을 생성하는 기능을 구현해보겠습니다. 이를 위해 AppComponent 템플릿을 업데이트합니다.

src/app/app.component.html

<mat-toolbar color="primary">
...
</mat-toolbar>

<div class="content-wrapper">
  <button (click)="newTask()" mat-button>
    <mat-icon>add</mat-icon> Add Task
  </button>

  <div class="container-wrapper">
    <div class="container">
      ...
    </div>
</div>

container-wrapper 주위에 최상위 div 요소를 만들고 '작업 추가' 라벨 옆에 'add' 머티리얼 아이콘이 있는 버튼을 추가합니다. 구획 목록 위에 버튼을 배치하려면 추가 래퍼가 필요한데, 이는 나중에 Flexbox를 사용하여 나란히 배치하겠습니다. 이 버튼은 머티리얼 버튼 구성요소를 사용하므로 AppModule에서 상응하는 모듈을 가져와야 합니다.

src/app/app.module.ts

...
import { MatButtonModule } from '@angular/material/button';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatButtonModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

이제 AppComponent에서 작업을 추가하는 기능을 구현해보겠습니다. 머티리얼 대화상자를 사용합니다. 대화상자에는 제목 필드와 설명 필드가 포함된 양식이 있습니다. 사용자가 '작업 추가' 버튼을 클릭하면 대화상자가 열리고 사용자가 양식을 제출하면 새로 생성된 작업이 todo 목록에 추가됩니다.

AppComponent에서 이 기능의 대략적인 구현을 살펴보겠습니다.

src/app/app.component.ts

...
import { MatDialog } from '@angular/material/dialog';

@Component(...)
export class AppComponent {
  ...

  constructor(private dialog: MatDialog) {}

  newTask(): void {
    const dialogRef = this.dialog.open(TaskDialogComponent, {
      width: '270px',
      data: {
        task: {},
      },
    });
    dialogRef
      .afterClosed()
      .subscribe((result: TaskDialogResult|undefined) => {
        if (!result) {
          return;
        }
        this.todo.push(result.task);
      });
  }
}

MatDialog 클래스를 삽입하는 생성자를 선언합니다. newTask 내에서 다음 작업을 실행합니다.

  • 잠시 후 정의할 TaskDialogComponent를 사용하여 새 대화상자를 엽니다.
  • 대화상자의 너비가 270px.이 되도록 지정합니다.
  • 대화상자에 빈 작업을 데이터로 전달합니다. TaskDialogComponent에서 이 데이터 객체 참조를 가져올 수 있습니다.
  • 닫기 이벤트를 구독하고 result 객체의 작업을 todo 배열에 추가합니다.

제대로 작동하도록 하려면 먼저 AppModule에서 MatDialogModule을 가져와야 합니다.

src/app/app.module.ts

...
import { MatDialogModule } from '@angular/material/dialog';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatDialogModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

이제 TaskDialogComponent를 만들어보겠습니다. src/app 디렉터리로 이동하여 다음을 실행합니다.

ng generate component task-dialog

기능을 구현하려면 먼저 src/app/task-dialog/task-dialog.component.html을 열고 콘텐츠를 다음으로 바꿉니다.

src/app/task-dialog/task-dialog.component.html

<mat-form-field>
  <mat-label>Title</mat-label>
  <input matInput cdkFocusInitial [(ngModel)]="data.task.title" />
</mat-form-field>

<mat-form-field>
  <mat-label>Description</mat-label>
  <textarea matInput [(ngModel)]="data.task.description"></textarea>
</mat-form-field>

<div mat-dialog-actions>
  <button mat-button [mat-dialog-close]="{ task: data.task }">OK</button>
  <button mat-button (click)="cancel()">Cancel</button>
</div>

위 템플릿에서 title 필드와 description 필드가 포함된 양식을 만듭니다. 사용자가 대화상자를 열 때 cdkFocusInput 지시어를 사용하여 자동으로 title 입력에 포커스를 둡니다.

템플릿 내에서 구성요소의 data 속성을 어떻게 참조하는지 확인합니다. 이는 AppComponent에서 dialogopen 메서드에 전달하는 data와 동일합니다. 사용자가 해당 필드의 콘텐츠를 변경할 때 작업의 제목과 설명을 업데이트하기 위해 ngModel과 양방향 데이터 결합을 사용합니다.

사용자가 확인 버튼을 클릭하면 결과 { task: data.task }가 자동으로 반환되며 이는 위 템플릿의 양식 필드를 사용하여 변경한 작업입니다.

이제 구성요소의 컨트롤러를 구현해보겠습니다.

src/app/task-dialog/task-dialog.component.ts

import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Task } from '../task/task';

@Component({
  selector: 'app-task-dialog',
  templateUrl: './task-dialog.component.html',
  styleUrls: ['./task-dialog.component.css'],
})
export class TaskDialogComponent {
  private backupTask: Partial<Task> = { ...this.data.task };

  constructor(
    public dialogRef: MatDialogRef<TaskDialogComponent>,
    @Inject(MAT_DIALOG_DATA) public data: TaskDialogData
  ) {}

  cancel(): void {
    this.data.task.title = this.backupTask.title;
    this.data.task.description = this.backupTask.description;
    this.dialogRef.close(this.data);
  }
}

TaskDialogComponent에서 대화상자 참조를 삽입하므로 대화상자를 닫아도 되며 MAT_DIALOG_DATA 토큰과 연결된 제공자의 값도 삽입합니다. 이는 위 AppComponent에서 open 메서드에 전달한 데이터 객체입니다. 데이터 객체와 함께 전달한 작업의 사본인 비공개 속성 backupTask도 선언합니다.

사용자가 취소 버튼을 눌렀을 때 this.data.task 속성이 변경되었다면 이를 복원하고 대화상자를 닫아 this.data를 결과로 전달합니다.

참조했으나 아직 선언하지 않은 두 가지 유형이 있습니다. TaskDialogDataTaskDialogResult입니다. src/app/task-dialog/task-dialog.component.ts 내부에서 파일 하단에 다음 선언을 추가합니다.

src/app/task-dialog/task-dialog.component.ts

...
export interface TaskDialogData {
  task: Partial<Task>;
  enableDelete: boolean;
}

export interface TaskDialogResult {
  task: Task;
  delete?: boolean;
}

기능을 준비하기 전에 마지막으로 해야 할 작업은 AppModule에서 몇 가지 모듈을 가져오는 것입니다.

src/app/app.module.ts

...
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatInputModule,
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

이제 '작업 추가' 버튼을 클릭하면 다음과 같은 사용자 인터페이스가 표시됩니다.

33bcb987fade2a87.png

7. 앱 스타일 개선

애플리케이션을 시각적으로 멋지게 만들기 위해 스타일을 약간 조정하여 레이아웃을 개선합니다. 구획을 서로 나란히 배치하려고 합니다. '작업 추가' 버튼과 빈 목록 라벨도 약간 조정합니다.

src/app/app.component.css를 열고 하단에 다음 스타일을 추가합니다.

src/app/app.component.css

mat-toolbar {
  margin-bottom: 20px;
}

mat-toolbar > span {
  margin-left: 10px;
}

.content-wrapper {
  max-width: 1400px;
  margin: auto;
}

.container-wrapper {
  display: flex;
  justify-content: space-around;
}

.container {
  width: 400px;
  margin: 0 25px 25px 0;
}

.list {
  border: solid 1px #ccc;
  min-height: 60px;
  border-radius: 4px;
}

app-new-task {
  margin-bottom: 30px;
}

.empty-label {
  font-size: 2em;
  padding-top: 10px;
  text-align: center;
  opacity: 0.2;
}

위 스니펫에서는 툴바와 라벨의 레이아웃을 조정합니다. 또한 너비를 1400px로, 여백을 auto로 설정하여 콘텐츠가 가로로 정렬되도록 합니다. 그런 다음 flexbox를 사용하여 구획을 나란히 배치하고, 마지막으로 작업 및 빈 목록을 시각화하는 방법을 조정합니다.

앱이 새로고침되면 다음과 같은 사용자 인터페이스가 표시됩니다.

69225f0b1aa5cb50.png

앱 스타일을 크게 개선했지만 작업을 이동할 때 여전히 성가신 문제가 있습니다.

f9aae712027624af.png

'우유 구매' 작업을 드래그하기 시작하면 같은 작업에 카드가 두 개 표시됩니다. 하나는 현재 드래그하는 카드고 다른 하나는 구획에 있는 카드입니다. Angular CDK는 이 문제를 해결하는 데 사용할 수 있는 CSS 클래스 이름을 제공합니다.

src/app/app.component.css 하단에 다음 스타일 재정의를 추가합니다.

src/app/app.component.css

.cdk-drag-animating {
  transition: transform 250ms;
}

.cdk-drag-placeholder {
  opacity: 0;
}

요소를 드래그하는 동안 Angular CDK의 드래그 앤 드롭은 요소를 클론하여 원본을 드롭할 위치에 삽입합니다. 이 요소가 표시되지 않도록 하기 위해 CDK가 자리표시자에 추가할 cdk-drag-placeholder 클래스에 불투명도 속성을 설정합니다.

추가로, 요소를 드롭하면 CDK에서 cdk-drag-animating 클래스를 추가합니다. 요소를 직접 스냅하는 대신 부드러운 애니메이션을 표시하려면 재생 시간을 250ms로 설정하여 전환을 정의합니다.

작업 스타일도 약간 조정하려고 합니다. task.component.css에서 호스트 요소의 디스플레이를 block으로 지정하고 여백을 설정해보겠습니다.

src/app/task/task.component.css

:host {
  display: block;
}

.item {
  margin-bottom: 10px;
  cursor: pointer;
}

8. 기존 작업 수정 및 삭제

기존 작업을 수정하고 삭제하기 위해 이미 구현한 기능 대부분을 재사용하겠습니다. 사용자가 작업을 더블클릭하면 TaskDialogComponent가 열리고 양식의 두 필드가 작업의 titledescription으로 채워집니다.

TaskDialogComponent에 삭제 버튼도 추가하겠습니다. 사용자가 이 버튼을 클릭하면 삭제 명령이 전달되고 이 명령은 AppComponent에서 끝납니다.

TaskDialogComponent에서 변경해야 하는 유일한 사항은 템플릿에 있습니다.

src/app/task-dialog/task-dialog.component.html

<mat-form-field>
 ...
</mat-form-field>

<div mat-dialog-actions>
  ...
  <button
    *ngIf="data.enableDelete"
    mat-fab
    color="primary"
    aria-label="Delete"
    [mat-dialog-close]="{ task: data.task, delete: true }">
    <mat-icon>delete</mat-icon>
  </button>
</div>

이 버튼에는 삭제 머티리얼 아이콘이 표시됩니다. 사용자가 클릭할 때 대화상자를 닫고 객체 리터럴 { task: data.task, delete: true }를 결과로 전달해보겠습니다. 또한 mat-fab를 사용하여 버튼을 원형으로 만들고 색상은 기본으로 설정하여 대화상자 데이터에 삭제 기능이 사용 설정된 경우에만 버튼을 표시하겠습니다.

수정 및 삭제 기능의 나머지 구현은 AppComponent에 있습니다. editTask 메서드를 다음으로 바꿉니다.

src/app/app.component.ts

@Component({ ... })
export class AppComponent {
  ...
  editTask(list: 'done' | 'todo' | 'inProgress', task: Task): void {
    const dialogRef = this.dialog.open(TaskDialogComponent, {
      width: '270px',
      data: {
        task,
        enableDelete: true,
      },
    });
    dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
      if (!result) {
        return;
      }
      const dataList = this[list];
      const taskIndex = dataList.indexOf(task);
      if (result.delete) {
        dataList.splice(taskIndex, 1);
      } else {
        dataList[taskIndex] = task;
      }
    });
  }
  ...
}

editTask 메서드의 인수를 살펴보겠습니다.

  • 개별 구획과 연결된 속성에 상응하는 값을 포함하는 문자열 리터럴 합집합 유형인 'done' | 'todo' | 'inProgress', 유형 목록
  • 수정하려는 현재 작업

먼저 메서드 본문에서 TaskDialogComponent의 인스턴스를 엽니다. 수정할 작업을 지정하는 객체 리터럴을 data로 전달하고 enableDelete 속성을 true로 설정하여 양식에서 수정 버튼도 사용 설정합니다.

대화상자에서 결과를 받으면 다음 두 가지 시나리오를 처리합니다.

  • delete 플래그가 true로 설정된 경우(즉, 사용자가 삭제 버튼을 누른 경우) 상응하는 목록에서 작업이 삭제됩니다.
  • 또는 주어진 색인의 작업을 대화상자 결과에서 얻은 작업으로 대체합니다.

9. 새 Firebase 프로젝트 만들기

이제 새 Firebase 프로젝트를 만들어보겠습니다.

  • Firebase Console로 이동합니다.
  • 'KanbanFire'라는 이름으로 새 프로젝트를 만듭니다.

10. 프로젝트에 Firebase 추가

이 섹션에서는 프로젝트를 Firebase와 통합합니다. Firebase팀은 두 기술 간의 통합을 제공하는 @angular/fire 패키지를 제공합니다. 앱에 Firebase 지원을 추가하려면 작업공간의 루트 디렉터리를 열고 다음을 실행합니다.

ng add @angular/fire

이 명령어를 실행하면 @angular/fire 패키지가 설치되고 몇 가지 질문이 표시됩니다. 터미널에 다음과 같이 표시됩니다.

9ba88c0d52d18d0.png

그동안 Firebase 계정으로 인증할 수 있도록 설치 시 브라우저 창이 열립니다. 마지막으로 Firebase 프로젝트를 선택하라는 메시지가 표시되고 디스크에 파일이 생성됩니다.

이제 Firestore 데이터베이스를 만들어야 합니다. 'Cloud Firestore'에서 '데이터베이스 만들기'를 클릭합니다.

1e4a08b5a2462956.png

그런 다음 테스트 모드에서 데이터베이스를 만듭니다.

ac1181b2c32049f9.png

마지막으로 리전을 선택합니다.

34bb94cc542a0597.png

이제 Firebase 구성을 환경에 추가하기만 하면 됩니다. Firebase Console에서 프로젝트 구성을 찾을 수 있습니다.

  • 프로젝트 개요 옆에 있는 톱니바퀴 아이콘을 클릭합니다.
  • 프로젝트 설정을 선택합니다.

c8253a20031de8a9.png

'내 앱'에서 '웹 앱'을 선택합니다.

428a1abcd0f90b23.png

이제 애플리케이션을 등록하고 'Firebase 호스팅'을 사용 설정합니다.

586e44cb27dd8f39.png

'앱 등록'을 클릭하면 구성을 src/environments/environment.ts에 복사할 수 있습니다.

e30f142d79cecf8f.png

최종적으로 구성 파일은 다음과 같이 표시됩니다.

src/environments/environment.ts

export const environment = {
  production: false,
  firebase: {
    apiKey: '<your-key>',
    authDomain: '<your-project-authdomain>',
    databaseURL: '<your-database-URL>',
    projectId: '<your-project-id>',
    storageBucket: '<your-storage-bucket>',
    messagingSenderId: '<your-messaging-sender-id>'
  }
};

11. Firestore로 데이터 이동

Firebase SDK를 설정했으므로 이제 @angular/fire를 사용하여 데이터를 Firestore로 이동합니다. 먼저 AppModule에서 필요한 모듈을 가져옵니다.

src/app/app.module.ts

...
import { environment } from 'src/environments/environment';
import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';

@NgModule({
  declarations: [AppComponent, TaskDialogComponent, TaskComponent],
  imports: [
    ...
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Firestore를 사용할 것이므로 AppComponent의 생성자에 AngularFirestore를 삽입해야 합니다.

src/app/app.component.ts

...
import { AngularFirestore } from '@angular/fire/firestore';

@Component({...})
export class AppComponent {
  ...
  constructor(private dialog: MatDialog, private store: AngularFirestore) {}
  ...
}

그런 다음 구획 배열을 초기화하는 방법을 업데이트합니다.

src/app/app.component.ts

...

@Component({...})
export class AppComponent {
  todo = this.store.collection('todo').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  inProgress = this.store.collection('inProgress').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  done = this.store.collection('done').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  ...
}

여기서는 AngularFirestore를 사용하여 컬렉션의 콘텐츠를 데이터베이스에서 직접 가져옵니다. valueChanges는 배열 대신 Observable을 반환하고 이 컬렉션의 문서 ID 필드는 Task 인터페이스에서 사용하는 이름과 일치하도록 id로 호출되어야 한다고 지정합니다. valueChanges가 반환하는 Observable은 변경될 때마다 작업 컬렉션을 내보냅니다.

배열 대신 Observable을 사용하고 있으므로 작업을 추가, 삭제, 수정하는 방법과 구획 간에 작업을 이동하는 기능을 업데이트해야 합니다. 메모리 내 배열을 변경하는 대신 Firebase SDK를 사용하여 데이터베이스의 데이터를 업데이트합니다.

먼저 재정렬이 어떻게 표시되는지 살펴보겠습니다. src/app/app.component.tsdrop 메서드를 다음으로 바꿉니다.

src/app/app.component.ts

drop(event: CdkDragDrop<Task[]>): void {
  if (event.previousContainer === event.container) {
    return;
  }
  const item = event.previousContainer.data[event.previousIndex];
  this.store.firestore.runTransaction(() => {
    const promise = Promise.all([
      this.store.collection(event.previousContainer.id).doc(item.id).delete(),
      this.store.collection(event.container.id).add(item),
    ]);
    return promise;
  });
  transferArrayItem(
    event.previousContainer.data,
    event.container.data,
    event.previousIndex,
    event.currentIndex
  );
}

위 스니펫에서는 새 코드가 강조표시되어 있습니다. 현재 구획에서 대상 구획으로 작업을 이동하려면 첫 번째 컬렉션에서 작업을 삭제하고 두 번째 컬렉션에 추가합니다. 하나처럼 보이도록 하려는 작업 2개를 실행하므로(즉, 작업을 원자성으로 만듦) Firestore 트랜잭션에서 실행합니다.

이제 Firestore를 사용하도록 editTask 메서드를 업데이트합니다. 닫기 대화상자 핸들러 내에서 다음 코드 줄을 변경해야 합니다.

src/app/app.component.ts

...
dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
  if (!result) {
    return;
  }
  if (result.delete) {
    this.store.collection(list).doc(task.id).delete();
  } else {
    this.store.collection(list).doc(task.id).update(task);
  }
});
...

Firestore SDK를 사용하여 조작하는 작업에 상응하는 대상 문서에 액세스하여 삭제 또는 업데이트합니다.

마지막으로 새 작업을 만드는 메서드를 업데이트해야 합니다. this.todo.push('task')this.store.collection('todo').add(result.task)로 바꿉니다.

이제 컬렉션이 배열이 아니라 Observable입니다. 이를 시각화하려면 AppComponent 템플릿을 업데이트해야 합니다. todo, inProgress, done 속성의 모든 액세스를 각각 todo | async, inProgress | async, done | async로 교체하면 됩니다.

비동기 파이프는 컬렉션과 연결된 Observable을 자동으로 구독합니다. Observable에서 새 값을 내보내면 Angular는 변경 감지를 자동으로 실행하여 내보낸 배열을 처리합니다.

예를 들어 todo 구획에서 변경해야 하는 사항을 살펴보겠습니다.

src/app/app.component.html

<mat-card
  cdkDropList
  id="todo"
  #todoList="cdkDropList"
  [cdkDropListData]="todo | async"
  [cdkDropListConnectedTo]="[doneList, inProgressList]"
  (cdkDropListDropped)="drop($event)"
  class="list">
  <p class="empty-label" *ngIf="(todo | async)?.length === 0">Empty list</p>
  <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo | async" cdkDrag [task]="task"></app-task>
</mat-card>

데이터를 cdkDropList 지시어에 전달하면 비동기 파이프가 적용됩니다. *ngIf 지시어 내부와 동일하지만 todo | asyncnull 또는 undefined가 아닌 경우 런타임 오류가 발생하지 않도록 length 속성에 액세스할 때 체이닝(Angular에서는 안전한 탐색 연산자라고도 함)도 선택적으로 사용합니다.

이제 사용자 인터페이스에서 새 작업을 만들고 Firestore를 열면 다음과 같이 표시됩니다.

dd7ee20c0a10ebe2.png

12. 낙관적 업데이트 개선

애플리케이션에서 현재 낙관적 업데이트를 실행하고 있습니다. Firestore에 정보 소스가 있지만 동시에 작업의 로컬 사본이 있습니다. 컬렉션과 연결된 Observable을 내보내면 작업 배열이 반환됩니다. 사용자 작업이 상태를 변경하면 먼저 로컬 값을 업데이트하고 변경사항을 Firestore에 전파합니다.

한 구획에서 다른 구획으로 작업을 이동하면 각 구획의 작업을 나타내는 배열의 로컬 인스턴스에서 작동하는 transferArrayItem,이 호출됩니다. Firebase SDK는 이러한 배열을 변경 불가능으로 처리합니다. 즉, 다음에 Angular가 변경 감지를 실행할 때 새 인스턴스를 가져와 작업을 전송하기 전에 이전 상태를 렌더링합니다.

동시에 Firestore 업데이트가 트리거되고 Firebase SDK는 올바른 값으로 업데이트를 트리거하므로 사용자 인터페이스가 몇 밀리초 후에 올바른 상태가 됩니다. 따라서 방금 전송한 작업이 첫 번째 목록에서 다음 목록으로 이동합니다. 아래 GIF에서 확인할 수 있습니다.

70b946eebfa6f316.gif

이 문제를 해결하는 올바른 방법은 애플리케이션마다 다르지만 모든 경우에 데이터가 업데이트될 때까지 일관된 상태가 유지되도록 해야 합니다.

valueChanges에서 수신하는 원래 관찰자를 래핑하는 BehaviorSubject를 활용할 수 있습니다. 내부적으로 BehaviorSubjecttransferArrayItem의 업데이트를 유지하는 변경 가능한 배열을 유지합니다.

수정사항을 구현하려면 AppComponent를 업데이트하기만 하면 됩니다.

src/app/app.component.ts

...
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { BehaviorSubject } from 'rxjs';

const getObservable = (collection: AngularFirestoreCollection<Task>) => {
  const subject = new BehaviorSubject<Task[]>([]);
  collection.valueChanges({ idField: 'id' }).subscribe((val: Task[]) => {
    subject.next(val);
  });
  return subject;
};

@Component(...)
export class AppComponent {
  todo = getObservable(this.store.collection('todo')) as Observable<Task[]>;
  inProgress = getObservable(this.store.collection('inProgress')) as Observable<Task[]>;
  done = getObservable(this.store.collection('done')) as Observable<Task[]>;
...
}

위 스니펫에서는 컬렉션과 연결된 Observable이 발생될 때마다 값을 내보내는 BehaviorSubject를 만들기만 합니다.

모두 예상대로 작동합니다. BehaviorSubject가 변경 감지 호출에서 배열을 재사용하고 Firestore에서 새 값을 가져올 때만 업데이트되기 때문입니다.

13. 애플리케이션 배포

앱을 배포하려면 다음을 실행하기만 하면 됩니다.

ng deploy

이 명령어는 다음 작업을 실행합니다.

  1. 컴파일 시간 최적화를 적용하여 프로덕션 구성으로 앱을 빌드합니다.
  2. Firebase 호스팅에 앱을 배포합니다.
  3. 결과를 미리 볼 수 있도록 URL을 출력합니다.

14. 마무리 단계

수고하셨습니다. Angular와 Firebase를 사용하여 간반 보드를 빌드했습니다.

다양한 작업의 상태를 나타내는 열이 3개 있는 사용자 인터페이스를 만들었습니다. Angular CDK를 사용하여 열 간에 작업의 드래그 앤 드롭을 구현했습니다. 그런 다음, Angular Material을 사용하여 새 작업을 만들고 기존 작업을 수정하는 양식을 만들었습니다. @angular/fire 사용 방법을 알아보고 모든 애플리케이션 상태를 Firestore로 옮겼습니다. 마지막으로 애플리케이션을 Firebase 호스팅에 배포했습니다.

다음 단계

테스트 구성을 사용하여 애플리케이션을 배포했습니다. 앱을 프로덕션으로 배포하기 전에 올바른 권한을 설정했는지 확인하세요. 여기에서 방법을 알아볼 수 있습니다.

현재 Google에서는 특정 구획에서 개별 작업의 순서를 유지하지 않습니다. 이를 구현하려면 작업 문서의 순서 필드를 사용하고 이 필드를 기반으로 하여 정렬하면 됩니다.

또한 단일 사용자 전용 간반 보드를 만들었으며, 이는 앱을 여는 사용자에게 제공되는 단일 간반 보드가 생겼다는 뜻입니다. 앱의 여러 사용자를 위한 별도의 보드를 구현하려면 데이터베이스 구조를 변경해야 합니다. 여기에서 Firestore의 권장사항을 알아보세요.