[Redux] Redux-toolkit

LMH·2022년 12월 23일
0
post-thumbnail

리덕스는 단방향 데이터 흐름을 기반으로한 웹 어플리케이션 개발 도구로 많은 개발자들이 사용해 왔지만 다음과 같은 단점들도 가지고 있었습니다.

  1. 리덕스 스토어 환경 설정은 너무 복잡합니다.
  2. 리덕스를 유용하게 사용하려면 리덕스 외에도 미들웨어와 같은 많은 패키지를 추가해야합니다.
  3. 리덕스를 사용하면 반복적인 코드(보일러플레이트)의 작성이 많이 요구 됩니다.

이러한 문제점을 해결하면서 리덕스 코드 작성에 표준적인 방식을 만든 것이 RTK(Redux-toolkit)입니다.
이미 리덕스를 사용해본 개발자나 처음으로 RTK를 활용해서 프로젝트를 구성하는 신규 사용자 모두가 쉽게 사용할 수 있도록 구성되었습니다.RTK를 사용하면 불필요한 환경 설정을 줄이고 보다 간단하게 코드를 작성할 수 있습니다.

설치

다음과 같은 명령어를 통해 redux-toolkit을 설치합니다.

npm install @reduxjs/toolkit

configureStore()

가장 먼저 스토어를 구성하는 함수에 대해서 알아보겠습니다. configureStore는 리덕스 코어 라이브러리의 표준 함수인 createStore를 추상화한 것입니다

단순화된 구성 옵션과 좋은 기본값을 제공하기 위해 래핑 합니다. 슬라이스 리듀서를 자동으로 결합하고, 제공하는 모든 Redux 미들웨어를 추가하고, redux-thunk기본적으로 포함하고, Redux DevTools Extension을 사용할 수 있습니다.

import { configureStore } from '@reduxjs/toolkit'

import rootReducer from './reducers'

const store = configureStore({ reducer: rootReducer })

위처럼 선언하면 기본 미들웨어로 redux-thunk를 추가하고 개발 환경에서 리덕스 개발자 도구(Redux DevTools Extension)를 활성화해줍니다. 이전에는 매번 프로젝트를 시작할 때마다 이런 설정을 직접 하는 불편한 과정이 있었다고 하니 개발 경험을 높이기 위해서 그동안 RTK가 어떤 접근을 했었는지 알 수 있었습니다.

다음 예제는 configureStore를 사용한 전체적인 구성을 담고 있습니다.

import logger from 'redux-logger'
import { reduxBatch } from '@manaflair/redux-batch'

import todosReducer from './todos/todosReducer'
import visibilityReducer from './visibility/visibilityReducer'

const rootReducer = {
  todos: todosReducer,
  visibility: visibilityReducer,
}

const preloadedState = {
  todos: [
    {
      text: 'Eat food',
      completed: true,
    },
    {
      text: 'Exercise',
      completed: false,
    },
  ],
  visibilityFilter: 'SHOW_COMPLETED',
}

const store = configureStore({
  reducer,
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
  devTools: process.env.NODE_ENV !== 'production',
  preloadedState,
  enhancers: [reduxBatch],
})
  • reducer: 리듀서에는 단일 함수를 전달하여 스토어의 루트 리듀서(root reducer)로 바로 사용할 수 있습니다. 또한 슬라이스 리듀서들로 구성된 객체를 전달하여 루트 리듀서를 생성하도록 할 수 있습니다. 이런 경우에는 내부적으로 기존 리덕스 combineReducers 함수를 사용해서 자동적으로 병합하여 루트 리듀서를 생성합니다.
  • middleware: 기본적으로는 리덕스 미들웨어를 담는 배열입니다. 사용할 모든 미들웨어를 배열에 담아서 명시적으로 작성할 수도 있는데요. 그렇지 않으면 getDefaultMiddleware를 호출하게 됩니다. 사용자 정의, 커스텀 미들웨어를 추가하면서 동시에 리덕스 기본 미들웨어를 사용할 때 유용한 방법입니다.
  • devTools: 불리언값으로 리덕스 개발자 도구를 끄거나 켭니다.
  • preloadedState: 스토어의 초기값을 설정할 수 있습니다.
  • enchaners: 기본적으로는 배열이지만 콜백 함수로 정의하기도 합니다. 예를 들어 다음과 같이 작성하면 개발자가 원하는 store enhancer를 미들웨어가 적용되는 순서보다 앞서서 추가할 수 있습니다.
const store = configureStore({
  ...
  enhancers: (defaultEnhancers) => [reduxBatch, ...defaultEnhancers],
})

