1. 초벌 분리의 시작: 하나의 거대한 컴포넌트
처음에는 TodoPage.tsx 내부에 상태 관리, 탭 UI, 입력 폼, 리스트 렌더링까지 모두 들어있는 구조였다. 작성 당시에는 직관적으로 빠르게 구현할 수 있었지만, 다음과 같은 문제점들이 눈에 띄었다.
- 한 파일에 너무 많은 책임이 집중되어 있었다.
- 컴포넌트 재사용이 어려웠다.
- 테스트 및 유지보수가 어려워질 조짐이 보였다.
따라서 다음과 같은 기준을 두고 리팩토링을 시작했다.
2. 단계적 분리 전략
2-1. Atomic 컴포넌트 분리
공통적으로 쓰일 수 있는 Input과 Button을 components/atomic/ 디렉토리로 분리했다.
components/
atomic/
Input.tsx
Button.tsx
이는 UI 재사용성과 일관된 스타일 적용을 위한 첫 단계였다.
2-2. 콘텐츠 컴포넌트 분리
입력 폼, 리스트, 탭 등의 콘텐츠 컴포넌트도 개별 파일로 분리했다.
components/
contents/
TodoList.tsx
TabContent.tsx
TodoForm.tsx
이를 통해 TodoPage.tsx는 상태를 전달하고 UI를 조립하는 역할만 맡도록 가볍게 만들 수 있었다.
3. 상태 분리 - 커스텀 훅 vs 전역 상태
3-1. useTodo() 커스텀 훅 사용 (글로벌 상태, Props)
초기에는 상태를 useTodo()라는 커스텀 훅으로 분리했다. 로컬 상태 관리에 적합하며 컴포넌트 내에서만 상태를 다룬다면 적절하다. 하지만 이 훅은 다음과 같은 단점을 내포하고 있었다:
// TabContent.tsx
const { setShowTodoContent } = useTodo(); // 문제 발생
이렇게 사용하면 useTodo()가 TabContent 내부에서 새로운 상태를 생성하게 된다. React의 커스텀 훅은 상태를 공유하지 않기 때문에 TodoPage와 TabContent는 서로 다른 showTodoContent를 갖게 된다.
더 심각한 문제는 다음과 같은 상황이다.
// TodoPage.tsx
const { showTodoContent } = useTodo();
// TabContent.tsx
const { setShowTodoContent } = useTodo(); // 서로 다른 인스턴스!
이 구조에서는 두 컴포넌트가 서로 독립적인 useState를 각각 갖게 되어 상태가 동기화되지 않는다.
TabContent에서 탭을 클릭해 상태를 변경해도 TodoPage는 그 변경을 감지하지 못하는 문제가 발생한다.
따라서 상태를 공유하려면 반드시 props로 전달하거나, 전역 상태 관리로 전환해야 한다:
<TabContent
showTodoContent={showTodoContent}
setShowTodoContent={setShowTodoContent}
/>
3-2. 전역 상태 관리 도입 고려
TabContent, TodoPage, TodoList 등 다양한 컴포넌트에서 showTodoContent, todoList 등을 공유하고 싶다면 전역 상태 관리 도구(Zustand 등)를 도입하는 것이 더 적절하다. Zustand는 Context처럼 Provider로 감쌀 필요도 없고, 필요한 상태만 구독할 수 있어 리렌더링 최소화도 가능하다.
4. 전역 상태 관리 시 주의할 점
const list = state(
const completedLIst = list.ffilter()⇒
4-1. 파생 상태는 전역으로 관리하지 않는다
Zustand store에 다음과 같이 파생 상태를 넣는 것은 일반적으로 지양된다:
get completedTodoList() {
return get().todoList.filter((item) => item.completed);
}
이런 getter 방식은 Zustand가 내부적으로 감지하지 않기 때문에 해당 값이 바뀌어도 리렌더링이 발생하지 않는다. 대신 파생 상태는 컴포넌트 내에서 useMemo 등을 활용해 계산하는 것이 React스럽고, 리렌더링도 정확하게 일어난다.
4-2. get()은 계산 로직에만 일시적으로 사용한다
Zustand의 get()은 상태를 읽기 위한 함수이며, 내부 addTodo() 같은 동작에서 참조할 때만 잠깐 쓰는 게 좋다. get()을 통해 정의한 필드(get foo() { ... })를 상태처럼 사용하는 것은 권장되지 않는다. 상태 추적과 구독이 불가능하고, 예측하지 못한 버그를 유발할 수 있다.
- Zustand에서 상태로 정의된 get completedTodoList() 같은 getter 함수는
- 상태가 변경되어도 React가 자동으로 리렌더링하지 않는다.
// useTodoStore.ts
import { create } from 'zustand';
export const useTodoStore = create((set, get) => ({
todoList: [],
get completedTodoList() {
// getter 함수
return get().todoList.filter((t) => t.completed);
},
addTodo: () => {
const newTodo = { id: Date.now(), text: 'hi', completed: false };
set((state) => ({
todoList: [...state.todoList, newTodo],
}));
},
}));
4-3. 상태는 진짜 상태만 store에 둔다
todoList, showTodoContent, todoContent처럼 실제로 공유되어야 하며 변경 가능한 값들만 상태로 정의하는 것이 바람직하다. completedList, filteredList, count 등은 상태가 아니라 계산된 값이므로 컴포넌트에서 구해주는 방식이 훨씬 안정적이다.
5. 최원빈이 생각한 몇가지 교훈
- 커스텀 훅은 상태를 생성하지만 공유하지 않는다. 상태 공유가 필요하면 상위에서 내려주는 방식으로 구성하거나 전역 상태 관리 도구를 써야 한다.
- props 전달은 불편해 보여도 구조가 얕을 경우 가장 명시적이고 안전한 방식이다.
- useTodo()에서 반환 타입을 명시한 interface를 도입하면 추론 정확성과 유지보수성이 향상된다.
- 전역 상태 관리 도구(Zustand 등)는 컴포넌트 트리와 무관하게 상태를 유지하므로 복잡성이 올라가는 시점에 도입을 고려한다.
- 전역 상태에는 반드시 "공유할 필요가 있는 진짜 상태"만 두고, 파생 상태는 컴포넌트에서 계산하는 게 기본 원칙이다.
- Zustand에서 get()으로 만든 필드는 구독되지 않으므로 상태 필드로 오해하고 사용하지 않도록 주의한다.
6. 결론
이번 리팩토링 과정을 통해 **SRP(Single Responsibility Principle)**를 지키는 설계가 얼마나 중요한지를 체감했다. 처음에는 작은 파일 하나였지만, 각 컴포넌트를 책임에 따라 분리하고, 상태 흐름을 명확히 제어한 결과 유지보수성이 높고 구조적으로 탄탄한 코드가 완성되었다.
또한 전역 상태 도구를 사용할 때의 주의사항까지 반영하면서, 상태 관리와 파생 계산 사이의 경계를 명확히 하고 React스럽고 확장 가능한 설계를 구현할 수 있었다.
'FrontEnd Develop > Project : TODOMVC' 카테고리의 다른 글
타입스크립트(자바스크립트) 배열 반복(iteration) 메서드 정리 (0) | 2025.05.01 |
---|---|
TypeScript의 객체 타입 지정에 편리한 내장 Record 유틸리티 타입 (0) | 2025.05.01 |
Tailwind 기반 버튼 컴포넌트 재활용하기 (0) | 2025.04.30 |
Zustand X Generic 심층분석. (0) | 2025.04.09 |
Vite + React에서 이상적인 라우팅 구조 설계하기 (0) | 2025.04.02 |