Skip to content

1. HTTP 클라이언트


프론트엔드 애플리케이션은 일반적으로 데이터를 받아오거나 업로드하기 위해 서버와 HTTP 프로토콜로 통신한다. Angular는 이런 경우를 위해 클라이언트측 HTTP API를 제공한다. @angular/common/http 패키지로 제공되는 HttpClient 서비스를 활용하면 된다.


HTTP 클라이언트 서비스는 이런 기능을 제공한다.


  • 요청을 보내고 응답을 받을 때 응답 객체에 타입을 지정할 수 있다.
  • 에러를 스트림으로 처리할 수 있다.
  • 테스트를 적용하기 쉽다.
  • 요청과 응답을 가로채서 다른 작업을 할 수 있다.


1. 사전지식

HttpClientModule에 대해 알아보기 전에 이런 내용을 먼저 이해하고 있는 것이 좋다:


  • TypeScript 사용방법
  • HTTP 프로토콜 사용방법
  • Angular 개요 문서에서 설명하는 Angular 앱 설계 개념
  • 옵저버블과 옵저버블 연산자 사용방법


2. 서버와 통신할 준비하기

HttpClient를 사용하려면 먼저 Angular HttpClientModule을 로드해야 한다. 이 모듈은 보통 AppModule에 등록한다.


app/app.module.ts (일부)
import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { HttpClientModule } from "@angular/common/http";

@NgModule({
  imports: [
    BrowserModule,
    // BrowserModule 뒤에 HttpClientModule을 로드합니다.
    HttpClientModule,
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
})
export class AppModule {}


모듈을 등록하고 나면 애플리케이션 클래스에 HttpClient 서비스를 의존성으로 주입할 수 있다. ConfigService에 주입한다면 이렇게 구현하면 된다.


app/config/config.service.ts (일부)
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";

@Injectable()
export class ConfigService {
  constructor(private http: HttpClient) {}
}


HttpClient 서비스는 모든 동작에 옵저버블을 활용한다. 그래서 이 문서에서 다루는 예제에도 RxJS 옵저버블과 옵저버블 연산자들을 자주 보게 될 것이다. ConfigService 파일에서는 이렇게 로드했다.


app/config/config.service.ts (RxJS 로드하기)
import { Observable, throwError } from "rxjs";
import { catchError, retry } from "rxjs/operators";


Note

  • 이 문서에서 다루는 예제 앱은 라이브 예제 링크 / 다운로드 링크에서 직접 확인하거나 다운받아 확인할 수 있다.
  • 이 예제 앱이 동작할 때는 데이터 서버가 없어도 된다.
  • 예제에서는 HttpClient 모듈의 HttpBackend를 대체하는 Angular 인-메모리 web API를 활용한다.
  • 그래서 REST API로 동작하는 백엔드와 비슷한 동작을 흉내낼 수 있다.


3. 서버에서 데이터 받아오기

HttpClient.get() 메서드를 사용하면 서버에서 데이터를 받아올 수 있다. 이 함수는 HTTP 요청을 보내고 Observable로 HTTP 응답을 전달하는 비동기 메서드이다. 반환하는 타입은 메서드를 실행할 때 observe, responseType 필드에 명시적으로 지정할 수 있다.


get() 메서드는 URL과 options 객체를 인자로 받는다.


options: {
    headers?: HttpHeaders | {[header: string]: string | string[]},
    observe?: 'body' | 'events' | 'response',
    params?: HttpParams|{[param: string]: string | number | boolean | ReadonlyArray<string | number | boolean>},
    reportProgress?: boolean,
    responseType?: 'arraybuffer'|'blob'|'json'|'text',
    withCredentials?: boolean,
  }


옵션 중에는 observeresponseType 프로퍼티가 중요하다.


  • observe: HTTP 응답을 어떤 범위까지 반환할지 반환한다.
  • responseType: 응답으로 받는 데이터의 타입을 지정한다.


Note

  • options 객체는 상황에 따라 다양하게 활용할 수 있다.
  • HTTP 요청을 보낼 때 기본 헤더가 필요하다면 헤더를 추가하기 위해 headers 옵션 프로퍼티를 사용할 수 있으며, HTTP URL로 인자를 전달하기 위해 params 프로퍼티를 사용할 수 있고, 용량이 큰 데이터를 보내거나 받을 때 진행률을 감지하는 용도로 reportProgress 옵션을 사용할 수도 있다.


애플리케이션은 보통 JSON 데이터를 응답으로 받는다. 그래서 ConfigService 예제에서도 서버에서 설정 파일 config.json을 요청하는 식으로 구현했다.


assets/config.json
{
  "heroesUrl": "api/heroes",
  "textfile": "assets/textfile.txt",
  "date": "2020-01-29"
}


JSON 데이터를 받아오려면 get() 메서드를 실행할 때 {observe: 'body', responseType: 'json'} 옵션을 사용하면 된다. 그런데 이 옵션은 get() 메서드의 기본 옵션이기 때문에 따로 옵션을 지정하지 않으면 이 설정이 사용된다. 다음 예제에서는 다른 형태로 옵션을 활용해 보자.


아래 예제는 의존성으로 주입하는 서비스를 재사용할 수 있는 형태로 구현한 것이다. HTTP 프로토콜로 데이터를 요청하는 서비스는 데이터를 리모트 서버로 보내거나, 에러를 처리하고, 실패했을 때 재시도하는 로직도 추가할 수 있다.


ConfigService에서 HttpClient.get() 메서드로 파일을 불러오는 코드는 이렇다.


app/config/config.service.ts (getConfig() v.1)
configUrl = 'assets/config.json';

getConfig() {
  return this.http.get<Config>(this.configUrl);
}


ConfigComponentConfigService를 의존성으로 주입받으며 이 서비스에 정의된 getConfig() 메서드를 실행한다.


그런데 이 메서드는 Observable 형태로 데이터를 반환하기 때문에 컴포넌트가 데이터를 받으려면 메서드가 반환하는 옵저버블을 구독해야 한다. 컴포넌트에 정의한 구독 함수는 필요한 로직만 간단하게 실행한다. 이 함수는 서비스에서 가져온 데이터를 파싱해서 컴포넌트 config 객체에 할당한다.


app/config/config.component.ts (showConfig() v.1)
showConfig() {
  this.configService.getConfig()
    .subscribe((data: Config) => this.config = {
        heroesUrl: data.heroesUrl,
        textfile:  data.textfile,
        date: data.date,
    });
}


1) 응답 타입 지정하기

HttpClient를 사용할 때 응답으로 받을 데이터의 타입을 지정하면 서버에서 받는 데이터를 좀 더 명확하게 처리할 수 있다. 응답 타입을 지정하는 코드는 컴파일 시점에 타입을 검사하는 용도로도 활용된다.


