TIL

Redux

유댕2 2021. 4. 21. 11:17

Redux 란?

💡 Redux는 리액트에서 현재 가장 많이 사용되는 상태관리 라이브러리이다. 리덕스를 사용하면 우리가 어플리케이션을 만들면서 컴포넌트들의 상태관련 로직을 다른 파일로 분리시켜 효율적인 관리를 할 수 있는 라이브러리이다.

리덕스 사용 이유

리덕스가 있기전 우리는 보통 하나의 루트 컴포넌트(App.js)에서 상태를 관리하였다.

이렇게 관리되고 있는 state를 각 부모 컴퍼넌트가 중간다리 역할을 해주었고 그렇게 받아온 상태값을 각각의

자식 컴퍼넌트들에게 넘겨주는 식으로 개발을 해다. 물론 컴포넌트끼리 직접 소통하는 방법도 있지만

그렇게하면 코드가 많이 꼬여버리고 개발자가 피해야할 코드인 스파게티 코드가 될 가능성이 높아졌다.

리덕스를 사용하면 store라는 컴퍼넌트에서 상태를 관리하는데 이것을 "상태의 중앙화" 라고 한다.

이런 상태 중앙화를 통해서 우리는 웹사이트 상태를 어디서 관리할지 고민할 필요가 없어졌고 state를 쉽게

저장하고 불러올 수 있게 되었다.

세팅방법

리덕스 설치

// NPM

npm install redux

//Yarn

yarn add redux

보조 도구 설치

npm install react-redux

왜 react-redux를 사용할까?

redux 자체는 vanilla JS, Angular, Vue, React를 포함한 모든 UI레이어 또는 프레임워크와 사용할 수 있는

독립형 라이브러리이다. 결론적으로 React와 Redux는 주로 함께 쓰이지만 독립적인 관계이다.

React-redux는 react의 redux UI공식 바인딩이다. 그렇기에 리덕스와 리액트를 함께 사용한다면

react-redux를 활용해 두 라이브러리를 바인딩해야한다.

또한 react-redux는 성능 최적화를 도와준다.

리액트는 일반적으로 빠르지만 컴포넌트에서 업데이트 하면 리액트가 컴포넌트 트리 해당부분에 있는 모든

컴퍼넌트를 다시 렌더링 한다. 이러한 재렌더링 작업이 낭비가 될 수 있을것이다.

성능을 개선하기위해 가장좋은 방법은 불필요한 렌더링을 하지 않는건데 react-redux는 자체적으로 많은

성능 최적화를 구현하여 자신의 구성요소가 실제로 필요할 때만 재렌더링이 일어난다.

리덕스(Redux)

기본 개념

액션(Action)

상태에 어떤 변화가 필요할때 액션이란걸 발생시킨다.

액션은 하나의 객체로 되어있다.

액션 생성함수(Action Creator)

액션 생성함수는 액션을 만드는 함수이다. 단순 파라미터를 받아와 액션 객체 형태로 만들어 준다.

// action 예시

function addTodo(Todo){
	return{
		type:"ADD_TODO",
		todo
	}
}

리듀서(Reducer)

쉽게 리듀서는 변화를 일으키는 함수라 생각하면 된다.

리듀서는 현재 상태와 전달받은 액션 이 두가지를 파라미터를 받는다.

즉, 리듀서는 현재의 상태와 액션을 참조하여 새로운 상태값을 반환해주는 것을 리듀서라고 한다.

// reducer예시

export const reducer = (state = initialState, action){
	switch(action.type){
		case ADD_TODO:
			return{
				newState.concat(action.payload)
			}
	}
}

스토어(store)

전체 상태를 관리해 주는 컴퍼넌트이다. 리덕스에서는 하나의 애플리캐이션 당 하나의 스토어를 가진다.

스토어 안에는 현재의 앱 상태, 리듀서, 추가적인 내장 함수들이 있다.

디스패치(dispatch)

디스패치는 스토어 내장함수이다. 역할은 액션을 발생시키는 것이다. 그리고 디스패치에는 액션을 파라미터로

