Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
Frontend/ReactNative

상태관리와 Redux

by Toughie 2024. 11. 4.

 

언제든지 redux가 필요할 때 빠르게 복기하기 위해서 정리하는 용도로 작성!


프론트엔드에서 상태관리란? 

 
상태관리는 앱의 UI에 필요한 데이터나 상태를 저장하고 변경사항을 관리하는 과정을 말한다. 
React는 컴포넌트를 기반으로 UI를 구성하는데, 상태가 바뀌면 '리랜더링'이 발생하기 때문에 상태를 정확히, 효율적으로 관리해야 한다.
대규모 프로젝트의 경우 엄청나게 많은 컴포넌트가 존재하고 상태가 걷잡을 수 없이 많아지며 꼬일 수 있다.
또한 상태를 공유하기 위해 props-drilling와 같은 문제에 직면할 수도 있다.
그래서 컴포넌트별로 상태를 관리하는 것이 아니라, 중앙집중식으로 상태를 관리하려는 시도가 이어졌다.


다양한 상태관리 라이브러리

리액트,리액트네이티브에서 사용할 수 있는 상태관리 라이브러리의 종류는 다양한다.

Context API

리액트 내장 상태관리 라이브러리이다. (순정)
외부 라이브러리 설치가 필요없으며 컴포넌트 트리를 따라 하위 컴포넌트로 상태를 직접 전달할 수 있다.
하지만 컨텍스트의 값이 업데이트 될 때마다 이 컨텍스트를 구독하고 있는 모든 컴포넌트가 리랜더링 되기 때문에
대규모 프로젝트에서는 비효율적일 수 있다. 또한 디버깅 관련 기능도 다소 빈약하다.
 
하지만 아래와 같이 context api의 단점을 보완해서 탄생한 유명한 라이브러리들도 많다.
Zustand, Recoil

Zustand는 그림이 귀여워서 눈길이 갔는데 독일어로 상태를 의미한다고 한다.
최근 리덕스에서 많이 갈아타는 추세로 보인다.

import create from 'zustand'

const useStore = create((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 })),
  decrease: () => set((state) => ({ count: state.count - 1 }))
}))


const count = useStore((state) => state.count)
const increase = useStore((state) => state.increase)

 
얼핏 봐도 엄청 심플해 보이긴 한다..
 


Redux ( react-redux & redux-toolkit )

redux는 앱 상태 관리를 위한 JS 라이브러리이다. (주로 react앱에서 많이 쓰지만 JS앱이면 쓸 수 있다.)
react 프로젝트에서 redux를 쓰려면 react-redux와 redux-toolkit를 보통 설치한다.
 
react-redux react와 redux의 브릿지 역할을 한다. (redux 스토어 상태를 react 컴포넌트에서  쓸 수 있도록)
redux-toolkit : 복잡한 redux의 설정을 도와주고 유티리티를 제공해주는 라이브러리(*보일러 플레이트 코드 감소)
* 보일러 플레이트 코드 : 특정 작업을 위해서 매번 반복해서 작성해야 하는 기본 코드 구조
 

Flux Architecture

 
Redux는 Meta가 만든 Flux 아키텍쳐(패턴)에서 영감을 받아 발전했다고 한다.  '단방향 데이터 흐름'을 중요시 한다. 

 
Action : 상태를 변경하기 위한 명령이나 요청 (어떤 상태 변경이 필요한지? 관련 정보를 포함하는 객체) 
 
Dispatcher : 액션을 스토어로 전달하는 역할 
 
Store : 앱의 상태와 비즈니스 로직을 보관.
디스패쳐를 통해 액션을 받으면 이에 맞게 내부 상태를 업데이트 하고 ,이 상태를 구독하는 뷰 컴포넌트에 알려준다. 
 
