[React] Redux

invisibleVoice·2025년 2월 3일

리액트

목록 보기
13/14
post-thumbnail

Redux

리덕스는 전역 상태 관리 라이브러리다. 상태를 저장하는 중앙 저장소가 있고 모든 컴포넌트가 이 저장소에서 상태를 사용할 수 있다. 중요한 점은 부모-자식 관계가 아니어도 되고, 자식 컴포넌트에서 만든 상태도 부모 컴포넌트에서 사용할 수 있다는 것이다.

애플리케이션의 크기가 커지면서 상태가 언제 어디서 업데이트 되는지 파악하기가 힘들어졌다. 수많은 상태들을 효과적으로 관리하기 위해 상태 관리만을 위한 기술이 필요했고, 그렇게 등장한 것이 Redux 되시겠다.

Flux Architecture

Flux는 리액트를 위한 단방향 데이터 바인딩을 기반으로 설계된 아키텍쳐(추상적인 구조, 방법론)다. Redux는 이 Flux를 실제로 구현한 구현체다. 이름부터가 Reducer + Flux = Redux 다.

  • Action: 어떤 이벤트가 발생했음을 나타내는 객체
  • Dispatcher: 발생한 Action을 받아서 다른 요소들에게 전달하는 역할
  • Store: Action의 결과로 변경된 상태를 저장하는 공간
  • View: 사용자에게 데이터를 표시하고, 또 다른 Action을 트리거할 수 있는 UI

Redux에서 변한 점

  • Store의 역할 차이:
    Flux에서는 Store가 상태와 상태 갱신 로직을 모두 담고 있지만,
    Redux에서는 Store는 단순히 상태를 보관하며, 갱신 로직은 Reducer에서 처리한다.
  • Dispatcher 삭제:
    대신 뷰에 dispatch라는 핸들러가 여전히 존재하기 때문에 뷰가 그 역할을 대신한다.
  • 불변성 준수:
    컴포넌트들은 Store에 저장된 상태에 의존하기 때문에 Redux는 상태의 불변성을 강력하게 권장한다.

Redux의 삼원칙

  1. Single Source of Truth : SSOT. 모든 상태는 하나의 Store에 저장된다. 상태의 출처는 언제나 하나! 데이터의 정확성, 일관성, 신뢰성을 보장해준다.

  2. Read-Only State : 상태는 직접 변경이 불가능하고 Action객체를 이용한 제한적인 방법으로만 변경할 수 있다.

  3. Reducer should be Pure Functions : 상태 업데이트는 순수 함수인 Reducer를 통해 이루어져야 한다.



Context API vs. Redux

비슷한 기능을 가진 비슷한 Hook을 이미 다룬 적이 있다. Context API로 어느정도 데이터를 유하게 다룰 수 있는데, 왜 Redux가 필요할까?

Context API만으로는 제한이 생기기 때문에 상태가 복잡해질수록 Redux를 사용하는 것이 유리한 면이 있다. 무엇보다, Context API는 단방향 데이터 흐름을 기본으로 하기 때문에 전역 상태를 다루지 않는다.

  • 리렌더링 회피
    Context APIProvider에서 제공한 상태가 변할 경우 모든 하위 컴포넌트가 리렌더링되어 이를 최적화하기 위해서 많은 노력이 필요하지만, 리덕스는 상태 변경 시 관련된 컴포넌트만 선택적으로 업데이트가 가능해서 성능 최적화가 용이하다.

  • 상태 로직의 중앙화
    리덕스는 상태들을 하나의 저장소(store)에 저장한다. 상태 로직이 중앙에서 관리되어 일관성과 신뢰성을 보장한다. Context API는 저장소의 개념이 아니다. 특정 데이터를 Context를 통해 하위 컴포넌트로 전달하는 방식이다.

  • 다양한 미들웨어
    리덕스는 다양한 미들웨어를 지원하여 비동기 작업, 로깅, 상태 변경에 대한 추가 처리 등 복잡한 기능들을 구현할 수 있다.

Context API는 "부모 컴포넌트의 상태를 하위 컴포넌트에 쉽게 전달하는 방법"이고, Redux는 "앱 전체에서 상태를 관리하는 글로벌 저장소"다.

Q. 리렌더링 때문에라도 리덕스를 사용하는 것이 Context API를 사용하는 것 보다 이득 아닌가요? 기능도 훨씬 다양하잖아요!

A. 그렇진 않다. 단순한 애플리케이션에 리덕스를 사용하면 오히려 역효과가 날 수 있다. 리덕스는 기본적으로 꽤 복잡한 구조를 가지고 있다. 기본만으로 꽤 코드량이 많기 때문에 불필요한 오버헤드가 생길 수 있다. 다만 상태 관리는 성능의 관점보다는 어떤 방법을 사용하는 것이 애플리케이션에 어울릴까를 생각하며 선택하는 것이 좋다


Redux 사용하기

1. RootReducer 정의

// reducers/index.js

import { combineReducers } from "redux";
import todos from "./todos";

const rootReducer = combineReducers({	// reducer들을 하나의 객체로 묶는다.
  todos,
});

