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

이번 장에서는 로그인한 회원의 정보를 표시하는 내정보 화면을 구현해 보겠습니다. Interceptor를 이용하여 인증이 필요한 API의 Http Header 세팅을 공통화하고, 인증(로그인)이 필요한 페이지에 대한 접근을 처리하는 Guard에 대해서도 실습해 보겠습니다.

내정보 화면 생성

내 정보 화면 component를 생성합니다.

$ cd src/app/component/member
$ ng g c myinfo --spec false
CREATE src/app/component/member/myinfo/myinfo.component.css
CREATE src/app/component/member/myinfo/myinfo.component.html
CREATE src/app/component/member/myinfo/myinfo.component.ts

내 정보 html 작성

로그인한 회원정보를 보여주는 html페이지를 아래와 같이 작성합니다. loginUser 데이터를 ts파일에서 전달받아 단방향 바인딩으로 처리합니다. 가입일의 경우 Date 형태인데 파이프(‘|’)를 이용하여 원하는 형식으로 변경해서 화면에 표시합니다. 파이프에 대한 설명은 아래 공식 사이트에서 자세하게 살펴보실 수 있습니다.

https://angular.kr/guide/pipes

<mat-card>
    <mat-card-title>내정보</mat-card-title>
    <mat-card-content>
        <mat-list>
        <mat-list-item>
            <mat-icon matListIcon>email</mat-icon>
            <h3 matLine> {{loginUser.uid}} </h3>
        </mat-list-item>
        <mat-list-item>
            <mat-icon matListIcon>account_circle</mat-icon>
            <h3 matLine> {{loginUser.name}} </h3>
        </mat-list-item>
        <mat-list-item>
            <mat-icon matListIcon>date_range</mat-icon>
            <h3 matLine> {{loginUser.createdAt | date: 'yyyy-MM-dd HH:mm'}} </h3>
        </mat-list-item>
        </mat-list>
    </mat-card-content>
</mat-card>

Material UI 아이콘 사용

위에서 작성한 html에서 mat-icon을 사용하고 있는데 mat-icon 태그안에 아이콘 이름을 적어넣으면 화면에 아이콘이 표시가 됩니다. Material ui에서 제공하는 다양한 아이콘은 아래 링크에서 확인할 수 있습니다.

https://material.io/resources/icons/?style=baseline

내정보 화면 스타일 작성

기본 UI로도 데이터 표현에 문제가 없어 myinfo.component.css에 추가 스타일을 적용하지 않습니다.

내정보 API 연동

모델 파일 생성

회원 정보 api의 스펙 및 성공 결과는 아래와 같고, data 필드의 정보를 담을 모델을 생성합니다.

{
  "success": true,
  "code": 0,
  "msg": "성공하였습니다.",
  "data": {
    "createdAt": "2019-09-16T14:44:47.447",
    "modifiedAt": "2019-09-16T14:44:47.447",
    "msrl": 79,
    "uid": "happydaddy@naver.com",
    "name": "아빠프로그래머",
    "provider": null,
    "roles": [
      "ROLE_USER"
    ],
    "authorities": [
      {
        "authority": "ROLE_USER"
      }
    ]
  }
}
$ cd src/app/model 
$ mkdir myinfo
$ cd myinfo
$ touch User.ts

다음과 같이 내용을 작성합니다.

export interface User {
    msrl: number;
    uid: string;
    name: string;
    provider: string;
    roles: string[];
    createdAt: Date;
    modifiedAt: Date;
}

Interceptor를 이용한 http header 세팅

내 정보와 같이 인증을 필요로 하는 api의 경우 호출시 header에 x-auth-token값을 세팅해야 합니다. token의 경우 로그인을 한 이후에는 localstorage에 저장되므로 api마다 매번 파라미터로 받아서 세팅할 필요가 없습니다. Interceptor를 이용하여 http로 api 요청시 자동으로 token을 세팅하도록 처리합니다.

