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

이번 장에서는 가입과 로그인 기능을 추가하여 Rest API와 연동하는 방법을 실습하겠습니다. 이번 실습 부터는 리소스를 제공할 rest api가 필요하므로 아래 내용을 참고하여 서버를 띄우고 실습하겠습니다.

Rest api Git 프로젝트

아래 Git소스를 클론 받아 로컬에서 서버를 실행하고 실습을 진행합니다. 관련해서 필요한 내용은 아래의 포스트들을 참고 합니다.

https://github.com/codej99/SpringRestApi.git

REST API 설정 관련 포스트

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

Rest api 스펙 확인

Rest api 서버를 실행하면 다음 링크에서 API 목록 및 연동 방법을 확인 할 수 있습니다.

http://localhost:8080/swagger-ui.html

로그인 기능 구현

본격적으로 웹 개발을 진행해 보겠습니다. 첫번째로 로그인 기능을 구현하겠습니다. 아래와 같이 로그인(signin), 가입(signup) Component를 생성합니다.

$ cd src/app/component
$ mkdir member
$ cd member
$ ng g c signin --spec false
CREATE src/app/component/signin/signin.component.css (0 bytes)
CREATE src/app/component/signin/signin.component.html (21 bytes)
CREATE src/app/component/signin/signin.component.ts (269 bytes)
UPDATE src/app/app.module.ts (841 bytes)
$ ng g c signup --spec false
CREATE src/app/component/signup/signup.component.css (0 bytes)
CREATE src/app/component/signup/signup.component.html (21 bytes)
CREATE src/app/component/signup/signup.component.ts (269 bytes)
UPDATE src/app/app.module.ts (933 bytes)

로그인 화면 html 작성

로직을 제외한 간단한 UI의 로그인 화면을 아래와 같이 작성합니다.

// signin.component.html
<div class="wrapper">
  <mat-card>
    <mat-card-title>로그인</mat-card-title>
    <mat-card-content>
      <form>
        <p>
          <mat-form-field>
            <input type="text" matInput placeholder="Id">
          </mat-form-field>
        </p>
        <p>
          <mat-form-field>
            <input type="password" matInput placeholder="Password">
          </mat-form-field>
        </p>
        <div class="button">
          <button type="submit" mat-stroked-button>Login</button>
        </div>
      </form>
    </mat-card-content>
  </mat-card>
</div>

로그인 화면 스타일 적용

html에 적용할 스타일을 아래와 같이 작성합니다.

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

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

/* 입력 폼 사이즈 지정 */
.mat-form-field {
    width: 100%;
    min-width: 300px;
}

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

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

.button button {
    margin-right: 8px;
}

폼/통신 모듈 추가

validation 체크와 같은 폼과 관련된 처리를 하기 위해 FormsModule, ReactiveFormsModule 모듈을 추가합니다. 외부 api와 통신을 하기 위해 필요한 HttpClient 모듈도 추가합니다.

// app.module.ts
... 생략
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    SigninComponent,
    SignupComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MaterialModule,
    FlexLayoutModule,
    FormsModule,
    ReactiveFormsModule,
    HttpClientModule
  ],
  exports: [
    ReactiveFormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})

라우팅 설정 추가

로그인 path를 라우팅 설정에 추가합니다.

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

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

메뉴 로그인 항목에 경로 추가

app.component.html에 로그인 화면 주소를 추가합니다. routerLink를 이용해 호출하도록 링크를 추가하면 클릭시 화면 전체가 아닌 내용 부분만 치환되어 표시됩니다.

<div class="wrapper">
  <mat-sidenav-container>
      <mat-sidenav  #sidenav role="navigation">
        <mat-nav-list>
            <a mat-list-item [routerLink]="['/signin']" routerLinkActive="router-link-active" >
              <mat-icon class="icon">input</mat-icon>
              <span class="label">로그인</span>
            </a>
            ... 생략
            </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>
                  <a [routerLink]="['/signin']">
                    <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>
    // 푸터 생략
</div>

ng serve명령어로 서버를 띄운후 브라우저에서 localhost:4200을 호출하고 상단 로그인을 눌러 내용을 확인합니다.

입력 폼 validation 처리

