Gitlog & Branch
리펙토링 한 기록은 아래 링크 Branch에서 확인할 수 있다.
- feature/리펙토링 Branch
지난 포스팅에서 나는 기존 코드의 문제점(God Object, 안티 패턴 등)을 분석하여 POST 기능인 신규 유저 추가(UsersNewForm) 컴포넌트를 리팩토링했다.
이번 포스팅에서는 개별 수정과 다수 수정 기능의 로직이 혼재되어있어 올라간 복잡성을 해결하고, useState와 전역 Provider에 의존하던 명령형 코드를 useReducer를 도입하여 선언적이고 성능 효율적인 구조로 개선한 내용을 기록했다.
초기 구현에서는 수정 중인 모든 유저의 입력값을 UsersContext의 builtAllUsersValue라는 하나의 거대한 객체에 몰아넣고 관리했다.
// [Before] UsersItem.tsx
// 문제점: Input 하나 칠 때마다 Context가 업데이트되고, 구독하는 모든 컴포넌트가 영향을 받음
const { builtAllUsersValue } = useUsersState();
const firstNameValue = builtAllUsersValue[id] ? builtAllUsersValue[id][`first_name_${id}`] : '';
문제점:
리팩토링의 핵심은 다음과 같다.
- Form의 데이터는 해당 컴포넌트(Local)가 관리한다.
- UI 관리는 전역(Global)이 관리한다.
에디터 노출(displayedEditor), 수정 진행중 확인(editing), 전체 유저 에디터 노출(isShowAllEditor), 리셋 트리거(isResetAllValue) 등 상태의 흐름을 제어하는 변수들을 useReducer로 관리했다.
// [After] src/reducers/usersReducer.ts
export type UserEditState = {
isShowAllEditor: boolean
isResetAllValue: boolean
displayedEditor: User['id'][]
editing: User['id'] | 'all' | null
// ...
}
export type UserEditAction =
| { type: 'SHOW_EDITOR'; payload: { id: User['id'] } }
| { type: 'HIDE_EDITOR'; payload: { id: User['id'] } }
| { type: 'OPEN_ALL_EDITOR' }
| { type: 'RESET_START_ALL_VALUE' }
| { type: 'RESET_COMPLETE_ALL_VALUE' }
// ...
export const INIT_USER_EDIT_STATE: UserEditState = {
isShowAllEditor: false,
isResetAllValue: false,
displayedEditor: [],
editing: null,
// ...
}
export function userEditReducer(state: UserEditState, action: UserEditAction) {
const { displayedEditor } = state
switch (action.type) {
case 'SHOW_EDITOR': {
const { id } = action.payload
return {
...state,
displayedEditor: displayedEditor.includes(id) ? displayedEditor : [...displayedEditor, id],
}
}
case 'HIDE_EDITOR': {
const { id } = action.payload
return {
...state,
displayedEditor: displayedEditor.filter((displayedId) => displayedId !== id),
}
}
case 'OPEN_ALL_EDITOR': {
return {
...state,
displayedEditor: [],
isShowAllEditor: true,
}
}
case 'RESET_START_ALL_VALUE': {
return {
...state,
isResetAllValue: true,
isShowAllEditor: false,
}
}
case 'RESET_COMPLETE_ALL_VALUE': {
return {
...state,
isResetAllValue: false,
}
}
case 'SUBMIT_START': {
const { id } = action.payload
return { ...state, editing: id }
}
case 'SUBMIT_SUCCESS': {
const { id, data } = action.payload
return {
...state,
editing: null,
displayedEditor: displayedEditor.filter((displayedId) => displayedId !== id),
data,
}
}
case 'SUBMIT_MODIFIED_USERS_START':
return { ...state, editing: 'all' as const }
case 'SUBMIT_MODIFIED_USERS_SUCCESS': {
const { data } = action.payload
return { ...state, editing: null, isShowAllEditor: false, data }
}
case 'SUBMIT_ERROR': {
const { msg } = action.payload
return { ...state, editing: null, error: msg }
}
case 'RESET':
return INIT_USER_EDIT_STATE
default:
return state
}
}
이제 UsersItem은 전역 스토어(UsersProvider)의 builtAllUsersValue를 바라보지 않는다. 자신의 데이터는 자신이 관리하도록 했다.
// [After] src/features/users/components/UsersItem.tsx
// 개선: 전역 Store 의존성 제거. 오직 Props로 받은 초기값만으로 로컬 상태 생성
const [formData, setFormData] = useState<EditableUserFormObject>(originalData)
이로써 타이핑 시 발생하던 불필요한 전역 리렌더링을 해결하였다.

각 컴포넌트가 로컬 상태(useState)를 가지게 되면서 생긴 고민은 부모(Users)에서 전체 취소 버튼을 눌렀을 때, 자식(UsersItem)들이 가지고 있는 로컬 상태를 어떻게 일괄 초기화할지였다.
보통은 useEffect로 부모의 신호를 감지하지만, 이는 불필요한 연쇄 업데이트를 유발한다.
React의 재조정 메커니즘을 역이용하여 컴포넌트의 key가 바뀌면 상태가 초기화된다는 점을 활용하기로 했다.
// [After] src/features/users/components/UsersList.tsx
<UsersItem
// 리셋 트리거(isResetAllValue)가 바뀌면 컴포넌트를 아예 새로 갈아끼움(Remount)
key={`${user.id}_${userEditState.isResetAllValue ? 'reset' : 'active'}`}
{...user}
/>
이 방식 덕분에 복잡한 상태 동기화 로직 없이, 단 한 줄의 코드로 모든 자식 컴포넌트의 상태를 초기값으로 되돌릴 수 있었다.
다수 수정 시 각 유저의 데이터(FormData)를 제출하는 로직도 개선했다. 기존에는 UI 컴포넌트 안에서 데이터를 제출했었지만, 이번에는 form태그를 활용하여 파이프라인을 구축했다.
form 태그 내 name을 가지고 있는 모든 input 요소의 value를 가져옴id를 key 값으로 한 유저의 데이터를 객체화// [After] src/features/users/containers/UsersContainer.tsx
const handleSubmitAllUsers = async (e: FormEvent<HTMLFormElement>) => {
// 1. 객체 변환
const currentUsersObj = parseFormDataToUsers(new FormData(e.currentTarget));
// 2. 원본과 비교하여 변경된 데이터만 추출
const data = users.reduce((acc, originalUser) => {
const filtered = filterModifiedData({ ... });
if (filtered) acc.push(filtered);
return acc;
}, []);
// 3. API 전송
await onAllModify(data);
};
이번 리팩토링을 통해 얻은 성과는 다음과 같다.
useState 들을 Reducer로 응집시켜, 상태 변화의 로직을 한곳에서 파악할 수 있게 되었다.filterModifiedData라는 동일한 로직을 공유하게 되어, 추후 필드가 추가되어도 한곳만 수정하면 된다.다음 포스팅에서는 마지막인 "삭제하기"기능을 리팩토링한다. 수정 방식은 지금까지 진행한 useReducer로 변경하면서 최적화할 것이다.