Redux, RTK, RTK Qeury 뿌시기

Jeonghun·2024년 1월 9일
0

React

목록 보기
21/21
post-thumbnail


본 포스트는 상태관리 툴인 Redux Reduxt Toolkit RTK Query 에 대한 개인적인 공부와 이해를 위해 작성하는 포스트로, 공식문서 및 기존에 작성된 외부 포스팅을 참고하여 작성되었습니다.

포스팅에 참고한 본문을 확인하시려면 포스트 최하단의 참고 문서 섹션을 확인해주세요.


최근 진행하게된 프로젝트의 상태 관리툴로써, Redux 를 사용하고 있습니다. ContextAPI 혹은 Recoil, Zustand 만 사용해본 저에게 Redux 는 조금은 두려운(?) 녀석이었습니다. 사용시 익혀야하는 Boilerplate Code 가 많은 것으로 알고있고, 경험이 없어 익숙치 않았기 때문인데요.

이번 포스팅에서는 이러한 두려움을 없애보고자 Redux 에 대해 알아보는 시간을 가지려고 합니다.

1. Redux

ReduxJavaScript 에서 사용하는 상태관리 라이브러리입니다. 리액트로 프로젝트를 진행하는 프론트엔드 개발자들은 Props Drilling을 피하기 위해 또, 데이터를 여러 페이지에서 사용하기 위해 '전역 상태관리' 에 대해 많은 고민을 하고 있습니다.

Redux 는 이러한 전역 상태관리를 조금 더 쉽게 해결할 수 있도록 도와주는 대표적인 라이브러리 중 하나입니다.

여기서 말하는 '전역 상태' 란 무엇일까요? 프론트엔드를 개발하신 경험이 있으신 분들은 State(상태) 라는 말을 자주 접했을 겁니다. 간단하게 말하면 State 는 '컴포넌트에서 사용하는 데이터' 라고 할 수 있습니다. 이러한 상태(데이터)를 전역에서 사용해야할 때, 이것을 우리는 '전역 상태' 라고 부릅니다.

Redux의 원칙

Redux 공식문서에 따르면 Redux 는 3가지의 원칙을 가지고 있습니다.

1. Single source of truth

  • 애플리케이션의 모든 state는 하나의 저장소 안에 저장됩니다.
  • 리덕스에서는 이 저장소를 store 라고 부르며, 하나의 store가 모든 부분을 지휘하게 됩니다.

2. State is read-only

  • 상태를 변화시키는 유일한 방법은 액션 객체를 전달하는 것입니다.
  • React 에서 setState 함수를 통해 상태를 변경하듯, Redux 에서는 Action 이라는 객체를 dispatch (전달) 함으로써 상태를 변경할 수 있습니다.
  • 이를 통해 View 혹은 Network Callback 에서 상태를 직접적으로 변경할 수 없다는 것을 보장할 수 있습니다.

3. Changes are made with pure functions

  • 상태의 변경은 순수 함수로 작성되어야합니다.
  • 액션에 의해 상태가 어떻게 변경될지 지정하기 위해 개발자는 순수 Reducer (리듀서) 함수를 작성해야합니다.
  • 리듀서는 이전 상태와 액션을 받아 다음 상태를 반환하는 순수 함수입니다.
  • 여기서 우리가 알아두어야 할 것은 이전 상태를 직접 변경하는 대신 새로운 상태 객체를 생성해 반환한다는 것입니다.

기본 용어 정리

위에서 언급된 Redux 의 기본 용어들에 대해 간단하게 알아봅시다.

1. Store (스토어)

  • state가 저장되는 하나의 저장소입니다.
  • 우리는 state 가 필요할 때 store 에 접근해 이를 사용할 수 있습니다.

2. Action (액션)

  • Reducer 에게 할 일을 알려주는 데이터 객체입니다.
  • 액션은 JavaScript의 객체 형식으로 이루어져 있습니다.

3. Reducer (리듀서)

  • 특정 Action에 따라 Store에 저장된 state를 변경하는 순수 함수입니다.
  • 우리는 Action을 Store에 직접 전달하지 않고 Reducer에 전달하여 이를 처리합니다.
  • Reducer에게 Action을 전달하기 위해 Dispatch 함수를 사용합니다.

4. Dispatch (디스패치)

  • Action을 호출할 때 사용되는 함수입니다.
  • 디스패치 함수로 Action을 발생시켜 이를 Reducer 에게 전달합니다.

더 자세한 용어 정리는 공식문서를 참고하세요!

동작 원리

위의 내용을 바탕으로 Redux 의 동작 원리를 정리하면 다음과 같습니다.

상태 데이터를 저장할 store를 생성합니다. -> dispatch를 통해 action을 발생시켜 reducer에 전달합니다. -> reducer는 action을 받아 변경된 새로운 상태를 반환해 store에 저장합니다. -> 컴포넌트에서 store에 접근해 변경된 상태 값을 사용합니다.

아래 그림을 통해 리덕스의 동작 원리를 확인해봅시다.

코드 예제

이제 위에서 학습한 내용을 바탕으로 코드를 작성해봅시다. 예제는 CRA를 통해 생성된 React 환경에서 진행하겠습니다.

Redux를 사용하기 위해선 먼저 라이브러리를 설치해야 합니다.

터미널에서 아래 명령어를 통해 라이브러리를 설치할 수 있습니다.

yarn add redux
// or
npm install redux

Store 생성

프로젝트를 생성하고 리덕스를 정상적으로 설치하는데 까지 성공했다면, store 를 만들어봅시다. store 는 위의 기본 용어 정리 섹션에서 다뤘듯, state를 관리하는 하나의 저장소입니다. redux를 사용하는 프로젝트에서 모든 state는 store 내에 저장되고 관리되며, 이를 사용하기 위해 store를 생성해야 합니다.

store는 주로 src 폴더 내에 store 폴더 내에서 관리합니다.

store 폴더에 index.js 파일을 생성하고 아래와 같이 코드를 작성할 수 있습니다.

