Redux는 여전히 대규모 React 애플리케이션에서 중요한 역할을 합니다. 다만 순수 Redux + TypeScript 조합은 설정과 보일러플레이트가 많아 실무에서 피로도가 큽니다. 이 문제를 해결하기 위해 등장한 것이 Redux Toolkit(RTK)이며, RTK는 TypeScript와 함께 사용할 때 가장 큰 효과를 발휘합니다.
Redux Toolkit + TypeScript를 쓰는 이유
RTK를 쓰면 다음 문제들이 한 번에 해결됩니다.
- 액션 타입 문자열 관리 불필요
- 불변성 코드 작성 부담 감소 (Immer 내장)
- 타입 추론이 잘 동작하는 slice 구조
- 비동기 로직 표준화(createAsyncThunk)
결론적으로 RTK + TypeScript는 현재 Redux의 사실상 표준 조합입니다.
패키지 설치
React 프로젝트(Vite 기준)에 필요한 패키지를 설치합니다.
npm install @reduxjs/toolkit react-redux
npm install -D @types/react-redux
RTK는 TypeScript 타입을 자체 포함하고 있으므로 별도의 Redux 타입 패키지는 필요하지 않습니다.
기본 폴더 구조 (실무 권장)
Redux 관련 코드는 한 곳에 모아두는 것이 관리에 유리합니다.
src/
store/
index.ts
hooks.ts
userSlice.ts
Slice 작성하기 (상태 + 리듀서 + 타입)
RTK의 핵심은 createSlice입니다.
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type User = {
id: number;
name: string;
};
type UserState = {
user: User | null;
loading: boolean;
};
const initialState: UserState = {
user: null,
loading: false,
};
const userSlice = createSlice({
name: "user",
initialState,
reducers: {
setUser(state, action: PayloadAction) {
state.user = action.payload;
},
clearUser(state) {
state.user = null;
},
},
});
export const { setUser, clearUser } = userSlice.actions;
export default userSlice.reducer;
핵심 포인트:
- State 타입을 먼저 정의
- PayloadAction으로 액션 payload 타입 명시
- Immer 덕분에 state 직접 변경 가능
Store 설정하기
모든 slice를 하나의 store로 묶습니다.
import { configureStore } from "@reduxjs/toolkit";
import userReducer from "./userSlice";
export const store = configureStore({
reducer: {
user: userReducer,
},
});
RootState와 AppDispatch 타입 추출
이 단계가 TypeScript + Redux의 핵심입니다.
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
이 두 타입은 이후 전체 애플리케이션에서 재사용됩니다.
타입 안전한 커스텀 훅 만들기
react-redux 기본 훅 대신, 타입이 고정된 커스텀 훅을 만드는 것이 실무 표준입니다.
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./index";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
이제 컴포넌트에서는 타입을 신경 쓸 필요가 없습니다.
React에 Store 연결하기
import { Provider } from "react-redux";
import { store } from "./store";
<Provider store={store}>
<App />
</Provider>
컴포넌트에서 사용하기
function Profile() {
const user = useAppSelector(state => state.user.user);
const dispatch = useAppDispatch();
if (!user) {
return (
<button onClick={() => dispatch(setUser({ id: 1, name: "Alice" }))}>
Login
</button>
);
}
return (
<div>
{user.name}
<button onClick={() => dispatch(clearUser())}>Logout</button>
</div>
);
}
state, dispatch 모두 타입이 자동으로 보장됩니다.
비동기 처리(createAsyncThunk)와 타입
RTK의 비동기 표준은 createAsyncThunk입니다.
import { createAsyncThunk } from "@reduxjs/toolkit";
export const fetchUser = createAsyncThunk<
User,
number
>("user/fetch", async (id) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
});
제네릭 의미:
- 첫 번째: 성공 시 반환 타입
- 두 번째: 인자 타입
extraReducers로 비동기 상태 처리
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state) => {
state.loading = false;
});
}
비동기 액션의 각 단계도 타입 안전하게 처리됩니다.
실무에서 자주 하는 실수
- RootState / AppDispatch 타입을 만들지 않음
- useSelector / useDispatch를 직접 사용
- Slice State 타입을 명확히 정의하지 않음
- Redux에 UI 전용 상태까지 모두 넣음
Redux는 “전역 비즈니스 상태”에만 사용하는 것이 이상적입니다.
실무 권장 체크리스트
- Redux Toolkit 사용
- Slice 단위로 상태 분리
- RootState / AppDispatch 타입 필수
- 커스텀 훅으로 타입 은닉
- 비동기는 createAsyncThunk 사용
Redux / RTK + TypeScript의 핵심은 “전역 상태를 타입 계약으로 고정하는 것”입니다. 설정만 제대로 해두면 이후 개발자는 상태 구조를 외울 필요 없이 타입에 따라 자연스럽게 코드를 작성할 수 있습니다.
- RTK는 기본 선택
- 타입은 중앙에서 정의
- 컴포넌트는 타입을 의식하지 않게
