Reactive Forms Module 로 Login Form 만들기

소스코드

이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.

이 게시물의 소스코드는 기본사이트 만들기 / interfaces 생성 및 auth API 연결에서 이어집니다.

angular-site.git 을 clone 한 적이 있는 경우: 터미널에서 해당 폴더로 이동 후 아래 명령어들을 붙여넣기합니다. 폴더 내 모든 코드가 이 게시물의 코드로 교체됩니다. 이를 원치 않으시면 이 방법을 선택하지 마세요.

git reset --hard
git pull
git reset --hard bc53806
git reset --soft 7867d26
npm install
atom .

angular-site.git 을 clone 한 적이 없는 경우: 터미널에서 코드를 다운 받을 폴더로 이동한 후 아래 명령어들을 붙여넣기하여 angular-site.git 을 clone 합니다.

git clone https://github.com/a-mean-blogger/angular-site.git
cd angular-site
git reset --hard bc53806
git reset --soft 7867d26
npm install
atom .

- Github에서 소스코드 보기: https://github.com/a-mean-blogger/angular-site/tree/bc538061fed251bc1d00ce7e1c346ed06fc7dcd7


Angular 2/Tour of Heroes 에서 form을 사용할때 양방향 binding을 이용해서 input에 변수를 연결해 보았었습니다. 이번에는 한단계 더 나아가 form 전체를 한 변수(FormGroup 타입)에 연결하고 관리하는 방법에 대해 알아보겠습니다.

username과 password 두개의 input이 있는 로그인 form과 validation을 만들어 봅시다.

Reactive Forms Module

Reactive Forms Module을 사용하면 form을 객체화 시킬 수 있습니다.
html 의 form 태그는 FormGroup이라는 타입이에 연결되고, 각각의 form field 들은 FormControl 이라는타입가 되어 FormGroup 오브젝트안에 포함됩니다. 이렇게 form 태그와  form field 들이 객체화가 되면, FormGroup, FormControl 타입이 제공하는 함수를 통해서 form의 정보를 읽어오거나 조작할 수 있게 됩니다.

중요사항

Sign Up(회원가입) form 보다 Login form 이 더 단순하기 때문에 먼저 강의를 만들게 되었습니다. Login을 테스트하기 위해서는 가입된 회원이 있어야 하는데, Node JS API/API 테스트 프로그램 Postman 설치및 간단 사용법 강의와 Node JS API/JWT Jason Web Token 로 로그인 REST API 만들기 강의를 참고해서 테스트용 user를 먼저 생성하시기 바랍니다.

폴더 구조

주황색은 변경된 파일, 녹색은 새로 생성된 파일, 회색은 변화가 없는 파일입니다.

아래 명령어로 login component를 생성해 줍시다.

$ ng g component login --spec false

--spec false를 options으로 사용하면 .spec.ts를 생성하지 않습니다.

코드

 핵심인 login.component.ts를 바로 살펴보도록 합시다. 

// src/app/login/login.component.ts

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

import { ApiResponse } from '../api-response';

import { UtilService } from '../util.service';
import { AuthService } from '../auth.service';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
  redirectTo: string;
  errorResponse: ApiResponse;
  form: FormGroup; //1
  formErrors = { //2
    'username':'',
    'password':'',
  };
  formErrorMessages = { //3
    'username': {
      'required': 'Username is required!',
    },
    'password': {
      'required': 'Password is required!',
    },
  };
  buildForm(): void { //4
    this.form = this.formBuilder.group({ //4-1
      username:["", Validators.required],
      password:["", Validators.required],
    });

    this.form.valueChanges.subscribe(data => { //4-2
      this.utilService.updateFormErrors(this.form, this.formErrors, this.formErrorMessages);
    });
  };

  constructor(
    private router: Router,
    private formBuilder: FormBuilder,
    private route: ActivatedRoute,
    private utilService: UtilService,
    private authService: AuthService,
  ) {
    this.buildForm();
    this.redirectTo = this.route.snapshot.queryParamMap.get('redirectTo');
  }

  ngOnInit() {
  }

  submit() { //5
    this.utilService.makeFormDirtyAndUpdateErrors(this.form, this.formErrors, this.formErrorMessages); //5-1
    if(this.form.valid){
      this.authService.login(this.form.value.username, this.form.value.password) //5-2
      .then(data =>{
        this.router.navigate([this.redirectTo?this.redirectTo:'/']); //5-3
      })
      .catch(response =>{
        this.errorResponse = response;
        this.utilService.handleFormSubmitError(this.errorResponse, this.form, this.formErrors); //5-4
      });
    }
  }

}

