본문 바로가기

7. RxJS와 NgRx (2)

1. NgRx 동작 원리

NgRx는 리액트의 redux처럼 Reactive한 앵귤러 어플리케이션에서 상태관리를 위한 라이브러리입니다.

 

redux의 flux 패턴을 이해하고 있다면 NgRx도 그 영향을 받았기 때문에 단방향으로 데이터가 흐르고 있다는 걸을 알 수 있습니다. flux 패턴은 여기에서 정확히 확인할 수 있습니다.

 

flux 패턴은 MVC 패턴의 양방향 데이터 바인딩에서 앱 규모가 커짐에 따라 데이터 흐름을 추적하기 어려워지고 불필요한 업데이트가 발생할 수 있는 어려움을 해결하기 위해 중앙에 스토어를 두고 데이터를 액션에 의해서만 변경할 수 있는 순수 함수인 리듀서를 두어 데이터를 단방향으로 관리합니다.

 

NgRx도 이러한 흐름을 그대로 적용하고 있기 때문에 flux 패턴을 먼저 이해한다면 큰 어려움 없이 적응할 수 있을 것입니다.

(글쓴이는 이전에 리액트에서 redux를 사용하였기 때문에 큰 어려움이 없었습니다.)

 

먼저 NgRx의 전체적인 흐름을 표현한 다이어그램을 소개합니다.

출처: NgRx 공식 홈페이지

위의 다이어그램에서 Effect, Service, API로 흐르는 부분은 side effect를 처리하기 위한 부분이므로 우선 생략하고 보면

 

COMPONENT → ACTION → REDUCER(STORE) → SELETOR → COMPONENT

 

흐름인 것을 확인할 수 있습니다. flux 패턴의 다음 그림과 일맥상통하는 것을 알 수 있습니다.

출처: flux 공식 홈페이지

1.1 Selector

selector는 NgRx만의 특별한 패턴은 아닙니다. redux에도 selector가 존재합니다. selector는 컴포넌트에서 스토어에서 가져와야할 데이터를 선별적으로 가져오거나 여러 개의 데이터를 조합하여 계산한 결과값을 사용하고 싶을 때 등 원하는 데이터 형태로 가공할 때 유용하게 활용됩니다.

 

NgRx의 Selector는 이뿐만아니라 메모이제이션(memoization)까지 제공하여 이전 state값을 캐시에 저장해두었다가 현재 값과 비교를 통해 값의 변경이 없으면 캐시 값을 반환합니다. 따라서 앵귤러가 변경 감지에 의한 리렌더링이 state의 변경에 의해 무분별하게 발생하는 것을 방지할 수 있습니다.

 

셀렉터에 대해 참고하면 좋을 링크들입니다.

1.2 Effect

이펙트란, 리덕스 미들웨어들처럼 다양한 부수 효과를 처리할 수 있습니다. 또한 리덕스 미들웨어와 마찬가지로 부수 효과에 의한 새 액션을 디스패치할 수도 있습니다.

Effects perform tasks, which are synchronous or asynchronous and return a new action.

실무에서는 주로 이펙트를 서버 통신 후 그 결과값의 성공/실패 여부에 따라 액션을 다르게 디스패치하는 역할을 담당합니다.

2. NgRx로 State 관리하기

이제 NgRx를 앵귤러 프로젝트에 도입하는 순서를 설치부터 state 사용까지 알아보겠습니다. 공식 문서에 자세히 나와 있어 이를 참고하였습니다.

 

@ngrx/store와 @ngrx/effects 패키지가 필요하니 먼저 두 패키지를 설치합니다.

 

지금까지 실습에서 썼던 코드 중 메뉴 컴포넌트에서 메뉴를 가져오는 부분과 검색하는 로직을 스토어와 이펙트를 활용하여 설정해보겠습니다.

실습처럼 보이겠지만 실습에는 그닥 적합한 부분은 아니라서 초기 세팅하는 용도로 사용하였습니다.

 