전달합니다. dispatch(action)과 같은 형태로 호출을 하면 스토어는 리듀서 함수를 실행시켜서 액션을 처리하는

로직이 있다면 액션을 참조하여 새로운 상태를 반환한다.

구독(Subscribe)

구독 또한 스토어의 내장함수이다. subscribe함수는 함수 형태의 값을 파라미터로 받아온다.

subscribe에 특정 함수를 전달하면 액션이 디스패치 되었을 때 전달해준 함수가 호출 된다.

[참조] ⇒ https://opentutorials.org/module/4078/24937

위의 사진은 생활코딩 강의 영상을보면 나오는 자료이다.

사진을 통해서 리덕스가 어떻게 동작하는지 위의 개념을 참조하여 전체적인 플로우를 알 수 있는데 도움을 준다.

리덕스의 3가지 규칙

뒤에 간단한 Todo를 진행하기전 리덕스에서 꼭 지켜야하는 3가지 규칙을 알고 가겠다.

하나의 애플리케이션엔 하나의 스토어

말그대로 하나의 애플리케이션에서는 하나의 스토어만 사용한다. 물론 여러개의 스토어를 만들고 싶다면

만들 수 있다.

특정 업데이트가 너무 빈번하게 일어나거나 특정부분의 관심사를 분리하고 싶을 때 여러개 스토어를 만들 수

있다. 하지만 그렇게 되면 개발도구를 활용하지 못한다.

상태는 읽기전용

리액트에서 state업데이트가 필요한 상황에서 setState를 사용하고, 배열을 업데이트 해야할 때는 배열자체에

push를 직접하지않고 concat과 같은 메서드를 활용해 기존 배열을 수정하는 것이 아니고 새로운 배열을 만들어

교체하는 식으로 업데이트를 하였다. 또한 깊은구조의 객체를 업데이트 할때는 Object.assign, spread연산자

를 활용하여 업데이트를 하였다.

그렇듯 리덕스에서도 마찬가지로 기존 상태값을 건드리는것이 아니라 새로운 상태를 생성하여 업데이트 해주는

방식으로 해주면 개발자 도구를 통해 뒤로 돌릴 수도 앞으로 다시 돌릴 수도 있다.

리덕스를 조금만 공부해보면 계속해서 나오는 말이 불변성을 유지해야 한다 인데 이유는 리덕스는 내부 데이터가

변경 되는것을 감지할 때 얕은 비교를 통해 검사하기 때문이다. 그렇기에 객체를 비교할 때 깊숙히 객체를 비교하는

것이 아니라 겉 핡기 식으로 비교를 하여 리덕스가 좋은 성능을 유지하는 것이다.

리듀서는 순수한 함수여야 한다.

우리는 리덕스를 위에서 배우면서 크게 3가지를 배웠다.

  • 리듀서는 현재상태와 액션을 파라미터로 받아 새로운 상태값을 반환한다
  • 이전상태를 직접 건들이는 것이 아닌 새로운 상태 객체를 만들어 반환한다.

그리고 마지막으로

  • 똑같은 파라미터로 호출된 리듀서는 언제나 똑같은 결과를 반환해야한다.

동일한 인풋에는 동일한 아웃풋이 있어야한다.

하지만 new Date(), 랜덤숫자를 생성하기, 네트워크 요청 과 같은 순수하지 않은 작업은 리듀서 바깥에서 처리

해야한다. 다음 문서화에 다루게 되겠지만 이런걸 위해 미들웨어를 사용한다.

조금더 자세히 알아보자면 리덕스는 두객체(prevState, newState)의 메모리 위치를 비교해 이전 객체와

새로운 객체가 동일한지 여부를 단순 체크한다. 만약 리듀서에서 이전 객체의 속성을 변경하면 새로운 상태와

이전 상태가 모두 동일한 객체를 가리킨다. 그러면 리덕스는 아무것도 변경되지 않았다 판단하고 동작하지 않는다.

리덕스로 간단한 Todo 앱 만들기

위의 설명을 바탕으로 간단한 Todo앱을 만들어 보겠다.

