-
[Portfolio #3-2] Zustand 도입으로 비동기적 서버 데이터 관리 + React Router & 폴더 구조 리팩토링하기Project/Portfolio Website 2026. 6. 4. 17:44
비동기 데이터 관리를 Zustand로 캡슐화한 뒤, 그 과정에서 드러난 전역 상태 기반 내비게이션의 한계를 직면했다. Zustand가 UI 상태를 충분히 잘 다루었지만, "URL이 없는 페이지 전환"은 새로고침, 뒤로가기, 북마크 모두를 무너뜨렸다. 결국 React Router를 도입하고 폴더 구조까지 Feature-based로 재편하면서 진짜 웹 애플리케이션다운 구조를 갖추게 되었다.
총 세 편 중 3편(리팩토링)을 상태의 성격에 따라 구별하여 작성했다.
- [Portfolio #1] React로 웹사이트 구성하고 Tailwind로 디자인 입히기
- [Portfolio #2] Firebase 연동과 트러블 슈팅 (CRUD & 보안)
- [Portfolio #3-1] Zustand 도입으로 동기적 UI 상태 관리 리팩토링하기
- [Portfolio #3-2] Zustand 도입으로 비동기적 서버 데이터 관리 리팩토링하기 + React Router & 폴더 구조 리팩토링하기
3-1 요약. 동기적 UI 상태 (Synchronous): Projects, Activities 상세 페이지
첫 번째 단계는 즉각적인 반응이 필요한 순수 UI 상태의 관리다.
- 대상: Projects, Activities 페이지의 프로젝트 상세 페이지
- 문제점: 기존에는 상위 컴포넌트에서 isOpen 상태를 관리하다 보니, 깊은 컴포넌트까지 Props를 전달(Prop Drilling)해야 했고, 컴포넌트 코드가 복잡해졌다.
- 해결:
- 전역 관리: 모달의 열림/닫힘 상태를 Store로 옮겨, 어디서든(버튼, 리스트 등) 모달을 제어할 수 있게 만들었다.
- 생명주기 제어: 전역 상태는 페이지를 이동해도 값이 유지되는 특성이 있다. 따라서 useEffect의 Cleanup 함수를 활용해 페이지를 벗어날 때 상태를 reset 하는 로직을 추가하여 UX 디테일을 챙겼다.
3-2 요약. 비동기적 서버 데이터 (Asynchronous) + React Router 도입 + 폴더 구조 리팩토링
두 번째 단계는 서버와의 통신이 필요한 데이터 상태 관리, 그리고 그 과정에서 드러난 전역 상태 기반 내비게이션의 한계를 해결한 과정이다.
- 대상: Feedback 메시지의 댓글 목록 (CRUD 전체) + Projects/Activities 페이지 전환 구조
- 문제점:
- 데이터를 가져오고(Loading), 성공하면 보여주고(Success), 실패하면 에러를 띄우는(Error) 일련의 과정을 컴포넌트 내부에서 처리하니 비즈니스 로직과 UI가 뒤섞였다.
- Zustand로 내비게이션 상태를 관리하다 보니 새로고침/뒤로가기/링크 공유가 동작하지 않는 구조적 한계가 있었다. - 해결:
1. Action 내 비동기 처리: fetchList라는 액션 하나에 '요청 → 대기 → 응답 → 상태 업데이트' 흐름을 캡슐화했다.
2. 관심사 분리: 컴포넌트는 복잡한 통신 과정을 몰라도 된다. 그저 store.fetchList()만 호출하면 끝이다.
3. React Router 도입: useMenuStore/useProjectStore/useActivityStore를 제거하고, URL(/projects/:id, /activities/:id)이 상태를 대신하도록 전환했다.
4. 폴더 구조 재편: components/ 중심 구조에서 pages/, layout/, lib/ 도메인 기반으로 분리했다.
1. useFeedbackStore 설계 — 비동기 상태의 캡슐화
useFeedbackStore에는 서버 데이터 특유의 고민이 담겨있다. 단순한 CRUD를 넘어, 권한 체계(일반 유저의 비밀번호 검증 vs 관리자 직접 삭제)와 낙관적 업데이트 대신 안전한 재조회 전략을 선택했다.
// src/stores/useFeedbackStore.ts interface FeedbackState { list: GuestMessage[]; isAdmin: boolean; isLoading: boolean; fetchList: () => Promise<void>; loginAdmin: (passwordInput: string) => boolean; logoutAdmin: () => void; createFeedback: (data: { name: string; password: string; text: string; isSecret: boolean }) => Promise<boolean>; deleteFeedback: (id: string, passwordInput?: string) => Promise<{ success: boolean; message?: string }>; updateFeedback: (id: string, newText: string) => Promise<boolean>; }
① 로딩 상태를 스토어가 소유한다.기존 코드에는 isLoading 자체가 없었다. 데이터를 가져오는 동안 UI가 아무 피드백도 주지 않았다. 스토어를 도입하면서 isLoading을 스토어가 직접 소유하도록 추가했다. 컴포넌트는 그냥 읽기만 하면 된다.
fetchList: async () => { set({ isLoading: true }); try { const snapshot = await getDocs(q); set({ list: data }); } catch (error) { console.error("Fetch Error:", error); } finally { set({ isLoading: false }); // 성공/실패 무관하게 반드시 해제 } },② 권한 체계를 액션 내부에서 처리한다.
deleteFeedback은
단순 삭제가 아니다. 관리자인지 여부에 따라 분기가 달라지고, 기존에는 이 판단 로직이 컴포넌트 안에서 prompt()와 함께 뒤섞여 있었다. 이것을 액션 안으로 옮겼다.
deleteFeedback: async (id, passwordInput) => { const { list, isAdmin, fetchList } = get(); const targetItem = list.find((item) => item.id === id); if (!targetItem) return { success: false, message: "게시글을 찾을 수 없습니다." }; // 권한 체크: 관리자가 아니면 비밀번호 검증 if (!isAdmin) { if (!passwordInput) return { success: false, message: "비밀번호를 입력해주세요." }; if (passwordInput !== targetItem.password) return { success: false, message: "비밀번호가 일치하지 않습니다." }; } try { await deleteDoc(doc(db, COLLECTION_NAME, id)); await fetchList(); // 삭제 후 목록 재조회 return { success: true }; } catch (error) { return { success: false, message: "삭제 중 오류가 발생했습니다." }; } },2. Before & After — 비동기 로직의 분리
// Before: Feedback.tsx (리팩토링 전) // Firebase 통신, 권한 처리, CRUD 로직이 전부 컴포넌트 안에 있었다 export default function Feedback() { const [list, setList] = useState<GuestMessage[]>([]); const [isAdmin, setIsAdmin] = useState(false); // 관리자 상태도 로컬 useState const refreshList = useCallback(async () => { const data = await getFeedbackList(); // 컴포넌트 파일 상단에 정의한 헬퍼 함수 setList(data); }, []); useEffect(() => { const fetchInitialData = async () => { const data = await getFeedbackList(); setList(data); }; fetchInitialData(); }, []); // 생성/삭제/수정 로직이 전부 여기 정의됨 const handleCreate = async (data) => { await addDoc(collection(db, COLLECTION_NAME), { ...data, createdAt: new Date() }); refreshList(); }; const handleDelete = async (id, correctPassword) => { if (!isAdmin) { const input = prompt("비밀번호를 입력해주세요."); if (input !== correctPassword) { alert("비밀번호가 일치하지 않습니다."); return; } } await deleteDoc(doc(db, COLLECTION_NAME, id)); refreshList(); }; const handleUpdate = async (id, newText) => { await updateDoc(doc(db, COLLECTION_NAME, id), { text: newText, updatedAt: new Date() }); refreshList(); }; return ( <> <FeedbackForm onSubmit={handleCreate} /> {/* props로 내려줘야 함 */} {list.map((item) => ( <FeedbackItem key={item.id} item={item} isAdmin={isAdmin} {/* props 전달 */} onDelete={handleDelete} {/* props 전달 */} onUpdate={handleUpdate} {/* props 전달 */} /> ))} </> ); }// After: FeedbackPage.tsx (리팩토링 후) // 컴포넌트는 UI에만 집중한다 export default function FeedbackPage() { const { list, isLoading, fetchList } = useFeedbackStore(); useEffect(() => { fetchList(); // 그냥 이 한 줄 }, [fetchList]); if (isLoading) return <LoadingSpinner />; return <FeedbackList list={list} />; }컴포넌트가 "어떻게 가져오는지"를 알 필요가 없어졌다. fetchList() 한 줄이면 끝이고, FeedbackItem에 isAdmin, onDelete, onUpdate 세 개를 내려주던 Props Drilling도 사라졌다. 각 하위 컴포넌트가 스토어에서 직접 필요한 것만 꺼내 쓴다.
3. React Router 도입 — Zustand 내비게이션의 한계
비동기 데이터 관리를 마치고 나니, 3-1에서 해결했다고 생각했던 내비게이션 구조의 문제가 눈에 들어왔다.
동작 Zustand 전역 상태 방식 React Router 방식 새로고침 About Me 초기화 현재 페이지 유지 뒤로 가기 동작 안 함 이전 페이지 이동 북마크 / 링크 공유 불가 /projects/proovy 직접 접근 가능 SEO 단일 URL 각 페이지마다 URL useMenuStore에 persist를 써서 새로고침은 막았지만, 나머지는 근본적으로 해결이 안 됐다. URL이 없는 페이지 전환은 브라우저 입장에서 페이지 이동이 아니다. 그래서 React Router를 도입하기로 했다.
① 라우트 구조 설계
// src/App.tsx function App() { return ( <Routes> <Route path="/" element={<PortfolioLayout />}> <Route index element={<Navigate to="/about" replace />} /> <Route path="about" element={<AboutPage />} /> <Route path="projects" element={<ProjectsPage />} /> <Route path="projects/:id" element={<ProjectDetailPage />} /> <Route path="activities" element={<ActivitiesPage />} /> <Route path="activities/:id" element={<ActivityDetailPage />} /> <Route path="feedback" element={<FeedbackPage />} /> <Route path="*" element={<Navigate to="/about" replace />} /> </Route> </Routes> ); }PortfolioLayout은 중첩 라우트의 Shell이다. 사이드바/네비게이션은 고정이고, 내부 콘텐츠 영역만 <Outlet />으로 교체된다.
② NavLink로 활성 상태 처리
// Before: 전역 상태로 활성 탭 추적 const { activeMenu, setActiveMenu } = useMenuStore(); <button onClick={() => setActiveMenu("Projects")} className={activeMenu === "Projects" ? "활성" : "비활성"}> Projects </button> // After: NavLink가 isActive를 자동으로 제공 <NavLink to="/projects" className={({ isActive }) => isActive ? "pb-1 text-black border-b-4 border-black rounded-sm transition-colors" : "pb-1 text-gray-300 hover:text-gray-500 transition-colors cursor-pointer" } > Projects </NavLink>useMemoStore가 하던 일을 URL 자체가 대신한다. 현재 경로가 /prjects 면 Projects 탭이 활성이 된다. 상태를 따로 관리할 필요가 없다.
③ useNavigate + useParams로 상세 페이지 전환
// ProjectsPage.tsx — 카드 클릭 시 URL 이동 const navigate = useNavigate(); <HarangCard onClick={() => navigate("/projects/harang")} /> // ProjectDetailPage.tsx — URL에서 ID 읽기 const { id } = useParams<{ id: string }>(); // id === "harang" → HarangDetail 렌더링 {id === "harang" && <HarangDetail />} {id === "proovy" && <ProovyDetail />}기존의 useProjectStore, useActivityStore가 완전히 삭제됐다. 선택된 항목을 "전역 상태"에 저장할 이유가 없다. URL이 그 역할을 한다.
4. 폴더 구조 리팩토링 — Feature-based Architecture
React Router 도입을 계기로 폴더 구조도 함께 손봤다. 기존의 components/ 중심 구조는 파일이 많아질수록 "이 파일이 어디에 속하는지"를 파악하기 어려웠다.
이전 구조 (역할 기반)
src/ ├── components/ ← 모든 컴포넌트가 한 곳에 │ ├── AboutMe.tsx │ ├── Activities.tsx │ ├── Nav.tsx │ └── Portfolio.tsx ├── feedback/ ├── stores/ └── firebase.ts이후 구조 (도메인 기반)
src/ ├── pages/ ← 각 도메인별 분리 │ ├── about/AboutPage.tsx │ ├── projects/ │ │ ├── ProjectsPage.tsx │ │ ├── ProjectDetailPage.tsx │ │ └── components/details/ │ ├── activities/ │ │ ├── ActivitiesPage.tsx │ │ ├── ActivityDetailPage.tsx │ │ └── components/details/ │ └── feedback/ │ ├── FeedbackPage.tsx │ ├── feedback.constants.ts │ └── feedback.types.ts ├── layout/ ← 레이아웃 전용 │ ├── PortfolioLayout.tsx │ ├── Nav.tsx │ └── Contact.tsx └── lib/ ← 외부 서비스 └── firebase.ts규칙은 단순하다. "이 파일이 어느 도메인의 것인가?"로 위치를 결정한다. HarangDetail.tsx는 projects 도메인의 컴포넌트니까 pages/projects/components/details/에 들어간다. 나중에 Projects 관련 코드를 수정하러 갔을 때 그 폴더 안에서만 찾으면 된다.

5. 트러블 슈팅
① 스크롤 초기화 로직의 이관 문제
- 배경: 3-1에서 useScrollTop 훅을 만들어 각 페이지 진입 시 스크롤을 초기화했으나 React Router 도입 후에는 이 훅이 불필요해졌다.
- 문제: useScrollTop 을 제거했더니 페이지 이동 시 스크롤이 초기화되지 않는 문제가 재발했다. 스크롤의 주체가 window가 아닌 커스텀 div(custom-scrollbar)이었기 때문에 React Router의 기본 ScrollRestoration 도 작동하지 않았다.
- 원인: React Router는 window.scrollTo 기준으로 스크롤을 처리한다. 레이아웃의 스크롤 컨테이너가 별도 div 이면 Router가 이를 감지하지 못한다.
- 해결: useScrollTop 의 로직을 PortfolioLayout 으로 통합했다. useLocation 으로 pathname 변화를 감지하고, scrollRef 로 스크롤 컨테이너를 직접 참조해 초기화한다.
// PortfolioLayout.tsx const { pathname } = useLocation(); const scrollRef = useRef<HTMLDivElement>(null); useEffect(() => { scrollRef.current?.scrollTo({ top: 0, behavior: "instant" }); }, [pathname]); // 스크롤 컨테이너에 ref 연결 <div ref={scrollRef} className="flex-1 h-full md:overflow-y-auto custom-scrollbar"> <Outlet /> </div>결과: 각 페이지가 개별적으로 useScrollTop 을 호출하던 코드가 사라지고, 레이아웃 한 곳에서 일괄 처리된다. 또한 useScrollTop.ts 파일도 삭제됐다.
② git mv 후 편집 내용이 커밋에 반영되지 않는 문제
- 배경: 폴더 구조 변경을 위해 git mv로 파일을 이동하면서 동시에 내부 import 경로를 수정했다.
- 문제: git mv로 이동된 파일을 수정했는데, 첫 커밋에서 import 경로 수정이 반영되지 않았다. 이동 전 내용 그대로 커밋됐고, CI 빌드가 실패했다.
- 원인: git mv는 파일 이동을 자동 스테이징하지만, 이후 수정한 변경은 별도로 git add가 필요하다. 이동(staged) + 수정(unstaged)이 분리된 상태였다.
- 해결: 수정된 파일을 명시적으로 git add 후 재커밋.
git add src/pages/ src/layout/ src/lib/ src/stores/ src/App.tsx src/main.tsx git commit -m "fix: 폴더 이동 후 누락된 import 경로 수정 반영"결과: git mv후 파일 내부를 편집하면 반드시 다시 스테이징해야 한다는 것을 확인. 이동과 수정은 git 입장에서 독립적인 두 가지 변경이다.
③ revert된 브랜치를 다시 올릴 때 git rebase의 함정
- 배경: 리팩토링 PR이 문제가 발생해 main에서 revert됐다. 따라서 수정 후 다시 feat/react-router 브랜치를 main에 올려야 했다.
- 문제: git rebase origin/main을 실행했더니 기존 리팩토링 커밋이 모두 사라지고 빈 브랜치 상태가 됐다.
- 원인: revert란 "이 커밋의 내용을 되돌리는 새 커밋"을 추가하는 것이다. 원래 리팩토링 커밋 자체는 히스토리에 그대로 남아있다. rebase는 "이미 main에 있는 커밋"을 건너뛰기 때문에, 리팩토링 커밋이 main 히스토리에 존재한다고 판단하고 적용하지 않았다.
- 해결: rebase 대신 patch 방식으로 변경 내용을 추출해 다시 적용했다.
# 1. revert 이전과 이후의 diff를 추출 git diff origin/main 5a1af85 > /tmp/react-router-changes.patch # 2. 브랜치를 main 기준으로 초기화 git reset --hard origin/main # 3. patch 적용 git apply /tmp/react-router-changes.patch # 4. 빌드 확인 후 커밋 pnpm build git add -A && git commit -m "refactor: React Router 도입 및 Feature-based 폴더 구조 리팩토링"결과: revert된 커밋이 있는 브랜치에서는 rebase가 아닌 patch로 우회해야 한다. git 히스토리 상 "같은 커밋"으로 인식되는 문제를 피할 수 있다.
6. 결론
이번 리팩토링은 Zustand 도입으로 시작해서, 한계를 직면하고 React Router와 폴더 구조 재편으로 마무리됐다.
- Zustand는 서버 데이터 관리에 적합하다: 비동기 흐름, 권한 로직, 로딩 상태를 액션 안에 캡슐화하면 컴포넌트가 깔끔해진다. 단, 내비게이션 상태는 URL에 맡기는 게 맞다.
- URL은 상태다: 현재 어떤 페이지인지, 어떤 항목을 보고 있는지를 URL이 표현하면 새로고침/뒤로가기/링크 공유가 공짜로 따라온다. Zustand로 이것을 흉내내는 건 브라우저가 이미 잘 하는 일을 직접 다시 구현하는 것이었다.
- 폴더 구조는 아키텍처다: 역할 기반(components/)에서 도메인 기반(pages/, layout/, lib/)으로 바꾸니 파일 탐색이 직관적으로 빨라졌다. "이 기능을 고치려면 어디를 봐야 하지?"라는 질문의 답이 명확해졌다.
- 삭제된 코드가 성과다: useMenuStore, useProjectStore, useActivityStore, useScrollTop, useModalReset — 총 5개의 파일이 삭제됐다. 추가한 코드보다 지운 코드가 많을 때 리팩토링이 제대로 된 것이라고 생각한다.
'Project > Portfolio Website' 카테고리의 다른 글
[Portfolio #3-1] Zustand 도입으로 동기적 UI 상태 관리 리팩토링하기 (0) 2026.01.02 [Portfolio #2] Firebase 연동과 트러블 슈팅 (CRUD & 보안) (0) 2025.12.21 [Portfolio #1] React로 웹사이트 구성하고 Tailwind로 디자인 입히기 (1) 2025.12.17