Programming/react

[react] 프론트 상태 관리를 위한 Redux 기능

x-coder 2025. 4. 19. 20:19

Hello. #{Somebody}

리액트 상태관리, 리덕스(Redux)! 🚀

 

👋

애플리케이션의 규모가 커지면서 상태 관리가 점점 복잡해지는 경험, 혹시 해보셨나요?

컴포넌트 간에 데이터를 주고받는 것이 마치 거미줄처럼 얽혀서 어디서부터 손대야 할지 막막했던 순간들이 있으셨을 수 있습니다.

이럴 때 많은 개발자들이 구세주처럼 찾는 것이 바로 Redux입니다. 특히 React와 함께 사용할 때는 React Redux라는 훌륭한 동반자가 있습니다. 


왜 상태 관리가 필요할까? (Redux의 등장 배경)

처음 React를 시작할 때는 컴포넌트 내부의 useState나 부모-자식 간의 props 전달만으로도 충분히 애플리케이션을 만들 수 있습니다. 하지만 애플리케이션이 커지고 여러 컴포넌트에서 동일한 상태를 공유하거나, 멀리 떨어진 컴포넌트끼리 데이터를 주고받아야 하는 상황이 생기면 이야기가 달라집니다.

부모에서 자식으로 props를 계속 내려주는 Prop Drilling은 코드를 읽기 어렵게 만들고 유지보수를 힘들게 합니다. 또한, 여러 컴포넌트에서 같은 상태를 독립적으로 관리하게 되면 데이터의 일관성을 유지하기 어려워지죠. 어떤 컴포넌트의 상태가 변경되었을 때 다른 컴포넌트들이 그 변화를 알기 어렵고, 상태가 왜 변경되었는지 추적하기도 힘들어집니다.

바로 이런 문제들을 해결하기 위해 전역적으로 애플리케이션의 상태를 관리하는 라이브러리들이 등장했고, 그중 가장 대표적이고 널리 사용되는 것이 바로 Redux입니다. Redux는 애플리케이션의 모든 상태를 한 곳에 모아 관리함으로써, 상태 변화를 예측 가능하고 투명하게 만들어줍니다.

 

 

Redux의 핵심 아이디어 살펴보기: 단방향 데이터 흐름

Redux의 가장 중요한 아이디어는 애플리케이션의 전체 상태를 하나의 JavaScript 객체 트리에 저장한다는 것입니다. 이것을 '단일 진실 근원(Single Source of Truth)'이라고 부릅니다.

(마치 우리 집의 모든 살림살이 목록이 적힌 하나의 큰 장부 같은 거죠.)

 

그리고 이 상태는 절대로 직접 수정하지 못하도록 강제합니다. 상태를 변경하고 싶다면 반드시 '액션(Action)'이라는 것을 발생시켜야 하고, 이 액션을 받은 '리듀서(Reducer)'만이 새로운 상태를 만들어 반환할 수 있습니다. UI는 이 새로운 상태를 받아 다시 렌더링되는 방식입니다.

이러한 방식은 데이터가 항상 일정한 방향(UI -> Action -> Reducer -> Store -> UI)으로만 흐르도록 만듭니다. 이것이 바로 '단방향 데이터 흐름(Unidirectional Data Flow)'이며, 덕분에 상태 변화를 훨씬 쉽게 예측하고 추적할 수 있게 됩니다. 복잡했던 상태 변화의 원인을 파악하고 디버깅하는 것이 훨씬 수월해지는 마법을 경험하실 수 있습니다.

 

Redux를 구성하는 세 가지 핵심 요소

 

Redux의 단방향 데이터 흐름을 이해하셨다면, 이제 이 흐름을 만드는 세 가지 주요 구성 요소를 자세히 알아볼 차례입니다. 바로 스토어(Store), 액션(Action), 리듀서(Reducer)입니다. 이 세 가지가 Redux의 전부라고 해도 과언이 아닙니다.

요소역할설명예시
스토어 (Store) 애플리케이션의 전체 상태를 담는 곳 단일 진실 근원으로, 상태(State)를 저장하고, 액션을 전달받고, 상태 변화를 구독(Subscribe)하게 해줍니다. 애플리케이션의 현재 로그인 상태, 장바구니 목록, 테마 설정 등 모든 데이터
액션 (Action) '무슨 일이 일어났는지'를 나타내는 단순 JavaScript 객체 상태를 변경하려는 의도를 표현합니다. 반드시 type 속성을 가져야 하며, 추가 데이터는 payload에 담습니다. { type: 'INCREMENT_COUNTER' }, { type: 'ADD_ITEM', payload: { id: 1, name: '책' } }
리듀서 (Reducer) 현재 상태와 액션을 받아 새로운 상태를 만들어 반환하는 순수 함수 이전 상태(prevState)와 액션(action)을 인자로 받아, 새로운 상태(newState)를 반환합니다. 상태를 불변(Immutable)하게 다루는 것이 매우 중요합니다. 카운터 값을 1 증가시키는 함수, 목록에 새 항목을 추가하는 함수

