Redux

김영재·2021년 11월 10일
1

Redux 란?

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

리덕스를 사용하는 이유?

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

이렇게 관리되고 있는 state를 각 부모 컴퍼넌트가 중간다리 역할을 해주었고 그렇게 받아온 상태값을 각각의 자식 컴퍼넌트들에게 넘겨주는 식으로 개발을 해왔습니다. 물론 컴포넌트끼리 직접 소통하는 방법도 있지만

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

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

이런 상태 중앙화를 통해서 우리는 웹사이트 상태를 어디서 관리할지 고민할 필요가 없어졌고 state를 쉽게 저장하고 불러올 수 있게 되었다.

세팅방법

리덕스 설치

// NPM

npm install redux

//Yarn

yarn add redux

보조 도구 설치

npm install react-redux

why react-redux?

redux 자체는 vanilla JS, Angular, Vue, React를 포함한 모든 UI레이어 또는 프레임워크와 사용할 수 있는 독립형 라이브러리입니다.
결론적으로 React와 Redux는 주로 함께 쓰이지만 독립적인 관계입니다.

React-redux는 react의 redux UI공식 바인딩입니다. 그렇기에 리덕스와 리액트를 함께 사용한다면 react-redux를 활용해 두 라이브러리를 바인딩해야합니다.

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

리액트는 일반적으로 빠르지만 컴포넌트에서 업데이트 하면 리액트가 컴포넌트 트리 해당부분에 있는 모든 컴퍼넌트를 다시 렌더링 합니다. 이러한 재렌더링 작업이 낭비가 될 수 있을겁니다.

성능을 개선하기위해 가장좋은 방법은 불필요한 렌더링을 하지 않는건데 react-redux는 자체적으로 많은 성능 최적화를 구현하여 자신의 구성요소가 실제로 필요할 때만 재렌더링이 일어납니다.

리덕스(Redux)

기본 개념

액션(Action)

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

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

액션 생성함수(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에 특정 함수를 전달하면 액션이 디스패치 되었을 때 전달해준 함수가 호출 됩니다.

위의 사진은 생활코딩 강의 영상을보면 나오게되는 겁니다.

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

리덕스의 3가지 규칙

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

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

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

특정 업데이트가 너무 빈번하게 일어나거나 특정부분의 관심사를 분리하고 싶을 때 여러개 스토어를 만들 수 있습니다.
하지만 그렇게 되면 개발도구를 활용하지 못합니다.

상태는 읽기전용

리액트에서 state업데이트가 필요한 상황에서 setState를 사용하고, 배열을 업데이트 해야할 때는 배열자체에 push를 직접하지않고 concat과 같은 메서드를 활용해 기존 배열을 수정하는 것이 아니고 새로운 배열을 만들어 교체하는 식으로 업데이트를 하였다. 또한 깊은구조의 객체를 업데이트 할때는 Object.assign, spread연산자를 활용하여 업데이트를 하였다.

그렇듯 리덕스에서도 마찬가지로 기존 상태값을 건드리는것이 아니라 새로운 상태를 생성하여 업데이트 해주는 방식으로 해주면 개발자 도구를 통해 뒤로 돌릴 수도 앞으로 다시 돌릴 수도 있다.

리덕스를 조금만 공부해보면 계속해서 나오는 말이 불변성을 유지해야 한다 인데 이유는 리덕스는 내부 데이터가 변경 되는것을 감지할 때 얕은 비교를 통해 검사하기 때문이다. 그렇기에 객체를 비교할 때 깊숙히 객체를 비교하는 것이 아니라 겉 핡기 식으로 비교를 하여 리덕스가 좋은 성능을 유지하는 것이다.

얕은복사 vs 깊은복사에 대해 알고싶다면 제가 정리한 이 링크를 보면 정리되어있다.

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

우리는 리덕스를 위에서 배우면서 크게 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관련 velog를 작성할 떄 다루겠습니다.

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모두 알아야하니 알아보도록하자

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함수가 동작하는 코드를 보면서 이해하자.

profile
[ frontend-developer ]

0개의 댓글