투두앱을 만들기전 개인적으로 굉장히 중요하게 생각하는 부분이 있다.

폴더 구조입니다.

리액트 공식문서에서도 추천하는 방식도 있고 구글링을 해보면 쉽게 많은 구조를 접할 수있다.

어떤 것이 맞다라고 정답이 있는 것은 아니지만 추후에 유지보수를 위해서나 아니면 전체 적인 플로우를 다른

개발자가 쉽게 파악하기 위해 본인만의 룰을 가지는 것도 좋은 것 같다.

본격적으로 앱을 만들어 보겠다.

action

// redux/action.js

export const ADD_TODO = "ADD_TODO";
export const DELETE_TODO = "DELETE_TODO";
export const UPDATE_TODO = "UPDATE_TODO";
export const CHECKED_TODO = "CHECKED_TODO";

export function addTodo(todo) {
	return{
		type:ADD_TODO,
		payload: todo
	}
}

export function deleteTodo(todo) {
	return{
		type:DELETE_TODO,
		payload: todoId
	}
}

export function updateTodo(todo) {
	return{
		type:UPDATE_TODO,
		payload: todo
	}
}

export function checkedTodo(todo) {
	return{
		type:CHECKED_TODO,
		payload: todo
	}
}

여기서 type과 payload를 알아보자

type

액션의 종류를 한번에 식별 할 수 있는 문자열

payload

액션의 실행에 필요한 데이터, 번역하면 유효탑재량이란 뜻을 가진다.

그런데 위의 내용을 보다보면 액션함수를 만들어 주기위해 기본적으로 작성해줘야 하는 코드가 너무 많다.

이를 해결하기 위해 redux-actions에서 제공하는 createAction이 있다.

import { createAction } from 'redux-actions';

export const ADD_TODO = 'actions/ADD_TODO';
export const DELETE_TODO = 'actions/DELETE_TODO';
export const UPDATE_TODO = 'actions/UPDATE_TODO';
export const CHECKED_TODO = 'actions/CHECKED_TODO';

export const addTodo = createAction(ADD_TODO, (todo) => todo);
export const deleteTodo = createAction(DELETE_TODO, (todo) => todo);
export const updateTodo = createAction(UPDATE_TODO, (todo) => todo);
export const checkedTodo = createAction(CHECKED_TODO, (todo) => todo);

이걸 사용하면 위의 코드와 같이 훨씬 간결해지는 효과를 볼 수있는데 이에 관련한 자세한 내용은

redux-toolkit문서화에서 다루겠다.

reducer

// redux/reducer.js

import { ADD_TODO, UPDATE_TODO, DELETE_TODO, CHECKED_TODO } from '../actions/actions';

const initialState = [
  {
    id: 0,
    name: 'Redux',
    checked: false
  }
];

export const reducer = (state = initialState, action) => {
  let newTodos = [...state];

  switch (action.type) {
    case ADD_TODO:
      return addTodo(newTodos, action);
    case DELETE_TODO:
      return deleteTodo(newTodos, action);
    case UPDATE_TODO:
      return updateTodo(newTodos, action);
    case CHECKED_TODO:
      return checkedTodo(newTodos, action);
  }
  return state;
};

function addTodo(newTodos, action) {
  return newTodos.concat(action.payload);
}

function deleteTodo(newTodos, action) {
  newTodos = newTodos.filter((todo) => todo.id !== action.payload);
  return newTodos;
}

function updateTodo(newTodos, action) {
  const index = newTodos.findIndex((todo) => todo.id === action.payload.id);
  newTodos[index] = action.payload;
  return newTodos;
}

function checkedTodo(newTodos, action) {
  const findCheckedIndex = newTodos.findIndex((todo) => todo.id === action.payload.id);
  newTodos[findCheckedIndex].checked = !newTodos[findCheckedIndex].checked;
  return newTodos;
}

위와 같이 리듀서함수를 작성하였다.

그냥 리듀서 함수에 관련 내용의 코드를 작성해도 물론 동작하지만 리듀서 함수를 보다 간결하고 가독성좋게 그리고 유지보수에서도 좋게해주기 위해 각 함수들을 아래 분리해 놓았다.

