Tour of Heroes - HTTP

소스코드

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

이 게시물의 소스코드는 Tour of Heroes / Tour of Heroes - Routing (routerLink)에서 이어집니다.

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

git reset --hard
git pull
git reset --hard f86a01b
git reset --soft 16242b1
npm install
atom .

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

git clone https://github.com/a-mean-blogger/tour-of-heroes.git
cd tour-of-heroes
git reset --hard f86a01b
git reset --soft 16242b1
npm install
atom .

- Github에서 소스코드 보기: https://github.com/a-mean-blogger/tour-of-heroes/tree/f86a01bd302c1cd6b0de069330e8f55eec0212a7


이번 강의에서는 Tour of Heroes 사이트에 사용될 가짜 web API를 만들고 이 API를 사용해서 hero의 CRUD 기능을 모두 완성합니다. 가짜 API라고 하는 이유는 이 API들은 Tour of Heroes 사이트 내에서만 사용할 수 있기 때문입니다.

실제로 존재하지 않는 API를, Angular 사이트가 해당 API들이 존재하는 것처럼 행동하게 하는 것인데, Angular In Memory Web API라는 package를 사용해서 이 가짜 API를 구현하게 됩니다.

Angular In Memory Web API로 생성된 API들은 가짜이긴 하지만, 나중에 실제 API로 전환시에 service에서 API 주소만 바꿔주면 되기 때문에 실제 API가 없는 Tour of Heroes 강의에 사용한 것으로 보입니다. 하지만 'Angular In Memory Web API'을 세팅하는 코드는 실전에서 쓸 일이 없기 때문에 중요하게 볼 필요는 없고, web API를 사용하는 코드(hero.service.ts)를 중점적으로 살펴봅니다.

이번 tutorial에는 hero 검색 기능도 추가됩니다. 사실 이 부분은 입문 tutorial이라기 보다는 advance 강의라고 생각이 됩니다.. 한글자 한글자 입력할때마다 검색창 아래에 검색어 제안이 뜨는 기능을 가지고 있는데, 키입력이 있을 때 마다 검색을 하는 것이 아니라, 키입력 -> 0.3초 기다림 -> 만약 다음 키입력이 0.3밀리초 이내에 이루어 졌을 경우, 검색 API를 호출하지 않고 다시 0.3초 기다림 -> 0.3초간 키입력이 없는 경우 검색 API 호출 하는 방식으로 작동합니다.

폴더 구조

아래 명령어로 in memory data service와 hero search component 파일들을 생성해 줍니다.

$ ng g s in-memory-data --skipTests
$ ng g c hero-search --skipTests

Package 설치

아래 명령어를 입력하여 Angular In Memory Web API package를 설치합니다.

$ npm install angular-in-memory-web-api --save

코드 - In Memory Data Service

// src/app/in-memory-data.service.ts

import { InMemoryDbService } from 'angular-in-memory-web-api';
import { Hero } from './hero';
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class InMemoryDataService implements InMemoryDbService {
  createDb() {
    const heroes = [
      { id: 11, name: 'Dr Nice' },
      { id: 12, name: 'Narco' },
      { id: 13, name: 'Bombasto' },
      { id: 14, name: 'Celeritas' },
      { id: 15, name: 'Magneta' },
      { id: 16, name: 'RubberMan' },
      { id: 17, name: 'Dynama' },
      { id: 18, name: 'Dr IQ' },
      { id: 19, name: 'Magma' },
      { id: 20, name: 'Tornado' }
    ];
    return {heroes};
  }

  genId(heroes: Hero[]): number {
    return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;
  }
}

이 파일은 현재 API가 없는 Tour of Heroes 튜토리얼에 가짜 API를 생성하기 코드로 내용을 공부할 필요는 없습니다. 다만 위 코드를 통해 어떠한 API들이 생성되는지를 살펴봅시다.

7 Standard Actions 중 form 생성과 관련이 없는 5가지 action들(index, show, create, update, destroy)이 생성되고 data를 검색할 수 있는 API도 생성됩니다. 이 API들을 표로 나타내면 다음과 같습니다.

기능http verbsroute
indexgetapi/heroes
showgetapi/heroes/:id
createpostapi/heroes
updateputapi/heroes/:id
destroydeleteapi/heroes/:id
검색getapi/heroes/?name=

heroes라는 이름으로 data를 생성하였기 때문에 route에 heroes가 들어갑니다.

