이 연재글은 Spring Rest api + Angular framework로 웹사이트 만들기의 5번째 글입니다.

이번장에서는 지금까지 개발한 내용에 대해서 개선사항을 적용해 보겠습니다. 첫번째로 현재 사용중인 시스템 alert 및 confirm 다이얼로그를 Material Dialog를 이용하여 커스텀 모달 다이얼로그로 변경해 보겠습니다. 두번째로는 resolve를 이용하여 ajax로 페이지 전환이 이뤄질때의 컨텐츠 깜박임 문제를 해결해보겠습니다.

Material Dialog 모듈 추가

Material Dialog를 이용해야 하므로 기존에 추가하지 않았다면 다음과 같이 material.module.ts에 추가합니다.

// material.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
  // 생략
  MatDialogModule
} from '@angular/material';

@NgModule({
  imports: [
    // 생략
    MatDialogModule
  ],
  exports: [
    // 생략
    MatDialogModule
  ],
  declarations: []
})
export class MaterialModule { }

Alert 다이얼로그 컴포넌트 생성

$ cd src/app/component/   
$ mkdir common
$ cd common
$ ng g c alert-dialog --spec false
CREATE src/app/component/common/alert-dialog/alert-dialog.component.css
CREATE src/app/component/common/alert-dialog/alert-dialog.component.html
CREATE src/app/component/common/alert-dialog/alert-dialog.component.ts

Html 작성

mat-dialog 태그를 이용하여 Alert 화면을 구성합니다.

// alert-dialog.component.html
<h2 mat-dialog-title>{{title}}</h2>
<mat-dialog-content [formGroup]="form">
  <h4>{{description}}</h4>
</mat-dialog-content>
<mat-dialog-actions align="end">
    <button class="mat-raised-button mat-primary" [mat-dialog-close]="true">확인</button>
</mat-dialog-actions>

컴포넌트 작성

다이얼로그를 호출하는 쪽에서 데이터를 세팅해서 보내면 constructor에서 @Inject(MAT_DIALOG_DATA)로 데이터를 읽을수 있습니다. constructor에서 읽은 정보는 다이얼로그 화면이 그려지면(ngOnInit) 제목과 내용이 세팅되어 화면에 출력됩니다.

// alert-dialog.component.ts
import { Component, OnInit, Inject } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material';

@Component({
  selector: 'app-alert-dialog',
  templateUrl: './alert-dialog.component.html',
  styleUrls: ['./alert-dialog.component.css']
})
export class AlertDialogComponent implements OnInit {

  form: FormGroup;
  title: string;
  description: string;

  constructor(
      private fb: FormBuilder,
      private dialogRef: MatDialogRef<AlertDialogComponent>,
      @Inject(MAT_DIALOG_DATA) data) {
      this.title = data.title;
      this.description = data.description;
  }

  ngOnInit() {
      this.form = this.fb.group({
        title: [this.title, []],
        description: [this.description, []]
      });
  }
}

APP 모듈에 등록

Alert은 시스템 전역에서 사용되므로 최초에 로드되어 있어야 합니다. 따라서 app.module.ts의 entryComponents 항목에 AlertDialogComponent를 설정합니다.

// import 생략
import { AlertDialogComponent } from './component/common/alert-dialog/alert-dialog.component';