View : 사용자가 보는 UI로 Store가 제공하는 상태에 기반해서 렌더링 된다. 
스토어의 상태가 업데이트 되면 자동으로 리랜더링해서 유저에게 최신 상태를 보여준다.
 
 
[단방향 흐름]
사용자 상호작용 -> View에서 Action생성 -> Dispatcher를 통해 Action이 Store로 전달 
-> Store에서 액션에 따라 상태를 업데이트, 새로운 상태를 View에 전달 -> View는 변경된 상태에 맞춰 리랜더링 
 
단방향 흐름의 장점은 뭘까? 
상태의 변화 과정 이해 및 예측이 용이해 진다.
결국 어떤 상태든 뷰에서 출발해서 액션, 디스패쳐, 스토어를 통해 다시 뷰로 들어오기 때문이다. 
(뒷걸음질 치지 않는다..!)
유지보수, 디버깅에 있어서도 이곳 저곳에 어지럽게 상태가 바뀌는 것보다 훨씬 포인트를 찾기 쉬울 것이다.
또한 Redex의 경우 Redux DevTools와 같은 강력한 디버깅 툴도 있다. 


Redux 기본 설정 및 예시

[ 0. 설치 ]

https://react-redux.js.org/introduction/getting-started

Getting Started with React Redux | React Redux

Introduction > Getting Started: First steps with React Redux

react-redux.js.org

https://redux-toolkit.js.org/

Redux Toolkit | Redux Toolkit

The official, opinionated, batteries-included toolset for efficient Redux development

redux-toolkit.js.org

먼저 react-redux와 redux-toolkit을 설치해준다.


[1. Slice와 Reducer 생성]

 
slice는 redux에서 독립된 상태 관리 단위이다. 
슬라이스의 이름, 초기 상태, 상태를 변경하는 함수인 reducer 그리고 reducer에 연결되는 action 함수 등이 포함된다.
이렇게 조각 조각 만들고 나서 이 조각들을 store에서 보관하고 관리한다고 이해할 수 있다. 
slice가 책이라면 store은 책장 정도로 생각해도 괜찮을 것 같다.
 

// favorites.js 파일
import { createSlice } from "@reduxjs/toolkit";

const favoritesSlice = createSlice({
  name: "favorites",
  initialState: {
    ids: [],
  },
  reducers: {
    addFavorite: (state, action) => {
      state.ids.push(action.payload.id);
    },
    removeFavorite: (state, action) => {
      state.ids.splice(state.ids.indexOf(action.payload.id), 1);
    },
  },
});

export const addFavorite = favoritesSlice.actions.addFavorite;
export const removeFavorite = favoritesSlice.actions.removeFavorite;
export default favoritesSlice.reducer;

 
createSlice : redux toolkit에서 제공하며 상태와 이를 변경할 수 있는 reducer를 정의하는 슬라이스를 만든다. 
인수로 아래의 키들을 포함한 객체를 넘겨줘야 한다.
 
name : 슬라이스의 이름으로, 전역 상태에서 구분된다.
initialState : 슬라이스의 초기 상태 정의 (위 예시에서는 ids를 키로, 빈 배열을 밸류로 ) 다른 키-밸류 추가 가능
 
reducers : 상태를 변경하는 함수들이 모여있는 객체 (불변성 처리 이후 후술)


* Redux Toolkit의 불변성 처리 

https://redux-toolkit.js.org/usage/immer-reducers

Writing Reducers with Immer | Redux Toolkit

 

redux-toolkit.js.org

 
위 예시 코드에서는 배열을 변경할 때 push를 사용하고 있는데 일반적인 redux에서는 push가 아니라 concat을 사용하는 등 새로운 배열을 반환해야 한다. 즉 기존 상태 객체를 직접 수정하지 않고, 새로운 객체를 생성하는 것이 Redux의 원래 규칙이다. 

