Skip to content

6. 서비스 추가하기


HeroesComponent가 표시하는 데이터는 아직 가짜 데이터이다.


이번 튜토리얼에서는 HeroesComponent가 화면을 표시하는 로직에만 집중하도록 가볍게 리팩토링해 보자. 이렇게 수정하면 목 서비스를 사용할 수 있기 때문에 컴포넌트에 유닛 테스트를 적용하기도 쉬워진다.


Note


1. 왜 서비스를 사용할까?

컴포넌트는 데이터를 직접 가져오거나 직접 저장하도록 요청하지 않는 것이 좋다. 그리고 사용하는 데이터가 실제 데이터인지 가짜 데이터인지 알 필요도 없다. 컴포넌트는 데이터를 표시하는 것에만 집중하는 것이 좋으며, 데이터를 처리하는 로직은 서비스에게 맡겨두는 것이 좋다.


이 튜토리얼에서는 히어로의 데이터를 처리하는 HeroService를 만들어 본다. 그런데 이 서비스는 new 키워드로 인스턴스를 직접 생성하지 않는다. 이 서비스는 Angular가 제공하는 의존성 주입 메커니즘에 따라 HeroesComponent의 생성자로 주입될 것이다.


여러 클래스에 사용되는 정보를 공유하려면 서비스를 사용하는 방법이 가장 좋다. MessageService를 만들고 다음 두 곳에 이 서비스를 주입해서 활용해 보자:


1] HeroService가 메시지를 보낼 때 사용한다.

2] 이 메시지는 MessagesComponent가 받아서 화면에 표시한다.


2. HeroService 생성하기

Angular CLI로 다음 명령을 실행해서 hero 서비스를 생성한다.


ng generate service hero


이 명령을 실행하면 src/app/hero.service.ts 파일에 HeroService 클래스가 다음과 같이 생성된다:


src/app/hero.service.ts (새로 만든 서비스)
import { Injectable } from "@angular/core";

@Injectable({
  providedIn: "root",
})
export class HeroService {
  constructor() {}
}


1) @Injectable() 서비스

Angular CLI로 만든 서비스 클래스에는 Injectable 심볼이 로드되어 @Injectable() 데코레이터로 사용되었다. 이 구문은 이 클래스가 의존성 주입 시스템에 포함되는 클래스라고 선언하는 구문이다. 그래서 HeroService 클래스는 의존성으로 주입될 수 있으며 이 클래스도 의존성을 주입받을 수 있다. 아직까지는 이 클래스에 주입되는 의존성 객체가 없지만 곧 추가될 것이다.


@Injectable() 데코레이터는 서비스를 정의하는 메타데이터 객체를 인자로 받는다. @Component() 데코레이터에 메타데이터를 사용했던 것과 같은 방식이다.


2) 히어로 데이터 가져오기

HeroService는 웹 서비스나 로컬 스토리지, 목 데이터 소스 등 어디에서든 히어로 데이터를 가져올 수 있다.


컴포넌트에서 데이터에 접근하는 로직을 제거하면 컴포넌트는 데이터를 표시하는 목적에만 집중할 수 있으며, 데이터를 가져오는 곳이 변경되더라도 컴포넌트가 이 내용을 신경쓰지 않아도 된다.


이 문서에서는 이전과 마찬가지로 목 데이터를 가져오도록 구현해 본다.


HeroServiceHero 심볼과 HEROES 배열을 로드한다.


src/app/hero.service.ts
import { Hero } from "./hero";
import { HEROES } from "./mock-heroes";


그리고 목 히어로 데이터를 반환하는 getHeroes 메서드를 추가한다.


src/app/hero.service.ts
getHeroes(): Hero[] {
  return HEROES;
}


3. HeroService 등록하기

HeroServiceHeroesComponent에 의존성으로 주입하려면 이 서비스의 프로바이더(provider)가 Angular 의존성 주입 시스템에 등록되어야 한다. 프로바이더는 서비스를 생성하고 전달하는 방식을 정의한 것이다. 이 예제에서는 서비스 클래스가 HeroService의 프로바이더이다.


서비스 프로바이더는 인젝터(injector)에 등록된다. 인젝터는 의존성 주입 요청이 있었던 객체를 적절하게 고르고 생성하는 역할을 한다.


