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

이번장에서는 Angular로 간단한 게시판을 만들어봄으로써 CRUD(Create/Read/Update/Delete) 실습을 해보겠습니다. 게시판의 기능은 총 4가지로 구성되며 각각 게시글 조회, 게시글 쓰기, 게시글 수정, 게시글 삭제로 이루어지며 하나씩 구현해보겠습니다.

게시글 리스트 조회

게시글 조회는 메뉴의 게시판을 눌렀을때 보여지는 화면입니다. 읽기 기능에 해당하며 인증이 필요없이 누구나 볼수 있는 페이지로 구성됩니다.

게시글 리스트 Component 생성

$ cd src/app/component 
$ ng g c board --spec false
CREATE src/app/component/board/board.component.css (0 bytes)
CREATE src/app/component/board/board.component.html (20 bytes)
CREATE src/app/component/board/board.component.ts

게시글 Model 생성

api 결과 데이터와 맵핑시킬 모델을 생성합니다.

$ cd src/app/model 
$ mkdir board
$ cd board
$ touch Post.ts
import { User } from '../myinfo/User';

export interface Post {
  postId: number;
  title: string;
  author: string;
  content: string;
  createdAt: Date;
  modifiedAt: Date;
  user: User;
}

게시글 리스트 Html 작성

게시글 리스트를 표현하기 위해 Material Table을 사용합니다. Material table에 관한 자세한 정보는 아래 링크에서 확인할 수 있습니다.

https://material.angular.io/components/table/overview

Material Table은 데이터를 표현하는 방식이 좀 특이한데 다음과 같습니다.

  • 리스트 정보를 루프를 돌면서 추가하지 않고 dataSource에 주입을 합니다.
  • 리스트 컬럼마다 매핑되는 데이터의 id를 지정합니다. : matColumnDef=”매핑ID정보”
  • 리스트에 보여지는 데이터의 순서를 매핑ID 기준으로 세팅합니다.
    <mat-header-row *matHeaderRowDef=”displayedColumns”></mat-header-row>
    <mat-row *matRowDef=”let row; columns: displayedColumns;”></mat-row>
// board.component.html
<div class="wrapper">
<mat-card class="card">
    <mat-card-title fxLayout.gt-xs="row" fxLayout.xs="column">
        <div>
    <H3>자유 게시판</H3>
        <button mat-raised-button color="primary">
        <mat-icon>add</mat-icon> Add Post
        </button>
    </div>
    </mat-card-title>
    <mat-card-content>
    <div class="container">
        <mat-table [dataSource]="posts">
        <ng-container matColumnDef="postId">
            <mat-header-cell *matHeaderCellDef>no.</mat-header-cell>
            <mat-cell *matCellDef="let element" class="column-center">{{element.postId}}</mat-cell>
        </ng-container>
        <ng-container matColumnDef="title">
            <mat-header-cell *matHeaderCellDef>제목</mat-header-cell>
            <mat-cell *matCellDef="let element">{{element.title}}</mat-cell>
        </ng-container>
        <ng-container matColumnDef="author">
            <mat-header-cell *matHeaderCellDef>작성자</mat-header-cell>
            <mat-cell *matCellDef="let element" class="column-center">{{element.author}}</mat-cell>
        </ng-container>
        <ng-container matColumnDef="createdAt">
            <mat-header-cell *matHeaderCellDef>작성일</mat-header-cell>
            <mat-cell *matCellDef="let element" class="column-center">{{element.createdAt | date: 'yyyy-MM-dd HH:mm'}}</mat-cell>
        </ng-container>
        <ng-container matColumnDef="modifiedAt">
            <mat-header-cell *matHeaderCellDef>수정</mat-header-cell>
            <mat-cell *matCellDef="let element" class="column-center">
                <mat-icon class="icon">edit</mat-icon>
                <mat-icon class="icon">delete_forever</mat-icon>
            </mat-cell>
        </ng-container>
        <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
        <mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
        </mat-table>
        <div *ngIf="posts.length === 0" class="no-content">
            등록된 게시글이 없습니다.
        </div>
    </div>
    </mat-card-content>
</mat-card>
</div>

게시글 리스트 스타일 작성

// board.component.css
/* 전체 영역을 반응형 으로 설정 */
.wrapper {
    display: flex;
    flex-direction: column;
    overflow: auto;
}

/* 앵커 태그 마우스 위치시 포인터 변경 */
a {
    cursor: pointer;
}

/* 테이블 헤더의 컨텐츠 중앙정렬 */
mat-header-cell {
    justify-content: center;
}

/* 테이블 컬럼의 컨텐츠 중앙정렬 */
.column-center {
    justify-content: center;
}

/* 게시글 없을경우 텍스트 중앙정렬. 상단 영역 여백 처리 */
.no-content {
    text-align: center;
    margin: 10px 0;
}

