6. 서비스 추가하기
HeroesComponent
가 표시하는 데이터는 아직 가짜 데이터이다.
이번 튜토리얼에서는 HeroesComponent
가 화면을 표시하는 로직에만 집중하도록 가볍게 리팩토링해 보자. 이렇게 수정하면 목 서비스를 사용할 수 있기 때문에 컴포넌트에 유닛 테스트를 적용하기도 쉬워진다.
1. 왜 서비스를 사용할까?
컴포넌트는 데이터를 직접 가져오거나 직접 저장하도록 요청하지 않는 것이 좋다. 그리고 사용하는 데이터가 실제 데이터인지 가짜 데이터인지 알 필요도 없다. 컴포넌트는 데이터를 표시하는 것에만 집중하는 것이 좋으며, 데이터를 처리하는 로직은 서비스에게 맡겨두는 것이 좋다.
이 튜토리얼에서는 히어로의 데이터를 처리하는 HeroService
를 만들어 본다. 그런데 이 서비스는 new
키워드로 인스턴스를 직접 생성하지 않는다. 이 서비스는 Angular가 제공하는 의존성 주입 메커니즘에 따라 HeroesComponent
의 생성자로 주입될 것이다.
여러 클래스에 사용되는 정보를 공유하려면 서비스를 사용하는 방법이 가장 좋다. MessageService
를 만들고 다음 두 곳에 이 서비스를 주입해서 활용해 보자:
1] HeroService
가 메시지를 보낼 때 사용한다.
2] 이 메시지는 MessagesComponent
가 받아서 화면에 표시한다.
2. HeroService
생성하기
Angular CLI로 다음 명령을 실행해서 hero
서비스를 생성한다.
이 명령을 실행하면 src/app/hero.service.ts
파일에 HeroService
클래스가 다음과 같이 생성된다:
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class HeroService {
constructor() {}
}
1) @Injectable()
서비스
Angular CLI로 만든 서비스 클래스에는 Injectable
심볼이 로드되어 @Injectable()
데코레이터로 사용되었다. 이 구문은 이 클래스가 의존성 주입 시스템에 포함되는 클래스라고 선언하는 구문이다. 그래서 HeroService
클래스는 의존성으로 주입될 수 있으며 이 클래스도 의존성을 주입받을 수 있다. 아직까지는 이 클래스에 주입되는 의존성 객체가 없지만 곧 추가될 것이다.
@Injectable()
데코레이터는 서비스를 정의하는 메타데이터 객체를 인자로 받는다. @Component()
데코레이터에 메타데이터를 사용했던 것과 같은 방식이다.
2) 히어로 데이터 가져오기
HeroService
는 웹 서비스나 로컬 스토리지, 목 데이터 소스 등 어디에서든 히어로 데이터를 가져올 수 있다.
컴포넌트에서 데이터에 접근하는 로직을 제거하면 컴포넌트는 데이터를 표시하는 목적에만 집중할 수 있으며, 데이터를 가져오는 곳이 변경되더라도 컴포넌트가 이 내용을 신경쓰지 않아도 된다.
이 문서에서는 이전과 마찬가지로 목 데이터를 가져오도록 구현해 본다.
HeroService
에 Hero
심볼과 HEROES
배열을 로드한다.
그리고 목 히어로 데이터를 반환하는 getHeroes
메서드를 추가한다.
3. HeroService
등록하기
HeroService
를 HeroesComponent
에 의존성으로 주입하려면 이 서비스의 프로바이더(provider)가 Angular 의존성 주입 시스템에 등록되어야 한다. 프로바이더는 서비스를 생성하고 전달하는 방식을 정의한 것이다. 이 예제에서는 서비스 클래스가 HeroService
의 프로바이더이다.
서비스 프로바이더는 인젝터(injector)에 등록된다. 인젝터는 의존성 주입 요청이 있었던 객체를 적절하게 고르고 생성하는 역할을 한다.
Angular CLI로 ng generate service
명령을 싱행하면 이 서비스의 @Injectable()
데코레이터에 providedIn: 'root'
를 지정해서 서비스 프로바이더를 최상위 인젝터에 등록한다.
서비스가 최상위 인젝터에 등록되면 Angular는 HeroService
의 인스턴스를 하나만 생성하며, 이 클래스가 주입되는 모든 곳에서 같은 인스턴스를 공유한다. 그리고 @Injectable()
데코레이터는 이 데코레이터가 등록된 클래스가 실제로 사용되지 않으면 이 클래스를 최종 빌드 결과물에서 제거하는 대상으로 등록하는 역할도 한다.
HeroService
는 이제 HeroesComponent
에 주입될 준비가 되었다. 지금까지 작성한 코드는 HeroService
를 프로바이더로 등록하기 위한 임시 코드이다. 최종코드 리뷰와는 조금 다르다.
4. HeroesComponent
수정하기
HeroesComponent
클래스 파일을 연다.
이 파일에서 HEROES
를 로드했던 부분을 제거하고 HeroService
를 로드한다.
그리고 heroes
프로퍼티 값을 할당하는 부분을 다음과 같이 수정한다.
1) HeroService
주입하기
생성자에 HeroService
타입의 heroService
인자를 선언하고 이 인자를 private
으로 지정한다.
이렇게 작성하면 heroService
인자를 클래스 프로퍼티로 선언하면서 HeroService
타입의 의존성 객체가 주입되기를 요청한다는 것을 의미한다.
그러면 Angular가 HeroesComponent
를 생성할 때 의존성 주입 시스템이 HeroService
의 인스턴스를 찾아서 heroService
라는 인자로 전달할 것이다.
2) getHeroes()
추가하기
서비스에서 히어로 목록을 받아오는 메서드를 정의한다.
3) ngOnInit()
에서 서비스 호출하기
서비스에 구현한 getHeroes()
함수는 컴포넌트 클래스에서도 호출할 수 있지만, 이 방법은 최선이 아니다.
컴포넌트의 생성자는 생성자로 받은 인자를 클래스 프로퍼티로 연결하는 정도로 간단하게 유지하는 것이 좋다. 생성자에는 이 외의 로직이 들어가지 않는 것이 좋다. 리모트 서버로 HTTP 요청을 보내는 로직도 물론 들어가지 않는 것이 좋다.
getHeroes()
함수는 ngOnInit
라이프싸이클 후킹 함수에서 실행하는 것이 좋다. ngOnInit()
함수는 Angular가 HeroesComponent
의 인스턴스를 생성한 직후에 실행되는 함수이다.
4) 동작 확인하기
브라우저가 갱신되고 나면 앱이 이전과 동일하게 동작할 것이다. 화면에 히어로의 목록이 표시되고, 사용자가 히어로 중 하나의 이름을 클릭하면 해당 히어로의 상세정보도 화면에 표시된다.
5. 옵저버블 데이터
위에서 작성한 HeroService.getHeroes()
메서드는 동기 방식으로 동작하기 때문에, 이 함수의 실행 결과는 바로 반환된다. 그래서 HeroesComponent
의 heroes
프로퍼티에 값이 할당될 때도 동기 방식으로 할당된다.
하지만 실제로 운영되는 앱에서 이런 방식을 사용하는 경우는 별로 없다. 지금 작성한 코드는 목 데이터를 가져오기 때문에 유효한 것이다. 애플리케이션은 리모트 서버에서 데이터를 가져오는 것이 일반적이기 때문에, 비동기 동작을 처리해야 하는 경우가 대부분이다.
그래서 HeroService.getHeroes()
는 서버의 응답을 기다려야 하며, 히어로 데이터를 즉시 반환할 수 없다. 함수의 실행은 서버의 응답이 올 때까지 기다리지 않고 바로 종료된다.
이런 경우에는 HeroService.getHeroes()
함수가 비동기로 동작해야 한다.
비동기 동작은 콜백 함수를 사용해서 처리할 수 있다. Promise
를 반환하도록 처리할 수도 있다. 그리고 Observable
을 반환할 수도 있다.
이 튜토리얼에서는 HeroService.getHeroes()
함수가 Observable
을 반환하도록 구현해 본다. Angular가 제공하는 HttpClient.get
메서드는 Observable
을 반환하기 때문에 이렇게 구현하는 것이 가장 자연스럽다.
1) 옵저버블 HeroService
Observable
은 RxJS 라이브러리가 제공하는 클래스 중 가장 중요한 클래스이다.
이후에 HTTP에 대해서 알아볼 때 Angular의 HttpClient
클래스가 제공하는 메서드는 모두 RxJS가 제공하는 Observable
타입을 반환한다는 것을 다시 한 번 살펴볼 것이다. 이 튜토리얼에서는 리모트 서버를 사용하지 않고 RxJS의 of()
함수로 데이터를 즉시 반환해 본다.
getHeroes()
메서드를 다음과 같이 수정한다:
getHeroes(): Observable<Hero[]> {
const heroes = of(HEROES);
return heroes;
}
of(HEROES)
는 히어로 목 데이터를 Observable<Hero[]>
타입으로 한 번에 반환한다.
Note
- 이후에 살펴볼 HTTP 튜토리얼에서도
HttpClient.get<Hero[]>
는 이번 예제와 동일하게Observable<Hero[]>
타입을 반환하기 때문에, HTTP 응답으로 받은 히어로의 데이터 배열은 한 번에 반환된다.
2) HeroesComponent
에서 옵저버블 구독하기
이전까지 HeroService.getHeroes
메서드는 Hero[]
타입을 반환했지만 이제는 Observable<Hero[]>
타입을 반환한다.
그래서 HeroesComponent
의 내용을 조금 수정해야 한다.
getHeroes
메서드를 실행했던 부분을 찾아서 다음과 같이 변경한다. 이전에 작성했던 코드와 비교해 보자.
Observable.subscribe()
를 사용한 부분이 가장 중요하다.
이전 버전에서는 히어로의 데이터를 배열로 가져와서 컴포넌트의 heroes
프로퍼티에 직접 할당했다. 이 동작은 동기 방식으로 동작하기 때문에 서비스가 데이터를 즉시 반환하거나 서버의 응답이 동기 방식으로 전달될 때에만 제대로 동작한다.
하지만 HeroService
는 리모트 서버에 요청을 보내는 방식으로 동작하는 경우에는 이 로직이 제대로 동작하지 않는다.
수정한 버전의 코드는 서비스의 함수가 Observable
타입을 반환하는데, 반환 시점은 함수를 실행한 직후일 수도 있고 몇 분이 지난 후일 수도 있다. 서버의 응답이 언제 도착하는지와 관계없이, 이 응답이 도착했을 때 subscribe
가 서버에서 받은 응답을 콜백 함수로 전달하고, 컴포넌트는 이렇게 받는 히어로 데이터를 heroes
프로퍼티에 할당한다.
HeroService
가 실제로 서버에 요청을 보낸다면 이렇게 비동기 방식으로 구현해야 제대로 동작한다.
6. 메시지 표시하기
이번 섹션에서는 다음 내용에 대해 다룬다.
- 애플리케이션에서 발생하는 메시지를 화면 아래쪽에 표시하기 위해
MessagesComponent
를 추가해 본다. - 앱 전역 범위에 의존성으로 주입할 수 있는
MessageService
를 만들고, 이 서비스로 메시지를 보내본다. MessageService
를HeroService
에 주입해 본다.HeroService
가 서버에서 가져온 히어로 데이터를 화면에 표시해 본다.
1) MessagesComponent
생성하기
Angular CLI로 다음 명령을 실행해서 MessagesComponent
를 생성한다.
그러면 Angular CLI가 src/app/messages
폴더에 컴포넌트 파일들을 생성하고 AppModule
에 MessagesComponent
를 자동으로 등록할 것이다.
이렇게 만든 MessagesComponent
를 화면에 표시하기 위해 AppComponent
템플릿을 다음과 같이 수정한다.
<h1>{{title}}</h1>
<app-heroes></app-heroes>
<app-messages></app-messages>
브라우저가 갱신되면 화면 아래쪽에 MessagesComponent
가 표시되는 것을 확인할 수 있다.
2) MessageService
생성하기
src/app
폴더에서 Angular CLI로 다음 명령을 실행해서 MessageService
를 생성한다.
그리고 이렇게 만든 MessageService
파일을 열어서 다음 내용으로 수정한다.
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class MessageService {
messages: string[] = [];
add(message: string) {
this.messages.push(message);
}
clear() {
this.messages = [];
}
}
이 서비스는 messages
프로퍼티에 메시지를 캐싱하는데, add()
메서드는 프로퍼티에 메시지를 추가하고 clear()
메서드는 캐시를 비우는 역할을 한다.
3) HeroService
에 의존성으로 주입하기
HeroService
파일을 다시 열고 MessageService
를 로드한다.
그리고 HeroService
의 생성자를 수정해서 messageService
프로퍼티를 private
으로 선언하도록 한다. 그러면 HeroService
가 생성될 때 Angular가 MessageService
의 싱글턴 인스턴스를 의존성으로 주입할 것이다.
Note
- "서비스 안에 서비스"가 존재하는 경우 위와 같이 구현한다.
MessageService
는HeroService
에 의존성으로 주입되고,HeroService
는 다시HeroesComponent
에 의존성으로 주입된다.
4) HeroService
에서 메시지 보내기
getHeroes()
메서드에서 히어로 데이터를 받아온 뒤에 메시지를 보내도록 다음과 같이 수정한다.
getHeroes(): Observable<Hero[]> {
const heroes = of(HEROES);
this.messageService.add('HeroService: fetched heroes');
return heroes;
}
5) HeroService
에서 받은 메시지 표시하기
MessagesComponent
는 HeroService
가 서버에서 히어로 데이터를 가져왔을 때 보냈던 메시지와 같이, MessagesService
가 받은 모든 메시지를 표시하려고 한다.
MessagesComponent
를 열어서 MessageService
를 로드한다.
import { MessageService } from "../message.service";
MessagesComponent
의 생성자를 수정해서 messageService
프로퍼티를 public
으로 할당하도록 다음과 같이 수정한다. 이렇게 작성하면 Angular가 MessagesComponent
의 인스턴스를 생성할 때 MessageService
의 싱글턴 인스턴스를 이 프로퍼티로 전달할 것이다.
이 때 messageService
프로퍼티는 템플릿에 바인딩되기 때문에 반드시 public
으로 선언해야 한다.
Note
- Angular에서는
public
으로 선언된 컴포넌트 프로퍼티만 바인딩할 수 있다.
6) MessageService
바인딩하기
Angular CLI가 생성한 MessagesComponent
의 템플릿을 다음과 같이 수정한다.
<div *ngIf="messageService.messages.length">
<h2>Messages</h2>
<button class="clear" (click)="messageService.clear()">Clear messages</button>
<div *ngFor="let message of messageService.messages">{{message}}</div>
</div>
이 템플릿은 컴포넌트에 의존성으로 주입된 messageService
를 직접 바인딩한다.
- 메시지가 존재할 때만 컴포넌트의 내용을 표시하기 위해
*ngIf
를 사용했다. - 리스트에 존재하는 메시지마다
<div>
엘리먼트를 반복하기 위해*ngFor
를 사용했다. - 버튼을 클릭했을 때
MessageService.clear()
함수를 실행하기 위해 이벤트 바인딩 문법을 사용했다.
이 메시지 컴포넌트 CSS 파일 messages.component.css
에서 스타일을 지정하면 좀 더 보기 좋게 표시할 수 있다. 최종코드 리뷰 탭에서 스타일이 지정된 모습을 확인해 보자.
7. 히어로 서비스로 메시지 보내기
사용자가 히어로를 클릭할 때마다 어떤 히어로를 선택했는지 기록을 남기려면 다음과 같은 코드를 추가하면 된다. 이 내용은 다음 섹션인 라우팅에서 활용해 보자.
import { Component, OnInit } from "@angular/core";
import { Hero } from "../hero";
import { HeroService } from "../hero.service";
import { MessageService } from "../message.service";
@Component({
selector: "app-heroes",
templateUrl: "./heroes.component.html",
styleUrls: ["./heroes.component.css"],
})
export class HeroesComponent implements OnInit {
selectedHero?: Hero;
heroes: Hero[] = [];
constructor(
private heroService: HeroService,
private messageService: MessageService
) {}
ngOnInit() {
this.getHeroes();
}
onSelect(hero: Hero): void {
this.selectedHero = hero;
this.messageService.add(`HeroesComponent: Selected hero id=${hero.id}`);
}
getHeroes(): void {
this.heroService.getHeroes().subscribe((heroes) => (this.heroes = heroes));
}
}
브라우저를 새로고침하면 히어로 목록이 화면에 표시된다. 그리고 이 화면에서 스크롤을 화면 끝까지 내리면 HeroService
가 보낸 메시지를 확인할 수 있다. 이 메시지 목록은 사용자가 히어로를 클릭할 때마다 추가되며, "Clear messages" 버튼을 누르면 기록을 지울 수 있다.
8. 최종코드 리뷰
이 문서에서 다룬 코드의 내용은 다음과 같다.
import { Injectable } from "@angular/core";
import { Observable, of } from "rxjs";
import { Hero } from "./hero";
import { HEROES } from "./mock-heroes";
import { MessageService } from "./message.service";
@Injectable({
providedIn: "root",
})
export class HeroService {
constructor(private messageService: MessageService) {}
getHeroes(): Observable<Hero[]> {
const heroes = of(HEROES);
this.messageService.add("HeroService: fetched heroes");
return heroes;
}
}
import { Component, OnInit } from "@angular/core";
import { Hero } from "../hero";
import { HeroService } from "../hero.service";
import { MessageService } from "../message.service";
@Component({
selector: "app-heroes",
templateUrl: "./heroes.component.html",
styleUrls: ["./heroes.component.css"],
})
export class HeroesComponent implements OnInit {
selectedHero?: Hero;
heroes: Hero[] = [];
constructor(
private heroService: HeroService,
private messageService: MessageService
) {}
ngOnInit() {
this.getHeroes();
}
onSelect(hero: Hero): void {
this.selectedHero = hero;
this.messageService.add(`HeroesComponent: Selected hero id=${hero.id}`);
}
getHeroes(): void {
this.heroService.getHeroes().subscribe((heroes) => (this.heroes = heroes));
}
}
import { Component, OnInit } from "@angular/core";
import { MessageService } from "../message.service";
@Component({
selector: "app-messages",
templateUrl: "./messages.component.html",
styleUrls: ["./messages.component.css"],
})
export class MessagesComponent implements OnInit {
constructor(public messageService: MessageService) {}
ngOnInit() {}
}
/* MessagesComponent에 적용되는 CSS 스타일 */
h2 {
color: #a80000;
font-family: Arial, Helvetica, sans-serif;
font-weight: lighter;
}
.clear {
color: #333;
background-color: #eee;
margin-bottom: 12px;
padding: 1rem;
border-radius: 4px;
font-size: 1rem;
}
.clear:hover {
color: white;
background-color: #42545c;
}
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { AppComponent } from "./app.component";
import { HeroesComponent } from "./heroes/heroes.component";
import { HeroDetailComponent } from "./hero-detail/hero-detail.component";
import { MessagesComponent } from "./messages/messages.component";
@NgModule({
declarations: [
AppComponent,
HeroesComponent,
HeroDetailComponent,
MessagesComponent,
],
imports: [BrowserModule, FormsModule],
providers: [
// `providedIn`을 사용했기 때문에 프로바이더는 등록하지 않습니다.
],
bootstrap: [AppComponent],
})
export class AppModule {}
9. 정리
이번 튜토리얼을 진행하면서 다음과 같은 내용에 대해 알아봤다.
- 컴포넌트가 데이터를 직접 가져오는 방식을
HeroService
클래스가 제공하는 방식으로 변경했다. - 프로바이더를 사용해서
HeroService
를 최상위 인젝터에 등록했다. HeroService
를 컴포넌트에 의존성으로 주입하기 위해 Angular의 의존성 주입 시스템을 사용했다.HeroService
에서 비동기 방식으로 데이터를 가져오는 메서드를 구현했다.- RxJS가 제공하는
Observable
에 대해 간단하게 알아봤다. - 히어로 목 데이터(
Observable<Hero[]>
)를 반환할 때 RxJS가 제공하는of()
함수를 사용했다. - 컴포넌트가
HeroService
를 활용하는 로직은 컴포넌트 생성자가 아니라ngOnInit
라이프싸이클 후킹 함수에 구현했다. - 클래스끼리 데이터를 주고받지만 결합도를 낮추기 위해
MessageService
를 만들었다. HeroService
는 컴포넌트에 의존성으로 주입되지만 또 다른 서비스인MessageService
를 의존성으로 주입받기도 한다.