이 세 가지 요소가 서로 어떻게 영향을 받고 동작하는지 확인해보면,

  1. 액션 발생: UI에서 어떤 이벤트가 발생하면 (예: 버튼 클릭), 상태 변경을 원하는 액션을 만듭니다.
  2. 스토어로 전달 (Dispatch): 만들어진 액션을 store.dispatch(action) 메소드를 이용해 스토어로 보냅니다.
  3. 리듀서 실행: 스토어는 전달받은 액션을 등록된 리듀서에게 전달합니다.
  4. 새로운 상태 생성: 리듀서는 현재 스토어의 상태와 전달받은 액션을 가지고, 불변성을 지키면서 새로운 상태를 계산하여 반환합니다.
  5. 상태 업데이트 및 알림: 스토어는 리듀서가 반환한 새로운 상태로 자신의 상태를 업데이트하고, 상태 변화를 구독하고 있는 모든 부분(주로 React 컴포넌트)에게 상태가 바뀌었음을 알립니다.
  6. UI 업데이트: 상태 변화를 알림 받은 React 컴포넌트들은 필요한 데이터를 스토어에서 가져와 다시 렌더링됩니다.

이렇게 데이터가 한 방향으로만 흘러가니, 상태 변화의 원인과 결과를 명확하게 파악할 수 있게 되는 거죠.

 

React와 Redux를 연결하기: React-Redux

 

Redux는 JavaScript 라이브러리이기 때문에 React 뿐만 아니라 다른 프레임워크나 순수 JavaScript 환경에서도 사용할 수 있습니다. 하지만 우리가 React 애플리케이션에서 Redux를 사용하기 위해서는 이 둘을 효과적으로 연결해주는 다리가 필요한데, 그 역할을 해주는 것이 바로 react-redux 라이브러리입니다.

react-redux는 React 컴포넌트가 Redux 스토어와 쉽게 상호작용할 수 있도록 도와주는 여러 기능들을 제공합니다. 가장 핵심적인 두 가지는 다음과 같습니다.

  1. Provider 컴포넌트: 애플리케이션의 최상위 컴포넌트를 <Provider store={store}>로 감싸주면, 하위의 모든 컴포넌트들이 별도의 props 전달 없이도 Redux 스토어에 접근할 수 있게 됩니다. Context API와 비슷한 원리라고 생각하시면 쉽습니다.
  2. useSelector 훅과 useDispatch 훅:
    • useSelector: Redux 스토어의 상태에서 원하는 데이터를 선택해서 가져올 때 사용합니다. const counter = useSelector(state => state.counter); 와 같이 사용하며, 스토어의 상태가 변경되면 해당 컴포넌트를 자동으로 리렌더링합니다.
    • useDispatch: 액션을 스토어로 전달하기 위해 dispatch 함수를 가져올 때 사용합니다. const dispatch = useDispatch(); 로 함수를 가져온 후, dispatch({ type: 'INCREMENT' }); 와 같이 액션을 발생시킵니다.

예전에는 connect라는 고차 컴포넌트(Higher-Order Component, HOC)를 사용했지만, React 훅스가 등장한 이후로는 useSelector와 useDispatch를 사용하는 것이 훨씬 간결하고 편리하여 현재는 훅스 방식을 주로 사용하고 있습니다.

 

간단한 예제 코드 : 카운터 애플리케이션

 

간단한 카운터 애플리케이션을 통해 Redux와 React-Redux가 어떻게 함께 동작하는지 예제 코드로 살펴보겠습니다.

Step 1: 액션 정의

src/actions/counterActions.js

// 액션 타입 정의
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';

// 액션 생성 함수
export const increment = () => ({
  type: INCREMENT
});

export const decrement = () => ({
  type: DECREMENT
});

카운터 값을 증가시키거나 감소시키는 두 가지 액션 타입을 정의하고, 이 액션을 쉽게 생성하기 위한 함수를 만들었습니다.

 

Step 2: 리듀서 정의

src/reducers/counterReducer.js

import { INCREMENT, DECREMENT } from '../actions/counterActions';

// 초기 상태 정의
const initialState = {
  count: 0
};

// 리듀서 함수
function counterReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return {
        ...state, // 기존 상태를 복사 (불변성 유지!)
        count: state.count + 1 // count만 업데이트
      };
    case DECREMENT:
      return {
        ...state, // 기존 상태를 복사
        count: state.count - 1 // count만 업데이트
      };
    default:
      return state; // 알 수 없는 액션이 오면 현재 상태 그대로 반환
  }
}

export default counterReducer;

initialState로 카운터의 초기값을 설정하고, counterReducer 함수를 작성했습니다. 이 함수는 현재 상태와 액션을 받아 switch 문으로 액션 타입에 따라 새로운 상태를 반환합니다. 여기서 중요한 것은 ...state 문법을 사용하여 기존 상태 객체를 직접 수정하지 않고 새로운 객체를 만들어 반환한다는 점입니다. 이것이 Redux 리듀서의 불변성 규칙입니다.

 