/* 셀 스타일 지정. 마우스오버시 커서 형태 지정 */
mat-row:hover {
    background: lightgray;
    color: #fff;
    cursor: pointer;
}

게시글 Component 수정

게시글 리스트 확인을 위해 posts 변수에 더미데이터를 세팅합니다. 해당 정보는 추후 api 연동으로 대체됩니다.

// board.component.ts
import { Component, OnInit } from '@angular/core';
import { Post } from 'src/app/model/board/Post';

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

  posts: Post[] = [
    {'postId':3, 'title': '슈퍼맨이 요리를 한다.', 'author': '슈퍼맨', 'content': '슈퍼맨이 최고다', 'createdAt': new Date(), 'modifiedAt': new Date(), 'user': null},
    {'postId':2, 'title': '배트맨이 요리를 한다.', 'author': '배트맨', 'content': '배트맨이 최고다', 'createdAt': new Date(), 'modifiedAt': new Date(), 'user': null},
    {'postId':1, 'title': '아이언맨이 요리를 한다.', 'author': '아이언맨', 'content': '아이언맨이 최고다', 'createdAt': new Date(), 'modifiedAt': new Date(), 'user': null}
  ];
  headerColumns: string[] = ['postId', 'title', 'author', 'createdAt', 'modifiedAt'];

  constructor() { }

  ngOnInit() {
  }
}

라우팅 추가

여러개의 board가 존재할수 있으므로 Path Variable로 boardName을 설정합니다.

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

const routes: Routes = [
  // path 생략
  {path: 'board/:boardName', component: BoardComponent}
];

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

더미데이터 테스트

localhost:4200/board/free로 접속합니다. 주소의 free는 변경될수 있는 값입니다. 더미데이터 세팅에 의해 다음과 같은 화면을 볼수 있습니다.

API 연동

서비스 생성

$ cd src/app/service/rest-api
$ ng g s board --flat --spec false
CREATE src/app/service/rest-api/board.service.ts

리스트 형태이므로 api결과를 ApiReponseList로 매핑 시킵니다.

// board.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ApiValidationService } from './common/api-validation.service';
import { Post } from 'src/app/model/board/Post';
import { ApiReponseList } from 'src/app/model/common/ApiReponseList';

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

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

  private getBoardUrl = '/api/v1/board';

  getPosts(boardName: string): Promise<Post[]> {
    const getPostsUrl = this.getBoardUrl + '/' + boardName + '/posts';
    return this.http.get<ApiReponseList>(getPostsUrl)
      .toPromise()
      .then(this.apiValidationService.validateResponse)
      .then(response => {
        return response.list as Post[];
      })
      .catch(response => {
        alert('[게시판 조회 중 오류 발생]\n' + response.error.msg);   
        return Promise.reject(response.error.msg);
      });
  }
}

모듈에 서비스 추가

app.module.ts의 providers에 BoardService를 추가합니다.

// app.module.ts
// import 생략
import { BoardService } from './service/rest-api/board.service';

providers: [
  // 생략
  BoardService
]

컴포넌트와 api 연동

더미 데이터를 내리던 부분을 삭제하고 boardService를 이용하여 데이터를 불러오도록 수정합니다. 이때 게시판 주소로부터 게시판이름(boardName)이 Path Variable로 넘어오게 되는데 ActivatedRoute를 통해 해당 값을 얻어낼 수 있습니다.

// board.component.ts
import { BoardService } from './../../service/rest-api/board.service';
import { Component, OnInit } from '@angular/core';
import { Post } from 'src/app/model/board/Post';
import { ActivatedRoute } from '@angular/router';

@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;

  constructor(private boardService: BoardService,
    private route: ActivatedRoute) { 
      this.boardName = this.route.snapshot.params['boardName'];
    }

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

메뉴에 게시판 링크 등록

app.component.html에 게시판 링크를 등록합니다. [routerLink]=”[‘/board/free’]”

