본문 바로가기

5. 서비스와 의존성 주입 (2)

1. 싱글톤 서비스

4장 실습에서 MenuModule과 CartModule에 SharedModule을 각각 임포트하면 각각 다른 모듈 인젝터에 인스턴스가 생성되어 데이터가 공유되지 않는다는 것을 확인하였습니다.

 

주문 리스트 정보를 관리하는 OrderService는 어플리케이션에서 단 하나만 존재해야 합니다. 즉 데이터 통신을 전역에서 수행하도록 변경해야 합니다. 이렇게 전역에서 단 하나의 인스턴스만 생성되어 관리되는 인스턴스가 싱글톤 서비스입니다.

 

싱글톤 서비스를 만드는 방법은 간단합니다. 서비스를 루트 모듈에만 프로바이딩하면 싱글톤 서비스가 됩니다.

 

공식문서의 싱글톤 서비스에서 서비스를 루트 모듈에 프로바이딩하는 방법으로 세 가지를 소개합니다.

  • @Injectable({ providedIn: "root" })로 설정 (tree-shakable하므로 추천)
  • 루트 모듈의 providers 리스트에 추가하거나, 루트 모듈에서만 import하는 모듈의 providers에 추가 (tree-shakable하지 않아서 비추천)
  • forRoot() 패턴을 사용

가장 쉬운 방법으로는 서비스의 @Injectable() 데코레이터의 providedIn을 'root'로 설정하는 것입니다.

@Injectable({
  providedIn: 'root'
})
export class MyService {}

같은 동작을 하는 @NgModule()의 providers에서 서비스를 프로바이딩할 수도 있지만 Angular 6.0부터 이 방법이 트리 쉐이킹이 되어 필요한 모듈만을 로드할 수 있기 때문에 추천합니다.

 

트리 쉐이킹이란, 나무를 흔들면 일부 나뭇가지가 땅으로 떨어지는 것처럼 필요한 기능만을 내 런타임 환경에서 남겨두고 필요하지 않는 코드를 제거하는 것을 의미합니다. providedIn에서 명시한 서비스가 컴포넌트에서 실제로 사용하지 않으면 런타임에서 제거됩니다.

동작은 동일하지만, Angular 6.0부터는 트리 셰이킹이 가능한 서비스를 명확하게 지정하기 위해 서비스 클래스의 @Injectable() 데코레이터에 providedIn 메타데이터를 지정하는 방법을 더 권장합니다.

1.1 forRoot() 패턴

그런데 만약 서비스를 프로바이딩하는 모듈이 서비스 뿐만 아니라 다른 declarations또한 export해야 한다면 위의 방법은 모듈간의 관계에 따라 루트 모듈에 중복 생성될 수 있는 위험이 있습니다.

 

이러한 위험을 확실하게 방지하려면 서비스를 프로바이딩하는 주체가 모듈이 아니면 될 것입니다. 앵귤러에서는 모듈과 프로바이더를 분리하는 방법으로 forRoot() 패턴을 소개합니다.

 

모듈 클래스에서 forRoot() 메소드를 생성하여 메소드에서 서비스를 프로바이딩하여 모듈과 함께 객체로 리턴합니다.

export class SharedModule {  
  static forRoot () : ModuleWithProviders {
    return {
      ngModule: SharedModule,
      providers: [
        ApiService, StorageService
      ]
    }
  }
}

이제 루트 모듈에서 forRoot()메소드를 사용하여 모듈을 임포트합니다.

@NgModule({
  declarations: [AppComponent],
  imports: [
    AppRoutingModule, 
    SharedModule.forRoot(),
  ]
  bootstrap: [AppComponent],
})
export class AppModule {}
[주의] 만약 모듈이 아래 코드처럼 declarations을 export하는 동시에 싱글톤 서비스를 프로바이딩한다면 루트 모듈에서 forRoot()를 호출하여 임포트하는 것만으로는 충분하지 않습니다.

해당 모듈의 declarations를 사용하는 lazy load되는 모듈에도 추가적으로 임포트해야 비로소 declarations를 사용할 수 있습니다. 단, 이때는 forRoot()가 아닌 모듈 자체를 임포트합니다.

프로바이더는 모듈이 아닌 forRoot()에 의해 프로바이딩되므로 서비스는 여전히 싱글톤입니다.
@NgModule({
  imports: [
    CommonModule,
    SharedModule,
  ],
})
export class SubModule {} // SharedModule의 declarations 사용 가능

2. Resolution과 Resolution Modifier

이 장은 공식 문서의 인젝터 계층을 중복해서 읽으면서 이해한 내용을 바탕으로 작성하였습니다.

 