export default rootReducer;

2. 세부 Reducer 정의

Reducer는 상태를 변화시키는 함수로서, 순수 함수(항상 같은 결과를 가지고 외부 상태를 변화시키지 않는 함수)여야 한다.

휴먼 에러 방지를 위해 Action Creator를 사용한다.
Action Creator의 인자로 payload를 전달할 수 있다.

// reducers/todos.js

export const ADD_TODO = "TODO/ADD_TODO";
export const REMOVE_TODO = "TODO/REMOVE_TODO";
export const TOGGLE_TODO = "TODO/TOGGLE_TODO";

// Action Creator
// dispatch로 전달할 액션 객체를 반환하는 함수. payload가 들어갈 수 있다. 
export const addTodo = (text) => ({ type: ADD_TODO, text });
export const removeTodo = (id) => ({ type: REMOVE_TODO, id });
export const toggleTodo = (id) => ({ type: TOGGLE_TODO, id });

// 초기 상태값 설정. 아래 Reducer를 이용해 만들 store에 들어갈 초기값이다. 
// 꼭 객체일 필요는 없다. 
const initialState = {	
  todos: [],
};

// Reducer(상태 변화를 일으키는 순수 함수)
const todos = (state = initialState, action) => {
  switch (action.type) {	// action.type은 어떤 기능을 사용해야 하는지 알려준다
    case ADD_TODO:			// 기능에 따라 상태가 어떻게 변할지 달라진다.
      return {
        ...state,
        todos: [
          ...state.todos,
          { id: Date.now(), text: action.text, completed: false },
        ],
      };
    case REMOVE_TODO:
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    case TOGGLE_TODO:
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
        ),
      };
    default:
      return state;
  }
};

export default todos;

3. Store 만들기

// store/index.js

import { createStore } from "redux";
import rootReducer from "../reducers";

const store = createStore(	// 인자로 rootReducer를 전달
  rootReducer
);

export default store;

4. App.jsxStore 넣기

// index.js

import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import store from "./store";
import App from "./App";

ReactDOM.render(
  <Provider store={store}>	// 만들었던 store를 Provider 속성으로 넣으면
    <App />					// 태그로 감싸진 컴포넌트는 store를 사용할 수 있다. 
  </Provider>,
  document.getElementById("root")
);

5. 컴포넌트에서 Redux 사용하기

  • useSelector를 통해 원하는 reducer를 선택할 수 있다.
  • useDispatch를 통해 action 객체를 reducer로 보낼 수 있다.
    뷰에서 store의 값을 변경할 일이 생기면 dispatch를 이용하여 reducer로 action 객체를 전달한다.
    여기서 action이란 뷰에서 일어나는 이벤트라고 할 수 있다.
// components/TodoList.js

import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { addTodo, removeTodo, toggleTodo } from "../reducers/todos";