// [reduxBatch, applyMiddleware, devToolsExtension]

createReducer()

상태에 변화를 일으키는 리듀서 함수를 생성하는 함수입니다. switch문을 사용하는 대신 내부적으로 immer 라이브러리 사용하여 별도로 state 값을 복사하지 않고 코드를 작성할 수 있습니다.

복사하는 과정에서 발생할 수 있는 원본 객체에 직접적인 변형, 얕은 복사가 이루어지는 등 발생할 수 있는 다양한 사이드 이펙드를 방지할 수 있습니다. 또한 코드의 가독성도 향상 됩니다.

// 기존 스위치 문으로 이루어진 카운터 리듀서 함수

function todosReducer(state = [], action) {
  switch (action.type) {
    case 'UPDATE_VALUE': {
      return {
        ...state,
        first: {
          ...state.first,
          second: {
            ...state.first.second,
            [action.someId]: {
              ...state.first.second[action.someId],
              fourth: action.someValue,
            },
          },
        },
      };
    }
    default: {
      return state;
    }
  }
}

// createReducer 함수 사용

const todosReducer = createReducer(state = [], (builder) => {
  builder.addCase('UPDATE_VALUE', (state, action) => {
    const {someId, someValue} = action.payload;

    state.first.second[someId].fourth = someValue;
  })
})

RTK에서 case reducer(이하 케이스 리듀서)가 액션을 처리하는 두 가지 방법은 builder callback 표기법과 map object 표기법이 있습니다. 두 방법 모두 동일한 역할을 하지만 타입스크립트와의 호환성을 위해서는 builder callback 표기법이 더 선호됩니다.

Builder Callback 표기법

createReducer의 콜백 함수 인자로 주어지는 builder 객체는 addCase, addMatcher, addDefaultCase라는 메서드를 제공합니다. 그리고 각 함수에서 액션을 리듀서에서 어떻게 처리할지를 정의할 수 있습니다.

// 각 라인마다 빌더 메서드를 나누어 호출합니다.
const counterReducer = createReducer(initialState, (builder) => {
  builder.addCase(increment, (state) => {})
  builder.addCase(decrement, (state) => {})
})

// 또는 메서드 호출을 연결하여 연속적으로 작성합니다.
const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state) => {})
    .addCase(decrement, (state) => {})
})

createReducer(initialState, builderCallback)

  • builder.addCase(actionCreator, reducer): 액션 타입과 맵핑되는 케이스 리듀서를 추가하여 액션을 처리합니다. addMatcher 또는 addDefaultCase 메서드 보다 먼저 작성되어야 합니다.
  • builder.addMatcher(matcher, reducer): 새로 들어오는 모든 액션에 대해서 주어진 패턴과 일치하는지 확인하고 리듀서를 실행합니다.
  • builder.addDefaultCase(reducer): 그 어떤 케이스 리듀서나 매처 리듀서도 실행되지 않았다면, 기본 케이스 리듀서가 실행됩니다.
const increment = createAction('increment')
const decrement = createAction('decrement')

function isActionWithNumberPayload(
  action: AnyAction
): action is PayloadAction {
  return typeof action.payload === 'number'
}

const initialState = {
  counter: 0,
  sumOfNumberPayloads: 0,
  unhandledActions: 0,
};

const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state, action) => {
      state.counter += action.payload
    })
    .addCase(decrement, (state, action) => {
      state.counter -= action.payload
    })
    .addMatcher(isActionWithNumberPayload, (state, action) => {})
    .addDefaultCase((state, action) => {})
})

Map Object 표기법

액션 타입 문자열을 ‘키’로 사용하는 객체를 받아서 케이스 리듀서에 맵핑합니다. 이는 builder callback 표기법보다 짧게 작성할 수 있다는 장점이 있기는 하지만 JavaScript를 사용하는 프로젝트에 유효한 방법입니다. TypeScript를 고려한다면 대부분의 경우 builder callback 표기법을 권장합니다

const counterReducer = createReducer(0, {
  increment: (state, action) => state + action.payload,
  decrement: (state, action) => state - action.payload
})

// 위 예제처럼 작성하거나 
// 또는 'createAction'에서 생성된 액션 생성자(action creator)를
// 연산된 프로퍼티(computed property) 문법을 사용해서 바로 '키'로 사용할 수 있습니다.

const increment = createAction('increment')
const decrement = createAction('decrement')

const counterReducer = createReducer(0, {
  [increment]: (state, action) => state + action.payload,
  [decrement.type]: (state, action) => state - action.payload
})