// src/store/index.js

import { createStore } from "redux";
import rootReducer from "../reducers/index";

const store = createStore(rootReducer);

export default store;

createStore 는 store를 생성하기 위한 함수입니다.

createStore는 첫번째 인수로 reducer 함수를 가지며, 예제에선 rootReducer 라는 reducer를 전달하고 있습니다.

reducer 는 state를 이전 값과 비교하여 새로운 state 값을 반환하는 순수 함수라고 말씀드렸는데요, 다음 섹션에서 조금 더 자세하게 알아봅시다.

Reducer

reducer는 두 개의 파라미터를 받는 순수한 JavaScript 함수입니다. 순수 함수란 아래와 같이 주어진 input 값에 대해 정확히 같은 output을 반환하는 함수를 말합니다.

// pure function example

const multiply= (x, y) => x * y;

multiply(5,3); // return 15

이 때 인자로 받는 두 개의 파라미터는 현재의 state (initial state) 와 action 입니다. 위의 섹션에서 언급했듯이 Redux에서는 state를 직접 변경할 수 없고, reducer를 통해 변경됩니다.

이를 바탕으로 간단한 초기 reducer를 작성해보겠습니다.

// src/reducers/index.js

const initialState = {
	users: [],
}

const rootReducer = (state = initialState, action) => {
	return state;
  
	// 여기에 추가적인 코드를 작성할 수 있습니다.
}

export default rootReducer;

초기 상태값을 가진 initialState를 선언하고, 이를 그대로 반환하는 rootReducer를 작성했습니다.

지금은 그저 input으로 들어온 state를 아무런 처리 없이 반환하고 있지만, action을 추가해서 다양한 작업을 처리할 수 있습니다.

Action

Redux의 원칙 섹션에서 두 번째 원칙을 살펴보면, redux에서 state를 바꿀 땐 store에 action 객체를 전달 (dispatch) 하는 것이라고 말합니다.

이 때 우리는 store에 직접적으로 action을 전달하는 것이 아니라, 위에서 작성한 reducer를 통해 전달하게 됩니다.

그럼 action은 무엇일까요?

action은 단지 JavaScript의 객체입니다.

// action example

{
    type: 'ADD_USER',
    payload: {
      userID: 1,
      name: 'hoonnn',
    }
 }

이것이 바로 action입니다. 모든 aciont은 state를 어떻게 변화시킬지에 대한 type 속성을 필수적으로 가집니다.

payload 속성도 지정할 수 있는데, 위 예제에서의 payload는 새롭게 추가될 user 객체입니다.

reducer는 해당 action을 전달받아 현재의 state에 새로운 user 객체를 추가하게 됩니다.

다음으로 우리가 해주어야 할 일은 action을 생성하는 함수 (action creator)를 만드는 것 입니다.

// src/actions/index.js

export const addUser = user => ({
	type: 'ADD_USER',
  	payload: user,
});

예시에서와 같이 type 속성은 단순한 문자열 (string) 입니다. 문자열의 오타나 중복으로 인한 오류를 방지하기 위해 actions의 type을 constant (상수)로 관리하는 것이 좋습니다.

// src/constans/actionTypes.js

// constants 폴더를 만들어 action의 type을 상수로 관리합니다.

export const ADD_USER = 'ADD_USER';
// src/actions/index.js

// 상수로 선언된 type을 import해 action creator에서 사용합니다.

import { ADD_USER } from '../constants/actionTypes';

export const addUser = user => ({
	type: ADD_USER,
  	payload: user,
});

Reducer refoctor

이전 섹션에서 만들어둔 rootReducer는 initialState를 받아 그대로 return하고 있습니다.

이에 action을 추가해서 기능을 붙여봅시다.

다시 한 번 강조하자면, reducer 함수는 initialState와 action 두 가지를 매개변수로 갖는 JS 순수 함수입니다.

reducer 함수는 대게 switch case 구문으로 이루어져 있습니다.

또한, reducer는 action의 type에 따라 변경된 새로운 state를 반환하고, 일치하는 type이 없을 경우 초기 state를 반환합니다.

이를 토대로 위에서 작성한 rootReducer를 수정해보겠습니다.


// src/reducers/index.js


 import { ADD_USER } from "../constants/actionTypes";
  
  const initialState = {
	users: [],
  };

  const rootReducer = (state = initialState, action) => {
    switch (action.type) {
      case ADD_USER:
        state.users.push(action.payload);
        return state;
      default:
        return state;
    }
  };

  export default rootReducer;

위 코드에서 잘못된 부분을 찾으셨나요?

Redux의 원칙 섹션에서 우리는 이전 상태를 직접 변경하는 대신 새로운 상태 객체를 생성해 반환한다고 배웠습니다. 이는 불변성의 원칙이라고도 하는데, 위 예제 코드에서 사용한 push 함수는 기존 배열에 값을 추가하기 때문에 이를 어기게 됩니다.

 import { ADD_USER } from "../constants/actionTypes";
  
  const initialState = {
	users: [],
  };

  const rootReducer = (state = initialState, action) => {
    switch (action.type) {
      case ADD_USER:
        return { ...state, users: [...state.users, action.payload] };
      default:
        return state;
    }
  };

  export default rootReducer;

spread 연산자를 사용하여 문제를 해결할 수 있습니다.

spread 연산자는 initialState의 복사본을 생성하여 이를 가지고 값을 변경할 수 있습니다.

Redux의 기본 메소드

다음으로는 redux에서 제공하는 간단한 기본 메소드를 살펴보겠습니다.

redux에서는 store의 state를 관리하기 위해 간단한 API Method를 제공하고 있습니다.

  • getState는 현재 스토어의 상태를 반환합니다.
  • subscribe는 상태가 변경될 때마다 호출될 리스너 함수를 등록합니다.
  • dispatch는 스토어에 액션을 전달하여 상태를 변경합니다.

위에서 작성한 함수들과 메소드를 사용한 간단한 예제를 만들어봅시다.

