프론트엔드

TypeScript를 활용한, FLUX 패턴 적용하기

CodeBoyEd 2022. 1. 2. 22:42

FLUX 패턴 적용하기

 

부스트캠프에서 Redux 의 구조를 공부하다가, FLUX 패턴을 직접 구현한 글을 보게 되었습니다. 이미 유명한 황준일 개발자님의 글이었습니다. [ 참고 링크 ]

 

글을 읽고 나서 해당 구조를 이해하고 벤치마킹 해보고 싶었습니다.

먼저, FLUX 패턴을 이해하기 위해 공식 문서를 참고했습니다.

 

Action

액션은 데이터의 상태를 변경하라고 명령하는 함수입니다. 액션 생성자는 새로 발생한 액션의 타입과 데이터 페이로드를 액션 메시지로 묶어 디스패쳐로 전달합니다. Action을 이용하여 명령을 하기만 하면, View 입장에서는 Store 내부의 구조를 몰라도 State를 변경할 수 있습니다.

 

Dispatcher

디스패쳐는 액션 메시지를 감지하는 순간 그것을 각 스토어에 전달합니다. 전달은 콜백 함수로 이루어지며, 등록되어 있는 모든 스토어로 페이로드를 전달할 수 있습니다.

 

Store (Model)

스토어는 어플리케이션의 상태와, 상태를 변경할 수 있는 메서드를 가지고 있습니다. 어떤 타입의 액션이 날아왔느냐에 따라 메서드를 다르게 적용해 상태를 변경하게 됩니다. 이 과정에서 Redux의 경우 Reducer 라는 함수가 사용됩니다.

 

View

화면에 보이는 뷰 부분입니다. 컨트롤러 뷰는 스토어에서 변경된 데이터를 가져와 모든 자식 뷰에게 데이터를 분배합니다. 데이터를 넘겨받은 뷰는 화면을 새로 렌더링합니다.


제가 이해한 FLUX 패턴의 주된 아이디어는 화면에 보여지는 부분과 상태를 저장하고 있는 Store가 서로의 구조를 모른채 상호작용 한다는 점 입니다.

중간에 존재하는 함수들(Action, Reducer, Dispatch)을 이용해 서로의 구조에 직접 접근하지 않고, 약속된 함수로 상태 값을 참조하고, 변경하는 방법이었습니다. 또한, 어디서나 Store에 접근하고 상태 값을 참조 및 변경할 수 있었습니다.

 

미션 1. 옵저버 패턴 구현하기

SPA 구조에서는 페이지의 상태 값이 바뀌면, 해당 상태 값을 렌더링하고 있는 컴포넌트가 다시 렌더링되야 합니다.

때문에, 상태 값을 컴포넌트가 관찰하고 있는 옵저버 패턴 의 필요성을 느꼈습니다.

컴포넌트에서 상태 값 역시 ‘객체’ 로 관리하고, 객체가 변경될 때마다 해당 객체를 사용하고 있는 컴포넌트가 다시 렌더링이 되야 했습니다.

 

블로그의 로직을 많이 참고했지만, TypeScript 및 Proxy 객체를 적용하여 다음과 같이 옵저버 패턴을 구현했습니다.

let currentFn: Fn = () => {};

export const observe = (fn: Fn): void => {
  currentFn = fn;
  fn(); // rendering => state.isToggleOn => false
  currentFn = () => {};
};

export const observable = (obj: Obj): Obj => {
  const observerMap: ObserverMap<Set<Fn>> = {};

  const state = new Proxy(obj, {
    get: (obj: Obj, prop: string) => {
      observerMap[prop] = observerMap[prop] || new Set<Fn>();
      if (currentFn) observerMap[prop].add(currentFn);
      return obj[prop];
    },

    set: (obj: Obj, prop: string, value: any): boolean => {
      if (obj[prop] === value) return true; // 같은 값으로 업데이트하는 경우 함수가 재실행 되는 것을 방지
      if (JSON.stringify(obj[prop]) === JSON.stringify(value)) return true; // 같은 값으로 업데이트하는 경우 함수가 재실행 되는 것을 방지
      obj[prop] = value;
      observerMap[prop].forEach((fn: Fn) => fn());
      return true;
    },
  });

  return state;
};

 

기본적인 로직은 Proxy 객체를 이용해, observable 함수에 인자로 입력된 객체의 getter 와 setter를 조작하는 것이었습니다. 객체가 사용될 때는 ( 컴포넌트에서 상태 객체가 참조되는 경우 ) getter에 특정 함수들을 Map 에다가 저장하라는 로직을 추가했습니다.

 

