Vuex/Redux/NGRX

November 24, 2021

Vuex/Redux/NGRX

Vuex/Redux/NgRx

Vuex

Vuex

Vuex란?

  • Vue.js의 상태 관리를 위한 패턴이자 라이브러리
  • 애플리케이션의 모든 컴포넌트들에 대한 중앙 집중식 저장소의 역할 및 관리를 담당
  • 공통의 상태를 공유하는 여러 컴포넌트가 있는 경우 사용하기 적합
  • 외부 API를 바로 불러다 쓰는 것이 아니라 중간에서 vuex의 state에 API 데이터를 담아서 사용

Vuex 구조

Vuex 구조

State

  • 공통으로 참조하기 위한 변수를 정의한 것
  • 프로젝트의 모든 곳에서 이를 참조하고 사용 가능
  • 모든 컴포넌트들에서 공통된 값을 사용 가능
state: {
  dataName: 초기값,
}

Getters

  • vue의 computed와 동일속성인데 다만 store에 있는 것
  • 여러 컴포넌트에서 동일한 computed가 사용될 경우 Getters에 정의 후 사용
// Store
 getters: {
   computedName(state) {
     return state.dataName
   }
 }

// Componenet
import { mapGetters } from 'vuex';

export default {
  computed: {
    ...mapGetters([
      'computedName' // 같은 이름으로 쓸 경우 배열, 아니면 객체 key:value형식
    ])
  }
}

Mutations

  • 비동기 호출로 받은 데이터를 State에 담음
  • State 변경을 담당 (반드시 Mutation을 통해서만 State를 변경해야함)
  • commit('함수명', '전달인자') 방식으로 호출
  • 동기처리 기준
mutation: {
   MutationName(state, data) {
	state.dataName = data;
   }
 }

Actions

  • 비동기 호출(Backend API)은 모두 Action에서 호출
  • dispatch('함수명', '전달인자') 방식으로 호출
  • 비동기처리 기준
// Store
 action: {
   ActionName(context) {
     비동기호출()
     .then(res => {
       context.commit('MutationName', data); //mutation 전달
     })
       .catch(err => {
     });
   }
 }

// Component
export default {
  created() {
    this.$store.dispatch('ActionName'); //Action 호출
  }
}

Redux

Redux

Redux란?

  • 상태관리 라이브러리
  • 자바스크립트 앱을 예측 가능한 상태 컨테이너
  • 리액트 뿐 아니라 순수 자바스크립트의 상태관리에도 리덕스를 이용할 수 있다.

Redux 구조

Redux

Actions

  • 객체 형태
  • type을 필수로 가져야 함
  • 그 외에 상태를 변화시킬 test같은 값은 자유
const ADD_TODO = 'ADD_TODO';
{ type: ADD_TODO, text: 'Build my first Redux app' }

혹은 ‘액션 생성함수(Action Creators)‘를 이용하여 간단하게 파라미터를 받아와 액션을 미리 정의해 줄 수 있다.

function addTodo(text) {
  return {
    type: ADD_TODO,
    text,
  };
}

dispatch(addTodo("Build my first Redux app"));

Reducers

  • 이전 상태와 액션을 받아서 다음 상태를 반환
  • 어떤 일이 일어날지만 설명하는 객체
  • Redux 상태가 어떻게 변하는지 보여줄 수 없음(Reducers가 순수 함수이기 때문)
function reducer(state, action) {
  switch (
    action.type
    // ... 상태업데이트 로직이 존재합니다.
  ) {
  }
}

Store

  • Redux 한개의 애플리케이션에 하나의 스토어가 존재
  • 내부에는 현재의 상태, Reducer 그리고 몇가지 내장함수가 있다.

dispatch

  • Store의 내장함수 중 하나
  • 액션을 파라미터로 전달
  • 함수를 호출하면 Store가 Reducer 함수를 실행
  • Reducer 함수가 넘긴 액션을 처리해 새로운 상태를 만듦

subscribe

  • Store의 내장함수 중 하나
  • 함수 형태의 값을 파라미터로 전달
  • 액션이 dispatch될 때마다 전달해준 함수를 호출

세 가지 원칙

Redux는 세 가지의 기본 원칙으로 설명할 수 있다.

Single source of truth

우리의 앱 전체상태는 단일스토어로 관리한다. 단일스토어는 앱 전체를 쉽게 관리할 수 있는 범용성을 가지고, 또한 디버깅이나 개발자도구를 쉽게 사용할 수 있게 한다.

State is read-only

상태는 ‘읽기 전용’ 이며, 상태를 변경할 수 있는 유일한 방법은 ‘액션 객체’를 이용하여 ‘액션’을 발생시키는 것

