RTK, Redux Toolkit은 상태 관리 라이브러리 중 하나입니다.
요즘에는 다양한 상태 관리 라이브러리들이 나오게 되면서 이전에 사용하던 redux(리덕스)로 상태를 관리하는 방법이 다소 번거롭게 느껴질 수 입니다.
redux(리덕스)는 Flux 아키텍처의 구현체로 MVC 패턴에서 연쇄적인 갱신(데이터 간 의존성 이슈) 문제 해결을 위해 고안되었습니다.
데이터 간의 의존성을 완화하기 위한 해결책으로 단방향의 데이터 흐름을 떠올리게 되고, 이것이 Flux 아키텍처의 핵심 모델이 되었습니다.
구현체인 redux는 애플리케이션을 위한 상태를 담는 컨테이너라고 볼 수 있습니다.
데이터의 단방향 흐름을 활용해 시스템을 예측 가능하게 만들어 기능을 보완해주는 역할을 수행해줍니다.
reducer(리듀서)는 변화를 일으키는 함수입니다.
전달받은 함수를 갖고 새로운 상태를 만들어 store(스토어)에 전달합니다.
- 상태의 예측 가능함
동일한 상태와 액션이 리듀서에 전달되면 항상 동일한 결과가 생성
(리듀서는 순수 함수이기 때문)
- 유지보수의 용이함
코드를 구성하는 방법이 엄격
Redux에 대한 지식이 있는 사람이 Redux 애플리케이션의 구조를 더 쉽게 이해 가능
- 쉬운 디버깅
어떤 액선이 일어나고 데이터가 어떻게 변화했는지 로그가 남기 때문에 개발자는 이전의 특정 상태로 돌아가볼 수 있음
버그가 나기 이전 상태로 돌아가서 테스트 가능
- store의 효율적인 전역 상태 관리
- 코드의 증량
리덕스로 코드를 구현하는 순간 필수적으로 만들어야하는 파일이 존재
자연스럽게 코드량이 그만큼 증가
- 읽기 전용 X
리덕스는 상태를 읽기 전용으로 취급하지만 읽기 전용으로 만들어주지는 않음
항상 직접 수정하지 않게 하기위해 주의 필요
- 컴포넌트와의 연결성
store와 component를 연결하기 위해 메서드가 필요( -> 코드량이 증가)
Redux Toolkit(리덕스 툴킷)은 redux를 더욱 편리하게 사용하기 위해 만들어졌습니다. 이름 그대로 "redux를 위한 도구(tool) 세트(set/kit)" 입니다.
redux에는 몇 가지 단점들이 있습니다 :
1. store 환경 설정의 복잡함
2. 설치해야할 패키지의 방대함
3. too much한 상용 코드들
RTK는 이러한 redux의 복잡함을 낮춰 사용성을 높일 목적으로 만들어졌습니다.
react가 CRA를 통해 개발 접근성을 높인 것처럼 redux도 RTK를 통해 추상화를 시도한 것입니다.
RTK에서 제공하는 7가지의 API(configureStore, createReducer, createAction, createSlice, createAsyncThunk, createSelector, createEntityAdapter) 중에서 제가 사용한 것들 위주로 정리해보려고 합니다.
configureStore
는 createStore
(redux core library의 표준 함수)를 추상화한 함수로 더 나은 개발 경험을 위해 기존 redux의 번거로운 기본 설정 과정을 자동화할 수 있습니다.
아래와 같이 선언하면 기본 middle-ware(미들웨어)로 redux-thunk
를 추가하고 개발 환경헤서 리덕스 개발자 도구를 활성화해줍니다.
import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'
const store = configureStore({ reducer: rootReducer })
configureStore
함수는 아래의 정보들을 전달합니다 :
import logger from 'redux-logger'
import { reduxBatch } from '@manaflair/redux-batch'
const rootReducer = {
//... codes
}
const preloadedState = {
//... codes
}
const store = configureStore({
reducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
devTools: process.env.NODE_ENV !== 'production',
preloadedState,
enhancers: [reduxBatch],
})
reducer
단일 함수를 전달해 store의 root-reducer로 바로 사용 가능
slide-reducer들로 구성된 객체를 전달해 root-reducer 생성 가능
내부적으로 기존 redux의 `combineReducers`함수를 사용해 자동적으로 병합해 root-reducer를 생성
middleware
redux의 middleware를 담는 배열
사용할 모든 middleware를 명시적으로 담지 않으면 `getDefaultMiddleware`를 호출하게 됨
사용자 정의, custom middleware를 추가함과 동시에 redux의 기본 middleware를 사용할 때 유용한 방법
devTools
boolean 값으로 redux 개발자 도구를 끄거나 켬
preloadState
store의 초기값을 설정
enhancer
기본적으론 배열이지만 콜백 함수로 정의할 수 있음
아래와 같이 작성할 경우 개발자가 원하는 `store enhancer`를 middleware가 적용되는 순간보다 앞서 추가 가능
const store = configureStore({
...
enhancers: (defaultEnhancers) => [reduxBatch, ...defaultEnhancers],
})
아래는 제가 토이 프로젝트를 진행하면서 생성한 store 파일입니다.
위의 모든 정보들을 처음부터 전부 적어둘 필요는 없고 진행하면서 필요한 부분들을 추가해 사용하면 될 것 같아요.
export const store = configureStore({
reducer: {
user: userReducer,
post: postReducer,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false, // 불필요한 경고를 피하기 위해 추가
}),
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
상태에 변화를 일으키는 reducer 함수를 생성하는 util 함수로 immer
라이브러리를 사용해 불변 업데이트가 이뤄지도록 로직을 간단하게 구성할 수 있습니다.
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
중에서 TS와 호환성이 더 좋은 표기법은 전자입니다.
기존 redux 라이브러리에서 액션을 정의하기 위해선 액션 타입 상수와 액션 생성자 함수를 분리해 각각 선언해야 합니다. RTK에서는 이러한 과정을 createAction 함수를 통해 결합할 수 있도록 해줍니다.
createSlice를 사용하면 위의 createReducer와 createAction을 따로 작성할 필요가 없어집니다. createSlice는 선언된 slice의 이름을 따서 reducer와 액션 생성자, 액션 타입을 자동으로 만들어주기 때문입니다.
저는 createSlice를 사용해 진행했습니다. slice 파일은 features 폴더 안에서 생성하는 것을 권장드립니다. :)
redux official style guide
interface UserState {
userId(userId: string): unknown
id: string
nickName: string
profileImg: string
}
const initialState: UserState[] = []
// signUp
export const SignUp = createAsyncThunk("user", async ({ ...formData }: SignUpUser) => {
const { userId, password, nickname } = formData
try {
const response = await axiosInstance.post(PATH + "/sign-up", {
userId,
password,
data: {
nickname,
},
})
return [response.data]
} catch (error) {
throw new Error("사용자 데이터를 불러오는 데 실패했습니다!")
}
})
export const userSlice = createSlice({
name: "user",
initialState,
reducers: {
setCurrentUser: (state, action) => {
return { ...state, ...action.payload }
},
signUp: (state, action) => {
state[0] = action.payload
},
signOut: state => {
state.length = 0
state.push(...initialState)
},
},
})
export const { signUp, signOut } = userSlice.actions
export default userSlice.reducer
비동기로 생성된 액션(createAsyncThunk)은 extraReducers에 추가해 참조할 수 있게 설정할 수 있습니다. extraReducers는 createSlice가 생성한 액션 타입 외 다른 액션 타입에 응답할 수 있게 도와주는 역할을 합니다. slice reducer에 mapping된 내부 액션 타입이 아닌 외부 액션을 참조하기 위한 의도를 갖고 있습니다.
export const postSlice = createSlice({
name: "post",
initialState,
reducers: {},
extraReducers(builder) {
builder
// getOnePost : read
.addCase(getOnePost.pending, state => {
state.loading = true
})
.addCase(getOnePost.fulfilled, (state, action) => {
if (!isPostDetail(action.payload)) return
state.loading = false
state.error = null
state.postDetail = action.payload
})
.addCase(getOnePost.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || "예기치 못한 에러로 게시글 데이터를 불러오지 못했습니다!"
state.postDetail = null
})
...
createAsyncThunk는 createAction을 비동기적으로 실행하기 위해 만들어졌습니다.
actionType 문자열과 promise를 반환하는 콜백 함수를 인자로 받아 주어진 actionType을 접두어로 사용하는 promise의 생명 주기에 기반한 actionType을 생성합니다.
const initialState: PostState = {
postList: {
data: [],
pageNation: undefined,
},
postDetail: null,
loading: false,
error: "",
}
export const getOnePost = createAsyncThunk("post/getPost", async (postId: string) => {
const post = await PostApi.getOnePost(postId)
return post
})
저는 서버와 통신을 해야하는 부분에서만 사용했지만 꼭 그럴 필요는 없습니다.
비즈니스 로직을 비동기 형태로 구현할 때에도 충분히 응용 가능합니다.
createSelector는 redux store 상태에서 데이터를 추출할 수 있도록 도와주는 유틸리티입니다.
useSelector 함수의 결점을 보완하기 위해 제공되고 있습니다. useSelector는 store에서 값을 memoization(메모이제이션)으로 조회하도록 할 수 있습니다.
createEntityAdapter는 효율적인 CRUD 수행을 위해 미리 빌드된 reducer와 selector를 생성해주는 함수입니다.
install RTK
npm install @reduxjs/toolkit
npm install react-redux
yarn add @reduxjs/toolkit
yarn add react-redux
store/store.ts
먼저 store를 만들어줍니다.
export const store = configureStore({
reducer: {
user: userReducer,
post: postReducer,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false, // 불필요한 경고를 피하기 위해 추가
}),
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
store ?
상태(state)를 저장하고 상태에 대한 변경 사항을 관리하는 중심적인 역할을 수행합니다.
Redux Toolkit의 store는 애플리케이션의 상태를 저장하고, 액션을 통해 상태를 변경하는 주체로서 중요한 역할을 합니다.
이를 통해 애플리케이션의 상태 관리를 효율적으로 할 수 있고, 컴포넌트 간의 상태 공유 및 상태 변경을 관리하기 용이해집니다.
hooks/use-redux-toolkit.ts
Redux의 useDispatch와 useSelector를 사용할 때 타입 지정을 편리하게 하기 위해 custom-hook을 하나 만들어줍니다.
import { AppDispatch, RootState } from "@/features/store"
import { useDispatch, useSelector } from "react-redux"
import type { TypedUseSelectorHook } from "react-redux"
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
useAppDispatch ?
createSlice
Redux Toolkit에서 createSlice 함수는 Redux reducer와 action을 하나로 묶은 개념으로 "slice(조각)"를 생성하는데 사용됩니다. 이를 통해 상태 관리를 보다 모듈화하고 간단하게 만들 수 있습니다.
일반적으로 Redux에서는 action-type, action 생성자 함수, reducer 함수를 각각 작성해야 했는데, createSlice를 사용하면 이들을 하나로 묶을 수 있습니다. 이를 통해 코드의 반복을 줄이고 가독성을 높일 수 있습니다.
features/user/user.slice.ts
interface UserState {
userId(userId: string): unknown
id: string
nickName: string
profileImg: string
}
const initialState: UserState[] = []
const PATH = "/user"
export const SignUp = createAsyncThunk("user", async ({ ...formData }: SignUpUser) => {
const { userId, password, nickname } = formData
try {
const response = await axiosInstance.post(PATH + "/sign-up", {
userId,
password,
data: {
nickname,
},
})
return [response.data]
} catch (error) {
throw new Error("사용자 데이터를 불러오는 데 실패했습니다!")
}
})
export const userSlice = createSlice({
name: "user",
initialState,
reducers: {
setCurrentUser: (state, action) => {
// action.payload: 로그인 후 서버에서 받아온 유저 정보
return { ...state, ...action.payload }
},
signUp: (state, action) => {
state[0] = action.payload
},
signOut: state => {
state.length = 0 // 배열을 비운 뒤
state.push(...initialState) // 초기 상태를 다시 추가
},
},
})
export const { signUp, signOut } = userSlice.actions
export default userSlice.reducer
features/post/post.slice.ts
type PostState = {
postList: TPostsResponse
postDetail: {
data: Post
children: Comment
} | null
loading: boolean
error: string | null
}
const initialState: PostState = {
postList: {
data: [],
pageNation: undefined,
},
postDetail: null,
loading: false,
error: "",
}
export const getOnePost = createAsyncThunk("post/getPost", async (postId: string) => {
const post = await PostApi.getOnePost(postId)
return post
})
// getPost : read
export const getPosts = createAsyncThunk("post/getPosts", async (pageParam: number) => {
try {
const posts = await PostApi.getPosts(pageParam)
return posts
} catch (error) {
throw new Error("게시글 데이터를 불러오는 데 실패했습니다!")
}
})
// postPost : create
export const postPost = createAsyncThunk<Post, { formData: FormData }>("post/postPost", async ({ formData }) => {
try {
const res = await PostApi.postPost({ formData })
return res.data
} catch (error) {
throw new Error("게시글을 등록하는 데 실패했습니다!")
}
})
// deletePost : delete
export const deletePost = createAsyncThunk<void, string>("post/deletePost", async (postId: string) => {
try {
await PostApi.deletePost(postId)
} catch (error) {
throw new Error("게시글을 삭제하는 데 실패했습니다!")
}
})
// editPost : update
export const editPost = createAsyncThunk<Post, { post: Partial<{ title: string; content: string }>; postId: string }>(
"post/editPost",
async ({ post, postId }) => {
try {
const updatedPost = await PostApi.editPost(post, postId)
return updatedPost
} catch (error) {
throw new Error("게시글을 수정하는 데 실패했습니다!")
}
},
)
export const postSlice = createSlice({
name: "post",
initialState,
reducers: {},
extraReducers(builder) {
builder
// getOnePost : read
.addCase(getOnePost.pending, state => {
state.loading = true
})
.addCase(getOnePost.fulfilled, (state, action) => {
if (!isPostDetail(action.payload)) return
state.loading = false
state.error = null
state.postDetail = action.payload
})
.addCase(getOnePost.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || "예기치 못한 에러로 게시글 데이터를 불러오지 못했습니다!"
state.postDetail = null
})
// getPost : read
.addCase(getPosts.pending, state => {
state.loading = true
})
.addCase(getPosts.fulfilled, (state, action) => {
if (!isPostArray(action.payload)) return
state.loading = false
state.error = null
state.postList = action.payload
})
// postPost : create
.addCase(getPosts.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || "예기치 못한 에러로 게시글 데이터를 불러오지 못했습니다!"
state.postList = {
data: [],
pageNation: {},
}
})
// postPost : create
.addCase(postPost.pending, state => {
state.loading = true
})
.addCase(postPost.fulfilled, (state, action) => {
if (!isPostArray(action.payload)) return
state.loading = false
})
.addCase(postPost.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || "게시글을 등록하는 데 실패했습니다!"
})
// deletePost : delete
.addCase(deletePost.pending, state => {
state.loading = true
})
.addCase(deletePost.fulfilled, state => {
state.loading = false
})
.addCase(deletePost.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || "게시글을 삭제하는 데 실패했습니다!"
})
// editPost : update
.addCase(editPost.pending, state => {
state.loading = true
})
.addCase(editPost.fulfilled, state => {
state.loading = false
})
.addCase(editPost.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || "게시글을 수정하는 데 실패했습니다!"
})
},
})
export default postSlice.reducer
type guard
아래 두 함수는 TypeScript에서 타입 가드(Type Guard)로 사용되는 함수입니다.
해당하는 타입의 데이터인지를 판별하여, 타입스크립트에게 그에 맞는 타입을 인지하도록 도와줍니다.
function isPostArray(args: unknown): args is TPostsResponse {
return true
}
function isPostDetail(args: unknown): args is { data: Post; children: Comment } {
return true
}
slice, reducer, action 등등 새로운 개념들을 어느 정도 익히는데 시간이 조금 걸리지만 RTK를 통해 Redux의 사용이 확실히 편리해진 것 같긴 합니다.
오랜 기간동안 사용해온 라이브러리이기 때문에 구글링을 했을 때 많은 자료가 나오는 것은 정말 좋지만
여전히 react가 익숙한 제가 다루기는 아직 어려운 라이브러리인 것 같습니다.
타입스크립트와 함께 사용했기 때문에 더 복잡하고 번잡하다 느껴졌을 수도 있지만 일단 RTK Query로 무한 스크롤을 구현하는 것을 보면서(완벽하게 구현하는 것도 어려움) 오히려 ReactQuery가 얼마나 편리한 라이브러리인지를 느낄 수 있었던 것 같습니다. 무한 스크롤은 reactQuery(tanstack)로.. 👀
경험을 위해 한 번 사용해봤으나 자발적으로 두 번째 사용을 하게 될지는 잘 모르겠습니다...🫠