diff --git a/src/components/home/study-matching-toggle.tsx b/src/components/home/study-matching-toggle.tsx index 56883428..52164ea5 100644 --- a/src/components/home/study-matching-toggle.tsx +++ b/src/components/home/study-matching-toggle.tsx @@ -6,7 +6,7 @@ import { usePatchAutoMatchingMutation, useUserProfileQuery, } from '@/entities/user/model/use-user-profile-query'; -import { usePhoneVerificationStore } from '@/features/phone-verification/model/store'; +import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; import { useAuth } from '@/hooks/common/use-auth'; @@ -20,7 +20,9 @@ export default function StudyMatchingToggle() { const { mutate: patchAutoMatching, isPending } = usePatchAutoMatchingMutation(); - const { isVerified, setVerified } = usePhoneVerificationStore(); + const { isVerified, setVerified } = usePhoneVerificationStatus( + memberId ?? undefined, + ); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const [enabled, setEnabled] = useState(false); diff --git a/src/entities/user/model/use-user-profile-query.ts b/src/entities/user/model/use-user-profile-query.ts index e7283e61..2837f0f5 100644 --- a/src/entities/user/model/use-user-profile-query.ts +++ b/src/entities/user/model/use-user-profile-query.ts @@ -37,7 +37,7 @@ export const usePatchAutoMatchingMutation = () => { const prev = qc.getQueryData(['userProfile', memberId]); if (prev && typeof prev === 'object') { qc.setQueryData(['userProfile', memberId], { - ...(prev as any), + ...prev, autoMatching, }); } diff --git a/src/features/phone-verification/model/use-phone-verification-status.ts b/src/features/phone-verification/model/use-phone-verification-status.ts new file mode 100644 index 00000000..d6f2916c --- /dev/null +++ b/src/features/phone-verification/model/use-phone-verification-status.ts @@ -0,0 +1,128 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useUserProfileQuery } from '@/entities/user/model/use-user-profile-query'; +import { useAuth } from '@/hooks/common/use-auth'; +import { usePhoneVerificationStore } from './store'; + +/** + * @deprecated usePhoneVerificationStore를 직접 사용하지 마세요. + * 대신 usePhoneVerificationStatus 훅을 사용하세요. + * + * 이유: + * - usePhoneVerificationStore는 localStorage 기반으로 서버 상태와 불일치할 수 있음 + * - usePhoneVerificationStatus는 서버 상태를 단일 진실 공급원으로 사용 + * + * @see usePhoneVerificationStatus + */ +export { usePhoneVerificationStore } from './store'; + +/** + * 본인인증 상태를 서버 데이터와 동기화하여 반환하는 훅 + * + * 문제: + * - Zustand의 localStorage 기반 상태는 다른 브라우저/기기에서의 인증 상태를 반영하지 못함 + * - localStorage가 삭제되면 인증이 풀린 것처럼 보임 + * - 다른 계정이 같은 번호로 인증하면 기존 계정의 인증이 해제되지만 클라이언트는 모름 + * + * 해결: + * - 서버의 userProfile.isVerified 또는 memberProfile.tel을 단일 진실 공급원으로 사용 + * - Zustand 상태는 UI 반응성을 위한 캐시로만 활용 + * - 서버 데이터로 Zustand 상태를 자동 동기화 + * + * @param overrideMemberId - 특정 memberId로 조회할 때 사용 (선택) + */ +export function usePhoneVerificationStatus(overrideMemberId?: number) { + const { data: authData } = useAuth(); + const memberId = overrideMemberId ?? authData?.memberId ?? null; + + const { data: userProfile, isLoading: isProfileLoading } = + useUserProfileQuery(memberId ?? 0); + + const { + isVerified: zustandIsVerified, + phoneNumber: zustandPhoneNumber, + setVerified, + reset, + } = usePhoneVerificationStore(); + + // 서버 데이터 기반 인증 상태 계산 + const serverIsVerified = userProfile?.isVerified ?? false; + const serverPhoneNumber = userProfile?.memberProfile?.tel ?? null; + + // 서버 데이터를 우선시하여 최종 인증 상태 결정 + // 서버에 tel이 있으면 인증된 것으로 간주 (isVerified보다 tel 존재 여부가 더 확실한 지표) + const isVerified = useMemo(() => { + // 프로필 로딩 중일 때는 Zustand 상태를 임시로 사용 (UI 깜빡임 방지) + if (isProfileLoading && !userProfile) { + return zustandIsVerified; + } + + // 서버에 전화번호가 있으면 인증 완료 + if (serverPhoneNumber) { + return true; + } + + // 서버의 isVerified 플래그 확인 + if (serverIsVerified) { + return true; + } + + // 서버 데이터가 없으면 미인증 + return false; + }, [ + isProfileLoading, + userProfile, + zustandIsVerified, + serverPhoneNumber, + serverIsVerified, + ]); + + const phoneNumber = useMemo(() => { + if (isProfileLoading && !userProfile) { + return zustandPhoneNumber; + } + + return serverPhoneNumber ?? null; + }, [isProfileLoading, userProfile, zustandPhoneNumber, serverPhoneNumber]); + + // 서버 데이터와 Zustand 상태 동기화 + useEffect(() => { + if (isProfileLoading || !userProfile) return; + + if (serverPhoneNumber) { + // 서버에 전화번호가 있으면 Zustand도 동기화 + if (!zustandIsVerified || zustandPhoneNumber !== serverPhoneNumber) { + setVerified(serverPhoneNumber); + } + } else if (!serverIsVerified) { + // 서버에서 인증이 해제되었으면 Zustand도 초기화 + if (zustandIsVerified) { + reset(); + } + } + }, [ + isProfileLoading, + userProfile, + serverPhoneNumber, + serverIsVerified, + zustandIsVerified, + zustandPhoneNumber, + setVerified, + reset, + ]); + + return { + isVerified, + phoneNumber, + isLoading: isProfileLoading, + // 원본 상태들 (디버깅용) + _serverIsVerified: serverIsVerified, + _serverPhoneNumber: serverPhoneNumber, + _zustandIsVerified: zustandIsVerified, + _zustandPhoneNumber: zustandPhoneNumber, + // Zustand 액션 (인증 완료 시 호출용) + setVerified, + reset, + }; +} diff --git a/src/features/study/group/ui/apply-group-study-modal.tsx b/src/features/study/group/ui/apply-group-study-modal.tsx index 1f2c3b1a..7df7451d 100644 --- a/src/features/study/group/ui/apply-group-study-modal.tsx +++ b/src/features/study/group/ui/apply-group-study-modal.tsx @@ -8,7 +8,7 @@ import { useController, useForm } from 'react-hook-form'; import Button from '@/components/ui/button'; import Checkbox from '@/components/ui/checkbox'; import { Modal } from '@/components/ui/modal'; -import { usePhoneVerificationStore } from '@/features/phone-verification/model/store'; +import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import { GroupStudyDetailResponse } from '../api/group-study-types'; import { @@ -33,7 +33,8 @@ export default function ApplyGroupStudyModal({ onSuccess, }: ApplyGroupStudyModalProps) { const [open, setOpen] = useState(false); - const { isVerified, setVerified } = usePhoneVerificationStore(); + // 서버 상태와 동기화된 인증 상태 사용 + const { isVerified, setVerified } = usePhoneVerificationStatus(); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const handleOpenChange = (isOpen: boolean) => { diff --git a/src/features/study/group/ui/group-study-form-modal.tsx b/src/features/study/group/ui/group-study-form-modal.tsx index 3f922ed7..9df04ea6 100644 --- a/src/features/study/group/ui/group-study-form-modal.tsx +++ b/src/features/study/group/ui/group-study-form-modal.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from 'react'; import { GroupStudyFullResponseDto } from '@/api/openapi'; import { Modal } from '@/components/ui/modal'; -import { usePhoneVerificationStore } from '@/features/phone-verification/model/store'; +import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import GroupStudyForm from './group-study-form'; @@ -57,7 +57,8 @@ export default function GroupStudyFormModal({ refetch: refetchGroupStudyInfo, } = useGroupStudyDetailQuery(groupStudyId!); - const { isVerified, setVerified } = usePhoneVerificationStore(); + // 서버 상태와 동기화된 인증 상태 사용 + const { isVerified, setVerified } = usePhoneVerificationStatus(); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const handleVerificationComplete = (phoneNumber: string) => { diff --git a/src/features/study/participation/ui/reservation-list.tsx b/src/features/study/participation/ui/reservation-list.tsx index 31e54d70..f6bda17a 100644 --- a/src/features/study/participation/ui/reservation-list.tsx +++ b/src/features/study/participation/ui/reservation-list.tsx @@ -8,7 +8,7 @@ import { useUserProfileQuery, } from '@/entities/user/model/use-user-profile-query'; import ProfileDefault from '@/entities/user/ui/icon/profile-default.svg'; -import { usePhoneVerificationStore } from '@/features/phone-verification/model/store'; +import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import ReservationCard from '@/features/study/participation/ui/reservation-user-card'; import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; @@ -36,7 +36,10 @@ export default function ReservationList({ const { data: userProfile } = useUserProfileQuery(memberId ?? 0); const autoMatching = userProfile?.autoMatching ?? false; - const { isVerified, setVerified } = usePhoneVerificationStore(); + // 서버 상태와 동기화된 인증 상태 사용 + const { isVerified, setVerified } = usePhoneVerificationStatus( + memberId ?? undefined, + ); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const firstMemberId = useMemo( diff --git a/src/features/study/participation/ui/start-study-modal.tsx b/src/features/study/participation/ui/start-study-modal.tsx index 19d9b940..582734c6 100644 --- a/src/features/study/participation/ui/start-study-modal.tsx +++ b/src/features/study/participation/ui/start-study-modal.tsx @@ -23,7 +23,7 @@ import { useUpdateUserProfileInfoMutation, } from '@/features/my-page/model/use-update-user-profile-mutation'; -import { usePhoneVerificationStore } from '@/features/phone-verification/model/store'; +import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import { @@ -99,7 +99,8 @@ export default function StartStudyModal({ open, onOpenChange, }: StartStudyModalProps) { - const { isVerified, setVerified } = usePhoneVerificationStore(); + // 서버 상태와 동기화된 인증 상태 사용 + const { isVerified, setVerified } = usePhoneVerificationStatus(memberId); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false); diff --git a/src/widgets/home/todo-list.tsx b/src/widgets/home/todo-list.tsx index 71c04340..333e4e8c 100644 --- a/src/widgets/home/todo-list.tsx +++ b/src/widgets/home/todo-list.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import Button from '@/components/ui/button'; -import { usePhoneVerificationStore } from '@/features/phone-verification/model/store'; +import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import CheckIcon from 'public/icons/check.svg'; @@ -17,7 +17,7 @@ const todoItems = [ ] as const; export default function TodoList({ statusList }: TodoListProps) { - const { isVerified, setVerified } = usePhoneVerificationStore(); + const { isVerified, setVerified } = usePhoneVerificationStatus(); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const handleVerificationComplete = (phoneNumber: string) => {