안녕하세요 오픈소스컨설팅 Playce Dev 팀 Front-end 개발자 이정현입니다!

이번에는 React 프로젝트를 개발하며, 규모가 확장됨에 따라 많아지는 데이터들을 어떻게 효율적으로 관리할지, 상태 관리를 위한 기술에 대해 알아보도록 하겠습니다.

들어가기 전에…

Playce Dev 팀에서 새로운 Front-End 아키텍처에 대해 준비하며 신규 프로젝트 진행 시 반영하기로 하였는데요. 이전에는 front-end 프레임워크에 대해 논의하는 시간을 가졌다면 이번에는 상태 관리 기술에 대해 논의해보기로 하였습니다! 현재 저희 팀에서는 react-redux 라이브러리를 사용 중에 있는데요 이러한 기술이 어떻게 탄생하게 되었고 왜 필요한지에 대해 알아보도록 하겠습니다!


리덕스의 역사를 알아보자

MVC 아키텍처의 한계

Redux를 이해하기 전, 애플리케이션을 작성해 본 개발자라면 한 번쯤은 사용해보거나 혹은 들어봤을 MVC 패턴에 대해 먼저 알아야 합니다.

출처: Flux 공식문서

MVC 패턴에서 ControllerModel에 정의된 데이터를 조회하거나 업데이트하는 역할을 하며, 변경된 Model의 데이터를 View에 반영해 줍니다. 또한 사용자는 View를 통해 데이터를 입력하고 Model에 반영되며, ViewModel은 데이터를 양방향으로 주고받는 형태입니다.

출처: Flux 공식문서

하지만 이 패턴에는 큰 문제가 있는데요. 프로젝트 규모가 커질수록 수많은 View와 Model들이 생겨나게 되었고 데이터가 어디로 흐르는지 파악하기 어려워졌습니다. 이러한 이유로 새 기능을 추가할 때마다 크고 작은 문제가 생겼으며, 예기치 못한 결과(Side Effect)를 불러 일으켰습니다.

Flux의 탄생

이러한 문제를 해결하기 위해 페이스북 개발팀이 MVC를 버리고 다른 아키텍처를 적용하기로 하여 나오게 된 것이 Flux 패턴입니다.

출처: Flux 공식문서

Flux 패턴은 어떤 Action이 발생하면 dispatcher에 의해 store에 변경된 사항이 저장되고 그 저장된 데이터들에 의해 view가 변경되는 단방향 패턴입니다. 이러한 패턴의 가장 큰 장점은 양방향으로 흐르던 MVC 패턴과는 반대로 단방향으로 흐르기 때문에 흐름을 훨씬 파악하기 쉽고 예측 가능(predictable) 하다는 것입니다.

당시 Flux 패턴이 적용된 많은 구현체들이 나타났는데, 그중 널리 사용되는 Flux 구현체 중 하나가 바로 Redux 입니다. Redux는 Dan Abramov 가 작성했으며 Flux 보다 조금 더 단순화되어 사용이 간편해졌고, Flux를 개발한 Jing Chen이나 Bill Fisher의 찬사도 얻었을 정도로 잘 만들어진 Flux 구현체입니다!

해당 글에서는 Flux에 대해 자세히 다루지는 않지만 Flux의 구성 요소의 역할에 대해 좀더 자세히 알고 싶다면 Flux 카툰 안내서를 읽어보세요. 이해에 많은 도움이 될 겁니다! Flux 카툰 안내서

Flux와 Redux는 무엇이 다를까

그렇다면 Redux와 Flux는 무엇이 다른지, Flux에서 존재했던 문제점에 대해 비교하며 알아보도록 하겠습니다.

1. Hot Reloading

Hot Reloading이란 코드가 변경되어도 기존의 상태를 유지할 수 있게 만들어 주는 것입니다. Flux의 첫 번째 문제점으로는 상태 업데이트에 관련된 코드를 리로딩하게 되면 애플리케이션 상태도 같이 리로딩되어 저장된 상태 정보를 잃어버리게 되는 것입니다. 이것은 store가 아래의 두 가지 역할을 맡고 있기 때문인데요.

  • 1. 상태(state) 변환을 위한 로직
  • 2. 현재 애플리케이션의 상태(state)

Redux가 유용한 점이 바로 두 가지 역할을 분리함으로써 상태 변환 로직을 핫 리로딩할 수 있다는 것입니다. Redux에서는 store애플리케이션 상태를 가지며, 상태 변환 로직reducer가 관리를 합니다. 즉, 리듀서를 리로딩하는 것으로 애플리케이션 상태를 잃어버리지 않고 관련 로직만을 핫 리로딩 할 수 있습니다.