// app.component.html
<div class="wrapper">
  <mat-sidenav-container>
      <mat-sidenav  #sidenav role="navigation">
        <mat-nav-list>
            <a mat-list-item [routerLink]="['/signin']" routerLinkActive="router-link-active" *ngIf="!signService.isSignIn()">
              <mat-icon class="icon">input</mat-icon>
              <span class="label">로그인</span>
            </a>
            <a mat-list-item>
              <mat-icon class="icon">home</mat-icon>
                <span class="label">홈</span>
            </a>
            <a mat-list-item *ngIf="signService.isSignIn()" [routerLink]="['/myinfo']">
              <mat-icon class="icon">person</mat-icon>
                <span class="label">내정보</span>
            </a>
            <a mat-list-item [routerLink]="['/board/free']">
              <mat-icon class="icon">dashboard</mat-icon>
              <span class="label">게시판</span>
            </a>
            <a mat-list-item *ngIf="signService.isSignIn()" href="/logout">
              <mat-icon class="icon">input</mat-icon>
              <span class="label">로그아웃</span>
            </a>
        </mat-nav-list>
      </mat-sidenav>
      <mat-sidenav-content>
        <mat-toolbar color="primary">
         <div fxHide.gt-xs>
           <button mat-icon-button (click)="sidenav.toggle()">
            <mat-icon>menu</mat-icon>
          </button>
        </div>
         <div>
          <a routerLink="/">
            <mat-icon class="icon">home</mat-icon>
            <span class="label">홈</span>
          </a>
         </div>
         <div fxFlex fxLayout fxLayoutAlign="flex-end" fxHide.xs>
            <ul fxLayout fxLayoutGap="20px" class="navigation-items">
                <li *ngIf="!signService.isSignIn()">
                  <a [routerLink]="['/signin']">
                    <mat-icon class="icon">input</mat-icon>
                    <span  class="label">로그인</span>
                    </a>
                </li>
                <li *ngIf="signService.isSignIn()">
                  <a [routerLink]="['/myinfo']">
                    <mat-icon class="icon">person</mat-icon>
                    <span class="label">내정보</span>
                  </a>
                </li>
                <li>
                  <a [routerLink]="['/board/free']">
                    <mat-icon class="icon">dashboard</mat-icon>
                    <span class="label">게시판</span>
                  </a>
                </li>
                <li *ngIf="signService.isSignIn()">
                  <a href="/logout">
                    <mat-icon class="icon">input</mat-icon>
                    <span class="label">로그아웃</span>
                  </a>
                </li>
            </ul>
         </div>
        </mat-toolbar>
        <main>
          <router-outlet></router-outlet>
        </main>
      </mat-sidenav-content>
    </mat-sidenav-container>
    <footer>
      <div>
        <span><a href="http://www.daddyprogrammer.org">HappyDaddy's Angular Website</a></span>
      </div>
      <div>
        <span>Powered by HappyDaddy ©2018~2019. </span>
        <a href="http://www.daddyprogrammer.org">Code licensed under an MIT-style License.</a>
      </div>
    </footer>
</div>

게시판 생성

컴포넌트에서 조회하는 게시판이 free인데 미등록된 게시판이므로 api 서버의 swagger를 통해 등록합니다. Rest API서버가 실행중인 상태에서 http://localhost:8080/swagger-ui.html 에 접근합니다. 게시판 생성은 인증이 필요한 api이므로 로그인하여 token을 얻은후 게시판 등록 api를 호출합니다.

로그인
게시판 등록

게시판 테스트

서버를 실행하고 브라우저에서 localhost:4200에 접근하여 게시판을 클릭하면 다음과 같은 화면을 확인할 수 있습니다. 아직 등록된 게시글이 하나도 없어 빈 리스트가 출력됩니다.

게시글 작성

게시글 리스트에서 Add Post를 클릭시 나타나는 게시글 작성 페이지를 만들어보겠습니다. 다음과 같이 게시글 작성 컴포넌트를 생성합니다.

$ cd src/app/component/board 
$ ng g c post --flat --spec false 
CREATE src/app/component/board/post.component.css
CREATE src/app/component/board/post.component.html
CREATE src/app/component/board/post.component.ts

게시글 작성 html

validation 적용을 위해 폼에 formGroup, formControlName을 설정합니다. *ngIf로 상황별 에러메시지를 추가합니다. 필드가 유효하지 않은경우 등록 버튼을 비활성화합니다.

// post.component.html
<div class="wrapper">
    <mat-card>
        <mat-card-title>글쓰기</mat-card-title>
        <mat-card-content>
          <form [formGroup]="postForm" (ngSubmit)="submit()">
            <p>
              <mat-form-field>
                <input type="text" matInput placeholder="제목" formControlName="title">
              </mat-form-field>
            </p>
            <p>
              <mat-form-field>
                <textarea matInput placeholder="내용" formControlName="content" rows="5"></textarea>
              </mat-form-field>
            </p>
            <p *ngIf="f.title.touched && f.title.invalid && f.title.errors.required" class="error">
                Title is required
            </p>
            <p *ngIf="f.content.touched && f.content.invalid && f.content.errors.required" class="error">
                Contents is required
            </p>
            <div class="button">
              <button type="submit" mat-stroked-button color="primary" [disabled]="!postForm.valid">등록</button>
            </div>
          </form>
        </mat-card-content>
      </mat-card>
</div>

게시글 작성 화면 스타일 적용

// post.component.css
/* 페이지 전체 요소에 적용. 박스 사이즈를 테두리를 포함한 크기로 지정 */
* {
    box-sizing: border-box;
}

/* 박스 스타일 지정 */
.wrapper {
    justify-content: center;
    margin: 20px 20px 0px 20px;
}

/* 폼 영역 스타일 지정 */
.mat-form-field {
    width: 100%;
}

