-
[Portfolio #3-1] Zustand 도입으로 동기적 UI 상태 관리 리팩토링하기Project/Portfolio Website 2026. 1. 2. 18:29
컴포넌트끼리 의존성이 커짐에 따라 상대적으로 가벼운 Zustand를 통해 Props Drilling 문제를 해결하려 했다. 단순하게 전역 상태 관리를 Zustand로 교체하는 것에 그치지 않고, 상태의 성격(동기 vs 비동기)에 따라 관리 전략을 다르게 가져갔다.
총 세 편 중 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. Zustand 도입 및 스토어 설계
// 스토어 분리 전략 // 모든 상태를 하나의 스토어에 몰아넣지 않고, 역할(Role)에 따라 파일을 분리했다. 이렇게 하면 나중에 특정 기능만 수정하고 싶을 때 해당 파일만 보면 되기 때문에 관리가 훨씬 편하다.
- useMenuStore.ts: 메인 컴포넌트(메뉴) 관리
- useProjectStore.ts: 프로젝트 상세 페이지 관리
- useActivityStore.ts: 대외활동 상세 페이지 관리
- useFeedbackStore.ts: 비동기적 서버 데이터 관리(3-2편)
useMenuStore.ts 의 Nav 새로고침 시 초기화되면 안 되므로, Zustand의 persist 미들웨어를 사용해 sessionStorage에 자동으로 저장되도록 구현했다.
// src/stores/useMenuStore.ts import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; interface MenuState { activeMenu: string; setActiveMenu: (menu: string) => void; } export const useMenuStore = create<MenuState>()( persist( (set) => ({ activeMenu: 'About Me', // 초기값 setActiveMenu: (menu) => set({ activeMenu: menu }), }), { name: 'menu-storage', // 세션 스토리지에 저장될 키 이름 storage: createJSONStorage(() => sessionStorage), // 세션 스토리지 사용 } ) );2. Before & After (리팩토링 결과)
// Before(리팩토링 전) // 부모 컴포넌트에서 useState와 useEffect로 상태를 관리하고, props로 일일이 내려주는 복잡한 구조였다.

// Portfolio.tsx (Before) const [activeMenu, setActiveMenu] = useState(() => { return sessionStorage.getItem("currentMenu") || "About Me"; }); // 직접 useEffect로 스토리지 저장 로직까지 작성해야 했음 useEffect(() => { sessionStorage.setItem("currentMenu", activeMenu); }, [activeMenu]); return ( // ... <Nav activeMenu={activeMenu} onMenuClick={setActiveMenu} /> // ... );// After 리팩토링 후 // props 전달이 사라졌다. useMenuStore 하나로 깔끔하게 상태를 가져올 수 있다.

// Portfolio.tsx (After) import { useMenuStore } from "../stores/useMenuStore"; export default function Portfolio() { const { activeMenu } = useMenuStore(); // 깔끔! return ( // ... <Nav /> {/* Props가 필요 없음 */} // ... ); }// Nav 컴포넌트 변화 // Nav.tsx에서도 직접 스토어에서 데이터를 꺼내 쓴다. 불필요한 interface 정의도 삭제되었다.
// Nav.tsx import { useMenuStore } from "../stores/useMenuStore"; export default function Nav() { const { activeMenu, setActiveMenu } = useMenuStore(); // 직거래 return ( // ... <button onClick={() => setActiveMenu(menu)}>...</button> ); }3. 트러블 슈팅 (Trouble Shooting)
1. 전역 상태를 활용한 페이지 전환 시 스크롤 비동기화 이슈
- 배경: 기존에 useState로 파편화되어 있던 상세 보기 상태를 useActivityStore 로 통합하여 관리하도록 리팩토링함.
- 문제(Trouble): 상태 하나로 리스트와 상세 페이지를 스위칭하게 되면서, 페이지 이동이 아닌 '컴포넌트 교체' 방식이 됨. 이로 인해 리스트에서 스크롤을 내린 상태에서 상세 페이지를 열면 상세 페이지가 중간 지점부터 보이는 UX 결함 발생.
- 원인 분석: 브라우저의 기본 페이지 이동(Link, <a>)이 아니기 때문에 브라우저가 스크롤 위치를 자동으로 초기화해주지 않음. 또한, 스크롤의 주체가 window가 아닌 특정 div(custom-scrollbar)였기 때문에 일반적인 window.scrollTo가 작동하지 않음.
- 해결(Shooting):
- Zustand 상태(activityDetailId)를 구독하는 커스텀 훅(useScrollTop) 제작.
- 단순 호출 시 렌더링 시점 차이로 인해 스크롤이 튀는 현상을 방지하기 위해 useEffect 내에서 DOM을 직접 조작하여 동기적으로 스크롤 위치를 강제 초기화.
- 결과: 전역 상태 기반의 SPA 구조에서도 멀티 페이지 웹사이트와 같은 매끄러운 사용자 경험(UX)을 제공함.
// useScrollTop.ts import { useEffect } from "react"; export const useScrollTop = ( trigger: unknown, selector: string = ".custom-scrollbar" ) => { useEffect(() => { // trigger 값이 변할 때마다(상세 진입 or 목록 복귀 둘 다 포함) 실행 const container = document.querySelector(selector) as HTMLElement | null; if (container) { container.scrollTo({ top: 0, behavior: "instant", }); } }, [trigger, selector]); };2. 메뉴 이동 시 잔류하는 전역 상태 초기화 이슈 (Logic 설계 관련)
- 배경: Zustand는 컴포넌트가 언마운트되어도 상태가 메모리에 유지되는 특성이 있음.
- 문제(Trouble): 사용자가 활동 상세 페이지를 보다가 사이드바 메뉴를 눌러 'About Me'로 이동한 뒤, 다시 'Activities'로 돌아왔을 때 이전의 상세 페이지가 그대로 남아있는 문제 발견.
- 원인 분석: activityDetailId가 전역 스토어에 저장되어 있어, 섹션(메뉴) 이동과 상관없이 데이터가 보존됨. 사용자는 메뉴를 누르면 해당 섹션의 '메인(리스트)'이 나올 것을 기대하지만, 상태는 '상세'에 멈춰있음.
- 해결(Shooting): 메뉴 변경을 감지하여 스토어의 reset 액션을 실행해주는 useModalReset 훅 설계.
- 섹션 진입 시 전역 상태를 초기값(null)으로 되돌리는 로직을 추가하여 '기대하는 첫 화면'이 나오도록 강제함.
- 결과: 전역 상태 관리의 부작용을 방지하고, 내비게이션과 상태 간의 정합성을 맞춤.
import { useEffect } from "react"; export const useModalReset = (resetFn: () => void) => { useEffect(() => { return () => { resetFn(); // 모달이 닫힐 때 상태 초기화 }; }, [resetFn]); };3. 전역 상태에 따른 레이아웃 깨짐과 반응형 조건의 충돌
- 배경: Zustand를 통해 상세 페이지를 별도 모달이 아닌 '인라인 교체' 방식으로 리팩토링하면서 레이아웃 계산 방식이 변경됨.
- 문제(Trouble): 상세 페이지 진입 시 전역 상태에 따라 sticky 헤더가 나타나야 하는데, 부모 요소의 overflow 설정 때문에 헤더가 고정되지 않고 본문과 함께 스크롤되어 사라짐.
- 해결(Shooting): 전역 상태로 렌더링되는 컴포넌트의 위계(Hierarchy)를 재검토.
- 부모 요소에 overflow를 주는 대신, 전역 상태가 활성화되었을 때만 특정 영역에 스크롤을 위임하거나, CSS 구조를 flex-col로 재설계하여 sticky 속성의 기준점을 확보함.
- 결과: 복잡한 전역 상태 전환 속에서도 UI 요소(헤더)의 고정 위치를 보장함.
export default function Activities() { const { activityDetailId } = useActivityStore(); // activityDetailId가 있으면 '상세 페이지'를 보여주고 if (activityDetailId) { return <UMCDetail />; // 리스트는 사라지고 이 내용만 보임 (교체) } // activityDetailId가 null이면 '리스트 페이지'를 보여줌 return <ActivityList />; }4. 결론
이번 리팩토링을 통해 전역 상태 관리(특히 동기적)가 왜 필요한지 체감할 수 있었다.
- 코드 가독성 향상: 컴포넌트들이 본연의 UI 역할에만 집중할 수 있다.
- 유지보수 용이: 상태 관리 로직이 stores 폴더에 모여 있어, 나중에 수정할 때 찾기 쉽다.
- 확장성: 앞으로 페이지가 더 늘어나도 store만 연결하면 되니 부담없다.
다음 편에서는 비동기적 서버 데이터 관리에 대해 다루겠다.
'Project > Portfolio Website' 카테고리의 다른 글
[Portfolio #3-2] Zustand 도입으로 비동기적 서버 데이터 관리 + React Router & 폴더 구조 리팩토링하기 (0) 2026.06.04 [Portfolio #2] Firebase 연동과 트러블 슈팅 (CRUD & 보안) (0) 2025.12.21 [Portfolio #1] React로 웹사이트 구성하고 Tailwind로 디자인 입히기 (1) 2025.12.17