2. Time Travel Debugging

시간 여행 디버깅이란 이름에서 추측이 가능하듯이 이전의 특정 상태로 돌아갈 수 있게 해주는 것입니다. 시간 여행 디버깅이 가능하기 위해서는 상태가 새로 바뀔 때마다 상태 객체의 모든 버전을 기록해 두어야 합니다.

Redux는 이 문제를 해결하기 위해 기존의 애플리케이션 상태를 직접 수정하는 대신 그 상태를 복사하여 복사본을 수정하는 식으로 불변성을 유지합니다. 이는 물론 Flux 에서도 구현이 가능하지만 상당히 복잡한 반면, Redux 에서는 아주 간단히 이를 처리할 수 있습니다.

Flux와 대비되는 Redux의 주요 특징을 요약하자면 Flux는 여러개의 스토어가 사용되지만 리덕스는 하나의 스토어만 가지며, reducer 가 존재하고, 불변성을 유지해 상태를 변경할 수 없습니다. 이러한 특징들은 앞으로 리덕스에 대해 제대로 소개를 드리며 자세히 알아보도록 하겠습니다.

리덕스란?

Redux란 가장 사용률이 높은 JavaScript 상태 관리 라이브러리입니다.

Github 공식 문서

최신 버전 (2022.05.08 기준): v8.0.1

보통 React를 사용하면서 Redux를 많이 접해봤을 텐데요. Redux는 리액트 뿐만 아니라 Augular, jQuery, vanilla JavaScript 등 다양한 framework와 작동될 수 있도록 설계되었습니다. 즉 Redux는 React만을 위한 Library가 아닙니다!

출처: npm trends

(위 사진의 다운로드 횟수로 redux의 인기가 압도적이라는 걸 확인할 수 있습니다.)

그렇다면 상태가 대체 무엇이길래 이렇게 별도로 관리하는 것들이 생겨났을까요?

상태란?

상태(state) 란 간단하게 말하자면 데이터 입니다. 덧붙이자면 상태는 컴포넌트내부에서 사용하는 데이터 라고 할 수 있습니다.

상태 관리 도구가 왜 필요할까?

프로젝트 규모가 커질수록 컴포넌트 개수도 많아질 것이고, 그에 따라 관리해야 하는 state들도 많아져 복잡해질 수밖에 없습니다. 그래서 개발자들은 상태 관리 라이브러리를 사용하게 되고, 그 중의 하나가 Redux인 것입니다.

Component 간의 정보 공유가 어떻게 이루어지길래

현재 저희 Playce Dev 팀은 React를 이용해서 개발을 진행하고 있습니다. React에서의 데이터가 공유되는 방식에 대해 알아보자면 부모에서 자식으로만 데이터가 흐르는 단방향 패턴이며 자식 컴포넌트들 간의 다이렉트 데이터 전달은 불가능합니다.

하지만 자식이 너무 많아진다면 어떻게 될까요?

나는 단순히 name이라는 상태를 D 컴포넌트로 전달하고 싶은데, 데이터를 상위 컴포넌트에서 계속 내려받아야 하기 때문에 name을 사용하지 않는 B, C component에도 name을 전달하게 됩니다.

Props drilling 이슈

이러한 공유 과정은 프로젝트의 규모가 커질수록, data를 전달하기 위해 필요없는 data의 흐름이 생기게 되며 코드가 지저분해질 뿐더러 유지보수도 어려워지게 됩니다.

상태 관리 툴은 어떤 문제를 해결해주나?

이때 하나의 Store 라는 매체를 두면 A > B > C > D 가 아니라 A > store > D 식의 효율적인 접근이 가능하게 해줍니다. store 라는 전역 상태 저장소 덕분에 불필요한 컴포넌트 간의 데이터 전달이 없어지게 되었습니다!

Redux의 기본 개념을 알아보자 : 세 가지 원칙

1. Single source of truth : 하나의 어플리케이션은 하나의 store만 가진다.

동일한 데이터는 store라는 하나뿐인 데이터 공간에서 관리됩니다. 이렇게 하면 애플리케이션의 디버깅이 쉬워지고 서버와의 직렬화가 가능하며 클라이언트에서 데이터를 쉽게 받아 들여올 수 있습니다.

2. State is read-only : 상태는 읽기 전용이다.

state를 직접 변경해서는 안되며 state의 변경은 reducer에서만 할 수 있습니다.  reducer 이외 공간에서의 state는 읽기 전용인 것입니다. 이것이 바로 데이터의 단방향 흐름의 이점으로 상태를 변화시키는 의도를 정확히 표현할 수 있으며 상태 변경에 대한 추적이 용이해집니다.