지금은 리듀서페이지가 하나이지만 나중에 프로젝트 규모가 커지면 리듀서가 많아지게 된다.

그럴때 모든 리듀서를 한군데 모아주는 combineReducers라는 것이 있다.

// redux/index.js

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

export default combineReducers({
	...
});

이런식으로 리듀서 폴더에서 index.js라는 컴포넌트를 만들어주고 모든 리듀서 컴포넌트를 모아주는 역할을

할 수 있게 된다.

그러면 스토어에서 한번에 모든 리듀서를 import받아와서 사용할 수 있기 때문에 우리가 중요하게 생각하는

유지보수나 간결한 코드 두가지를 모두 적용 시킬 수 있다.

Store

// redux/store.js

import { createStore } from 'redux';
import { reducer } from './reducers/reducers';

export let store = createStore(reducer);

위의 코드를 통해 우리는 스토어에서 투두에 관련한 내용을 담을 수 있다.

하지만 스토어에서 보다 많은 것을 설정해 줄 수 있다. 아래코드는 위에서 combineReducer를 import 받았을때

기준으로 보도록하자.

import { createStore } from 'redux';
import index from './reducers/index';
import logger from 'redux-logger';

const store = createStore(rootReducer, logger);

export default store;

위와 같이 기본적인 코드가 작성된다.

redux-logger를 사용하면 콘솔창에서 조금더 리덕스의 상태 변화를 보기쉽게 확인할 수 있다.

이제 리덕스 관련 코드 작성을 마쳤으니 투두를 위한 컴퍼넌트를 확인하자.

우선 전체적인 컴퍼넌트를 확인해보자.

// _app.js

import React from 'react';
import { Provider } from 'react-redux';
import { store } from '../src/store/store';

function MyApp({ Component, pageProps }) {
  return (
    <Provider store={store}>
      <Component {...pageProps} />
    </Provider>
  );
}

export default MyApp;

redux에 store에 있는 상태값들을 각 컴포넌트에 전달해주는 Provider를 사용하였다.

어렵게 생각할 필요 없이 Provider는 단순히 하나의 컴포넌트이며 리액트로 작성된 컴포넌트들을 위와 같이

Provider안에 넣으면 하위 컴포넌트들이 Provider를 통해 redux-store에 접근이 가능해 지는 것이다.

말그대로 공급자라고 생각하자.

// TodoList.js

import React from 'react';
import { connect } from 'react-redux';
import TodoItem from './TodoItem';

function TodoList({ state }) {
  return (
    <TodoListItemFullWrapper>
      {state.todo.map((todo) => {
        return <TodoItem key={todo.id} todo={todo} />;
      })}
    </TodoListItemFullWrapper>
  );
}
const mapStateToProps = (state) => ({
  state
});
export default connect(mapStateToProps)(TodoList);
// TodoForm.js

import React, { useState } from 'react';
import { connect } from 'react-redux';
import { addTodoSaga } from '../store/modules/todo';
import * as S from '../../styles/styles';

function TodoForm({ addTodoSaga }) {
  const [input, setInput] = useState('');

  const handleChange = (e) => {
    e.preventDefault();
    setInput(e.target.value);
  };

  const handleSubmit = () => {
    addTodoSaga({
      id: Date.now(),
      text: input
    });
    setInput('');
  };

  return (
    <S.HeadTodoForm>
      <form className="formContainer" onSubmit={handleSubmit}>
        <S.TodoInput
          className="todoInput"
          value={input}
          onChange={handleChange}
          type="text"
          required
        />
        <S.MainButton
          huge
          type="submit"
          className="todoButton"
          onClick={handleSubmit}>
          추가
        </S.MainButton>
      </form>
    </S.HeadTodoForm>
  );
}

const mapStateToProps = ({ text, addTodoSaga }) => ({
  text,
  addTodoSaga
});

const mapDispatchToProps = (dispatch) => ({
  addTodoSaga: (todo) => dispatch(addTodoSaga(todo))
});

