Routing(router-outlet, routerLink, ActivatedRoute, Observable)

소스코드

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

이 게시물의 소스코드는 Tour of Heroes / Services(@Injectable, OnInit, ngOnInit)에서 이어집니다.

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

git reset --hard
git pull
git reset --hard 548f57d
git reset --soft 0c706ee
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 548f57d
git reset --soft 0c706ee
npm install
atom .

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


Angular사이트의 공식 tutorial인 Tour of Heroes의 다섯번째 강의, Routing입니다.

공식 tutorial link : https://angular.io/docs/ts/latest/tutorial/toh-pt5.html
온라인 예제 : https://angular.io/generated/live-examples/toh-pt5/eplnkr.html

우선 강의에 들어가기 앞서 이번 포스트에서 뭘 만들건지 페이지들의 관계도를 보며 설명하겠습니다.
이미지 출처(https://angular.io/docs/ts/latest/tutorial/toh-pt5.html)

  • 페이지 종류는 총 3개로, 왼쪽 위의 dashboard 페이지, 왼쪽 아래의 hero detail 페이지 그리고 오른쪽의 heroes 페이지입니다.
  • 모든 페이지에는 상단에 메뉴가 있어서 dashboard 와 heroes 페이지로 이동할 수 있습니다.
  • dashboard 페이지는 4명의 hero를 보여주며, hero를 클릭하면 hero detail 페이지로 이동합니다.
  • heroes 페이지는 모든 hero를 보여주며, hero를 클릭하면 아래에 view details 버튼이 생기고 이 버튼을 클릭하면 hero detail 페이지로 이동합니다.
  • hero detail 에서는 hero 이름을 수정할 수 있고, back버튼을 클릭하면 이전 페이지로 돌아갑니다.

우리가 만들 route은 다음과 같습니다.

  • '/' - /dashboard로 redirect합니다.
  • '/dashboard' - dashboard 페이지로 이동합니다.
  • '/heroes' - heroes 페이지로 이동합니다.
  • '/detail/:id' - hero detail 페이지로 이동합니다. :id에서 콜론(:)은 parameter를 뜻합니다. :id자리에 들어오는 값으로 보여줄 hero를 선택합니다.

Route과는 별개로 이번 강의부터 ts파일에서 html과 css를 분리합니다.

이번강의에서 처음 등장하게 될 Observable에 대해 알아봅시다.

Observable

Observable은 Angular에서 사용되는 객체로(자바스크립트 객체가 아닙니다) 일반적으로 Observable을_리턴하는_함수.subscribe(Observable을_처리하는_함수) 의 형태로 사용됩니다. 
Observable을_리턴하는_함수에 의해 정의된 특정조건이 갖춰지면 Observable 생성되고  .subscribe를 통해 Observable을_처리하는_함수로 전달되어 함수가 실행됩니다.

이벤트.subscribe(이벤트의_처리) 로 생각하시면 이해가 쉬울텐데, Promise를 더 발전시킨 개념으로 Promise와 유사한 점이 많습니다.

실제 코드에서 다시 설명하겠습니다.

폴더구조

이쯤에서 한번 짚고 넘어가는 각 파일들의 역할

  • app-routing.module.ts - route 정보를 담고 있는 module. app.module에 import 됩니다.
  • app.component.ts, .css - 사이트의 상단메뉴와 route에 의해 바뀌는 부분의 위치를 담고 있는 component.
  • app.module.ts - 사이트의 메인 module.
  • dashboard.component.ts, .html, .css - dashboard 페이지 component
  • hero-detail.component.ts, .html, .css - hero detail 페이지 component
  • hero.service.ts - hero 데이터 service
  • hero.ts - hero type class
  • heroes.component.ts, .html, .css - heroes 페이지 component
  • mock-heroes.ts - heroes 임시 테스트 데이터 저장 파일

코드

CSS 코드는 설명하지 않습니다. 소스코드에서 복사하여 붙여넣기 해주세요.

먼저 app-routing.module.ts부터 살펴봅시다.

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

import { NgModule }             from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

import { DashboardComponent }   from './dashboard.component';
import { HeroesComponent }      from './heroes.component';
import { HeroDetailComponent }  from './hero-detail.component';

const routes: Routes = [ //2-1
  { path: '', redirectTo: '/dashboard', pathMatch: 'full' }, //2-2
  { path: 'dashboard',  component: DashboardComponent }, //2-3
  { path: 'detail/:id', component: HeroDetailComponent },
  { path: 'heroes',     component: HeroesComponent }
];

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

1. AppRoutingModule class를 export하는데 NgModule decorator(@NgModule)가 붙어있는 것으로 봐서 이 class가 NgModule임을 알 수 있습니다.

2-1. Routes 타입인 const routes에 route을 정의해 줍니다.

2-2.path에는 경로를 입력하고, redirect인 경우에는redirectTo에 redirect할 경로를 입력합니다.pathMatch는 redirect인 경우에 필요한 항목입니다. full을 입력해 줍니다.

2-3.path에는 경로를 입력하고component에 보여줄 page의 component를 입력하면 해당 경로에서 해당 component를 보여주게 됩니다.

Angular에서 route은 현재 component의 template에서 <router-outlet></router-outlet>를 설정된 component로 교체하는 방식입니다. 즉 현재 component는 AppComponent이므로 여기에 <router-outlet></router-outlet>를 넣어주어야 합니다.

app.component.ts를 살펴봅시다.

// src/app/app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  template: ` //1
    <h1>{{title}}</h1>
    <nav>
      <a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
      <a routerLink="/heroes" routerLinkActive="active">Heroes</a>
    </nav>
    <router-outlet></router-outlet>
  `,
  styleUrls: ['./app.component.css'], //2
})
export class AppComponent {
  title = 'Tour of Heroes';
}

1. 사이트 제목, 상단메뉴와 <router-outlet></router-outlet>가 있는 것을 볼 수 있습니다. a tag에 href가 아니라routerLink에 이동할 route를 적어줍니다.routerLinkActive는 해당 route로 이동한 경우 해당 a 의 class에 값을 추가하는 역할을 합니다. 현재 "active"가 들어 있으므로 해당 route로 이동하면 a 의 class에 active가 추가됩니다.

2. css 코드를 분리하기 위해 styles대신에 styleUrls가 사용되었습니다. css파일의 위치를 적어주면 해당 css파일이 호출됩니다.

// src/app/app.module.ts

import { NgModule }       from '@angular/core';
import { BrowserModule }  from '@angular/platform-browser';
import { FormsModule }    from '@angular/forms';

import { AppComponent }         from './app.component';
import { DashboardComponent }   from './dashboard.component';
import { HeroDetailComponent }  from './hero-detail.component';
import { HeroesComponent }      from './heroes.component';
import { HeroService }          from './hero.service';

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

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    AppRoutingModule //1
  ],
  declarations: [
    AppComponent,
    DashboardComponent,  //2
    HeroDetailComponent, //2
    HeroesComponent      //2
  ],
  providers: [ HeroService ], //3
  bootstrap: [ AppComponent ] 
})
export class AppModule { }

1. 사이트에서 사용할 module들은 imports에 넣습니다.

2. 사이트에서 사용할 component들은 declarations에 넣습니다.

3. 이전 포스트에서 AppComponent providers로 들어갔던 HeroServie가 AppModule의 providers로 옮겨졌습니다. Module의 providers에 service를 넣으면 해당 service는 각각 class의 providers에 넣지 않아도 됩니다.

// src/app/hero.service.ts

import { Hero } from './hero';
import { HEROES } from './mock-heroes';
import { Injectable } from '@angular/core';

@Injectable()
export class HeroService {
  getHeroes(): Promise<Hero[]> {
    return Promise.resolve(HEROES);
  }

  getHeroesSlowly(): Promise<Hero[]> {
    return new Promise(resolve => {
      // Simulate server latency with 2 second delay
      setTimeout(() => resolve(this.getHeroes()), 2000);
    });
  }

  getHero(id: number): Promise<Hero> { //1
    return this.getHeroes()
               .then(heroes => heroes.find(hero => hero.id === id));
  }
}

1. 하나의 hero를 return하는 getHero 함수가 service에 추가되었습니다. 나중에는 모든 CRUD기능이 추가됩니다.

// src/app/dashboard.component.ts

import { Component, OnInit } from '@angular/core';

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

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

  heroes: Hero[] = [];

  constructor(private heroService: HeroService) { }

  ngOnInit(): void {
    this.heroService.getHeroes()
      .then(heroes => this.heroes = heroes.slice(1, 5));
  }
}

이전 포스트에서 익혔던 OnInit을 사용해서 component가 준비되면 hero 리스트를 받은 다음 1번부터 5번까지 4개의 hero를 this.heroes에 넣습니다.

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

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

*ngFor를 사용해서 4개의 hero를 보여줍니다. routerLink에 들어갈 항목이 하나 이상이라면 배열을 사용할 수도 있습니다. 배열 항목 사이에는 자동으로 '/'가 추가됩니다.

// src/app/heroes.component.ts

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

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

@Component({
  selector: 'my-heroes',
  templateUrl: './heroes.component.html',
  styleUrls: [ './heroes.component.css' ]
})
export class HeroesComponent implements OnInit {
  heroes: Hero[];
  selectedHero: Hero;

  constructor(
    private router: Router, //1
    private heroService: HeroService) { }

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

  ngOnInit(): void {
    this.getHeroes();
  }

  onSelect(hero: Hero): void {
    this.selectedHero = hero;
  }

  gotoDetail(): void { //1
    this.router.navigate(['/detail', this.selectedHero.id]);
  }
}

지금까지의 AppComponent에 해당하는 부분이 HeroesComponent로 옮겨왔습니다.

1. 다만 Router가 추가되었고, gotoDetail 함수에서 router.navigate를 사용해서 route를 변경하는 기능이 추가되었습니다.

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

<h2>My Heroes</h2>
<ul class="heroes">
  <li *ngFor="let hero of heroes"
    [class.selected]="hero === selectedHero"
    (click)="onSelect(hero)">
    <span class="badge">{{hero.id}}</span> {{hero.name}}
  </li>
</ul>
<div *ngIf="selectedHero">
  <h2>
    {{selectedHero.name | uppercase}} is my hero //1
  </h2>
  <button (click)="gotoDetail()">View Details</button>
</div>

hero 리스트를 보여주고, hero를 click하면 하나의 hero를 보여줍니다. View Detail버튼을 click하면 gotoDetail 함수가 호출됩니다.

1. uppercase filter를 사용해서 | 앞의 문자열을 대문자로 변경하고 있습니다. 이처럼 Angular에서는 template에서 연산을 할 때 javascript를 사용하지 않습니다.

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

import 'rxjs/add/operator/switchMap'; //2
import { Component, OnInit }      from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { Location }               from '@angular/common';

import { Hero }         from './hero';
import { HeroService }  from './hero.service';
@Component({
  selector: 'hero-detail',
  templateUrl: './hero-detail.component.html',
  styleUrls: [ './hero-detail.component.css' ]
})
export class HeroDetailComponent implements OnInit {
  hero: Hero;

  constructor(
    private heroService: HeroService,
    private route: ActivatedRoute,
    private location: Location
  ) {}

  ngOnInit(): void { //1
    this.route.params
      .switchMap((params: Params) => this.heroService.getHero(+params['id'])) //3
      .subscribe(hero => this.hero = hero);
  }

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

1. 이부분이 Observable을 사용하고 있는 부분입니다. this.route.params은 route의 parameter의 변화를 감지하여 Observable을 생성합니다.
HeroDetailComponent의 route path가 'detail/:id' 이므로 :id에 해당하는 문자열이 parameter가 되고 이 값이 변경되는 경우(처음 생성되는 경우 포함) this.route.params함수에 의해 Params 타입을 가지는 Observable이 생성됩니다.

강의 초반에 Observable은 주로 Observable_리턴.subscribe(함수)의 형태로 사용된다고 했던 것 기억나시나요? 근데 위의 코드를 보면 형태가 this.route.params.subscribe(...)가 아니라 this.route.params.swithMaps(...).subscribe(...) 입니다. 이 중간에 끼인 .switchMap은Observable을 advance하게 사용하는 방법으로 만약 해당 이벤트의 처리가 덜 끝난 상태에서 Observable이 새로운 이벤트를 감지하는 경우(route.params의 경우는 route가 변경되어 parameter가 변경된 경우) 현재 진행중인 이벤트 처리를 중단하고 새로운 이벤트를 처리하게 합니다. .switchMap 역시 Observable를 리턴하므로 다음에 .subscribe로 할일을 추가한 형태입니다. .switchMap 은 그냥 사용할 수 있는 함수가 아니라 //2 처럼 따로 RxJS의 switchMaps package를 써야만 사용할 수 있습니다.

.switchMap을 빼고 그냥 Observable-subscribe으로 작성하면 아래와 같이 작성할 수 있습니다. .switchMap은 일단 제쳐두고 .subscribe을 먼저 이해하도록 합시다.

  ngOnInit(): void {
    this.route.params.subscribe((params: Params) => {
      this.heroService.getHero(+params['id']) //3
      .then(hero => this.hero = hero);
    });
  }

route에서 paramater가 변경되면, heroservice의 getHero함수에 'id' route parameter를 전달하여 호출합니다.

3. params['id']의 'id'는 물론 'detail/:id' 의 :id에서 콜론(:)을 뺀 것입니다. 만약 이 명칭을 바꾸고 싶다면 양쪽 다 동일한 것으로 바꾸어 줘야 합니다. route parameter는 항상 문자열로 전달되기 때문에 params['id'] 앞에 + 기호를 써서 문자열을 숫자로 바꾸었습니다.

4. goBack 함수는 Location 객체를 사용해서 브라우저의 이전페이지로 이동시키는 함수입니다.

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

<div *ngIf="hero">
  <h2>{{hero.name}} details!</h2>
  <div>
    <label>id: </label>{{hero.id}}</div>
  <div>
    <label>name: </label>
    <input [(ngModel)]="hero.name" placeholder="name" />
  </div>
  <button (click)="goBack()">Back</button>
</div>

이전 포스트의 HeroDetailComponent에 있던 detail 부분이 여기로 옮겨왔습니다. Back버튼만 추가되었습니다.

마치며..

내용 중에 이해가 잘 안되는 부분이 있으면 일단은 형태를 익힌 후 넘어가시기 바랍니다. 물론 꼭 이해를 하고 싶으신 분은 답글로 질문을 남겨 주시면 답변해 드립니다.

지금까지는 hero data를 파일에서 읽어왔는데, 다음 포스트에서 hero data를 REST API에서 불러오는 방법에 대해 알아보겠습니다.

댓글

하영아빠 2017.07.14
안녕하세요. 오늘은 질문이 있습니다.
예제를 실행하면 실행은 잘되는데 (로컬)
크롬 콘솔에서 이미지와 같은 에러가 납니다. http://i.imgur.com/0DjYyt5.png
index.html 에 있는 css, js 등에서 나는 것 같은데
이것들을 빼면 콘솔 에러가 없는데 또 재컴파일하다보면 없다고 안되기도 하고 그러네요.
혹시 관련해서 짐작 가시는 부분이 있으실까요?
I
Ian H 2017.07.15
@하영아빠,
아마 환경변수에 port값으로 8080이 들어 있는 상태인거 같은데요, (angular cli 기본 port값은 4200입니다) 아마 다른 프로그램도 8080을 동시에 사용하고 있는 것이 아닌가 생각됩니다. 
하영아빠 2017.07.17
@Ian H,
오!! 맞습니다. 그 문제였군요!! 감사합니다. 
하영아빠 2017.07.14
그리고 AppRoutingModule을 사용하여  컴포넌트들이 router-outlet 엘리먼트에 표시되는 것이므로
dashboard컴포넌트와 heroes컴포넌트의 selector 는 의미가 없는것인가요?
selector 라인을 지워도 잘 작동해서 궁금합니다.
I
Ian H 2017.07.15
@하영아빠,
selector는 다른 template에서 component를 호출하기 위해 사용됩니다. 현재는 app-routing.module.ts에서 component들을 직접 호출하고 있으므로 selector라인을 지워도 작동합니다.
다만 selector라인이 없으니까 template에서 호출할 수 없겠죠?
template에서 호출하지 않고 route에서만 호출받은 component라면 selector라인은 지워도 됩니다^^
하영아빠 2017.07.17
@Ian H,
아하 넵 정확하게 이해되었습니다. 감사합니다.
비밀s 2018.02.07
안녕하세요 ng build하니까 한폴더에 파일이 떨어지는데 이것이맞나요? 톰켓에 배포해봤는데 index.html이나 /는 url접근이 되는데 라우팅으로 설정한 다른url은 파일이없다보니 접근이 안되네요 index.html통해서 이동시에만 접근되는데 방법이 있을까요?
비밀s 2018.02.07
요약드리면 ng serve로 실행시엔 모든 path에 접근이잘되고 ng build후 배포했을때 접근이안되는 문제입니다. url다이렉트접근은 거의없을꺼같은데 새로고침하면 페이지를 못찾네요..
I
Ian H 2018.02.07
@비밀s,
안녕하세요 톰캣은 안써봐서 모르겠는데 나머지 route에 요청이 오더라도 같은 index.html을 response해주시면 됩니다.
이때 redirect가 아니라 index.html이 해당 url을 갖도록 해주면 angular가 해당 url에 맞는 페이지를 render해서 보여주게 됩니다.
댓글쓰기

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

UP