3. Changes are made with pure functions : 리듀서는 순수 함수여야 한다.

reducer는 순수 함수여야만 합니다. reducer 함수는 기존의 state를 직접 변경하지 않고, 새로운 state object를 작성해서 return 해야 합니다. 동일한 파라미터로 호출 된 reducer는 순수함수이기 때문에 언제나 같은 결과값만 반환합니다.

Redux 핵심 키워드 파악하기

Store (스토어)

Store는 상태가 관리되는 오직 하나의 공간입니다. 앱에서 필요한 상태들과 리듀서가 저장되어 있으며 컴포넌트에서 상태 정보가 필요할 때 스토어에 접근합니다.

Action (액션)

Action은 상태를 변화시키려는 의도를 표현한 객체입니다. 상태를 변경해야 될 때, 어떠한 변화를 줄지 해당하는 액션을 발생시킬 수 있습니다.

action은 action type과 전송할 데이터(payload)로 이루어져 있습니다.

const ADDTODO = 'todo/ADDTODO';
const DELETETODO = 'todo/DELETETODO';

Action Creator (액션 생성 함수)

Action이 동작에 대해 선언된 객체라면, Action Creator는 Action을 생성해 실제 객체로 만들어 주는 함수입니다.
export const addTodo = createAction(ADDTODO, (text: string) => ({
  id: nextId++,
  text: text,
}))<Todo>();

export const deleteTodo = createAction(DELETETODO)<number>();

Reducer (리듀서)

Action을 통해 그 결과 어플리케이션의 상태가 어떻게 바뀌는지 특정하는 즉, state에 변화를 일으키는 함수입니다.

reducer는 현재(이전)의 stateaction 을 인자로 받아 store에 접근해 action 에 맞춰 state를 변경합니다. 앞서 언급한 reducer순수 함수임을 지켜야 하는 원칙으로, 이전의 상태는 건드리지 않고 변화된 새로운 상태 객체를 만들어 반환합니다. reducer핫 리로딩시간여행과 같은 멋진 기능들이 가능하게 해주는 소중한 존재입니다.

const todos = createReducer<TodosState, TodosAction>(initialState, {
  [ADDTODO]: (state, action) =>
    state.concat({
      ...action.payload,
      done: false,
    }),

  [DELETETODO]: (state, { payload: id }) => state.filter((todo) => todo.id !== id),
});

Dispatch (디스패치)

Dispatch는 store의 내장 함수 중 하나로, action을 발생시킵니다. action을 파라미터로 전달하고 reducer를 호출합니다.
dispatch(addTodo({ id: id, text: todo, done: false }));

Subscribe (구독)

Subscribe는 store의 내장 함수 중 하나로, 특정 함수를 전달해주면 action이 dispatch 되었을 때마다 전달된 함수가 호출됩니다.

Redux Flow

Redux 공식문서

Redux의 키워드를 파악했으니 Redux에서 state가 어떻게 관리되는지 흐름을 알아보도록 하겠습니다.

  1. UI에서 컴포넌트 내에 존재하는 이벤트가 호출됩니다.
  2. 이벤트와 연결된 액션 생성자가 호출됩니다.
  3. 액션 생성자에서 생성된 액션이 호출됩니다.
  4. 액션리듀서로 전달됩니다. 이 과정을 디스패치에서 담당합니다.
  5. 리듀서에서 디스패치된 액션에 따라 상태값을 변경합니다.
  6. 변경사항이 렌더링되어 UI에 나타납니다.

간단한 Todo 예제를 이용하여 Redux를 익혀보자

typescript react 프로젝트 생성

npx create-react-app [프로젝트명] --typescript

필요한 library 설치

yarn add redux react-redux @types/react-redux
yarn add typesafe-actions

typesafe-actions액션 생성 함수리듀서를 훨씬 쉽고 깔끔하게 작성 할 수 있게 해주는 라이브러리입니다.

Todo Redux modlue 작성하기

// src/modules/todos.ts

import { ActionType, createAction, createReducer } from 'typesafe-actions';

// 이 리덕스 모듈에서 관리 할 Todo의 타입을 선언합니다
export type Todo = {
  id: number;
  text: string;
  done: boolean;
};

export type TodosState = Todo[];

// action 타입 선언
const ADDTODO = 'todo/ADDTODO';
const DELETETODO = 'todo/DELETETODO';
let nextId = 1;

// action creator
export const addTodo = createAction(ADDTODO, (text: string) => ({
  id: nextId++,
  text: text,
}))<Todo>();

export const deleteTodo = createAction(DELETETODO)<number>();