export default connect(mapStateToProps, mapDispatchToProps)(TodoForm);
// TodoItem.js

import React, { useState } from 'react';
import { connect } from 'react-redux';
import {
  updateTodoSaga,
  checkedTodoSaga,
  deleteTodoSaga
} from '../store/modules/todo';
import * as S from '../../styles/styles';

function TodoItem({
  todo,
  text,
  updateTodoSaga,
  checkedTodoSaga,
  deleteTodoSaga
}) {
  const [isEditTodo, setIsEditTodo] = useState(false);
  const [input, setInput] = useState(todo.text);
  const [isChecked, setIsChecked] = useState(false);
  const handleUpdate = () => {
    updateTodoSaga({
      ...todo,
      text: input
    });
    setIsEditTodo(!isEditTodo);
  };

  const handleChange = (e) => {
    e.preventDefault();
    setInput(e.target.value);
  };

  const handleDelete = () => {
    deleteTodoSaga(todo.id);
  };

  const handleChecked = () => {
    checkedTodoSaga({ ...todo });
    setIsChecked(!isChecked);
  };

  const todoClassName = todo.checked ? 'doneTodo' : 'notDoneTodo';

  return (
    <S.TodoItemFull>
      <span className="listItems">
        {isEditTodo ? (
          <input type="text" value={text} onChange={handleChange} />
        ) : (
          <div className={todoClassName}>{todo.text}</div>
        )}
      </span>
      <span className="listButtonContainer">
        <S.MainButton onClick={handleUpdate}>
          {isEditTodo ? '변경' : '수정'}
        </S.MainButton>
        <S.MainButton onClick={handleDelete}>삭제</S.MainButton>
        <S.MainButton onClick={handleChecked}>완료</S.MainButton>
      </span>
    </S.TodoItemFull>
  );
}

const mapStateToProps = ({
  updateTodoSaga,
  deleteTodoSaga,
  checkedTodoSaga
}) => ({
  updateTodoSaga,
  deleteTodoSaga,
  checkedTodoSaga
});

const mapDispatchToProps = (dispatch) => ({
  deleteTodoSaga: (todo) => dispatch(deleteTodoSaga(todo)),
  checkedTodoSaga: (todo) => dispatch(checkedTodoSaga(todo)),
  updateTodoSaga: (todo) => dispatch(updateTodoSaga(todo))
});

export default connect(mapStateToProps, mapDispatchToProps)(TodoItem);

위의 코드를 천천히 보자. 처음 리덕스가 익숙하지 않으면 어렵게 느껴질 수도있다.

우선 HTML부분에서는 기본적인 리액트를 활용해 추가, 수정, 삭제 기능이있는 Todo어플리케이션 코드이다.

위에 보면 3컴포넌트모두 connect라는 것을 사용하였다.

connect를 사용하기전 useDispatch, useSelector를 활용해서 먼저 이 애플리케이션을 만들었지만

connect의 장점을 생각해서 리팩토링을 진행하면서 connect를 사용하게 변경하였다.

connect / useDispatch, useSelector모두 장단점이 있으니 알아보도록하자

useSelector() + useDispatch()

useSelector()는 리덕스 스토어의 데이터를 추출할 수 있다. 개념적으로 connect의 mapStateToProps와

거의 동일하다.

그렇다면 위의 코드에서 TodoList.js코드를 useSelector()를 사용해 보겠다.

// TodoList.js

import React from 'react';
import { useSelector } from "react-redux"
import TodoItem from './TodoItem';

function TodoList() {

let todo = useSelector(state => state);

  return (
    <TodoListItemFullWrapper>
      {todo.map((todo) => {
        return <TodoItem key={todo.id} todo={todo} />;
      })}
    </TodoListItemFullWrapper>
  );
}

export default TodoList
// TodoItem.js

import React, { useState } from 'react';
import { useDispatch } from "react-redux
import {
  updateTodoSaga,
  checkedTodoSaga,
  deleteTodoSaga
} from '../store/modules/todo';
import * as S from '../../styles/styles';