로그인 화면의 입력폼에 입력되는 값에 대하여 유효성 체크를 추가해 보겠습니다.

  • id : 입력 필수, 이메일 주소만 입력 가능
  • password : 입력 필수

유효성 체크에 대한 좀더 상세한 설명은 공식 사이트에서 확인하는 것이 좋을거 같아 아래 링크를 추가하였습니다.

https://angular.kr/guide/form-validation

signin.component.html 수정

  • form tag에 formGroup을 지정
  • 로그인 버튼 클릭시 submit()을 호출하도록 처리
  • input tag에 formControlName을 설정
  • input form 유효성 검증 에러 메시지 추가
  • input form 유효성 검증 실패시 로그인 버튼 비활성화 처리
// signin.component.html
<div class="wrapper">
  <mat-card>
    <mat-card-title>로그인</mat-card-title>
    <mat-card-content>
      <form [formGroup]="signInForm" (ngSubmit)="submit()">
        <p>
          <mat-form-field>
            <input type="text" matInput placeholder="Id" formControlName="id">
          </mat-form-field>
        </p>
        <p>
          <mat-form-field>
            <input type="password" matInput placeholder="Password" formControlName="password">
          </mat-form-field>
        </p>
        <p *ngIf="id.touched && id.invalid && id.errors.required" class="error">
          Id is required
        </p>
        <p *ngIf="id.touched && id.invalid && id.errors.email" class="error">
            Please enter valid email
        </p>
        <p *ngIf="password.touched && password.invalid && password.errors.required" class="error">
            Password is required
        </p>
        <div class="button">
          <button type="submit" mat-stroked-button  [disabled]="!signInForm.valid">Login</button>
        </div>
      </form>
    </mat-card-content>
  </mat-card>
</div>

signin.component.ts 내용 추가

  • html에서 signInForm을 선언 하여 FormGroup, FormControl을 사용하여 유효성 체크 적용이 가능합니다. constructor에 FormGroup에 대한 Validation을 설정합니다.
  • html에서 인풋 폼에 대한 에러 정보를 얻을수 있도록 get id(), get password()를 선언해 줍니다.
  • submit() 함수를 작성합니다. 아직 로그인 기능을 완성하지 않았으므로 빈 내용으로 작성합니다.
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';

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

  signInForm: FormGroup;

  constructor() {
    this.signInForm = new FormGroup({
      id: new FormControl('', [Validators.required, Validators.email]),
      password: new FormControl('', [Validators.required])
    });
  }

  get id() {
    return this.signInForm.get('id');
  }

  get password() {
    return this.signInForm.get('password');
  }

  submit() {
  }
}

유효성 체크 Test

내용을 저장하고 브라우저에서 로그인 화면을 확인합니다. 폼 클릭후 값을 입력하지 않거나 유효하지 않은 값을 입력하고 폼을 벗어나면 아래와 같이 에러 내용이 표시되고 로그인 버튼이 비활성화 되는것을 확인 할 수 있습니다.

로그인 API 연동

폼에 입력한 데이터를 이용하여 로그인 API와 연동해 보겠습니다. 다음과 같이 API서버의 결과를 맵핑할 model ts 파일을 생성하고 내용을 작성합니다. 여기서는 단일 결과를 받을 모델과, 리스트 결과를 받을 모델 두개를 생성하였습니다. 리스트 결과 모델은 차후에 사용할 예정이므로 미리 만들어 놓습니다.

$ cd src/app
$ mkdir model
$ cd model
$ mkdir common
$ cd common
$ touch ApiReponseSingle.ts
$ touch ApiReponseList.ts
// ApiReponseSingle.ts
export interface ApiReponseSingle {
  success: boolean;
  code: number;
  msg: string;
  data: any;
}
// ApiReponseList.ts
export interface ApiReponseList {
    success: boolean;
    code: number;
    msg: string;
    list: any[];
}

로그인 api의 성공 실패 결과는 다음과 같은 형태의 json으로 결과가 내려옵니다. 따라서 로그인 API의 경우에는 ApiReponseSingle 모델로 결과를 받으면 됩니다.

