공식 문서를 돌같이 보는 버릇을 고치자!
FE를 꿈꾸면서 상태 관리 라이브러리를 등한시했다. 프로젝트라고 해봤자 개인 프로젝트이고 규모가 토이 수준이다 보니 '굳이?'라는 생각을 많이 했다. React
로 개발하니 Context API
와 useReducer
면 충분했으니까. 하지만 채용 공고에서 상태 관리를 자주 언급되다 보니 익히지 않을 수는 없었다.
그 와중에 나는 공식 문서를 보는데 매우 서툴렀다. 이런 부분도 고치는 의도에서 공식 문서만 보고 토이 프로젝트에 적용해 보는 연습을 계획했다.
제일 처음 고른 공식 문서는 Redux
이다. 상태 관리도 익히면서 공식 문서도 읽을 수 있기 때문에 선택했다.
소재로 사용한 토이 프로젝트는 ToDo App으로, 예~전에 원티드 프리온보딩 인턴십 프론트엔드 11차에서 선발 과제로 했던 프로젝트를 사용했다.
Redux는 자바스크립트 앱을 위한 예측 가능한 상태 컨테이너입니다. - Redux 시작하기
공식 문서의 한 줄처럼 JavaScript
환경이라면 어디에서든 사용할 수 있는 상태 관리 라이브러리이다.
상태를 모아두는 하나의 store
를 컨테이너처럼 두고, 필요한 곳에서 불러와 사용한다(진실은 하나의 근원). 이때, 상태는 불변성을 유지하는 객체이며, 변화는 action
이라는 트리거를 통해 동작한다(상태는 읽기 전용). 트리거되는 동작의 조건은 순수 함수인 reducer
에서 지정한다(변화는 순수 함수로 작성). - 3가지 원칙
공식 문서에서 Redux
를 추천하는 경우는 다음과 같다.
- 앱의 여러 위치에서 필요한 대량의 애플리케이션 상태가 있는 경우
- 앱 상태가 자주 업데이트되는 경우
- 해당 상태를 업데이트하는 로직이 복잡한 경우
- 앱에 중대형 코드베이스가 있고 많은 사람이 작업하는 경우
- 시간이 지남에 따라 해당 상태가 어떻게 업데이트되는지 확인해야 하는 경우 - FAQ - General
이 설명을 보면 주로 규모가 큰 프로젝트에서 사용하는 라이브러리라는 생각이 든다. 나는 이런 경우가 없었기에 강의나 서적에서 배웠어도 고려를 안 했다. 댄 아브라모브도 "React에서 문제가 생길 때까지 Redux를 사용하지 말아라"라고 했다니까.
따라서 내 토이 프로젝트에 Redux를 적용하는 것은 이론상 부적절한 부분이다. 그러나 어쩌겠는가, 배우려면 어디엔가는 적용해야 익히지. 나는 그런 마음으로 사용했다.
강의나 서적에서 Redux + Redux-Thunk 조합이나 Redux + Redux-Saga 조합을 주로 배웠다. 그래서 나도 이렇게 써봐야 하나 고민했는데, 마침 공식 문서에 이런 문장이 있었다.
Redux Toolkit은 Redux 로직을 작성하기 위해 저희가 공식적으로 추천하는 방법입니다. RTK는 Redux 앱을 만들기에 필수적으로 여기는 패키지와 함수들을 포함합니다. 대부분의 Redux 작업을 단순화하고, 흔한 실수를 방지하며, Redux 앱을 만들기 쉽게 해주는 모범 사례를 통해 만들어졌습니다. - Redux 시작하기
무려 공식적으로 추천하는 방법이며 모범 사례를 통해 만들어졌다고 한다. 못 참는 단어의 조합이었다.
가이드에 따라 RTK
와 React-Redux
를 설치했다. - Quick Start
npm install @reduxjs/toolkit react-redux
천천히 살펴 보니 기본 사용법은 배운 것과 큰 차이가 없었다.
// store
import { configureStore } from "@reduxjs/toolkit";
const store = configureStore({
reducer: {},
});
export default store;
상태 컨테이너로 사용할 store를 만들고 export하고, 최상위 트리에서 Provider
에 정의한 store를 추가한다.
// index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { RouterProvider } from "react-router-dom";
import router from "./Router";
import { HelmetProvider } from "react-helmet-async";
// Redux를 사용하기 위한 store와 Provider 추가
import { Provider } from "react-redux";
import store from "./store";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<HelmetProvider>
<Provider store={store}>
<RouterProvider router={router} />
</Provider>
</HelmetProvider>
</React.StrictMode>
);
처음 작성하려니 어디서부터 시작해야 할지 막막했다. 일반 Redux
와 다르게 createSlice
라는 함수를 사용하여 reducer
를 생성했기 때문이다. 그래서 조금 천천히 뜯어봤다. - Quick Start
import { createSlice } from '@reduxjs/toolkit'
export const counterSlice = createSlice({
// store에 저장될 상태의 이름표
name: 'counter',
// 초기 상태 값
initialState: {
value: 0
},
// 상태를 변경하는 reducer들
reducers: {
increment: state => {
state.value += 1
},
decrement: state => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
}
}
})
// disaptch로 사용할 트리거들
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
여기서 눈에 띈 점은 state.value += 1
처럼 상태를 직접 변경하는 듯 보이는 코드였다. '상태는 읽기 전용 아니었나?'라고 생각했으나
createSlice는 Immer 라이브러리를 사용하는 리듀서를 작성할 수 있게 해줍니다. 이를 통해 state.value = 123과 같은 "변형 (mutating)" JS 문법을 spreads 없이도 불변성을 유지하며 업데이트할 수 있습니다. - Redux Toolkit은 무얼 하나요?
위 문장을 통해 이유를 알 수 있었다. immer
라이브러리는 객체를 불변성으로 만들어주는 역할을 하는데, draft
를 통해 draft += 1
과 같이 직접 수정하는 것처럼 작성하여 새 상태를 반환해주는 기능을 제공한다. 뭔가 보기에는 불변성을 위배하는 것 같아 찝찝하지만, 근거도 명확하고 신경 쓸게 덜어져 확실히 편하기는 했다.
여기에 더해서, TypeScript
를 필수로 사용하므로 TS
예제도 함께 살펴 봤다. RTK TypeScript Quick Start - Define Root State and Dispatch Types
import { configureStore } from '@reduxjs/toolkit'
// ...
const store = configureStore({
reducer: {
posts: postsReducer,
comments: commentsReducer,
users: usersReducer
}
})
// 스토어 자체에서 'RootState' 및 'AppDispatch' 유형을 추론합니다.
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch
다른 장소에서 state
가 어떤 요소들과 트리거를 지녔는지 알기 위해 RootState
와 Dispatch
타입을 정의한다. store.getState()
는 Redux
에서 store
에 저장된 상태를 반환해주는 함수로, ReturnType<typeof store.getState>
은 그 함수의 반환 값 타입을 RootState
의 타입으로 지정함을 의미한다. 가령, state:RootState
라면 그 안에는 posts
, comments
, users
가 담겨 있다.
TS
환경에서는 useSelector
나 useDispatch
훅을 새로 정의해야 한다.
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
export const useAppDispatch: () => AppDispatch = useDispatch
이렇게 정의하는 데에는 특별한 이유가 있다고 한다. - RTK TypeScript Quick Start - Define Typed Hooks
useSelector
의 경우 매번 입력할 필요가 없습니다(state: RootState
).useDispatch
의 경우 기본Dispatch
유형은 thunks에 대해 알지 못합니다. thunks를 올바르게 dispatch하려면 store에서 thunk middleware 유형이 포함된 특정 사용자 정의 AppDispatch 유형을 사용하고 이를 useDispatch와 함께 사용해야 합니다. 미리 입력된 useDispatch 훅을 추가하면 필요한 곳에 AppDispatch를 가져오는 것을 잊어버리지 않게 됩니다.
reducer
부분에서도 타입이 필요해진다. initialState
는 물론, 매개변수인 action
의 타입도 지정해줘야 한다. - RTK TypeScript Quick Start - Define Slice State and Action Types
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'
interface CounterState {
value: number
}
const initialState: CounterState = {
value: 0
}
export const counterSlice = createSlice({
name: 'counter',
initialState,
// ...
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})
// export actions...
export const selectCount = (state: RootState) => state.counter.value
export default counterSlice.reducer
이러면 얼추 준비가 끝난 것 같으니 프로젝트에 적용해 보기로 했다.
내 프로젝트에서 상태를 사용하는 곳은 두 곳이었다. 로그인을 담당하는 auth
와 ToDo 서비스를 담당하는 todo
. 그 중 auth
가 간단해 보였기에 먼저 적용해봤다.
import {
createAsyncThunk,
createSlice,
type PayloadAction,
} from "@reduxjs/toolkit";
import { RootState } from "../store";
// 초깃값 설정
const initialState: AuthSliceState = {
response: { ok: false, message: "" },
isLoading: false,
validation: {
emailError: "",
passwordError: "",
},
};
export const authReducer = createSlice({
name: "auth",
initialState,
reducers: {
// 필요에 따라 상태를 초기화하기 위한 로직을 짰다.
// all일 경우에는 전체 초깃값으로 변경하고,
// 아니라면 payload로 받은 속석만 초기화한다.
initialize: (
state,
action: PayloadAction<keyof AuthSliceState | "all">
) => {
if (action.payload === "all") {
state = initialState;
return state;
}
const newState = {
...state,
[action.payload]: initialState[action.payload],
};
return newState;
},
},
});
export const { initialize, validate } = authReducer.actions;
export default authReducer.reducer;
문제는 비동기 통신이었다. 자연스럽게 reducer
내부에서 async/await
을 휘갈기니 에러가 발생했다. actions
의 반환 값은 Promise
타입이 아니기 때문에 당연한 결과였다. 그래서 다시 고민에 빠졌다.
어디를 참고해야 하나 방황하다가 Redux 핵심, Part 5: Async Logic and Data Fetching - Fetching Data with createAsyncThunk
를 발견하고 머리가 맑아졌다.
createAsyncThunk
는 action type과 Promise를 반환하는 콜백함수를 인자로 받는 함수다. 여기서 반환하는 값이 state로 담긴다.
export const fetchSignin = createAsyncThunk(
"auth/fetchSignin",
async (body: RequestBodyType, { rejectWithValue }) => {
try {
const response = await authService.signin(body);
return response;
} catch (e) {
return rejectWithValue(e);
}
}
);
로그인하는 로직만 가져왔다. rejectWithValue
는 api 요청이 거부되었을 때 결과를 반환한다. 에러 핸들링을 위한 처리는 아니었고, 무슨 이유로 요청이 실패해서 확인차 넣었다. 물론 넣은 김에 에러 핸들링이 되어서 남겨 두었다.
이렇게 fetch한 데이터는 일반 reducer
로는 컨트롤할 수 없고, extraReducer
를 사용해야 한다. - Redux 핵심, Part 5: Async Logic and Data Fetching - Reducers and Loading Actions
export const authReducer = createSlice({
name: "auth",
initialState,
reducers: { ... },
extraReducers: (builder) => {
builder
.addCase(fetchSignin.pending, (state) => {
state.isLoading = true;
})
.addCase(fetchSignin.fulfilled, (state, action) => {
state.isLoading = false;
state.response = action.payload;
})
.addCase(fetchSignin.rejected, (state) => {
state.isLoading = false;
});
},
});
extraReducers
의 builder
는 외부에서 작성된 함수의 액션을 실행하여 응답에 대한 상황과 상태를 반환한다. addCase
를 통해 여러 상황을 구성할 수 있으며, createAsyncThunk
로 만든 함수에 있는 pending
, fulfilled
, rejected
를 이용하여 fetch 로딩 상태나 거부 상태를 손쉽게 설정할 수 있다. 이 부분은 참 매력적이었다. 따로 고민 안 해도 패치 시작 부분과 종료, 거부 부분을 설정할 수 있으니 말이다.
// store/index.ts
const store = configureStore({
reducer: {
auth: authReducer,
},
});
export default store;
// reducer/auth.ts
export const selectAuthState = (state: RootState) => state.auth;
이렇게 설정한 리듀서를 store에 등록한다. 그리고 select를 등록해두면 사전에 정의한 useAppSelector
를 사용할 때 selectAuthState
를 매개 변수로 주입해 바로 사용할 수 있다.
적용한 Redux
를 실제로 사용해 기존에 작성한 useSignin
이라는 커스텀 훅의 내용을 수정했다. 기존 코드가 아주 더러웠으므로 변한 부분만 짚어 보려고 한다.
// 이전 코드
function useSignin() {
const { signin } = useSigninContext();
const { state, onFetching, loading, error } = useFetch<{
ok: boolean;
message?: string;
}>(() => signin({ email, password }), true );
const onCompleteSignin = (e: FormEvent) => {
e.preventDefault();
onFetching();
};
return { ... }
}
// 현재 코드
function useSignin() {
const { response, isLoading } = useAppSelector(selectAuthState);
const dispatch = useAppDispatch();
const onCompleteSignin = (e: FormEvent) => {
e.preventDefault();
dispatch(fetchSignin({ email, password }));
};
return { ... }
}
이전에는 class 객체를 만들어 context로 보낸 다음 메서드를 가져와 사용했고, useFetch
라는 커스텀 훅을 만들어 패치한 데이터를 useState
에 담아 그것을 반환하여 가져다 썼다. 로직이 반복되어 나름 효율을 추구한다며 만들었지만, 보기에 좋지는 않았다. 또한, 렌더링 시 실행될 로직과 핸들링할 로직을 분리하느라 트리거로 true
를 사용했더랬다. true
가 있으면 useEffect
를 중단하고 onFetching
으로 핸들링하는 식이었다.
수정한 코드는 기능 면에서 똑같지만 훨씬 간결하고 직관적이다. 트리거도 dispatch
를 통해 내가 핸들링할 수 있다.
지면이 길어져 ToDo
파트는 다음에 이어서 작성해야겠다. 이 글과 큰 차이는 없지만 정리에 의의가 있는 거니까.