다시 한번 강조하지만, angular-in-memory-web-api package와 예제 코드를 사용했기 때문에 위 6가지의 API를 tour of heroes 예제에서 사용할 수 있는 것으로, 실전에서는 위 6개의 API들을 분리된 프로젝트로 만들고, 해당 API들이 DB에 접근해 데이터를 조작하게 됩니다. Angular 사이트 자체는 front-end만 담당하며 DB에 직접적으로 접근할 수 없습니다.

코드 - App Module

// src/app/app.module.ts

...
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http'; // 1
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api'; // 2-1

...
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
import { HeroSearchComponent } from './hero-search/hero-search.component'; // 3
import { MessagesComponent } from './messages/messages.component';
import { InMemoryDataService } from './in-memory-data.service'; // 2

@NgModule({
  declarations: [
    ...
    MessagesComponent,
    HeroSearchComponent, // 3
  ],
  imports: [
    ...
    AppRoutingModule,
    HttpClientModule, // 1
    HttpClientInMemoryWebApiModule.forRoot( // 2-2
      InMemoryDataService, { dataEncapsulation: false }
    ),
  ],
  ...

1. web API를 사용하기 위해 필요한 module을 imports에 넣는 코드입니다.

2-1, 2-2. In memory data service를 위한 부분으로 이부분은 실전에서 필요하진 않습니다. 반대로 이 부분들과 바로 위의 angular-in-memory-web-api를 제외한 나머지 모두는 실전에서 필요합니다.

3. search component를 추가되었습니다.

코드 - Hero Service

hero.service.ts는 코드가 많이 길어서 3 파트로 나누어서 살펴보겠습니다.

// src/app/hero.service.ts <Part 1/3>

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http'; // 1
import { Observable, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; // 2

import { Hero } from './hero';
import { MessageService } from './message.service';

@Injectable({
  providedIn: 'root',
})
export class HeroService {
  private heroesUrl = 'api/heroes'; // 3

  httpOptions = { // 4
    headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  };

  constructor(
    private http: HttpClient, // 5
    private messageService: MessageService,
  ) { }

  ...//continue to <part 2/3>

1. HttpClient는 API를 호출하기 위한 service class이고, HttpHeaders는 http request의 header schema를 담고 있는 타입 class입니다.

2. Observable.pipe함수에서 사용될 함수들을 'rxjs/operators'에서 가져오고 있습니다. 이어지는 코드에서 Observable.pipe함수와 catchError함수, tap함수가 어떻게 사용되는지를 알아보겠습니다.

3. heroes API의 url base입니다. hero service의 API의 url들은 모두 'api/heroes'로 시작하기 때문에 반복을 피하기 위해서 따로 hero service class의 항목(property)으로 만들었습니다.

4. POST, PUT API의 경우 'Content-Type': 'application/json'으로 설정하여 API에 JSON type 데이터를 전달한다는 것을 알리는 header가 필요한데, 이 header가 들어간 옵션 object가 몇몇 API에 사용되므로 마찬가지로 반복을 피하기 위해서 따로 항목으로 만들었습니다.

5. HttpClient service를 사용하기 위해 constructor에 인자로 넣습니다.

// src/app/hero.service.ts <Part 2/3>

  ...//continued from <part 1/3>

  getHeroes(): Observable<Hero[]> { // 6
    return this.http.get<Hero[]>(this.heroesUrl)
      .pipe(
        tap(_ => this.log('fetched heroes')),
        catchError(this.handleError<Hero[]>('getHeroes', []))
      );
  }

  getHero(id: number): Observable<Hero> { // 7
    const url = `${this.heroesUrl}/${id}`;
    return this.http.get<Hero>(url).pipe(
      tap(_ => this.log(`fetched hero id=${id}`)),
      catchError(this.handleError<Hero>(`getHero id=${id}`))
    );
  }

  searchHeroes(term: string): Observable<Hero[]> { // 8
    if (!term.trim()) {
      return of([]);
    }
    return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(
      tap(_ => this.log(`found heroes matching "${term}"`)),
      catchError(this.handleError<Hero[]>('searchHeroes', []))
    );
  }

  addHero (hero: Hero): Observable<Hero> { // 9
    return this.http.post<Hero>(this.heroesUrl, hero, this.httpOptions).pipe(
      tap((newHero: Hero) => this.log(`added hero w/ id=${newHero.id}`)),
      catchError(this.handleError<Hero>('addHero'))
    );
  }
  
  ...//continue to <part 3/3>

6. getHeroes함수가 더이상 mock data를 사용하지 않고 API로부터 hero list를 받아오도록 코드가 수정되었습니다.

HttpClient_service.get<타입>(API_주소옵션)의 형태로 Angular에서 GET API를 호출할 수 있습니다. 타입은 API가 return하는 데이터의 타입을 적습니다. 현재 코드에서 옵션은 생략되었습니다.

Observable.pipe함수는 데이터를 받은 후, subscribe에 전달하기 전 해당 데이터나 현재 observable을 조작할 때 사용됩니다. 함수의 인자로는 'rxjs/operators'의 함수들이 들어갈 수 있습니다.

tap함수는 observable의 데이터를 확인하고 사용하는 용도로 사용됩니다. 현재 코드에서는 화살표 함수에서 아예 인자를 "_"(없음)으로 하여 어떠한 데이터가 오든 신경쓰지 않고 this.log함수를 호출하는 용도로만 쓰이는데, "_"대신에 인자를 넣어서 API가 return한 값을 확인하고 사용할 수 있습니다.

catchError함수는 이전의 pipe나 original observable에 오류가 있는 경우 실행됩니다. try-catch에서 catch를 담당한다고 생각하시면 됩니다. 다만, catchError함수 이전의 observable에서 발생한 error에만 작동합니다.

7. getHero함수 역시 5번과 유사하게 코드가 업데이트 되었습니다.

8. searchHeroes함수는 hero의 name의 일부분을 받아서 해당 이름이 포함되는 모든 hero들을 return하는 [GET] api/heroes?name=검색어 API를 호출합니다.

9. addHero함수는 hero를 전달받아 생성하는 [POST]api/heroes API를 호출합니다.

HttpClient_service.post<타입>(API_주소데이터옵션)의 형태로 Angular에서 POST API를 호출할 수 있습니다. 마찬가지로 타입은 API가 호출된 이후에 return하는 데이터의 타입을 적습니다. 데이터는 hero 타입 class의 object가 사용되고, 4번에서 만든 옵션을 전달합니다.

// src/app/hero.service.ts <Part 3/3>

  ...//continued from <part 2/3>

  deleteHero (hero: Hero | number): Observable<Hero> { // 10
    const id = typeof hero === 'number' ? hero : hero.id;
    const url = `${this.heroesUrl}/${id}`;

    return this.http.delete<Hero>(url, this.httpOptions).pipe(
      tap(_ => this.log(`deleted hero id=${id}`)),
      catchError(this.handleError<Hero>('deleteHero'))
    );
  }

  updateHero (hero: Hero): Observable<any> { // 11
    const url = `${this.heroesUrl}/${hero.id}`;

    return this.http.put(url, hero, this.httpOptions).pipe(
      tap(_ => this.log(`updated hero id=${hero.id}`)),
      catchError(this.handleError<any>('updateHero'))
    );
  }

  private handleError<T> (operation = 'operation', result?: T) { // 12
    return (error: any): Observable<T> => {
      console.error(error);
      this.log(`${operation} failed: ${error.message}`);
      return of(result as T);
    };
  }

  private log(message: string) { // 13
    this.messageService.add(`HeroService: ${message}`);
  }
}

10. deleteHero함수는 hero id를 전달받아 삭제하는 [DELETE]api/heroes/Hero_id API를 호출합니다.

함수자체는 hero라는 인자를 하나 받는데, 타입은 Hero 혹은 number입니다. 이처럼 |를 사용해서 하나 이상의 타입을 줄수도 있습니다. 당연히 해당 코드안에서 각각의 타입이 오는 경우를 대응할 수 있게 코드를 만들어야 합니다.

HttpClient_service.delete<타입>(API_주소옵션)의 형태로 DELETE API를 호출합니다.

11. updateHero함수는 hero id를 전달받아 수정하는 [PUT]api/heroes/Hero_id API를 호출합니다.

HttpClient_service.put<타입>(API_주소데이터옵션)의 형태로 PUT API를 호출합니다.

12. heroes API의 error를 처리하는 함수입니다. console에 error를 출력하고, this.log함수를 호출하고, 전달받은 result를 of함수를 사용해서 return합니다.

13. log함수는 message service를 이용해 메세지를 추가합니다.

코드 - Hero Search Component

Hero search component는 hero 검색을 위한 form을 가지는 component입니다. 검색창이 하나 표시되고, 거기에 텍스트를 입력하면 heroes service의 searchHeroes함수를 이용해 hero들의 리스트를 생성하고 여기서 클릭된 hero의 페이지로 이동하는 일을 합니다.

이때 텍스트가 입력되고 0.3초 후에 자동으로 heroes service의 searchHeroes함수를 호출하는데, 0.3초 안에 텍스트가 계속입력되면 다시 0.3초간 기간이 연장됩니다. 이 부분을 구현하기 위해 코드가 좀 어려워 지는데요, 사실 이 내용은 Angular 입문 tutorial으로는 좀 과한 내용이라고 생각됩니다. 해당 부분은 건너 뛰셔도 괜찮습니다.

이번에는 html 코드부터 살펴보겠습니다.

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

<div id="search-component">
  <h4><label for="search-box">Hero Search</label></h4>

  <input #searchBox id="search-box" (input)="search(searchBox.value)" /> <!-- 1 -->

  <ul class="search-result">
    <li *ngFor="let hero of heroes$ | async" > <!-- 2 -->
      <a routerLink="/detail/{{hero.id}}">
        {{hero.name}}
      </a>
    </li>
  </ul>
</div>

1. input 폼이 있고, "input" 이벤트가 있으면 search함수에 searchBox의 value를 넣어서 호출합니다. #searchBox에 의해 이 input element자체가 searchBox 오브젝트가 되기 때문에 이 input에 들어간 값은 searchBox.value로 사용할 수 있습니다.

2. heroes 배열로 *ngFor를 사용하고 있는데, | async 파이프가 붙어 있습니다. | async는 observable에 붙어서 observable의 subscribe으로 보내지는 데이터에 바로 접근할 수 있게 해줍니다.

heroes$는 observable로 이 자체는 사용할만한 데이터를 가지고 있지 않습니다. heroes$ | asyncheroes$.subscribe(data => { ... })data에 접근할 수 있게 해줍니다.

// src/app/hero-search/hero-search.component.ts

import { Component, OnInit } from '@angular/core';
import { Observable, Subject } from 'rxjs'; // 1-1

import {
   debounceTime, distinctUntilChanged, switchMap // 2
 } from 'rxjs/operators';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
  selector: 'app-hero-search',
  templateUrl: './hero-search.component.html',
  styleUrls: [ './hero-search.component.css' ]
})
export class HeroSearchComponent implements OnInit {
  heroes$: Observable<Hero[]>;
  private searchTerms = new Subject<string>(); // 1-2

  constructor(private heroService: HeroService) {}

  // Push a search term into the observable stream.
  search(term: string): void {
    this.searchTerms.next(term); // 1-3
  }

  ngOnInit(): void {
    this.heroes$ = this.searchTerms.pipe(
      // wait 300ms after each keystroke before considering the term
      debounceTime(300), // 2-1

      // ignore new term if same as previous term
      distinctUntilChanged(), // 2-2

      // switch to new search observable each time the term changes
      switchMap((term: string) => this.heroService.searchHeroes(term)), // 2-3
    );
  }
}

1-1.Subject는 observable을 생성할 수 있는 class입니다.

1-2.new Subject<타입>()로 subject를 생성하면, 이 subject는 observable을 생성할 때 해당 타입의 데이터를 전달합니다.

1-3. Subject.next함수로 observable를 해당 데이터로 생성할 수 있습니다. search함수에 전달받은term인자로 observable을 생성합니다.

2. Observable.pipe함수에 사용될 함수들입니다.

2-1. observable이 발생하면 0.3초간 기다리고, 만약 다른 observable이 발생하면 이전 observable을 취소시킵니다.

2-2. 만약 새로 생성된 observable이 이전에 실행된 observable과 같다면 무시합니다.

2-3. 2-1번과 2-2번이 모두 통과된 경우 현재 observable을 this.heroService.searchHeroes observable과 교체합니다.

위 내용이 이해가 안되셔도 괜찮습니다. 입문 tutorial의 수준을 넘어서는 코드이니까요. 단순하게

  • 키입력이 있음
  • 0.3초간 기다림. 새 키입력이 없다면 다음으로 진행
  • 이미 실행된 observable과 입력된 키값이 같지 않다면 다음으로 진행
  • hero service의 searchHero함수로 hero 검색

의 과정을 구현하기 위한 코드 정도로만 생각하고 넘어가셔도 됩니다.

/* src/app/hero-search/hero-search.component.css */

.search-result li {
  border-bottom: 1px solid gray;
  border-left: 1px solid gray;
  border-right: 1px solid gray;
  width: 195px;
  height: 16px;
  padding: 5px;
  background-color: white;
  cursor: pointer;
  list-style-type: none;
}

.search-result li:hover {
  background-color: #607D8B;
}

.search-result li a {
  color: #888;
  display: block;
  text-decoration: none;
}

.search-result li a:hover {
  color: white;
}
.search-result li a:active {
  color: white;
}
#search-box {
  width: 200px;
  height: 20px;
}


