[CRUD 실습 - React + Vite] 리팩토링 (3) - PATCH

Chan의 기술 블로그·2025년 12월 21일

Gitlog & Branch
리펙토링 한 기록은 아래 링크 Branch에서 확인할 수 있다.
- feature/리펙토링 Branch

산출물 링크
- GitHub
- 배포 페이지

지난 포스팅에서 나는 기존 코드의 문제점(God Object, 안티 패턴 등)을 분석하여 POST 기능인 신규 유저 추가(UsersNewForm) 컴포넌트를 리팩토링했다.

이번 포스팅에서는 개별 수정과 다수 수정 기능의 로직이 혼재되어있어 올라간 복잡성을 해결하고, useState와 전역 Provider에 의존하던 명령형 코드를 useReducer를 도입하여 선언적이고 성능 효율적인 구조로 개선한 내용을 기록했다.


1. 문제 분석

Before : 거대한 전역 상태와 렌더링 이슈

초기 구현에서는 수정 중인 모든 유저의 입력값을 UsersContextbuiltAllUsersValue라는 하나의 거대한 객체에 몰아넣고 관리했다.

// [Before] UsersItem.tsx
// 문제점: Input 하나 칠 때마다 Context가 업데이트되고, 구독하는 모든 컴포넌트가 영향을 받음
const { builtAllUsersValue } = useUsersState();
const firstNameValue = builtAllUsersValue[id] ? builtAllUsersValue[id][`first_name_${id}`] : '';

문제점:

  • 불필요한 리렌더링: 1번 유저의 이름 수정하는데, 관련 없는 2번, 3번 유저 컴포넌트까지 Context 업데이트를 감지해야 했다.
  • 관심사의 오염: 단순한 Form Input 상태(Local State)가 비즈니스 로직을 담는 Context(Global State)를 오염시켰다.
  • 명령형 제어: "수정 버튼 누르면 -> 전역 상태에 초기값 세팅하고 -> 모달 열고..." 식의 명령형 로직이 컴포넌트 곳곳에 산재했다.

2. 해결방안

리팩토링의 핵심은 다음과 같다.

  • Form의 데이터는 해당 컴포넌트(Local)가 관리한다.
  • UI 관리는 전역(Global)이 관리한다.

After 1: Reducer로 UI 관리

에디터 노출(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
  }
}

After 2: Input의 상태 위치시키기 (State Colocation)

이제 UsersItem은 전역 스토어(UsersProvider)의 builtAllUsersValue를 바라보지 않는다. 자신의 데이터는 자신이 관리하도록 했다.

// [After] src/features/users/components/UsersItem.tsx
// 개선: 전역 Store 의존성 제거. 오직 Props로 받은 초기값만으로 로컬 상태 생성
const [formData, setFormData] = useState<EditableUserFormObject>(originalData)

이로써 타이핑 시 발생하던 불필요한 전역 리렌더링을 해결하였다.


3. 전체 수정 취소 시, 수백 개의 로컬 상태를 어떻게 초기화할까?

각 컴포넌트가 로컬 상태(useState)를 가지게 되면서 생긴 고민은 부모(Users)에서 전체 취소 버튼을 눌렀을 때, 자식(UsersItem)들이 가지고 있는 로컬 상태를 어떻게 일괄 초기화할지였다.

보통은 useEffect로 부모의 신호를 감지하지만, 이는 불필요한 연쇄 업데이트를 유발한다.

React Key를 이용한 'Hard Reset'

React의 재조정 메커니즘을 역이용하여 컴포넌트의 key가 바뀌면 상태가 초기화된다는 점을 활용하기로 했다.

// [After] src/features/users/components/UsersList.tsx
<UsersItem
  // 리셋 트리거(isResetAllValue)가 바뀌면 컴포넌트를 아예 새로 갈아끼움(Remount)
  key={`${user.id}_${userEditState.isResetAllValue ? 'reset' : 'active'}`}
  {...user}
/>

이 방식 덕분에 복잡한 상태 동기화 로직 없이, 단 한 줄의 코드로 모든 자식 컴포넌트의 상태를 초기값으로 되돌릴 수 있었다.


4. 제출 로직 개선

다수 수정 시 각 유저의 데이터(FormData)를 제출하는 로직도 개선했다. 기존에는 UI 컴포넌트 안에서 데이터를 제출했었지만, 이번에는 form태그를 활용하여 파이프라인을 구축했다.

  1. HTML Form으로 form 태그 내 name을 가지고 있는 모든 input 요소의 value를 가져옴
  2. idkey 값으로 한 유저의 데이터를 객체화
  3. 원본과 비교하여 변경된 데이터만 추출
  4. API 전송
// [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);
};

5. 리팩토링의 성과

이번 리팩토링을 통해 얻은 성과는 다음과 같다.

  • 성능 최적화: Input 입력 시 렌더링 범위를 전역(App)에서 컴포넌트(Item)로 좁혔다.
  • 유지보수성: UI의 상태를 변경하는 useState 들을 Reducer로 응집시켜, 상태 변화의 로직을 한곳에서 파악할 수 있게 되었다.
  • 확장성: 개별 수정과 다수 수정이 filterModifiedData라는 동일한 로직을 공유하게 되어, 추후 필드가 추가되어도 한곳만 수정하면 된다.

앞으로의 계획

다음 포스팅에서는 마지막인 "삭제하기"기능을 리팩토링한다. 수정 방식은 지금까지 진행한 useReducer로 변경하면서 최적화할 것이다.

profile
퍼블리셔에서 프론트앤드로 전향하기

0개의 댓글