store.getState() 를 통해 현재 state에 접근할 수 있습니다.

import store from 'store/index';

// 현재 상태를 콘솔에 출력하는 함수

const logState = () => {
  console.log('Current state:', store.getState());
};
// return value
{ users: [] }

stroe.subscribe() 메소드로 state의 변경 상태를 구독할 수 있습니다.

import store from 'store/index';

// 상태 변경 구독

const subscribe = store.subscribe(logState);

subscribe는 action이 dispatch 될 때 마다 콜백함수를 호출합니다.

state를 변경하기 위해 action을 dispatch 해야 하는데, 그러기 위해 사용하는 것이 stroe.dispatch() 입니다.

앞에서 만든 action creator를 dispatch 해봅시다.

import store from 'store/index';

// 사용자1 추가 액션 디스패치
store.dispatch(addUser({ id: 1, name: 'hoonnn' }));

// 사용자2 추가 액션 디스패치
store.dispatch(addUser({ id: 2, name: 'h0onnn' }));

dispatch 후에 getStore()를 호출하면 어떤 값이 나올까요?

// return value after dispatch

{
  users: [
    { id: 1, name: 'hoonnn' },
    { id: 2, name: 'h0onnn' }
  ]
}

users 배열에 두 명의 user가 추가된 것을 확인할 수 있습니다.

2. react-redux

redux는 프레임워크에 구애받지 않는 라이브러리입니다. Angular나 React, Vue 혹은 Pure JavaScript에서도 사용이 가능합니다.

이 때 각 프레임워크에서 redux를 쉽게 사용할 수 있도록 해주는 라이브러리가 존재하는데, React에서는 react-redux 를 사용할 수 있습니다.

react-redux 는 React와 Redux를 효율적으로 사용할 수 있게 묶어주는 라이브러리입니다.

아래 명령어를 통해 설치할 수 있습니다.

yarn add react-redux
// or
npm install react-redux

Provider

React와 Redux를 연결해주기 위해 우리는 react-redux에서 제공하는 Provider 컴포넌트를 사용할 수 있습니다.

src 폴더의 index.js 에서 다음과 같이 적용해줍시다.

// src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux'; // react-redux의 Provider import
import store from './store/index'
import './index.css';
import App from './App';

  ReactDOM.render(
    <Provider store={store}>
      <App />
    </Provider>,
    document.getElementById('root'));

Provider로 App 컴포넌트를 감싸고 props로 store를 넘겨줍니다.

이렇게 함으로써 React는 모든 컴포넌트에서 Redux의 store를 인식할 수 있습니다.

react-redux의 기본 메소드

react-redux에서는 React와 Redux의 원활한 연결을 위해 기본적인 메소드를 제공합니다.

- connent()

redux는 기본적으로 stroe -> component -> action -> reducer -> store 의 과정을 통해 state를 변경합니다.

react-redux는 store -> component -> action 단계에서 connect() 라는 특별한 함수를 사용합니다.

connect 함수는 단어 그대로 React 컴포넌트와 Redux의 store를 연결하는 역할을 수행합니다.

- mapStateToProps()

mapStateToProps 는 Redux의 state를 React 컴포넌트의 props와 연결합니다. 그렇게 연결된 컴포넌트는 store에서 필요한 state에 접근할 수 있습니다.

- mapDispatchToProps()

mapDispatchToProps 는 mapStateToProps와 비슷하지만 Redux의 action을 컴포넌트와 연결한다는 차이점이 있습니다. 이를 통해 컴포넌트는 필요한 action을 dispatch 할 수 있습니다.

이제 기본적인 메소드에 대해 알았으니 한 번 사용해볼까요?

코드 예제

// src/components/UserManagement.js
import React from 'react';
import UserList from './UserLists';
import { connect } from 'react-redux';
import { addUser, deleteUser } from '../actions';

const UserManagement = ({ users, addUser, deleteUser }) => (
  <div>
	<UserList users={users} />
    <button className='user-create-btn' onClick={addUser}>Create</button>
    <button className='user-delete-btn' onClick={deleteUser}>Delete</button>
  </div>
);

const mapStateToProps = state => ({
  users: state.users
});

const mapDispatchToProps = {
  addUser,
  deleteUser,
};

export default connect(mapStateToProps, mapDispatchToProps)(UserManagement);

users data를 관리하는 간단한 예제 컴포넌트를 작성해보았는데요, 편의를 위해 mapStateToProps와 mapDispatchToProps 함수를 모두 한 파일 내에서 사용했습니다.

UserManagement 컴포넌트에서 button 클릭을 통해 새로운 user를 추가하거나 삭제할 수 있다고 가정해봅시다.

UserManagement 컴포넌트는 connect 함수를 통해 Redux 스토어에 연결됩니다.

mapStateToProps는 Redux store의 상태를 컴포넌트의 props로 매핑하고, mapDispatchToProps는 action creator를 props로 매핑합니다.

UserManagement 컴포넌트를 상위 컴포넌트인 App에서 사용해봅시다.

// src/App.js

import React from 'react';
import UserManagement from './components/UserManagement';

const App = () => {
  return (
    <div className="app-container">
      <h1>User Management Application</h1>
      <UserManagement />
    </div>
  );
};

export default App;

src 폴더의 index.js 파일에서 App 컴포넌트를 react-redux의 Provider 컴포넌트로 감싸주었기 때문에, UserManagement 컴포넌트에서 store에 접근하여 state를 사용하는 것이 가능해집니다.

useSelector와 useDispatch

최신의 react-redux에서는 mapStateToProps와 mapDispatchToProps 함수를 대체할 수 있는 useSelector, useDispatch 훅을 제공하고 있습니다.

이러한 훅을 이용하여 기존의 connect, subscribe를 이용하지 않고 더 쉽게 React와 Redux를 연결할 수 있게 되었습니다.

useSelector

useSelector 는 기존 mapStateToProps를 대체하는 훅입니다.