아래는 map object 표기법을 사용하는 createReducer 함수의 인자를 차례로 나열한 것입니다.

createReducer(initialState, actionsMap, actionMatchers, defaultCaseReducer)

  • initialState: 리듀서가 최초로 호출되었을 때 사용될 상태 값입니다.
  • actionsMap: 액션 타입이 케이스 리듀서에 맵핑되어 있는 객체입니다.
  • actionMatchers: { matcher, reducer } 형태로 정의된 매처를 배열로 담습니다. 매칭된 리듀서는 순서대로 독립적으로 실행됩니다.
  • defaultCaseReducer: 그 어떤 케이스 리듀서나 매처 리듀서도 실행되지 않았다면, 기본 케이스 리듀서가 실행됩니다.

createAction()

기존 리덕스 코어 라이브러리에서 액션을 정의하는 일반적인 접근법은 액션 타입 상수와 액션 생성자 함수를 분리하여 선언하는 것이었습니다. RTK에서는 이러한 두 과정을 createAction 함수를 사용하여 하나로 결합하여 추상화했습니다.

// core library
const INCREMENT = 'counter/increment'
function increment(amount) {
  return {
    type: INCREMENT,
    payload: amount,
  }
}
const action = increment(3)
// { type: 'counter/increment', payload: 3 }
// toolkit
import { createAction } from '@reduxjs/toolkit'
const increment = createAction('counter/increment')
const action = increment(3)
// { type: 'counter/increment', payload: 3 }

createAction 함수를 사용해서 생성한 액션 생성자함수에 toString() 메소드를 사용하면 action의 type이 문자열 형태로 리턴 됩니다.

const increment = createAction('counter/increment')

const counterReducer = createReducer(0, {
  [increment]: (state, action) => state + action.payload,
  [decrement.type]: (state, action) => state - action.payload
})

console.log(increment.toString())
// 'counter/increment'

action을 생성할 때 payload를 원하는 형태로 만들기 위해서는 createAction의 두번째 인자로 전달되는 콜백함수에서 원하는 payload를 정의할 수 있습니다.

import { createAction, nanoid } from '@reduxjs/toolkit'

const addTodo = createAction('todos/add', function prepare(text) {
  return {
    payload: {
      text,
      id: nanoid(),
      createdAt: new Date().toISOString(),
    },
  }
})

console.log(addTodo('Write more docs'))
/**
 * {
 *   type: 'todos/add',
 *   payload: {
 *     text: 'Write more docs',
 *     id: '4AJvwMSWEHCchcWYga3dj',
 *     createdAt: '2019-10-03T07:53:36.581Z'
 *   }
 * }
 **/

createSlice()

createSlice는 선언된 슬라이스 이름을 따라서 리듀서와 그리고 그것에 상응하는 액션 생성자와 액션 타입을 자동으로 생성합니다. 따라서 createSlice를 사용하면 따로 createAction, createReducer를 작성할 필요가 없습니다.

const alertSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {},
	extraReducers: (builder) => {}
});
// features/todos/todosSlice.js

import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { nanoid } from 'nanoid'

interface Item {
  id: string
  text: string
}

// 투두 슬라이스
const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Item[],
  reducers: {
    // 액션 타입은 슬라이스 이름을 접두어로 사용해서 자동 생성됩니다. -> 'todos/addTodo'
    // 이에 상응하는 액션 타입을 가진 액션이 디스패치 되면 리듀서가 실행됩니다.
    addTodo: {
      reducer: (state, action: PayloadAction) => {
        state.push(action.payload)
      },
      // 리듀서가 실행되기 이전에 액션의 내용을 편집할 수 있습니다.
      prepare: (text: string) => {
        const id = nanoid()
        return { payload: { id, text } }
      },
    },
  },
})

const { actions, reducer } = todosSlice
export const { addTodo } = actions

export default reducer 

extraReducers

extraReducers는 createSlice가 생성한 액션 타입 외 다른 액션 타입에 응답할 수 있도록 합니다. 슬라이스 리듀서에 맵핑된 내부 액션 타입이 아니라, 외부의 액션을 참조하려는 의도를 가지고 있습니다.

const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: [], loading: 'idle' },
  reducers: {},
  extraReducers: (builder) => {

    // 'users/fetchUserById' 액션 타입과 상응하는 리듀서가 정의되어 있지 않지만
    // 아래처럼 슬라이스 외부에서 액션 타입을 참조하여 상태를 변화시킬 수 있습니다.

    builder.addCase(fetchUserById.fulfilled, (state, action) => {
      state.entities.push(action.payload)
    })
  },
})