/* 폼 에러 표시 관련 스타일 지정 */
.error {
    padding: 16px;
    width: 300px;
    color: white;
    background-color: red;
}

/* 버튼 스타일 지정 */
.button {
    display: flex;
    justify-content: flex-end;
}

/* 버튼 오른쪽 여백 설정 */
.button button {
    margin-right: 8px;
}

컴포넌트 내용 추가

constructor에서 postForm에 Validation설정을 추가합니다. ActivatedRoute를 이용하여 현재 주소로부터 path variable로 설정된 boardName을 얻습니다. 폼으로부터 필드 정보를 얻기 위해 postForm을 getter처리합니다. 이렇게 하면 html에서는 f.필드명 으로 접근하여 필드의 유효성 정보를 확인할 수 있습니다. submit()은 일단 로직없이 선언만 해둡니다.

// post.component.ts
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormBuilder, FormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';

@Component({
  selector: 'app-post',
  templateUrl: './post.component.html',
  styleUrls: ['./post.component.css']
})
export class PostComponent {

  boardName: string;
  postForm: FormGroup;

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    private formBuilder: FormBuilder
  ) {
    this.postForm = this.formBuilder.group({
      title: new FormControl('', [Validators.required]),
      content: new FormControl('', [Validators.required])
    });
    this.boardName = this.route.snapshot.params['boardName'];
  }

  // 폼 필드에 쉽게 접근하기 위해 getter 설정
  get f() { return this.postForm.controls; }

  submit() {
  }
}

라우팅 추가

글작성 화면 path를 라우팅에 추가합니다. boardName은 유동적인 값이므로 path variable로 처리합니다. 그리고 글작성은 회원만 가능하므로 Guard를 적용합니다.

// app.routing.module.ts
// import 생략
import { PostComponent } from './component/board/post.component';

const routes: Routes = [
  // path 생략
  {path: 'board/:boardName/post', component: PostComponent, canActivate: [AuthGuard]}
];

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

게시판 리스트에 글작성 링크 추가

Add Post 버튼에 글 작성으로 이동하는 routerLink를 추가합니다.

// board.component.html
<div class="wrapper">
<mat-card class="card">
    <mat-card-title fxLayout.gt-xs="row" fxLayout.xs="column">
    <div>
    <H3>자유 게시판</H3>
        <button mat-raised-button color="primary" [routerLink]="['/board', boardName, 'post']">
            <mat-icon>add</mat-icon> Add Post
        </button>
    </div>
    </mat-card-title>
    <mat-card-content>
    // 내용 생략
    </mat-card-content>
</mat-card>
</div>

게시글 작성 화면 테스트

서버를 실행하고 게시판 메뉴를 클릭합니다. 비로그인 상태에서 Add Post를 누르면 로그인 화면으로 이동합니다. 로그인을 완료하거나 기 로그인 상태인경우 아래와 같이 글 작성 화면이 노출됩니다. 폼의 유효성여부를 체크하여 유효하지 않은경우 등록 버튼이 비활성화 됩니다.

게시글 작성 API 연동

BoardService에 게시글 작성 메서드를 추가합니다.

// board.service.ts
@Injectable({
  providedIn: 'root'
})
export class BoardService {

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

  private getBoardUrl = '/api/v1/board';

  // 생략

  // 게시글 작성
  addPost(boardName: string, author: string, title: string, content: string): Promise<Post> {
    const postUrl = this.getBoardUrl+'/'+boardName+'/post';
    const params = new FormData();
    params.append('author', author);
    params.append('title', title);
    params.append('content', content);
    return this.http.post<ApiReponseSingle>(postUrl, params)
      .toPromise()
      .then(this.apiValidationService.validateResponse)
      .then(response => {
        return response.data as Post;
      })
      .catch(response => {
        alert('[게시글 등록 중 오류 발생]\n' + response.error.msg);   
        return Promise.reject(response.error.msg);
      });
  }
}

컴포넌트에 게시글 api 연동

게시글 작성 컴포넌트의 submit() 메서드에 api연동 내용을 작성합니다. 로그인 되어있고 폼 필드에 작성된 내용이 유효한 경우에만 글작성 api를 호출하고, api가 성공할 경우 게시글 리스트 화면으로 이동하도록 로직을 구성합니다.

// post.component.ts
import { MyinfoService } from 'src/app/service/rest-api/myinfo.service';
import { Component } from '@angular/core';
import { FormGroup, FormBuilder, FormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { SignService } from 'src/app/service/rest-api/sign.service';
import { BoardService } from 'src/app/service/rest-api/board.service';

@Component({
  selector: 'app-post',
  templateUrl: './post.component.html',
  styleUrls: ['./post.component.css']
})
export class PostComponent {

  boardName: string;
  postForm: FormGroup;

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    private formBuilder: FormBuilder,
    private signService: SignService,
    private myinfoService: MyinfoService,
    private boardService: BoardService
  ) {
    this.postForm = this.formBuilder.group({
      title: new FormControl('', [Validators.required]),
      content: new FormControl('', [Validators.required])
    });
    this.boardName = this.route.snapshot.params['boardName'];
  }

  // 폼 필드에 쉽게 접근하기 위해 getter 설정
  get f() { return this.postForm.controls; }

  submit() {
    if(this.signService.isSignIn && this.postForm.valid) {
      this.myinfoService.getUser().then( user => {
        this.boardService.addPost(this.boardName, user.name, this.postForm.value.title, this.postForm.value.content)
        .then(response => {
          this.router.navigate(['/board/' + this.boardName]);
        });
      });      
    }
  }
}