해당 훅을 사용해 Redux의 store에 접근해 원하는 state를 조회하여 사용할 수 있습니다.

useDispatch

useDispatch 는 Redux store의 dispatch 함수에 접근할 수 있게 해줍니다. mapDispatchToProps의 기능을 대체하며, 이를 사용하여 action을 dispatch 할 수 있습니다.

그럼 이 두 가지 훅을 사용해서 위에 작성된 UserManagement 컴포넌트를 마이그레이션 해봅시다.

코드 예제

// src/components/UserManagement.jsx

import React from 'react';
import UserList from './UserLists';
import { useSelector, useDispatch } from 'react-redux';
import { addUser, deleteUser } from '../actions';

const UserManagement = () => {
  const users = useSelector(state => state.users);
  const dispatch = useDispatch();

  return (
    <div>
      <UserList users={users} />
      <button className='user-create-btn' onClick={() => dispatch(addUser({ id: 1, name: 'hoonnn' }))}>Create</button>
      <button className='user-delete-btn' onClick={() => dispatch(deleteUser({ id: users[0].id }))}>Delete</button>
    </div>
  );
};

export default UserManagement;

수정된 컴포넌트에서는 connect, mapStateToProps, mapDispatchToProps를 사용하는 대신 useSelector와 useDispatch 훅을 사용합니다.

users 상태는 useSelector 훅을 사용하여 Redux store에서 직접 가져옵니다.

addUser와 deleteUser action은 useDispatch 훅을 사용하여 dispatch 합니다.

action을 dispatch 할 때는 dispatch 함수에 action creator를 인자로 전달합니다.

위 처럼 useSelector와 useDispatch 훅을 사용하여 기존의 connect를 사용한 방법보다 훨씬 간결하고 가독성이 좋게 작성할 수 있습니다.

3. Redux-toolkit (RTK)

많은 분들이 redux를 조금 더 편하게 사용하기 위해 redux-toolkit을 사용한다고 알고 계실겁니다.

그럼 기존 redux의 단점은 무엇일까요?

기존의 redux는 다음과 같은 단점을 가지고 있습니다.

  • 사용을 위한 설정이 복잡하며, 필요한 보일러플레이트 코드가 많다.
  • 반복되는 코드의 작성이 많다.
  • redux의 원칙인 불변성을 유지하기 어렵다.

redux-toolkit의 공식문서를 보면, redux 개발자들은 toolkit에 대해 이렇게 말하고 있습니다.

We specifically created Redux Toolkit to eliminate the "boilerplate" from hand-written Redux logic, prevent common mistakes, and provide APIs that simplify standard Redux tasks.

즉, Redux Toolkit은 Redux의 핵심 원칙을 유지하면서 보일러플레이트 코드를 줄이고, Redux 애플리케이션을 더 쉽게 작성하고 유지 관리할 수 있도록 설계된 라이브러리 입니다.

이번 섹션에서는 redux-toolkit의 개념에 대해 알아보고, 위에서 작성된 코드를 toolkit을 이용한 코드로 수정해 봅시다.

우선 아래 명령어를 통해 redux-toolkit을 설치해주세요.

yarn add @reduxjs/toolkit
// or
npm install @reduxjs/toolkit

RTK의 메소드

코드를 작성해보기 전에, RTK에서 제공하는 기본적인 메소드들에 대해 알아봅시다.

- configureStore
기본 Redux의 createStore를 대체하며, 기본 미들웨어와 Redux DevTools 확장 기능을 자동으로 설정합니다.

- createSlice
state, reducer 및 action을 하나의 함수에서 정의할 수 있게 해줍니다. 이는 reducer와 action creator의 작성을 단순화합니다.

- createAsyncThunk
비동기 로직을 처리하기 위한 thunk를 쉽게 생성할 수 있습니다.

- createReducer
Immer 라이브러리를 내장하여 상태의 불변성을 쉽게 관리할 수 있습니다.

코드 예제

RTK의 기본 메소드를 코드를 통해 알아봅시다.

configureStore

우리는 기존 Redux를 이용하여 아래와 같이 store를 작성했습니다.

// basic redux
// src/store/index.js

import { createStore } from "redux";
import userReducer from "../reducers/index";

const store = createStore(userReducer);

export default store;

이를 RTK에서는 configureStore 함수를 통해 작성할 수 있습니다.

// RTK
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import userReducer from '../features/userSlice';

export default configureStore({
  reducer: {
    users: userReducer
  }
});

configureStore는 기존 Redux의 createStore를 추상화 한 것으로, Redux의 번거로운 설정 과정을 자동화 해주며, 기본적으로 redux-thunk와 Dev-tools를 제공합니다.

만약 store를 생성할 때, reducer의 갯수가 많아진다면 어떻게 처리해야 할까요?

redux 에서는 여러개의 reducer를 결합하기 위해 combineReducers 함수를 제공합니다.

import { combineReducers } from 'redux';
import userReducer from './userReducer';
import productReducer from './productReducer';

const rootReducer = combineReducers({
  users: userReducer,
  products: productReducer
});

export default rootReducer;

예제에서는 combineReducers를 이용해 userReducer와 productReducer를 rootReducer로 결합하여 이를 store에 제공합니다.

RTK를 사용하는 경우에는, configureStore가 conbineReducers의 기능을 내부적으로 처리할 수 있습니다.

import { configureStore } from '@reduxjs/toolkit';
import userReducer from './features/userSlice';
import productReducer from './features/productSlice';

const store = configureStore({
  reducer: {
    users: userReducer,
    products: productReducer
  }
});

export default store;

위와 같이 여러 reducer를 각각의 키와 함께 reducer 옵션에 전달하면 됩니다.

configureStore는 reducer를 포함한 여러가지 옵션을 적용할 수 있습니다.

import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
import myMiddleware from './middleware/myMiddleware';

const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(myMiddleware),
  devTools: process.env.NODE_ENV !== 'production',
  preloadedState: {},
  enhancers: []
});

export default store;