Note

  • 응답으로 받는 데이터의 타입을 지정하는 것은 TypeScript를 사용하는 관점에서 데이터 타입을 지정한 것뿐이며, 서버가 보내는 데이터가 정말 이 타입인 것을 보장하는 것은 아니다.
  • Angular 앱에서는 서버가 보낸 데이터의 타입을 예상하기만 할 뿐이고, 이 예상대로 동작하려면 서버 API가 보내는 데이터도 이 타입에 맞아야 한다.


응답으로 받는 객체의 타입을 지정하려면 먼저 인터페이스를 정의해야 한다. 이 때 클래스보다는 인터페이스를 사용하는 것을 권장한다. 서버에서 받아온 데이터는 단순한 객체 형식이며 클래스로 자동 변환되지 않는다.


export interface Config {
  heroesUrl: string;
  textfile: string;
  date: any;
}


그리고 HttpClient.get() 메서드를 실행할 때 제네릭으로 타입을 지정한다.


app/config/config.service.ts (getConfig() v.2)
getConfig() {
  // 이제 HTTP 요청 결과는 Config 타입의 Observable로 반환합니다.
  return this.http.get<Config>(this.configUrl);
}


Note

  • HttpClient.get() 메서드에 인터페이스를 타입으로 지정하면 RxJS map 연산자를 활용해서 화면에 사용하기 편한 형태로 데이터를 가공할 수 있다.
  • 그리고 템플릿에 Async 파이프를 사용하면 컴포넌트 클래스 코드를 거치지 않고 템플릿에 바로 활용할 수도 있다.


컴포넌트 메서드는 이제 데이터의 타입을 명확하게 지정할 수 있기 때문에 이후에 사용하기도 편하다:


app/config/config.component.ts (showConfig() v.2)
config: Config | undefined;

showConfig() {
  this.configService.getConfig()
    // Config 타입을 알기 때문에 클래스 프로퍼티로 바로 할당할 수 있습니다.
    .subscribe((data: Config) => this.config = { ...data });
}


응답으로 받은 JSON 객체의 프로퍼티에 접근하려면 이 객체에 정확한 타입을 지정해야 한다. 컴포넌트의 구독 콜백 함수에 이 내용을 빠뜨리면 응답으로 받은 데이터를 명시적으로 any 타입으로 캐스팅해야 사용할 수 있다.


.subscribe(data => this.config = {
  heroesUrl: (data as any).heroesUrl,
  textfile:  (data as any).textfile,
});


Note

  • observe 옵션과 response 옵션의 타입은 일반 문자열이 아니라 문자열 유니언(string unions)이다.


options: {
    ...
    observe?: 'body' | 'events' | 'response',
    ...
    responseType?: 'arraybuffer'|'blob'|'json'|'text',
    ...
  }


  • 사용방법이 조금 헷갈릴 수 있으니 예제를 보자:


// 이 코드는 동작합니다.
client.get("/foo", { responseType: "text" });

// 이 코드는 동작하지 않습니다.
const options = {
  responseType: "text",
};
client.get("/foo", options);


  • 두 번째 예제 코드처럼 작성하면 TypeScript는 options의 타입을 {responseType: string}이라고 추론한다.
  • 하지만 이 타입은 HttpClient.get() 메서드에 사용하기에는 충분하지 않다.
  • responseType의 값은 HttpClient가 사전에 정의한 문자열 중 하나여야 하지만 string 타입은 그보다 범위가 넓기 때문이다.
  • HttpClient를 사용할 때는 정확한 타입을 지정해야 한다.
  • 이 경우에는 as const를 사용해서 해당 문자열이 사전에 정의된 문자열이라고 지정해도 된다:


const options = {
  responseType: "text" as const,
};
client.get("/foo", options);


2) 응답 전체를 읽기

이전 예제에서는 HttpClient.get()을 사용할 때 따로 옵션을 지정하지 않았다. 이렇게 사용하면 get() 메서드는 응답의 바디를 JSON 타입으로 반환한다.


그런데 상황에 따라 전체 응답을 확인해야 하는 경우가 있다. 응답으로 받은 헤더나 상태 코드를 활용해야 하는 경우가 그렇다.


이 경우에는 get() 메서드를 실행할 때 observe 옵션을 사용하면 응답의 바디가 아니라 응답 전체를 받아올 수 있다:


getConfigResponse(): Observable<HttpResponse<Config>> {
  return this.http.get<Config>(
    this.configUrl, { observe: 'response' });
}


이렇게 구현하면 HttpClient.get() 메서드가 반환하는 Observable은 응답의 바디를 JSON 형식으로 전달하는 게 아니라 응답 전체를 HttpResponse 타입으로 전달한다.


아래 코드는 이 응답을 처리하는 showConfigResponse() 메서드이다:


app/config/config.component.ts (showConfigResponse())
showConfigResponse() {
  this.configService.getConfigResponse()
    // 반환 형식은 `HttpResponse<Config>` 입니다.
    .subscribe(resp => {
      // 헤더를 확인합니다.
      const keys = resp.headers.keys();
      this.headers = keys.map(key =>
        `${key}: ${resp.headers.get(key)}`);

      // `HttpResponse` 객체의 body 프로퍼티는 `Config` 타입입니다.
      this.config = { ...resp.body! };
    });
}


코드에서 볼 수 있듯이, 응답으로 전달된 객체의 body 프로퍼티는 Http.get() 메서드에 제네릭으로 지정한 타입이다.


3) JSONP 요청 보내기

서버가 CORS 프로토콜을 지원하지 않는다면 HttpClient로 다른 도메인에 JSONP 요청을 보낼 수 있다.


이 때도 Angular는 Observable을 반환한다. 따라서 HttpClient 메서드가 반환하는 옵저버블은 RxJS map 연산자를 활용해서 async 파이프에 사용하기 적합한 형태로 가공할 수 있다.


Angular 애플리케이션에서 JSONP 요청을 보내려면 NgModuleHttpClientJsonpModule을 로드해야 한다. 아래 예제에서 searchHeroes() 메서드는 이름에 특정 단어가 들어간 히어로 목록을 가져오기 위해 JSONP 요청을 보내는 메서드이다.


/* 이름에 특정 단어가 들어간 히어로 목록을 가져옵니다. */
searchHeroes(term: string): Observable {
  term = term.trim();

  const heroesURL = `${this.heroesURL}?${term}`;
  return this.http.jsonp(heroesUrl, 'callback').pipe(
      catchError(this.handleError('searchHeroes', [])) // 에러 처리
    );
}


이 메서드의 첫 번째 인자는 heroesURL이며 두 번째 인자는 콜백 함수의 이름을 지정했다. 그러면 JSONP 요청으로 보낸 응답은 콜백 함수로 랩핑되어 옵저버블로 전달되기 때문에, pipe로 체이닝해서 옵저버블 형태로 에러를 처리할 수 있다.


4) JSON 형식이 아닌 응답 처리하기