// 기존 Redux 방식 (불변성을 유지하기 위해 새로운 객체를 반환)
return {
  ...state,
  ids: [...state.ids, action.payload.id]
};

 
하지만 redux toolkit의 createSlice 내부에서는 상태를 '직접 변경하는 것처럼 보이는' 코드를 작성할 수 있다.
'것처럼 보이는'이 중요하다. 실제로는 진짜 직접 상태 객체를  변경 하는 것이 아니다. 
immer라는 불변 업데이트 로직 작성을 더 편리하게 해주는 라이브러리를 활용하는 것이다. 
push로 빈배열에 직접 조작을 가하는 것 같은 코드를 작성하면, immer가 이 코드를 내부적으로 불변성을 지키는(redux-rule)
코드로 변환해서 새로운 상태 객체를 생성해 주는 것이다. immer가 알아서 기존 상태를 복사해서 새로운 객체를 반환해준다는 말이다.
 
[결론]
redux에서는 원본 상태 객체를 직접 변경하는 것이 아니라, 새로운 상태 객체를 생성하는 것이 원칙.
하지만 redux toolkit의 createSlice 내부에서는 immer가 위 작업을 편하게 해주기 때문에 원본 상태 객체를 변경하는 것과 같은
직관적인 코드를 작성할 수 있다.


 
자 다시, reducer의 함수 하나를 살펴보자.

    addFavorite: (state, action) => {
      state.ids.push(action.payload.id);
    },

 
state: 현재 최신 상태 (지금 예시에서는 빈 배열) 
 
action : 디스패치된 액션 객체, type과 payload를 포함한다. 
 

//뷰 컴포넌트에서 사용할 경우
dispatch(addFavorite({ id: mealId }))

(컴포넌트에서 액션 생성기를 호출해 액션 객체 생성 후 디스패치 했다고 가정)
 
redux toolkit의 createSlice를 사용하면 리듀서 함수에 의해 자동으로 액션 생성기가 생긴다. 
이 액션 생성기는 아래와 같은 액션 객체를 반환한다.

{
  type: 'favorites/removeFavorite', // 자동으로 생성되는 타입
  payload: { id: mealId } // 전달된 payload
}

payload는 액션이 작업을 수행하는데 필요한 데이터를 담는 곳이라고 생각하면 된다.
 
그냥 쉽게 addFavorite 리듀서 함수는 최신 상태와, 데이터를 받아서 상태를 최신화 하는 것이다. 
(위 예시에서는 배열에 데이터 push)
 
액션 -> 디스패치 -> 리듀서 -> 상태 최신화! 
 
addFavorite 뿐만 아니라 다양한 상태를 변경하는 (crud 등) 리듀서 함수를 추가해줄 수 있다.
 
위에서 설명을 위해서 dispatch 함수가 먼저 등장했는데, export 부분도 설명이 필요하다. 

export const addFavorite = favoritesSlice.actions.addFavorite;
export const removeFavorite = favoritesSlice.actions.removeFavorite;
export default favoritesSlice.reducer;

 
이름이 동일하게 돼있어서 헷갈릴 수 있는데, 오히려 그래서 더 명확히 다른 것을 확인할 수 있다.
reducer에 등록된 addFavorite은 reducer 함수이고,
지금 내보내고 있는 addFavorite는 액션 객체를 생성하는 액션생성기이다. 
 
1. 액션 생성기를 통해서 액션 생성 - addFavorite({id: mealId});
2. 디스패치 - dispatch(액션);
3. 스토어(리듀서 관리_아래에서 추가 설명) 는 디스패치된 액션의 타입을 확인하고 해당 타입을 처리하는 리듀서 확인 (책 찾기) 
4. 리듀서는 현재 상태와 디스패치된 액션 객체를 인자로 받아서 호출 
 
export default 익명 내보내기
위 예시코드에서 슬라이스에서 생성된 리듀서 함수를 이름없이 그냥 기본으로 내보내고 있다.
export default는 모듈에서 한 번만 사용할 수 있고, 다른 파일에서 import할 때 중괄호 없이 
바로 이름을 정해서 바로 불러올 수 있다. (아래 스토어 생성 import에서 확인할 수 있다.)