- reducer
reducer를 정의하는 옵션입니다. 단일 reducer 또는 combineReducers를 사용하여 결합된 reducer 객체를 전달할 수 있습니다.

- middleware
스토어에 적용할 미들웨어를 지정하는 옵션입니다. getDefaultMiddleware 함수를 사용하여 Redux Toolkit이 제공하는 기본 미들웨어를 포함시킬 수 있으며, 추가적인 미들웨어를 지정할 수도 있습니다.

- devTools
Redux 개발자 도구 확장의 사용 여부를 지정하는 옵션입니다. 기본값은 true로, 개발 모드에서 개발자 도구를 활성화합니다. 프로덕션 빌드에서는 자동으로 비활성화됩니다.

- preloadedState
스토어의 초기 상태를 정의하는 옵션입니다. 주로 서버 사이드 렌더링(SSR)이나 상태 복원 시 사용됩니다.

- enhancers
store에 추가적인 enhancer를 적용하는 옵션입니다. Redux의 기능을 확장하는 데 사용됩니다.

configureStore에 대한 더욱 자세한 내용은 공식 문서를 확인하세요!

createSlice

RTK 에서 제공하는 createSlice 함수를 통해 slice를 생성할 수 있습니다.

slice는 기존 redux의 state, action, reducer를 간단하게 작성할 수 있도록 해줍니다.

// src/slices/userSlice.js

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

export const userSlice = createSlice({
  name: 'users',
  initialState: {
    users: []
  },
  reducers: {
    addUser: (state, action) => {
      state.users.push(action.payload);
    },
    deleteUser: (state, action) => {
      state.users = state.users.filter(user => user.id !== action.payload.id);
    }
  }
});

// action creator
export const { addUser, deleteUser } = userSlice.actions;

// create reducer
export default userSlice.reducer;

예제에서 name 속성은 action type을 생성하는데 사용되는 이름입니다.

initialState는 state의 초기값을 나타내며, reducers 속성은 기존 redux의 reducer와 action creator가 합쳐진 것이라고 볼 수 있습니다.

위 예제에서는 push 함수를 이용해 기존 state에 새로운 값을 추가하고 있습니다.

이전 섹션에서 Redux는 불변성을 지켜야 하기 때문에 기존 배열의 값을 변경하는 push를 사용하는 대신 spread 연산자를 사용하는 방식을 채택했는데요,

createSlice 는 내부적으로 immer 라이브러리를 사용하여 불변성을 자동으로 관리해줍니다.

따라서 state.users.push(action.payload) 는 직접적으로 상태를 수정하는 것처럼 보이지만, 실제로는 immer에 의해 불변성을 유지하는 새로운 state가 생성됩니다. 이는 Redux의 핵심 원칙 중 하나인 불변성을 깨트리지 않습니다.

또한, 예제에서는 action과 action creator가 따로 존재하지 않습니다.

RTK의 createSlice가 이들을 내부적으로 생성해주기 때문인데요, 코드의 name : 'users 와 reducers의 addUser 필드가 합쳐지면서 users/addUser 라는 action이 자동으로 생성되는 것입니다.

Slice.actions 를 통해 생성된 action creator에 접근할 수 있으며, 위 예제에서는 구조분해를 통해 addUser, deleteUser 함수를 가져오고 있습니다.

이와 같이 createSlice를 통해 기존 Redux에서 각각의 파일로 관리하던 actionTypes, action, reducer를 하나의 Slice에서 관리할 수 있습니다.

컴포넌트에서 사용

// src/components/UserManagement.js

import React from 'react';
import UserList from './UserList';
import { useDispatch, useSelector } from 'react-redux';
import { addUser, deleteUser } from '../features/userSlice';

const UserManagement = () => {
  const users = useSelector(state => state.users.users);
  const dispatch = useDispatch();

  return (
    <div>
      <UserList users={users} />
      <button onClick={() => dispatch(addUser({id: 10, name: 'hoonnn'}))}>Create</button>
      <button onClick={() => dispatch(deleteUser({id: 1}))}>Delete</button>
    </div>
  );
};

export default UserManagement;

react-redux의 useSelector, useDispatch와 함께 Redux Store를 더욱 쉽게 사용할 수 있습니다.

RTK의 thunk를 이용한 비동기 처리에 대해서는 추가적인 학습 이후 내용을 추가하겠습니다!

4. RTK Query

앞선 섹션에서는 전역 상태를 효율적으로 관리하기 위한 상태관리 라이브러리인 Redux와 이를 더 편리하게 사용할 수 있도록 해주는 (Redux-Toolkit) RTK에 대해 알아보았습니다.

state는 '컴포넌트에서 사용되는 데이터' 라고 말씀드렸는데요, Redux나 Recoil, Zustand와 같은 상태관리 툴을 이용해 관리하는 state는 주로 'UI state' 입니다.

React 에서는 state가 변화하게 되면 컴포넌트가 리렌더링 되면서 변경된 state 값이 반영되고, 이에 따른 UI를 사용자에게 제공합니다.

프론트엔드 개발시에는 다양한 부분을 고려해야 합니다.

state에 따른 UI 제공도 중요하지만, 백엔드에 API를 요청하여 받아오는 서버 데이터를 어떻게 효율적으로 관리할 것인가에 대해 많은 고민을 필요로 합니다.

이 때 서버에서 받아오는 데이터 즉, 'server state' 를 관리하기 위해 SWR, react-query, RTK query 등 다양한 라이브러리가 개발되었습니다.

우리는 이번 마지막 섹션에서 그 중 RTK Query 에 대해 알아보는 시간을 가지려고 합니다.

RTK Query란 무엇일까요?

앞서 말했듯 RTK Query는 server data를 효율적으로 관리하기 위해 RTK에서 제공하는 툴입니다. RTK를 설치하면 별도의 설치 없이 사용할 수 있는 RTK의 추가 기능 (optional addon) 입니다.

웹 애플리케이션에서 데이터를 가져오는 상황을 간단하게 하여 data fetchingcashing 로직을 직접 작성하지 않고, 사용할 수 있도록 하는 기능을 제공합니다.