게시글 작성 기능 테스트

게시글을 작성하고 정상적으로 등록이 되면 아래와 같이 리스트에 출력이 됩니다.

게시글 조회

이번에는 게시글 리스트 클릭시 상세 내용을 확인 할 수 있는 게시글 조회 화면을 만들어보겠습니다.

게시글 조회 컴포넌트 생성

$ cd src/app/component/board 
$ ng g c post-view --flat --spec false
CREATE src/app/component/board/post-view.component.css
CREATE src/app/component/board/post-view.component.html
CREATE src/app/component/board/post-view.component.ts

게시글 조회 html

// post-view.component.html
<mat-card>
    <mat-card-title>글 상세</mat-card-title>
    <mat-card-content>
        <mat-list class="list-class">
            <mat-list-item>
            <mat-icon matListIcon>title</mat-icon>
            <div matLine></div>
            </mat-list-item>
            <mat-list-item>
            <mat-icon matListIcon>account_circle</mat-icon>
            <div matLine></div>
            </mat-list-item>
            <mat-list-item>
            <mat-icon matListIcon>message</mat-icon>
            <div matLine>
                <div class="white-space-pre"></div>
            </div>
            </mat-list-item>
            <mat-list-item>
            <mat-icon matListIcon>date_range</mat-icon>
            <div matLine></div>
            </mat-list-item>
        </mat-list>
    </mat-card-content>
</mat-card>
<div class="button">
    <button mat-stroked-button color="primary">리스트</button>
    <button mat-raised-button color="primary">수정</button>
</div>

게시글 조회 스타일

// post-view.component.css
/* newline(\n)을 <br>로 변경하여 적용한다. */
.white-space-pre {
    white-space: pre-wrap;
}

/* 리스트의 세로 사이즈를 auto로 설정 */
.list-class mat-list-item {
  height: auto;
}

/* 버튼 영역 스타일 지정 */
.button {
  margin-top: 10px;
  text-align: right;
}

/* 버튼의 오른쪽 여백 지정 */
.button button {
  margin-right: 8px;
}

라우팅 추가

// app-routing.module.ts
// import 생략
import { PostViewComponent } from './component/board/post-view.component';

const routes: Routes = [
  // path 생략
  {path: 'board/:boardName/post/:postId', component: PostViewComponent}
];

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

게시글 리스트에 상세화면 경로 추가

리스트의 항목 클릭시 게시글 상세로 가는 routerLink를 추가합니다.
[routerLink]=”[‘/board’, this.boardName, ‘post’, element.postId]”

<div class="wrapper">
<mat-card class="card">
    <mat-card-title fxLayout.gt-xs="row" fxLayout.xs="column">
    <div>
    <H3>자유 게시판</H3>
        <button mat-raised-button color="primary" [routerLink]="['/board', boardName, 'post']">
            <mat-icon>add</mat-icon> Add Post
        </button>
    </div>
    </mat-card-title>
    <mat-card-content>
    <div class="container">
        <mat-table [dataSource]="posts">
        <ng-container matColumnDef="postId">
            <mat-header-cell *matHeaderCellDef>no.</mat-header-cell>
            <mat-cell *matCellDef="let element" class="column-center" [routerLink]="['/board', this.boardName, 'post', element.postId]">{{element.postId}}</mat-cell>
        </ng-container>
        <ng-container matColumnDef="title">
            <mat-header-cell *matHeaderCellDef>제목</mat-header-cell>
            <mat-cell *matCellDef="let element" [routerLink]="['/board', this.boardName, 'post', element.postId]">{{element.title}}</mat-cell>
        </ng-container>
        <ng-container matColumnDef="author">
            <mat-header-cell *matHeaderCellDef>작성자</mat-header-cell>
            <mat-cell *matCellDef="let element" class="column-center" [routerLink]="['/board', this.boardName, 'post', element.postId]">{{element.author}}</mat-cell>
        </ng-container>
        <ng-container matColumnDef="createdAt">
            <mat-header-cell *matHeaderCellDef>작성일</mat-header-cell>
            <mat-cell *matCellDef="let element" class="column-center" [routerLink]="['/board', this.boardName, 'post', element.postId]">{{element.createdAt | date: 'yyyy-MM-dd HH:mm'}}</mat-cell>
        </ng-container>
        <ng-container matColumnDef="modifiedAt">
            <mat-header-cell *matHeaderCellDef>수정</mat-header-cell>
            <mat-cell *matCellDef="let element" class="column-center">
                <mat-icon class="icon">edit</mat-icon>
                <mat-icon class="icon">delete_forever</mat-icon>
            </mat-cell>
        </ng-container>
        <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
        <mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
        </mat-table>
        <div *ngIf="posts.length === 0" class="no-content">
            등록된 게시글이 없습니다.
        </div>
    </div>
    </mat-card-content>
