ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Portfolio #3-2] Zustand 도입으로 비동기적 서버 데이터 관리 + React Router & 폴더 구조 리팩토링하기
    Project/Portfolio Website 2026. 6. 4. 17:44

    비동기 데이터 관리를 Zustand로 캡슐화한 뒤, 그 과정에서 드러난 전역 상태 기반 내비게이션의 한계를 직면했다. Zustand가 UI 상태를 충분히 잘 다루었지만, "URL이 없는 페이지 전환"은 새로고침, 뒤로가기, 북마크 모두를 무너뜨렸다. 결국 React Router를 도입하고 폴더 구조까지 Feature-based로 재편하면서 진짜 웹 애플리케이션다운 구조를 갖추게 되었다.

     

     총 세 편 중 3편(리팩토링)을 상태의 성격에 따라 구별하여 작성했다.

    3-1 요약. 동기적 UI 상태 (Synchronous): Projects, Activities 상세 페이지

     첫 번째 단계는 즉각적인 반응이 필요한 순수 UI 상태의 관리다.
    • 대상: Projects, Activities 페이지의 프로젝트 상세 페이지
    • 문제점: 기존에는 상위 컴포넌트에서 isOpen 상태를 관리하다 보니, 깊은 컴포넌트까지 Props를 전달(Prop Drilling)해야 했고, 컴포넌트 코드가 복잡해졌다.
    • 해결:
      1. 전역 관리: 모달의 열림/닫힘 상태를 Store로 옮겨, 어디서든(버튼, 리스트 등) 모달을 제어할 수 있게 만들었다.
      2. 생명주기 제어: 전역 상태는 페이지를 이동해도 값이 유지되는 특성이 있다. 따라서 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() 한 줄이면 끝이고, FeedbackItemisAdmin, onDelete, onUpdate 세 개를 내려주던 Props Drilling도 사라졌다. 각 하위 컴포넌트가 스토어에서 직접 필요한 것만 꺼내 쓴다.

     

     

    3. React Router 도입 — Zustand 내비게이션의 한계

     

    비동기 데이터 관리를 마치고 나니, 3-1에서 해결했다고 생각했던 내비게이션 구조의 문제가 눈에 들어왔다.

    동작 Zustand 전역 상태 방식 React Router 방식
    새로고침 About Me 초기화 현재 페이지 유지
    뒤로 가기 동작 안 함 이전 페이지 이동
    북마크 / 링크 공유 불가 /projects/proovy 직접 접근 가능
    SEO 단일 URL 각 페이지마다 URL

     

    useMenuStorepersist를 써서 새로고침은 막았지만, 나머지는 근본적으로 해결이 안 됐다. 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개의 파일이 삭제됐다. 추가한 코드보다 지운 코드가 많을 때 리팩토링이 제대로 된 것이라고 생각한다.

     

제목 없는 코딩 블로그