Angular CLI로 ng generate service 명령을 싱행하면 이 서비스의 @Injectable() 데코레이터에 providedIn: 'root'를 지정해서 서비스 프로바이더를 최상위 인젝터에 등록한다.


@Injectable({
  providedIn: 'root',
})


서비스가 최상위 인젝터에 등록되면 Angular는 HeroService의 인스턴스를 하나만 생성하며, 이 클래스가 주입되는 모든 곳에서 같은 인스턴스를 공유한다. 그리고 @Injectable() 데코레이터는 이 데코레이터가 등록된 클래스가 실제로 사용되지 않으면 이 클래스를 최종 빌드 결과물에서 제거하는 대상으로 등록하는 역할도 한다.


HeroService는 이제 HeroesComponent에 주입될 준비가 되었다. 지금까지 작성한 코드는 HeroService를 프로바이더로 등록하기 위한 임시 코드이다. 최종코드 리뷰와는 조금 다르다.


4. HeroesComponent 수정하기

HeroesComponent 클래스 파일을 연다.


이 파일에서 HEROES를 로드했던 부분을 제거하고 HeroService를 로드한다.


src/app/heroes/heroes.component.ts (HeroService 로드하기)
import { HeroService } from "../hero.service";


그리고 heroes 프로퍼티 값을 할당하는 부분을 다음과 같이 수정한다.


src/app/heroes/heroes.component.ts
heroes: Hero[] = [];


1) HeroService 주입하기

생성자에 HeroService 타입의 heroService 인자를 선언하고 이 인자를 private으로 지정한다.


src/app/heroes/heroes.component.ts
constructor(private heroService: HeroService) {}


이렇게 작성하면 heroService 인자를 클래스 프로퍼티로 선언하면서 HeroService 타입의 의존성 객체가 주입되기를 요청한다는 것을 의미한다.


그러면 Angular가 HeroesComponent를 생성할 때 의존성 주입 시스템이 HeroService의 인스턴스를 찾아서 heroService라는 인자로 전달할 것이다.


2) getHeroes() 추가하기

서비스에서 히어로 목록을 받아오는 메서드를 정의한다.


src/app/heroes/heroes.component.ts
getHeroes(): void {
  this.heroes = this.heroService.getHeroes();
}


3) ngOnInit()에서 서비스 호출하기

서비스에 구현한 getHeroes() 함수는 컴포넌트 클래스에서도 호출할 수 있지만, 이 방법은 최선이 아니다.


컴포넌트의 생성자는 생성자로 받은 인자를 클래스 프로퍼티로 연결하는 정도로 간단하게 유지하는 것이 좋다. 생성자에는 이 외의 로직이 들어가지 않는 것이 좋다. 리모트 서버로 HTTP 요청을 보내는 로직도 물론 들어가지 않는 것이 좋다.


getHeroes() 함수는 ngOnInit 라이프싸이클 후킹 함수에서 실행하는 것이 좋다. ngOnInit() 함수는 Angular가 HeroesComponent의 인스턴스를 생성한 직후에 실행되는 함수이다.


src/app/heroes/heroes.component.ts
ngOnInit() {
  this.getHeroes();
}


4) 동작 확인하기

브라우저가 갱신되고 나면 앱이 이전과 동일하게 동작할 것이다. 화면에 히어로의 목록이 표시되고, 사용자가 히어로 중 하나의 이름을 클릭하면 해당 히어로의 상세정보도 화면에 표시된다.


5. 옵저버블 데이터

위에서 작성한 HeroService.getHeroes() 메서드는 동기 방식으로 동작하기 때문에, 이 함수의 실행 결과는 바로 반환된다. 그래서 HeroesComponentheroes 프로퍼티에 값이 할당될 때도 동기 방식으로 할당된다.


src/app/heroes/heroes.component.ts
this.heroes = this.heroService.getHeroes();


하지만 실제로 운영되는 앱에서 이런 방식을 사용하는 경우는 별로 없다. 지금 작성한 코드는 목 데이터를 가져오기 때문에 유효한 것이다. 애플리케이션은 리모트 서버에서 데이터를 가져오는 것이 일반적이기 때문에, 비동기 동작을 처리해야 하는 경우가 대부분이다.


