[Redux] Redux Toolkit 적용하기

SuamKang·2023년 8월 8일

Redux

목록 보기
3/6
post-thumbnail

Toolkit이 나온 배경


프로젝트가 더 복잡해지면서 리덕스의 사용성도 복잡해지기 마련이다.
즉, 리덕스에서 관리해야할 상태들이 점점 더 많아질때를 의미한다.

가장 크게 두드러지는 부분은

1. Action type

: 리듀서에서 받는 엑션의 타입의 식별 형태가 여러 개발자가 같이 작업하고 서로 다른 엑션이 많을때, 오타가 나거나 충돌이 일어나게 될 수도 있다.

-> 이를 해결하기 위해 엑션생성자 함수를 한번만 정의해놓고 사용했다.


2. 데이터 양

: 관리하는 데이터의 양과 관련이 있다. 상태가 많을수록, 반환되는 상태 객체도 점점 비대해진다. 그말은 즉, 많은 상태를 복사해야하고, 그렇담 리듀서 함수의 로직 길이도 매우 길어질테다. ContextAPI의 복잡성을 대체하려 리덕스를 사용하는 이유가 불분명해질 수 있다는것이다.


해결방법
  • 리듀서 분리(Reducer Splitting): 특정 부분의 상태를 관리하며, 최종적으로 합쳐진 상태를 반환하는 루트 리듀서에서 조합하곤 했다.

  • 리덕스 미들웨어(Redux Middleware): 엑션이 리듀서로 디스패치 되어 실행되기 전에 중간에서 작업을 수행할 수 있도록 해준다. 비동기 작업이나 로깅, 상태 변환등을 처리 할 수 있다.

  • Immer 라이브러리 활용 : Immer는 불변성을 유지하면서 더 쉽게 상태를 업데이트 할 수 있돌고 도와주는 라이브러리이다. 복잡한 중첩 구조의 상태를 변경하여 반환할때 불변성을 지켜주면서 코드를 간결하게 작성하게 해준다.


이렇게 방법을 찾아서 해결해도 좋지만,
리덕스 팀에서 개발한 추가적인 페키지인 Redux Toolkit을 사용하면 더 편리하고 쉽게 작동할 수 있게 해준다고 한다.


Toolkit 사용하기


기존 리덕스 index.js 코드

import { createStore } from "redux";


const defaultState = {
  counter: 0,
  showCounter: true,
};

const counterReducer = (state = defaultState, action) => {
  if (action.type === "INCREMENT") {
    return {
      counter: state.counter + 1,
      showCounter: state.showCounter,
    };
  }
  if (action.type === "INCREASE") {
    return {
      counter: state.counter + action.amount,
      showCounter: state.showCounter,
    };
  }
  if (action.type === "DECREMENT") {
    return {
      counter: state.counter - 1,
      showCounter: state.showCounter,
    };
  }
  if (action.type === "TOGGLE") {
    return {
      showCounter: !state.showCounter,
      counter: state.counter,
    };
  }

  return state;
};

const store = createStore(counterReducer);

export default store;

우선 리덕스 툴킷은 리덕스의 몇가지를 단순화 시켰다.

먼저, 리덕스 툴킷의 패키지 내장된 메소드들을 사용하기 위해서 임포트 해온다.
그 중 createSlice 함수를 불러와준다.

import { createSlice } from "@reduxjs/toolkit"


"slice"는 즉 조각으로 여러개의 데이터 저장소를 분할하는 느낌으로 이해해도 좋을것 같다.

그 다음 createSlice를 호출해서 전역상태의 slice를 미리 생성해 둔다.그리고 인자로 객체 타입을 받는데, 여기서 모든 slice는 각각 식별자 역할을 하는 name 프로퍼티가 있어야 한다.

그리고 initialState와 reducers도 차례로 설정해주어야한다.

import { createStore } from "redux";
import { createSlice } from '@reduxjs/toolkit'