(1)에서 인젝터란, DI할 서비스 클래스 인스턴스를 관리하며 모듈 인젝터와 노드 인젝터 두 종류가 있다고 하였습니다.

 

모듈 인젝터는 모듈 단위에서 서비스 인스턴스를 관리하며 지연 로딩되는 모듈은 각각의 인젝터를 가지고, 그 위에 차례로 루트 인젝터 > 플랫폼 인젝터 > Null인젝터가 존재하는 인젝터 트리로 존재합니다.

 

노드 인젝터는 컴포넌트 별로 생성되며, DOM 요소 간 관계에 기반한 컴포넌트 상하 관계에 따른 인젝터 트리가 형성됩니다.

 

이제 이 트리를 탐색하면서 컴포넌트가 DI를 받을 때 서비스 인스턴스를 검색하고 생성하는 과정을 알아볼건데 이 과정을 Resolution이라고 부릅니다.

2.1 Resolution 과정

컴포넌트에서 서비스를 DI받을 때 가장 먼저 탐색하는 트리는 노드 인젝터 트리입니다.

 

노드 인젝터 트리에서 가장 먼저 탐색하는 대상은 서비스를 DI받는 대상 컴포넌트의 인젝터로 자신의 인젝터에 프로바이딩되었는지 우선 탐색합니다.

 

현재 컴포넌트의 인젝터에 DI 토큰이 없다면 상위 컴포넌트의 인젝터를 탐색하기 시작하여 루트 컴포넌트까지 탐색합니다.

 

만약 현재 노드 인젝터 트리 상에서 DI 토큰을 찾지 못했다면 모듈 인젝터 트리를 탐색하기 시작해 컴포넌트가 선언된 모듈 인젝터부터 탐색합니다.

 

컴포넌트가 선언된 모듈 인젝터에서 실패했다면 모듈이 임포트된 루트모듈에서 검색합니다. 루트 모듈은 어플리케이션 상 최상위 모듈이고, 여기서 실패한다면 이제 플랫폼 인젝터로 거슬러 올라갑니다.

 

플랫폼 인젝터란, 여러 개의 어플리케이션이 공유하는 platform에 특화된 의존성 인스턴스가 저장되는 인젝터입니다. 루트 인젝터가 어플리케이션 고유의 인젝터라면, 플랫폼 인젝터는 서버사이트 렌더링과 관련이 있어 예컨대 nodejs 환경의 모듈 인젝터를 일컫습니다.

 

플랫폼 인젝터까지 탐색에 실패했다면 가장 최상위 모듈인 Null인젝터에서 DI할 서비스를 찾지 못했다는 ProvidingError를 반환합니다. 단, Resolution Modifier 중 @Optional()을 사용하였다면 에러가 아닌 null을 반환합니다.

 

위에서 기술했던 과정을 한 눈에 보기 쉬운 그림으로 표현하면 아래와 같습니다. 출처는 (1)에서 인젝터 종류에 대해 설명했던 곳과 동일합니다.

해당 인포그래픽은 DI의 모든것을 담았다고 해도 무방할 정도로 Resolution과정을 잘 묘사했다고 생각합니다.

붉은색 화살표와 번호를 따라가면 해당 내용을 이해하는데 도움이 될 것입니다.

2.2 Resolution Modifier 종류와 소개

Resolution Modifier는 constructor()의 인자로 서비스를 주입할 때 인스턴스 탐색과정을 변경할 수 있는 데코레이터로 Resolution 과정을 조정할 수 있습니다. 종류는 다음과 같습니다.

  • @Optional() : DI 토큰에 매치되는 인스턴스를 찾지 못했을 때 ProvidingError가 아닌 null을 반환
  • @Self() : DI 받는 컴포넌트의 노드 인젝터에서만 탐색하고 종료
  • @SkipSelf() : DI 받는 컴포넌트를 제외하고 부모 노드 인젝터부터 탐색
  • @Host() : 호스트 엘리먼트까지만 탐색 (즉, 부모 컴포넌트 인젝터까지만 탐색)

Modifier는 여러 개를 동시에 사용할 수도 있습니다. 예를 들어 @Self()와 @Optional()을 함께 사용하면 대상 컴포넌트 인젝터에서 찾지 못하면 null을 반환합니다.

 

템플릿의 논리 구조에서 이 modifier들을 어떻게 등록하느냐에 따라 동작 방식이 다를 수 있습니다. 자세한 건 provider와 view provider의 차이점을 소개하고 modifier에 따른 차이점을 설명하겠습니다.

2.3 Provider와 View Provider