ul.search-result {
  margin-top: 0;
  padding-left: 0;
}

코드 - Dashboard Component

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

<h3>Top Heroes</h3>
<div class="grid grid-pad">
  <a *ngFor="let hero of heroes" class="col-1-4"
      routerLink="/detail/{{hero.id}}">
    <div class="module hero">
      <h4>{{hero.name}}</h4>
    </div>
  </a>
</div>

<app-hero-search></app-hero-search> <!-- 1 -->

1. hero search component가 배치되었습니다.

코드 - Hero Detail Component

// src/app/hero-detail/hero-detail.component.ts

  ...

  goBack(): void {
    this.location.back();
  }

 save(): void { // 1
    this.heroService.updateHero(this.hero)
      .subscribe(() => this.goBack());
  }
}

1. hero service의 updateHero함수를 사용해서 hero를 update하는 save함수가 추가되었습니다.

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

  ...

  <button (click)="goBack()">go back</button>
  <button (click)="save()">save</button>  <!-- 1 -->
  
</div>

1. save버튼이 추가되었습니다.

코드 - Heroes Component

// src/app/heroes/heroes.component.ts

  ...

  getHeroes(): void {
    this.heroService.getHeroes()
      .subscribe(heroes => this.heroes = heroes);
  }

  add(name: string): void { // 1
    name = name.trim();
    if (!name) { return; }
    this.heroService.addHero({ name } as Hero)
      .subscribe(hero => {
        this.heroes.push(hero);
      });
  }

  delete(hero: Hero): void { // 2
    this.heroes = this.heroes.filter(h => h !== hero);
    this.heroService.deleteHero(hero).subscribe();
  }

}