const defaultState = {
  counter: 0,
  showCounter: true,
};

createSlice({
  name: 'counter',
  initialState : defaultState,
  reducers: {
    increment(state){},
    decrement(state){},
    increase(state){},
    toggle(state){}
  }
});

해당 reducers에 자리한 메소드들은 이전에 기본적인 리덕스 방법으로 if case로 설정한 action type에 따른 조건을 그대로 적용해주었다.

그리고 해당 메소드들은 자동으로 최근 state를 받는다.
두번째 인자로 action도 받지만, 내가 설정한 reducers는 애초에 action에 따라 메서드가 자동으로 호출되게 지정했기 때문이다.

(하지만, increase는 엑션으로 payload를 받아서 처리하는 로직이기 때문에 받을 필요가 있다!)


이렇게 하면 불필요한 if문을 작성하지 않아도 되어 보일러 플레이트 코드도 줄일 수 있다.

그리고 각 리듀서맵 안의 메서드에 따라 로직을 작성해주는데, 이전에 리덕스 리듀서의 반환에서 가장 중요하게 다뤘던 불변성부분을 해결해주는 특성이 여기서 나타난다!

왜냐하면 Redux Toolkit은 내부적으로 Immer라는 다른 패키지를 사용하는데 이것이 코드를 감지해서 자동으로 원래 있는 상태를 복사하여 새로운 상태 객체를 생성하고, 모든 상태를 변경할 수 없게 유지하며, 변경한 상태는 그대로 변하지 않도록 오버라이딩 해준다.

--> 따라서 더 이상 불변성을 신경 쓸 필요가 없어지게 되었고 직접 상태를 변경 할 수 있다!

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

const defaultState = {
  counter: 0,
  showCounter: true,
};

createSlice({
  name: "counter",
  initialState: defaultState,
  reducers: {
    increment(state) {
      state.counter++;
    },
    decrement(state) {
      state.counter--;
    },
    increase(state, action) {
      // 이 메서드는 action을 받아야한다.
      state.counter = state.counter + action.amount;
    },
    toggle(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

이렇게 우선 하나의 slice를 생성해 보았다.
확실히 코드를 작성하는게 좀 더 편리하고 코드의 길이와 가독성도 좋아졌다.


그렇다면 store에서 생성한 slice를 어떻게 감지하여 사용할 수 있도록 해야할까?


store의 state 연결


생성한 slice를 store에 등록해 줘야한다.

그리고 이전 counterReducer를 지우고 createStore에 counterSlice.reducer를 전달 해준다.

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

const defaultState = {
  counter: 0,
  showCounter: true,
};

const counterSlice = createSlice({
  name: "counter",
  initialState: defaultState,
  reducers: {
    increment(state) {
      state.counter++;
    },
    decrement(state) {
      state.counter--;
    },
    increase(state, action) {
      // 이 메서드는 action을 받아야한다.
      state.counter = state.counter + action.amount;
    },
    toggle(state) {
      state.showCounter = !state.showCounter;
    },
  },
});


const store = createStore(counterSlice.reducer);

export default store;

그러면 counterSlice에서 설정한 .reducer에 접근할 수 있다.(여러개가 모여있는 reducers 그 자체를 의미한다.)

그러나❗️

slice가 많아지면서 앱의 규모가 커지면 문제가 생길수 있다.
왜냐하면 리덕스의 store에는 하나의 reducer만 전달해아하는 규칙에 어긋나기 때문이다.

물론 그럴때 "combine Reducers()"를 사용 할 수 있지, 리덕스 툴킷에서 제공하는 "configureStore()"를 가져와 사용할 수 있다!


configureStore는 createStore처럼 store를 만들어주는 메소드이다.
하지만 다른점은 여러개의 리듀서를 하나의 리듀서로 쉽게 합칠 수 있다는 것이다.
인자로 객체를 전달하는데 이는 요구되는 설정 객체이다.

import { createSlice, configureStore } from '@reduxjs/toolkit'

const store = configureStore({
  reducer: counterSlice.reducer;
})

주의!!!
: 객체 프로퍼티로 들어가는 리듀서는 reducers가 아닌 "reducer"
이다. 이유는 설명했다시피 리덕스에서 전역상태관리를 담당하는 리듀서는 1개만 있어야한다는것!!


현재는 하나의 slice로 다루고 있지만, 실제 프로젝트에선 다양한 slice를 다루게 될테고 가정한다면, reducer를 키로 하는 객체안에 리듀서들을 설정하여 리듀서 맵을 생성하도록 한다.

const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    auth: authSlice.reducer,
    user: userSlice.reducer
  }
});

export default store;

이렇게 모든 리듀서를 하나의 큰 리듀서로 병합한다.

이제 이렇게 해당 리듀서로 어떻게 엑션을 전달할지 살펴보자.


action 전달하기


이제 엑션을 전달하는건 createSlice로 할 수 있다.
createSlice는 서로 다른 리듀서에 해당하는 고유한 엑션 식별자를 자동으로 생성한다.

그 값을 얻으려면 현재 코드에서 counterSlice.actions를 사용하면 된다.
reducers영역에 있는 메서드 이름과 매칭해야한다.

ex) counterSlice.actions.toggle