const TodoList = () => {
  const [input, setInput] = useState("");
  // state 인자는 combineReducers에 넣었던 reducers를 말한다. 
  // 정확히는 key가 reducers고 value가 해당 reducer가 관리하는 상태인 객체다.
  // state.todos는 여러 reducer 중 todos reducer가 관리하는 상태를 말하고,
  // state.todos.todos는 todos reducer가 관리하는 상태의 todos 속성을 말한다. 
  const todos = useSelector((state) => state.todos.todos);
  // dispatch는 객체를 전달한다. 이 객체에는 type과 type의 이름이 들어가야 한다. 
  const dispatch = useDispatch();

  const handleAddTodo = () => {
    if (input.trim()) {
      dispatch(addTodo(input));
      setInput("");
    }
  };

  const handleRemoveTodo = (id) => {
    dispatch(removeTodo(id));
  };

  const handleToggleTodo = (id) => {
    dispatch(toggleTodo(id));
  };

  return (
    <div>
      <h1>Todo List</h1>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Add a new todo"
      />
      <button onClick={handleAddTodo}>Add</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
            <span onClick={() => handleToggleTodo(todo.id)}>{todo.text}</span>
            <button onClick={() => handleRemoveTodo(todo.id)}>Remove</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

정리

  • Action 객체는 type이란 key를 가진 객체로, reducer로 보낼 명령같은 것. 상태를 이렇게 바꿔줘~
  • dispatch는 Action 객체를 reducer로 보내는 전달자 함수
  • reducer는 전달받은 Action 객체에 따라 상태 업데이트를 하는 함수

Redux Toolkit RTK

Redux는 편리하지만 최대 단점이 있는데, 바로 사용하기 위해 작성해야하는 코드가 너무 많다는 것! 실제로 이 문제 때문에 점유율도 줄고 다른 상태 관리 도구들이 생겨났었다. 그러나 RTK가 나오면서 다시 Redux로 모여들게 되었다.

Redux Toolkit이란 현재 Redux 로직을 작성할 때 권장되는 방법으로, Redux의 복잡성을 줄이고 개발 효율성을 높이기 위해 만들어진 일종의 문법이다. 기존의 Redux를 개량했다고 생각하면 된다. RTK를 통해 개발자는 불필요한 보일러플레이트(비슷한 로직이지만 이름만 다른, 반복적으로 사용해야 하는 코드들) 코드를 줄이고 간결하게 작성할 수 있다.

RTK의 주요 기능

  • configureStore
    Redux Store를 간단하게 설정하는 함수로 여러 reducer를 결합하고, 미들웨어와 개발자도구를 합칠 수 있게 해준다. 기존의 createStore보다 간편하다.

  • createSlice
    Redux에서 state 슬라이스를 관리하는 함수. 상태와 관련된 reducer, Action creator를 한 곳에서 정의할 수 있고, 불변성을 유지하며 상태를 업데이트할 수 있도록 Immer 라이브러리를 내부적으로 사용한다. Immer 라이브러리는 객체를 깊은 복사해주는 라이브러리로, 불변성을 지켜주는 라이브러리다.

그래서 RTK는 개발 시간 절약, 유지보수 용이, Redux의 장점 유지, 불변성 유지라는 장점을 지닌다.

RTK 사용하기

1. Redux Slice 생성하기

// features/todos/todosSlice.js

import { createSlice } from "@reduxjs/toolkit";

// createSlice를 사용해 Slice 생성
// name, initialState, reducres 3가지 속성을 가진다.
const todosSlice = createSlice({
  name: "todos",
  initialState: {
    todos: [],
  },
  reducers: {
    addTodo: (state, action) => {
      state.todos.push({			// state를 직접 바꾸듯이 코드 작성 가능
        id: crypto.randomUUID(),	// Immer 라이브러리가 내부적으로 불변성을 보장하기 때문
        text: action.payload,		// 이런 식으로 payload를 명시하면 
        completed: false,			// 알아서 payload가 포함된 Action Creator가 생성됨
      });
    },
    removeTodo: (state, action) => {
      state.todos = state.todos.filter((todo) => todo.id !== action.payload);
    },
    toggleTodo: (state, action) => {
      const todo = state.todos.find((todo) => todo.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
  },
});

// actions 속성을 통해 Action Type, Action Creator 자동 생성
// 개발자가 명시한 속성을 이용하여 payload도 알아서 추가한다. 
export const { addTodo, removeTodo, toggleTodo } = todosSlice.actions;
// reducer 속성을 통해 reducer 자동 생성
export default todosSlice.reducer;

참고로 prepare 속성을 이용하면 payload를 직접 커스터마이징할 수도 있다.
다만 prepare 함수를 추가하면 기존의 리듀서 함수가 객체 형태로 변형된다.
prepare 함수가 먼저 실행되어 payload를 원하는 형태로 가공한 후, reducer에서 그 값을 받아 상태를 변경하게 된다.

const counterSlice = createSlice({
  name: "counter",
  initialState: { value: 0 },
  reducers: {
    incrementByAmount: {
      reducer: (state, action) => {
        state.value += action.payload.amount; // 변경된 payload 구조 사용
      },
      prepare: (amount) => {
        return { payload: { amount, timestamp: Date.now() } }; // payload를 직접 변경
      },
    },
  },
});

// 커스텀 payload가 적용된 액션 객체
// prepare(10)가 먼저 실행되어 amount 값으로 10을 넣어 
// payload: { amount: 10, timestamp: 1700000000000 } 반환
console.log(counterSlice.actions.incrementByAmount(10));
// 출력 결과: 
// { type: "counter/incrementByAmount", payload: { amount: 10, timestamp: 1700000000000 } }

// prepare가 없었다면 { type: "counter/incrementByAmount", payload: 10 }이 되었을 것이다.

2. store 설정하기

// store/index.js

import { configureStore } from "@reduxjs/toolkit";
import todosReducer from "../features/todos/todosSlice";

// configureStore의 reducer 속성에 내보냈던 리듀서를 넣는다. 
const store = configureStore({
  reducer: {
    todos: todosReducer,
  },
});

export default store;

3. 컴포넌트에서 Redux 사용하기

import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { addTodo, removeTodo, toggleTodo } from "../features/todos/todosSlice";
// 똑같은데, 경로가 다르기 때문에 이것만 주의하면 된다. 

...

Ducks Pattern

Ducks 패턴은 Redux 앱을 구성할 때 사용하는 패러다임 중 하나로, 일반적으로 분산되어 있던 Action type, Action Creator, Reducer를 하나의 파일로 구성하는 방식이다. Redux 관련 코드의 관리를 보다 간결하고 모듈화하여 관리할 수 있도록 돕는다.

아래 내용을 모두 지켜 모듈을 작성하면 된다.

  1. Reducer 함수를 export default 한다.

  2. Action creator 함수들을 export 한다.

  3. Action type은 app/reducer/ACTION_TYPE 형태로 작성한다.
    (외부 라이브러리로서 사용될 경우 또는 외부 라이브러리가 필요로 할 경우에는 UPPER_SNAKE_CASE 로만 작성해도 괜찮다.)

profile
내배캠 React 9기 수료 후 Wecommit 풀스택 개발자로 근무중

0개의 댓글