모든 HTTP 요청이 JSON 데이터를 반환하는 것은 아니다. 아래 예제에서 DownloaderServicegetTextFile() 메서드는 서버에 있는 텍스트 파일의 내용을 받아온 후에 로그에 출력하고 Observable<string> 타입으로 반환하는 함수이다.


app/downloader/downloader.service.ts (getTextFile())
getTextFile(filename: string) {
  // 반환 형식을 지정하면 get() 함수가 반환하는 타입을 Observable<string>으로 변경할 수 있습니다.
  // 이 때 get() 함수에 제네릭으로 <string> 타입을 지정할 필요는 없습니다.
  return this.http.get(filename, {responseType: 'text'})
    .pipe(
      tap( // HTTP 응답이나 에러를 로그로 출력합니다.
        data => this.log(filename, data),
        error => this.logError(filename, error)
      )
    );
}


이 때 HttpClient.get() 메서드에는 responseType 옵션이 사용되었기 때문에 기본 형식인 JSON 형식이 아니라 문자열 타입을 반환한다.


그리고 나서 RxJS tap 연산자를 사용해서 성공했을 때와 에러가 발생했을 때를 처리하고 있다.


이 메서드는 DownloaderComponent에 있는 download() 메서드가 시작한다.


app/downloader/downloader.component.ts (download())
download() {
  this.downloaderService.getTextFile('assets/textfile.txt')
    .subscribe(results => this.contents = results);
}


4. HTTP 에러 처리하기

서버로 보낸 요청이 실패하면 HttpClient는 성공 응답 대신 에러 객체를 반환한다. 그리고 이 에러 객체를 분석하면 왜 에러가 발생했는지 자세한 정보를 확인할 수 있다. 상황에 따라 요청을 다시 보낼 수도 있다.


서버로 요청을 보내는 서비스는 에러도 함께 처리하는 것이 좋다.


1) 에러 원인 확인하기

서버로 보낸 요청이 실패하면 이 요청이 왜 실패했는지 사용자에게 알려주는 것이 좋다. 이 때 에러 객체 자체를 표시하는 것은 유용하지 않다. 에러에 대한 세부정보를 확인한 후에 사용자가 이해하기 쉬운 형태로 안내하는 것이 좋다.


에러는 보통 두 가지 이유로 발생한다.


  • 백엔드 서버가 요청을 거부하면 HTTP 응답의 상태 코드가 404나 500이 된다. 이 응답은 에러 응답이다.
  • 네트워크 에러 등 클라이언트쪽에서 뭔가가 잘못되어 요청을 완료하지 못했거나 RxJS 연산자에서 에러가 발생한 경우이다. 이 에러 객체의 status 프로퍼티 값은 0이며, error 프로퍼티로 ProgressEvent 객체가 전달되기 때문에 이 객체의 type 필드를 확인하면 더 많은 정보를 확인할 수 있다.


HttpClient는 두 가지 에러를 모두 HttpErrorResponse로 처리하기 때문에, 서버로 보낸 요청이 왜 실패했는지 확인하려면 에러가 왜 발생했는지 파악해야 한다.


아래 코드는 이전 섹션에서 작성했던 ConfigService를 활용해서 에러를 처리하는 예제 코드이다.


app/config/config.service.ts (handleError())
private handleError(error: HttpErrorResponse) {
  if (error.status === 0) {
    // 클라이언트나 네트워크 문제로 발생한 에러.
    console.error('An error occurred:', error.error.message);
  } else {
    // 백엔드에서 실패한 것으로 보낸 에러.
    // 요청으로 받은 에러 객체를 확인하면 원인을 확인할 수 있습니다.
    console.error(
      `Backend returned code ${error.status}, body was: `, error.error);
  }
  // 사용자가 이해할 수 있는 에러 메시지를 반환합니다.
  return throwError(
    'Something bad happened; please try again later.');
}


이 핸들러 함수는 사용자가 이해할 수 있는 메시지를 담아 RxJS ErrorObservable을 보낸다. 아래 코드는 HttpClient.get() 함수 실행 결과를 파이프로 연결해서 에러 처리 함수로 보내는 getConfig() 메서드 코드이다.


app/config/config.service.ts (에러 처리 기능이 추가된 getConfig() v.3)
getConfig() {
  return this.http.get<Config>(this.configUrl)
    .pipe(
      catchError(this.handleError)
    );
}


2) 요청 재시도하기

HTTP 요청을 보냈을 때 발생한 에러가 일시적인 원인 떄문이라면 자동으로 재시도를 하는 것도 좋다. 모바일 디바이스인 경우에는 네트워크가 끊어지는 상황이 많기 때문에 이런 경우도 자연스럽게 처리하면 사용자가 더 편하게 앱을 사용할 수 있다.


RxJS 라이브러리가 제공하는 연산자 중에서 요청을 재시도할 때 활용할 수 있는 것들이 몇 가지가 있다. 이 중 retry() 연산자는 실패한 Observable를 정해진 횟수까지 자동으로 재구독하는 연산자이다. HttpClient 메서드가 반환한 에러 옵저버블을 다시 구독하면 HTTP 요청도 다시 발생한다.


아래 코드는 HTTPClient 메서드 실행결과를 에러 처리 함수에 전달하기 전에 retry() 연산자를 활용하는 getConfig() 메서드 코드이다.


app/config/config.service.ts (재시도 기능이 추가된 getConfig())
getConfig() {
  return this.http.get<Config>(this.configUrl)
    .pipe(
      retry(3), // HTTP 요청이 실패하면 3번 더 시도합니다.
      catchError(this.handleError) // 재시도한 후에도 발생한 에러를 처리합니다.
    );
}


5. 서버에 데이터 보내기

HttpClient로 서버에 데이터를 요청할 때 사용하는 HTTP 메서드가 PUT, POST, DELETE라면 서버로 추가 데이터를 보낼 수 있다.


이번 섹션에서는 "히어로들의 여행" 튜토리얼에서 히어로의 목록을 가져오고 추가, 삭제, 수정했던 예제를 간단하게 다시 구현해 본다. 예제에서 다루는 코드는 HeroesService만 해당된다.


1) POST 요청 보내기

데이터는 POST 방식으로 보낼 수도 있다. 일반적으로 POST 메서드는 폼을 제출할 때도 사용하며, 우리가 살펴보고 있는 HeroesService에서는 히어로를 DB에 추가할 때 사용한다.


app/heroes/heroes.service.ts (addHero())
/** POST: DB에 새로운 히어로를 추가합니다. */
addHero(hero: Hero): Observable<Hero> {
  return this.http.post<Hero>(this.heroesUrl, hero, httpOptions)
    .pipe(
      catchError(this.handleError('addHero', hero))
    );
}


HttpClient.post() 메서드는 get() 메서드와 비슷하다. 서버로부터 받아올 데이터의 타입을 제네릭으로 지정하고, 첫 번째 인자로 서버 API의 URL을 받는 것도 같다.


  • hero: POST 메서드일 때 요청으로 보낼 body 데이터를 지정한다.
  • httpOptions: HTTP 요청에 대한 옵션을 지정한다. 헤더 추가하기에서 지정한 옵션이다.