그래서 HeroService.getHeroes()는 서버의 응답을 기다려야 하며, 히어로 데이터를 즉시 반환할 수 없다. 함수의 실행은 서버의 응답이 올 때까지 기다리지 않고 바로 종료된다.


이런 경우에는 HeroService.getHeroes() 함수가 비동기로 동작해야 한다.


비동기 동작은 콜백 함수를 사용해서 처리할 수 있다. Promise를 반환하도록 처리할 수도 있다. 그리고 Observable을 반환할 수도 있다.


이 튜토리얼에서는 HeroService.getHeroes() 함수가 Observable을 반환하도록 구현해 본다. Angular가 제공하는 HttpClient.get 메서드는 Observable을 반환하기 때문에 이렇게 구현하는 것이 가장 자연스럽다.


1) 옵저버블 HeroService

Observable은 RxJS 라이브러리가 제공하는 클래스 중 가장 중요한 클래스이다.


이후에 HTTP에 대해서 알아볼 때 Angular의 HttpClient 클래스가 제공하는 메서드는 모두 RxJS가 제공하는 Observable 타입을 반환한다는 것을 다시 한 번 살펴볼 것이다. 이 튜토리얼에서는 리모트 서버를 사용하지 않고 RxJS의 of() 함수로 데이터를 즉시 반환해 본다.


src/app/hero.service.ts (Observable 심볼 로드하기)
import { Observable, of } from "rxjs";


getHeroes() 메서드를 다음과 같이 수정한다:


src/app/hero.service.ts
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 메서드를 실행했던 부분을 찾아서 다음과 같이 변경한다. 이전에 작성했던 코드와 비교해 보자.


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


Observable.subscribe()를 사용한 부분이 가장 중요하다.


이전 버전에서는 히어로의 데이터를 배열로 가져와서 컴포넌트의 heroes 프로퍼티에 직접 할당했다. 이 동작은 동기 방식으로 동작하기 때문에 서비스가 데이터를 즉시 반환하거나 서버의 응답이 동기 방식으로 전달될 때에만 제대로 동작한다.


하지만 HeroService는 리모트 서버에 요청을 보내는 방식으로 동작하는 경우에는 이 로직이 제대로 동작하지 않는다.


수정한 버전의 코드는 서비스의 함수가 Observable 타입을 반환하는데, 반환 시점은 함수를 실행한 직후일 수도 있고 몇 분이 지난 후일 수도 있다. 서버의 응답이 언제 도착하는지와 관계없이, 이 응답이 도착했을 때 subscribe가 서버에서 받은 응답을 콜백 함수로 전달하고, 컴포넌트는 이렇게 받는 히어로 데이터를 heroes 프로퍼티에 할당한다.


HeroService가 실제로 서버에 요청을 보낸다면 이렇게 비동기 방식으로 구현해야 제대로 동작한다.


6. 메시지 표시하기

이번 섹션에서는 다음 내용에 대해 다룬다.


  • 애플리케이션에서 발생하는 메시지를 화면 아래쪽에 표시하기 위해 MessagesComponent를 추가해 본다.
  • 앱 전역 범위에 의존성으로 주입할 수 있는 MessageService를 만들고, 이 서비스로 메시지를 보내본다.
  • MessageServiceHeroService에 주입해 본다.
  • HeroService가 서버에서 가져온 히어로 데이터를 화면에 표시해 본다.


1) MessagesComponent 생성하기

Angular CLI로 다음 명령을 실행해서 MessagesComponent를 생성한다.


ng generate component messages


그러면 Angular CLI가 src/app/messages 폴더에 컴포넌트 파일들을 생성하고 AppModuleMessagesComponent를 자동으로 등록할 것이다.


이렇게 만든 MessagesComponent를 화면에 표시하기 위해 AppComponent 템플릿을 다음과 같이 수정한다.


src/app/app.component.html
<h1>{{title}}</h1>
<app-heroes></app-heroes>
<app-messages></app-messages>


브라우저가 갱신되면 화면 아래쪽에 MessagesComponent가 표시되는 것을 확인할 수 있다.


2) MessageService 생성하기

src/app 폴더에서 Angular CLI로 다음 명령을 실행해서 MessageService를 생성한다.


ng generate service message


그리고 이렇게 만든 MessageService 파일을 열어서 다음 내용으로 수정한다.