createAsyncThunk

createAction의 비동기 버전을 위해서 제안되었습니다. 액션 타입 문자열과 프로미스를 반환하는 콜백 함수를 인자로 받아서 주어진 액션 타입을 접두어로 사용하는 프로미스 생명 주기 기반의 액션 타입을 생성합니다

mport { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'

const fetchUserById = createAsyncThunk(
  'users/fetchByIdStatus',
  async (userId, thunkAPI) => {
    const response = await userAPI.fetchById(userId)

    return response.data
  }
)

const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: [], loading: 'idle' },
  reducers: {},
  // extraReducers에 케이스 리듀서를 추가하면 
  // 프로미스의 진행 상태에 따라서 리듀서를 실행할 수 있습니다.
  extraReducers: (builder) => {
    builder
      .addCase(fetchUserById.pending, (state) => {})
      .addCase(fetchUserById.fulfilled, (state, action) => {
	      state.entities.push(action.payload)
      })
      .addCase(fetchUserById.rejected, (state) => {})
  },
})

// 위에서 fetchUserById, 즉 thunk를 작성해두고
// 앱에서 필요한 시점에 디스패치 하여 사용합니다.

// ...

dispatch(fetchUserById(123))

꼭 서버와 통신이 이루어지는 구간에서만 사용되어야 하는 것은 아닙니다. 비즈니스 로직을 비동기 형태로 구현할 때에도 응용할 수 있습니다. 예를 들어 사용자가 UI와 상호작용한 이후에 이행된 프로미스 결과에 따라서 어떤 로직을 실행해야 하는지 응집도 높게 작정할 수 있습니다. 코드는 이런 모습입니다.

const ReactComponent = () => {
  const { openDialog } = useDialog();
  
  // (아래 GIF처럼 버튼의 onClick 액션을 핸들링하는 함수입니다.)
  const handleSubmit = async (): Promise => {

    // 화면에 띄울 다이얼로그를 선언하고, 프로미스 결과를 기다립니다.
    // 사용자가 '동의' 버튼을 누르면 true로 평가됩니다.
    const hasConfirmed = await openDialog({
      title: '데이터 전송',
      contents: '입력한 데이터를 전송할까요?',
    });

    if (hasConfirmed) {
      // 이후 비즈니스 로직 실행
    }
  };
}
const useDialog = () => {
  const dispatch = useAppDispatch();

  // 리액트 컴포넌트에서 훅을 사용해서 openDialog 함수를 호출했다면
  // 썽크(thunk) 액션 생성자 함수를 통해서 액션을 디스패치하게 됩니다.
  const openDialog = async (state: DialogContents): Promise => {
    const { payload } = await dispatch(confirmationThunkActions.open(state));

    return payload
  };

  // ...
	
  return {
    openDialog,
    ...
  }
};
const confirmationThunkActions = {
  open: createAsyncThunk<
    boolean,
    DialogContents,
    { extra: ThunkExtraArguments }
  >('dialog', async (payload, { extra: { store }, dispatch }) => {
    // thunk 액션이 실행되고, 실제로 다이얼로그가 열리는 부분입니다.
    dispatch(openDialog(payload));

    return new Promise<boolean>((resolve) => {

      // 스토어를 구독하고 상태 변경을 감지하면
      // 사용자의 '동의', '거절' 액션에 맞추어 resolve 처리합니다.
      const unsubscribe = store.subscribe(() => {
        const { dialog } = store.getState() as RootState;

        if (dialog.isConfirmed) {
          unsubscribe();
          resolve(true);
        }

        if (dialog.isDeclined) {
          unsubscribe();
          resolve(false);
        }
      });
    });
  }),
};

export default confirmationThunkActions;

return (
  <div>
    <main>콘텐츠</main>
    <Dialog
      isOpen={isOpen1}
      title={title1}
      contents={title2}
      onConfirmed={handleConfirmedCase1}
      onDeclined={handleDeclinedCase1}
    />
    <Dialog
      isOpen={isOpen2}
      title={title2}
      contents={contents2}
      onConfirmed={handleConfirmedCase2}
      onDeclined={handleDeclinedCase2}
    />
  </div>
)

// 위 예체처럼 다이얼로그 선언하고 많은 핸들링 함수를 넘겨서 사용하던 부분에서 개선을 기대할 수 있습니다.