다음과 같이 interceptor 서비스를 생성합니다.

$ cd src/app/service/rest-api/common 
$ ng g s http-request-interceptor --flat --spec false
CREATE src/app/service/rest-api/common/http-request-interceptor.service.ts

interceptor 내용을 다음과 같이 작성합니다. Observable 사용을 위해 rxjs 라이브러리가 필요한데 기존에 설치하지 않았다면 다음과 같이 설치합니다.

$ npm install --save rxjs
$ npm install --save rxjs-compat

Interceptor는 http 요청을 중간에 가로채서 header에 추가 정보를 세팅하는 역할을 합니다. 아래에서는 localstorage에 저장된 token이 있으면 header에 x-auth-token값을 세팅합니다.

// http-request-interceptor.service.ts
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpInterceptor, HttpEvent, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class HttpRequestInterceptorService  implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    var token = localStorage.getItem('x-auth-token');
    var reqHeader: HttpHeaders = req.headers;
    if(token)
      reqHeader = reqHeader.set('x-auth-token', token);
    const newRequest = req.clone({headers: reqHeader});
    return next.handle(newRequest);
  }
}

Interceptor 등록

인터셉터가 http 요청마다 적용되게 하려면 app.module.ts의 providers에 아래와 같이 추가해야 합니다.

import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { HttpRequestInterceptorService } from './service/rest-api/common/http-request-interceptor.service';
// 생략
providers: [
  {
    provide: HTTP_INTERCEPTORS,
    useClass: HttpRequestInterceptorService,
    multi: true,
  },
  SignService,
  MyinfoService
]

API 연동을 위한 서비스 생성

API서버와의 통신처리를 위해 아래와 같이 서비스를 생성합니다.

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

로직을 보면 localStorage에서 loginUser정보가 있을경우 바로 리턴하는데, 회원 정보의 경우 자주 변경되는 데이터가 아니기 때문에 api호출 결과를 localStorage에 저장해두고 재사용하기 위함입니다. 똑같은 api를 반복해서 호출하는 것을 막는 의도도 있습니다.

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

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

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

  private getUserUrl = '/api/v1/user';

  getUser(): Promise<User> {
    const loginUser = JSON.parse(localStorage.getItem('loginUser'));
    if(loginUser == null) {
      return this.http.get<ApiReponseSingle>(this.getUserUrl)
        .toPromise()
        .then(this.apiValidationService.validateResponse)
        .then(response => {
          localStorage.setItem('loginUser', JSON.stringify(response.data));
          return response.data as User;
        })
        .catch(response => {
          localStorage.removeItem('x-auth-token');
          alert('[회원 정보 조회중 오류 발생]\n' + response.error.msg);   
          return Promise.reject(response.error.msg);
        });
    } else {
      return Promise.resolve(loginUser);
    }
  }
}

서비스 등록

새로 생성한 MyInfoService를 app.module.ts에 적용합니다.

// app.module.ts
import { MyinfoService } from './service/rest-api/myinfo.service';

// 생략

providers: [
    SignService,
    MyinfoService
]

내정보 컴포넌트에 api 데이터 연동

loginUser 변수를 선언하고 컴포넌트가 생성될때 서비스를 호출하여 결과를 변수에 담습니다. loginUser 변수는 html 파일에 바인딩 되어있어 해당 변수값을 채워넣으면 뷰단에 정보가 표시됩니다.

// myinfo.component.ts
import { Component } from '@angular/core';
import { MyinfoService } from 'src/app/service/rest-api/myinfo.service';
import { User } from 'src/app/model/myinfo/User';

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

  loginUser: User;

  constructor(private myInfoService: MyinfoService) {
    this.myInfoService.getUser().then(user => {
      this.loginUser = user;
    });
  }
}

라우팅 추가

라우팅 설정 파일에 내정보 path를 추가합니다.