먼저 지연로딩 모듈 디렉토리 내에 (실습에서는 menu/) reducers/, actions/을 생성합니다. 각각 리듀서와 액션을 정의하는 부분을 담당합니다.

2.1 State 생성

먼저 리듀서 파일에 스토어에 저장할 데이터를 설정합니다.

 

메뉴 컴포넌트에는 커피 리스트만 저장하고 있으면 되서 타입을 지정하는 인터페이스와 초기 State값을 생성합니다.

// State interface 정의
export interface State {
  coffees: Product.Coffee[];
}

// State 초기값 정의
const initialState: State = {
  coffees: [],
};

2.2 Reducer 생성

@ngrx/store의 createReducer()는 첫번째 인자로 초기 State를 받고, 두번째 인자부터 액션에 따른 state 변경함수를 받아 리듀서를 생성합니다.

 

아직 액션을 만들지 않았으므로 initialState만 받아 리듀서를 생성합니다.

// reducer 구현 (initialState, ...ons)
const menuReducer = createReducer(
  initialState
);

이제 리듀서를 리턴하는 reducer함수와 전체 state 중 menu state만 식별하기 위한 featureSelector를 만듭니다.

export function reducer(state: State, action: any): State {
  return menuReducer(state, action);
}

// featureSelectorkey
export const selectMenu = createFeatureSelector<State>('menu');

createFeatureSelector()의 인자인 key값과 global state에 생성될 메뉴 State key값은 서로 동일해야 합니다. 이는 root reducer를 설정하면서 한 번 더 확인하겠습니다.

 

root reducer는 app/reducers/ 디렉토리에 생성하겠습니다.

 

root reducer는 지연로딩 모듈에 위치한 모든 리듀서를 한 데 묶어 하나의 state로 받을 수 있도록 설정하는 부분입니다.

마치 redux의 combineReducer의 기능을 하는 것과 동일합니다.

import { ActionReducerMap } from "@ngrx/store";
import * as menuReducer from '../modules/menu/reducers/menu.reducer';

export interface State {
  menu: menuReducer.State
}

export const reducers: ActionReducerMap<State> = {
  menu: menuReducer.reducer, // menuReducer의 featureSelectorKey와 동일
};

굵은 글씨로 강조한 부분이 곧 root reducer의 "menu" key가 menu reducer에서 createFeatureSelector()로 생성한 key값과 동일해야 한다는 의미이며, 동일해야 state에서 컴포넌트에서 필요한 menu state 값을 가져올 수 있습니다.

 

이제 AppModule에서 root reducer를 forRoot 패턴을 사용하여 싱글톤으로 생성합니다.