// 성공
{
  "success": true,
  "code": 0,
  "msg": "성공하였습니다.",
  "data": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlhdCI6MTU2ODU2MTk5MCwiZXhwIjoxNTY4NTY1NTkwfQ.XTwP9K3dvljx4d04zHWM_pS1UDGIyFj71UrsUeWzbCg"
}
// 실패
{
  "success": false,
  "code": -1001,
  "msg": "계정이 존재하지 않거나 이메일 또는 비밀번호가 정확하지 않습니다."
}

API 통신 서비스 생성

실제 api와 통신하는 service를 생성하고 내용을 작성합니다. 로그인의 경우 단일 결과를 리턴받으므로 ApiReponseSingle을 리턴받도록 처리합니다. Http통신은 Angular에서 제공하는 HttpClient를 이용합니다.

$ cd src/app
$ mkdir service
$ cd service
$ mkdir rest-api
$ cd rest-api
$ ng g s sign --spec false
CREATE src/app/service/rest-api/sign.service.ts (133 bytes)

API 통신이 성공하면 결과의 token을 브라우저의 localstorage에 저장합니다. 이 token을 이용하여 다른 api를 사용할 수 있습니다.

// sign.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ApiReponseSingle } from 'src/app/model/common/ApiReponseSingle';

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

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

  constructor(private http: HttpClient) { }

  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(response => {
        localStorage.setItem('x-auth-token', response.data);
      });
  }
}

신규 서비스 모듈 등록

신규로 서비스를 생성하면 app.module.ts의 providers에 등록해줘야 다른 컴포넌트에서 사용이 가능합니다.

// import 생략
import { SignService } from './service/rest-api/sign.service';

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

로그인 화면에 api 연동

constructor에 signService를 선언하고 submit() 내용을 작성합니다.

// signin.component.ts
// import 생략
import { SignService } from 'src/app/service/rest-api/sign.service';

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

  signInForm: FormGroup;

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

  get id() {
    return this.signInForm.get('id');
  }

  get password() {
    return this.signInForm.get('password');
  }

  submit() {
    if (this.signInForm.valid) {
      this.signService.signIn(this.signInForm.value.id, this.signInForm.value.password)
        .then(data => {
          alert('로그인에 성공하였습니다');
        })
        .catch(response => {
          alert('로그인에 실패하였습니다 - ' + response.error.msg);
        });
    }
  }
}

크로스 도메인 이슈 처리를 위한 Proxy 설정 추가

angular와 같은 Front web 프레임워크로 웹사이트를 구축할때는 외부 리소스를 사용하기 위해 api 서버와 통신이 필요합니다. 그런데 브라우저에서 ajax를 이용하여 외부 리소스를 호출할때 현재 도메인내에서 다른 도메인의 주소(port도 구별)를 호출하면 크로스 도메인이라고 하여 브라우저에서 보안 오류를 내며 통신이 실패합니다. 따라서 localhost:4200에서 localhost:8080으로의 호출은 현재로선 불가능합니다.

이를 처리하기 위해서는 api 서버의 설정을 바꾸어 crossdomain 처리가 가능하게 하는 방법과 Proxy를 이용하여 클라이언트에서 처리하는 방법 2가지가 있습니다. api의 설정을 바꿔 어떤 클라이언트든 받아들이는것은 좋지 않은 방법이라 생각되므로 두번째 방법인 Proxy를 이용하여 처리해보겠습니다.

다행히도 angular에서는 Proxy설정을 기본으로 지원하고 있습니다. api주소를 현재도메인(localhost:4200)으로 호출하더라도 내부적으로 localhost:8080으로 리소스를 요청해주는 기능입니다.

Proxy 설정

프로젝트 root에 proxy.conf.json을 생성합니다.

$ touch proxy.conf.json
$ ls
README.md     e2e/           package-lock.json  src/               tsconfig.spec.json
angular.json  karma.conf.js  package.json       tsconfig.app.json  tslint.json
browserslist  node_modules/  proxy.conf.json    tsconfig.json

proxy.conf.json 내용을 아래와 같이 작성합니다. 아래와 같이 작성하면 localhost:4200/api/v1/siginin으로 호출할경우 실제로는 localhost:8080/v1/siginin이 요청됩니다. 즉 http 통신시 호출 주소의 path에 api가 포함되어있으면 proxy 설정이 적용됩니다. pathRewrite는 api요청시 주소에서 api문자열을 빼라는 의미입니다.