[ 2. Redux Store 생성 ]

// store.js 파일
import { configureStore } from "@reduxjs/toolkit";
import favoritesReducer from "./favorites.js";

export const store = configureStore({
  reducer: {
    favoriteFruits: favoritesReducer,
  },
});

 
configureStore : Redux toolkit의 함수로 'Redux Store'를 생성한다.
reducer : store에 등록할 reducer 객체 (여러 슬라이스의 리듀서를 포함할 수 있다.)
favoritesReducer로 익명 내보내기된 리듀서를 가져와서 favoriteFruits라는 키로 등록했다.
 
store는 전체 앱의 상태를 관리하는 중앙 저장소이다.
컴포넌트는 데이터 읽기가 필요하면 useSelector 훅을 사용해서 스토어의 상태를 읽고,
데이터 업데이트가 필요하면 useDispatch 훅을 사용해서 액션 생성 -> 스토어에 디스패치 한다.
그러면 스토어에서 리듀서를 찾아서 호출하고 데이터가 업데이트 된다. 
 
컴포넌트는 스토어의 상태를 구독하고, 상태가 변경되면 자동으로 리렌더링 된다. 아래 Redux Flow를 살펴보자.

https://medium.com/@ralph1786/how-to-interact-with-the-redux-store-in-a-react-app-55b2572655fa

[3. Redux Store 연결]

슬라이스와 스토어 구성을 마쳤다면 컴포넌트에서 스토어를 구독할 수 있게 연결해줘야 한다.
Redux를 쓰는건 전역으로 어디서든 필요한 데이터에 접근하고, 변경하려는 목적이 크기 때문에
여기서는 작은 프로젝트로 가정하고 App.js에서 전체 컴포넌트를 감싸는 것으로 설명해 보겠다.

// App.js 파일
import { Provider } from "react-redux";
import { store } from "./store/redux/store";

<Provider store={store}>
  {/* 컴포넌트들 */}
</Provider>

 
Provider : Redux Store을 전체 리액트 앱에 전달해주는 역할을 한다. 스토어 접근 가능! 
store : 모든 하위 컴포넌트에서 Redux Store에 접근할 수 있도록 Provider에 전달됨.


[4. 상태 조회 및 UI 업데이트]

이제 컴포넌트에서 스토어 접근이 가능해 졌으니, 데이터를 가져오고 데이터를 변경하는 방법에 대해 알아보자.

import React from "react";
import { View, Button } from "react-native";

import { useSelector, useDispatch } from "react-redux";
import { addFavorite } from "./store/redux/favorites";

const FavoriteComponent = () => {
  const favoriteMealIds = useSelector((state) => state.favoriteMeals.ids);
  const dispatch = useDispatch();

  const addMealToFavorites = (mealId) => {
    dispatch(addFavorite({ id: mealId }));
  };

  return (
    <View>
      <Button 
        title="Add Favorite"
        onPress={() => addMealToFavorites(1)}
      />
    </View>
  );
};

export default FavoriteComponent;

지금은 예시를 최대한 단순화 해서 완벽하지는 않지만, 기본적으로 현재 데이터 슬라이스에는 빈배열이 있고
버튼을 누르면 빈 배열에 1이 추가되는 상황을 가정했다.
 
 
useSelector : 스토어(store)에 등록된 특정 슬라이스키(favoriteMeals)로 접근해 상태(ids)를 가져온다.
 
useDispatch : Redux 액션을 dispatch 하는 함수 
버튼을 누른다 -> addFavorite 액션 생성기로 액션 객체를 만든다
-> useDispatch를 통해  이 액션으로 store의 dispatch 함수를 호출한다. (스토어로 액션을 전달한다!)
-> 스토어는 액션의 타입을 보고 해당하는 리듀서를 찾고 호출한다.
-> 해당 리듀서가 호출돼서 현재 상태와 액션을 받아 새로운 상태를 반환하면서 상태가 업데이트 된다. 