@NgModule({
  declarations: [
	//...중략
  ],
  imports: [
	//...중략...
    StoreModule.forRoot(reducers)
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

2.3 액션함수 생성 및 리듀서에 등록

이제 스토어를 업데이트할 수 있는 액션 함수를 생성하고 리듀서에서 액션 타입에 따라 state를 변경해봅니다.

 

예제에서는 메뉴를 가져오는 loadMenu와 검색을 수행하는 searchMenu 두 액션을 생성할 것입니다.

 

이펙트를 사용하는 예제와 그렇지 않은 예제 구분을 위해 loadMenu는 이펙트 없이, searchMenu는 이펙트를 사용하여 searchMenuComplete 액션을 다시 디스패치하겠습니다.

 

먼저 action 파일에 수행하고자 하는 액션 함수를 생성합니다. @ngrx/store의 createAction()을 사용합니다.

import {createAction, props} from '@ngrx/store';

export const loadMenu = createAction('[MENU] Load Menus');

export const searchMenu = createAction('[MENU] Search Menu', props<{keyword: string}>());
export const searchMenuComplete = createAction('[MENU] Search Menu Complete', props<{res: Product.Coffee[]}>());

props는 payload가 존재하는 액션에서 payload를 전달할 수 있습니다.

 

이제 리듀서에서 액션 타입에 따른 원하는 state 업데이트을 구현합니다.

import { products } from '../../../utils/products'; // 임시데이터

// reducer 구현 (initialState, ...ons)
const menuReducer = createReducer(
  initialState,
  on(menuActions.loadMenu, (state : State) => ({
    coffees: products,
  })),
  on(menuActions.searchMenuComplete, (state : State, {res}) => ({
      coffees: res,
    })
  )
);

2.4 컴포넌트에서 스토어와 액션을 활용하기

이제 스토어와 액션에 관한 설정이 완료되었으니 컴포넌트에서 원하는 스토어 데이터를 가져오고, 원하는 액션을 디스패치합니다.

 

메뉴 컴포넌트에서 store의 menu state를 불러와서 메뉴 리스트를 출력해보겠습니다.

 

먼저 생성자 함수에서 store observable을 받아와 스토어 옵저버블에서 select()를 통해 원하는 데이터만을 가져옵니다.

[참고] 아직 이 글에서는 기본적인 selector 사용법밖에 다루고 있지 못하지만 기회가 된다면 더 많은 데이터 조합을 다뤄볼 수 있도록 공부하겠습니다. 공식 문서를 참고하시면 더욱 도움이 될 것입니다.
import { createSelector, Store } from '@ngrx/store';

// menu state selector
const menuSelector = {
  coffees: createSelector(menuReducer.selectMenu, state => state.coffees),
};

@Component({
  //...중략...
})
export class PwHomeComponent implements OnInit {
  products$ = this.store$.select(menuSelector.coffees); // state 중 coffees데이터만 셀렉트

  ngOnInit () {
    this.store$.dispatch(menuActions.loadMenu()); // 첫 렌더링 후 loadMenu 액션 디스패치
  }

  constructor(private store$:Store<State>) {} // store$ 옵저버블 DI
}

2.5 이펙트로 부수 효과 적용하기

이제 검색을 수행하는 컴포넌트 또한 리팩토링하겠습니다. 먼저 메뉴 이펙트를 생성하겠습니다. 이펙트를 @ngrx/effects의 createEffect()로 간단하게 생성할 수 있습니다.

 

createEffect()의 첫번째 인자는 observable을 반환하는 함수입니다.

import { MenuService } from './../services/menu.service';
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as menuActions from '../actions/menu.actions';
import { switchMap, map } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class MenuEffects {
  searchMenu$ = createEffect(() =>
    this.actions$.pipe(
      ofType(menuActions.searchMenu),
      switchMap(({keyword}) => {
        return this.menuService.doSearchResult(keyword).pipe(
          map((res: Product.Coffee[]) => menuActions.searchMenuComplete({ res })),
        )
      })
    )
  );

  constructor(
    private actions$: Actions,
    private menuService:MenuService
  ){}
}

createEffect 첫번째 인자만 가져와서 어떤 흐름으로 진행되는지 살펴보겠습니다.

this.actions$.pipe(
  ofType(menuActions.searchMenu), // (1)
  switchMap(({keyword}) => {
    return this.menuService.doSearchResult(keyword).pipe( // (2)
      map((res: Product.Coffee[]) => menuActions.searchMenuComplete({ res })), // (3)
    )
  })
)

(1) ngrx/effects에서 임포트한 Actions타입에 pipe의 첫번째로 실행되는 함수인 ofType은 액션 중 어떤 액션함수가 디스패치되었을 때 해당 이펙트의 실행을 결정합니다.

 

(2) searchMenu 액션함수가 디스패치되면 switchMap으로 inner observable인 menuService.doSearchResult를 수행합니다. 이 때 menuService.doSearchResult는 옵저버블을 반환합니다.

 

(3) doSearchResult()가 반환하는 옵저버블의 pipe에서 map 연산자가 결과를 받아 다음 액션인 menuActions.searchMenuComplete 액션함수를 디스패치합니다.

 

이펙트는 위의 예시처럼 특정 액션이 디스패치되면 부수 효과로 실행하는 로직을 작성하고, 그 결과에 따라 다음 액션을 새로 디스패치할 수 있습니다.

 

주로 서버 통신과 같은 비동기 작업을 수행 후 성공/실패 유무에 따라 다음 액션을 디스패치할 때 유용하게 쓰입니다. 좀 더 복잡한 예제는 실습에서 설명하도록 하겠습니다.

3. [실습] 주문 기능에 NgRx 적용하기

이제 서비스로 동작하던 주문 로직을 NgRx 버전으로 변경하겠습니다.

 

지금까지 OrderService에서 주문 리스트에 메뉴를 추가할 때 Product에 관한 모든 정보를 저장하고 있었는데 사실 우리가 필요한 건 어떤 메뉴를 몇 개 주문할 것이냐입니다.

 

따라서 State는 productId를 key로 수량을 저장하는 객체로 정의하였습니다.

export interface State {
  quantityById: {
    [key:string]: number
  }
}

3.1 이펙트 없이 작성하기

먼저 메뉴를 주문 리스트에 추가하는 액션을 생성한 후 이펙트 없이 바로 리듀서에 로직을 작성해봅시다.

 

order.actions.js를 생성 후 액션을 추가합니다.

export const addCoffee = createAction('[ORDER] Add Coffee into Cart', props<{productId:string}>());

이제 리듀서에 해당 액션이 디스패치되었을 때 state.quantityById에 productId key가 존재한다면 수량을 더하고, 없다면 새로 추가하는 로직을 추가합니다.

const orderReducer = createReducer(
  initialState,
  on(orderActions.addCoffee, (state:State, {productId}) => {
    let nextState = {...state.quantityById};

    nextState[productId]
    ? nextState[productId]++
    : nextState[productId]=1
    return {
      quantityById: nextState
    }
  }),
}

만약 아메리카노를 주문 리스트에 1잔 추가하면 state는 다음과 같은 상태가 됩니다.

{
  "PRD001": 1
}

주문 리스트 추가 액션처럼 주문 리스트에서 메뉴를 1잔 빼거나 전부 제거하는 액션 또한 비슷하게 작성할 수 있습니다.

 

여기까지 완성한 리듀서 모듈은 아래 코드처럼 작성되었습니다.

 

 

 

3.2 이펙트와 함께 작성하기

이제 주문 리스트에 추가된 메뉴를 주문하는 로직을 작성하겠습니다.  현재 RxJs에서 실습할 때 해당 로직은 컴포넌트에서 작성되어있습니다. 이제 이 로직을 effect와 service로 분리하고 컴포넌트는 액션을 디스패치하기만 하면 되는 로직으로 변경하겠습니다.

 

먼저 아래의 액션을 추가합니다.

export const createNewOrder = createAction('[ORDER] Create New Order', props<{ name: string, requirement: string, payment:number}>());
export const createNewOrderComplete = createAction('[ORDER] Create New Order Complete');
export const createNewOrderFailure = createAction('[ORDER] Create New Order Failure', props<{message: string}>());

주문 성공/실패에 따라 이펙트에서 다른 액션을 디스패치할 것입니다.

 

주문 컴포넌트에서 기존의 로직을 잠시 주석처리하고 위의 액션을 디스패치하는 로직으로 변경합니다.

 

 

 

[참고] 글쓴이는 Subject를 사용하여 좀 이상하게 등록한 거 같은데 이는 나중에 Reactive Form으로 폼을 등록하는 법을 배우고 나면 변경할 로직이니 단순하게 스토어에 디스패치하는 부분만 구현하여도 무방합니다.

이제 createNewOrder 액션이 디스패치되었을 때의 부수 효과를 이펙트와 서비스로 작성하겠습니다.

 

OrderService는 원래 이 모든 로직을 담당하고 있던 모듈인데 이제 로컬스토리지에 주문을 저장하는 로직만을 담은 서비스로 변경합니다.

[참고] 원래 서버가 존재한다면 서버로 http 요청을 보내는 로직이 담길 것입니다.

 

 

위 코드의 주석으로 "?" 표시해둔 getOrderItemList, getTotalInfo가 무엇인지 아직 소개하지 않았습니다. 이는 1.1에서 설명한 Selector로 원하는 데이터 format대로 State를 정제한 부분인데 지금은 전체 흐름에 집중하기 위해서 우선 소개하지 않았습니다. 

 

지금은 그저 단어 뜻대로 주문 메뉴 리스트를 가져오는 Selector와 전체 정보를 불러오는 Selector구나 정도로만 생각하고 흐름을 파악해봅시다.

 

이제 OrderService의 saveIntoLocalStorage를 호출하여 로컬 스토리지에 주문 데이터를 저장할 수 있습니다. 이펙트에서 OrderService를 DI받아 사용하면 됩니다.

 

 

OrderService에서 반환하는 status 값에 따라 다른 액션을 디스패치합니다.

 

또한 어떤 액션이 디스패치됐는지에 따라 성공/실패 화면으로 다르게 라우팅하는 로직또한 이펙트에서 구현할 수 있습니다.

 

다만 createEffect() 두번째 인자로  { dispatch: false } 를 반드시 추가하여 이 이펙트를 다음 액션을 디스패치하는 이펙트가 아님을 명시해야 합니다.

 

여기까지 완료되었다면 이제 리듀서에서 성공/실패에 따라 state를 다르게 업데이트하면 완성입니다.

const orderReducer = createReducer(
  initialState,
  // ... 중략 ...
  on(orderActions.createNewOrderComplete, (state:State) => {
    return initialState;
  }),
  on(orderActions.createNewOrderFailure, (state:State, {message}) => {
    return state;
  })
)

3.3 Selector 작성하기

order.service.ts에서 Selector가 잠깐 등장했었는데 여러 데이터를 조합하여 계산하는데 사용됩니다.

selector는 컴포넌트에서 스토어에서 가져와야할 데이터를 선별적으로 가져오거나 여러 개의 데이터를 조합하여 계산한 결과값을 사용하고 싶을 때 등 원하는 데이터 형태로 가공할 때 유용하게 활용됩니다.

아까 주문 리듀서에서 메뉴의 이미지와 이름 등을 포함한 메뉴의 모든 정보를 저장하는 것이 아니라 productId마다 주문할 수량만 저장하였습니다.

 

하지만 Cart 컴포넌트와 주문을 진행하는 로직에서는 더 많은 정보를 필요로 합니다.

 

예를 들어 아래처럼 어떤 메뉴를 주문하는지를 나타내는 컴포넌트를 그리는데 주문 리듀서의 state 정보만으로는 충분하지 않다는 것을 알 수 있습니다.

따라서 Selector로 데이터를 조합하고 계산하여 우리가 원하는 데이터 format을 계산하겠습니다.

 

글쓴이는 reducers/index.ts에 selector를 두고 메뉴 리스트를 담고 있는 menuState와 고객이 주문하고자 하는 주문 리스트 정보를 담은 orderState를 조합하여 어떤 메뉴를 얼만큼 주문하는지에 대한 getOrderItemList Selector를 구현하였습니다.

 

또한 모든 메뉴에 대한 총 수량과 총 금액을 계산하는 getTotalInfo selector도 구현했습니다.

 

 

각 Selector는 스토어의 select 메소드로 select하여 디렉티브와 서비스에서 사용할 수 있습니다.

constructor(
    private store$: Store<State>
  ) {
  this.store$.select(getOrderItemList)
}

 

여기까지 NgRx와 RxJs의 기본 사용법을 공부하였습니다. 사실 더 잘 작성할 수 있는 부분도 다시보니까 보이는데 좀 더 숙련된 사용은 역시나 연습밖에 없는 것 같습니다.

 

다양한 서비스의 기능을 구현하면서 RxJs가 제공하는 다양한 연산자를 학습할 것을 추천드리는 바입니다.

 

부족한 설명을 봐주셔서 늘 감사합니다 :) 지금까지 작성된 모든 코드는 여기에서 확인하실 수 있습니다.

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

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