Gitlog & Branch
리펙토링 한 기록은 아래 링크 Branch에서 확인할 수 있다.
- feature/리펙토링 Branch
지난 포스팅에서 UsersNewForm(생성)과 UsersItem(수정)을 리팩토링하며 상태의 위치(State Location)를 바로잡는 것이 얼마나 중요한지 깨달았다. 마지막 삭제(DELETE) 기능 또한 동일한 방법으로 수정할 것이다.
삭제 기능, 특히 다중 선택 삭제는 UI 상태(체크박스)와 데이터 상태(삭제 로딩)가 복잡하게 얽혀 있어 UsersProvider를 거대하게 만들었다. 이번 글에서는 이 복잡한 삭제 로직을 Reducer로 격리하고, Derived State(파생 상태) 패턴을 적용해 코드를 다이어트시킨 과정을 기록한다.
리팩토링 전, 삭제와 관련된 모든 상태와 로직은 UsersProvider에 몰려 있었다. isShowDeleteCheckbox, checkedDeleteItems, isDeleting 같은 상태들이 useState로 선언되어 있었고, 이를 제어하기 위한 핸들러 함수들(handleToggleDeleteCheckbox, onChangeCheckDeleteItems 등)이 Provider를 거대하게 만들고 있었다.
// [Before] UsersProvider.tsx
const [isShowDeleteCheckbox, setIsShowDeleteCheckbox] = useState(false);
const [checkedDeleteItems, setCheckedDeleteItems] = useState<number[]>([]);
const [isAllChecked, setIsAllChecked] = useState(false);
// ...삭제 핸들러 함수 등등...
이 구조는 유지보수를 어렵게 만들 뿐만 아니라, 삭제 기능과 관련 없는 컴포넌트들까지 불필요하게 리렌더링 시키는 성능 문제를 안고 있었다.
가장 먼저 한 일은 UsersProvider에서 삭제 관련 로직을 뜯어내어 userDeleteReducer로 독립시키는 것이었다.
삭제 기능에 필요한 상태들을 UserDeleteState 하나로 묶고, 체크박스 토글이나 삭제 요청 같은 액션들을 Reducer 안에서 처리하도록 변경했다.
// src/reducers/usersReducer.ts
export type UserDeleteState = {
isShowDeleteCheckbox: boolean
targetIds: User['id'][]
checkedIds: User['id'][]
deleteing: User['id'] | 'all' | null
error: string | null
}
export type UserDeleteAction =
| { type: 'SYNC_TARGET_USERS'; payload: { ids: User['id'][] } }
| { type: 'SHOW_CHECKBOX' }
| { type: 'HIDE_CHECKBOX' }
| { type: 'ALL_CHECKED' }
| { type: 'RESET_CHECKED' }
| { type: 'TOGGLE_ITEM'; payload: { id: User['id'] } }
| { type: 'SUBMIT_START'; payload: { id: User['id'] } }
| { type: 'SUBMIT_CHECKED_ITEMS_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_ERROR'; payload: { msg: string } }
| { type: 'RESET' }
export const INIT_USER_DELETE_STATE: UserDeleteState = {
targetIds: [],
isShowDeleteCheckbox: false,
checkedIds: [],
deleteing: null,
error: null,
}
export function userDeleteReducer(state: UserDeleteState, action: UserDeleteAction) {
const { targetIds, checkedIds } = state
switch (action.type) {
case 'SYNC_TARGET_USERS': {
const { ids } = action.payload
return {
...state,
targetIds: ids,
}
}
case 'SHOW_CHECKBOX': {
return {
...state,
isShowDeleteCheckbox: true,
}
}
case 'HIDE_CHECKBOX': {
return {
...state,
isShowDeleteCheckbox: false,
checkedIds: [],
}
}
case 'ALL_CHECKED': {
return {
...state,
checkedIds: targetIds,
}
}
case 'TOGGLE_ITEM': {
const { id } = action.payload
const isChecked = checkedIds.includes(id)
const newCheckedIds = isChecked
? checkedIds.filter((itemId) => itemId !== id)
: [...checkedIds, id]
return {
...state,
checkedIds: newCheckedIds,
}
}
case 'RESET_CHECKED': {
return {
...state,
checkedIds: [],
}
}
case 'SUBMIT_START': {
const { id } = action.payload
return { ...state, deleteing: id }
}
case 'SUBMIT_CHECKED_ITEMS_START': {
return { ...state, deleteing: 'all' as const }
}
case 'SUBMIT_SUCCESS': {
return { ...state, deleteing: null, checkedIds: [], isShowDeleteCheckbox: false }
}
case 'SUBMIT_ERROR': {
const { msg } = action.payload
return { ...state, deleteing: null, error: msg }
}
case 'RESET':
return INIT_USER_DELETE_STATE
default:
return state
}
}
이제 UsersProvider는 삭제 기능에 대해 알 필요가 없어졌다. 단지 Reducer를 제공(Provide)하는 역할만 할 뿐이다.
이번 리팩토링의 핵심은 전체 선택 기능을 구현하는 방식의 변화다. 이전에는 isAllChecked라는 별도의 useState를 두고, useEffect를 써서 checkedItems와 싱크를 맞추느라 고생했다.
리펙토링 하면서 isAllChecked 상태를 과감히 삭제했다. 대신 전체 유저 수와 체크된 ID 수가 같으면 전체 선택된 것으로 렌더링 시점에 계산하도록 바꿨다.
// src/features/users/components/UsersControler.tsx
// Derived State: 상태로 저장하지 않고, 그때그때 계산한다.
const isAllChecked = users.length > 0 && userDeleteState.checkedIds.length === users.length
const handleAllCheck = () => {
if (isAllChecked) {
userDeleteDispatch({ type: 'RESET_CHECKED' })
} else {
// 전체 선택 시 현재 화면의 모든 ID를 액션으로 넘긴다.
userDeleteDispatch({
type: 'ALL_CHECKED',
payload: { ids: users.map((u) => u.id) }
})
}
}
이렇게 하면 useEffect를 쓸 필요가 전혀 없다. 데이터가 변하면 isAllChecked는 알아서 다시 계산된다.
개별 유저 컴포넌트(UsersItem)도 가벼워졌다. 기존에는 각 아이템이 자신의 체크 여부를 useState로 가지고 있으면서 부모와 동기화하려다 보니 useEffect 지옥에 빠졌었다.
이제 UsersItem은 로컬 상태를 가지지 않는다. Reducer에 있는 checkedIds 배열을 구독하고, 자신의 ID가 거기에 있는지만 확인하면 된다.
// src/features/users/components/UsersItem.tsx
const isChecked = userDeleteState.checkedIds.includes(id)
const handleChangeCheckItem = () => {
userDeleteDispatch({ type: 'TOGGLE_ITEM', payload: { id } })
}
POST, Patch, Delete까지 모든 기능을 리팩토링하며 얻은 성과는 명확하다.
관심사의 분리: UsersProvider는 가벼워졌고, 각 기능(생성/수정/삭제)은 각자의 Reducer에서 명확하게 관리된다.
state & ref 안티 패턴 해결: useEffect로 억지 동기화를 하던 코드를 모두 제거하고, Derived State와 Reducer 패턴으로 대체하여 데이터 무결성을 확보했다.
코드의 예측 가능성: 상태가 어디서 변하는지 찾아 헤맬 필요가 없다. 모든 상태 변경은 dispatch(action)을 통해서만 일어난다.
이로써 fetchApi 프로젝트의 핵심 기능인 유저 CRUD에 대한 대규모 리팩토링을 마쳤다.