// proxy.conf.json
{
    "/api/*" : {
        "target" : "http://localhost:8080",
        "secure" : false,
        "logLevel" : "debug",
        "pathRewrite": {
            "^/api": ""
        }
    }
}

proxy설정을 적용하려면 아래처럼 옵션을 주고 서버를 시작 해야 합니다.

$ ng serve --proxy-config proxy.conf.json

위와 같이 매번 쓰는것은 불편하므로 package.json을 변경합니다. scripts 항목의 start를 위 명령어로 변경합니다.

"scripts": {
    "ng": "ng",
    "start": "ng serve --proxy-config proxy.conf.json",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e"
}

이제부터는 아래와 같이 서버를 실행하면 됩니다.

$ npm start

이제 로그인 테스트를 시도해봅니다. 현재 가입 기능이 없으므로 실패 테스트만 가능합니다.

API 처리 기능 개선

api 연동시 결과는 성공 혹은 실패입니다. 성공/실패 여부는 json 결과의 success: true/false로 알수 있습니다. 그리고 실패시 상세 내용은 msg로 확인이 가능합니다. 이부분은 모든 api가 공통이므로 component에서 처리하는 것이 아닌 Service에서 공통으로 처리하도록 수정해 보겠습니다.

api validation 서비스 생성

$ cd src/app/service/rest-api 
$ mkdir common
$ cd common 
$ ng g s api-validation --flat --spec false
CREATE src/app/service/rest-api/common/api-validation.service.ts (142 bytes)

아래 내용을 작성합니다. api의 결과에서 success: true인경우 성공처리(resolve)하고 false인경우 실패 처리(reject)합니다. Promise.reject로 반환될 경우 호출단에서 catch로 받아서 처리할 수 있습니다.

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class ApiValidationService {
  public validateResponse(response: any): Promise<any> {
    if(response.success) 
      return Promise.resolve(response);
    else 
      return Promise.reject(response);
  }
}

sign 서비스에 api 유효성 체크를 추가합니다.

// sign.service.ts
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';

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

  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);   
        return Promise.reject(response.error.msg);
      });
  }
}

signInComponent 수정

service에서 예외처리를 하여 Component에서는 관련 내용을 삭제 합니다. 로그인 화면 호출시 redirectTo 파라미터를 세팅하였는데 로그인 성공후에 이동하는 경로를 유동적으로 세팅하기 위해서입니다. redirectTo가 리퀘스트 파라미터에 추가되어 호출된 경우엔 로그인 성공시 해당 redirectTo 주소로 이동하고 세팅되지 않은경우는 Home으로 이동합니다.

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) {
    this.signInForm = new FormGroup({
      id: new FormControl('', [Validators.required, Validators.email]),
      password: new FormControl('', [Validators.required])
    });
  }

  get id() {
    return this.signInForm.get('id');
  }

  get password() {
    return this.signInForm.get('password');
  }

  ngOnInit() {
    this.route.queryParams.subscribe(params => {
      this.redirectTo = params['redirectTo']
    });
  }

  submit() {
    if (this.signInForm.valid) {
      this.signService.signIn(this.signInForm.value.id, this.signInForm.value.password)
        .then(data => {
          this.router.navigate([this.redirectTo ? this.redirectTo : '/']);
        });
    }
  }
}

가입 기능 구현

두번째로 가입 기능을 구현합니다. 로그인 기능 구현시 signUp Component를 미리 만들어 놓았기 때문에 해당 컴포넌트를 열어 내용을 작성합니다. 가입화면은 로그인화면과 유사하고 폼 유효성 체크도 별반 다르지 않으므로 점진적으로 개발하지 않고 Full Source를 첨부합니다.

가입 화면 html 작성