이 코드가 실행되면서 에러가 발생하면 위에서 설명했던 것처럼 처리된다.


이제 HeroesComponent가 옵저버블을 구독하면 POST 요청이 발생하며, 서버의 응답으로 받은 내용은 Observable 타입으로 전달된다.


app/heroes/heroes.component.ts (addHero())
this.heroesService.addHero(newHero).subscribe((hero) => this.heroes.push(hero));


그러면 새로운 히어로가 정상적으로 추가되었다는 것을 컴포넌트가 알 수 있고, heroes 배열에 이 히어로를 추가해서 새로운 목록으로 화면에 표시할 수 있다.


2) DELETE 요청 보내기

이 서비스는 히어로를 삭제할 때 HttpClient.delete 메서드를 활용하며, 삭제하려는 히어로의 ID는 url에 포함시켜 보낸다.


app/heroes/heroes.service.ts (deleteHero())
/** DELETE: DB에서 히어로를 삭제합니다. */
deleteHero(id: number): Observable<unknown> {
  const url = `${this.heroesUrl}/${id}`; // DELETE api/heroes/42
  return this.http.delete(url, httpOptions)
    .pipe(
      catchError(this.handleError('deleteHero'))
    );
}


이 메서드도 HeroesComponent가 구독할 때 실행되기 시작하며, 메서드가 실행되면서 DELETE 요청도 시작된다. 그리고 메서드 실행결과는 Observable 타입으로 반환된다.


app/heroes/heroes.component.ts (deleteHero())
this.heroesService.deleteHero(hero.id).subscribe();


컴포넌트는 삭제 동작의 결과값을 활용하지 않기 때문에 콜백 함수 없이 구독을 시작했다. 옵저버블 구독은 이렇게 옵저버를 지정하지 않으면서 시작할 수도 있다. subscribe() 메서드가 실행되면 옵저버블이 실행되고, DELETE 요청도 시작된다.


Note

  • 옵저버블은 subscribe() 함수를 실행해야 시작된다.
  • HeroesService.deleteHero()를 호출하는 것만으로는 DELETE 요청이 시작되지 않는다.


// subscribe()가 없으면 아무것도 시작되지 않습니다.
this.heroesService.deleteHero(hero.id);


HttpClient에서 제공하는 모든 메서드는 subscribe() 없이 HTTP 요청이 시작되지 않는다.


Note

  • 템플릿에서 AsyncPipe를 사용하면 옵저버블을 자동으로 구독하고 해지한다.


HttpClient 메서드가 반환하는 옵저버블은 모두 콜드 옵저버블(cold observable)이다. 옵저버블을 구독하는 객체가 없으면 HTTP 요청이 시작되지 않으며, tap이나 catchError와 같은 RxJS 연산자를 연결해도 구독 전에는 아무것도 실행되지 않는다.


그리고 subscribe(...)를 실행해야 옵저버블이 시작되고 HTTP 요청도 발생한다.


옵저버블은 실제 HTTP 요청을 표현한다고 이해할 수도 있다.


Note

  • subscribe() 함수는 실행될 때마다 새로운 옵저버블을 구성한다.
  • 그래서 이 함수가 두 번 실행되면 HTTP 요청도 두 번 발생한다.


3) PUT 요청 보내기

HTTP 클라이언트 서비스를 활용하면 PUT 요청을 보낼 수 있다. 아래 코드는 HeroService 예제 중에서 POST 예제와 비슷하지만 히어로 데이터를 갱신하기 위한 용도로 사용하는 예제 코드이다.


app/heroes/heroes.service.ts (updateHero())
/** PUT: DB 데이터를 수정합니다. HTTP 요청이 성공하면 새로운 히어로 데이터를 반환합니다. */
updateHero(hero: Hero): Observable<Hero> {
  return this.http.put<Hero>(this.heroesUrl, hero, httpOptions)
    .pipe(
      catchError(this.handleError('updateHero', hero))
    );
}


HTTPClient가 제공하는 메서드가 모두 그렇듯, put() 메서드도 옵저버블을 반환하기 때문에 HeroesComponent.update() 메서드는 반드시 subscribe()로 구독을 시작해야 실제 요청을 보낸다.


4) 헤더 추가/수정하기

서버가 저장 동작을 수행할 때 필요한 추가 정보를 헤더로 받는 경우가 있다. 인증 토큰을 요구한다던가 요청으로 보낸 내용의 MIME 타입을 결정하기 위해 "Content-Type"을 지정해야 하는 경우가 그렇다.


헤더 추가하기

HeroServiceHttpClient 메서드를 실행할 때 헤더를 추가로 지정하기 위해 다음과 같은 httpOptions 객체를 사용한다.


app/heroes/heroes.service.ts (httpOptions)
import { HttpHeaders } from "@angular/common/http";

const httpOptions = {
  headers: new HttpHeaders({
    "Content-Type": "application/json",
    Authorization: "my-auth-token",
  }),
};


헤더 수정하기

HttpHeaders 클래스의 인스턴스는 이뮤터블 객체이기 때문에 이미 존재하는 헤더를 직접 수정할 수 없다. 그래서 헤더의 내용을 변경하려면 set() 메서드를 실행하고 새로 생성되는 인스턴스를 활용해야 한다.


아래 예제 코드는 이미 만료된 인증 토큰을 새로운 토큰으로 갱신하는 예제 코드이다.


httpOptions.headers = httpOptions.headers.set(
  "Authorization",
  "my-new-auth-token"
);


6. HTTP URL 변수 활용하기

HttpRequest 옵션 중 paramsHttpParams 클래스를 활용하면 URL 쿼리 스트링을 인자로 전달할 수 있다.


아래 예제는 히어로 이름에 특정 단어가 들어간 히어로 목록을 조회하는 searchHeroes() 메서드이다.


먼저, HttpParams 클래스를 로드한다.


import { HttpParams } from "@angular/common/http";
/* 입력된 단어가 포함된 히어로 목록을 GET 방식으로 요청합니다. */
searchHeroes(term: string): Observable<Hero[]> {
  term = term.trim();

  // 전달된 인자로 HttpParams 객체를 생성합니다.
  const options = term ?
   { params: new HttpParams().set('name', term) } : {};

  return this.http.get<Hero[]>(this.heroesUrl, options)
    .pipe(
      catchError(this.handleError<Hero[]>('searchHeroes', []))
    );
}


이 코드는 검색어가 전달되면 HTML URL 인코딩된 형태로 옵션 객체를 생성한다. 그래서 "cat"이라는 검색어가 전달되면 GET 요청을 보내는 URL은 api/heroes?name=cat이 된다.


HttpParams 객체는 이뮤터블 객체이다. 그래서 옵션 항목의 값을 변경하려면 .set() 메서드를 실행했을 때 생성되는 객체를 활용하면 된다.


