지난 포스팅까지 DELETE 기능을 끝으로 기본적인 CRUD 구현을 모두 마쳤다. 이번 단계부터는 작성한 코드를 엔지니어링 관점에서 분석하고 개선하는 리팩토링 과정을 기록하고자 한다.
본격적인 코드 수정에 앞서, 현재 프로젝트가 가진 구조적 한계와 성능 이슈를 분석했다. 유지보수성과 렌더링 최적화 관점에서 발견된 치명적인 문제점은 크게 3가지다.
가장 큰 문제는 UsersProvider가 소위 'God Object(전지전능한 객체)'가 되어버렸다는 점이다. 하나의 Provider가 성격이 다른 너무 많은 책임을 동시에 지고 있다.
useUsers)input value) 관리 (builtAllUsersValue)alert, confirm 등 사용자 인터랙션 처리비즈니스 로직과 UI 로직이 한곳에 뒤섞이면서 코드의 응집도는 낮아지고 결합도는 높아졌다. 이로 인해 기능 수정 시 사이드 이펙트를 예측하기 어렵고, 유지보수가 힘든 상태가 되었다.
입력 필드(input)의 상태가 최상위 컴포넌트인 UsersProvider에 위치함으로써 심각한 렌더링 비효율이 발생했다.
UsersProvider가 업데이트되면서 하위의 모든 컴포넌트(리스트 전체)가 강제로 리렌더링 된다.
useCallback 등의 의존성 배열 관리 문제를 회피하기 위해, state와 ref를 억지로 동기화하는 기형적인 패턴을 사용하고 있었다.
// 의존성 회피를 위한 수동 동기화
const [newUserValue, setNewUserValue] = useState<PayloadNewUser>(INIT_NEW_USER_VALUE);
const newUserValueRef = useRef<PayloadNewUser>(newUserValue);
useEffect(() => {
newUserValueRef.current = newUserValue; // State 변경 시 Ref에 강제 주입
}, [newUserValue]);
위에서 분석한 문제점들, 특히 '복잡한 상태 로직'과 '최적화' 문제를 해결하기 위해 useReducer를 도입하기로 결정했다. 도입 이유는 다음과 같다.
useReducer를 사용하면 컴포넌트 내부에 흩어져 있던 상태 변경 로직(setState들의 나열)을 reducer라는 외부 함수로 분리할 수 있다. 컴포넌트는 "무엇을 할지"만 요청하고, 구체적인 "어떻게 변경할지"는 리듀서가 담당하게 하여 관심사를 명확히 분리할 수 있다.
useReducer가 제공하는 dispatch 함수는 React로부터 "객체의 주소값이 영원히 변하지 않음"을 보장받는다.
// 부모 컴포넌트
const Parent = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
// dispatch는 리렌더링이 되어도 주소값이 변하지 않는다.
// 따라서 ChildComponent에 props로 넘겨도 불필요한 리렌더링을 유발하지 않는다.
<ChildComponent dispatch={dispatch} />
);
};
useState의 핸들러 함수를 자식에게 넘길 때는 useCallback 처리가 필수적이었지만, dispatch는 그 자체로 최적화되어 있어 자식 컴포넌트의 불필요한 렌더링을 방지하는 데 유리하다.
useReducer는 단순히 렌더링 횟수를 줄이는 도구가 아니라, '서로 연관된 복잡한 상태들'을 '안전하고 예측 가능하게' 관리하기 위한 설계 도구다.
한 번에 모든 코드를 뜯어고치는 것은 위험 부담이 크다. 따라서 수정하고자 하는 영역을 분활하여 리펙토링을 진행할 것이다.
가장 먼저 UsersNewForm (신규 유저 추가 영역)부터 리팩토링을 진행한다. 전체 수정 로직에 비해 상대적으로 범위가 작아, useReducer 패턴을 실험하고 적용하기에 적합하기 때문이다.