[React] Redux 직접 만들어보기

2020. 9. 4. 11:32Frontend

 

 

Overview

 

리덕스는 웹 프론트엔드 라이브러리인 리액트(React)에서 상태관리 컨테이너로 사랑받고 있는 라이브러리 입니다. 리액트 앱에서 컴포넌트의 상태관리를 할 수 있게 도와주는 다른 여러 라이브러리 (Flux, MobX)들이 있지만, 현재까지는 그 편리함과 리액트의 Virtual DOM(가상돔)과 가장 잘 어울리는 것 같다는 장점들 때문에 가장 많이 쓰이고 있습니다. 이번 포스팅에서는 이 리덕스를 처음부터 간단하게 구현해보면서 동작원리 및 특징들을 살펴보도록 하겠습니다. 

 

How Redux Works

 

리덕스가 가지고 있는 특징 및 키워드를 간단하게 정리해 보았습니다. 이를 어느정도 이해한 뒤에 리덕스를 살펴보면 실제로 앱에 리덕스를 적용하는 데에도 도움이 될 것이라고 생각합니다.

 

 

불변성 (IMMUTABILITY)

 

리덕스가 상태관리. 즉 상태 업데이트(Update)에 대해 가지고 있는 기본 생각은 바로 불변성입니다. 즉, 전체 객체의 참조가 바뀌지 않는한 내부적으로 상태가 바뀌는 것은 인정하지 않는다는 의미입니다. 

const state = {
	hello: 'world'
}

이런 상태가 있다고 할때, 이 상태를 업데이트 하기 위해 state.hello = 'world!!!!!'로 바꾸는 것은 리덕스에서 생각하는 상태 업데이트 방식이 아닙니다. 이렇게 상태를 업데이트 하게 되면, 수많은 컴포넌트에서 상태를 관리하는 것이 사실상 불가능해지게 되며, 리덕스에서는 어느 시점에 어떤 상태가 바뀌었는지를 감지하는데에 많은 컴퓨팅 자원을 소모하게 됩니다. 따라서 리덕스에서는 다음과 같이 상태를 업데이트 합니다.

 

const updatedState = {...state, hello: 'world!!!!!' }

javascript spread 연산자는 기존 state 객체를 얕은복사(shallow copy)하여 새로운 참조를 리턴합니다. 리덕스에서는 이런 식으로 상태를 업데이트 하게 되며, 단지 state 의 reference가 변경되었다는 사실을 인지하는것 만으로도 상태가 변경되었다는 사실을 감지할 수 있습니다.

 

 

클로저(CLOSURE)

 

클로저는 자바스크립트 고유의 개념은 아니지만, 자바스크립트의 고유한 스코프(SCOPE)정의 방식에서 redux를 만드는데 큰 도움을 줍니다. 내부에서 외부 변수에 접근할 수 있기 때문에 state를 참조하고 변경하려 할 때, 그리고 그 참조하고 변경하는 방식을 메서드로 만들어서 외부에 제공할때 유용합니다. 사실은 리덕스가 클로저를 이용해서 구현되어 있습니다.

 

 

Implementation

 

우선 리덕스는 "상태(state)"를 저장하고 있는 함수이기 때문에 다음과 같이 state를 가지고 있는 함수로 간단하게 구현할 수 있습니다. createStore를 통해 아래 함수를 호출하면 이 함수 스코프 안에 있는 state는 오직 "getState"라는 클로저를 통해서만 접근할 수 있습니다. 즉, createStore함수 외부에서 state를 직접 변경하는 일이 불가능해 진다는 것입니다. 

 

 

리덕스가 가지는 가장 중요한 특징 중의 하나는 앞서 설명했듯 "Immutability"입니다. 즉, 외부에서 명시적으로 상태가 변한다는 것을 알려주지 않고, 상태의 내부변수에 직접 접근해서 그것만 변경할 경우, 리액트는 상태가 변경되었다는 사실을 제대로 인지하지 못합니다. 따라서 명시적인 액션을 통하지 않고 상태에 직접 접근해서 이를 변경하는 것을 막기 위해서 클로저를 사용해서 상태를 업데이트 하는 방식을 취하게 됩니다.

 

 

redux.js

export function createStore() {
  let state = {
    age: 10,
  };
  const getState = () => ({...state})
  return {
    getState
  };
}

 

실제로 리덕스를 사용하는 컴포넌트(여기서는 index.js라고 가정하겠습니다.)에서는 다음과 같이 리덕스 스토어를 만들고, 스토어의 내부 클로저인 getState() method call을 통해 상태를 가져오게 됩니다.

 

 