function TodoItem() {
  const [isEditTodo, setIsEditTodo] = useState(false);
  const [input, setInput] = useState(todo.text);
  const [isChecked, setIsChecked] = useState(false);
	const dispatch = useDispatch()
  const handleUpdate = () => {
    dispatch(updateTodoSaga({
      ...todo,
      text: input
    }));
    setIsEditTodo(!isEditTodo);
  };

  const handleChange = (e) => {
    e.preventDefault();
    setInput(e.target.value);
  };

  const handleDelete = () => {
    dispatch(deleteTodoSaga(todo.id));
  };

  const handleChecked = () => {
   dispatch(checkedTodoSaga({ ...todo }));

  };

  const todoClassName = todo.checked ? 'doneTodo' : 'notDoneTodo';

  return (
    <...>
  );
}

export default TodoItem;

이런식으로 훅스에서 사용하는 useSelector와 usedispatch를 사용해 보았다.

useSelector를 통해서 state를 가져다 사용을 하였고 useDispatch를 사용해 props에

action dispatch를 할 필요없이 action객체를 dispatch 할 수 있다.

connect

우선 커넥트를 사용하게되면 따라오는 mapStateToProps, mapDispatchToProps를 알아보자.

mapStateToProps ⇒ 리덕스 스토어에서 state를 조회하여 어떤걸 props로 넣어줄지 정의하는 것이다.

mapDispatchToProps ⇒ 컴퍼넌트에 프롭스로 넣어줄 액션을 디스패치하는 함수들에 관련된 함수이다.

connect는 HOC(Higher-Order-Component)고차원 함수이다.

고차원 함수는 리액트 컴퍼넌트를 개발하는 하나의 패턴으로 컴포넌트 로직을 재활용 할 때 유용한 패턴이다.

특정함수나 특정 값을 프롭으로 받아와 사용하고 싶을 때 사용한다.

커넥트 함수는 리덕스 스토어 안에 있는 state를 프롭으로 줄수도 액션을 디스패치하는 함수를 프롭으로 넣어 줄

수도 있다.

커넥트 관련 설명은 더자세히 하는것 보다는 위의 코드를 보면서 어떻게 사용했는지 확인하고 본인이 써보면

금방 터들할 수 있다.

커넥트의 동작 원리는 이 링크에서 connect함수가 동작하는 코드를 보면서 이해하자.

Connect vs useSelector(), useDispatch 무엇을 사용 해야 할까?

작성자의 개인적인 생각입니다.

사실 답은 없다.

어떤것을 사용하던 두가지 모두 학습을 해야하는 것은 맞다.

과거 회사코드는 클래스형으로 되어있는 경우가 많을 것이고 그 컴포넌트 코드에서는 connect를 사용했을

것이다. 또한 아무리 앞으로 우리가 개발을 할 때에도 함수형 컴포넌트로 작성하는것을 우선시 하겠지만

필요시 클래스형 컴포넌트를 써야할 일도 오기 때문이다.

또한 커넥트를 사용하면 위의 코드에서 보았듯이 리덕스 코드와 일반 리액트 컴포넌트 코드와 확실히 관심사

분리가 명확히 되어있는 것을 확인 할 수 있고 이는 추후 우리가 유지보수를 할 때 큰 이점이 될 것이다.

하지만 개인적인 생각으로는 앞으로 connect를 사용한 컴포넌트는 점점 보기 어려워 질것이란 생각을 한다.

현재 리액트 개발의 트렌드인 함수형 컴포넌트, 물론 트렌드라고 무조건 따라가는 것 또한 좋은것은 아니라

생각하지만 useSelector,useDispatch를 사용하였을때 편리함과 코드가 간결해지는것 그리고 빠른 개발속도

라는 장점을 생각했을 때 훅스에서 사용되는 useSelector, useDispatch에서 오는 편하다는 장점이 너무 크다

생각한다.

앞서 말했듯 정답은 없지만 절대적인 것은 이런 함수들이 어떤식으로 동작하는지 알아야하고 왜 이걸 프로젝트

에서 사용했는지 본인만의 명확한 목적성이 있어야한다.