From b8849f2ad866da8241f9b7c0552d7bac38fe1e06 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sat, 11 Apr 2026 20:49:30 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(store):=20useAdminClubStore,=20useAppl?= =?UTF-8?q?icantSSE=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clubId, hasConsented를 관리하는 Zustand store 생성 - SSE 연결 및 applicantsData를 관리하는 useApplicantSSE 훅 생성 --- .../docs/features/store/useAdminClubStore.md | 54 +++++++++++++ frontend/src/hooks/useApplicantSSE.ts | 79 +++++++++++++++++++ frontend/src/store/useAdminClubStore.ts | 30 +++++++ 3 files changed, 163 insertions(+) create mode 100644 frontend/docs/features/store/useAdminClubStore.md create mode 100644 frontend/src/hooks/useApplicantSSE.ts create mode 100644 frontend/src/store/useAdminClubStore.ts diff --git a/frontend/docs/features/store/useAdminClubStore.md b/frontend/docs/features/store/useAdminClubStore.md new file mode 100644 index 000000000..58666e3bb --- /dev/null +++ b/frontend/docs/features/store/useAdminClubStore.md @@ -0,0 +1,54 @@ +# useAdminClubStore — 어드민 전역 상태 관리 + +`AdminClubContext`를 Zustand로 마이그레이션하여 SSE 업데이트로 인한 불필요한 리렌더링을 제거한 store. + +## 배경 + +기존 `AdminClubContext`에는 4개 상태가 묶여 있었다: +- `clubId` — 로그인한 관리자의 클럽 ID +- `hasConsented` — 개인정보 동의 여부 +- `applicantsData` — SSE로 실시간 업데이트되는 지원자 데이터 +- `applicationFormId` — SSE 연결 트리거용 ID + +`applicantsData`가 SSE 이벤트마다 변경되면서 Context를 구독하는 9개 컴포넌트 전부가 리렌더링되었다. `applicantsData`의 실제 소비자는 `ApplicantsTab` 하나뿐이었기 때문에 나머지 8개 컴포넌트의 리렌더링은 불필요했다. + +## 구조 + +``` +src/store/useAdminClubStore.ts — clubId, hasConsented (전역 공유 필요) +src/hooks/useApplicantSSE.ts — applicantsData + SSE 연결 (ApplicantsTab 스코프) +``` + +`applicationFormId`는 store에 포함하지 않는다. SSE 훅의 인자로 직접 전달한다. + +## Selector 훅 + +```typescript +import { useAdminClubId } from '@/store/useAdminClubStore'; +import { useAdminHasConsented } from '@/store/useAdminClubStore'; + +const { clubId, setClubId } = useAdminClubId(); +const { hasConsented, setHasConsented } = useAdminHasConsented(); +``` + +각 selector 훅은 해당 상태만 구독하므로, 다른 상태가 변경되어도 리렌더링이 발생하지 않는다. + +## useApplicantSSE + +```typescript +import { useApplicantSSE } from '@/hooks/useApplicantSSE'; + +const { applicantsData, setApplicantsData } = useApplicantSSE(applicationFormId); +``` + +- `applicationFormId`가 변경되면 SSE 연결을 재수립한다 +- 컴포넌트 언마운트 시 SSE 연결을 자동으로 닫는다 +- 에러 발생 시 2초 후 자동 재연결한다 +- `setApplicantsData`로 초기 데이터(`useGetApplicants` 결과)를 주입할 수 있다 + +## 관련 코드 + +- `src/store/useAdminClubStore.ts` — Zustand store 정의 +- `src/hooks/useApplicantSSE.ts` — SSE 연결 관리 및 applicantsData 상태 +- `src/apis/clubSSE.ts` — EventSource 생성 유틸 +- `src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx` — useApplicantSSE 사용처 diff --git a/frontend/src/hooks/useApplicantSSE.ts b/frontend/src/hooks/useApplicantSSE.ts new file mode 100644 index 000000000..270e6612b --- /dev/null +++ b/frontend/src/hooks/useApplicantSSE.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createApplicantSSE } from '@/apis/clubSSE'; +import { + ApplicantsInfo, + ApplicantStatusEvent, + ApplicationStatus, +} from '@/types/applicants'; + +export const useApplicantSSE = (applicationFormId: string | undefined) => { + const [applicantsData, setApplicantsData] = useState( + null, + ); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + const handleApplicantStatusChange = useCallback( + (event: ApplicantStatusEvent) => { + setApplicantsData((prevData) => { + if (!prevData) return null; + + const updatedApplicants = prevData.applicants.map((applicant) => + applicant.id === event.applicantId + ? { ...applicant, status: event.status, memo: event.memo } + : applicant, + ); + + return { + ...prevData, + applicants: updatedApplicants, + reviewRequired: updatedApplicants.filter( + (a) => a.status === ApplicationStatus.SUBMITTED, + ).length, + scheduledInterview: updatedApplicants.filter( + (a) => a.status === ApplicationStatus.INTERVIEW_SCHEDULED, + ).length, + accepted: updatedApplicants.filter( + (a) => a.status === ApplicationStatus.ACCEPTED, + ).length, + }; + }); + }, + [], + ); + + useEffect(() => { + if (!applicationFormId) return; + + const sseConnect = () => { + eventSourceRef.current?.close(); + + eventSourceRef.current = createApplicantSSE(applicationFormId, { + onStatusChange: handleApplicantStatusChange, + onError: (error) => { + console.error('SSE connection error:', error); + + if (reconnectTimeoutRef.current) return; + + reconnectTimeoutRef.current = window.setTimeout(() => { + reconnectTimeoutRef.current = null; + sseConnect(); + }, 2000); + }, + }); + }; + + sseConnect(); + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, [applicationFormId, handleApplicantStatusChange]); + + return { applicantsData, setApplicantsData }; +}; diff --git a/frontend/src/store/useAdminClubStore.ts b/frontend/src/store/useAdminClubStore.ts new file mode 100644 index 000000000..703509a73 --- /dev/null +++ b/frontend/src/store/useAdminClubStore.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +interface AdminClubStore { + clubId: string | null; + setClubId: (id: string | null) => void; + hasConsented: boolean; + setHasConsented: (value: boolean) => void; +} + +export const useAdminClubStore = create()( + subscribeWithSelector((set) => ({ + clubId: null, + setClubId: (id) => set({ clubId: id }), + hasConsented: true, + setHasConsented: (value) => set({ hasConsented: value }), + })), +); + +export const useAdminClubId = () => { + const clubId = useAdminClubStore((state) => state.clubId); + const setClubId = useAdminClubStore((state) => state.setClubId); + return { clubId, setClubId }; +}; + +export const useAdminHasConsented = () => { + const hasConsented = useAdminClubStore((state) => state.hasConsented); + const setHasConsented = useAdminClubStore((state) => state.setHasConsented); + return { hasConsented, setHasConsented }; +}; From 303d7a7125449ccd3badf5f41efd7adb87f0c2a1 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sat, 11 Apr 2026 20:49:38 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor(admin):=20AdminClubContext?= =?UTF-8?q?=EB=A5=BC=20Zustand=20store=EB=A1=9C=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminClubProvider 제거 (App.tsx) - 9개 컴포넌트를 useAdminClubId, useAdminHasConsented selector 훅으로 교체 - ApplicantsTab에서 useApplicantSSE 사용, SSE 활성화 effect 제거 - AdminClubContext 파일 삭제 --- frontend/src/App.tsx | 9 +- .../common/Header/admin/AdminProfile.tsx | 4 +- frontend/src/context/AdminClubContext.tsx | 135 ------------------ frontend/src/pages/AdminPage/AdminPage.tsx | 8 +- .../auth/PrivateRoute/PrivateRoute.tsx | 9 +- .../ClubCoverEditor/ClubCoverEditor.tsx | 4 +- .../ClubLogoEditor/ClubLogoEditor.tsx | 4 +- .../PersonalInfoConsentModal.tsx | 4 +- .../ApplicantDetailPage.tsx | 4 +- .../tabs/ApplicantsTab/ApplicantsTab.tsx | 14 +- .../ApplicationEditTab/ApplicationEditTab.tsx | 4 +- 11 files changed, 32 insertions(+), 167 deletions(-) delete mode 100644 frontend/src/context/AdminClubContext.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f21bd9e4..c0b1b98d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,6 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from 'styled-components'; import { ScrollToTopButton } from '@/components/common/ScrollToTopButton/ScrollToTopButton'; -import { AdminClubProvider } from '@/context/AdminClubContext'; import { ScrollToTop } from '@/hooks/Scroll/ScrollToTop'; import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab'; import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute'; @@ -120,11 +119,9 @@ const App = () => { path='/admin/*' element={ - - - - - + + + } /> diff --git a/frontend/src/components/common/Header/admin/AdminProfile.tsx b/frontend/src/components/common/Header/admin/AdminProfile.tsx index b6565be39..6b5b94374 100644 --- a/frontend/src/components/common/Header/admin/AdminProfile.tsx +++ b/frontend/src/components/common/Header/admin/AdminProfile.tsx @@ -1,10 +1,10 @@ import DefaultMoadongLogo from '@/assets/images/logos/default_profile_image.svg'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import { useGetClubDetail } from '@/hooks/Queries/useClub'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from '../Header.styles'; const AdminProfile = () => { - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); const { data: clubDetail } = useGetClubDetail(clubId || ''); const { name, logo } = clubDetail || {}; diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx deleted file mode 100644 index f99cf3953..000000000 --- a/frontend/src/context/AdminClubContext.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react'; -import { createApplicantSSE } from '@/apis/clubSSE'; -import { - ApplicantsInfo, - ApplicantStatusEvent, - ApplicationStatus, -} from '@/types/applicants'; - -interface AdminClubContextType { - clubId: string | null; - setClubId: (id: string | null) => void; - applicantsData: ApplicantsInfo | null; - setApplicantsData: (data: ApplicantsInfo | null) => void; - applicationFormId: string | null; - setApplicationFormId: (id: string | null) => void; - hasConsented: boolean; - setHasConsented: (value: boolean) => void; -} - -const AdminClubContext = createContext( - undefined, -); - -export const AdminClubProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const [clubId, setClubId] = useState(null); - const [applicantsData, setApplicantsData] = useState( - null, - ); - const [applicationFormId, setApplicationFormId] = useState( - null, - ); - const [hasConsented, setHasConsented] = useState(true); - const eventSourceRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - - // SSE 이벤트 핸들러 - const handleApplicantStatusChange = useCallback( - (event: ApplicantStatusEvent) => { - setApplicantsData((prevData) => { - if (!prevData) return null; - - const updatedApplicants = prevData.applicants.map((applicant) => - applicant.id === event.applicantId - ? { ...applicant, status: event.status, memo: event.memo } - : applicant, - ); - - return { - ...prevData, - applicants: updatedApplicants, - reviewRequired: updatedApplicants.filter( - (a) => a.status === ApplicationStatus.SUBMITTED, - ).length, - scheduledInterview: updatedApplicants.filter( - (a) => a.status === ApplicationStatus.INTERVIEW_SCHEDULED, - ).length, - accepted: updatedApplicants.filter( - (a) => a.status === ApplicationStatus.ACCEPTED, - ).length, - }; - }); - }, - [], - ); - - useEffect(() => { - if (!applicationFormId) return; - - const sseConnect = () => { - eventSourceRef.current?.close(); - - eventSourceRef.current = createApplicantSSE(applicationFormId, { - onStatusChange: handleApplicantStatusChange, - onError: (error) => { - console.error('SSE connection error:', error); - - if (reconnectTimeoutRef.current) return; - - reconnectTimeoutRef.current = window.setTimeout(() => { - reconnectTimeoutRef.current = null; - sseConnect(); - }, 2000); - }, - }); - }; - - sseConnect(); - - return () => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - eventSourceRef.current?.close(); - eventSourceRef.current = null; - }; - }, [applicationFormId, handleApplicantStatusChange]); - - return ( - - {children} - - ); -}; - -export const useAdminClubContext = () => { - const context = useContext(AdminClubContext); - if (!context) - throw new Error( - 'useAdminClubContext는 AdminClubProvider 내부에서만 사용할 수 있습니다', - ); - return context; -}; diff --git a/frontend/src/pages/AdminPage/AdminPage.tsx b/frontend/src/pages/AdminPage/AdminPage.tsx index 4347feae4..bf746be67 100644 --- a/frontend/src/pages/AdminPage/AdminPage.tsx +++ b/frontend/src/pages/AdminPage/AdminPage.tsx @@ -1,13 +1,17 @@ import { Outlet } from 'react-router-dom'; import Header from '@/components/common/Header/Header'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import { useGetClubDetail } from '@/hooks/Queries/useClub'; import PersonalInfoConsentModal from '@/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal'; import SideBar from '@/pages/AdminPage/components/SideBar/SideBar'; +import { + useAdminClubId, + useAdminHasConsented, +} from '@/store/useAdminClubStore'; import * as Styled from './AdminPage.styles'; const AdminPage = () => { - const { clubId, hasConsented } = useAdminClubContext(); + const { clubId } = useAdminClubId(); + const { hasConsented } = useAdminHasConsented(); const { data: clubDetail, error } = useGetClubDetail(clubId || ''); if (!clubDetail) { diff --git a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx index 2e72b3e5e..e2f36da8b 100644 --- a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx +++ b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx @@ -2,15 +2,18 @@ import { useEffect } from 'react'; import { Navigate } from 'react-router-dom'; import Spinner from '@/components/common/Spinner/Spinner'; import { STORAGE_KEYS } from '@/constants/storageKeys'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import useAuth from '@/hooks/useAuth'; +import { + useAdminClubId, + useAdminHasConsented, +} from '@/store/useAdminClubStore'; // import { useGetApplicants } from '@/hooks/queries/applicants/useGetApplicants'; const PrivateRoute = ({ children }: { children: React.ReactNode }) => { const { isLoading, isAuthenticated, clubId } = useAuth(); - const { setClubId, setHasConsented } = useAdminClubContext(); - // const { setClubId, setApplicantsData } = useAdminClubContext(); + const { setClubId } = useAdminClubId(); + const { setHasConsented } = useAdminHasConsented(); // const { data: applicantsData } = useGetApplicants(clubId ?? ''); useEffect(() => { diff --git a/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx b/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx index b1f69a190..29f9f7f5c 100644 --- a/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx +++ b/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx @@ -2,9 +2,9 @@ import { useRef } from 'react'; import defaultCover from '@/assets/images/logos/default_profile_image.svg'; import { ADMIN_EVENT } from '@/constants/eventName'; import { MAX_FILE_SIZE } from '@/constants/uploadLimit'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useDeleteCover, useUploadCover } from '@/hooks/Queries/useClubCover'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from './ClubCoverEditor.styles'; interface ClubCoverEditorProps { @@ -13,7 +13,7 @@ interface ClubCoverEditorProps { const ClubCoverEditor = ({ coverImage }: ClubCoverEditorProps) => { const trackEvent = useMixpanelTrack(); - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); const fileInputRef = useRef(null); if (!clubId) return null; diff --git a/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx b/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx index ab0252c30..b395dc1f6 100644 --- a/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx +++ b/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx @@ -2,9 +2,9 @@ import React, { useRef } from 'react'; import defaultLogo from '@/assets/images/logos/default_profile_image.svg'; import { ADMIN_EVENT } from '@/constants/eventName'; import { MAX_FILE_SIZE } from '@/constants/uploadLimit'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useDeleteLogo, useUploadLogo } from '@/hooks/Queries/useClubImages'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from './ClubLogoEditor.styles'; interface ClubLogoEditorProps { @@ -14,7 +14,7 @@ interface ClubLogoEditorProps { const ClubLogoEditor = ({ clubLogo }: ClubLogoEditorProps) => { const trackEvent = useMixpanelTrack(); - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); if (!clubId) return null; const fileInputRef = useRef(null); diff --git a/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx b/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx index f853e74b5..6849accd7 100644 --- a/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx +++ b/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx @@ -3,7 +3,7 @@ import { allowPersonalInformation } from '@/apis/auth'; import Button from '@/components/common/Button/Button'; import PortalModal from '@/components/common/Modal/PortalModal'; import { STORAGE_KEYS } from '@/constants/storageKeys'; -import { useAdminClubContext } from '@/context/AdminClubContext'; +import { useAdminHasConsented } from '@/store/useAdminClubStore'; import * as Styled from './PersonalInfoConsentModal.styles'; const GUIDE_ITEMS = [ @@ -25,7 +25,7 @@ interface PersonalInfoConsentModalProps { const PersonalInfoConsentModal = ({ clubName, }: PersonalInfoConsentModalProps) => { - const { setHasConsented } = useAdminClubContext(); + const { setHasConsented } = useAdminHasConsented(); const [loading, setLoading] = useState(false); const handleConsent = async () => { diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx index 099c2b04c..4a094c69d 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx @@ -5,7 +5,6 @@ import PrevApplicantButton from '@/assets/images/icons/prev_applicant.svg'; import Header from '@/components/common/Header/Header'; import Spinner from '@/components/common/Spinner/Spinner'; import { AVAILABLE_STATUSES } from '@/constants/status'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import { useGetApplicants, useUpdateApplicant, @@ -13,6 +12,7 @@ import { import { useGetApplication } from '@/hooks/Queries/useApplication'; import QuestionAnswerer from '@/pages/ApplicationFormPage/components/QuestionAnswerer/QuestionAnswerer'; import QuestionContainer from '@/pages/ApplicationFormPage/components/QuestionContainer/QuestionContainer'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import { ApplicationStatus } from '@/types/applicants'; import { Question } from '@/types/application'; import mapStatusToGroup from '@/utils/mapStatusToGroup'; @@ -51,7 +51,7 @@ const ApplicantDetailPage = () => { const [applicantStatus, setApplicantStatus] = useState( ApplicationStatus.SUBMITTED, ); - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); const { data: applicantsData, isLoading: isApplicantsLoading, diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx index 3f9f0227c..78c49ada1 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx @@ -6,13 +6,14 @@ import selectIcon from '@/assets/images/icons/selectArrow.svg'; import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDown'; import SearchField from '@/components/common/SearchField/SearchField'; import { AVAILABLE_STATUSES } from '@/constants/status'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import { useDeleteApplicants, useGetApplicants, useUpdateApplicant, } from '@/hooks/Queries/useApplicants'; +import { useApplicantSSE } from '@/hooks/useApplicantSSE'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import { Applicant, ApplicationStatus } from '@/types/applicants'; import mapStatusToGroup from '@/utils/mapStatusToGroup'; import * as Styled from './ApplicantsTab.styles'; @@ -23,9 +24,10 @@ const sortOptions = [ ] as const; const ApplicantsTab = () => { - const { clubId, applicantsData, setApplicantsData, setApplicationFormId } = - useAdminClubContext(); + const { clubId } = useAdminClubId(); const { applicationFormId } = useParams<{ applicationFormId: string }>(); + const { applicantsData, setApplicantsData } = + useApplicantSSE(applicationFormId); const navigate = useNavigate(); const statusOptions = AVAILABLE_STATUSES.map((status) => ({ @@ -63,12 +65,6 @@ const ApplicantsTab = () => { (typeof sortOptions)[number] >(sortOptions[0]); - // SSE 연결 활성화 - useEffect(() => { - setApplicationFormId(applicationFormId ?? null); - return () => setApplicationFormId(null); - }, [applicationFormId, setApplicationFormId]); - // 초기 데이터 로드 useEffect(() => { if (fetchData) { diff --git a/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx index 12a73b6e1..976f4f203 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx @@ -7,13 +7,13 @@ import CustomTextArea from '@/components/common/CustomTextArea/CustomTextArea'; import { APPLICATION_FORM } from '@/constants/applicationForm'; import INITIAL_FORM_DATA from '@/constants/initialFormData'; import { queryKeys } from '@/constants/queryKeys'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import { useGetApplication } from '@/hooks/Queries/useApplication'; import QuestionBuilder from '@/pages/AdminPage/components/QuestionBuilder/QuestionBuilder'; import { hasErrors, validateApplicationForm, } from '@/pages/AdminPage/validation/validateApplicationForm'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import { PageContainer } from '@/styles/PageContainer.styles'; import { ApplicationFormData, @@ -30,7 +30,7 @@ const ApplicationEditTab = () => { const { applicationFormId: formId } = useParams<{ applicationFormId?: string; }>(); - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); const { data: existingFormData, From 5c1fada8240bb2407649637cc42219bd0bb3e120 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 12 Apr 2026 16:32:46 +0900 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20=EC=83=81=ED=83=9C=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EB=B6=80=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...00\353\246\254\353\266\200\354\204\234.md" | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 "frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" diff --git "a/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" "b/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" new file mode 100644 index 000000000..63f0bd345 --- /dev/null +++ "b/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" @@ -0,0 +1,417 @@ +# 상태관리 Agent + +UI 상태, 브라우저 상태, 서버 상태를 적절한 도구로 분류하고 관리하는 전담 에이전트 + +## 역할 + +- 새로운 상태 추가 시 적절한 도구 선택 가이드 +- Zustand 스토어 생성 및 수정 +- localStorage / sessionStorage 사용 패턴 유지 +- URL 상태(useSearchParams) 관리 +- React Context 생성 및 관리 +- 상태 중복, 과도한 전역화 방지 + +## 상태 도구 선택 기준 + +새로운 상태를 추가할 때 아래 순서로 판단: + +```text +Q1. 서버에서 오는 데이터인가? + YES → React Query (→ API훅부서에 위임) + +Q2. URL로 공유/북마크/뒤로가기 복원이 필요한가? + YES → useSearchParams / useParams + +Q3. 이 컴포넌트 외에 다른 컴포넌트가 접근해야 하는가? + NO → useState (로컬 UI 상태) + YES ↓ + +Q4. 브라우저를 닫아도 유지해야 하는가? + YES → localStorage 직접 사용 (또는 Zustand + persist localStorage) + +Q5. 탭을 닫을 때까지만 유지하면 되는가? + YES → Zustand + persist + sessionStorage + +Q6. 메모리(새로고침 시 리셋)로 충분한가? + YES → Zustand (persist 없이) + +Q7. 실시간 이벤트(SSE/WebSocket)와 연결되거나 + 특정 서브트리에서만 공유가 필요한가? + YES → React Context +``` + +--- + +## 1. 로컬 UI 상태 → `useState` + +**기준**: 컴포넌트 외부에서 접근할 필요 없는 일시적 상태 + +```typescript +// 모달 열기/닫기 +const [isOpen, setIsOpen] = useState(false); + +// 현재 이미지 인덱스 +const [index, setIndex] = useState(0); + +// 유효성 검사 실패 ID 목록 +const [invalidQuestionIds, setInvalidQuestionIds] = useState([]); +``` + +**하지 말 것**: 모달 하나 때문에 Zustand 스토어 만들지 않기. + +--- + +## 2. 전역 UI 상태 → `Zustand` + +**기준**: 여러 컴포넌트에서 공유되며 서버와 무관한 클라이언트 상태 + +**파일 위치**: `src/store/use도메인Store.ts` + +### 기본 패턴 (메모리만 유지) + +```typescript +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +interface SearchStore { + keyword: string; + setKeyword: (keyword: string) => void; + inputValue: string; + setInputValue: (value: string) => void; + isSearching: boolean; + setIsSearching: (isSearching: boolean) => void; + resetSearch: () => void; +} + +export const useSearchStore = create()( + subscribeWithSelector((set) => ({ + keyword: '', + setKeyword: (keyword) => set({ keyword }), + inputValue: '', + setInputValue: (value) => set({ inputValue: value }), + isSearching: false, + setIsSearching: (isSearching) => set({ isSearching }), + resetSearch: () => set({ keyword: '', inputValue: '', isSearching: false }), + })), +); +``` + +### selector 훅 export 패턴 (권장) + +스토어 파일 내에서 selector 훅을 함께 export해 컴포넌트가 스토어 구조에 직접 의존하지 않게 한다. + +```typescript +export const useAdminClubStore = create()( + subscribeWithSelector((set) => ({ + clubId: null, + setClubId: (id) => set({ clubId: id }), + hasConsented: true, + setHasConsented: (value) => set({ hasConsented: value }), + })), +); + +// 스토어 파일 내에서 selector 훅 export +export const useAdminClubId = () => { + const clubId = useAdminClubStore((state) => state.clubId); + const setClubId = useAdminClubStore((state) => state.setClubId); + return { clubId, setClubId }; +}; + +export const useAdminHasConsented = () => { + const hasConsented = useAdminClubStore((state) => state.hasConsented); + const setHasConsented = useAdminClubStore((state) => state.setHasConsented); + return { hasConsented, setHasConsented }; +}; +``` + +컴포넌트에서는 스토어 직접 접근 대신 selector 훅 사용: + +```typescript +// ❌ 스토어 직접 접근 +const clubId = useAdminClubStore((state) => state.clubId); + +// ✅ selector 훅 사용 +const { clubId, setClubId } = useAdminClubId(); +``` + +### sessionStorage 영속 패턴 (탭 닫을 때까지 유지) + +```typescript +import { create } from 'zustand'; +import { + createJSONStorage, + persist, + subscribeWithSelector, +} from 'zustand/middleware'; + +interface CategoryStore { + selectedCategory: string; + setSelectedCategory: (category: string) => void; +} + +export const useCategoryStore = create()( + subscribeWithSelector( + persist( + (set) => ({ + selectedCategory: 'all', + setSelectedCategory: (category) => set({ selectedCategory: category }), + }), + { + name: 'category-storage', + storage: createJSONStorage(() => sessionStorage), + }, + ), + ), +); +``` + +### 선택적 구독 (성능 최적화) + +```typescript +// 전체 스토어 구독 (불필요한 리렌더링 유발 가능) +const { keyword, setKeyword } = useSearchStore(); + +// 특정 값만 구독 (권장) +const keyword = useSearchStore((state) => state.keyword); +``` + +### 미들웨어 사용 기준 + +| 미들웨어 | 사용 시점 | +| -------------------------- | -------------------------------- | +| `subscribeWithSelector` | 항상 사용 (선택적 구독 가능하게) | +| `persist + sessionStorage` | 탭 닫을 때까지 유지 필요 | +| `persist + localStorage` | 브라우저 닫아도 유지 필요 | + +--- + +## 3. 브라우저 영속 상태 → `localStorage` + +**기준**: 로그아웃/명시적 삭제 전까지 유지해야 하는 단순 데이터 + +**키 관리**: 반드시 `src/constants/storageKeys.ts`에 등록 + +```typescript +// src/constants/storageKeys.ts +export const STORAGE_KEYS = { + ACCESS_TOKEN: 'accessToken', + HAS_CONSENTED_PERSONAL_INFO: 'hasConsentedPersonalInfo', + // 새 키 추가 시 여기에 등록 +} as const; +``` + +**사용 패턴**: + +```typescript +import { STORAGE_KEYS } from '@/constants/storageKeys'; + +// 저장 +localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, token); + +// 조회 +const token = localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN); + +// 삭제 +localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN); +``` + +**현재 사용 중인 키**: + +| 키 | 유지 기간 | 용도 | +| -------------------------------------- | ------------- | ------------------------- | +| `accessToken` | 로그아웃까지 | JWT 인증 토큰 | +| `hasConsentedPersonalInfo` | 영구 | 개인정보 활용 동의 여부 | +| `promotion_last_checked_time` | 영구 | 프로모션 마지막 확인 시간 | +| `moadong_experiments` | 영구 | A/B 테스트 배정값 캐시 | +| `applicationAnswers_{clubId}_{formId}` | 지원 완료까지 | 지원서 임시 저장 | + +--- + +## 4. URL 상태 → `useSearchParams` / `useParams` + +**기준**: 공유/북마크 가능해야 하거나 뒤로가기로 복원되어야 하는 상태 + +### useSearchParams (쿼리 파라미터) + +```typescript +import { useSearchParams } from 'react-router-dom'; + +const [searchParams, setSearchParams] = useSearchParams(); + +// 읽기 +const tabParam = searchParams.get('tab'); + +// 쓰기 (히스토리 추가) +setSearchParams({ tab: 'photos' }); + +// 쓰기 (히스토리 교체 - 뒤로가기 없애기) +setSearchParams({ tab: 'intro' }, { replace: true }); +``` + +**사용 예시**: `/clubDetail/@clubName?tab=photos` + +### useParams (경로 파라미터) + +```typescript +import { useParams } from 'react-router-dom'; + +const { clubName } = useParams<{ clubName: string }>(); +const { clubId, applicationFormId } = useParams<{ + clubId: string; + applicationFormId: string; +}>(); +``` + +**URL vs useState 판단**: + +- 공유/북마크 필요 → URL +- 뒤로가기 복원 필요 → URL +- 그 외 임시 UI 상태 → useState + +--- + +## 5. 실시간 이벤트 → 커스텀 훅 + +**기준**: SSE/WebSocket 연결처럼 생명주기 관리가 필요한 실시간 상태 + +Context가 아닌 **커스텀 훅**으로 처리한다. 연결/해제 로직을 훅 내부에 캡슐화하고, 필요한 컴포넌트에서 직접 호출한다. + +**파일 위치**: `src/hooks/use도메인SSE.ts` + +```typescript +// src/hooks/useApplicantSSE.ts +export const useApplicantSSE = (applicationFormId: string | undefined) => { + const [applicantsData, setApplicantsData] = useState( + null, + ); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + useEffect(() => { + if (!applicationFormId) return; + + const sseConnect = () => { + eventSourceRef.current?.close(); + eventSourceRef.current = createApplicantSSE(applicationFormId, { + onStatusChange: handleApplicantStatusChange, + onError: () => { + // 2초 후 자동 재연결 + reconnectTimeoutRef.current = window.setTimeout(() => { + reconnectTimeoutRef.current = null; + sseConnect(); + }, 2000); + }, + }); + }; + + sseConnect(); + + return () => { + clearTimeout(reconnectTimeoutRef.current ?? undefined); + eventSourceRef.current?.close(); + }; + }, [applicationFormId]); + + return { applicantsData, setApplicantsData }; +}; +``` + +**현재 커스텀 SSE 훅**: + +- `useApplicantSSE` — 지원자 SSE 연결 + 상태 변경 실시간 반영 (`src/hooks/useApplicantSSE.ts`) + +**React Context 사용 기준**: + +Context는 현재 프로젝트에서 사용하지 않는다. 아래 기준으로 판단: + +- 전역 공유 상태 → Zustand +- 실시간 이벤트 → 커스텀 훅 +- 특정 서브트리 공유 + Provider 패턴이 명확히 필요한 경우에만 Context 고려 + +--- + +## 도구별 비교 요약 + +| 상황 | 도구 | +| ------------------------------- | ------------------------------------ | +| 모달 열기/닫기, 임시 UI | `useState` | +| 여러 컴포넌트 공유, 서버 무관 | `Zustand` | +| 세션 동안 유지 (탭 닫으면 삭제) | `Zustand + persist + sessionStorage` | +| 브라우저 닫아도 유지 | `localStorage` (직접) | +| 공유/북마크/뒤로가기 | `useSearchParams` | +| 경로 식별자 | `useParams` | +| 서버 데이터 | `React Query` → API훅부서 | +| 실시간 이벤트 (SSE/WebSocket) | 커스텀 훅 (`useXxxSSE`) | + +--- + +## 주의사항 + +### Zustand에 서버 데이터 담지 않기 + +```typescript +// ❌ 잘못된 예: 서버 데이터를 Zustand에 저장 +const useClubStore = create((set) => ({ + clubList: [], + fetchClubs: async () => { ... } +})); + +// ✅ 올바른 예: 서버 데이터는 React Query +const { data: clubList } = useGetCardList({ ... }); +``` + +### 상태 범위 최소화 + +```typescript +// ❌ 잘못된 예: 컴포넌트 로컬 상태를 전역으로 +const useModalStore = create((set) => ({ + isOpen: false, + toggle: () => set((s) => ({ isOpen: !s.isOpen })), +})); + +// ✅ 올바른 예: 로컬이면 useState +const [isOpen, setIsOpen] = useState(false); +``` + +### localStorage 키는 반드시 상수로 + +```typescript +// ❌ 하드코딩 금지 +localStorage.setItem('myKey', value); + +// ✅ 상수 사용 +localStorage.setItem(STORAGE_KEYS.MY_KEY, value); +``` + +--- + +## 체크리스트 + +새 상태 추가 시 확인: + +- [ ] 서버 데이터인가? → React Query (API훅부서 위임) +- [ ] URL 공유/뒤로가기 필요한가? → useSearchParams +- [ ] 컴포넌트 로컬로 충분한가? → useState +- [ ] 전역 필요 시 적절한 persist 전략을 선택했는가? +- [ ] localStorage 키를 `storageKeys.ts`에 등록했는가? +- [ ] Zustand 스토어에 `subscribeWithSelector`를 적용했는가? +- [ ] 불필요하게 전역 상태로 끌어올리진 않았는가? + +--- + +## 참고 파일 + +- `src/store/useSearchStore.ts` — 메모리 Zustand 스토어 예시 +- `src/store/useCategoryStore.ts` — sessionStorage persist 예시 +- `src/store/useAdminClubStore.ts` — selector 훅 export 패턴 예시 +- `src/hooks/useApplicantSSE.ts` — SSE 커스텀 훅 패턴 예시 +- `src/constants/storageKeys.ts` — localStorage 키 관리 +- `src/constants/queryKeys.ts` — React Query 키 관리 + +## 기술 스택 + +- Zustand (전역 클라이언트 상태) +- React Router v7 `useSearchParams` / `useParams` (URL 상태) +- 커스텀 훅 (SSE 실시간 이벤트 처리) +- localStorage / sessionStorage (브라우저 영속 상태) +- @tanstack/react-query v5 (서버 상태 → API훅부서 담당) From 58b548b8357b8224b97375e270a6e29439ae3d92 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 15 Apr 2026 21:22:05 +0900 Subject: [PATCH 4/7] =?UTF-8?q?docs:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=82=B4=EC=9A=A9=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...4\352\264\200\353\246\254\353\266\200\354\204\234.md" | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git "a/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" "b/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" index 63f0bd345..9fee2a2ba 100644 --- "a/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" +++ "b/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" @@ -35,9 +35,12 @@ Q5. 탭을 닫을 때까지만 유지하면 되는가? Q6. 메모리(새로고침 시 리셋)로 충분한가? YES → Zustand (persist 없이) -Q7. 실시간 이벤트(SSE/WebSocket)와 연결되거나 - 특정 서브트리에서만 공유가 필요한가? - YES → React Context +Q7. 실시간 이벤트(SSE/WebSocket)인가? + YES → 커스텀 훅 (`useXxxSSE`) + +Q8. 특정 서브트리에서만 공유가 필요하고 + Provider 패턴이 명확히 필요한가? + YES → React Context (현재 프로젝트에서는 Zustand 우선 고려) ``` --- From cf1eade2bf85fa224722572f7f4e450b387c06c8 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 15 Apr 2026 21:26:32 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(hooks):=20applicationFormId=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=20applicantsData=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 폼 전환 시 이전 지원자 데이터가 잠깐 노출되는 문제 수정. SSE 재연결 전 setApplicantsData(null)로 상태를 초기화한다. --- frontend/src/hooks/useApplicantSSE.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/hooks/useApplicantSSE.ts b/frontend/src/hooks/useApplicantSSE.ts index 270e6612b..47d2593ac 100644 --- a/frontend/src/hooks/useApplicantSSE.ts +++ b/frontend/src/hooks/useApplicantSSE.ts @@ -45,6 +45,8 @@ export const useApplicantSSE = (applicationFormId: string | undefined) => { useEffect(() => { if (!applicationFormId) return; + setApplicantsData(null); + const sseConnect = () => { eventSourceRef.current?.close(); From 90f9672ea43cbb530fdea96763d3e8df445b675e Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 15 Apr 2026 21:40:40 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor(store):=20hasConsented=EB=A5=BC=20?= =?UTF-8?q?Zustand=EC=97=90=EC=84=9C=20=EB=A1=9C=EC=BB=AC=20useState?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminPage 트리 내에서만 사용되는 상태로 전역 불필요. - AdminPage: localStorage lazy init으로 useState 초기화 - PersonalInfoConsentModal: onConsent prop으로 콜백 수신 - PrivateRoute: setHasConsented 제거 - useAdminClubStore: hasConsented 관련 코드 제거 --- frontend/src/pages/AdminPage/AdminPage.tsx | 19 +++++++++++++------ .../auth/PrivateRoute/PrivateRoute.tsx | 13 ++----------- .../PersonalInfoConsentModal.tsx | 6 +++--- frontend/src/store/useAdminClubStore.ts | 10 ---------- 4 files changed, 18 insertions(+), 30 deletions(-) diff --git a/frontend/src/pages/AdminPage/AdminPage.tsx b/frontend/src/pages/AdminPage/AdminPage.tsx index bf746be67..aa8b3e4a9 100644 --- a/frontend/src/pages/AdminPage/AdminPage.tsx +++ b/frontend/src/pages/AdminPage/AdminPage.tsx @@ -1,17 +1,19 @@ +import { useState } from 'react'; import { Outlet } from 'react-router-dom'; import Header from '@/components/common/Header/Header'; +import { STORAGE_KEYS } from '@/constants/storageKeys'; import { useGetClubDetail } from '@/hooks/Queries/useClub'; import PersonalInfoConsentModal from '@/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal'; import SideBar from '@/pages/AdminPage/components/SideBar/SideBar'; -import { - useAdminClubId, - useAdminHasConsented, -} from '@/store/useAdminClubStore'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from './AdminPage.styles'; const AdminPage = () => { const { clubId } = useAdminClubId(); - const { hasConsented } = useAdminHasConsented(); + const [hasConsented, setHasConsented] = useState( + () => + localStorage.getItem(STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO) === 'true', + ); const { data: clubDetail, error } = useGetClubDetail(clubId || ''); if (!clubDetail) { @@ -23,7 +25,12 @@ const AdminPage = () => { return ( <>
- {!hasConsented && } + {!hasConsented && ( + setHasConsented(true)} + /> + )} diff --git a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx index e2f36da8b..6e344f663 100644 --- a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx +++ b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx @@ -1,30 +1,21 @@ import { useEffect } from 'react'; import { Navigate } from 'react-router-dom'; import Spinner from '@/components/common/Spinner/Spinner'; -import { STORAGE_KEYS } from '@/constants/storageKeys'; import useAuth from '@/hooks/useAuth'; -import { - useAdminClubId, - useAdminHasConsented, -} from '@/store/useAdminClubStore'; +import { useAdminClubId } from '@/store/useAdminClubStore'; // import { useGetApplicants } from '@/hooks/queries/applicants/useGetApplicants'; const PrivateRoute = ({ children }: { children: React.ReactNode }) => { const { isLoading, isAuthenticated, clubId } = useAuth(); const { setClubId } = useAdminClubId(); - const { setHasConsented } = useAdminHasConsented(); // const { data: applicantsData } = useGetApplicants(clubId ?? ''); useEffect(() => { if (clubId) { setClubId(clubId); - const consented = - localStorage.getItem(STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO) === - 'true'; - setHasConsented(consented); } - }, [clubId, setClubId, setHasConsented]); + }, [clubId, setClubId]); // useEffect(() => { // if (clubId && applicantsData) { diff --git a/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx b/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx index 6849accd7..79714d0ce 100644 --- a/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx +++ b/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx @@ -3,7 +3,6 @@ import { allowPersonalInformation } from '@/apis/auth'; import Button from '@/components/common/Button/Button'; import PortalModal from '@/components/common/Modal/PortalModal'; import { STORAGE_KEYS } from '@/constants/storageKeys'; -import { useAdminHasConsented } from '@/store/useAdminClubStore'; import * as Styled from './PersonalInfoConsentModal.styles'; const GUIDE_ITEMS = [ @@ -20,12 +19,13 @@ const GUIDE_ITEMS = [ interface PersonalInfoConsentModalProps { clubName: string; + onConsent: () => void; } const PersonalInfoConsentModal = ({ clubName, + onConsent, }: PersonalInfoConsentModalProps) => { - const { setHasConsented } = useAdminHasConsented(); const [loading, setLoading] = useState(false); const handleConsent = async () => { @@ -34,7 +34,7 @@ const PersonalInfoConsentModal = ({ try { await allowPersonalInformation(); localStorage.setItem(STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO, 'true'); - setHasConsented(true); + onConsent(); } catch (error) { console.error('서비스 동의 실패:', error); alert('동의 처리에 실패했습니다. 다시 시도해주세요.'); diff --git a/frontend/src/store/useAdminClubStore.ts b/frontend/src/store/useAdminClubStore.ts index 703509a73..4f79e4a47 100644 --- a/frontend/src/store/useAdminClubStore.ts +++ b/frontend/src/store/useAdminClubStore.ts @@ -4,16 +4,12 @@ import { subscribeWithSelector } from 'zustand/middleware'; interface AdminClubStore { clubId: string | null; setClubId: (id: string | null) => void; - hasConsented: boolean; - setHasConsented: (value: boolean) => void; } export const useAdminClubStore = create()( subscribeWithSelector((set) => ({ clubId: null, setClubId: (id) => set({ clubId: id }), - hasConsented: true, - setHasConsented: (value) => set({ hasConsented: value }), })), ); @@ -22,9 +18,3 @@ export const useAdminClubId = () => { const setClubId = useAdminClubStore((state) => state.setClubId); return { clubId, setClubId }; }; - -export const useAdminHasConsented = () => { - const hasConsented = useAdminClubStore((state) => state.hasConsented); - const setHasConsented = useAdminClubStore((state) => state.setHasConsented); - return { hasConsented, setHasConsented }; -}; From 6d6dc49be464c9f89d509a2cb2fb7e1e90a3105c Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 15 Apr 2026 21:41:07 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=EC=BB=A4=EB=B0=8B=20=EC=A0=84=20fo?= =?UTF-8?q?rmat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.claude/commands/commit.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/.claude/commands/commit.md b/frontend/.claude/commands/commit.md index 3e883cf48..c0ea0f7dc 100644 --- a/frontend/.claude/commands/commit.md +++ b/frontend/.claude/commands/commit.md @@ -1,6 +1,6 @@ --- description: 세션 작업 기록 + 기능 문서화 + 변경 내용 커밋 -allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(git status), Bash(git diff *), Bash(git log *), Bash(git add *), Bash(git commit *), Read, Write, Edit, Glob, Grep +allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(npm run format), Bash(git status), Bash(git diff *), Bash(git log *), Bash(git add *), Bash(git commit *), Read, Write, Edit, Glob, Grep --- # 작업 지시 @@ -107,15 +107,18 @@ allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(git status), Bash(g 기록이 완료되면 커밋을 수행합니다. -1. `git status`로 변경된 파일 확인 -2. `git diff HEAD`로 모든 변경사항 확인 (또는 `git diff`와 `git diff --staged`를 각각 실행) -3. `git log --oneline -5`로 최근 커밋 스타일 참고 -4. 변경 내용을 분석하여 커밋 메시지 작성 -5. 관련 파일만 `git add`로 스테이징 - - `docs/features/` 문서 파일 포함 +1. `npm run format` 실행하여 코드 포맷팅 +2. `git status`로 변경된 파일 확인 +3. `git diff HEAD`로 모든 변경사항 확인 (또는 `git diff`와 `git diff --staged`를 각각 실행) +4. `git log --oneline -5`로 최근 커밋 스타일 참고 +5. 변경된 파일을 **기능(scope) 단위로 그룹핑** + - 같은 기능/도메인에 속하는 파일끼리 묶음 (예: store 변경, admin UI 변경, hooks 변경 등) + - 논리적으로 독립적인 변경은 별도 커밋으로 분리 + - `docs/features/` 문서 파일은 관련 기능 커밋에 포함 - `dailyNote/`는 gitignore 대상이므로 제외 -6. **커밋 전에 변경 내용과 커밋 메시지를 사용자에게 확인 요청** -7. 사용자 승인 후 커밋 실행 +6. **커밋 전에 그룹핑 계획과 각 커밋 메시지를 사용자에게 확인 요청** +7. 사용자 승인 후 그룹별로 순서대로 커밋 실행 + - 각 그룹: `git add <관련 파일들>` → `git commit -m "..."` 순으로 반복 **커밋 메시지 형식:**