// app-routing.module.ts
// import 생략
import { MyinfoComponent } from './component/member/myinfo/myinfo.component';

const routes: Routes = [
  // path 생략
  {path: 'myinfo', component: MyinfoComponent}
];

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

메뉴에 내정보 링크 추가

메뉴의 내정보에 myinfo 링크를 추가합니다.
[routerLink]=”[‘/myinfo’]”

// 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>
              <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>
                    <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>

로그아웃시 localStorage 회원 정보 삭제

로그아웃시 localstroage에 저장된 loginUser정보를 비우도록 추가합니다.

// logout.component.ts
export class LogoutComponent {
  constructor(private router: Router) {
    localStorage.removeItem('x-auth-token');
    localStorage.removeItem('loginUser');
    this.router.navigate(['/']);
  }
}

내 정보 화면 확인

로그인 후 내정보를 클릭하면 다음과 같은 화면을 확인할 수 있습니다.

Router Guard 적용

내정보 페이지가 완성되었지만 추가적으로 작업할 사항이 있습니다. 현재 상태에서 비 로그인 상태로 내정보 페이지 URL을 바로 접근하면 어떻게 될까요? 다행히도 오류처리를 해놓아서 다음과 같이 오류가 발생합니다.

대부분의 웹사이트는 위와 같은 경우에 로그인 화면으로 이동합니다. 로그인이 완료되면 해당 페이지로 이동하여 다시 정보를 보여주도록 되어있지요. Angular에서는 이러한 기능을 Router Guard라고 합니다. 말 그대로 보호한다는 의미가 있습니다. 여기서는 페이지를 보호하는 기능이라고 보면 됩니다.

Guard 생성

$ cd src/app/
$ mkdir guards
$ cd guards
$ touch auth.guard.ts

로그인 상태이면 true를 반환하고 종료하며 비로그인 상태인경우 로그인 화면으로 이동하고 false를 반환합니다. 로그인 화면으로 보낼때 redirectTo queryParameter를 세팅해 보내는데 state.url은 현재 페이지 정보입니다. 즉 비로그인 상태에서 Guard가 적용된 페이지에 접근하면 로그인을 거친 후 내정보 페이지로 이동하도록 설정하는 것입니다.

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { SignService } from '../service/rest-api/sign.service';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(private router: Router,
    private signService: SignService) {
  }

  canActivate(next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
    if (this.signService.isSignIn()) {
      return true;
    } else {
      this.router.navigate(['/signin'], {queryParams: { redirectTo: state.url }});
      return false;
    }
  }
}

라우팅 설정에 Guard 적용

다음과 같이 라우팅 설정 파일에 canActivate정보를 추가로 세팅하고 위에서 생성한 Guard를 적용합니다.

// app-routing.module.ts
// import 생략
import { MyinfoComponent } from './component/member/myinfo/myinfo.component';
import { AuthGuard } from './guards/auth.guard';

const routes: Routes = [
  // 생략
  {path: 'myinfo', component: MyinfoComponent, canActivate: [AuthGuard]}
];

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

테스트

서버를 재시작 하고 localhost:4200/myinfo를 입력하여 내정보 페이지에 바로 접근합니다. 그러면 다음과 같이 로그인 화면으로 이동하고 로그인을 완료하면 내정보 페이지로 이동하여 정보가 표시되는것을 볼수 있습니다.

이제 웹사이트가 갖춰야할 기본적인 기능이 어느정도 완성되었습니다. 로그인/가입이 가능하고 로그인이 필요한 페이지의 접근 제한도 가능합니다. 다음 장 부터는 간단한 게시판을 만들어보면서 CRUD 실습을 해보겠습니다.

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

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

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

연재글 이동[이전글] Spring Rest api + Angular framework로 웹사이트 만들기(2) – 로그인/가입(HttpClient, Proxy, Validation)
[다음글] Spring Rest api + Angular framework로 웹사이트 만들기(4) – 게시판(CRUD)