-> 해당 슬라이스의 상태를 구독중인 컴포넌트는 자동으로 리랜더링 된다. (e.g. useSelector로 읽는 경우)
 


그래서 클라이언트에서 상태를 관리하는 것이 좋을까 아니면 서버에서 그때그때 받아오는 것이 좋을까?

 

 
간단하게 Redux를 통한 상태 관리가 무엇인지, 어떻게 하는 것인지 살펴봤다.
그런데 이걸 언제 어디서 어떻게 사용해야 하는지 고민할 필요도 있어 보인다.
 
프론트엔드는 결국 데이터를 사용자에게 보여주는 역할이다.
데이터를 어디서 주로 가져오고 관리할 지 비중 조절을 잘 하는 것이 포인트일 것이다.
뭐가 더 좋을까?는 사실 의미가 없는 질문이긴 하다. 가볍게 각 방식의 장단점 정도만 정리해 보았다.
결국 대부분의 서비스 앱들은 두 가지 방식을 혼용해서 사용할 것이기 때문에..
 

서버에서 데이터를 그때그때 받아오는 방식 

[장점]
- 클라이언트에서 불필요한 데이터를 저장하지 않아도 되니 메모리를 절약할 수 있다.
- 확실하게 데이터의 최신성을 보장할 수 있다. 
- 클라이언트에서 상태를 별도로 관리하지 않아도 돼서 단순하고 유지보수가 용이한 코드를 유지할 수 있다.
 
[단점]
- 네트워크 요청이 잦아지기 때문에 성능 저하가 일어날 수 있다.
- UI 로딩 시간 때문에 사용자 경험이 떨어질 수 있다. 
- 로컬 저장 처리 등을 따로 하지 않으면 오프라인 모드에서 사용에 제약이 있을 수 있다. 
 

클라이언트에서 주로 상태를 관리하는 방식 

[장점]
- 클라이언트에서 데이터를 캐싱하고 관리하기 때문에 네트워크 요청을 줄일 수 있다. (자주 사용하는 데이터의 경우)
- 캐싱된 데이터를 통해 어느정도는 오프라인 상태에서도 데이터를 제공할 수 있다.
- 여러 컴포넌트들이 상태를 공유해야 하는 복잡한 상황에서 비즈니스 로직 구현에 유리할 수 있다.
 
[단점]
- 많은 데이터를 저장하는 경우 메모리 사용량이 늘어날 수 있다.
- 클라이언트와 서버의 데이터 동기화 문제가 발생할 수 있다. (싱크를 맞추는 것에 신경을 써야 한다.)
- redux의 예시만 보아도 보일러플레이트 코드가 많아지기 때문에 클라이언트 코드가 다소 복잡해질 수 있다.
 


어느 정도의 비중으로 어떻게 데이터를 관리할 것인지는 프로젝트에 따라, 숙련도에 따라 너무나도 달라질 것이다.
심플하게는 최신화, 동기화, 보안 같은 것들이 중요한 데이터라면 서버에서 바로 받아오고 
그렇지 않다면 적절히 클라이언트 상태 관리도 활용하는 방식이 적절하지 않을까? 
 
개인 프로젝트에서는 상태관리 툴을 사용할 때 Redux를 사용해볼 것 같다.
더 간단하고 가벼운 Zustand 등도 있지만 아직 여전히 redux가 압도적으로 많이 쓰이고 레퍼런스도 많기 때문이다.
최신 기술이 항상 최고는 아니라는 것을 절실히 느끼는 요즘이다.. 결국은 회사에서 뭐 쓰냐가 제일 중요한듯 ㅎㅎ

'Frontend > ReactNative' 카테고리의 다른 글

React 와 JavaScript정리  (0) 2024.07.28
Expo로 RN 프로젝트 시작하기 (settings)  (1) 2024.07.21