</mat-card>
</div>

게시글 상세 화면 확인

서버를 띄우고 게시판 리스트의 항목을 클릭하면 다음과 같은 화면이 출력됩니다. 아직 api데이터를 연동하지 않아 데이터가 출력되지 않습니다.

게시글 상세 api 연동

BoardService에 아래와 같이 게시글 상세 api 연동 메서드를 작성합니다.

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

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

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

  private getBoardUrl = '/api/v1/board';

  // 생략

  // 게시글 상세 내용 조회
  viewPost(postId: number): Promise<Post> {
    const getPostUrl = this.getBoardUrl + '/post/' + postId;
    return this.http.get<ApiReponseSingle>(getPostUrl)
      .toPromise()
      .then(this.apiValidationService.validateResponse)
      .then(response => {
        return response.data as Post;
      })
      .catch(response => {
        alert('[게시글 조회 중 오류 발생]\n' + response.error.msg);   
        return Promise.reject(response.error.msg);
      });
  }
}

컴포넌트에 게시글 상세 api 연동

constructor에서 path variable로 넘어온 boardName과 postId를 얻습니다. 페이지 초기화가 완료되면(ngOnInit) 현재 로그인한 회원정보와 게시글 상세정보를 조회하여 변수에 세팅합니다.

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

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

  loginUser: User;
  boardName: string;
  postId: number;
  post: Post;

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

  ngOnInit() {
    if (this.signService.isSignIn()) {
      this.myinfoService.getUser()
      .then(user => {
        this.loginUser = user;
      });
    }
    this.boardService.viewPost(this.postId)
    .then(post => {
      this.post = post;
    });
  }
}

게시글 상세 html에 데이터 주입

위에서 조회한 값을 바탕으로 html에 데이터를 출력할수 있도록 html파일을 수정합니다. 리스트 버튼에는 리스트로 가는 routerLink를 작성합니다.

// post-view.component.html
<mat-card>
    <mat-card-title>글 상세</mat-card-title>
    <mat-card-content>
        <mat-list class="list-class">
            <mat-list-item>
            <mat-icon matListIcon>title</mat-icon>
            <div matLine>{{post?.title}}</div>
            </mat-list-item>
            <mat-list-item>
            <mat-icon matListIcon>account_circle</mat-icon>
            <div matLine>{{post?.author}}</div>
            </mat-list-item>
            <mat-list-item>
            <mat-icon matListIcon>message</mat-icon>
            <div matLine>
                <div class="white-space-pre">{{post?.content}}</div>
            </div>
            </mat-list-item>
            <mat-list-item>
            <mat-icon matListIcon>date_range</mat-icon>
            <div matLine>{{post?.createdAt | date: 'yyyy-MM-dd HH:mm'}}</div>
            </mat-list-item>
        </mat-list>
    </mat-card-content>
</mat-card>
<div class="button">
    <button mat-stroked-button color="primary" [routerLink]="['/board', boardName]">리스트</button>
    <button mat-raised-button color="primary">수정</button>
</div>

게시글 상세 화면 테스트

데이터 세팅이 완료되었으므로 다시 상세화면에 진입하면 다음과 같은 화면을 확인할 수 있습니다.

게시글 수정

이번에는 게시글을 수정하는 화면을 만들어보겠습니다. 게시글 수정은 게시글 리스트나, 게시글 상세 화면에서 버튼을 눌러 진입 할 수 있습니다. 수정은 입력과 거의 유사하므로 별다른 설명없이 빠르게 진행합니다.

게시글 수정 컴포넌트 생성

$ cd src/app/component/board
$ ng g c post-modify --flat --spec false
CREATE src/app/component/board/post-modify.component.css
CREATE src/app/component/board/post-modify.component.html
CREATE src/app/component/board/post-modify.component.ts

게시글 수정 html