fromString 필드를 사용하면 쿼리 스트링을 HttpParams 객체로 직접 변환할 수도 있다:


const params = new HttpParams({ fromString: "name=foo" });


7. HTTP 요청/응답 가로채기

인터셉터를 활용하면 서버로 보내는 HTTP 요청을 가로채거나 변환할 수 있다. HTTP 요청에 적용된 인터셉터는 HTTP 응답에도 다시 활용할 수 있으며, 인터셉터 여러 개가 순서대로 실행되도록 체이닝할 수도 있다.


인터셉터는 다양한 기능을 수행할 수 있다. 일반적으로는 HTTP 요청/응답에 대해 사용자 인증 정보를 확인하고 로그를 출력하기 위해 사용한다.


만약 인터셉터를 사용하지 않는다면, 모든 HttpClient 메서드가 실행될 때마다 필요한 작업을 직접 처리해야 한다.


1) 인터셉터 구현하기

인터셉터를 구현하려면, HttpInterceptor 인터페이스를 사용하는 클래스를 정의하고 이 클래스 안에 intercept() 메서드를 정의하면 된다.


다음 코드는 기존 HTTP 요청을 변형하지 않고 그대로 통과시키는 인터셉터 기본 코드이다:


app/http-interceptors/noop-interceptor.ts
import { Injectable } from "@angular/core";
import {
  HttpEvent,
  HttpInterceptor,
  HttpHandler,
  HttpRequest,
} from "@angular/common/http";

import { Observable } from "rxjs";

/** 인자로 받은 HTTP 요청을 조작하지 않고, 다음 핸들러로 전달합니다. */
@Injectable()
export class NoopInterceptor implements HttpInterceptor {
  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    return next.handle(req);
  }
}


intercept 메서드는 Observable 타입으로 HTTP 요청을 받아서 HTTP 응답을 반환한다. 이것만 봐도 개별 인터셉터는 HTTP 요청에 대해 모든 것을 조작할 수 있다.


일반적으로 인터셉터는 요청을 보내거나 응답을 받는 방향을 그대로 유지하기 위해, HttpHandler 인터페이스로 받은 next 인자의 handle() 메서드를 호출한다.


export abstract class HttpHandler {
  abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;
}


intercept()와 비슷하게, handle() 메서드도 HTTP 요청으로 받은 옵저버블을 HttpEvents 타입의 옵저버블로 변환하며, 이 타입이 서버의 최종 응답을 표현하는 타입이다. intercept() 메서드는 이렇게 받은 서버의 응답을 확인할 수 있으며, HTTP 요청을 시작한 컨텍스트로 돌아가기 전까지 옵저버블의 내용을 조작할 수 있다.


원래 HTTP 요청이나 응답을 조작하지 않고 그대로 통과시키려면 단순하게 next.handle()을 실행하면 된다.


2) next 객체

next 객체는 체이닝되는 인터셉터 중 다음으로 실행될 인터셉터를 의미한다. 그리고 인터셉터 체인 중 마지막 인터셉터가 받는 next 객체는 HttpClient 백엔드 핸들러이며, 이 핸들러가 실제로 HTTP 요청을 보내고 서버의 응답을 첫 번째로 받는 핸들러이다.


인터셉터는 대부분 HTTP 요청이 진행되는 흐름을 그대로 유지하기 위해 next.handle()를 실행하며, 최종적으로는 백엔드 핸들러가 실행된다. 하지만 서버의 응답을 시뮬레이션하는 경우라면 next.handle()을 실행하지 않고 바로 Observable을 반환하면서 인터셉터 체인을 멈출 수도 있다.


이 방식은 Express.js와 같은 프레임워크에서 미들웨어 패턴으로 자주 사용하는 방식이다.


3) 인터셉터 적용하기

인터셉터를 등록하려면 @angular/common/http에서 HTTP_INTERCEPTORS 의존성 주입 토큰을 불러와서 다음과 같이 작성한다:


{ provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },


이 때 multi: true 옵션을 지정했다. 이 옵션을 지정하면 HTTP_INTERCEPTORS 토큰으로 적용되는 인터셉터가 하나만 있는 것이 아니라, 여러 개 있다는 것을 의미한다.


이 프로바이더 설정은 AppModule의 프로바이더 배열에 바로 추가할 수 있다. 하지만 인터셉터가 여러 개 있다면, 이 프로바이더 설정은 한 번에 묶어서 사용하는 방법도 좋다. 이렇게 인터셉터 여러 개를 동시에 적용한다면, 인터셉터가 실행되는 순서에 주의해야 한다.


인터셉터 프로바이더를 모두 파일 하나로 모으고, httpInterceptorProviders 배열로 관리해 보자. 먼저, 위에서 만든 NoopInterceptor를 다음과 같이 추가한다.


app/http-interceptors/index.ts
/* Http Interceptor를 한 번에 관리합니다. */
import { HTTP_INTERCEPTORS } from "@angular/common/http";

import { NoopInterceptor } from "./noop-interceptor";

/** Http interceptor 프로바이더를 실행 순서대로 등록합니다. */
export const httpInterceptorProviders = [
  { provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },
];


그리고 AppModule에 작성했던 프로바이더 배열을 다음과 같이 수정한다:


app/app.module.ts (인터셉터 프로바이더 등록하기)
providers: [
  httpInterceptorProviders
],


이제 새로운 인터셉터를 추가했을 때 httpInterceptorProviders에 등록하기만 하면, AppModule은 따로 수정하지 않아도 된다.


4) 인터셉터 실행 순서

인터셉터는 등록된 순서대로 실행된다. 서버로 요청을 보내기 전에 인증 필드를 추가하고 로그로 남겨야 한다고 하자. 이 경우에는 AuthInterceptor 서비스를 등록한 후에 LoggingInterceptor 서비스를 등록하면 된다. 그러면 외부로 향하는 요청이 AuthInterceptor를 거친 후에 LoggingInterceptor로 전달된다. 그리고 돌아오는 응답은 반대로 LoggingInterceptor를 거쳐 AuthInterceptor로 전달된다. 이 과정을 그림으로 표현해보면 이렇다:


001


인터셉터를 등록한 이후에 실행 순서를 변경하거나 특정 인터셉터를 건너뛸 수는 없다. 인터셉터를 적용할지 건너뛰어야 할지 지정하려면 인터셉터 안에 동적으로 로직을 작성해야 한다.


5) 인터셉터 이벤트 처리하기

HttpClient 메서드는 대부분 HttpResponse<any>를 옵저버블로 반환한다. 이 때 HttpResponse 클래스는 HttpEventType.Response이 지정되어 있기 때문에 그 자체로도 이벤트 하나를 표현한다. 그런데 HTTP 요청 하나가 처리되는 동안 다른 타입값으로 여러 번 이벤트가 발생할 수 있다. 업로드하거나 다운로드할 때 전달되는 진행률 이벤트가 그렇다. HttpInterceptor.intercept()HttpHandler.handle() 메서드는 모두 HttpEvent<any>를 옵저버블로 반환한다.