APIs

- createApi()

createApi 는 RTK Qeury의 핵심 기술입니다. 이를 이용해 데이터를 fetching 하거나 update 하는것을 포함해 백엔드 API 및, 기타 비동기 데이터를 관리하는 방법에 대한 endpoint를 정의할 수 있습니다.

대부분의 경우 base URL 당 하나의 API Slice를 사용해야 합니다.

- fetchBaseQuery

axios 와 같은 네트워크 라이브러리와 유사한 방식으로 HTTP 요청을 간소화하는 것을 목표로 하는 가벼운 fetch wrapper 입니다.

공식문서는 일반적으로 createApi에서 baseQuery 옵션에 fetchBaseQeury를 사용할 것을 권장하고 있습니다.

API Slice

위의 createApi와 fetchBaseQuery를 이용하여 apiSlice 를 작성해봅시다.

먼저 RTK Qeury를 시작하기 위해 아래와 같이 createApi를 import 할 수 있습니다.

import { createApi } from "@reduxjs/toolkit/query"
// or
import { createApi } from "@reduxjs/toolkit/query/react"

"@reduxjs/toolkit/query/react" 패키지는 React 애플리케이션에서 RTK Query를 사용하기 위한 효율적인 도구를 제공합니다.

이 패키지를 사용하면 useLazyQuery, useMutation, useQuery 등의 hooks를 제공하며, React 컴포넌트 내부에서 간편하게 RTK Query를 사용할 수 있습니다.

따라서 React 환경에서는 이 패키지를 사용할 것을 권장드립니다.

이후 apiSlice를 아래와 같이 작성할 수 있습니다.

예제에서는 src/features/api/ 경로에 apiSlice.js 파일을 생성합니다.

// src/features/api/apiSlice.js

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"

export const apiSlice = createApi({
  reducerPath: "api",
  baseQuery: fetchBaseQuery({ baseUrl: "/fakeApi" }),
  endpoints: builder => ({
    getUsers: builder.query({
      query: () => "/users",
    }),
  }),
})

// export hooks
export const { useGetUsersQuery } = apiSlice;

기존 RTK에서는 posts, users와 같은 다양한 데이터 유형 각각에 대해 별도의 Slices를 정의했습니다. 각 Slice에는 고유한 reducer가 있고 고유한 action과 thunk를 정의하고 해당 데이터 유형에 대한 항목을 별도로 캐시했습니다.

하지만 RTK Query 에서는 캐시된 데이터를 관리하기 위한 logic이 애플리케이션당 단일 “API Slice”로 중앙 집중화됩니다.

다시 말씀드리자면 애플리케이션에는 createApi 호출이 하나만 있어야 합니다. 이 하나의 API 슬라이스에는 동일한 기본 URL과 통신하는 모든 엔드포인트 정의가 포함되어야 합니다. 예를 들어 엔드포인트 /api/posts/api/users 는 모두 동일한 서버에서 데이터를 가져오므로 동일한 API Slice에서 관리합니다. 앱이 여러 서버에서 데이터를 가져오는 경우 각 endpoints에서 전체 URL을 지정하거나 필요한 경우 각 서버에 대해 별도의 API Slice를 만들 수 있습니다.

만약 endpoints를 별도의 파일로 분리하여 관리하는 경우에는 Injecting Endpoints 를 사용할 수 있습니다. 이에 대해서는 다음 섹션에서 다루겠습니다.

위의 예제에서는 createApi를 통해 apiSlice를 생성할 때 다양한 옵션이 적용된 것을 볼 수 있습니다.

createApi의 옵션 (paramater)에는 무엇이 있는지 알아봅시다.

createApi의 options

- baseQuery(필수)

모든 endpoint에 사용할 기본 쿼리를 정의하는 함수입니다. 일반적으로 fetchBaseQuery 인스턴스를 사용하여 모든 향후 요청의 baseURL을 전달할 수 있을 뿐만 아니라 요청 헤더 수정과 같은 동작을 재정의할 수 있습니다.

- endpoints(필수)

서버에 대해 수행하려는 작업의 집합을 나타냅니다. 사용자가 원하는 이름 별로 객체를 생성하고, 각 객체는 요청 유형과 해당 요청에 대한 설정 객체를 포함합니다. 이는 builder 구문을 사용하여 정의할 수 있습니다.

  • builder
    builder는 query, mutaion, subscription 메소드를 제공하며, 각 메소드는 builder.query, builder.mutation 과 같은 방식으로 호출할 수 있습니다. 각 메소드는 endpoint 객체를 생성하고, endpoint 객체의 API 요청을 정의하는 콜백 함수를 반환합니다. 반환된 콜백 함수는 endpoint의 동작을 정의하며, fetchBaseQuery 함수와 함께 사용됩니다.

    • builder.query
      builder.query는 데이터를 가져오는 요청에 대한 쿼리 매개 변수를 설정하는데 사용됩니다.
      쿼리 문자열, 필터링, 페이지 및 정렬 매개 변수를 설정할 수 있습니다.

    • builder.mutation
      builder.mutation은 데이터를 update하거나 delete할 때 사용됩니다.
      POST, PUT, PATCH, DELETE HTTP 요청을 보낼 수 있으며, 요청의 URL, body, Header 및 기타 옵션을 설정할 수 있습니다.

    • builder.sbscription
      builder.subscription는 반환된 콜백 함수에서 실시간으로 데이터를 수신 하기 위한 구독을 만들고, 구독이 통지될 때마다 fulfilled 액션을 보냅니다. 이 메서드를 사용하면 실시간으로 데이터를 받아올 수 있기 때문에, WebSocket이나 Server-Sent Events 등과 같은 실시간 통신 방법을 통해 데이터를 전송하는 서버에서 유용합니다.

아래 예제 코드를 통해 사용 예시를 확인해봅시다.

// builder.query example

