Redux Toolkit 깊이 보기 — createSlice부터 RTK Query까지
Redux는 보일러플레이트가 많다는 평가를 받았지만, Redux Toolkit은 이를 크게 줄였습니다. 그렇다면 RTK가 정확히 무엇을 어떻게 간소화했을까요?
개념 정의
Redux Toolkit(RTK)은 Redux의 공식 권장 개발 도구세트 입니다. createSlice, createAsyncThunk 등으로 보일러플레이트를 줄이고, RTK Query로 서버 상태 관리까지 통합합니다.
왜 필요한가
기존 Redux의 문제점들입니다.
- 액션 타입 상수, 액션 생성자, 리듀서를 별도로 정의해야 함
- 불변성을 수동으로 관리 (
{ ...state, nested: { ...state.nested, value: newValue } }) - 비동기 처리를 위한 미들웨어 설정이 복잡
- 서버 상태 관리(캐싱, 재검증)가 내장되어 있지 않음
내부 동작
Redux의 세 가지 원칙
- Single Source of Truth: 전역 상태가 하나의 스토어에 저장
- State is Read-Only: 상태는 액션을 통해서만 변경
- Changes with Pure Functions: 리듀서는 순수 함수
createSlice
액션 타입, 액션 생성자, 리듀서를 ** 한 번에 생성 **합니다.
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all',
},
reducers: {
// Immer 덕분에 직접 변경하는 것처럼 작성 가능
addTodo(state, action) {
state.items.push({
id: Date.now(),
text: action.payload,
completed: false,
});
},
toggleTodo(state, action) {
const todo = state.items.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo(state, action) {
state.items = state.items.filter(t => t.id !== action.payload);
},
setFilter(state, action) {
state.filter = action.payload;
},
},
});
// 자동 생성된 액션 생성자
export const { addTodo, toggleTodo, removeTodo, setFilter } = todosSlice.actions;
// 리듀서
export default todosSlice.reducer;
Store 설정
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './todosSlice';
import userReducer from './userSlice';
const store = configureStore({
reducer: {
todos: todosReducer,
user: userReducer,
},
// DevTools, thunk 미들웨어가 자동 포함
});
export default store;
createAsyncThunk
비동기 로직을 위한 thunk를 생성합니다. pending, fulfilled, rejected 액션이 자동 생성됩니다.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUsers = createAsyncThunk(
'users/fetchAll',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('요청 실패');
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const usersSlice = createSlice({
name: 'users',
initialState: {
items: [],
loading: false,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
컴포넌트에서 사용
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, toggleTodo, setFilter } from './todosSlice';
function TodoApp() {
const dispatch = useDispatch();
const todos = useSelector(state => state.todos.items);
const filter = useSelector(state => state.todos.filter);
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
return (
<div>
<input
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target.value) {
dispatch(addTodo(e.target.value));
e.target.value = '';
}
}}
/>
{filteredTodos.map(todo => (
<div key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
{todo.text}
</div>
))}
</div>
);
}
RTK Query — 서버 상태 관리
RTK Query는 Redux Toolkit에 내장된 ** 데이터 페칭 및 캐싱 솔루션 **입니다.
API 정의
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post', 'User'],
endpoints: (builder) => ({
// 쿼리 (GET)
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Post'],
}),
getPostById: builder.query({
query: (id) => `/posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
// 뮤테이션 (POST, PUT, DELETE)
addPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: ['Post'], // Post 관련 캐시 무효화
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useAddPostMutation,
useUpdatePostMutation,
} = api;
컴포넌트에서 사용
function PostList() {
const { data: posts, isLoading, error } = useGetPostsQuery();
const [addPost, { isLoading: isAdding }] = useAddPostMutation();
if (isLoading) return <Spinner />;
if (error) return <Error />;
return (
<div>
<button
onClick={() => addPost({ title: '새 글', content: '내용' })}
disabled={isAdding}
>
글 작성
</button>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Tag 기반 캐시 무효화
1. useGetPostsQuery() → 서버에서 데이터 가져옴 → 캐시에 저장 (tag: 'Post')
2. addPost() 호출 → 서버에 POST 요청
3. invalidatesTags: ['Post'] → 'Post' 태그의 캐시 무효화
4. useGetPostsQuery()가 자동으로 데이터 다시 가져옴
Redux DevTools 활용
// 시간 여행 디버깅
// 1. Redux DevTools 확장 설치
// 2. configureStore가 자동으로 DevTools 연결
// 3. 브라우저 개발자 도구 → Redux 탭
// DevTools에서 할 수 있는 것들:
// - 모든 액션의 타임라인 확인
// - 각 액션 전후의 state 비교
// - 특정 시점으로 되돌아가기 (time travel)
// - 액션을 수동으로 dispatch
// - state 트리 검색
주의할 점
Immer가 적용되는 범위를 오해
createSlice의 reducers 안에서만 Immer가 활성화됩니다. extraReducers에서도 동작하지만, slice 바깥에서 직접 state를 변경하면 일반 JavaScript의 mutation이 되어 버그가 발생합니다.
RTK Query와 createAsyncThunk를 함께 사용
같은 데이터에 대해 RTK Query와 createAsyncThunk를 동시에 사용하면 캐시가 이중으로 관리됩니다. 서버 상태는 RTK Query에, 클라이언트 로직은 createAsyncThunk에 집중하는 것이 올바릅니다.
정리
| 항목 | 설명 |
|---|---|
| createSlice | Immer 기반 불변 업데이트 + 액션 자동 생성 |
| createAsyncThunk | pending/fulfilled/rejected 자동 관리 |
| RTK Query | 태그 기반 캐시 무효화로 서버 상태 관리 |
| DevTools | time travel debugging으로 상태 변화 추적 |
| 적합한 상황 | 대규모 팀, 예측 가능한 상태 관리가 필요한 프로젝트 |
Redux가 과하다고 느끼는 프로젝트도 있지만, 대규모 팀에서 예측 가능한 상태 관리가 필요하다면 RTK는 여전히 강력한 선택입니다.
댓글 로딩 중...