1. form을 담는 FormGroup 타입 오브젝트입니다. 

2. form field들의 실제 에러메세지를 저장하는 오브젝트입니다. 이 오브젝트에 에러메세지가 있다면 html에 보여지게 됩니다.

3. form field의 에러메세지를 미리 설정해 놓는 부분입니다. username을 살펴보면, 'required'라는 에러가 있을때 'Username is required!'라는 메세지가 표시되게 하는 것입니다. 이 'required'라는 에러 이름은 validator 에서 정해진 이름입니다. 이처럼 validator에서 정해진 에러 이름을 사용하거나 특정한 이름으로 직접 에러를 만들 수도 있습니다.

4. form을 생성하는 함수입니다.
4-1. formBuilder.group함수를 사용해서 form group을 생성한 후 this.form에 넣습니다. 이때 그룹의 개별 맴버들을 "form control"이라고 부릅니다. 즉 이 코드에서는 username과 password 폼컨트롤을 form변수에 맴버로 추가하고 있습니다. username:["", Validators.required]를 살펴보면, Form_Control_이름:[초기값, Validator_타입] 의 형태입니다. 
4-2. this.form(FormGroup 타입)의 .valueChangethis.form에 값의 변화가 있는 경우 Observable을 생성합니다. 즉, form의 값변화가 감지되는 경우this.utilService.updateFormErrors(this.form, this.formErrors, this.formErrorMessages) 가 실행됩니다.
utilService.updateFormErrors는 this.form의 에러상태를 체크하여 this.formErrors에 this.formErrorMessages의 에러메세지를 업데이트하는 함수입니다. 나중에 util.service.ts의 코드에서 다시 살펴보겠습니다.

5. form이 submit되면 실행되는 함수입니다.
5-1. 먼저 this.utilService.makeFormDirtyAndUpdateErrors를 실행하여 form 에러메세지를 업데이트합니다.
5-2. login API를 호출합니다.
5-3. 로그인 성공시 redirect 할 곳이 있으면 redirect하고 아니면 '/'로 보냅니다.
5-4. 로그인 실패시 this.utilService.handleFormSubmitError를 실행하여 서버에러메세지를 업데이트합니다.

login.component.html을 보기 전에, 바로 위에 사용된 utilService의 함수들을 살펴보기 위해 util.service.ts를 먼저 보겠습니다.

// src/app/util.service.ts

import { environment } from '../environments/environment';
import { Injectable } from '@angular/core';
import { ApiResponse } from './api-response';
import { FormGroup } from '@angular/forms';

@Injectable()
export class UtilService {
  public checkSuccess(response: any): Promise<any> {
    if(response.success) return Promise.resolve(response);
    else return Promise.reject(response);
  }

  public handleApiError(error: any): Promise<any> {
    if(!environment.production) console.error('An error occurred', error);
    return Promise.reject(error);
  }

  public updateFormErrors(form: FormGroup, formErrors: any, formErrorMessages: any) { //1
    if (!form) { return; }

    for (const field in formErrors) {
      formErrors[field] = '';
      const control = form.get(field);
      if (control && control.dirty && !control.valid) {
        const messages = formErrorMessages[field];
        if(messages){
          for (const key in control.errors) {
            formErrors[field] += messages[key] + ' ';
          }
        }
      }
    }
  }

  public makeAllFormFieldsDirty(form: FormGroup) { //2
    if (!form) { return; }

    for (var field in form.controls) {
      const control = form.get(field);
      if(control) control.markAsDirty();
    }
  }

  public makeFormDirtyAndUpdateErrors(form: FormGroup, formErrors: any, formErrorMessages: any) { //3
    this.makeAllFormFieldsDirty(form);
    this.updateFormErrors(form, formErrors, formErrorMessages);
  }

  public handleFormSubmitError(response: ApiResponse, form: FormGroup, formErrors: any): void { //4
    if(response.errors){
      for (const field in formErrors) {
        const control = form.get(field);
        if (response.errors[field] && response.errors[field].message) {
          formErrors[field] += response.errors[field].message;
        }
      }
      if(response.errors.unhandled){
        response.message += response.errors.unhandled;
      }
    }
  }

}

1. form의 에러상태를 확인하고 form formErrorMessages에서 해당하는 에러를 가져와서 formErrors에 에러 메세지를 넣어주는 역할을 하는 함수입니다. 실제 로직은 formErrors의 항목에 해당하는 form의 control에 오류가 있는지를 확인하고 오류가 있다면 control의 오류목록에서 해당하는 에러메세지를 formErrorMessages에서 가져와 formErrors에 넣는 방식입니다.