endpoints: builder => ({
    getUsers: builder.query({
      query: ({ page, pageSize }) => ({
      	url: '/users',
        params: { page, pageSize },
      }),
    }),
  }),

pagenation에 필요한 page와 pageSize parmas를 전달하고 있습니다. query endpoints의 경우 기본 HTTP request 메소드는 GET 입니다. 따라서 위 코드는 /fakeApi/users 경로로 GET 요청을 보냅니다.

// builder.mutation example

endpoints: builder => ({
    getUsers: builder.query({
      query: ({ page, pageSize }) => ({
      	url: '/users',
        params: { page, pageSize },
      }),
    }),
    addUser: builder.mutation({
      query: user => ({
        method: 'POST',
        url: '/users',
        body: user
      }),
    }),
  }),

위 예제에서는 mutation을 이용해 addUser라는 endpoint를 정의합니다. 해당 endpoint의 HTTP request 메소드는 POST 이며, user 값을 받아 요청 본문 (body)에 user를 담아 전송합니다.

RTK Query는 우리가 정의한 모든 endpoints에 대해 React hooks를 자동으로 생성해줍니다.

// export hooks
export const { useGetUsersQuery, useAddUserMutation } = apiSlice;

이와 같이 API Slice에서 필요한 hooks를 export하여 React의 컴포넌트에서 import해 사용할 수 있습니다.

- reducerPath(옵셔널)

createApi는 reducerPath 옵션을 옵셔널로 적용할 수 있습니다. 이는 Redux store 내에서 해당 API Slice의 상태 및 캐싱 데이터가 저장될 경로를 결정합니다.

// reducerPath example

// import ...

export const userApiSlice = createApi({
  reducerPath: "usersApi", // set reducerPath
  // ...
})

예제에서 userApiSlice는 reducerPath의 값에 'usersApi' 를 지정하고 있습니다. 따라서 Redux stroe 내에서 userApiSlice의 상태 데이터는 'usersApi' 라는 키 아래에 저장되며, 이는 state.usersApi 를 통해 접근할 수 있습니다.

- tagTypes(옵셔널)

endpoint에서 사용하는 tag를 정의할 수 있는 객체입니다. 옵셔널로 적용할 수 있으며, 사용시 query의 캐싱 및 무효화에 사용할 수 있습니다. 이는 각각 providesTagsinvaildatesTags 를 통해 적용합니다.

RTK Query는 기본적으로 GET 요청을 통해 받아온 데이터를 자동으로 캐싱합니다. 캐싱된 데이터를 사용함으로써 동일한 페이지에 접근했을 때, 불필요한 API 요청 비용을 줄일 수 있는데요, 이 때 종종 mutation으로 새로운 데이터를 추가했음에도 캐싱 데이터에 최신 데이터가 반영되지 않는 문제가 발생하는 경우가 있습니다.

이런 경우에 tagTypes를 이용해 캐싱 데이터를 관리함으로써 데이터의 최신 상태를 유지할 수 있습니다.

// tagTypes example

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const apiSlice = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
  tagTypes: ['User'], // 태그 타입을 정의합니다.
  endpoints: builder => ({
    getUsers: builder.query({
      query: () => '/users',
      providesTags: ['Users'], // providesTags 'Users' 로 설정
    	}),
    }),
    addUser: builder.mutation({
      query: user => ({
        method: 'POST',
        url: '/users',
        body: user,
      }),
      invalidatesTags: ['Users'], // invalidate로 'Users' 태그 무효화
    }),
  }),
});

예제에서는 getUsers endpoint에서 GET 요청을 통해 데이터를 받아올 때, providesTags 옵션에 'Users' tag를 할당하고 있습니다. 이 태그를 통해 캐싱 데이터의 관리가 가능해집니다.

addUser endpoint를 보면, invalidateTags 옵션에 'Users' 태그를 할당하고 있는데요, 이는 addUser mutation이 실행될 때 'Users' tag를 가진 query의 캐싱 데이터를 무효화 합니다.

즉, 기존에 캐싱된 데이터를 초기화 하고 mutation이 적용된 새로운 데이터를 가져온다는 의미입니다. 이를 통해 RTK Query는 데이터의 일관성과 최신 상태를 유지할 수 있습니다.

그렇다면 RTK Queyr에서 invalidateTags를 사용하지 않고 더이상 사용하지 않는, 혹은 오래된 캐싱 데이터를 초기화 할 수 있는 방법은 무엇일까요?

RTK Query의 createApi는 이런 상황을 위해 keepUnusedDateFor 옵션을 제공합니다. 이는 tanstack-query (구 react-qeury) 의 gcTime과 같이 캐싱된 데이터의 가비지 컬렉션 시간을 관리할 수 있는 설정을 제공합니다.

- keepUnusedDataFor(옵셔널)

keepUnusedDataFor 는 캐싱된 데이터가 유지되는 시간(초 단위)를 설정합니다. 이는 query가 마지막으로 활성화된 이후 얼마 동안 캐시 데이터를 유지할지 결정합니다. 이 시간이 지나면 캐시 데이터는 자동으로 가비지 컬렉션되어 삭제됩니다. 설정하지 않을시 default value는 60 (1분) 입니다.

// keepUnusedDataFor example

export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
  endpoints: builder => ({
    getPosts: builder.query({
      query: () => '/posts',
      keepUnusedDataFor: 60, // 1분 동안 캐시 데이터 유지
    }),
    // 다른 엔드포인트 정의...
  }),
});

예제에서 getPosts query를 통해 받아온 데이터를 60초 (1분) 동안 캐시에 저장합니다.

- refetchOnFocus(옵셔널)

true로 설정시 브라우저 창에 포커싱 되었을 때 query를 강제로 refetch 할 수 있습니다. default는 false입니다.

- refetchOnReconnect(옵셔널)

true로 설정시 네트워크가 다시 연결되었을 때 query를 refetch 할 수 있습니다. default는 false입니다.

Injecting Endpoints

앞선 섹션에서 RTK Queyr의 createApi는 전체 애플리케이션에서 한 번만 호출되어야 한다고 말씀드렸습니다.