// post-modify.component.html
<div class="wrapper">
  <mat-card>
    <mat-card-title>글수정</mat-card-title>
    <mat-card-content>
      <form [formGroup]="postForm" (ngSubmit)="submit()">
        <p>
          <mat-form-field>
            <input type="text" [(ngModel)]="post.title" matInput placeholder="제목" formControlName="title">
          </mat-form-field>
        </p>
        <p>
          <mat-form-field>
            <textarea [(ngModel)]="post.content" matInput placeholder="내용" formControlName="content" rows="5"></textarea>
          </mat-form-field>
        </p>
        <p *ngIf="f.title.touched && f.title.invalid && f.title.errors.required" class="error">
            Title is required
        </p>
        <p *ngIf="f.content.touched && f.content.invalid && f.content.errors.required" class="error">
            Contents is required
        </p>
        <div class="button">
          <button type="submit" mat-raised-button color="primary">수정</button>
        </div>
      </form>
    </mat-card-content>
  </mat-card>
</div>

게시글 수정 스타일

// post-modify.component.css
/* 페이지 전체 요소에 적용. 박스 사이즈를 테두리를 포함한 크기로 지정 */
* {
    box-sizing: border-box;
  }
  
/* 박스 스타일 지정 */
.wrapper {
    justify-content: center;
    margin: 20px 20px 0px 20px;
}
  
/* 폼 영역 스타일 지정 */
.mat-form-field {
    width: 100%;
}
  
/* 폼 에러 표시 관련 스타일 지정 */
.error {
    padding: 16px;
    width: 300px;
    color: white;
    background-color: red;
}
  
/* 버튼 스타일 지정 */
.button {
    display: flex;
    justify-content: flex-end;
}

게시글 수정 컴포넌트 내용 추가

게시글 수정 화면 진입시 기존 정보를 보여주는 부분은 이전에 작성했던 viewPost api를 재활용합니다. 수정 완료 처리를 하는 submit() 메서드는 api연동이 필요하므로 일단 빈 로직으로 작성합니다.

// post-modify.component.ts
import { Component, OnInit } from '@angular/core';
import { Post } from 'src/app/model/board/Post';
import { FormGroup, FormBuilder, FormControl, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { BoardService } from 'src/app/service/rest-api/board.service';

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

  boardName: string;
  postId: number;
  post = {} as Post;
  postForm: FormGroup;

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    private boardService: BoardService,
    private formBuilder: FormBuilder) {
    this.boardName = this.route.snapshot.params['boardName'];
    this.postId = this.route.snapshot.params['postId'];
    this.postForm = this.formBuilder.group({
      title: new FormControl('', [Validators.required]),
      content: new FormControl('', [Validators.required])
    });
  }

  // 폼 필드에 쉽게 접근하기 위해 getter 설정
  get f() { return this.postForm.controls; }

  ngOnInit() {
    this.boardService.viewPost(this.postId)
    .then(post => {
      this.post = post;
    });
  }

  submit() {
    
  }
}

라우팅 추가

게시글 수정 화면을 라우팅 정보에 추가합니다. 게시글 수정의 경우 로그인 회원만 가능하므로 Guard를 적용합니다.

// app-routing.module.ts
// import 생략
import { PostModifyComponent } from './component/board/post-modify.component';

const routes: Routes = [
  // path 생략
  {path: 'board/:boardName/post/:postId/modify', component: PostModifyComponent, canActivate: [AuthGuard]},
];

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

게시글 수정 링크 추가

게시글 리스트 컴포넌트에 아래와 같이 내용을 추가합니다. 수정 기능의 경우 로그인한 회원이 작성자와 동일해야 하므로 loginUser정보를 알아야 하므로 해당 정보를 세팅합니다.

게시글 리스트

// board.component.ts
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) {
      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;
      });
    }
  }
}

게시글 리스트의 연필 아이콘에 routerLink를 적용합니다. 수정 버튼의 경우는 로그인 상태이고 로그인한 유저의 회원번호(loginUser?.msrl)와 게시글의 작성자 회원번호(element?.user.msrl)가 일치해야 노출되도록 처리합니다.

// board.component.html
// 생략
<mat-cell *matCellDef="let element" class="column-center">
    <mat-icon class="icon" *ngIf="signService.isSignIn() && loginUser?.msrl == element?.user.msrl" [routerLink]="['/board', boardName, 'post', element.postId, 'modify']">edit</mat-icon>
    <mat-icon class="icon">delete_forever</mat-icon>
</mat-cell>
// 생략

게시글 상세

게시글 상세화면의 수정버튼에 리스트와 마찬가지로 로그인 상태이고 로그인한 유저의 회원번호(loginUser?.msrl)와 게시글의 작성자 회원번호(post?.user.msrl)가 일치하면 버튼을 노출하도록 처리합니다.

// post-view.component.html
// 생략
<div class="button">
    <button mat-stroked-button color="primary" [routerLink]="['/board', boardName]">리스트</button>
    <button mat-raised-button color="primary" *ngIf="signService.isSignIn() && loginUser?.msrl == post?.user.msrl" [routerLink]="['/board', boardName, 'post', post?.postId, 'modify']">수정</button>
</div>
// 생략

게시글 수정화면 확인

게시글 리스트의 연필 버튼이나 게시글 상세의 수정 버튼을 클릭시 아래와 같은 화면을 확인 가능합니다.