Changes are made with pure functions

  • 리덕스의 리듀서는 순수 함수이어야 한다. 여기서 순수함수란 어떤 함수에 동일한 파라미터를 주었을때 항상 같은 값을 리턴하고, 외부의 상태를 변경하지 않는 함수
  • 리듀서는 이전의 상태와 액션을 받아서 다음 상태를 반환하는 순수한 함수
  • 한개의 리듀서부터 시작해서 여러개의 리듀서들까지 한개의 상태트리에서 관리하게 된다.

NGRX

image

NGRX 란?

  • Redux에서 영향을 받아 reactive한 어플리케이션을 만들 수 있도록 하는 프레임워크
  • Redux의 상태관리 패턴을 앵귤러에 적용시킬 수 있다.
  • RxJs를 사용하여 state의 Observable을 쉽게 가공할 수 있다.

RxJS는 Reactive Extensions For JavaScript 라이브러리이다. 여기서 Reactive Extensions는 ReactiveX 프로젝트에서 출발한 리액티브 프로그래밍을 지원하기위해 확장했다는 뜻이다.

NGRX 구조

image

Action

  • 어플리케이션 내에 발생되는 이벤트들을 기재해놓은 곳
  • 사용자 인터렉션, 네트워크 요청을 통한 외부 상호작용, API와의 통신 등 모두 action에 적혀있음
  • 어플리케이션 내에서 어떤 이벤트들이 어떻게 관리되고 있는지 action을 통해 알 수 있음
interface Action {
  type: string;
}

Action은 간단한 인터페이스로 구성되어있다.
여기서 ‘type’은 ’[Source]Event’의 형태로 쓰이는데, 해당 action이 어떤 이벤트인지 간단히 설명해준다.
여기에 우리는 상황에 따라 속성들을 추가하여 action에 추가적인 내용이나 메타데이터를 넣을 수 있다.

{
  type: '[Login Page] Login',
  username: string,
  password: string,
}

type을 보면 이 action은 로그인 페이지에서 로그인을 했을 시 쓰인다는 것을 알 수 있다. 그리고 username과 password를 추가적으로 받고 있다는 것도 확인할 수 있다.

Action은 createAction()을 통해 생성 할 수 있따. (createAction()은 Action 인터페이스 형태의 object를 반환해주는 Creator function을 만들어준다.)

아래와 같이 상황에 따라 action creator를 다르게 만들 수 있다

  1. 추가적인 메타데이터 없는 action creator
export const increment = createAction("[Counter] Increment");
  1. 추가적인 메타데이터 있는 action creator
export const loginSuccess = createAction(
  '[Auth/API]Login Success',
  props<{user: User}>()
);

props에 해당 action에서 추가적으로 필요한 메타데이터를 알려주고 있다.

  1. function이 있는 action creator
export const loginSuccess = createAction(
  "[Suth/API] Login Success",
  (response: Response) => response.user
);

해당 action에서 response를 전달받으면, response.user를 반환한다.

  • component에서 action을 dispatch하는 법 <login-page.component.ts>
store.dispatch(increment());
store.dispatch(loginSuccess({ user: newUser }));

추가적인 메타데이터가 필요한 경우 두번째 예시처럼 데이터(newUser)를 넣어주면 된다.