Step 3: 스토어 생성

src/store/configureStore.js

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

// 스토어 생성
const store = createStore(counterReducer);

export default store;

Redux의 createStore 함수를 사용하여 앞서 만든 counterReducer를 이용해 스토어를 생성합니다. (참고: 실제 프로젝트에서는 Redux Toolkit의 configureStore를 사용하는 것이 일반적이며 더 편리합니다. 여기서는 핵심 개념 설명에 집중했습니다.)

Step 4: React 앱에 스토어 연결 (Provider)

src/index.js 또는 src/App.js (최상위 파일)

import React from 'react';
import ReactDOM from 'react-dom/client'; // React 18+
import { Provider } from 'react-redux';
import store from './store/configureStore';
import App from './App'; // App 컴포넌트

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <App /> {/* App 컴포넌트와 그 하위 컴포넌트들이 store에 접근 가능해짐 */}
  </Provider>
);

react-redux에서 제공하는 Provider 컴포넌트로 앱의 최상위를 감싸고 store prop으로 생성한 Redux 스토어를 전달합니다.

 

Step 5: React 컴포넌트에서 상태 사용 및 액션 발생 (useSelector, useDispatch)

src/components/Counter.js

import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from '../actions/counterActions'; // 액션 생성 함수 가져오기

function Counter() {
  // Redux 스토어에서 'count' 상태 가져오기
  const count = useSelector(state => state.count);

  // 액션을 발생시킬 dispatch 함수 가져오기
  const dispatch = useDispatch();

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
    </div>
  );
}

export default Counter;

useSelector 훅을 사용하여 Redux 스토어의 state.count 값을 가져와 화면에 표시합니다. useDispatch 훅으로 dispatch 함수를 가져온 후, 버튼 클릭 이벤트 핸들러에서 dispatch 함수를 호출하여 increment() 또는 decrement() 액션 생성 함수가 만든 액션 객체를 스토어로 전달합니다.

이렇게 하면 Counter 컴포넌트는 직접 상태를 가지거나 부모로부터 props를 받을 필요 없이, Redux 스토어와 직접 소통하여 상태를 읽고 변경할 수 있게 됩니다.

 

Redux를 사용하면 어떤 점이 좋을까? (장점과 고려사항)

 

React Redux를 사용하면 다음과 같은 여러 장점을 얻을 수 있습니다.

  • 상태 관리의 중앙 집중화: 모든 상태가 한 곳에 모여 있어 상태를 파악하고 관리하기 용이합니다.
  • 예측 가능한 상태 변화: 단방향 데이터 흐름과 리듀서의 순수 함수 특성 덕분에 상태 변화의 원인을 쉽게 추적하고 예측할 수 있습니다.
  • 강력한 디버깅 도구: Redux DevTools와 같은 도구를 사용하면 액션의 흐름, 상태 변화 과정 등을 시간 여행(Time-travel debugging)하듯이 살펴보며 디버깅 효율을 크게 높일 수 있습니다.
  • 유지보수성 향상: 상태 관리 로직과 UI 로직이 분리되어 코드의 구조가 명확해지고 유지보수가 쉬워집니다.
  • 팀 협업 용이: 상태 관리 방식에 대한 규칙이 정해져 있어 여러 개발자가 함께 작업할 때 혼선을 줄일 수 있습니다.

하지만 모든 프로젝트에 Redux가 필요한 것은 아닙니다. 규모가 작거나 상태 관리가 복잡하지 않은 애플리케이션에서는 React의 Context API나 내장 useState/useReducer만으로도 충분할 수 있습니다. Redux는 초기 설정(보일러플레이트 코드)이 다소 필요하고 개념을 익히는 데 시간이 걸릴 수 있다는 점도 고려해야 합니다. 최근에는 Redux Toolkit이 이러한 보일러플레이트 코드를 줄여주고 개발 경험을 개선해 주므로 함께 사용하는 것을 추천합니다.

 

Redux, 선택의 문제!

 

Redux는 특히 규모가 크고 상태 변화가 잦으며 여러 컴포넌트가 상태를 공유하는 복잡한 애플리케이션에서 진가를 발휘합니다. 상태 관리가 어렵게 느껴질 때, Redux는 복잡성을 효과적으로 관리하고 개발 생산성을 높이는 좋은 해결책이 될 수 있습니다.

처음에는 익숙하지 않은 개념들 때문에 어렵게 느껴질 수도 있지만, 직접 코드를 작성하고 Redux DevTools를 사용해보면서 경험을 쌓는다면 그 강력함을 제대로 느끼실 수 있을 것입니다.

 

그럼 즐거운 개발 되시길 바랍니다!🚀😉 

 

 

Bye. #{Somebody}