지금까지 서비스를 프로바이딩할 때 사용하였던 @Component()의 providers에 정의된 DI 대상들은 컴포넌트 클래스에 서비스를 프로바이딩한 것입니다.

 

providers이외에 viewProviders라는 프로바이더는 컴포넌트에 해당하는 템플릿 뷰에 서비스를 프로바이딩한 것인데, 논리적인 구조로 따지자면 다음과 같습니다.

만약 컴포넌트 클래스에서 providers를 먼저 정의하고

자식 컴포넌트에서는 viewProviders로 동일한 토큰으로 DI받겠습니다.

차이점을 보기 위해 Content Projection으로 손자 컴포넌트를 생성 후 자식 컴포넌트에 Content Projection합니다.

자식 컴포넌트의 this.val과 손자 컴포넌트의 this.val의 값은 무엇일까요?

 

정답은 자식 컴포넌트의 this.val은 MySecondService의 val이고, 손자 컴포넌트의 this.val은 MyService의 val입니다.

 

viewProviders는 Content Projection된 요소를 제외한 템플릿 뷰에 서비스를 providing한 것으로, <ng-content>에 속하는 요소는 해당 서비스 인스턴스에 접근할 수 없습니다. 반면에 providers로 프로바이딩한 서비스는 Content projection 요소를 포함한 모든 자식 컨텐츠들까지 서비스 인스턴스를 사용할 수 있습니다.

 

이제 providers와 viewProviders에 Resolution Modifiers를 사용해보고 그 차이점을 알아보겠습니다.

차이점이 도드라지는 옵션은 @Host와 @SkipSelf를 함께 사용했을 때입니다.

 

예를 들어 다음의 예시에서 UserService와 CartService의 결과는 각각 무엇일지 생각합니다.

정답은 UserService는 null, CartService는 부모 인젝터에 DI된 값인 ['milk', 'cookie']입니다.

 

왜 이런 결과가 나오게 되었는지는 아래의 템플릿의 논리적인 구조를 살펴보면 이해될 것입니다.

@SkipSelf()로 인해 AppChild 컴포넌트의 인젝터는 무시되고 AppParent 컴포넌트부터 탐색을 시작합니다. 또한 @Host()에 의해 AppParent 컴포넌트의 <#VIEW>까지 탐색하고 종료됩니다.

3. HttpClientModule

3장의 Module에서 모듈의 종류 중 서비스 모듈에 대해 소개할 때 대표적인 예시로 HttpClientModule이 있다고 하였습니다.

 

앵귤러에서는 백엔드와의 통신을 위한 http 요청을 제공하는 HttpClientModule을 제공하여 보다 더 쉬운 서버 요청을 작성할 수 있도록 지원합니다.

 

앵귤러의 HttpClient는 XMLHttpRequest 인터페이스를 활용하였으며 요청 전후에 요청과 응답을 가로채어 별도의 로직을 작성할 수 있는 HttpInterceptor, Observable, 스트림 기반 에러 처리를 제공합니다.

 

http 요청을 하기 위한 과정으로 먼저 HttpClientModule을 임포트합니다.

@NgModule({
  //...중략...
  imports: [HttpClientModule]
})
export class AppModule {}

이제 http 요청이 필요한 클래스에서 HttpClient를 DI받습니다. 주로 서비스 클래스에서 DI받아 http 요청을 담당합니다.

@Injectable()
export class UserService {
  constructor(private http:HttpClient) {}
  apiUrl = `api/user/`;
  
  getUserInfo():Observable<{}>{
  	return this.http.get(`${this.url}/info`);
  }
}

HttpClient의 get 메소드는 Observable이라는 비동기 처리의 표준이 되는 객체를 반환하고, 이를 뷰 컴포넌트에서 subscribe하여 응답 데이터를 받을 수 있습니다.

 

자세한 사항은 6장의 RxJs를 소개할 때 더 자세한 사용법과 개념을 익히겠습니다.

4. [실습] 서비스를 싱글턴 서비스로 만들기

이제 저번에 작성했던 orderService를 싱글턴으로 만들어서 /menu와 /cart 페이지가 데이터를 공유할 수 있도록 작성합니다.

 

sharedModule에서 forRoot 패턴으로 작성하기 위해 forRoot 메소드를 작성합니다.

이제 menuModule과 cartModule에서 각각 임포트 받았던 것을 지우고 루트 모듈인 AppModule에서 forRoot()메소드를 호출하여 임포트합니다.

그런데 sharedModule을 보면 declarations에 정의한 KrCurrencyPipe를 export하고 있습니다.