const actions = { addTodo, deleteTodo };
type TodosAction = ActionType<typeof actions>;

// 초기상태 선언
const initialState: TodosState = [];

// reducer
const todos = createReducer<TodosState, TodosAction>(initialState, {
  [ADDTODO]: (state, action) =>
    state.concat({
      ...action.payload,
      done: false,
    }),

  [DELETETODO]: (state, { payload: id }) => state.filter((todo) => todo.id !== id),
});

export default todos;

프로젝트에 리덕스 적용하기

이제 프로젝트에 redux를 적용해보도록 하겠습니다. 지금은 모듈이 하나 뿐이지만 필요에 따라 여러개의 모듈이 존재할 수 있으니 그 상황을 고려하여 루트 리듀서를 만들어주도록 하겠습니다.

// src/modules/index.ts

import { combineReducers } from 'redux';
import todos from './todo';

const rootReducer = combineReducers({
  todos,
  // 그외 사용할 다른 module 추가
});

export default rootReducer;

export type RootState = ReturnType<typeof rootReducer>;

store를 생성해봅시다

// src/store.ts

import { configureStore } from '@reduxjs/toolkit';
import reducer from './modules';

const store = configureStore({
  reducer,    // rootReducer
});

export type AppDispatch = typeof store.dispatch;
export default store;

store를 프로젝트에 적용해봅니다

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { Provider } from 'react-redux';
import store from './store';

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);

root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

Provider 는 react-redux 라이브러리에 내장된, 앱에 store 를 손쉽게 연동 할 수 있도록 도와주는 컴포넌트입니다.

이제 프로젝트에서 Redux를 사용하기 위한 준비가 끝났으니 어떻게 사용되는지 알아봅시다!

Todo 추가하기

const dispatch = useDispatch();

const onSubmit = (e: FormEvent) => {
  e.preventDefault();
  dispatch(addTodo(todo));
  setTodo('');
};

새로운 Todo를 입력하고 Add 버튼을 누르면 onSubmit 함수가 호출되는 구조입니다.

onSubmit이 호출되면 dispatch를 통해 새로운 todo를 추가하겠다는 addTodo action이 동작되도록 reducer를 호출합니다.

Todo 조회하기

const todos = useSelector((state: RootState) => state.todos);

useSelector()는 Provider로 감싼 store에서 todos 데이터를 가져올 수 있게 해줍니다. useSelector를 사용하면 react-redux는 자동적으로 해당 컴포넌트가 store를 subscription 하도록 도와줍니다

실행하기

yarn start

Redux의 장점

  • 순수 함수를 사용해 상태를 예측 가능하게 만듭니다
  • 유지보수에 용이합니다
  • redux dev tool이 있어 디버깅에 유리합니다
  • 비동기를 지원하는 Redux Saga, Redux Thunk 등 다양한 미들웨어가 존재합니다.

리덕스는 언제 쓰는게 좋을까?

그렇다면 리덕스는 언제 쓰는게 좋을까요?

  • 전역 상태가 필요하다고 느껴질 때
  • 상태들이 자주 업데이트 될 때
  • 상태를 업데이트 하는 로직이 복잡할 때
  • 앱 크고 많은 사람들에 의해 코드가 관리될 때
  • 상태가 업데이트되는 시점을 관찰할 필요가 있을 때

마무리하며…

아직까지는 Redux의 사용에 미숙하여 데이터 흐름을 파악하기 어렵고, Redux를 도입하기 위해 초기 세팅하는 과정이 머릿속에서 잘 그려지지 않는 거 같습니다. 그만큼 learning curve가 높은 기술인 걸로 보이며, 상태 관리를 위해 Redux를 반드시 써야한다! 하기보다는 큰그림을 그리며 이 프로젝트에서 Redux를 도입할지, 비교적 가벼운 context API를 사용할지 등 어떤 상태 관리 방법이 프로젝트의 확장성과 미래를 고려하여 좋을지, 고민하는 시간을 진지하게 가져보고 신중히 도입해야 될 거 같습니다.

Playce Dev 팀에서는 redux와 함께 비동기를 지원하는 redux saga도 사용하고 있습니다. 이번 포스팅에서는 Redux의 탄생 배경과 Redux를 파악하고 이해하기에 초점을 맞춰 공부해보았으니 다음번엔 redux saga를 공부해보고자 합니다!

이정현
Developer

오픈소스컨설팅의 Front-End 개발자 이정현입니다. 한걸음 꾸준히 나아가며 한계를 뛰어넘기 위해 도전합니다.

Leave a Reply

Your email address will not be published. Required fields are marked *