// signup.component.html
<div class="wrapper">
    <mat-card>
        <mat-card-title>회원가입</mat-card-title>
        <form [formGroup]="signUpForm" (ngSubmit)="submit()">
          <p>
            <mat-form-field>
              <input type="text" matInput placeholder="Email" formControlName="id">
            </mat-form-field>
          </p>
          <p>
            <mat-form-field>
              <input type="password" matInput placeholder="Password" formControlName="password">
            </mat-form-field>
          </p>
          <p>
            <mat-form-field>
              <input type="password" matInput placeholder="Confirm Password" formControlName="password_re">
            </mat-form-field>
          </p>
          <p>
            <mat-form-field>
              <input type="text" matInput placeholder="Name" formControlName="name">
            </mat-form-field>
          </p>
          <p *ngIf="f.id.invalid && (f.id.touched || f.id.dirty)" class="error">
            Please enter valid email
          </p>
          <p *ngIf="f.password.invalid && (f.password.touched || f.password.dirty) && f.password.errors.required" class="error">
              Password is required
          </p>
          <p *ngIf="f.password_re.invalid && (f.password_re.touched || f.password_re.dirty) && f.password_re.errors.required" class="error">
              Password Re is required
          </p>
          <p *ngIf="signUpForm.hasError('notSame')" class="error">
              Password is not same
          </p>
          <p *ngIf="f.name.invalid && (f.name.touched || f.name.dirty) && f.name.errors.required" class="error">
              Name is required
          </p>
          <div class="button">
              <button type="submit" mat-raised-button [disabled]="!signUpForm.valid" color="primary">가입</button>
          </div>
        </form>
      </mat-card>
</div>

가입화면 스타일 작성

// signup.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;
}

가입 api 연동

서비스에 가입 api연동 부분을 추가합니다. 로그인 api와 형태는 유사하며 URL, 전달 파라미터, 예외처리 부분등을 수정해 줍니다.

// sign.service.ts
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) { }

  // 중간 코드 생략....

  // 가입 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);   
        return Promise.reject(response.error.msg);
      });
  }
}

가입 Component 작성

  • 가입시엔 이메일 체크시 Validators.email 대신 정규표현식을 사용해 보았습니다.
  • 패스워드 유효성 체크시 확인 패스워드와의 값 동일여부를 판단하기 위해 checkPassword 메서드를 구현하고 FormBuilder에 validator: this.checkPassword를 세팅해 주었습니다.
  • form 필드가 많은경우 form자체를 getter로 선언하여 html에서 필드에 쉽게 접근 가능하도록 처리합니다.
import { Component } from '@angular/core';
import { FormGroup, FormControl, Validators, FormBuilder } from '@angular/forms';
import { Router } from '@angular/router';
import { SignService } from 'src/app/service/rest-api/sign.service';

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

  signUpForm: FormGroup;

  constructor(
    private router: Router,
    private formBuilder: FormBuilder,
    private signService: SignService) {
    this.signUpForm = this.formBuilder.group({
      id: new FormControl('', Validators.compose([
        Validators.required,
        Validators.pattern('^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$')
      ])),
      password: new FormControl('', [Validators.required]),
      password_re: new FormControl('', [Validators.required]),
      name: new FormControl('', [Validators.required])
    }, {validator: this.checkPassword});
  }

  checkPassword(group: FormGroup) {
    let password = group.controls.password.value;
    let passwordRe = group.controls.password_re.value;
    return password === '' || passwordRe === '' || password === passwordRe ? null : { notSame : true }
  }

  // form field에 쉽게 접근하기 위해 getter 세팅
  get f() { return this.signUpForm.controls; }

  submit() {
    if(this.signUpForm.valid) {
      this.signService.signUp(this.signUpForm.value.id, this.signUpForm.value.password, this.signUpForm.value.name)
        .then( response => {
          // 가입 완료후 자동로그인
          this.signService.signIn(this.signUpForm.value.id, this.signUpForm.value.password)
          .then(response => {
            this.router.navigate(['/']);
          });
        });
    }
  }
}

라우터에 가입 path 추가

// app.routing.module.ts
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';

const routes: Routes = [
  {path: '', component: HomeComponent},
  {path: 'signin', component: SigninComponent},
  {path: 'signup', component: SignupComponent}
];

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

로그인 화면에 가입 버튼 추가

가입화면 버튼 리스트에 가입 버튼(SignUp)을 추가하고 클릭시 이동할 path를 설정합니다.

// 중간 코드 생략...
<div class="button">
  <button type="submit" mat-stroked-button [disabled]="!signInForm.valid">Login</button>
  <button mat-raised-button color="primary" routerLink="/signup">SignUp</button>
</div>

가입 테스트