2. form을 "dirty"하게 만듭니다. form 항목의 값이 변화되는 순간 from이 "dirty"하게 되는데, 항목값의 변화없이 "dirty"하게 만드는 함수입니다.

3. form을 submit하기 전에 form을 dirty하게 만들고 에러를 update하는 함수입니다.

4. 서버에러를 확인해서 forrmErrors에 에러 메세지를 넣거나, response.message를 수정하는 함수입니다. 이미 API가 에러를 이 함수에서 사용가능한 형태로 만들어져 있기 때문에 가능한 방법입니다.

 계속해서 login component들을 살펴봅시다.

<!-- src/app/login/login.component.html -->

<div class="page page-login">
  <form
    [formGroup]="form" 
    (ngSubmit)="submit()"
    class="login-form form-horizontal"
  >  <!-- 1 -->
    <div class="contentBox">
      <h3 class="contentBoxTop">Login</h3>
      <fieldset>
        <div class="form-group" [ngClass]="{'has-error': formErrors.username}"> <!-- 2 -->
          <label for="username" class="col-sm-3 control-label">Username</label>
          <div class="col-sm-9">
            <input class="form-control" type="text" formControlName="username" id="username" name="username" value=""> <!-- 3 -->
            <span *ngIf="formErrors.username" class="help-block">{{formErrors.username}}</span>
          </div>
        </div>
        <div class="form-group" [ngClass]="{'has-error': formErrors.password}">
          <label for="password" class="col-sm-3 control-label">Password</label>
          <div class="col-sm-9">
            <input class="form-control" type="password" formControlName="password" id="password" name="password" value=""> <!-- 3 -->
            <span *ngIf="formErrors.password" class="help-block">{{formErrors.password}}</span>
          </div>
        </div>
      </fieldset>
      <div *ngIf="errorResponse?.message" class="alert alert-danger">
        {{errorResponse?.message}}
      </div>
    </div>
    <div class="buttons">
      <input class="btn btn-default" type="submit" value="Submit">
    </div>
  </form>
</div>

 1.  form 태그에 [formGroup] 항목을 사용해서 login.component.ts의 form (FormGroup 타입 변수)와 binding해줍니다. (ngSubmit) 항목에는 submit 이벤트시 실행될 코드가 들어갑니다.

2. [ngClass]는 Angular에서 html class 항목을 컨트롤하는  항목입니다. string타입의 변수를 입력하면 해당 문자열을 class로 사용하고, 해당 변수의 문자열이 변경되면 해당 class 역시 바뀌게 됩니다. 위에서는 {'has-error': formErrors.password}로 문자열이 아닌 오브젝트를 사용했는데요, {문자열1: 변수1, 문자열2: 변수2 ...}의 형태로 해당 변수가 참이면 해당 문자열을 class로 사용합니다.

3. input 태그에 formControlName 항목을 사용해서 login.component.ts의 form에 생성한 from control와 binding해줍니다.

나머지 코드는 특별한 것이 없으니 설명을 생략합니다.

사이트에 로그인 기능이 생겼으니까, 로그인 유무에 따라 메뉴를 보이게 또는 안보이게 해봅시다.

<!-- src/app/app.component.html -->

<!-- ... -->
          <ul class="nav navbar-nav navbar-right">
            <li *ngIf="!authService.isLoggedIn()" [routerLinkActive]="['active']"> <!-- 1 -->
              <a [routerLink]="['/','login']">Login</a>
            </li>
            <li *ngIf="!authService.isLoggedIn()" [routerLinkActive]="['active']"> <!-- 1 -->
              <a [routerLink]="['/','users','new']">Sign Up</a>
            </li>
            <li *ngIf="authService.isLoggedIn()">  <!-- 1 -->
              <a (click)="authService.logout()">Logout</a>
            </li>
          </ul>
<!-- ... -->

1. authService.isLoggedIn()를 사용해서 로그인이 되었는지 아닌지를 알 수 있고, 이를 사용해서 Login과 Sign Up은 로그인이 되지 않은 경우에만, Logout은 로그인이 된 경우에만 보이도록 합시다.

새로 생성된 login 컴포넌트를 route에 추가해 줍시다

// src/app/app-routing.module.ts

// ...
import { LoginComponent } from './login/login.component'; //*