src/app/message.service.ts
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를 로드한다.


src/app/hero.service.ts (MessageService 로드하기)
import { MessageService } from "./message.service";


그리고 HeroService의 생성자를 수정해서 messageService 프로퍼티를 private으로 선언하도록 한다. 그러면 HeroService가 생성될 때 Angular가 MessageService의 싱글턴 인스턴스를 의존성으로 주입할 것이다.


src/app/hero.service.ts
constructor(private messageService: MessageService) { }


Note

  • "서비스 안에 서비스"가 존재하는 경우 위와 같이 구현한다.
  • MessageServiceHeroService에 의존성으로 주입되고, HeroService는 다시 HeroesComponent에 의존성으로 주입된다.


4) HeroService에서 메시지 보내기

getHeroes() 메서드에서 히어로 데이터를 받아온 뒤에 메시지를 보내도록 다음과 같이 수정한다.


src/app/hero.service.ts
getHeroes(): Observable<Hero[]> {
  const heroes = of(HEROES);
  this.messageService.add('HeroService: fetched heroes');
  return heroes;
}


5) HeroService에서 받은 메시지 표시하기

MessagesComponentHeroService가 서버에서 히어로 데이터를 가져왔을 때 보냈던 메시지와 같이, MessagesService가 받은 모든 메시지를 표시하려고 한다.


MessagesComponent를 열어서 MessageService를 로드한다.


src/app/messages/messages.component.ts (MessageService 로드하기)
import { MessageService } from "../message.service";


MessagesComponent의 생성자를 수정해서 messageService 프로퍼티를 public으로 할당하도록 다음과 같이 수정한다. 이렇게 작성하면 Angular가 MessagesComponent의 인스턴스를 생성할 때 MessageService의 싱글턴 인스턴스를 이 프로퍼티로 전달할 것이다.


src/app/messages/messages.component.ts
constructor(public messageService: MessageService) {}


이 때 messageService 프로퍼티는 템플릿에 바인딩되기 때문에 반드시 public으로 선언해야 한다.


Note

  • Angular에서는 public으로 선언된 컴포넌트 프로퍼티만 바인딩할 수 있다.


6) MessageService 바인딩하기

Angular CLI가 생성한 MessagesComponent의 템플릿을 다음과 같이 수정한다.


src/app/messages/messages.component.html
<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. 히어로 서비스로 메시지 보내기

사용자가 히어로를 클릭할 때마다 어떤 히어로를 선택했는지 기록을 남기려면 다음과 같은 코드를 추가하면 된다. 이 내용은 다음 섹션인 라우팅에서 활용해 보자.


src/app/heroes/heroes.component.ts
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 { Injectable } from "@angular/core";

@Injectable({
  providedIn: "root",
})
export class MessageService {
  messages: string[] = [];

  add(message: string) {
    this.messages.push(message);
  }

  clear() {
    this.messages = [];
  }
}
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() {}
}
<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>
/* 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 {}
<h1>{{title}}</h1>
<app-heroes></app-heroes>
<app-messages></app-messages>


9. 정리

이번 튜토리얼을 진행하면서 다음과 같은 내용에 대해 알아봤다.


  • 컴포넌트가 데이터를 직접 가져오는 방식을 HeroService 클래스가 제공하는 방식으로 변경했다.
  • 프로바이더를 사용해서 HeroService를 최상위 인젝터에 등록했다.
  • HeroService를 컴포넌트에 의존성으로 주입하기 위해 Angular의 의존성 주입 시스템을 사용했다.
  • HeroService에서 비동기 방식으로 데이터를 가져오는 메서드를 구현했다.
  • RxJS가 제공하는 Observable에 대해 간단하게 알아봤다.
  • 히어로 목 데이터(Observable<Hero[]>)를 반환할 때 RxJS가 제공하는 of() 함수를 사용했다.
  • 컴포넌트가 HeroService를 활용하는 로직은 컴포넌트 생성자가 아니라 ngOnInit 라이프싸이클 후킹 함수에 구현했다.
  • 클래스끼리 데이터를 주고받지만 결합도를 낮추기 위해 MessageService를 만들었다.
  • HeroService는 컴포넌트에 의존성으로 주입되지만 또 다른 서비스인 MessageService를 의존성으로 주입받기도 한다.

References