로그인 화면에서 SignUp 버튼을 클릭하면 회원 가입화면으로 이동합니다.

회원 가입 화면에서 가입정보를 입력합니다. 가입정보의 유효성에 문제가 있을경우 빨간색으로 표시되며 가입 버튼이 비활성화 됩니다.

가입정보에 문제가 없을경우 오류 메시지가 사라지고 가입 버튼이 활성화 됩니다. 가입버튼을 누르면 가입이 완료되고 홈 화면으로 이동됩니다.

가입자 로그인 테스트

위에서 가입한 회원에 대한 로그인을 시도합니다. 비밀번호를 틀리게 입력할 경우 알림창이 뜨게 됩니다.

로그인 성공시 홈 화면으로 이동하고 로컬스토리지에 토큰정보가 저장되는것을 알 수 있습니다.

로그아웃 기능 추가

로그아웃의 경우 로컬 스토리지에 저장된 토큰 정보를 삭제하면 됩니다. 화면이 따로 존재하지 않으므로 logout.component.ts 파일만 놔두고 삭제합니다.

$ cd src/app/component
$ ng g c logout --spec false
Option "spec" is deprecated: Use "skipTests" instead.
CREATE src/app/component/logout/logout.component.css
CREATE src/app/component/logout/logout.component.html
CREATE src/app/component/logout/logout.component.ts
$ cd logout
$ rm logout.component.css logout.component.html

logout.component.ts 파일을 아래와 같이 작성합니다. html은 사용하지 않으므로 template=”처리하고 styleUrls는 없애도록 합니다.

import { Component } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'app-logout',
  template: ''
})
export class LogoutComponent {
  constructor(private router: Router) {
    localStorage.removeItem('x-auth-token');
    this.router.navigate(['/']);
  }
}

라우터에 로그아웃 path 추가

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

const routes: Routes = [
  {path: '', component: HomeComponent},
  {path: 'signin', component: SigninComponent},
  {path: 'signup', component: SignupComponent},
  {path: 'logout', component: LogoutComponent}
];

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

로그인 여부 확인 기능 추가 / 메뉴 수정

로그인 여부를 확인하는 메서드를 추가하고 메뉴에서 로그인 여부에 따라 보여주는 정보를 다르게 처리해 보겠습니다. 로그인 성공시 브라우저 로컬스토리지에 토큰정보가 남습니다. 정보가 있는경우 로그인으로 처리하는 메서드를 추가합니다.

// sign.service.ts
// 생략

// 로그인 여부 확인
isSignIn(): boolean {
  const token = localStorage.getItem('x-auth-token');
  if (token) {
    return true;
  } else {
    return false;
  }
}

app.component.ts에 signService를 추가.

import { Component } from '@angular/core';
import { SignService } from './service/rest-api/sign.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  constructor(
    private signService: SignService
  ) {}
}

로그인 여부에 따라 메뉴에 로그인/로그아웃 버튼을 노출시키는 로직을 추가합니다.
*ngIf=”signService.isSignIn()” – 로그인시 로그인 버튼 숨김, 내정보, 로그아웃 노출
*ngIf=”!signService.isSignIn()” – 로그아웃시 로그인 버튼 노출, 내정보, 로그아웃 비노출
로그아웃 버튼에 로그아웃 링크를 추가합니다. 로그아웃 링크 추가시 routerLink를 사용하지 않고 href를 사용하였는데 그 이유는 로그아웃시 메뉴 영역을 포함한 전체 페이지가 갱신되어야 하기 때문입니다.

 // 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()">
              <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>
                    <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>

로그인/로그아웃 테스트

로그인 및 로그아웃 상태시 메뉴에 노출되는 내용이 변경되는것을 확인 할 수 있습니다.

로그인시 메뉴 화면

로그아웃시 메뉴 화면

이번 실습을 통해 로그인, 가입 기능 개발이 완료되었습니다. 다음 시간부터는 로그인이 필수인 페이지를 작성하는 실습을 진행해보겠습니다.

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

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

연재글 이동[이전글] Spring Rest api + Angular framework로 웹사이트 만들기(1) – 프로젝트 구성 및 반응형 레이아웃 구현
[다음글] Spring Rest api + Angular framework로 웹사이트 만들기(3) – 내정보(Interceptor, Router Guard)