@NgModule({
  declarations: [
    // 생략
  ],
  imports: [
    // 생략
  ],
  providers: [
    // 생략
  ],
  entryComponents: [
    AlertDialogComponent
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

다이얼로그 서비스 생성

커스텀 다이얼로그를 여러 컴포넌트에서 재사용 할 수 있도록 서비스를 생성합니다.

$ cd src/app/service   
$ mkdir dialog
$ cd dialog 
$ ng g s dialog --spec false
CREATE src/app/service/dialog/dialog.service.ts

제목과 내용을 인자로 받는 alert 메서드를 생성합니다. dialog.open을 통해 AlertDialogComponent를 화면에 출력합니다. 가로 사이즈(width)와 modal여부(diableClose) 그리고 전달할 데이터를 세팅합니다. 메서드 결과로 Dialog Reference를 리턴하여 확인 버튼을 눌렀을때의 이벤트를 처리할수 있도록 합니다.

// dialog.service.ts
import { MatDialog, MatDialogRef } from '@angular/material';
import { Injectable } from '@angular/core';
import { AlertDialogComponent } from 'src/app/component/common/alert-dialog/alert-dialog.component';

@Injectable({
  providedIn: 'root'
})
export class DialogService {

  constructor(private dialog: MatDialog) { }

  alert(title:string, desc: string): any {
    const dialogRef = this.dialog.open(AlertDialogComponent, {
      width: '300px',
      disableClose: true,
      data: { title: title, description: desc }
    });
    return dialogRef;
  }
}

로그인 완료시 Alert 메시지 보여주기

submit()에서 로그인 완료시 dialogSevice를 호출하여 커스텀 Alert을 띄우도록 아래와 같이 작성합니다. 확인 버튼 클릭에 대한 이벤트를 수신받으면 redirectTo나 홈페이지로 이동하는 로직을 수행하도록 처리합니다.

// signin.component.ts
import { DialogService } from './../../../service/dialog/dialog.service';
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { SignService } from 'src/app/service/rest-api/sign.service';
import { Router, ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-signin',
  templateUrl: './signin.component.html',
  styleUrls: ['./signin.component.css']
})
export class SigninComponent implements OnInit {

  redirectTo: string;
  signInForm: FormGroup;

  constructor(private signService: SignService,
    private router: Router,
    private route: ActivatedRoute,
    private dialogService: DialogService) {
    this.signInForm = new FormGroup({
      id: new FormControl('', [Validators.required, Validators.email]),
      password: new FormControl('', [Validators.required])
    });
  }

  // 생략

  submit() {
    if (this.signInForm.valid) {
      this.signService.signIn(this.signInForm.value.id, this.signInForm.value.password)
        .then(data => {
          this.dialogService.alert('안내', '로그인이 완료되었습니다.').afterClosed().subscribe(result => {
            if (result) {
              this.router.navigate([this.redirectTo ? this.redirectTo : '/']);
            }
          });
        });
    }
  }
}

커스텀 Alert 다이얼로그 테스트

로그인이 완료되면 메시지가 커스텀 Alert으로 보여지는것을 확인할 수 있습니다. 확인을 누르면 홈페이지로 이동합니다.

API 오류시 얼럿 교체

다이얼로그 서비스를 만들었으므로 다른 서비스나 컴포넌트에서도 쉽게 커스텀 alert을 적용할 수 있습니다. 로그인, 가입과 관련된 signSevice의 alert을 변경해 보겠습니다. constructor에 dialogService를 선언하고 alert -> this.dialogService.alert으로 변경하면 작업은 끝납니다.

import { DialogService } from './../dialog/dialog.service';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ApiReponseSingle } from 'src/app/model/common/ApiReponseSingle';
import { ApiValidationService } from './common/api-validation.service';

@Injectable({
  providedIn: 'root'
})
export class SignService {

  private signInUrl = '/api/v1/signin';
  private signUpUrl = '/api/v1/signup';

  constructor(private http: HttpClient,
    private apiValidationService: ApiValidationService,
    private dialogService: DialogService) { }

  // 로그인 api 연동
  signIn(id: string, password: string): Promise<any> {
    const params = new FormData();
    params.append('id', id);
    params.append('password', password);
    return this.http.post<ApiReponseSingle>(this.signInUrl, params)
      .toPromise()
      .then(this.apiValidationService.validateResponse)
      .then(response => {
        localStorage.setItem('x-auth-token', response.data);
      })
      .catch(response => {
        // alert('[로그인 실패]\n' + response.error.msg);
        this.dialogService.alert('로그인 실패', response.error.msg);   
        return Promise.reject(response.error.msg);
      });
  }

  // 가입 api 연동
  signUp(id: string, password: string, name: string): Promise<any> {
    const params = new FormData();
    params.append('id', id);
    params.append('password', password);
    params.append('name', name);
    return this.http.post<ApiReponseSingle>(this.signUpUrl, params)
      .toPromise()
      .then(this.apiValidationService.validateResponse)
      .then(response => {
        return true;
      })
      .catch(response => {
        // alert('[회원 가입중 오류 발생]\n' + response.error.msg);  
        this.dialogService.alert('회원 가입중 오류 발생', response.error.msg); 
        return Promise.reject(response.error.msg);
      });
  }

  // 생략
}

브라우저에서 로그인이 실패하면 다음과 같은 화면을 볼수 있습니다.

Confirm 다이얼로그 컴포넌트 생성

이번엔 Confirm 다이얼로그를 만들어 보겠습니다. Confirm은 Alert과 달리 취소가 가능합니다.

$ cd src/app/component/common 
$ ng g c confirm-dialog --spec false
CREATE src/app/component/common/confirm-dialog/confirm-dialog.component.css
CREATE src/app/component/common/confirm-dialog/confirm-dialog.component.html
CREATE src/app/component/common/confirm-dialog/confirm-dialog.component.ts

Html 작성

취소 버튼을 빼면 alert html과 내용은 거의 비슷합니다.

// confirm-dialog.component.html
<h2 mat-dialog-title>{{title}}</h2>
<mat-dialog-content [formGroup]="form">
  <h4>{{description}}</h4>
</mat-dialog-content>
<mat-dialog-actions>
    <button class="mat-raised-button" (click)="close()">취소</button>
    <button class="mat-raised-button mat-primary" [mat-dialog-close]="true">확인</button>
</mat-dialog-actions>

컴포넌트 작성

취소버튼을 처리할 close() 메서드를 제외하면 alert Component와 내용은 거의 비슷합니다.

import { Component, OnInit, Inject } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material';

@Component({
  selector: 'app-confirm-dialog',
  templateUrl: './confirm-dialog.component.html',
  styleUrls: ['./confirm-dialog.component.css']
})
export class ConfirmDialogComponent implements OnInit {

  form: FormGroup;
  title: string;
  description: string;

  constructor(private fb: FormBuilder,
    private dialogRef: MatDialogRef<ConfirmDialogComponent>,
    @Inject(MAT_DIALOG_DATA) data)
    { 
      this.title = data.title;
      this.description = data.description; 
    }

  ngOnInit() {
    this.form = this.fb.group({
      title: [this.title, []],
      description: [this.description, []]
    })
  }

  close() {
    this.dialogRef.close();
  }
}

APP 모듈에 등록

Confirm도 Alert과 마찬가지로 시스템 전역에서 사용되므로 최초에 로드되어 있어야 합니다. 따라서 app.module.ts의 entryComponents 항목에 ConfirmDialogComponent를 추가합니다.

// app.module.ts
// import 생략
import { AlertDialogComponent } from './component/common/alert-dialog/alert-dialog.component';
import { ConfirmDialogComponent } from './component/common/confirm-dialog/confirm-dialog.component';

@NgModule({
  declarations: [
    // 생략
  ],
  imports: [
    // 생략
  ],
  providers: [
    // 생략
  ],
  entryComponents: [
    AlertDialogComponent,
    ConfirmDialogComponent
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

다이얼로그 서비스에 Confirm 추가

여러곳에서 사용할수 있도록 dialogService에 아래와 같이 confirm 메서드를 작성합니다.

// dialog.service.ts
import { MatDialog, MatDialogRef } from '@angular/material';
import { Injectable } from '@angular/core';
import { AlertDialogComponent } from 'src/app/component/common/alert-dialog/alert-dialog.component';
import { ConfirmDialogComponent } from 'src/app/component/common/confirm-dialog/confirm-dialog.component';

@Injectable({
  providedIn: 'root'
})
export class DialogService {

  constructor(private dialog: MatDialog) { }

  // 생략

  confirm(title:string, desc: string): any {
    const dialogRef = this.dialog.open(ConfirmDialogComponent, {
      width: '300px',
      disableClose: true,
      data: { title: title, description: desc }
    });
    return dialogRef;
  }
}

게시글 삭제시 커스텀 Confirm 다이얼로그 적용

Confirm은 확인의 용도가 있으므로 게시글 삭제시 묻는 용도로 사용할 수 있습니다. delete() 메서드에서 사용하는 시스템 Confirm을 커스텀 Confirm으로 변경합니다.

import { DialogService } from './../../service/dialog/dialog.service';
import { MyinfoService } from 'src/app/service/rest-api/myinfo.service';
import { SignService } from 'src/app/service/rest-api/sign.service';
import { BoardService } from './../../service/rest-api/board.service';
import { Component, OnInit } from '@angular/core';
import { Post } from 'src/app/model/board/Post';
import { ActivatedRoute, Router } from '@angular/router';
import { User } from 'src/app/model/myinfo/User';

@Component({
  selector: 'app-board',
  templateUrl: './board.component.html',
  styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {

  posts: Post[] = [];
  displayedColumns: string[] = ['postId', 'title', 'author', 'createdAt', 'modifiedAt'];
  boardName: string;
  loginUser: User;

  constructor(private boardService: BoardService,
    private route: ActivatedRoute,
    private signService: SignService,
    private myinfoService: MyinfoService,
    private router: Router,
    private dialogService: DialogService) {
      this.boardName = this.route.snapshot.params['boardName'];
    }

  ngOnInit() {
    this.boardService.getPosts(this.boardName).then(response => {
      this.posts = response; 
    });

    if (this.signService.isSignIn()) {
      this.myinfoService.getUser()
      .then(user => {
        this.loginUser = user;
      });
    }
  }

  delete(postId: number) {
    // if(confirm('정말 삭제하시겠습니까?')) {
    //   this.boardService.deletePost(postId).then(response => {
    //     window.location.reload();
    //   });
    // }

    this.dialogService.confirm('삭제 요청 확인', '정말로 삭제하시겠습니까?').afterClosed().subscribe(result => {
      if (result) {
        this.boardService.deletePost(postId).then(response => {
          window.location.reload();
        });
      }
    });
  }
}

커스텀 Confirm 다이얼로그 테스트

게시글 리스트에서 휴지통 모양을 클릭하면 다음과 같이 커스텀 Confirm 다이얼로그가 나타납니다. 취소를 누르면 변경사항없이 다이얼로그가 닫히고 확인을 누르면 삭제 요청이 실행됩니다.

Angular Resolve의 적용

Angular는 SPA(Single Page Application)로 동작하는 프레임워크 입니다. 그래서 페이지간 이동시 페이지 전체가 다시 로드되지 않고 변경될 부분만 교체되는 방식으로 동작합니다. 페이지가 교체될때 내용부분의 로드는 ajax로 이루어집니다. 따라서 페이지에서 페이지로 이동시 유심히 살펴보면 UI가 그려진후 데이터가 주르륵 채워지는 것을 볼수 있습니다. 의식하지 않으면 알수없지만, 의식하고 보면 미세한 깜박임이 발생하는것은 이때문입니다.

Angular에서는 이런점을 보완하기 위해 resolve라는 것을 제공합니다. resolve를 이용하면 페이지가 로딩되기 전에 데이터를 먼저 로딩합니다. 즉 순서가 기존에는 화면UI출력 -> 데이터로드 였다면 resolve를 이용하면 데이터로드 -> 화면UI출력 으로 변경되어 깜박임이 없어지게 됩니다.

다만 이렇게 할경우 데이터의 로딩이 길어지면 흰 화면이 오래 지속될수 있으므로 로딩 UI가 들어가야 하는 이슈가 있을수 있습니다. Resolve에 대한 자세한 내용은 아래 공식사이트에서 확인 할 수 있습니다.

https://angular.io/api/router/Resolve

게시판 리스트에 Resolve 적용

게시판에 사용할 Resolve이므로 게시판 컴포넌트 하위에 Resolve를 생성합니다.

$ cd src/app/component/board 
$ mkdir resolve
$ cd resolve 
$ touch board-resolve.ts

Resolve의 내용은 생각외로 간단합니다. 주소의 Path Variable로부터 boardName을 받아 게시글 리스트를 반환 하는 resolve 메서드를 구현하면 됩니다.

import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Post } from 'src/app/model/board/Post';
import { BoardService } from 'src/app/service/rest-api/board.service';
import { Observable } from 'rxjs';
import { Injectable } from '@angular/core';

@Injectable()
export class BoardResolve implements Resolve<Post[]> {

    constructor(private boardService: BoardService) {}

    resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Post[] | Observable<Post[]> | Promise<Post[]> {
        return this.boardService.getPosts(route.params['boardName']);
    }
}

APP 모듈에 등록

Resolve를 사용하기 위해서 APP 모듈의 providers에 등록을 합니다.

// app.module.ts
// import 생략
import { BoardResolve } from './component/board/resolve/board-resolve';

@NgModule({
  declarations: [
    // 생략
  ],
  imports: [
    // 생략
  ],
  providers: [
    // 생략
    BoardResolve
  ],
  entryComponents: [
    // 생략
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

라우팅에 Resolve 적용

라우팅에 다음과 같이 Resolve를 적용합니다.
{path: ‘board/:boardName’, component: BoardComponent, resolve: {posts: BoardResolve}}
posts: BoardResolve로 선언하였는데 BoardComponent에 posts란 이름으로 데이터가 전달되므로 ActivatedRoute에서 해당 키값으로 게시글을 받을수 있습니다.

import { BoardResolve } from './component/board/resolve/board-resolve';
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './component/home.component';
import { SigninComponent } from './component/member/signin/signin.component';
import { SignupComponent } from './component/member/signup/signup.component';
import { LogoutComponent } from './component/logout/logout.component';
import { MyinfoComponent } from './component/member/myinfo/myinfo.component';
import { AuthGuard } from './guards/auth.guard';
import { BoardComponent } from './component/board/board.component';
import { PostComponent } from './component/board/post.component';
import { PostViewComponent } from './component/board/post-view.component';
import { PostModifyComponent } from './component/board/post-modify.component';

const routes: Routes = [
  {path: '', component: HomeComponent},
  {path: 'signin', component: SigninComponent},
  {path: 'signup', component: SignupComponent},
  {path: 'logout', component: LogoutComponent},
  {path: 'myinfo', component: MyinfoComponent, canActivate: [AuthGuard]},
  {path: 'board/:boardName', component: BoardComponent, resolve: {posts: BoardResolve}},
  {path: 'board/:boardName/post', component: PostComponent, canActivate: [AuthGuard]},
  {path: 'board/:boardName/post/:postId', component: PostViewComponent},
  {path: 'board/:boardName/post/:postId/modify', component: PostModifyComponent, canActivate: [AuthGuard]},
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Board 컴포넌트 수정

BoardComponent의 ngOnInit() 내용을 수정합니다. 라우터에서 resolve된 데이터가 posts란 이름으로 전달되므로 아래와 같이 데이터를 조회합니다.

this.posts = this.route.snapshot.data[‘posts’];

import { DialogService } from './../../service/dialog/dialog.service';
import { MyinfoService } from 'src/app/service/rest-api/myinfo.service';
import { SignService } from 'src/app/service/rest-api/sign.service';
import { BoardService } from './../../service/rest-api/board.service';
import { Component, OnInit } from '@angular/core';
import { Post } from 'src/app/model/board/Post';
import { ActivatedRoute, Router } from '@angular/router';
import { User } from 'src/app/model/myinfo/User';

@Component({
  selector: 'app-board',
  templateUrl: './board.component.html',
  styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {

  posts: Post[] = [];
  displayedColumns: string[] = ['postId', 'title', 'author', 'createdAt', 'modifiedAt'];
  boardName: string;
  loginUser: User;

  constructor(private boardService: BoardService,
    private route: ActivatedRoute,
    private signService: SignService,
    private myinfoService: MyinfoService,
    private router: Router,
    private dialogService: DialogService) {
      this.boardName = this.route.snapshot.params['boardName'];
    }

  ngOnInit() {
    // this.boardService.getPosts(this.boardName).then(response => {
    //   this.posts = response; 
    // });
    this.posts = this.route.snapshot.data['posts'];

    if (this.signService.isSignIn()) {
      this.myinfoService.getUser()
      .then(user => {
        this.loginUser = user;
      });
    }
  }

  // 생략
}

게시판 리스트 로딩 테스트

이제 게시판에 글을 여러개 등록하고 내정보와 게시판을 왔다갔다 하면서 클릭해봅니다. Resolve 적용 전에는 게시판에 게시글이 표시될때 순간적으로 로드되는것이 보였지만 Resolve를 적용한 뒤에는 깜박임이 사라진것을 확인할 수 있습니다.

Page Not Found(404) 페이지 만들기

이번에는 웹 사이트 접근시 해당 path에 리소스가 없을경우 404 not found 처리를 위한 컴포넌트를 만드는 방법에 대해 살펴보겠습니다.

404 에러 컴포넌트 생성

$ cd src/app/component/common 
$ mkdir error
$ cd error         
$ ng g c error404 --flat --spec false 
CREATE src/app/component/common/error/error404.component.css
CREATE src/app/component/common/error/error404.component.html
CREATE src/app/component/common/error/error404.component.ts 

Html 작성

<div class="wrapper">
    <mat-card class="error-card">
        <mat-card-header>
            <mat-card-title>요청하신 페이지를 찾을 수 없습니다.</mat-card-title>
        </mat-card-header>
        <mat-card-content>
            <p>
                페이지의 주소가 잘못 입력되었거나,<br>
                찾으시려는 페이지의 주소가 삭제 또는 변경되어 사용할 수 없는 상태입니다.<br>
                입력하신 주소가 정확한지 한 번 더 확인해 주시기 바랍니다.<br>
                <br>
                감사합니다.<br>
            </p>
        </mat-card-content>
    </mat-card>
</div>

스타일 작성

/* 박스 스타일 지정 - 여백, 가운데 정렬 */
.wrapper {
    display: flex;
    justify-content: center;
    margin: 150px 0px;
  }

  /* card 영역의 최고 가로 사이즈 지정 */
.error-card {
    max-width: 420px;
}

라우팅에 설정 추가

라우팅 설정에 아래와 같이 추가합니다. 요청이 들어오면 위에서부터 path를 찾고 매칭되는 path가 없으면 맨 아래의 Error404Component가 적용됩니다.

// import 생략
import { Error404Component } from './component/common/error/error404.component';

const routes: Routes = [
  {path: '', component: HomeComponent},
  {path: 'signin', component: SigninComponent},
  // 생략
  {path: '**', component: Error404Component}
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

테스트

존재하지 않는 페이지를 요청하면 아래와 같은 화면을 확인할 수 있습니다.

실습에 사용한 소스는 아래 Github에서 확인 가능합니다.

https://github.com/codej99/angular-website.git – feature/improvement 브랜치

Spring Boot2로 백엔드 서버(RestAPI) 구축하기 시리즈

연재글 이동[이전글] Spring Rest api + Angular framework로 웹사이트 만들기(4) – 게시판(CRUD)
[다음글] Spring Rest api + Angular framework로 웹사이트 만들기(6) – 개선사항 적용 (loading spinner)