리덕스 툴킷으로 이렇게 리듀서로 전달되는 action 객체를 뽑아 낼 수 있다.
그럼 자동으로 생성된 메서드가 생기고 그 메서드가 호출되면 액션 객체가 생성되는 흐름인것이다.

-> 즉, 메서드를 호출한다? -> 엑션객체가 생성된다.

따라서 이런 메서드는 "엑션생성자함수"라고도 불린다.

그리고 이런 객체는 이미 type프로퍼티를 내장하고 있다.
(action마다 다른 고유한 식별자와 함께!)

리덕스 툴킷을 사용함으로써 엑션 객체를 생성을 신경 쓸 필요가 없어지며 리듀서 메서드와 엑션 식별자가 이름(key)이 같다면 해당 엑션을 전달하는 플로우다.

정리하면,

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

const defaultState = {
  counter: 0,
  showCounter: true,
};

const counterSlice = createSlice({
  name: "counter",
  initialState: defaultState,
  reducers: {
    increment(state) {
      state.counter++;
    },
    decrement(state) {
      state.counter--;
    },
    increase(state, action) {
      // 이 메서드는 action을 받아야한다.
      state.counter = state.counter + action.amount;
    },
    toggle(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

const store = configureStore({
  reducer: counterSlice.reducer
});

// slice로 부터 얻어낸 action 추출
export const counterActions = counterSlice.actions;

export default store;

action 사용하기


그럼 이제 export한 엑션객체를 이용해서 사용할 컴포넌트(counter.js)에서 dispatch로 전달해보자.

import { useSelector, useDispatch } from "react-redux";

import { counterActions } from "../store";

const Counter = () => {

 const dispatch = useDispatch();
  
 const incrementHandler = () => {
   dispatch(counterActions.increment());
 };
  
 const decrementHandler = () => {
   dispatch(counterActions.decrement());
 };

 const toggleCounterHandler = () => {
   dispatch(counterActions.toggle());
 };
  
  
}  

하지만, 여기서 payload가 필요했던 엑션 객체를 받는 increaseHandler 함수를 고민해 봐야한다.

어떻게 해야할까?

답은 그대로 counterActions를 사용하고 자동으로 생성된 엑션생성자 메서드(increase())를 적용하고 인자로 어떤 값이든 전달해 주면 된다!

  const increaseHandler = () => {
    dispatch(counterActions.increase(5));
  };

그럼 increase()는 어떻게 값을 추출하는것인가?

Redux Toolkit이 자동으로 엑션 생성자를 생성하면, 엑션 객체는

{ type: SOME_UNIQUE_IDENTIFIER }

이러한 임의의 고유 식별자를 지닌 엑션 객체가 만들어 지는 형태인데, 만약 인자로 어떤 값을 부여하면
추가 필드명이 'payload'인 곳에 저장하는 메커니즘이다.

  const increaseHandler = () => {
    // { type: SOME_UNIQUE_IDENTIFIER, payload: 5 }
    dispatch(counterActions.increase(5));
  };

payload는 리덕스 툴킷이 정한 기본 필드명으로 바꿀수 없다.

따라서 기존 store/index.js 에서 정의한 slice에서

increase()로 받는 reducer의 action 접근을 amount에서 payload로 바꿔줘야한다.

// index.js

const counterSlice = createSlice({
  name: "counter",
  initialState: defaultState,
  reducers: {
    increment(state) {
      state.counter++;
    },
    decrement(state) {
      state.counter--;
    },
    increase(state, action) {
      state.counter = state.counter + action.payload;
    },
    toggle(state) {
      state.showCounter = !state.showCounter;
    },
  },
});


또다른 Slice 적용하기


리덕스 툴킷으로 다양한 상태관리를 여러개의 slice로 관리 해주고 있는걸 확인했다.

counterSlice에 이은 로그인 인증여부 상태에 따른 변화도 감지하여 컴포넌트들에 적용 시켜 보자.


store/index.js 수정


authSlice를 하나 추가해본다.


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

const initialCounterState = {
  counter: 0,
  showCounter: true,
};

const initialAuthState = {
  isAuthenticated: false,
};

const counterSlice = createSlice({
  name: "counter",
  initialState: initialCounterState,
  reducers: {
    increment(state) {
      state.counter++;
    },
    decrement(state) {
      state.counter--;
    },
    increase(state, action) {
      // 이 메서드는 action을 받아야한다.
      state.counter = state.counter + action.payload;
    },
    toggle(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

const authSlice = createSlice({
  name: "auth",
  initialState: initialAuthState,
  reducers: {
    login(state) {
      state.isAuthenticated = true;
    },
    logout(state) {
      state.isAuthenticated = false;
    },
  },
});

const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    auth: authSlice.reducer,
  },
});

// slice로 부터 얻어낸 action 추출
export const counterActions = counterSlice.actions;
export const authActions = authSlice.actions;

export default store;

그리고나서 App컴포넌트에 헤더인 Header컴포넌트와 로그인 폼이 담긴 auth컴포넌트 그리고 로그인 인증이 완료되었을때 나오는 UserProfile컴포넌트를 auth 조건에 따라 랜더링 해주자.

컴포넌트에 적용하기


App.js

import { useSelector } from "react-redux";

import Counter from "./components/Counter";
import Headers from "./components/Header";
import Auth from "./components/Auth";
import UserProfile from "./components/UserProfile";

function App() {
  const isAuth = useSelector((state) => state.auth.isAuthenticated);
  return (
    <>
      <Headers />
      {isAuth ? (
        <>
          <UserProfile />
          <Counter />
        </>
      ) : (
        <Auth />
      )}
    </>
  );
}

export default App;

그리고 로그인 폼을 담당하는 auth컴포넌트에서 해당 폼을 제출하면 authActions를 전달하여 새로운 객체를 전달 받기위해 이벤트함수를 만들어 준다.


Auth.js

import { useDispatch } from "react-redux";

import classes from "./Auth.module.css";

import { authActions } from "../store";
const Auth = () => {
  const dispatch = useDispatch();

  const loginHandler = (event) => {
    event.preventDefault();

    dispatch(authActions.login());
  };

  return (
    <main className={classes.auth}>
      <section>
        <form onSubmit={loginHandler}>
          <div className={classes.control}>
            <label htmlFor="email">Email</label>
            <input type="email" id="email" />
          </div>
          <div className={classes.control}>
            <label htmlFor="password">Password</label>
            <input type="password" id="password" />
          </div>
          <button>Login</button>
        </form>
      </section>
    </main>
  );
};

export default Auth;

그다음 마지막으로 Header컴포넌트에 로그인 인증이 완료가 되면 정상적으로 nav 컨텐츠가 보이고 UserProfile컴포넌트와 counter컴포넌트도 같이 보이게 설정한다.


Header.js

import { useSelector, useDispatch } from "react-redux";

import { authActions } from "../store";

import classes from "./Header.module.css";

const Header = () => {
  const isAuth = useSelector((state) => state.auth.isAuthenticated);
  const dispatch = useDispatch();

  const logoutHandler = () => {
    dispatch(authActions.logout());
  };

  return (
    <header className={classes.header}>
      <h1>Redux Auth</h1>
      <nav>
        {isAuth && (
          <ul>
            <li>
              <a href="/">My Products</a>
            </li>
            <li>
              <a href="/">My Sales</a>
            </li>
            <li>
              <button onClick={logoutHandler}>Logout</button>
            </li>
          </ul>
        )}
      </nav>
    </header>
  );
};

export default Header;

이렇게 보면 auth라는 하나의 데이터를 다루는 slice를 통해 여러 컴포넌트에서 state를 적용해 쓰는걸 확인 할 수 있었다.

하지만 여전히 리덕스 store은 1개뿐이고 그 안에 여러개의 다른 상태 slice가 있다는 것을 명심해야 한다!!


코드 분할하기

지금은 현재 리덕스 코드가 store라는 디렉토리 안 index.js파일에 몽땅 자리하고 있다.
하지만 일반적인 리액트 앱은 이렇다할 여러 상태 slice가 있을것이므로 slice별로 파일을 나누어 정리할 필요가 있다.


먼저 counterSlice와 authSlice를 각각 나눠 설정해 준다.

counter-slice.js

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

const initialCounterState = {
  counter: 0,
  showCounter: true,
};

const counterSlice = createSlice({
  name: "counter",
  initialState: initialCounterState,
  reducers: {
    increment(state) {
      state.counter++;
    },
    decrement(state) {
      state.counter--;
    },
    increase(state, action) {
      state.counter = state.counter + action.payload;
    },
    toggle(state) {
      state.showCounter = !state.showCounter;
    },
  },
});

export const counterActions = counterSlice.actions;

export default counterSlice.reducer;

auth-slice.js

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

const initialAuthState = {
  isAuthenticated: false,
};

const authSlice = createSlice({
  name: "auth",
  initialState: initialAuthState,
  reducers: {
    login(state) {
      state.isAuthenticated = true;
    },
    logout(state) {
      state.isAuthenticated = false;
    },
  },
});

export const authActions = authSlice.actions;

export default authSlice.reducer;

그 다음엔 이들을 store/index.js로 불러와서 메인 store를 병합해 준다.


store/index.js

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

import counterReducer from "./counter-slice";
import authReducer from "./auth-slice";

const store = configureStore({
  reducer: {
    counter: counterReducer,
    auth: authReducer,
  },
});

export default store;

각 slice에서 export할때 리듀서가 필요하기 때문에 그 부분만 해주고 수정한다.
이렇게 나눠 주었다면, 기존에 사용하던 컴포넌트들에도 import문도 수정도 같이 해주면 된다.


정리


이렇게 기존 리덕스 코드를 리덕스 툴킷으로 마이그레이션 해보았다.
툴킷을 사용해서 코드를 변환하고 더 단순하게 재구조화했다.

이렇게 툴킷으로 상태를 관리하게 된다면, 복잡한 프로젝트 앱에서
훨씬 짧고 간결하며 유지보수가 더 쉬워질 것 같다.

profile
마라토너같은 개발자가 되어보자

0개의 댓글