프로그래밍/ReactJS

Redux와 Redux Thunk로 React 앱의 상태 관리를 마스터하기

shimdh 2025. 10. 18. 09:58
728x90

React 애플리케이션을 개발하다 보면, 상태 관리의 복잡성에 부딪히는 경우가 많습니다. 특히 애플리케이션이 규모가 커질수록 데이터 흐름을 예측 가능하고 효율적으로 관리하는 것은 개발 생산성과 유지보수성에 직결되죠. 이런 문제를 해결해 주는 강력한 도구가 바로 Redux입니다. 그리고 비동기 작업을 우아하게 처리하기 위한 Redux Thunk는 Redux의 완벽한 파트너라고 할 수 있습니다.

이 글에서는 Redux의 기본 원칙과 핵심 개념을 탐구하고, Redux Thunk를 활용해 비동기 액션을 처리하는 방법을 자세히 알아보겠습니다. 실제 예시 코드를 통해 사용자 데이터를 API에서 가져오는 과정을 구현해 보겠어요. Redux를 처음 접하는 개발자부터, 더 깊이 파고들고 싶은 분들까지 유용할 거예요!

Redux, 왜 필요한가?

Redux는 예측 가능한 방식으로 애플리케이션 상태를 관리하는 데 도움을 주는 상태 관리 라이브러리입니다. 데이터 흐름이 복잡해지는 대규모 애플리케이션에서 특히 빛을 발휘하죠. Redux의 핵심 원칙은 크게 세 가지로 요약됩니다:

  1. 단일 진실 공급원 (Single Source of Truth): 애플리케이션의 전체 상태는 하나의 JavaScript 객체 트리에 저장되며, 이 객체 트리는 단 하나의 스토어(Store) 안에 존재합니다. 이는 상태의 일관성을 보장하고, 디버깅을 훨씬 용이하게 만듭니다. 모든 컴포넌트가 같은 상태를 공유하므로, 데이터 불일치 문제를 방지할 수 있어요.

  2. 상태는 읽기 전용 (State is Read-Only): 상태를 직접 변경할 수 없습니다. 상태를 변경하려면 액션(Action)디스패치(Dispatch) 해야 합니다. 이 원칙 덕분에 상태 변경 과정을 명확하게 추적할 수 있으며, 예측 가능성을 높여줍니다. "누가, 언제 상태를 바꿨나?"를 쉽게 파악할 수 있죠.

  3. 순수 함수로만 변경 가능 (Changes are Made with Pure Functions): 액션이 디스패치되면 리듀서(Reducer) 라고 불리는 순수 함수가 액션과 현재 상태를 인수로 받아 새로운 상태를 반환합니다. 리듀서는 사이드 이펙트를 일으키지 않고, 항상 동일한 입력에 대해 동일한 출력을 보장합니다. 이는 테스트와 디버깅을 간단하게 만들어줍니다.

이 원칙들을 지키면, React 앱이 더 안정적이고 확장 가능해집니다. 이제 Redux의 핵심 개념을 더 깊이 파헤쳐 보죠.

Redux의 핵심 개념 파헤치기

Redux를 이해하려면 네 가지 주요 개념을 알아야 합니다. 이 개념들은 Redux의 데이터 흐름을 구성하는 기본 블록이에요:

  • 스토어(Store): 전체 애플리케이션의 상태를 담는 중앙 저장소입니다. 모든 상태 변경은 이 스토어를 통해 이루어지며, 애플리케이션의 모든 데이터를 하나의 객체로 통합하여 관리의 용이성을 높입니다. 스토어는 getState(), dispatch(), subscribe() 같은 메서드를 제공해 상태를 읽고 변경하며 구독할 수 있게 해줍니다.

  • 액션(Actions): 상태 변경 의도를 나타내는 일반 JavaScript 객체입니다. 각 액션에는 반드시 type 속성이 있어야 하며, 선택적으로 payload로 데이터를 포함할 수 있습니다. 액션은 "무슨 일이 일어났는지"를 설명하며, 데이터와 함께 디스패치되어 리듀서에 전달됩니다. 예: { type: 'FETCH_USERS_SUCCESS', payload: userData }.

  • 리듀서(Reducers): 현재 상태와 액션을 인수로 받아 새로운 상태를 반환하는 순수 함수입니다. 리듀서는 상태 변경 로직을 포함하며, 이전 상태를 직접 변경하지 않고 항상 새로운 상태 객체를 반환하여 불변성을 유지합니다. 여러 리듀서를 combineReducers로 결합해 복잡한 상태 트리를 관리할 수도 있어요.

  • 미들웨어(Middleware): 비동기 호출과 같은 사이드 이펙트를 더 우아하게 처리할 수 있도록 Redux의 기능을 확장하는 함수입니다. 미들웨어는 액션이 디스패치되어 리듀서에 도달하기 전에 액션을 가로채어 추가적인 로직을 수행할 수 있게 합니다. 로깅, 에러 보고, 비동기 API 호출 등 다양한 용도로 활용되며, Redux Thunk가 대표적인 미들웨어입니다.

이 개념들을 바탕으로 Redux는 "액션 → 디스패치 → 리듀서 → 상태 업데이트 → 컴포넌트 재렌더링"이라는 예측 가능한 단방향 데이터 흐름을 만듭니다.

Redux Thunk: 비동기 액션을 위한 현명한 선택

Redux는 기본적으로 동기적인 데이터 흐름을 가정합니다. 하지만 실제 애플리케이션에서는 API 호출, 타이머, 파일 업로드 같은 비동기 작업이 필수적이에요. 이때 Redux Thunk 미들웨어가 등장합니다. Redux Thunk는 액션 객체 대신 함수를 반환하는 액션 생성자를 작성할 수 있게 해줍니다. 이 함수는 스토어를 업데이트하기 위해 액션을 디스패치하기 전에 비동기 작업을 수행할 수 있어요.