일반적으로 인터셉터는 외부로 보내는 요청에만 적용하고 next.handle()로 받아오는 방식으로 사용한다. 그런데 next.handle()이 반환하는 옵저버블을 활용하면 스트림으로 전달되는 응답 이벤트의 형태를 변환할 수 있다.


인터셉터가 요청이나 응답을 조작할 수는 있지만 HttpRequest 인스턴스와 HttpResponse 인스턴스의 프로퍼티는 readonly이기 때문에 이뮤터블이다. 이 프로퍼티들이 이뮤터블인 이유가 있다. 앱에서 보낸 요청이 실패하면 재시도하는 경우가 있는데, 이 때마다 인터셉터가 체이닝되면 인터셉터가 같은 요청을 여러 번 처리할 수 있다. 그래서 인터셉터는 기존에 보냈던 요청은 그대로 두고 새로운 객체를 받아서 변환 작업을 하는 것이 안전하다. 결국 인터셉터가 요청으로 보내는 객체를 한 번만 처리하는 것을 보장하기 위해 이뮤터블이 사용된다.


Note

  • 특별한 이유가 없다면 인터셉터가 변환하지 않는 이벤트 객체도 그대로 반환해야 한다.


다음과 같이 HttpRequest 읽기 전용 프로퍼티를 변경하는 코드는 TypeScript가 막기 때문에 사용할 수 없다.


// req.url은 readonly 로 지정되었기 때문에 TypeScript 에러가 발생합니다.
req.url = req.url.replace("http://", "https://");


그래서 요청을 조작하려면 먼저 이 요청을 복제한 후에 복제된 객체를 next.handle() 함수로 전달해야 한다. 요청으로 보내는 객체의 값은 한 번에 하나만 변경할 수 있다.


app/http-interceptors/ensure-https-interceptor.ts (일부)
// HTTP 인스턴스을 복사하면서 'http://'를 'https://'로 변경합니다.
const secureReq = req.clone({
  url: req.url.replace("http://", "https://"),
});
// 다음 핸들러에는 수정된 인스턴스를 전달합니다.
return next.handle(secureReq);


clone() 메서드는 해시 인자를 활용하기 때문에 특정 프로퍼티 값을 변경하더라도 나머지 프로퍼티 값은 그대로 사용할 수 있다.


6) 요청 바디 변환하기

readonly 가드는 특정 필드의 값이 변경되는 것을 방지하기 때문에, 다음과 같이 요청으로 보내는 필드의 값을 직접 변경할 수 없다.


req.body.name = req.body.name.trim(); // 이렇게 사용할 수 없습니다!


그래서 요청으로 보내는 바디를 조작해야 한다면 이렇게 해야 한다.


1] 요청 객체의 바디를 복사해서 원하는 대로 수정한다.

2] clone() 메서드를 실행해서 요청 객체를 복제한다.

3] 복제한 요청 객체의 바디를 수정한 바디로 교체한다.


app/http-interceptors/trim-name-interceptor.ts (일부)
// HTTP 바디를 복사하면서 name 필드의 공백을 제거합니다.
const newBody = { ...body, name: body.name.trim() };
// HTTP 요청 객체의 인스턴스를 복제하면서 새로운 바디를 적용합니다.
const newReq = req.clone({ body: newBody });
// 수정한 HTTP 요청을 다음 핸들러에 전달합니다.
return next.handle(newReq);


7) 요청으로 보내는 바디 비우기

어떤 경우에는 요청으로 보내는 바디를 교체하는 것이 아니라 비워야 할 때가 있다. 이 경우에는 요청으로 보내는 바디를 null 값으로 지정하면서 복제하면 된다.


Note

  • 요청 바디를 undefined 값으로 지정하면 Angular는 이 바디를 비우지 않는 것으로 간주한다.


newReq = req.clone({ ... }); // 기존 바디를 유지합니다.
newReq = req.clone({ body: undefined }); // 기존 바디를 유지합니다.
newReq = req.clone({ body: null }); // 바디의 내용을 비웁니다.


8. 진행상황 확인하기

애플리케이션이 리모트 서버에 보내는 데이터가 많다면 통신 시간도 오래 걸린다. 파일을 업로드하는 경우가 그렇다. 이 경우에는 업로드가 진행되는 상황을 사용자에게 표시하면 더 나은 UX를 제공할 수 있다.


요청을 보낼 때 진행률 이벤트를 활성화하려면 HttpRequest 인스턴스를 생성할 때 reportProgress 옵션의 값을 true로 지정하면 된다.


app/uploader/uploader.service.ts (업로드 요청)
const req = new HttpRequest("POST", "/upload/file", file, {
  reportProgress: true,
});


Note

  • 진행률 이벤트가 발생할 때마다 변화 감지 로직이 실행된다.
  • 이 옵션은 진행상황을 UI에 반영할 때만 켜는 것이 좋다.
  • HttpClient.request() 메서드를 실행할 때 observe: 'events' 옵션을 사용하면 진행률 이벤트를 포함한 모든 이벤트를 확인할 수 있다.


이제 HttpClient.request() 메서드에 요청 객체를 전달하면 이 메서드는 ObservableHttpEvents 객체를 반환한다. 이 이벤트 객체는 인터셉터가 처리하던 이벤트와 같다.


app/uploader/uploader.service.ts (업로드 코드)
// `HttpClient.request` API는 `HttpClient`에서 제공하는 다른 메소드보다
// 더 낮은 레벨의 이벤트 스트림을 생성합니다.
// 이 이벤트 스트림은 요청 시작, 진행률, 응답 이벤트를 전달됩니다.
return this.http.request(req).pipe(
  map((event) => this.getEventMessage(event, file)),
  tap((message) => this.showProgress(message)),
  last(), // 최종 메시지는 실행한 컨텍스트로 반환합니다.
  catchError(this.handleError(file))
);


getEventMessage 메서드는 이벤트 스트림으로 전달되는 HttpEvent 객체를 처리한다.


app/uploader/uploader.service.ts (getEventMessage())
/** 요청 시작, 업로드 진행률, 응답 이벤트를 사용자가 확인할 수 있는 메시지로 변환합니다. */
private getEventMessage(event: HttpEvent<any>, file: File) {
  switch (event.type) {
    case HttpEventType.Sent:
      return `Uploading file "${file.name}" of size ${file.size}.`;

    case HttpEventType.UploadProgress:
      // 진행률을 % 형식으로 변환합니다.
      const percentDone = Math.round(100 * event.loaded / (event.total ?? 0));
      return `File "${file.name}" is ${percentDone}% uploaded.`;

    case HttpEventType.Response:
      return `File "${file.name}" was completely uploaded!`;

    default:
      return `File "${file.name}" surprising upload event: ${event.type}.`;
  }
}