위와 같은 패턴으로 작성하게 되면 서로 먼 거리에서 소통을 주고받아야 하는 객체 간의 메시지 전달을 리덕스 스토어를 통해서 효율적으로 달성할 수 있게 됩니다. 아래처럼 리액트 컴포넌트에서 각각의 다이얼로그가 어떤 역할을 수행해야 하는지 명시적으로 하나하나 전달하지 않아도 됩니다.

RTK를 활용해서 컴포넌트에서 다이얼로그의 의존성을 제거할 수 있습니다.

createSelector

리덕스 스토어 상태에서 데이터를 추출할 수 있도록 도와주는 유틸리티입니다. Reselect 라이브러리에서 제공하는 함수를 그대로 가져온 것인데요. RTK에서 지원하는 이유는 useSelector 함수의 결점을 보완하기 위한 좋은 솔루션이기 때문입니다.

// useSelector는 스토어에서 값을 조회합니다.
const users = useSelector((state) => state.users)

Reselect 라이브러리를 살펴보면 createSelector 함수가 memoization(이하 메모이제이션), 즉 이전에 계산한 값을 메모리에 저장하여 값이 변경됐을 경우에만 계산하도록 동작하는 것을 확인할 수 있었습니다. 이것은 아래와 같은 상황을 개선할 수 있는데요.

const users = useSelector(
  (state) => state.users.filter(user => user.subscribed)
);

컴포넌트의 구현부에 작성된 인라인 useSelector 훅은 스토어를 자동으로 구독하고 있기 때문에 상태 트리가 갱신되어 컴포넌트를 다시 render 해야 되는 경우 매번 새로운 인스턴스를 생성하게 됩니다.

const shopItemsSelector = state => state.shop.items
const taxPercentSelector = state => state.shop.taxPercent

// subtotal 값을 메모이제이션 합니다.
const subtotalSelector = createSelector(
  shopItemsSelector,
  items => items.reduce((subtotal, item) => subtotal + item.value, 0)
)

// 메모이제이션된 subtotal 값과 taxPercentSelector를 합성하여
// 새로운 값을 메모이제이션 합니다.
const taxSelector = createSelector(
  subtotalSelector,
  taxPercentSelector,
  (subtotal, taxPercent) => subtotal * (taxPercent / 100)
)

const totalSelector = createSelector(
  subtotalSelector,
  taxSelector,
  (subtotal, tax) => ({ total: subtotal + tax })
)

const exampleState = {
  shop: {
    taxPercent: 8,
    items: [
      { name: 'apple', value: 1.20 },
      { name: 'orange', value: 0.95 },
    ]
  }
}

console.log(subtotalSelector(exampleState)) // 2.15
console.log(taxSelector(exampleState))      // 0.172
console.log(totalSelector(exampleState))    // { total: 2.322 }

위 예제에서는 어떤 서비스를 구독 중인 사용자, subscribed user를 조회하기 위해서 filter 함수를 사용하고 있는데요. useSelector가 실행될 때마다 필터 함수는 매번 새로운 배열을 반환하게 되면서 이전에 참조하고 있던 객체 주소가 현재 주소와의 차이를 발생시키게 됩니다. 그리고는 re-rendering을 발생시키는데 이때 재계산이 필요한 상태 트리의 사이즈나 계산 비용이 크다면 성능 문제로 이어질 수 있습니다.

이러한 문제를 회피하기 위해서 createSelector를 사용하면 애플리케이션을 최적화할 수 있습니다.

정리

리덕스를 사용하면서 비슷한 코드를 계속 작성하게 되는 문제점에 대해서 해결방안을 찾아보던 중 RTK에 대해 알게 되었습니다. 처음에 RTK를 왜 사용하는지와 작동원리에 대해 이해하는지 어려웠스지만 "화해" 블로그의 설명과 예제과 많은 도움이 되었고 그 자료를 바탕으로 이번 포스팅을 작성했습니다.

RTK를 공부하면서 리덕스의 작동원리에 대해 조금 더 깊게 알 수 있는 좋은 기회가 되었던 것 같습니다. 현재 사용되는 상태 관리 라이브러리 중에서 리덕스가 왜 개발자들에게 인기가 높은지에 대해서 이해할 수 있었습니다.

기존의 리덕트 프로젝트를 RTK를 활용하여 리펙토링 해보고 순수하게 리덕스로 코드를 작성할 때와 비교했을 때, 어떤 부분에서 더 효율적인지에 대해서 추가적인 고찰을 해볼 예정입니다.

Reference

https://redux-toolkit.js.org/introduction/getting-started
http://blog.hwahae.co.kr/all/tech/tech-tech/6946/

profile
새로운 것을 기록하고 복습하는 공간입니다.

0개의 댓글

관련 채용 정보