Thunk의 이름처럼 "생각하다(think)"라는 의미로, 액션 생성자가 "먼저 생각하고(비동기 작업 수행), 나중에 액션을 디스패치"하는 패턴을 지원하죠. 설치도 간단합니다: npm install redux-thunk.

Redux Thunk를 사용하는 이유

Redux Thunk를 사용하면 다음과 같은 이점을 얻을 수 있습니다:

  • 비동기 액션(Asynchronous Actions) 관리: 많은 애플리케이션에서 API에서 데이터를 가져오거나 다른 비동기 작업을 수행한 후에 애플리케이션 상태를 업데이트해야 합니다. Redux Thunk는 이러한 비동기 흐름을 깔끔하게 관리할 수 있도록 도와줍니다. 예를 들어, 네트워크 요청을 시작하고 성공 및 실패 시점에 따라 다른 액션을 디스패치할 수 있습니다. 로딩 상태, 에러 핸들링을 중앙에서 처리할 수 있어요.

  • 복잡한 로직 처리(Complex Logic Handling): 컴포넌트에 사이드 이펙트 코드를 어지럽히지 않고 복잡한 로직을 Thunk 안에 캡슐화할 수 있습니다. 이는 컴포넌트를 순수하게 유지하고, 비즈니스 로직을 액션 생성자에 집중시켜 코드의 가독성과 유지보수성을 향상시킵니다. 테스트도 더 쉬워집니다!

Redux Thunk는 가볍고 직관적이기 때문에, Redux 초보자도 쉽게 도입할 수 있어요. (대안으로는 Redux Saga나 Redux Observable이 있지만, Thunk가 가장 간단합니다.)

Redux와 Redux Thunk를 활용한 사용자 데이터 가져오기 예시

실제 예시를 통해 Redux와 Redux Thunk가 어떻게 함께 작동하여 사용자 데이터를 API에서 가져와 Redux 스토어에 저장하는지 살펴보겠습니다. JSONPlaceholder의 가짜 API를 사용해 보죠. (실제 프로젝트에서는 환경 변수로 API URL을 관리하세요.)

1단계: 스토어 설정

먼저, 기본적인 Redux 구조를 설정합니다. 리듀서, 액션 타입, 그리고 Thunk 미들웨어를 적용한 스토어를 만듭니다.

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

// 초기 상태
const initialState = {
  users: [],
  loading: false,
  error: null,  // 에러 상태 추가로 더 robust하게
};

// 액션 타입 정의
const FETCH_USERS_REQUEST = 'FETCH_USERS_REQUEST';
const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';

// 리듀서: 액션 타입에 따라 상태를 업데이트
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case FETCH_USERS_REQUEST:
      return { ...state, loading: true, error: null };  // 로딩 시작, 에러 초기화
    case FETCH_USERS_SUCCESS:
      return { ...state, loading: false, users: action.payload };  // 성공 시 데이터 저장
    case FETCH_USERS_FAILURE:
      return { ...state, loading: false, error: action.payload };  // 실패 시 에러 저장
    default:
      return state;
  }
};

// 미들웨어를 사용하여 스토어 생성
const store = createStore(reducer, applyMiddleware(thunk));

이 코드에서는 에러 상태를 추가해 더 실전적으로 만들었습니다. Redux DevTools를 사용하면 상태 변화를 실시간으로 볼 수 있어요!

2단계: Thunk를 사용한 비동기 액션 생성자 생성

이제 redux-thunk를 사용하여 비동기 액션 생성자를 만들어 보겠습니다. API 호출 전후에 적절한 액션을 디스패치합니다.

// 액션 생성자
export const fetchUsers = () => {
  return async (dispatch) => {
    dispatch({ type: FETCH_USERS_REQUEST });  // API 호출 시작 시 요청 액션 디스패치
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/users');
      if (!response.ok) {
        throw new Error('네트워크 오류 발생');
      }
      const data = await response.json();
      dispatch({ type: FETCH_USERS_SUCCESS, payload: data });  // 성공 시 데이터 디스패치
    } catch (error) {
      dispatch({ type: FETCH_USERS_FAILURE, payload: error.message });  // 실패 시 에러 디스패치
    }
  };
};

이 액션 생성자는 dispatch 함수를 받아 비동기 작업을 처리합니다. 에러 핸들링을 강화해 실제 앱처럼 만들었어요.

3단계: 컴포넌트 연결

마지막으로, React 컴포넌트에서 react-redux 훅을 사용해 스토어와 연결합니다. 컴포넌트가 마운트될 때 자동으로 데이터를 가져오도록 합니다.

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchUsers } from './actions';  // Thunk 액션 생성자 임포트

const UserList = () => {
  const dispatch = useDispatch();
  // Redux 스토어에서 상태 접근
  const users = useSelector((state) => state.users);
  const loading = useSelector((state) => state.loading);
  const error = useSelector((state) => state.error);

  // 컴포넌트 마운트 시 사용자 가져오기
  useEffect(() => {
    dispatch(fetchUsers());
  }, [dispatch]);

  if (loading) {
    return <div>로딩 중...</div>;  // 로딩 중일 때 메시지 표시
  }

  if (error) {
    return <div>오류 발생: {error}</div>;  // 에러 발생 시 메시지 표시
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name} ({user.email})
        </li>
      ))}
    </ul>
  );
};

export default UserList;

이 컴포넌트는 useSelector로 상태를 읽고, useDispatch로 액션을 디스패치합니다. 로딩과 에러를 처리해 사용자 경험을 향상시켰어요. 전체 앱에서 <Provider store={store}><UserList /></Provider>로 연결하면 됩니다.

728x90