Effects

  • 서비스와 이어져있어 api통신을 함
  • ‘Side Effect Model’ Side Effect Model:
    먼저 프로그래밍에서 ‘side effect’란 단순히 ‘부작용’이라는 뜻이 아니라, 어떠한 상태가 변화하는 것을 말한다.
    즉, 변수의 값을 변경한다든지, UI에서 버튼을 disable로 바꾼다든지 이러한 변화들을 가리킨다고 볼 수 있다.
    ( 참고 : https://softwareengineering.stackexchange.com/questions/40297/what-is-a-side-effect)
    따라서 여기서는 Side Effect를 ”부수효과” 라고 부르기로 한다.
    NgRx의 Effect는 이러한 부수효과들을 방지하며 개발할 수 있도록 해준다.
    만약 Effect가 없다면 컴포넌트가 직접 서비스와 연결되어 외부 통신을 통해 데이터를 받아와야 한다. 이렇게 받아온 데이터를 다루는 상황에서 부수효과가 발생하는 시점이 일어난다.
    Effect는 서비스와 컴포넌트 사이에 위치하여 서비스와 상호작용하는 길이 되어주고, 컴포넌트 & 서비스 사이를 끊어내어 컴포넌트가 api통신에 직접적으로 관여하지 않도록 해준다.
    (전) Component - Service => (후) Component - Effect - Service

Effect 는 주입 가능한 (Injectable) 서비스로써, 아래 몇 가지 특징이 있다 :

  1. ‘Actions 서비스’는 최근 state가 업데이트 되고 난 뒤 모든 action들을 observable stream형태로 전달해준다.
  2. createEfftect() 함수를 통해 메타데이터는 observable stream에 붙는다. 여기서 메타데이터는 store에 구독된 streams를 등록하는 데에 쓰인다. effect stream이 반환하는 action들은 다시 store에 돌려보내진다.
  3. Action은 ‘ofType()‘으로 구분된다. ofType()은 한 개 또는 여러 개의 action들을 인자를 받아 필터링을 한다.
  4. Effect는 store observable을 구독하고 있다.
  5. 우리는 effect에 service를 inject하여 외부API 통신을 할 수 있다.

<movie.effects.ts>

import { Injectable } from "@angular/core";
import { Actions, createEffect, ofType } from "@ngrx/effects";
import { EMPTY } from "rxjs";
import { map, mergeMap, catchError } from "rxjs/operators";
import { MovieService } from "./movies.service";

@Injectable()
export class MovieEffects {
  loadMovies$ = createEffect(() =>
    this.actions$.pipe(
      ofType("[Movies Page] Load Movies"),
      mergeMap(() =>
        this.moviesService.getAll().pipe(
          map((movies) => ({
            type: "[Movies API] Movies Loaded Success",
            payload: movies,
          })),
          catchError(() => EMPTY)
        )
      )
    )
  );

  constructor(
    private actions$: Actions,
    private moviesService: MoviesService
  ) {}
}

위 effect 예시는 다음과 같은 흐름으로 동작한다:

  1. loadMovies$ effect은 Actions 스트림을 통해서 오고 가는 action들을 구독하고 있다.
  2. 하지만 ofType으로 ’[Movies Page] Load Movies’로 지정했기에 해당 액션만 필터링해서 구독한다.
  3. mergeMap()을 사용하여 action 스트림을 새로운 observable으로 제조한다.
  4. movicesService.getAll()로 API통신을 한다.

    • 성공 시 새로운 action으로 map()한 observable를 리턴한다.
    • 실패 시 빈 observable를 리턴한다.
  5. 이렇게 새로 만들어진 action은 Store로 전달된다. 그리고 state 변경이 필요할 때 reducer은 해당 action을 다룬다.

Reducer

  • state의 변화를 담당함
  • action의 타입에 따라 어떤 action에 의해 state를 변화시켜야 하는지 정함
  • 항상 같은 input에 같은 output을 내보낸다는 점에서 ‘순수함수’라고 볼 수 있음 (=부수효과를 발생시키지 않음, 외부 상태에 의존적이지 않음)
  • 가장 최근에 dispatch된 action과 state를 가지고 새롭게 변경한 state를 반환할지 기존 state를 반환할 지 정함

ACTION -> dispatched! —> REDUCER (=action에 대한 listener)

  1. state shape 잡기

<scoreboard.reducer.ts>

import { Action, createReducer, on } from "@ngrx/store";
import * as ScoreboardPageActions from "../actions/scoreboard-page.actions";

export interface State {
  home: number;
  away: number;
}
  1. state 초기값 정하기
export const initialState: state = {
  home: 0,
  away: 0,
};

state가 undefined일 경우 초기값에 따라 정해진 값을 줄 수 있다.

  1. reducer function 만들기
const scorevoardReducer = createReducer(
  initialState,
  on(ScoreboardPageActions.homeScore, (state) => ({
    ...state,
    home: state.home + 1,
  })),
  on(ScoreboardPageActions.awayScore, (state) => ({
    ...state,
    away: state.away + 1,
  })),
  on(ScoreboardPageActions.resetScore, (state) => ({ home: 0, away: 0 })),
  on(ScoreboardPageActions.setScores, (state, { game }) => ({
    home: game.home,
    away: game.away,
  }))
);

export function reducer(state: State | undefined, action: Action) {
  return scoreboardReducer(state, action);
}
  • createReducer(): action들을 통해서 state를 다룰 수 있게 한다.
  • initialState: 위에서 정한 초기값
  • on(): action에 따라 어떻게 state를 다룰 것인지 정한다. (=각 action에 대한 불변의 transition)

immutable transition:
기존의 state를 변경하는 것이 아니라 spread operator를 통해 새로운 state를 반환한다. (=purity of change 유지!)
하지만 spread operator은 shallow copy를 하기 때문에 불변성을 유지하기 위해선 deep copy가 필요하다.(loadsh나 immer과 같은 라이브러리를 사용하면 쉽게 deep copy를 할 수 있다.)

Store

  • Redux의 영감을 받음, global하게 state를 관리함
  • Angular의 상단에 위치한 state 관리자로써 일관성 있는 어플리케이션을 만들 수 있도록 함

AppModule에 StoreModule을 import해야지 어플리케이션 내에서 Store를 사용할 수 있다.

<app.module.ts>

import { BrouwerModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";

import { AppComponent } from "./app.component";

import { StoreModule } from "@ngrx/store";
import { counterReducer } from "./counter.reducer";

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, StoreModule.forRoot({ count: counterReducer })],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

StoreModule.forRoot()에 state를 관리하는 ‘counterReducer’를 count라는 이름으로 등록하였다.
StoreModule.forRoot()은 글로벌한 provider를 등록시켜주기 때문에 어플리케이션 내에 어디서나 Store에 접근할 수 있다.

<my-counter.component.ts>

import { Component } from "@angular/core";
import { Store } from "@ngrx/store";
import { Observable } from "rxjs";
import { increment, decrement, reset } from "../counter.actions";

@Component({
  selector: "app-my-counter",
  templateUrl: "./my-conter.component.html",
})
export class MyCounterComponent {
  count$: Observable<number>;

  constructor(private store: Store<{ count: number }>) {
    this.count$ = store.select("count");
  }

  increment() {
    this.store.dispatch(increment());
  }

  decrement() {
    this.store.dispatch(decrement());
  }

  reset() {
    this.store.dispatch(reset());
  }
}

MyCounterComponent 내에서 Store를 통해 ‘count state’에 접근하여 count$ stream에 해당 state를 연결하였다. 그리고 increment(), decrement(), reset() 함수 내에서 action들을 dispatch()하였다.

Selector

  • 원하는 조건에 맞춰 state의 일부분을 가져올 수 있는 순수 함수임
  • 특징: portability, memoization, composition, testability, type saftey

createSelector(), createFeatureSelector() 함수는 가장 최신 인자들을 추적하고 있다. 만약 직전의 인자와 동일한 인자로 selector 함수를 호출하면, selector은 순수 함수이기 때문에 해당 함수를 재호출할 필요 없이 결과값을 가져올 수 있다. 이는 퍼포먼스적인 장점으로 볼 수 있으며, 이러한 행위는 ‘memoization’이라고 부른다.

memoization:
컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다.
(참고 : https://drehzr.tistory.com/77)

  1. 1 selector -> 1 piece of state 가져오기

<index.ts>

import { createSelector } from "@ngrx/store";

export interface FeatureState {
  counter: number;
}

export interface AppState {
  feature: FeatureState;
}

export const SelectFeature = (state: AppState) => state.feature;

export const selectFeatureCount = createSelector(
  selectFeature,
  (state: FeatureState) => state.counter
);

createSelector: 동일한 state 내에서 여러 조각들을 골라 필요한 데이터를 가져온다.

  1. 여러 개 selectors -> 여러개 pieces of state 가져오기

<index.ts>

import { createSelector } from "@ngrx/store";

export interface User {
  id: number;
  name: string;
}

export interface Book {
  id: number;
  userId: number;
  name: string;
}

export interface AppState {
  selectedUser: User;
  allBooks: Book[];
}

export const selectUser = (state: AppState) => state.selectedUser;
export const selectAllBooks = (state: AppState) => state.allBooks;

export const selectVisibleBooks = createSelector(
  selectUser,
  selectAllBooks,
  (selectedUser: User, allBooks: Book[]) => {
    if (selectedUser && allBooks) {
      return allBooks.filter((book: Book) => book.userId === selectedUser.id);
    } else {
      return allBooks;
    }
  }
);

AppState 내에 ‘selectedUser’라는 객체와 ‘allBooks’라는 Book 리스트가 있다.
그리고 createSelector을 통해서 우리는 현재 사용자에게 속한 모든 책들을 보여줄 수 있다.
이를 통해 필터링해서 가져오는 데이터는 항상 최신 상태를 유지하기 때문에 (always up to date!)
조건에 따라 (사용자가 유무에 따라) 상황에 맞게 가장 최신의 책 리스트를 불러올 수 있다.

이런 식으로 원하는 데이터를 자유롭게 selector로 state에서 가져와서 뷰에 보여주면 된다.

ngOnInit() {
  this.counter = this.store.select(exampleSelector.selectFeatureCount)
}

출처: https://velog.io/@jtwjs/Vuex%EB%9E%80, https://byseop.netlify.app/redux-01/, https://kkangdda.tistory.com/108


Written by @Soojin Kim

넥스트이노베이션의 기술, 문화, 뉴스, 행사 등 최신 소식 및 넥스트이노베이션이 겪은 다양한 경험을 공유합니다.