From 1bfa19b18ea0e21e363304a7d36541a76215cd6d Mon Sep 17 00:00:00 2001 From: HANSANGWOO <99471821+Han-wo@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:34:30 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/kospi-kosdac/index.ts | 9 +- src/app/layout.tsx | 2 + src/app/main/_components/asset-info.tsx | 9 +- src/app/main/_components/skeleton.tsx | 56 +++++++++ src/app/main/_components/stock-carousel.tsx | 1 + src/app/main/_components/stock-info.tsx | 8 +- src/app/page.tsx | 10 +- src/components/common/auth/refresh-modal.tsx | 94 ++++++++++++++++ src/components/common/modal/index.tsx | 99 ++++++++++++++++ .../nav-bar/_components/nav-menu.tsx | 2 +- src/hooks/use-auth.tsx | 50 ++++++--- src/hooks/use-token-refresh.ts | 106 ++++++++++++++++++ src/store/.gitkeep | 0 src/store/modal-store.ts | 42 +++++++ src/store/token-refresh.ts | 16 +++ src/utils/auth-handler.tsx | 19 ++++ src/utils/next-cookies.ts | 2 +- 17 files changed, 499 insertions(+), 26 deletions(-) create mode 100644 src/app/main/_components/skeleton.tsx create mode 100644 src/components/common/auth/refresh-modal.tsx create mode 100644 src/components/common/modal/index.tsx create mode 100644 src/hooks/use-token-refresh.ts delete mode 100644 src/store/.gitkeep create mode 100644 src/store/modal-store.ts create mode 100644 src/store/token-refresh.ts create mode 100644 src/utils/auth-handler.tsx diff --git a/src/api/kospi-kosdac/index.ts b/src/api/kospi-kosdac/index.ts index 73cbf11..cd2f078 100644 --- a/src/api/kospi-kosdac/index.ts +++ b/src/api/kospi-kosdac/index.ts @@ -12,8 +12,12 @@ interface StockData { async function getInitialStockData(): Promise { try { const [kospiRes, kosdaqRes] = await Promise.all([ - fetch(`${process.env.NEXT_PUBLIC_API_URL}/home/index/kospi`), - fetch(`${process.env.NEXT_PUBLIC_API_URL}/home/index/kosdaq`), + fetch(`${process.env.NEXT_PUBLIC_API_URL}/home/index/kospi`, { + cache: "no-store", + }), + fetch(`${process.env.NEXT_PUBLIC_API_URL}/home/index/kosdaq`, { + cache: "no-store", + }), ]); if (!kospiRes.ok || !kosdaqRes.ok) { @@ -37,4 +41,5 @@ async function getInitialStockData(): Promise { }; } } + export default getInitialStockData; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8338230..02fc529 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import type { Metadata } from "next"; import NavBar from "@/components/nav-bar"; import AuthInitializer from "@/provider/AuthInitializer"; +import AuthRefreshHandler from "@/utils/auth-handler"; import Providers from "./provider"; @@ -26,6 +27,7 @@ export default async function RootLayout({ +
{children}
diff --git a/src/app/main/_components/asset-info.tsx b/src/app/main/_components/asset-info.tsx index 9af5bb5..1aa1a5c 100644 --- a/src/app/main/_components/asset-info.tsx +++ b/src/app/main/_components/asset-info.tsx @@ -8,6 +8,8 @@ import { useAuth } from "@/hooks/use-auth"; import coinsIcon from "@/images/coin.png"; import shieldIcon from "@/images/shield.png"; +import { AssetInfoSkeleton } from "./skeleton"; + interface AssetResponse { asset: string; } @@ -33,7 +35,8 @@ const formatKoreanCurrency = (amount: number) => { const INITIAL_ASSET = 100_000_000; export default function AssetInfo() { - const { isAuthenticated, memberNickName, token, clearAuth } = useAuth(); + const { isAuthenticated, memberNickName, token, clearAuth, isInitialized } = + useAuth(); const [assetInfo, setAssetInfo] = useState(null); useEffect(() => { @@ -95,6 +98,10 @@ export default function AssetInfo() { await clearAuth(); }; + if (!isInitialized) { + return ; + } + if (!isAuthenticated) { return (
diff --git a/src/app/main/_components/skeleton.tsx b/src/app/main/_components/skeleton.tsx new file mode 100644 index 0000000..48219b5 --- /dev/null +++ b/src/app/main/_components/skeleton.tsx @@ -0,0 +1,56 @@ +export function AssetInfoSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export function MyStockInfoSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((idx) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/app/main/_components/stock-carousel.tsx b/src/app/main/_components/stock-carousel.tsx index 7ad182a..04bb05b 100644 --- a/src/app/main/_components/stock-carousel.tsx +++ b/src/app/main/_components/stock-carousel.tsx @@ -22,6 +22,7 @@ function StockIndexCarousel({ initialData }: StockIndexCarouselProps) { initialData, refetchInterval: 300000, refetchOnWindowFocus: false, + retry: 1, }); return ( diff --git a/src/app/main/_components/stock-info.tsx b/src/app/main/_components/stock-info.tsx index 97eb872..b022888 100644 --- a/src/app/main/_components/stock-info.tsx +++ b/src/app/main/_components/stock-info.tsx @@ -8,6 +8,8 @@ import { TableBody } from "@/components/common/table"; import { useAuth } from "@/hooks/use-auth"; import magnifierIcon from "@/images/stockInfo.png"; +import { MyStockInfoSkeleton } from "./skeleton"; + interface StockHolding { stockName: string; currentPrice: number; @@ -87,7 +89,7 @@ function StockTable({ data }: { data: StockHolding[] }) { } export default function MyStockInfo() { - const { isAuthenticated, token } = useAuth(); + const { isAuthenticated, token, isInitialized } = useAuth(); const [stockCount, setStockCount] = useState(null); const [stockHoldings, setStockHoldings] = useState([]); @@ -130,6 +132,10 @@ export default function MyStockInfo() { } }, [isAuthenticated, token]); + if (!isInitialized) { + return ; + } + if (!isAuthenticated || stockCount === "0") { return (
diff --git a/src/app/page.tsx b/src/app/page.tsx index 6253f49..b44a5d6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,7 +12,14 @@ import StockIndexCarousel from "@/app/main/_components/stock-carousel"; import MyStockInfo from "@/app/main/_components/stock-info"; export default async function Home() { - const queryClient = new QueryClient(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + retry: 1, + }, + }, + }); const initialStockData = await getInitialStockData(); await queryClient.prefetchQuery({ @@ -52,7 +59,6 @@ export default async function Home() {
- {/* 우측 사이드바 */}
diff --git a/src/components/common/auth/refresh-modal.tsx b/src/components/common/auth/refresh-modal.tsx new file mode 100644 index 0000000..6152b31 --- /dev/null +++ b/src/components/common/auth/refresh-modal.tsx @@ -0,0 +1,94 @@ +"use client"; + +import clsx from "clsx"; +import { useEffect } from "react"; + +import BaseModal from "@/components/common/modal"; + +interface RefreshModalProps { + isOpen: boolean; + onClose: () => void; + remainingRefreshes: number; + onAccept: () => void; + onDecline: () => void; +} + +export default function RefreshModal({ + isOpen, + onClose, + remainingRefreshes, + onAccept, + onDecline, +}: RefreshModalProps) { + useEffect(() => { + if (!isOpen) { + onClose(); + } + }, [isOpen, onClose]); + + const handleRefresh = () => { + onAccept(); + onClose(); + }; + + const handleLogout = () => { + onClose(); + onDecline(); + }; + + return ( + +
+
+

세션 연장

+
+ +
+

+ 로그인 세션이 5분 후에 만료됩니다. +
+ 세션을 연장하시겠습니까? +

+
+ +
+

+ 남은 연장 횟수:{" "} + {remainingRefreshes}회 +

+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/common/modal/index.tsx b/src/components/common/modal/index.tsx new file mode 100644 index 0000000..04a3a87 --- /dev/null +++ b/src/components/common/modal/index.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect } from "react"; + +interface BaseModalProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +} + +const overlayVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { duration: 0.2 }, + }, +}; + +const modalVariants = { + hidden: { + opacity: 0, + scale: 0.75, + y: 20, + }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + type: "spring", + stiffness: 300, + damping: 30, + }, + }, + exit: { + opacity: 0, + scale: 0.75, + y: 20, + transition: { duration: 0.2 }, + }, +}; + +export default function BaseModal({ + isOpen, + onClose, + children, +}: BaseModalProps) { + useEffect(() => { + if (!isOpen) { + return undefined; + } + + function handleEscape(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + } + } + + document.body.style.overflow = "hidden"; + window.addEventListener("keydown", handleEscape); + + return function cleanupModalEffect() { + window.removeEventListener("keydown", handleEscape); + document.body.style.overflow = "unset"; + }; + }, [isOpen, onClose]); + + return ( + + {isOpen && ( + + + + {children} + + + )} + + ); +} diff --git a/src/components/nav-bar/_components/nav-menu.tsx b/src/components/nav-bar/_components/nav-menu.tsx index 43d378e..8c71755 100644 --- a/src/components/nav-bar/_components/nav-menu.tsx +++ b/src/components/nav-bar/_components/nav-menu.tsx @@ -10,7 +10,7 @@ import ContentIcon from "@/public/icons/contents.svg"; import ContentActiveIcon from "@/public/icons/contents-active.svg"; import HomeIcon from "@/public/icons/home.svg"; import HomeActiveIcon from "@/public/icons/home-active.svg"; -import LogoIcon from "@/public/icons/logo.svg"; +import LogoIcon from "@/public/icons/Logo.svg"; import AccountIcon from "@/public/icons/mypage.svg"; import AccountActiveIcon from "@/public/icons/mypage-active.svg"; diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx index 677cf23..5265cb0 100644 --- a/src/hooks/use-auth.tsx +++ b/src/hooks/use-auth.tsx @@ -17,6 +17,7 @@ interface AuthStore { memberName: string | null; memberNickName: string | null; isAuthenticated: boolean; + isInitialized: boolean; setAuth: (response: LoginResponse) => Promise; clearAuth: () => Promise; initAuth: () => Promise; @@ -30,12 +31,12 @@ interface AuthStore { * import { useAuth } from '@/hooks/useAuth'; * * function LoginComponent() { - * const { setToken } = useAuth(); + * const { setAuth } = useAuth(); * * const handleLogin = async () => { * const response = await fetch('/api/login'); - * const { token } = await response.json(); - * await setToken(token); // 로그인 후 토큰 저장 + * const data = await response.json(); + * await setAuth(data); // 로그인 후 인증 정보 저장 * }; * } * @@ -54,22 +55,22 @@ interface AuthStore { * } * * @example - * // 3. 컴포넌트 마운트시 토큰 초기화 + * // 3. 컴포넌트 마운트시 인증 상태 초기화 * function App() { - * const { initToken } = useAuth(); + * const { initAuth } = useAuth(); * * useEffect(() => { - * initToken(); // 페이지 로드시 쿠키에서 토큰 복원 + * initAuth(); // 페이지 로드시 쿠키에서 인증 정보 복원 * }, []); * } * * @example - * // 4. 로그아웃시 토큰 제거 + * // 4. 로그아웃시 인증 정보 제거 * function LogoutButton() { - * const { clearToken } = useAuth(); + * const { clearAuth } = useAuth(); * * const handleLogout = async () => { - * await clearToken(); // 로그아웃시 토큰 삭제 + * await clearAuth(); // 로그아웃시 인증 정보 삭제 * }; * } */ @@ -79,6 +80,7 @@ export const useAuth = create((set) => ({ memberName: null, memberNickName: null, isAuthenticated: false, + isInitialized: false, // 초기값은 초기화 중 setAuth: async (response: LoginResponse) => { const { token, memberName, memberNickName } = response; @@ -91,6 +93,7 @@ export const useAuth = create((set) => ({ memberName, memberNickName, isAuthenticated: true, + isInitialized: true, // 로그인 시 초기화 완료 }); }, @@ -104,19 +107,30 @@ export const useAuth = create((set) => ({ memberName: null, memberNickName: null, isAuthenticated: false, + isInitialized: true, // 로그아웃 시 초기화 완료 }); }, initAuth: async () => { - const token = await getCookie("token"); - const memberName = await getCookie("memberName"); - const memberNickName = await getCookie("memberNickName"); + try { + // 초기화 시작할 때는 false로 설정 + set({ isInitialized: false }); - set({ - token, - memberName, - memberNickName, - isAuthenticated: !!token, - }); + const token = await getCookie("token"); + const memberName = await getCookie("memberName"); + const memberNickName = await getCookie("memberNickName"); + + set({ + token, + memberName, + memberNickName, + isAuthenticated: !!token, + isInitialized: true, // 초기화 완료 + }); + } catch (error) { + // 에러가 나도 초기화는 완료 처리 + set({ isInitialized: true }); + throw error; + } }, })); diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts new file mode 100644 index 0000000..1c641a0 --- /dev/null +++ b/src/hooks/use-token-refresh.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useAuth } from "@/hooks/use-auth"; +import { setCookie } from "@/utils/next-cookies"; + +const MAX_REFRESH_COUNT = 4; +const SESSION_DURATION = 30 * 60 * 1000; // 30분 +const MODAL_SHOW_BEFORE = 5 * 60 * 1000; // 5분 + +interface UseTokenRefreshReturn { + isModalOpen: boolean; + remainingRefreshes: number; + onClose: () => void; + onAccept: () => void; + onDecline: () => void; +} + +export default function useTokenRefresh(): UseTokenRefreshReturn { + const { token, memberName, memberNickName, clearAuth } = useAuth(); + const router = useRouter(); + const refreshCountRef = useRef(0); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); + const sessionTimerRef = useRef(); + const responseTimerRef = useRef(); + + const handleLogout = useCallback(async () => { + if (responseTimerRef.current) clearTimeout(responseTimerRef.current); + if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current); + setIsModalOpen(false); + await clearAuth(); + router.push("/login"); + router.refresh(); + }, [clearAuth, router]); + + const startResponseTimer = useCallback(() => { + if (responseTimerRef.current) clearTimeout(responseTimerRef.current); + + setIsWaitingForResponse(true); + responseTimerRef.current = setTimeout(() => { + if (isModalOpen) { + handleLogout(); + } + }, MODAL_SHOW_BEFORE); + }, [handleLogout, isModalOpen]); + + const startSessionTimer = useCallback(() => { + if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current); + + sessionTimerRef.current = setTimeout(() => { + if (refreshCountRef.current < MAX_REFRESH_COUNT) { + setIsModalOpen(true); + startResponseTimer(); + } else { + handleLogout(); + } + }, SESSION_DURATION - MODAL_SHOW_BEFORE); + }, [handleLogout, startResponseTimer]); + + const handleRefreshAccept = useCallback(async () => { + if (refreshCountRef.current >= MAX_REFRESH_COUNT) { + handleLogout(); + return; + } + + if (token) { + await setCookie("token", token); + if (memberName) await setCookie("memberName", memberName); + if (memberNickName) await setCookie("memberNickName", memberNickName); + } + + setIsWaitingForResponse(false); + if (responseTimerRef.current) clearTimeout(responseTimerRef.current); + + refreshCountRef.current += 1; + setIsModalOpen(false); + startSessionTimer(); + }, [handleLogout, startSessionTimer, token, memberName, memberNickName]); + + const handleRefreshDecline = useCallback(() => { + handleLogout(); + }, [handleLogout]); + + useEffect(() => { + if (token && !isWaitingForResponse) { + refreshCountRef.current = 0; + startSessionTimer(); + } + + return () => { + if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current); + if (responseTimerRef.current) clearTimeout(responseTimerRef.current); + }; + }, [token, isWaitingForResponse, startSessionTimer]); + + return { + isModalOpen, + remainingRefreshes: MAX_REFRESH_COUNT - refreshCountRef.current, + onClose: () => setIsModalOpen(false), + onAccept: handleRefreshAccept, + onDecline: handleRefreshDecline, + }; +} diff --git a/src/store/.gitkeep b/src/store/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/store/modal-store.ts b/src/store/modal-store.ts new file mode 100644 index 0000000..864584e --- /dev/null +++ b/src/store/modal-store.ts @@ -0,0 +1,42 @@ +import { create } from "zustand"; + +// 모달 props의 기본 타입 정의 +interface BaseModalProps { + onClose?: () => void; +} + +// 각 모달 컴포넌트의 props 타입 정의 +export interface RefreshModalProps extends BaseModalProps { + remainingRefreshes: number; + onAccept: () => void; + onDecline: () => void; +} + +// 지원되는 모달 타입들을 유니온 타입으로 정의 +export type ModalType = "refresh" | "alert" | "confirm"; + +// 각 모달 타입별 props 타입 매핑 +type ModalPropsMap = { + refresh: RefreshModalProps; + alert: BaseModalProps & { message: string }; + confirm: BaseModalProps & { message: string; onConfirm: () => void }; +}; + +interface ModalState { + isOpen: boolean; + modalType: ModalType | null; + modalProps: ModalPropsMap[ModalType] | null; + openModal: (type: T, props: ModalPropsMap[T]) => void; + closeModal: () => void; +} + +const useModalStore = create((set) => ({ + isOpen: false, + modalType: null, + modalProps: null, + openModal: (type, props) => + set({ isOpen: true, modalType: type, modalProps: props }), + closeModal: () => set({ isOpen: false, modalType: null, modalProps: null }), +})); + +export default useModalStore; diff --git a/src/store/token-refresh.ts b/src/store/token-refresh.ts new file mode 100644 index 0000000..a3cb525 --- /dev/null +++ b/src/store/token-refresh.ts @@ -0,0 +1,16 @@ +import { create } from "zustand"; + +interface RefreshState { + refreshCount: number; + incrementCount: () => void; + resetCount: () => void; +} + +const useRefreshStore = create((set) => ({ + refreshCount: 0, + incrementCount: () => + set((state) => ({ refreshCount: state.refreshCount + 1 })), + resetCount: () => set({ refreshCount: 0 }), +})); + +export default useRefreshStore; diff --git a/src/utils/auth-handler.tsx b/src/utils/auth-handler.tsx new file mode 100644 index 0000000..853874f --- /dev/null +++ b/src/utils/auth-handler.tsx @@ -0,0 +1,19 @@ +"use client"; + +import RefreshModal from "@/components/common/auth/refresh-modal"; +import useTokenRefresh from "@/hooks/use-token-refresh"; + +export default function AuthRefreshHandler() { + const { isModalOpen, remainingRefreshes, onClose, onAccept, onDecline } = + useTokenRefresh(); + + return ( + + ); +} diff --git a/src/utils/next-cookies.ts b/src/utils/next-cookies.ts index 81e8b41..cafdbb6 100644 --- a/src/utils/next-cookies.ts +++ b/src/utils/next-cookies.ts @@ -102,7 +102,7 @@ export async function setCookie( ): Promise { const defaultOptions: CookieOptions = { path: "/", - maxAge: 24 * 60 * 60, // 1일 + maxAge: 30 * 60, //30분 httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax",