[Redux-toolkit] 리덕스 어떻게 더 쉽게 쓰지?

BeomDev·2023년 11월 30일
0

Redux

목록 보기
2/2

리덕스

리덕스는 자바스크립트에서 상태 관리를 편하게 할 수 있도록 도와주는 라이브러리입니다.

리덕스를 사용하기 위해서 우선 리덕스의 핵심이 되는 개념을 알고 있어야 합니다.

설치

새로운 리액트 프로젝트를 생성해줍니다.

yarn create react-app myApp

해당 디렉터리에서 redux를 설치해줍니다.

yarn add redux

핵심 개념

store

리덕스에서 애플리케이션의 상태가 관리되는 하나의 공간을 말합니다.
이 공간에서 애플리케이션이 필요로 하는 모든 상태를 관리하고 있습니다.

storereducer를 인자로 받아 생성됩니다.

reducer

리듀서는 기본 상태와 상태를 업데이트 하는 방법을 알고 있는 함수입니다.

action

상태를 업데이트 하기 위한 데이터를 담고있는 객체입니다.

리듀서는 action 객체에 담긴 type을 통해 상태를 변화시킵니다.

이 객체는 type을 필수로 가져야하고, 나머지 값들은 사용자가 자유롭게 담아서 보낼 수 있습니다.

하지만 추가적인 데이터는 payload라는 속성에 담아 보내는것이 개발자들 간의 약속입니다.

참고 : 리덕스 공식 튜토리얼

middleware

action이 리듀서에 전달되기 이전에 어떤 작업을 하고 싶을 때 middleware을 사용할 수 있습니다.
보통 비동기 처리를 위해 자주 사용되며 redux-thunk와 redux-saga와 같은 라이브러리를 통해 많이 사용됩니다.

쉽게 설명해 줄 수는 없나요?

리듀서는 주차장 차단기입니다.

주차장 차단기는 미리 설정된 주파수로 신호가 들어오면 그 신호에 따라 열리고 닫힙니다.
열리고 닫히는 방식은 주차장 차단기에 이미 프로그래밍 되어 있습니다.

스토어는 리모컨입니다.

우선 주차장 차단기에 리모컨을 등록합니다. 리모컨의 버튼을 누르는 것으로 주차장 차단기에 신호를 보낼 수 있습니다. 이를 통해 차단기를 열고 닫을 수 있습니다.

디스패치는 무선 신호입니다.

리모컨의 버튼을 누르면 주차장 차단기에 무선 신호를 전달해줍니다.

액션은 리모컨의 버튼입니다.

결국 우리는 리모컨의 열림, 닫힘 버튼을 눌러 차단기를 열고, 닫을 수 있습니다.

우리는 주차장 차단기와 리모컨을 미리 만들어두고, 상황에 맞는 버튼을 눌러 차단기를 열고 닫아야 하는것입니다.

이 문장을 리덕스로 바꿔 표현하면,

우리는 리듀서와 스토어를 미리 만들어두고, 상황에 맞는 액션을 보내 상태를 업데이트 할 수 있습니다.

리덕스 사용하기

리덕스를 이용한 상태 관리는 위에서 설명한 개념들을 합쳐서 이루어집니다.

우리는 리덕스로 상태를 관리하기 위해서 리듀서와 스토어를 미리 만들어두고,
상태를 변화시키기 위해 상황에 맞는 액션 객체를 스토어에 보내기만 하면 됩니다.

import { createStore } from 'redux';

// 액션의 타입을 미리 정의합니다.
const PLUS = 'PLUS';
const MINUS = 'MINUS';

// 액션 객체를 반환하는 함수를 미리 정의해 둡니다.
const plusNumber = () => ({ type: PLUS });
const minusNumber = () => ({ type: MINUS });

// 기본 상태와 상태를 업데이트 하는 방법을 알고 있는 리듀서를 정의합니다.
const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case PLUS:
      return state + 1;
    case MINUS:
      return state - 1;
    default:
      return state;
  }
};

// 애플리케이션의 모든 상태를 관리하는 스토어에 리듀서를 전달합니다.
const store = createStore(counterReducer);

function Counter() {
  const number = store.getState();
  
  return (
    <>			     // 스토어에게 액션 객체를 전달해 업데이트 요청을 합니다.
      <button onClick={() => store.dispatch(minusNumber())}>minus</button>
      {number}
      <button onClick={() => store.dispatch(plusNumber())}>plus</button>
    </>
  );
}

function App() {
  return <Counter />
}

그럼 이제 숫자를 증가시켜 봅시다!

왜 값이 바뀌지 않을까요?

값이 변화하는지 확인하기 위해 store를 subscribe(구독)해 action이 발생할 때마다 상태를 관찰하는 함수가 호출되도록 하겠습니다.

// 상태를 관찰하는 함수
const logger = () => {
  const state = store.getState();
  console.log(state);
};

// 스토어를 구독
store.subscribe(logger);

업로드중..

값은 잘 바뀌는데, 뷰는 바뀌지 않네요😅

store에 담겨있는 리덕스의 상태는 리액트의 상태로 취급되지 않기 때문입니다.

리덕스는 리액트를 위해 등장한 라이브러리가 아닙니다. 그래서 리덕스를 사용하는 것만으로 리액트는 값이 변화하는 것을 알 수 없습니다.

react-redux

react-redux로 리덕스의 상태를 리액트의 상태로 취급할 수 있습니다.

설치

yarn add react-redux

Provider

react-redux는 스토어를 주입하는 Provider 컴포넌트를 제공합니다. 하위 컴포넌트에서 리덕스 스토어에 접근할 수 있도록 상태를 주입합니다.

Provider 컴포넌트의 인자로 값을 사용할 컴포넌트를 감싸줍니다. Context API의 Provider 컴포넌트 인자로 값을 전달하는 것과 동일한 형태로 store를 전달합니다.

import { Provider } from 'react-redux';
import store from 'store';

function App() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}

하위 컴포넌트에서는 이제 스토어에 접근할 수 있습니다.

useSelector

변화하는 상태를 화면에 반영하기 위해서 리덕스의 상태리액트의 상태로 취급될 수 있도록 이어주어야 합니다.

useSelector는 리덕스의 상태를 리액트의 상태로 연결시켜주는 역할을 하는 함수입니다.

콜백함수에는 스토어의 상태가 전달됩니다. 콜백함수에서 사용할 값을 반환하면 스토어의 상태가 리액트의 상태로 취급됩니다.

import { useSelector } from 'react-redux';
import { minusNumber, plusNumber } from 'counterReducer';
import store from 'store';

function Counter() {
  // useSelector 사용
  const number = useSelector(state => state.number);
  
  return (
    <>
      <button onClick={() => store.dispatch(minusNumber())}>minus</button>
      {number}
      <button onClick={() => store.dispatch(plusNumber())}>plus</button>   
    </>
  );
}

리덕스의 상태를 업데이트하기 위해 store를 불러오고 있는데, 우리는 스토어를 주입 받았기에 위 과정을 생략하고 스토어에 바로 업데이트 할 수 있는 hook이 있습니다.

useDispatch

리덕스의 상태를 바로 업데이트 하기 위해서 useDispatch 함수를 사용할 수 있습니다.

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

function Counter() {
  const number = useSelector(state => state.number);
  const dispatch = useDispatch();
  
  return (
    <>
      <button onClick={() => dispatch(minusNumber())}>minus</button>
      {number}
      <button onClick={() => dispatch(plusNumber())}>plus</button>   
    </>
  );
}

이제 리액트는 리덕스의 상태 변화를 감지하고 이를 뷰에 반영할 수 있습니다.

리듀서 나누기

지금까지 리덕스의 상태를 리액트의 상태로 취급하도록 코드를 작성했습니다.

여기 카운터, 게시글의 데이터를 저장하는 기능을 하는 리듀서가 있습니다.

const initialData = {
  number: 0,
  title: '',
  text: '',
};

const PLUS = 'PLUS';
const MINUS = 'MINUS';
const UPLOAD_POST = 'UPLOAD_POST';

const mainReducer = (state = initialData, action) => {
  switch (action.type) {
    case PLUS:
      return { ...state, number: state.number + 1 };
    case MINUS:
      return { ...state, number: state.number - 1 };
    case UPLOAD_POST:
      return { ...state, title: action.payload.title, text: action.payload.text };
    default:
      return state;
  }
};

const plusNumber = () => ({ type: PLUS });
const minusNumber = () => ({ type: MINUS });
const uploadPost = (title, text) => ({
  type: UPLOAD_POST,
  payload: {
    title,
    text,
  },
});

const store = createStore(mainReducer);

왜 상태를 직접 변경하지 않고 spread 문법으로 상태를 업데이트 하는걸까요?

return { ...state, number: state.number + 1 };

그냥 이렇게 변경하면 안되나요 ?

state.number += 1;
return state;

리덕스는 상태의 변경을 감지하기 위해 객체 간 얕은 비교를 수행하기 때문에 직접 상태를 변경하면 안됩니다.

그래서 우리는 새로운 상태를 반영한 객체를 생성하고 반환해 리덕스가 이전의 상태 객체와 비교할 수 있도록 해주는 것입니다.

immer.js를 사용해 객체의 프로퍼티를 직접 변경할 수도 있지만, 여기서는 다루지 않습니다.

리듀서를 잘게 쪼개봅시다

다시 본론으로 돌아와, 위 코드처럼 하나의 리듀서에서 애플리케이션의 모든 상태를 관리하게 되면 어떨까요?

애플리케이션의 크기가 커지면 커질수록 관리하기 어렵게 될겁니다.

이런 문제를 해결하기 위해 리듀서를 나눠서 관리할 수 있습니다.
그렇게 되면 이 리듀서가 어떠한 상태를 가지고 관리하는지 한눈에 알아볼 수 있게 되겠죠.

이제 리듀서를 나누어 작성해 봅시다.

🦆 Ducks Pattern

이미 리덕스의 복잡한 보일러플레이트를 경험한 개발자들은 reducer를 잘 관리할 수 있는 디자인 패턴을 정의해두었습니다.

널리 쓰이는 패턴으로 Ducks 패턴이 있습니다.

덕스 패턴에서는 액션 타입, 액션 생성 함수, 리듀서를 한 파일에서 관리합니다.

덕스 패턴을 사용할 때는 반드시 따라야 하는 규칙이 있습니다.

MUST export default a function called reducer()
reducer 함수는 반드시 export default 를 통해 내보내야 합니다.

MUST export its action creators as functions
액션을 생성하는 함수는 반드시 export 해야 합니다.

MUST have action types in the form npm-module-or-app/reducer/ACTION_TYPE
반드시 액션 타입의 값을 npm-module-or-app/reducer/ACTION_TYPE 형식을 따라 작성해야 합니다.
만일 NPM module을 만드는 게 아니라면 reducer/ACTION_TYPE 같은 형식으로 만들어도 됩니다.
액션의 이름이 중복되지 않도록 접두사를 달아서 작성하세요.

MAY export its action types as UPPER_SNAKE_CASE, if an external reducer needs to listen for them, or if it is a published reusable library
필수는 아니지만 외부 리듀서가 모듈 내 액션 타입을 바라보거나 모듈이 재사용이 가능한 라이브러리인 경우 해당 액션 타입을 대문자 SNAKE_CASE 형태로 export 하세요.

덕스 패턴을 활용해 리듀서를 나누어 볼까요?

// numberReducer.js

const counterData = {
  number: 0,
};

export const PLUS = 'counter/PLUS';
export const MINUS = 'counter/MINUS';

export default const counterReducer = (state = numberData, action) => {
  switch (action.type) {
    case PLUS:
      return { ...state, number: state.number + 1 };
    case MINUS:
      return { ...state, number: state.number - 1 };
    default:
      return state;
  }
}

export const plusNumber = () => ({ type: PLUS });
export const minusNumber = () => ({ type: MINUS });
// postReducer.js

const postData = {
  title: '',
  text: '',
};

const UPLOAD = 'post/UPLOAD';

export default const postReducer = (state = postData, action) => {
  switch (action.type) {
    case UPLOAD:
      return { ...state, title: action.payload.title, text: action.payload.text };
    default:
      return state;
  }
}

export const upload = (title, text) => ({
  type: UPLOAD,
  payload: {
    title,
    text,
  },
});

이제 각 리듀서가 어떤 역할을 하는지 쉽게 알아볼 수 있습니다.

나눠져 있는 리듀서를 합치자

리덕스는 여러 개의 리듀서를 하나의 리듀서로 합칠 수 있는 combineReducers 함수를 제공합니다. 흩어져 있는 여러 개의 리듀서를 하나로 모아 관리할 수 있습니다.

// rootReducer.js

import { combineReducers } from 'redux';
import postReducer from 'postReducer.js';
import counterReducer from 'counterReducer.js';

const rootReducer = combineReducers({ counterReducer, postReducer });

export default rootReducer;

리듀서들을 하나로 모아 스토어에게 전달합니다.

// store.js

import { createStore } from 'redux';
import rootReducer from 'rootReducer';

const store = createStore(rootReducer);

export default store;

이제 useSelector의 콜백함수의 인자에는 하나로 모은 리듀서가 전달됩니다.

const { number } = useSelector(state => state.counterReducer);

상태를 꺼내서 사용하고 싶다면 이렇게 하면 됩니다.

redux-toolkit

지금까지 우리는 기본적인 리덕스를 사용하는 방법에 대해 살펴보았는데요, 더 편하게 리덕스를 사용할 수 있는 redux-toolkit이 있습니다. 공식문서에서도 사용을 강력히 권고합니다.

기존의 리덕스는 사용하기 위한 보일러 플레이트가 너무 많았습니다.

이러한 문제와 보일러 플레이트를 줄이고 편하게 사용하기 위해 redux-toolkit이 등장합니다.

이제 리덕스 툴킷을 사용해 더 쉽게 리덕스를 사용해봅시다.

createSlice

액션 생성 함수와 리듀서를 한 번에 작성할 수 있도록 도와주는 함수입니다.

하나의 slice는 작은 스토어의 역할을 하게 됩니다.

createSlice에 리듀서를 작성하면, 알아서 액션 객체를 반환하는 함수가 리듀서의 키 값을 가지고 자동으로 생성됩니다.

이전에 우리는 리덕스를 사용하기 위해 action type을 정의하고, 액션 객체를 생성하는 함수를 작성했습니다.

또, 리듀서에는 action type이 전달됐을 때 어떻게 상태를 업데이트 할 것인지 나누어 작성해야 했습니다.

하나의 작업을 위해 미리 준비해야 할 것이 너무나도 많았습니다. 하지만 createSlice 함수를 사용하면 이 복잡한 과정을 모두 생략할 수 있습니다.

  • 이전에 리듀서에서 작성했던 것처럼 상태의 불변성을 유지하면서 새로운 객체를 만들어 반환할 필요도 없습니다. 직접 상태에 접근해 변경할 수 있습니다.

  • 액션을 정의하는 객체를 만들기 위해 직접 만들 필요도 없습니다. createSlice에 세 가지 값을 작성하기만 하면 됩니다.

createSlice 함수는 다음과 같은 인자를 전달받습니다.

name: 슬라이스의 이름
initialState: 기본 값
reducers: 상태를 업데이트 하는 함수

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0, 
  reducers: { 
    plus: (state, action) => state + 1,
    minus: (state, action) => state - 1,
  },
});

// actions는 액션 생성 함수를 가지고 있는 프로퍼티 입니다.
export const { plus, minus } = counterSlice.actions;

createSlice 함수의 반환 객체가 가지고 있는 actions에서 액션 객체를 만드는 함수를 꺼내 사용할 수 있습니다.

리덕스 툴킷의 createAction을 사용해 액션 객체를 생성할 수도 있습니다.

하지만 createSlice만 작성해도 자동으로 액션타입을 만들어주기 때문에 이 기능을 사용하는 편입니다.

액션 객체에 추가적인 데이터를 담아 전달하고 싶다면, 액션 객체를 반환하는 함수의 인자로 값을 전달할 수 있습니다.

전달되는 인자는 액션 객체의 payload의 값으로 받을 수 있습니다.

const dispatch = useDispatch();

dispatch(plus({ value: 5 }));

// payload: { value : 5 }

얼마나 코드가 줄어들었는지 기존에 작성했던 코드와 한번 비교해 보겠습니다.

리듀서 코드 비교

리덕스

const counterData = {
  number: 0,
};

export const PLUS = 'counter/PLUS';
export const MINUS = 'counter/MINUS';

export default const counterReducer = (state = numberData, action) => {
  switch (action.type) {
    case PLUS:
      return { ...state, number: state.number + 1 };
    case MINUS:
      return { ...state, number: state.number - 1 };
    default:
      return state;
  }
}

리덕스 툴킷

리덕스 툴킷은 immer.js가 내장되어 있으므로 사용자가 데이터를 불변하게 다루지 않아도 됩니다.

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0, 
  reducers: { 
    plus: (state, action) => state + 1,
    minus: (state, action) => state - 1,
  },
});

😳 immer.js
불변성을 쉽게 관리할 수 있게 도와주는 자바스크립트 라이브러리
상태를 직접 수정하는 것처럼 코드를 작성해도 immer.js가 불변성을 유지하도록 도와준다.

액션 객체 생성 코드 비교

리덕스

const PLUS = 'counter/PLUS';
const MINUS = 'counter/MINUS';
  
export const plusNumber = () => ({ type: PLUS });
export const minusNumber = () => ({ type: MINUS });

plusNumber(); // { type: counter/PLUS }

리덕스 툴킷

export const { plus, minus } = counterSlice.actions;

plus(); // { type: counter/plus }

configureStore

우리는 기존에 여러 개의 리듀서를 combineReducers 함수를 사용해 하나로 모았습니다.

const rootReducer = combineReducers({ counterReducer, postReducer });
const store = createStore(rootReducer);

리덕스 툴킷은 여러 개의 리듀서를 한번에 스토어로 전달할 수 있는 configureStore 함수를 제공합니다.

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

리덕스 툴킷의 Ducks pattern

리덕스 툴킷도 덕스 패턴을 적용할 수 있습니다.

리듀서 함수는 반드시 export default를 통해 내보내야 합니다.

액션을 생성하는 함수는 반드시 export 해야합니다.

리덕스 툴킷에 덕스 패턴을 적용해 보겠습니다.

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0, 
  reducers: { 
    plus: (state, action) => state + 1,
    minus: (state, action) => state - 1,
  },
});

// 액션을 생성하는 함수를 export 합니다.
export const { plus, minus } = counterSlice.actions;

// 리듀서 함수를 내보냅니다.
export default counterSlice.reducer;

마치며

이렇게 지금까지 리덕스에서의 상태 관리 방법에 대해서 알아보았습니다.
설명에 오류가 있다면 댓글로 알려주시면 감사하겠습니다.

0개의 댓글