index.js

import { createStore } from './redux';

const store = createStore();
console.log(store.getState());

 

 

 

이제부터 리덕스에 조금씩 살을 붙여나가 보겠습니다. 

상태를 조회할 수 있으니, 이제 상태를 변경할 수도 있어야 합니다. 리덕스의 관점에서 이를 액션('ACTION')으로 정의합니다. 한마디로 이야기하자면, Action을 통해 상태를 바꾸겠다 라고 명시하면 이를 반영해서 상태를 업데이트하고 새 상태를 리턴해 주겠다는 의미입니다.

 

 

redux안에 상태를 변경할 수 있는 "Action"을 처리하는 dispatch라는 메서드를 추가했습니다. 이전과 조금 달라진 점은 createStore가 reducer라는 파라미터를 새로 받게 되었다는 것과, 그 reducer라는 파라미터를 dispatch 내에서 호출하고 있다는 점입니다. 

 

 

 

redux.js

export function createStore(reducer) {
  let state = {
    age: 10
  };

  const dispatch = (action) => {
    state = reducer(state, action);
  };
  const getState = () => ({ ...state });

  return {
    getState,
    dispatch
  };
}

 

 

redux store의 state는 외부에서 직접 접근할 수 없기 때문에 action을 통해서 state를 업데이트 할 수 있는데, '어떤어떤 액션을 받으면 상태를 이렇게이렇게 업데이트 해줘' 라고 외부에서 메서드로 정의해두고, 이를 store를 생성할때 매개변수로 넘겨주면, store내부에서 이 메서드를 통해서 정해진 규칙대로 상태를 업데이트하는 것입니다. 

 

 

바로 이 규칙을 정의하는 함수가 바로 reducer입니다.

reducer는 store밖에서 정의하고, store에 파라미터로 넘겨주어 상태를 업데이트 하는 함수이기 때문에, 상태와 액션을 받아서 새로운 상태를 리턴해 줍니다. 

 

 

아래의 예시에서 작성된 리듀서는 'next'라는 타입을 가지는 액션을 파라미터로 넘겼을때, 파라미터로 같이 넘어온 state의 age값을 1만큼 증가시킨 (age값이 없으면 default = 10으로 세팅) 새로운 상태를 리턴해줍니다. 

 

 

여기서 중요한 부분이 바로 "새로운" 상태라는 점입니다. javascript의 spread 연산자 { ...state }를 통해서 기존의 상태를 얕은복사(shallow copy)한 후, age값을 새롭게 할당하여 참조가 다른 새로운 상태를 만든다는 점입니다. 

 

 

상태가 업데이트 되었을 때, 리액트의 virtual DOM이  바로 이 상태의 참조(reference)가 바뀌었는지를 가지고 컴포넌트 업데이트 여부를 결정하기 때문에 참조가 바뀐 새로운 상태를 리턴하는 것이 바로  reducer의 핵심입니다. (얕은 복사를 위한 spread연산자에 대해서는 다음 공식 문서를 참조하시면 좋을 것 같습니다.)

 

 

index.js

import { createStore } from "./redux";

function reducer(state = {}, action) {
  if (action.type === "next") {
    return {
      ...state,
      age: state.age ? state.age + 1 : 10
    };
  }
  return state;
}

const store = createStore(reducer);
console.log(store.getState());
store.dispatch({type: 'next'})
console.log(store.getState());

 

action을 dispatch 하는 것을 조금더 간단하게 만들기 위해서 다음과 같은 메서드들을 정의해서 사용할 수도 있습니다.

import { createStore } from "./redux";

const NEXT = 'next';
function reducer(state = {}, action) {
  if (action.type === "next") {
    return {
      ...state,
      age: state.age ? state.age + 1 : 10
    };
  }
  return state;
}

function actionCreator(type, data) {
  return {
    ...data,
    type: type
  }
}

function next() {
  store.dispatch(actionCreator(NEXT));
}

const store = createStore(reducer);
console.log(store.getState());

store.dispatch({type: 'next'})
console.log(store.getState());

store.dispatch(actionCreator(NEXT));
console.log(store.getState());

next();
console.log(store.getState());




 

store.dispatch(), actionCreator, next()등은 모두 동일한 기능을 수행하며, 이를 개발자들이 사용 환경과 입맛에 맞게 자유로운 형식으로 사용할 수 있는 것입니다.

 

 