만약 프로젝트의 규모가 커져 endpoints가 증가하여 이를 별도로 관리하고자 한다면 우리는 injectEndpoints 를 사용할 수 있습니다.

injectEndpoints는 프로젝트의 필요에 따라 API endpoint를 확장하고, 코드 분할(code splitting)을 적용하여 모듈화 및 재사용성 증진에 도움을 줄 수 있습니다.

// apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const apiSlice = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: builder => ({})
});

예제는 createApi를 이용하여 apiSlice를 생성합니다. 이 때 endpoints 옵션은 빈 객체입니다.

endpoints: builder => ({}) 이 구문은 필요한 endpoint를 별도의 파일에서 injectEndpoints를 통해 추가한다는 의미입니다.

// userApis.js
import { apiSlice } from './apiSlice';

export const userApi = apiSlice.injectEndpoints({
  endpoints: builder => ({
    getUsers: builder.query({
      query: () => '/users',
    }),
    addUser: builder.mutation({
      query: user => ({
        url: '/users',
        method: 'POST',
        body: user
      }),
    }),
  }),
});

export const { useGetUsersQuery, useAddUserMutation } = userApi;

실제 endpoint는 위 userApi와 같이 별도의 파일에서 정의하고 injectEndpoints를 사용하여 apiSlice에 주입할 수 있습니다.

store 생성

RTK Query 기능을 사용하기 위해 createApi를 통해 생성한 apiSlice를 Redux store와 연결해야 합니다.

// src/store/store.js

import { configureStore } from '@reduxjs/toolkit';
import { apiSlice } from '../features/api/apiSlice';

export const store = configureStore({
  reducer: {
    [apiSlice.reducerPath]: apiSlice.reducer,
  },
  // RTK Query의 미들웨어 설정
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(apiSlice.middleware),
});

이런식으로 store를 생성하고, middleWare 옵션을 통해 apiSlice의 middleWare를 주입하면 RTK Query가 제공하는 캐싱 및 상태관리 기능을 이용할 수 있습니다.

Provider

react-redux 섹션에서 배웠던 Provider를 사용해 App 컴포넌트를 감싸줍니다.

// src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux'; // react-redux의 Provider
import store from './store/index'
import './index.css';
import App from './App';

  ReactDOM.render(
    <Provider store={store}>
      <App />
    </Provider>,
    document.getElementById('root'));

컴포넌트에서 사용

이제 RTK Queyr를 사용할 모든 준비가 끝났으니, React 컴포넌트에서 사용해봅시다.

RTK Query가 자동으로 생성해주는 hooks를 import해서 사용할 수 있습니다.

아래는 user list를 보여주는 간단한 예제입니다.

// UserList.jsx

import React from 'react';
import { useGetUsersQuery } from './userApis'; // import hooks

const UserList = () => {
  const { data: users, isLoading, isError } = useGetUsersQuery();

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error fetching users</div>;

  return (
    <div>
      <h3>User List</h3>
      <ul>
        {users?.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default UserList;

useGetUsersQuery hooks를 통해 데이터와 로딩 상태, 에러 상태를 받아와 이를 사용할 수 있습니다.

useQeury의 return value

아래는 일반적으로 사용되는 query hooks가 반환하는 값입니다.

- data

요청이 성공적으로 완료되었을 때 반환되는 데이터입니다. 이는 서버로부터 받은 응답의 실제 데이터를 나타냅니다.

- isLoading

요청이 진행 중인지 여부를 나타냅니다. 요청이 시작되고 아직 완료되지 않았을 때 true로 설정됩니다.

- isFetching

요청이 진행 중이거나 백그라운드에서 자동으로 재요청되고 있는지 여부를 나타냅니다. isLoading과 비슷하지만, isFetching은 요청이 이미 한 번 성공적으로 완료된 후에도 백그라운드에서 다시 요청이 이루어질 때 true로 설정될 수 있습니다.

- isError

요청이 실패했을 때 true로 설정됩니다. 네트워크 오류나 서버에서 오류 응답을 보낸 경우에 해당합니다.

- error

error: 요청이 실패했을 때 오류의 상세 정보를 포함합니다. 이는 서버로부터 받은 오류 메시지나 네트워크 오류 등의 정보를 담을 수 있습니다.

- isSuccess

요청이 성공적으로 완료되었을 때 true로 설정됩니다. 이는 데이터를 성공적으로 받아온 경우에 해당합니다.

- isUninitialized

요청이 아직 시작되지 않았을 때 true로 설정됩니다. 즉, 훅이 마운트되었지만 요청이 아직 발생하지 않은 상태를 나타냅니다.

- refetch

요청을 수동으로 다시 실행하는 함수입니다. 이를 사용하면 API를 다시 호출하여 데이터를 새로 고칠 수 있습니다.

마무리

지금까지 프론트엔드에서 상태관리를 위해 사용하는 ReduxRedux-Toolkit (RTK), 서버 데이터를 관리하기 위한 RTK Query 에 대해 기본적인 개념과 사용 방법에 대해 알아보았습니다.

추가적인 정보와 수정 사항에 대해서는 계속해서 업데이트를 진행하도록 하겠습니다.

요즘엔 RecoilZustand 와 같은 상태관리 툴이 떠오르고 있지만, Npm trand를 보면 Redux가 아직은 압도적인 install count를 보여주고 있습니다.

여전히 많은 프로젝트에서 사용되고있는 기술인만큼 혹시라도 아직 사용해보지 않으신 분들은 개인 프로젝트로 한 번쯤은 사용하며 학습해 보시는걸 추천드리며, 그런 분들에게 제 포스팅이 조금이나마 도움이 되었으면 좋겠습니다.

감사합니다!


참고 문서

profile
안녕하세요, 프론트엔드 개발자 임정훈입니다.

1개의 댓글

comment-user-thumbnail
2024년 1월 12일

항상 잘보고 있습니다 감사합니다~

답글 달기