이때 특정 함수는 observe 함수에 인자로 주어진 함수인데, 실제 컴포넌트에서는 rendering 함수가 될 것입니다.

그리고 객체가 변경될 때, setter에 저장했던 함수를 실행하라는 로직을 추가하는 것입니다.

 

정리하면,

  • 컴포넌트에서 store에 있는 특정 상태를 참조합니다. ( ex. store.getstate(user_id) )
  • 해당 컴포넌트에 있는 렌더링 관련 함수를 observe 함수에 인자로 넣습니다.
  • 특정 상태가 변하면 해당 상태를 참조하고 있는 “모든” 컴포넌트의 렌더링 함수가 실행됩니다.

 

미션2. Action, Reducer, Store 구현하기

 

Action의 경우에는

  • 변경할 상태를 알려주는 type
  • 변경할 값을 입력할 수 있는 payload

두 개의 변수를 반환해야 합니다. 때문에 프로그래밍을 하며 필요한 Action 함수를 구현하고 다음과 같은 결과 값을 항상 반환하도록 통일해줘야 합니다.

 

const sampleAction = ( args ) => { 
  ...
    return {type: '...', payload: '...'}
}

 

Reducer는 Action의 type을 읽어서 type별로 정해진 로직을 만들면 됩니다.

이 과정에서는 switch 문을 사용했습니다. 저는 다음 장에서 소개할 “라우팅” 관련 코드에 Store 상태를 이용했는데 해당 코드는 다음과 같습니다.

 

export const reducer: Reducer = (state, action) => {
  if (!action) {
    return state;
  }

  switch (action.type) {
    case 'SET_ROUTE':
      return { ...state, route: action.payload };

    default:
      return state;
  }
};

 

위 경우 action.type 의 종류가 ‘SET_ROUTE’ 밖에 없지만, 추가적으로 생기는 type이 있더라도 결국 state 값을 변경한다는 점에서 공통점을 갖게 됩니다. 경우에 따라서는 payload 가 필요하지 않은 경우도 있을 것 입니다.

마지막으로 Store Class를 구현하는데요, Store class에 필요한 요소를 생각해보겠습니다.

  • 상태값을 저장할 객체
  • 외부 컴포넌트에서 접근할 경우, 상태 값을 참조할 수 있도록 해주는 함수 ( 기본적으로 상태 값은 getter, setter 의 조작이 불가능하도록 만들어야 무결성이 유지될 수 있기 때문에, get 하는 함수를 따로 만들어준다. )
  • 상태값을 바꿀 때 사용할 함수

 

각각, state, getstate(), dispatch() 라는 프로퍼티로 관리하도록 구현했습니다.

 

import { observable } from './observer';
import { reducer } from './reducer';

const initState = (): State => {
  return {
    route: { mainPath: 'home', subPath: '', popState: false },
  };
};

class Store {
  private state: Obj;
  private frozenState: Obj = {};
  private reducer: Reducer;

  constructor(reducer: Reducer) {
    this.reducer = reducer;
    this.state = observable(reducer(initState(), null));
    this.frozenState = new Proxy(this.frozenState, {
      get: (obj: Obj, prop: string) => this.state[prop],
    });
  }

  getState = (key: string) => this.frozenState[key];

  dispatch = (action: Action) => {
    const newState = this.reducer(this.state, action);

    if (!newState) {
      return false;
    }

    Object.entries(newState).forEach(([key, value]) => {
      if (!this.state[key] || value === this.state[key]) {
      } else {
        this.state[key] = value;
      }
    });
  };
}

export const store = new Store(reducer);

 

중요한 점은

  • state가 변경될 때 observer 패턴에 의해, 해당 state를 참조하고 있는 컴포넌트들의 리렌더링 함수가 실행된다.
  • 하나의 인스턴스를 생성하여 export 함으로써 단 하나의 store가 존재하도록 강제한다.

 

스토어 인스턴스의 개수를 하나로 강제하는 이유는, 프로젝트가 크지 않고 같은 상태 값이 여러 store에서 참조될 경우, 컴포넌트 내부에서 state를 관리하는 것과 별반 차이가 없기 때문에, 특별한 경우가 아닌 경우에는 강제하도록 구현했습니다.

 

위와 같은 과정을 통해 Store 인스턴스를 구현할 수 있었습니다. 이를 활용한 예제는 다음편 ‘라우팅’ 구현에서 이어서 작성하도록 하겠습니다.

 

참고 자료

'데이터가 폭포수처럼 흘러내려' React의 flux 패턴

Vanilla Javascript로 상태관리 시스템 만들기 | 개발자 황준일