지금까지의 리덕스 동작과정을 살펴보면 다음과 같습니다.

 

1. createStore에 reducer함수 매개변수로 넘겨주어서 redux 스토어 생성

 

2. redux store는 내부적으로 state를 가지고 있으며, 외부에서는 이 state의 참조를 직접 접근할 수 없고, getState를 통해서 값을 읽을 수만 있다. (getStore가 state의 복사본을 리턴하기 때문에 getState의 결과물을 아무리 수정해봐야 redux 에는 반영되지 않음)

 

3. redux store안의 state를 변경할 수 있도록 하기 위해 store안에 dispatch라는 메서드를 제공함. dispatch는 action을 파라미터로 받아서 reducer를 내부적으로 호출하며, reducer는 store의 내부 state와 action을 파라미터로 받아서 해당 액션을 state에 반영한 새로운 state를 리턴하고, 이를 store의 내부 state에 할당함 (참조가 바뀐 state)가 업데이트 됨

 

4. 외부에서는 reducer를 정의하고, 이에 맞게 dispatch를 호출함으로써 store내부의 state를 변경할 수 있으며, dispatch를 통해 변경한 state는 이전의 state와 참조(reference)가 다르기 때문에 virtualDOM에서 상태가 업데이트 됨을 감지할 수 있음.

 

 

이제 거의 다 왔습니다. 마지막으로 redux를 통해 내부 상태를 업데이트 한뒤에, 이를 컴포넌트에게 알려주는 단계만 남았습니다. 상태가 변경되었다는 사실을 컴포넌트가 제때 감지하지 못한다면, 그에 따른 적절한 UI 업데이트가 되지 않기 때문에 제대로 된 애플리케이션을 작성할 수 없습니다. 예를 들어서 버튼을 클릭해서 카운터를 1 증가시켰지만, 그리고 실제로 내부 state의 카운터가 1 증가되었지만, 이를 컴포넌트에 알려주지 못한다면 사용자 입장에서는 카운터가 증가되지 않은 것처럼 보이는 것입니다. 

 

 

redux안에 listener를 추가하고 dispatch가 되어 상태가 업데이트 될 때마다 이 listener함수를 호출하는 방식으로 구현하면, 상태가 업데이트 될 때마다 컴포넌트가 이를 감지할 수 있는 방법을 제공할 수 있게 됩니다. redux store안에 listener배열과 , 이 listener를 추가할 수 있는 subscribe메소드를 제공하여 구현하였습니다.

 

redux.js

export function createStore(reducer) {
  let state = {
    age: 10
  };
  const listeners = [];

  const dispatch = (action) => {
    state = reducer(state, action);
    listeners.forEach(fn => fn());
  };
  const getState = () => ({ ...state });
  const subscribe = (fn) => listeners.push(fn);

  return {
    getState,
    dispatch,
    subscribe,
  };
}

 

index.js

import { createStore } from "./redux";

function reducer(state = {}, action) {
  if (action.type === "next") {
    return {
      ...state,
      age: state.age ? state.age + 1 : 10
    };
  }
  return state;
}

function update() {
  console.log('update function called!: ',store.getState());
}

const store = createStore(reducer);
store.subscribe(update);

store.dispatch({type: 'next'})

 

Conclusion

리덕스는 컴포넌트간의 상태 의존성에 얽매이지 않고, 여러 컴포넌트에서 동일한 상태들을 효율적으로 관리하는 방법을 제공합니다. 물론, 리액트의 VirtualDOM이 DOM을 업데이트하는 방식과도 굉장히 잘 어울리기 때문에 리액트에서 아직까지도 주류 상태관리 라이브러리로 사용되고 있습니다.

 

그냥 문서를 보고 몇가지 예제를 통해 바로 리덕스를 구현하는 것도 좋지만, 이렇게 아무것도 없는 상태에서 리덕스를 처음부터 구현해보는 것도 리덕스와 리액트, 그리고 상태관리에 대해 이해하는 데 많은 도움이 될 수 있습니다. 

반응형

'Frontend' 카테고리의 다른 글

[React] Redux Middleware 직접 구현해보기  (0) 2020.09.19
[React] Virtual DOM & Diffing 알고리즘  (0) 2020.09.13
[HTML] iframe 태그란?  (0) 2020.08.23
[Browser] Console 객체  (0) 2020.07.26
[OAuth] OAuth2.0 Facebook 로그인 구현  (1) 2020.07.05