Note

  • 예제로 다루는 앱에서는 파일 업로드용 서버가 존재하지 않는다.
  • 대신 app/http-interceptors/upload-interceptor.ts 파일에 정의한 UploadInterceptor가 서버의 응답을 대신하는 방식으로 구현했다.


9. 서버로 보내는 요청 최적화하기

사용자가 입력한 내용으로 HTTP 요청을 보내야 한다고 하자. 그런데 이 때 키입력이 있을 때마다 HTTP 요청을 보내는 것은 비효율적이다. 이 방식보다는 사용자가 키 입력을 멈출 때까지 잠시 기다린 후에 요청을 보내는 것이 좋다. 이런 테크닉을 디바운싱(debouncing)이라고 한다.


아래 템플릿 코드는 사용자가 입력한 검색어로 npm 패키지를 검색하는 코드이다. 사용자가 입력 필드에 npm 패키지 이름을 입력하면 PackageSearchComponent가 이 값으로 검색 요청을 보낸다.


app/package-search/package-search.component.html (검색 컴포넌트 템플릿)
<input
  type="text"
  (keyup)="search(getValue($event))"
  id="name"
  placeholder="Search"
/>

<ul>
  <li *ngFor="let package of packages$ | async">
    <b>{{package.name}} v.{{package.version}}</b> -
    <i>{{package.description}}</i>
  </li>
</ul>


이 코드를 보면 keyup 이벤트가 컴포넌트 search() 메서드와 바인딩되었기 때문에 키입력 이벤트가 발생할 때마다 search() 메서드가 실행된다.


Note

  • 템플릿에서 확인할 수 있는 $event.target의 타입은 EventTarget이다.
  • 그래서 getValue() 메서드를 사용하려면 이 객체를 HTMLInputElement로 캐스팅해야 한다.


그리고 아래 코드는 RxJS 연산자로 입력값을 디바운싱하는 코드이다.


app/package-search/package-search.component.ts (일부)
withRefresh = false;
packages$!: Observable<NpmPackageInfo[]>;
private searchText$ = new Subject<string>();

search(packageName: string) {
  this.searchText$.next(packageName);
}

ngOnInit() {
  this.packages$ = this.searchText$.pipe(
    debounceTime(500),
    distinctUntilChanged(),
    switchMap(packageName =>
      this.searchService.search(packageName, this.withRefresh))
  );
}

constructor(private searchService: PackageSearchService) { }


searchText$는 사용자가 입력 필드에 입력한 값이 순서대로 전달되는 옵저버블이다. 이 옵저버블은 RxJS Subject 타입이기 때문에 next(값)을 실행해서 데이터를 여러 번 전달할 수 있다.


그렇다면 searchText 값이 변경될 때마다 PackageSearchService로 요청을 보내기보다 ngOnInit()에 구현한 것처럼 파이프를 사용해서 이 동작을 적절히 조절하는 것이 좋다. 이 코드에서는 사용자가 입력을 멈춘 시점에 값이 정말 변경되었을 때만 요청을 보내기 위해 연산자가 3개 사용되었다.


  • debounceTime(500): 사용자가 입력을 멈출 때까지 기다린다.
  • distinctUntilChanged(): 값이 실제로 변경된 것을 감지한다.
  • switchMap(): 서비스로 검색 요청을 보낸다.


연산자를 통과한 검색 결과는 packages$ 옵저버블에 저장된다. 그래서 템플릿에 AsyncPipe를 사용하면 검색 결과를 화면에서 확인할 수 있다.


1) switchMap() 연산자 활용하기

switchMap() 연산자는 Observable을 반환하는 연산자이다. 위에서 살펴본 예제에서도 PackageSearchService.search() 함수는 Observable을 반환한다. 만약 이전에 보낸 요청이 아직 완료되지 않았다면 switchMap() 연산자는 이전 요청을 취소하고 새로운 요청을 보낸다.


그리고 switchMap() 연산자는 서버가 응답하는 순서와 관계없이 요청을 보냈던 순서대로 응답을 반환한다.


Note

  • 디바운싱 로직을 재사용하려면 이 로직을 유틸리티 함수로 옮기거나 PackageSearchService 안쪽으로 옮기는 것이 좋다.


10. 보안: XSRF 방어

사이트간 요청 위조(Cross-Site Request Forgery, XSRF)는 인증받지 않은 사용자가 웹사이트를 공격하는 방법 중 하나이다. Angular에서 제공하는 HttpClient는 XSRF 공격을 방어하는 기능을 탑재하고 있다. 그래서 HTTP 요청이 발생했을 때 쿠키에서 토큰을 읽는 인터셉터가 자동으로 동작하며, XSRF-TOKEN으로 설정된 HTTP 헤더를 X-XSRF-TOKEN으로 변경한다. 결국 현재 도메인에 유효한 쿠키만 읽을 수 있으며, 백엔드가 HTTP 요청을 좀 더 안전하게 처리할 수 있다.


기본적으로 이 인터셉터는 상대 주소로 요청되는 모든 요청에 적용되며, 절대 주소로 요청되는 GET/HEAD 요청에는 적용되지 않는다.


그래서 모든 요청에 사이트간 위조된 요청을 방어하려면, 페이지가 로드되거나 처음 발생하는 GET 요청에 대해서 쿠키에 XSRF-TOKEN이 있는지 확인해야 한다. 그리고 이후에 발생한 요청의 헤더에 X-XSRF-TOKEN이 있으면 요청이 유효한 것으로 판단하며, 유효한 도메인에서 제대로 보내진 요청이라는 것으로 최종 판단할 수 있다. 이 때 사용하는 토큰은 사용자마다 달라야 하며, 서버에서 반드시 인증되어야 한다. 그래야 클라이언트에서 토큰을 위조하는 것도 방어할 수 있다. 서버에서 토큰을 생성할 때 인증키를 활용하면 좀 더 확실하다.


Angular 앱 여러 개가 같은 도메인이나 서브도메인을 사용해서 이 부분에 충돌이 발생한다면 애플리케이션마다 유일한 쿠키 이름을 사용해야 한다.


Note

  • HttpClient에서 제공하는 XSRF 방어 동작은 클라이언트에만 적용되는 내용이다.
  • 백엔드에서도 페이지에 쿠키를 설정해야 하며, 클라이언트에서 발생하는 모든 요청이 유효한지 확인해야 한다.
  • 백엔드에서 이 내용을 확인하지 않으면 Angular의 보안 로직이 제대로 동작하지 않는다.


1) 커스텀 쿠키/헤더 이름 지정하기

백엔드에서 XSRF 토큰 쿠키나 헤더를 다른 이름으로 사용하고 있다면 HttpClientXsrfModule.withOptions()를 사용해서 이름을 변경할 수 있다.