[참고] 파이프에 대한 설명을 한번도 한적이 없는거 같은데 파이프는 화면 즉 템플릿 뷰에 표시되는 데이터를 일관되게 변환할 때 사용합니다. 예를 들어 날짜를 일정한 형식으로 변환하거나, 화폐 단위를 우리나라 (원)에 맞게 변경하는 등의 작업을 수행할 수 있습니다. 자세한 설명은 여기를 참고하십시오.

forRoot()의 [주의]에서 설명한 것처럼 서비스 프로바이딩 외의 declarations를 export하고 있다면 해당 declarations을 사용하는 모듈에 별도로 import해야 합니다. 따라서 menuModule과 cartModule에 sharedModule자체를 임포트합니다.

여기까지 완성되었다면 이제 메뉴 페이지에서 추가한 메뉴가 주문 리스트 페이지에서도 뜨는 것을 확인할 수 있습니다.

이렇게 서비스로 로직을 뷰로부터 분리하고 싱글턴으로 만들어 어느 컴포넌트를 가도 자원을 공유할 수 있도록 설정하는 것까지 마쳤습니다.

4.1 로컬스토리지에 데이터 저장하기

이제 주문 프로세스를 좀 더 개선해보겠습니다. 일반적으로 장바구니에서 '주문하기'버튼을 클릭하면 주문 페이지로 넘어가고 주문이 완료되면 주문 성공페이지가 뜹니다.

 

원래라면 서버에서 DB에 데이터를 저장하고 응답을 주지만 서버를 만드는 중이라 임시로 브라우저 로컬스토리지에 저장하는 로직을 추가합니다.

 

local-storage.service.ts를 생성하여 주문 내역을 로컬 스토리지에 저장하고 데이터를 주문 성공 페이지에 출력하는 것을 구현해보겠습니다.

 

persist-state/services/에 로컬스토리지에 데이터를 저장하고 가져오는 서비스 클래스를 만듭니다.

[참고] setItem, getItem 로직만 작성할 수도 있지만 글쓴이는 만료 시간을 설정하여 만료되면 더이상 가져오지 못하는 로직과 함께 기존의 데이터 배열에 추가하는 append 메소드를 구현하였습니다.

다음으로 models/에서 Order.History 인터페이스를 추가합니다.

이제 주문 페이지에서 LocalStorageService를 DI받아서 '주문하기' 버튼을 클릭하면 로컬스토리지에 데이터를 설계한 형식대로 저장하는 로직을 구현합니다.

[참고] 아직 RxJs와 Observable에 대해 글을 작성하지 않았지만 비동기 데이터 스트림 처리를 묘사하고자 RxJs와 옵저버블을 사용하였습니다. 원래라면 서버에 요청을 보내는 형식으로 작성합니다만 해당 구현은 서버 구현이 완료되고나서 RxJs에 대한 실습을 진행할 때 작성하겠습니다 :)

아마 옵저버블을 처음 접하시는 분이라면 코드가 난해할 수 있지만 로컬스토리지에 데이터를 저장하는 saveOrder() 메소드에서 this.storageService.append()로 'order' key에 데이터를 저장하는 것을 알 수 있습니다.

 

또한 서버 통신 요청 중에 응답이 오기 전인 pending 상태에서는 로딩 ui가 보일 수 있도록 loading$ 데이터를 두어 로딩 상태를 확인할 수 있도록 하였습니다.

 

여기까지 완성되었다면 이제 화면은 다음처럼 보일 것입니다.

왼쪽부터 순서대로 진행

로컬 스토리지에는 아래처럼 데이터가 저장되어 있습니다.

여기까지 서비스와 의존성 주입의 기본 활용을 마치겠습니다.

 

글쓴이가 회사의 실무에서 보았던 서비스가 담당하는 역할은 주로 HttpClientModule과 함께 직접적으로 http 요청을 하는 로직을 작성하여 서버 통신을 요청하고 옵저버블을 리턴하는데 사용되었습니다.

 

또한 여러 개의 컴포넌트에서 중복적으로 쓰이는 로직을 서비스로 분리하여 모듈화하여 코드의 재사용을 높이는데 쓰이기도 합니다. (스크롤 복원, 스토리지 저장 로직 등)

 

6장은 앵귤러가 비동기를 처리하고 상태를 관리하는데 있어 적극 권장하는 RxJs와 NgRx를 공부하도록 하겠습니다.

'Today I Learn > Angular 기초' 카테고리의 다른 글

7. RxJS와 NgRx (2)  (0) 2020.07.18
6. RxJs와 NgRx (1)  (0) 2020.07.08
4. 서비스와 의존성 주입 (1)  (0) 2020.06.30
3. Modules  (0) 2020.06.26
2. Component와 Directive (2)  (0) 2020.06.20