게시글 수정 API 연동

boardService에 게시글 수정 api 연동 메서드를 추가합니다.

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

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

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

  private getBoardUrl = '/api/v1/board';

  // 생략

  // 게시글 수정
  modifyPost(post: Post): Promise<Post> {
    const postUrl = this.getBoardUrl + '/post/' + post.postId;
    const params = new FormData();
    params.append('author', post.author);
    params.append('title', post.title);
    params.append('content', post.content);
    return this.http.put<ApiReponseSingle>(postUrl, params)
      .toPromise()
      .then(this.apiValidationService.validateResponse)
      .then(response => {
        return response.data as Post;
      })
      .catch(response => {
        alert('[게시글 수정 중 오류 발생]\n' + response.error.msg);   
        return Promise.reject(response.error.msg);
      });
  }
}

게시글 수정 컴포넌트에 api 연동 메서드 적용

submit() 메서드에 api연동 메서드를 적용합니다.

import { Component, OnInit } from '@angular/core';
import { Post } from 'src/app/model/board/Post';
import { FormGroup, FormBuilder, FormControl, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { BoardService } from 'src/app/service/rest-api/board.service';

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

  boardName: string;
  postId: number;
  post = {} as Post;
  postForm: FormGroup;

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    private boardService: BoardService,
    private formBuilder: FormBuilder) {
    this.boardName = this.route.snapshot.params['boardName'];
    this.postId = this.route.snapshot.params['postId'];
    this.postForm = this.formBuilder.group({
      title: new FormControl('', [Validators.required]),
      content: new FormControl('', [Validators.required])
    });
  }

  // 생략

  submit() {
    this.boardService.modifyPost(this.post)
    .then(response => {
      this.router.navigate(['/board/' + this.boardName + '/post/' + this.postId]);
    });
  }
}

게시글 수정 기능 테스트

게시글 수정 화면에서 제목 및 내용을 작성한 후 수정버튼을 클릭하면 수정된 내용이 반영되고 게시글 상세 화면으로 이동되는것을 확인할 수 있습니다.

게시글 삭제 기능 추가

게시글 삭제의 경우 화면이 존재하지 않고 게시글 리스트에 버튼이 존재하기 때문에 따로 컴포넌트를 생성하여 작업하지 않습니다. 게시글 리스트 컴포넌트에서 api를 호출하여 삭제하도록 처리하겠습니다.

게시글 삭제 API 연동

boardService에 api 연동 메서드를 추가합니다.

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

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

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

  private getBoardUrl = '/api/v1/board';

  // 게시글 삭제
  deletePost(postId: number): Promise<boolean> {
    const deletePostUrl = this.getBoardUrl + '/post/' + postId;
    return this.http.delete<ApiReponseSingle>(deletePostUrl)
      .toPromise()
      .then(this.apiValidationService.validateResponse)
      .then(response => {
        return true;
      })
      .catch(response => {
        alert('[게시글 삭제 중 오류 발생]\n' + response.error.msg);   
        return Promise.reject(response.error.msg);
      });
  }
}

게시글 리스트 컴포넌트에 api 연동

아래와 같이 delete 메서드를 작성합니다. 삭제 완료후 게시글 리스트로 이동하는데 window.location.reload();를 사용한 이유는 SPA특성상 현재 페이지와 같은 주소를 호출하면 페이지 내용이 갱신이 되지 않기 때문입니다.

// board.component.ts
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) {
      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();
      });
    }
  }
}

게시글 리스트 html 수정

삭제 버튼에 로직을 추가합니다.

// board.component.html
// 생략
<mat-cell *matCellDef="let element" class="column-center">
                <mat-icon class="icon" *ngIf="signService.isSignIn() && loginUser?.msrl == element?.user.msrl" [routerLink]="['/board', boardName, 'post', element.postId, 'modify']">edit</mat-icon>
                <mat-icon class="icon" *ngIf="signService.isSignIn() && loginUser?.msrl == element?.user.msrl" (click)="delete(element.postId)">delete_forever</mat-icon>
            </mat-cell>
// 생략

게시글 삭제 테스트

게시글 리스트에서 휴지통 버튼을 누르면 다음과 같이 삭제 확인을 묻고 확인을 클릭할경우 삭제 처리하는것을 확인 할수 있습니다.

이번 실습으로 게시판의 기본기능 (읽기, 쓰기, 수정, 삭제)을 모두 개발 완료하였습니다. 다음 장 부터는 지금까지 개발한 내용에 대하여 개선사항을 적용해 보겠습니다.

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

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

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

연재글 이동[이전글] Spring Rest api + Angular framework로 웹사이트 만들기(3) – 내정보(Interceptor, Router Guard)
[다음글] Spring Rest api + Angular framework로 웹사이트 만들기(5) – 개선사항 적용 (custom alert/confirm dialog, resolve, 404page)