const routes: Routes = [
  { path: '',  component: WelcomeComponent },
  { path: 'login', component: LoginComponent }, //*
  { path: '**', component: Error404Component },
];

// ...

* 모든 path들은 반드시 '**' 위에 위치하여야합니다.

 

// src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; //*
import { HttpClientModule } from '@angular/common/http';

import { AppRoutingModule }    from './app-routing.module';

import { UtilService } from './util.service';
import { AuthService } from './auth.service';

import { AppComponent } from './app.component';
import { WelcomeComponent } from './welcome/welcome.component';
import { Error404Component } from './error404/error404.component';
import { LoginComponent } from './login/login.component'; //*

@NgModule({
  declarations: [
    AppComponent,
    WelcomeComponent,
    Error404Component,
    LoginComponent, //*
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    FormsModule, //*
    ReactiveFormsModule, //*
  ],
  providers: [
    UtilService,
    AuthService,
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

* form에 관련된 module들과 login component를 추가해줍니다.

src/app/styles.css에는 아래의 코드를 추가해 줍니다. 따로 설명은 하지 않겠습니다.

.page form label {
  padding: 3px;
  margin-bottom: 0;
  font-weight: 300;
}
.page form fieldset {
  margin: 7px 0 1px;
  padding: 0px 15px;
}
.page form .form-control {
  padding: 3px 7px;
  font-size: inherit;
  line-height: 20px;
  height: auto;
  border: 1px solid #ccc;
  border-radius: 3px;
  width: 100%;
}
.page form .form-group {
  margin: 0;
  padding-bottom: 6px;
}
.page .form-horizontal .control-label {
  padding-top: 5px;
  text-align: left;
}
.page .contentBox {
  border-top: 1px solid #ccc;
  border-bottom: 1px solid #ccc;
}
.page .contentBoxTop {
  font-size: 14px;
  font-weight: 600;
  margin: 0;
  border-bottom: 1px solid #ccc;
  background-color: #F5F5F5;
  padding: 6px 15px;
}
.page .buttons {
  margin: 7px 0;
  padding: 0 5px;
}
.page .buttons form {
  display: inline-block;
}
.page .buttons .btn-default {
  min-width: 55px;
  color: #165751;
  border: 1px solid #ccc;
  border-right: 1px solid #aaa;
  border-bottom: 1px solid #aaa;
  border-radius: 4px;
  padding: 5px 10px;
  background-color: #f5f5f5;
  font-size: 0.8em;
  font-weight: bold;
}
.page .buttons .btn-default:hover{
  text-decoration: none;
  position: relative;
  top: 1px;
  left: 1px;
  border: 1px solid #ccc;
  border-top: 1px solid #aaa;
  border-left: 1px solid #aaa;
}
.page-login {
  max-width: 330px;
}

실행 결과

Angular-CLI 명령어 사용해서 서버를 실행합시다. 

ng serve --open

현재 로그인되어 있지 않으므로 Logout 메뉴는 더이상 표시되지 않습니다. "Login" 메뉴를 눌러 로그인페이지로 이동합시다.

username과 password를 입력하고 submit을 누릅니다.

우측상단에 Logout 메뉴가 보이면 로그인이 성공한 것입니다. 로그아웃을 하고 다시 로그인으로 돌아가 form validation을 테스트해 봅시다.

form이 비어있는 상태에서 submit을 누르면 에러가 표시됩니다.

잘못된 정보를 입력하고 submit한 경우입니다.

마치며..

auth.service.ts를 보면 로그인이 될때 token을 localStorage에 'token'으로 저장하고 로그인 유무는 localStorage에 'token'이 있는지 없는지로 결정됩니다. 하지만 로그인된 상태에서 브라우저를 새로고침하면 아래와 같이 에러가 뜨고 로그아웃이 됩니다.


이는 현재 AppComponent의 constructor에 authService.refresh가 있어 사이트 시작시 token refresh API가 호출되는데, 이때 로그인 API 스펙에서 요구되는 x-access-token header가 전달되지 않기 때문입니다. 해당 코드를 수정하여 해결할 수도 있겠지만 우리는 다음 강의를 통해서 모든 REST API call에 자동으로 token을 넣는 방법에 대해 알아보겠습니다.

댓글

J
JeongHwan Kim 2018.03.17
login.component.html에 대한 설명이 없는데, 일부로 이번 강의에 안넣어주신건가욤??
I
Ian H 2018.03.19
@JeongHwan Kim,
login.component.html가 빠져있었네요. 추가하였습니다! 감사합니다
댓글쓰기

이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기

UP