1. hero service의 addHero함수를 사용해서 hero를 추가하는 add함수가 추가되었습니다.

2. hero service의 deleteHero함수를 사용해서 hero를 삭제하는 delete함수가 추가되었습니다.

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

<h2>My Heroes</h2>

<div> <!-- 1 -->
  <label>Hero name:
    <input #heroName />
  </label>
  <!-- (click) passes input value to add() and then clears the input -->
  <button (click)="add(heroName.value); heroName.value=''">
    add
  </button>
</div>

<ul class="heroes">
  <li *ngFor="let hero of heroes">
    <a routerLink="/detail/{{hero.id}}">
      <span class="badge">{{hero.id}}</span> {{hero.name}}
    </a>
    <button class="delete" title="delete hero"
      (click)="delete(hero)">x</button> <!-- 2 -->
  </li>
</ul>

1. 새 hero를 추가할 수 있는 form입니다.

2. hero를 삭제할 수 있는 x 버튼이 추가되었습니다.

실행 결과

검색창입니다. 'c'를 입력했을 때 'c'가 이름에 포함된 모든 hero들이 표시됩니다.

검색창외의 추가된 기능들, hero의 추가, 삭제, 수정 기능들도 모두 테스트 해봅시다.

마치며..

이번 강의에서는

  • HttpClientModule을 사용하여 http request보내는 법
  • Observable.pipe을 사용하여 observable의 데이터를 조작하는 법
  • Observable.pipe에 사용되는 catchError함수와 tap함수
  • async pipe

에 대해 알아봤습니다!

사실 tour of heroes 강의 시리즈는 제가 직접 만든 코드가 아니다 보니 제 강의 설명방식과 맞지 않는 부분이 많아서 강의 글을 작성하기가 힘들었어요ㅠ 만드는 사람이 재밌게 만들지 못하니 강의를 보는 사람들도 재밌게 읽을 수 없었을 것이라 생각됩니다. 그럼에도 끝까지 읽어주신 분들께 감사의 말씀을 전합니다. 감사합니다!

댓글

댓글쓰기

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

UP