imports: [
  HttpClientModule,
  HttpClientXsrfModule.withOptions({
    cookieName: 'My-Xsrf-Cookie',
    headerName: 'My-Xsrf-Header',
  }),
],


11. HTTP 요청 테스트하기

리모트 서버와 통신하는 로직을 테스트하려면 HTTP 백엔드를 목킹해야 한다. 그리고 Angular는 이런 작업을 위해 @angular/common/http/testing 라이브러리를 제공한다.


Angular가 제공하는 HTTP 테스트 라이브러리를 사용할 때는 먼저 리모트 서버로 요청을 보내야 한다. 그러면 이 요청을 가져와서 어떤 내용이 담겨있는지 검사하고, 정해진 형식으로 응답을 보낸다(flushing).


그리고 마지막으로 의도하지 않은 요청이 발생했는지 검사한다.


1) 테스트 환경설정

HttpClient를 테스트하려면 먼저 테스트용 모듈인 HttpClientTestingModule과 목업 환경을 구성하는 HttpTestingController를 로드해야 한다.


app/testing/http-client.spec.ts (심볼 로드)
// Http 테스트 모듈과 목업 컨트롤러를 로드합니다.
import {
  HttpClientTestingModule,
  HttpTestingController,
} from "@angular/common/http/testing";

// 다른 심볼도 로드합니다.
import { TestBed } from "@angular/core/testing";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";


그리고 나면 TestBedHttpClientTestingModule를 추가하면서 테스트 환경을 구성한다.


app/testing/http-client.spec.ts (환경 설정)
describe("HttpClient testing", () => {
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
    });

    // http 서비스와 테스트 컨트롤러를 각 테스트 케이스에 주입합니다.
    httpClient = TestBed.inject(HttpClient);
    httpTestingController = TestBed.inject(HttpTestingController);
  });
  /// 테스트 케이스 시작 ///
});


이제 테스트 케이스에서 HTTP 요청이 발생하면 실제 백엔드가 아니라 테스팅 백엔드로 전달된다.


이 코드에서 HttpClient 서비스와 목업 컨트롤러를 테스트 케이스마다 동적으로 주입하기 위해 TestBed.inject()을 사용했다.


2) 요청 확인하기, 요청에 응답하기

이제 GET 요청이 발생하는지 확인하고 목업 응답을 보내는 테스트 케이스를 작성해 보자.


app/testing/http-client.spec.ts (HttpClient.get())
it("can test HttpClient.get", () => {
  const testData: Data = { name: "Test Data" };

  // HTTP GET 요청을 발생시킵니다.
  httpClient.get<Data>(testUrl).subscribe((data) =>
    // 옵저버블이 처리되고 받은 응답이 테스트 데이터와 같은지 검사합니다.
    expect(data).toEqual(testData)
  );

  // `expectOne()`은 HTTP 요청의 URL과 매칭됩니다.
  // 이 주소로 HTTP 요청이 발생하지 않거나 여러번 요청되면 에러를 반환합니다.
  const req = httpTestingController.expectOne("/data");

  // HTTP 요청 방식이 GET인지 검사합니다.
  expect(req.request.method).toEqual("GET");

  // 목업 데이터로 응답을 보내면 옵저버블이 종료됩니다.
  // 옵저버블로 받은 데이터는 구독 함수에서 검사합니다.
  req.flush(testData);

  // 마지막으로, 보내지 않고 남아있는 HTTP 요청이 있는지 검사합니다.
  httpTestingController.verify();
});


모든 응답이 처리되었는지 마지막으로 검사하는 로직은 afterEach()로 옮겨도 된다:


afterEach(() => {
  // 각 테스트 케이스가 끝나기 전에, 보내지 않고 남아있는 HTTP 요청이 없는지 확인합니다.
  httpTestingController.verify();
});


HTTP 요청 객체 검사하기

지정된 URL로 HTTP 요청이 왔는지 검사하는 것만으로는 충분하지 않다면, 검사 로직을 직접 작성할 수도 있다. 예를 들어 HTTP 요청 헤더에 인증 토큰이 있는지 검사하는 로직은 다음과 같이 구현할 수 있다:


// 헤더에 인증 토큰이 있는지 검사합니다.
const req = httpTestingController.expectOne((request) =>
  request.headers.has("Authorization")
);


그러면 이전에 살펴본 expectOne()과 마찬가지로, HTTP 요청이 발생하지 않거나 2번 이상 발생한 경우에도 마찬가지로 에러를 발생시킨다.


여러 번 요청되는 HTTP 테스트하기

테스트 케이스가 실행되는 중에 HTTP 요청이 같은 주소로 여러 번 발생한다면, expectOne() 대신 match() API를 사용할 수도 있다. 이 함수는 expectOne()를 사용하는 방법과 같지만, 주소와 매칭되는 HTTP 요청을 배열로 반환한다. 그러면 이 배열을 한 번에 테스트할 수도 있고, 배열의 항목을 각각 테스트할 수도 있다.


// 지정된 URL과 매칭되는 HTTP 요청을 모두 가져옵니다.
const requests = httpTestingController.match(testUrl);
expect(requests.length).toEqual(3);

// 각각의 요청에 서로 다른 응답을 보냅니다.
requests[0].flush([]);
requests[1].flush([testData[0]]);
requests[2].flush(testData);


3) 에러 테스트하기

HTTP 요청이 실패한 경우에 애플리케이션의 방어 로직이 제대로 동작하는지도 테스트해야 한다.


이 때 request.flush()에 에러 객체를 보내면 HTTP 통신에 실패한 상황을 테스트할 수 있다.


it("can test for 404 error", () => {
  const emsg = "deliberate 404 error";

  httpClient.get<Data[]>(testUrl).subscribe(
    (data) => fail("should have failed with the 404 error"),
    (error: HttpErrorResponse) => {
      expect(error.status).toEqual(404, "status");
      expect(error.error).toEqual(emsg, "message");
    }
  );

  const req = httpTestingController.expectOne(testUrl);

  // 에러 응답을 보냅니다.
  req.flush(emsg, { status: 404, statusText: "Not Found" });
});


그리고 이 방식은 ErrorEvent 객체를 request.error() 함수에 전달하는 방식으로도 구현할 수 있다.


it("can test for network error", () => {
  const emsg = "simulated network error";

  httpClient.get<Data[]>(testUrl).subscribe(
    (data) => fail("should have failed with the network error"),
    (error: HttpErrorResponse) => {
      expect(error.error.message).toEqual(emsg, "message");
    }
  );

  const req = httpTestingController.expectOne(testUrl);

  // ErrorEvent 객체를 생성합니다. 이 에러는 네트워크 계층에서 발생하는 에러를 의미합니다.
  // 타임아웃, DNS 에러, 오프라인 상태일 때 발생하는 에러가 이런 종류에 해당합니다.
  const mockError = new ErrorEvent("Network error", {
    message: emsg,
  });

  // 에러 응답을 보냅니다.
  req.error(mockError);
});

References