프로젝트 개요
이 프로젝트는 실제 서비스 운영을 전제로 설계된 개인 포트폴리오이며,
인증, 사용자 데이터, 장바구니, 포인트, 대시보드, 공통 유틸, 입력 처리 등
반복적으로 사용되는 로직을 모두 Custom Hook으로 분리하여 관리합니다.
각 훅은 단일 책임 원칙을 기준으로 설계되었고,
UI 컴포넌트는 오직 화면 표현에만 집중할 수 있도록 구성되어 있습니다.
- useAuthUser
- useUserData
- useCart
- usePoint
- useDashboard
- useDashboardQuery
- useImageCache
- useInput
- useCommon
useAuthUser
Firebase 로그인 상태 변화를 자동으로 감지하고, 현재 로그인된 유저 정보를 어디서든 쉽게 사용할 수 있게 만든 훅입니다. 인증 로직을 컴포넌트에서 완전히 분리하여, 화면에서는 로그인 여부만 신경 쓰도록 설계되었습니다.
아래는 useAuthUser 훅의 전체 코드입니다.
'use client';
import { useState, useEffect } from "react";
import { onAuthStateChanged } from "firebase/auth";
import { auth } from "@/firebase/firebase";
export function useAuthUser() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
setUser(currentUser);
setLoading(false);
});
return () => unsubscribe();
}, []);
return { user, loading };
}
① ‘use client’ 선언
이 훅은 Firebase Auth SDK와 React 상태 훅을 사용하므로 반드시 Client Component에서만 동작해야 합니다. Next.js App Router 환경에서 이 훅은 브라우저에서만 동작해야 하므로, Next.js에게 “이 파일은 클라이언트용”이라고 알려주기 위해 최상단에 선언합니다.
② user / loading 상태 분리
user에는 현재 인증된 Firebase User 객체를 저장하고, loading은 아직 로그인 상태를 확인 중인지를 나타내는 값입니다. 이 분리를 통해 컴포넌트에서는 로딩 중 / 로그인됨 / 로그아웃됨 상태를 명확하게 나눌 수 있습니다.
③ onAuthStateChanged 구독 구조
Firebase의 onAuthStateChanged는 현재 로그인 상태를 로그인 상태가 바뀔 때마다 자동으로 호출되는 함수입니다. 페이지 새로고침, 로그인, 로그아웃 상황 모두를 커버하며, 별도로 로그인 여부를 확인하는 API를 만들 필요 없이, Firebase가 상태 변화를 알아서 알려줍니다.
④ unsubscribe 처리
useEffect의 cleanup 함수에서 unsubscribe를 반환하여, 화면이 사라질 때 더 이상 필요 없는 감지 기능을 끄는 역할을 합니다.
이 처리가 없으면, 화면이 없어졌는데도 상태가 계속 변경되는 문제가 생길 수 있습니다.
⑤ 컴포넌트에서의 사용 방식
컴포넌트에서는 Firebase Auth API를 직접 호출하지 않고, const { user, loading } = useAuthUser() 형태로 인증 상태만 소비합니다.
나중에 인증 방식이 바뀌어도, 화면 코드는 거의 손대지 않아도 됩니다.
useUserData
로그인된 사용자의 Firestore 데이터를 TanStack Query로 불러오고 관리하는 훅입니다. 실시간으로 계속 바뀔 필요는 없지만 안정적으로 불러와야 하는 사용자 기본 정보나 마이페이지 데이터를 조회하는 용도로 설계되었습니다.
아래는 TanStack Query를 활용한 useUserData 훅의 전체 코드입니다.
import { useQuery } from "@tanstack/react-query";
import { doc, getDoc } from "firebase/firestore";
import { db } from "@/shared/libs/firebase/firebase";
async function fetchUserData(uid: string | null) {
if (!uid) throw new Error("uid is required");
const docRef = doc(db, "users", uid);
const snap = await getDoc(docRef);
if (!snap.exists()) throw new Error("User not found");
return snap.data();
}
export function useUserData(uid: string | null) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", uid],
queryFn: () => fetchUserData(uid),
enabled: !!uid,
});
return { data, isLoading, error };
}
① fetchUserData 분리
실제 데이터 요청과 화면에서 사용하는 상태 관리를 역할별로 나누기 위함입니다. 이 구조는 테스트하기 쉽고 다른 곳에서도 재사용하기 좋습니다.
② uid null 가드 처리
uid가 없는 상태에서 Firestore 요청이 실행되지 않도록 초기에 차단합니다. 로그인 정보가 아직 준비되지 않은 순간에 발생할 수 있는 불필요한 요청을 막아줍니다.
③ queryKey 설계
queryKey를 ["user", uid] 형태로 구성하여, 사용자마다 데이터가 서로 섞이지 않도록 캐시를 분리하기 위해 이렇게 구성했습니다. uid가 바뀌면 이전 사용자 데이터는 그대로 보관되고 새로운 사용자 데이터만 다시 요청됩니다.
④ enabled 옵션을 통한 조건부 실행
uid가 존재할 때만 Firestore 요청이 실행되도록 제어합니다. 이 설정 덕분에 useAuthUser와 함께 사용할 때 오류 없이 자연스럽게 동작합니다.
⑤ TanStack Query 기반 상태 관리
항상 같은 방식으로 로딩, 에러, 데이터 상태를 다룰 수 있습니다. 컴포넌트에서는 상태값만 보고 화면을 나누면 되기 때문에 코드가 단순해집니다.
useCart
장바구니 데이터를 서버 상태로 관리하기 위해 TanStack Query를 사용해 구성한 훅 모음입니다. 이 프로젝트에서는 단순 로컬 상태가 아닌, 실제 서비스처럼 동작하도록 조회, 추가, 수정, 삭제, 검증 과정을 각각의 훅으로 나누어 구성했습니다.
아래는 장바구니 도메인에서 사용하는 전체 코드입니다.
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CartService } from '../services/cartService';
import { AddToCartRequest, UpdateCartItemRequest } from '../types/cart';
import { Product } from '../types/product';
export const cartKeys = {
all: ['cart'] as const,
lists: () => [...cartKeys.all, 'list'] as const,
list: (userId: string) => [...cartKeys.lists(), userId] as const,
count: (userId: string) => [...cartKeys.all, 'count', userId] as const,
};
export function useCart(userId: string | null) {
return useQuery({
queryKey: cartKeys.list(userId || ''),
queryFn: () => {
if (!userId) return null;
return CartService.getUserCart(userId);
},
enabled: !!userId,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
});
}
export function useCartItemCount(userId: string | null) {
return useQuery({
queryKey: cartKeys.count(userId || ''),
queryFn: () => {
if (!userId) return 0;
return CartService.getCartItemCount(userId);
},
enabled: !!userId,
staleTime: 1000 * 60 * 2,
gcTime: 1000 * 60 * 5,
});
}
export function useAddToCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, product, request }: { userId: string; product: Product; request: AddToCartRequest; }) => {
return CartService.addToCart(userId, product, request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: cartKeys.list(variables.userId) });
queryClient.invalidateQueries({ queryKey: cartKeys.count(variables.userId) });
},
});
}
export function useUpdateCartItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, request }: { userId: string; request: UpdateCartItemRequest; }) => {
return CartService.updateCartItem(userId, request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: cartKeys.list(variables.userId) });
queryClient.invalidateQueries({ queryKey: cartKeys.count(variables.userId) });
},
});
}
export function useRemoveFromCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, cartItemId }: { userId: string; cartItemId: string; }) => {
return CartService.removeFromCart(userId, cartItemId);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: cartKeys.list(variables.userId) });
queryClient.invalidateQueries({ queryKey: cartKeys.count(variables.userId) });
},
});
}
export function useClearCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: string) => {
return CartService.clearCart(userId);
},
onSuccess: (_, userId) => {
queryClient.invalidateQueries({ queryKey: cartKeys.list(userId) });
queryClient.invalidateQueries({ queryKey: cartKeys.count(userId) });
},
});
}
export function useValidateCart(userId: string | null) {
return useQuery({
queryKey: [...cartKeys.list(userId || ''), 'validate'],
queryFn: () => {
if (!userId) return null;
return CartService.validateCart(userId);
},
enabled: !!userId,
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 3,
});
}
코드 양이 많고 TanStack Query까지 함께 사용되기 때문에
처음 읽을 때 구조를 한 번에 이해하기 어려울 수 있습니다.
이에 따라 TanStack Query의 핵심 개념을 먼저 정리한 글을 별도로 작성했습니다.
링크 1: [Cart의 cartKeys(TanStack Query) 이해하기]
링크 2: [Cart의 queryFn 이해하기]
① cartKeys 설계 – 캐시 구조를 먼저 정의한 이유
장바구니 훅에서 가장 먼저 설계한 것은 데이터 구조가 아니라 캐시를 구분하기 위한 기준 구조입니다. cartKeys를 도메인 단위로 나누어, 장바구니 목록, 개수, 검증 데이터가 서로 섞이거나 동시에 갱신되지 않도록 분리했습니다.
② 조회 훅 분리 – 목록과 개수를 나눈 이유
장바구니 목록(useCart)과 아이템 개수(useCartItemCount)는 사용 목적이 달라 의도적으로 분리했습니다. 이를 통해 필요 없는 데이터 요청과 화면 재렌더링을 줄이고, 화면별로 필요한 정보만 조회할 수 있도록 구성했습니다.
③ enabled 조건 처리 – 인증 흐름과의 결합
모든 장바구니 쿼리는 userId를 기준으로 동작합니다. enabled 옵션을 사용해 로그인 정보가 없을 때 발생할 수 있는 잘못된 요청을 미리 막았습니다. 이로 인해 useAuthUser와 함께 사용해도 흐름이 어색하지 않게 동작합니다.
④ Mutation 훅 분리 – 행위 단위 설계
장바구니 추가, 수량 변경, 삭제, 비우기 로직을 각각의 Mutation 훅으로 분리했습니다. 각 훅이 하나의 동작만 책임지도록 설계되어, 코드 흐름을 이해하기 쉽고 수정도 편해집니다.
⑤ invalidateQueries 전략 – 로컬 상태를 두지 않는 이유
Mutation 성공 시마다 관련 쿼리를 무효화하여 서버 데이터를 다시 불러옵니다. 서버 데이터를 기준으로 모든 상태를 판단하고, 항상 서버에서 다시 가져온 최신 상태로 화면을 그립니다.
⑥ staleTime / gcTime 설정 의도
장바구니 데이터가 사용되는 방식을 고려해 staleTime과 gcTime을 설정했습니다. 성능 저하 없이 적절한 최신 상태를 유지하기 위한 설정입니다.
⑦ 이 구조를 선택한 이유
장바구니 데이터는 서버에 저장되고 여러 화면에서 함께 사용하는 데이터입니다. 기능이 늘어나도 구조가 과도하게 복잡해지지 않도록 설계했으며, 실제 서비스 확장을 염두에 둔 구성입니다.
usePoint
포인트 데이터를 서버 기준과 이력 관리 중심으로 다루기 위해 설계한 훅 모음입니다. 단순히 숫자를 더하고 빼는 로컬 상태가 아니라, 실제 서비스처럼 잔액 조회, 내역 페이징, 적립·사용·환불 과정을 각각의 훅으로 나누어 TanStack Query와 Mutation으로 관리했습니다.
아래는 포인트 도메인에서 사용하는 전체 코드입니다.
// 포인트 관련 React Hook
import { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import PointService from '@/shared/services/pointService';
import { PointHistory, AddPointRequest, UsePointRequest, RefundPointRequest } from '@/shared/types/point';
import { useAuth } from '@/context/authProvider';
export const usePointBalance = () => {
const { user } = useAuth();
return useQuery({
queryKey: ['pointBalance', user?.uid],
queryFn: () => PointService.getPointBalance(user!.uid),
enabled: !!user,
staleTime: 1000 * 60 * 5,
});
};
export const usePointHistory = (limit: number = 50) => {
const { user } = useAuth();
const [lastDoc, setLastDoc] = useState(null);
const [allHistory, setAllHistory] = useState([]);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const isLoadingMoreRef = useRef(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['pointHistory', user?.uid, limit],
queryFn: () => PointService.getPointHistory(user!.uid, limit),
enabled: !!user,
});
useEffect(() => {
if (data?.success && data.history && !isInitialized) {
setAllHistory(data.history);
setLastDoc(data.lastDoc);
setHasMore(data.hasMore);
setIsInitialized(true);
}
}, [data, isInitialized]);
const loadMore = useCallback(async () => {
if (!hasMore || isLoadingMoreRef.current || !lastDoc) return;
isLoadingMoreRef.current = true;
setIsLoadingMore(true);
try {
const response = await PointService.getPointHistory(user!.uid, limit, lastDoc);
if (response.success) {
setAllHistory(prev => [...prev, ...response.history]);
setLastDoc(response.lastDoc);
setHasMore(response.hasMore);
}
} finally {
isLoadingMoreRef.current = false;
setIsLoadingMore(false);
}
}, [hasMore, lastDoc, user, limit]);
const reset = useCallback(() => {
setAllHistory([]);
setLastDoc(null);
setHasMore(true);
setIsInitialized(false);
refetch();
}, [refetch]);
return {
history: allHistory,
isLoading,
isLoadingMore,
hasMore,
error,
loadMore,
reset,
};
};
export const useAddPoint = () => {
const queryClient = useQueryClient();
const { user } = useAuth();
return useMutation({
mutationFn: (data: AddPointRequest) => PointService.addPoint(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pointBalance', user?.uid] });
queryClient.invalidateQueries({ queryKey: ['pointHistory', user?.uid] });
},
});
};
export const useUsePoint = () => {
const queryClient = useQueryClient();
const { user } = useAuth();
return useMutation({
mutationFn: (data: UsePointRequest) => PointService.usePoint(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pointBalance', user?.uid] });
queryClient.invalidateQueries({ queryKey: ['pointHistory', user?.uid] });
},
});
};
export const useRefundPoint = () => {
const queryClient = useQueryClient();
const { user } = useAuth();
return useMutation({
mutationFn: (data: RefundPointRequest) => PointService.refundPoint(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pointBalance', user?.uid] });
queryClient.invalidateQueries({ queryKey: ['pointHistory', user?.uid] });
},
});
};
export const useSignupPoint = () => {
return useMutation({
mutationFn: () => PointService.addSignupPoint(),
});
};
export const useOrderPoint = () => {
return useMutation({
mutationFn: ({ orderAmount, orderId }: { orderAmount: number; orderId: string }) =>
PointService.addOrderPoint(orderAmount, orderId),
});
};
export const useReviewPoint = () => {
return useMutation({
mutationFn: ({ productName, orderId }: { productName: string; orderId: string }) =>
PointService.addReviewPoint(productName, orderId),
});
};
export const useBirthdayPoint = () => {
return useMutation({
mutationFn: () => PointService.addBirthdayPoint(),
});
};
① 잔액과 이력 분리 설계
포인트 잔액(usePointBalance)과 포인트 내역(usePointHistory)을 분리하여 설계했습니다. 사용 목적과 갱신 방식이 다르기 때문에 조회 방식도 다르게 설계했습니다. 잔액은 화면에서 자주 사용되지만 실제 변경은 자주 일어나지 않고, 이력은 페이징과 누적 관리가 필요합니다.
② 포인트 이력 페이징 구조
마지막으로 불러온 문서를 기준으로 다음 데이터를 가져오는 방식으로 관리하며, useRef를 사용해 중복 로딩을 방지합니다. 스크롤로 계속 불러오는 화면에서도 중복 요청 없이 안정적으로 동작하도록 설계했습니다.
③ enabled와 인증 결합
로그인 정보가 준비된 이후에만 포인트 요청이 실행되도록 제어했습니다.
④ Mutation 이후 캐시 무효화
포인트 적립, 사용, 환불 이후에는 잔액과 이력 캐시를 동시에 무효화하여 잔액과 이력이 서로 어긋나지 않도록 보장합니다. 모든 포인트 계산 기준을 서버에 두고 클라이언트는 결과만 사용합니다.
⑤ 상황별 포인트 훅 분리
회원가입, 주문, 리뷰, 생일 등 적립 조건과 계산 방식이 다른 포인트 정책을 각각의 훅으로 나누었습니다. 정책이 추가되거나 변경되어도 영향을 최소화하기 위한 구조입니다.
⑥ 서버 상태 중심 포인트 설계
포인트는 실제 돈과 동일하게 정확성이 중요한 데이터이므로 로컬 상태로 관리하지 않습니다. 클라이언트에서는 서버에서 계산된 결과만 화면에 표시합니다.
useDashboard
대시보드에서 사용하는 통계 데이터를 처음에는 한 번에 불러오고, 이후에는 일부만 주기적으로 갱신하도록 설계한 훅입니다. 단순히 값을 계산하는 훅이 아니라, 실제 서비스에서 필요한 로딩 처리, 에러 처리, 주기적 갱신까지 모두 포함한 대시보드 전용 훅입니다.
아래는 대시보드 도메인에서 사용하는 전체 코드입니다.
'use client';
import { useState, useEffect, useCallback } from 'react';
import { DashboardService, DashboardStats } from '@/shared/services/dashboardService';
export function useDashboard() {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const loadDashboardData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const dashboardStats = await DashboardService.getDashboardStats();
setStats(dashboardStats);
setLastUpdated(new Date());
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '대시보드 데이터를 불러오는데 실패했습니다.';
setError(errorMessage);
console.error('대시보드 데이터 로드 실패:', err);
} finally {
setLoading(false);
}
}, []);
const updateRealtimeData = useCallback(async () => {
if (!stats) return;
try {
const realtimeStats = await DashboardService.getRealtimeStats();
setStats(prevStats => ({
...prevStats!,
...realtimeStats
}));
setLastUpdated(new Date());
} catch (err) {
console.error('실시간 데이터 업데이트 실패:', err);
}
}, [stats]);
const refresh = useCallback(() => {
loadDashboardData();
}, [loadDashboardData]);
useEffect(() => {
loadDashboardData();
}, [loadDashboardData]);
useEffect(() => {
if (!stats) return;
const interval = setInterval(() => {
updateRealtimeData();
}, 30000);
return () => clearInterval(interval);
}, [updateRealtimeData, stats]);
return {
stats,
loading,
error,
lastUpdated,
refresh,
updateRealtimeData
};
}
export function useDashboardFormatters() {
const formatNumber = useCallback((num: number): string => {
return DashboardService.formatNumber(num);
}, []);
const formatCurrency = useCallback((amount: number): string => {
return DashboardService.formatCurrency(amount);
}, []);
const formatTimeAgo = useCallback((timestamp: Date): string => {
return DashboardService.formatTimeAgo(timestamp);
}, []);
const getGrowthColor = useCallback((growth: number): string => {
if (growth > 0) return '#10b981';
if (growth < 0) return '#ef4444';
return '#6b7280';
}, []);
const getGrowthIcon = useCallback((growth: number): string => {
if (growth > 0) return '↗️';
if (growth < 0) return '↘️';
return '➡️';
}, []);
const getPriorityColor = useCallback((priority: 'low' | 'medium' | 'high'): string => {
switch (priority) {
case 'high': return '#ef4444';
case 'medium': return '#f59e0b';
case 'low': return '#10b981';
default: return '#6b7280';
}
}, []);
return {
formatNumber,
formatCurrency,
formatTimeAgo,
getGrowthColor,
getGrowthIcon,
getPriorityColor
};
}
① 초기 집계 데이터 로딩 구조
화면에 필요한 모든 통계 데이터를 처음 한 번에 불러옵니다. 데이터를 불러오는 중인지, 실패했는지, 언제 갱신되었는지를 명확하게 관리하도록 구성했습니다.
② 실시간 부분 업데이트 전략
자주 변할 가능성이 있는 지표만 주기적으로 다시 불러옵니다. 필요한 데이터만 바꿔 화면 변경 범위를 최소화했습니다.
③ 주기적 업데이트 제어
화면이 사라진 뒤에도 업데이트가 계속 실행되는 문제를 막습니다.
④ 수동 새로고침 제공
관리자 화면에서 실제로 자주 사용되는 방식입니다.
⑤ 포맷 로직 분리
화면 컴포넌트에서는 값만 받아 바로 출력할 수 있도록 했습니다.
⑥ 대시보드 전용 상태 관리 설계
여러 데이터 조회와 갱신, 표시 로직이 한 번에 필요한 화면입니다. 화면 코드가 복잡해지지 않도록 로직을 한 곳에 모으는 것이 목적입니다.
useDashboardQuery
대시보드 데이터를 TanStack Query 기반 서버 상태로 관리하기 위해 설계한 훅 집합입니다. 집계 통계와 실시간 통계를 서로 다른 쿼리로 분리하고, 캐싱·자동 갱신·병합 전략을 통해 대시보드 특성에 맞는 비동기 데이터 흐름을 구성했습니다.
아래는 대시보드 Query 도메인에서 사용하는 전체 코드입니다.
'use client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DashboardService, DashboardStats } from '@/shared/services/dashboardService';
export const dashboardKeys = {
all: ['dashboard'] as const,
stats: () => [...dashboardKeys.all, 'stats'] as const,
realtime: () => [...dashboardKeys.all, 'realtime'] as const,
};
export function useDashboardStats() {
return useQuery({
queryKey: dashboardKeys.stats(),
queryFn: DashboardService.getDashboardStats,
staleTime: 2 * 60 * 1000,
refetchInterval: 5 * 60 * 1000,
});
}
export function useRealtimeDashboardStats() {
return useQuery({
queryKey: dashboardKeys.realtime(),
queryFn: DashboardService.getRealtimeStats,
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
});
}
export function useDashboardData() {
const queryClient = useQueryClient();
const statsQuery = useDashboardStats();
const realtimeQuery = useRealtimeDashboardStats();
const refreshDashboard = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: dashboardKeys.stats() }),
queryClient.invalidateQueries({ queryKey: dashboardKeys.realtime() }),
]);
};
const mergedStats: DashboardStats | undefined = statsQuery.data ? {
...statsQuery.data,
...(realtimeQuery.data || {}),
} : undefined;
return {
stats: mergedStats,
loading: statsQuery.isLoading,
error: statsQuery.error?.message || realtimeQuery.error?.message || null,
isRefreshing: statsQuery.isFetching || realtimeQuery.isFetching,
lastUpdated: statsQuery.dataUpdatedAt ? new Date(statsQuery.dataUpdatedAt) : null,
isStale: statsQuery.isStale,
refresh: refreshDashboard,
statsQuery,
realtimeQuery,
};
}
export function useDashboardFormatters() {
const formatNumber = (num: number): string => DashboardService.formatNumber(num);
const formatCurrency = (amount: number): string => DashboardService.formatCurrency(amount);
const formatTimeAgo = (timestamp: Date): string => DashboardService.formatTimeAgo(timestamp);
const getGrowthColor = (growth: number): string => {
if (growth > 0) return '#10b981';
if (growth < 0) return '#ef4444';
return '#6b7280';
};
const getGrowthIcon = (growth: number): string => {
if (growth > 0) return '↗️';
if (growth < 0) return '↘️';
return '➡️';
};
const getPriorityColor = (priority: 'low' | 'medium' | 'high'): string => {
switch (priority) {
case 'high': return '#ef4444';
case 'medium': return '#f59e0b';
case 'low': return '#10b981';
default: return '#6b7280';
}
};
return {
formatNumber,
formatCurrency,
formatTimeAgo,
getGrowthColor,
getGrowthIcon,
getPriorityColor
};
}
① Query Key 분리 전략
집계 통계(stats)와 실시간 통계(realtime)를 서로 다른 queryKey로 분리했습니다. 이를 통해 캐싱 주기와 갱신 빈도를 데이터 성격에 맞게 다르게 설정할 수 있습니다.
② 서로 다른 staleTime / refetchInterval
집계 데이터는 상대적으로 변경 빈도가 낮아 긴 staleTime을, 실시간 데이터는 짧은 staleTime과 refetchInterval을 사용했습니다. 이는 대시보드 데이터 특성을 반영한 전략입니다.
③ 쿼리 병합 구조
useDashboardData에서는 두 개의 쿼리 결과를 병합하여 화면에 필요한 단일 stats 객체를 제공합니다. UI는 데이터 출처를 알 필요 없이 결과만 소비합니다.
④ 수동 갱신 제어
자동 갱신 외에도 invalidateQueries를 통한 수동 refresh를 제공하여, 관리자가 즉시 최신 데이터를 확인할 수 있도록 했습니다.
⑤ Query 기반 대시보드 설계의 장점
이 구조는 로딩, 에러, 캐싱, 갱신 상태를 모두 Query에 위임합니다. useDashboard 훅과 비교해 서버 상태 관리에 더 적합한 방식입니다.
useImageCache
이미지 로딩을 TanStack Query 캐시를 이용해 관리하기 위해 만든 훅 모음입니다. 이미지 URL을 서버 데이터처럼 캐시에 보관해, 같은 이미지를 다시 요청하지 않고 화면 전환 속도를 개선합니다.
아래는 이미지 캐싱 도메인에서 사용하는 전체 코드입니다.
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
const preloadImage = (url: string): Promise => {
return new Promise((resolve) => {
if (!url) return resolve(url);
const img = new Image();
img.onload = () => resolve(url);
img.onerror = () => resolve(url);
img.src = url;
});
};
export const useImageCache = (imageUrl: string | undefined, enabled: boolean = true) => {
return useQuery({
queryKey: ['image', imageUrl],
queryFn: () => Promise.resolve(imageUrl),
enabled: enabled && !!imageUrl,
staleTime: 2 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 0,
});
};
export const useMultipleImageCache = (imageUrls: string[], enabled: boolean = true) => {
return useQuery({
queryKey: ['images', ...imageUrls.sort()],
queryFn: async () => imageUrls.filter(Boolean),
enabled: enabled && imageUrls.length > 0,
staleTime: 2 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 0,
});
};
export const useProductImageCache = (product: any, enabled: boolean = true) => {
const imageUrls = [product?.mainImage, ...(product?.images || [])].filter(Boolean);
return useMultipleImageCache(imageUrls, enabled);
};
export const useImagePreloader = () => {
const preloadImages = useCallback(async (urls: string[]) => {
const promises = urls.map(url => preloadImage(url));
return Promise.all(promises);
}, []);
return { preloadImages };
};
export const imageKeys = {
single: (url: string) => ['image', url] as const,
multiple: (urls: string[]) => ['images', ...urls.sort()] as const,
product: (productId: string) => ['product-images', productId] as const,
category: (categoryId: string) => ['category-images', categoryId] as const,
} as const;
① 이미지도 서버 상태로 취급
이미지를 단순히 바로 불러오는 리소스가 아니라, 캐시 가능한 데이터처럼 Query로 관리합니다. 페이지가 바뀌어도 이미 불러온 이미지를 다시 요청하지 않습니다.
② Query Key 기반 캐시 분리
사용 목적에 따라 queryKey를 나누어 서로 다른 이미지 캐시가 섞이지 않도록 했습니다.
③ retry 비활성화 전략
이미지 로딩 실패가 화면 전체를 막을 정도는 아니기 때문에 재시도를 하지 않도록 설정했습니다. 이미지가 안 보여도 화면 자체는 정상적으로 동작합니다.
④ staleTime / gcTime 설정
자주 바뀌지 않는 특성을 고려해 캐시를 비교적 오래 유지하도록 설정했습니다. 같은 이미지를 다시 다운로드하는 요청을 줄일 수 있습니다.
⑤ 프리로드 훅 분리
화면에 들어오기 전에 필요한 이미지를 미리 불러올 수 있도록 했습니다. 실제 렌더링과 이미지 선로딩 역할을 분리했습니다.
⑥ 단순 ref 캐시 대비 장점
컴포넌트가 사라지면 캐시도 함께 사라지지만, Query 캐시는 앱 전체에서 공유되어 여러 화면에서 재사용할 수 있습니다. 여러 화면에서 반복 사용되는 이미지 특성에 맞춰 Query 방식을 선택했습니다.
useInput
폼 입력 처리를 단일 입력용과 다중 입력용으로 나누어 정리한 훅입니다. 실제 서비스에서 자주 쓰는 폼 구조에 맞게 확장해서 사용할 수 있는 입력 상태 관리 방식입니다.
아래는 이 프로젝트에서 사용하는 다중 입력 폼 전용 훅의 전체 코드입니다.
"use client";
import { useState } from "react";
export default function useInputs(initialState: Record = {}) {
const [values, setValues] = useState(initialState);
const onChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const { name, value } = e.target;
setValues((prev) => ({
...prev,
[name]: value,
}));
};
return [values, onChange, setValues] as const;
}
① name 기반 입력 매핑 구조
입력값이 name 속성을 기준으로 자동으로 상태 객체에 연결됩니다. 훅 코드는 그대로 두고 입력 필드만 추가하면 됩니다.
② 다중 input 타입 대응
input, select, textarea를 모두 하나의 onChange로 처리하여 폼 코드가 복잡해지지 않도록 했습니다. 같은 방식으로 값을 처리할 수 있습니다.
③ 객체 기반 상태 관리
값을 다시 정리하지 않아도 바로 API 요청에 사용할 수 있도록, 폼 전체 값을 하나의 객체로 관리합니다.
④ setValues 직접 노출
특정 값만 직접 바꿀 수 있도록 setValues를 함께 반환합니다. 실제 수정 화면이나 초기화 기능에서 꼭 필요한 구조입니다.
⑤ 단일 필드 훅과의 역할 분리
용도에 따라 훅을 나누어 사용하도록 했습니다. 간단한 경우까지 복잡한 훅을 쓰지 않기 위한 선택입니다.
⑥ 폼 로직의 책임 분리
입력값을 어떻게 저장할지 신경 쓸 필요가 없어집니다. 화면 구성과 검증 로직에만 집중하면 됩니다.
useCommon
여러 화면에서 반복해서 쓰이는 상태 관련 유틸 로직을 한곳에 모아 관리하기 위한 공통 훅 모음입니다. 특정 화면이나 비즈니스 규칙에 묶이지 않는 기능만 골라 구성했습니다.
아래는 이 프로젝트에서 공통 훅으로 분리해 사용하고 있는 전체 코드입니다.
import { useState, useEffect } from 'react';
export function useLocalStorage(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void] {
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}
export function useDebounce(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
① useLocalStorage 훅의 목적
브라우저에서만 사용할 수 있는 localStorage 접근 로직을 컴포넌트 밖으로 분리하기 위해 만들었습니다. 화면에서는 일반적인 React 상태처럼 사용할 수 있습니다.
② SSR 환경 고려
서버 렌더링 시 에러가 나지 않도록 초기 값을 안전하게 처리했습니다.
③ 함수형 업데이트 지원
이전 값을 기준으로 값을 바꿀 때도 문제없이 동작합니다.
④ useDebounce의 역할
값이 계속 바뀔 때 바로 반영하지 않고 잠시 기다려 불필요한 계산과 요청을 줄입니다. 검색어 입력, 자동완성, 필터 UI에서 주로 사용됩니다.
⑤ 타이머 정리 처리
컴포넌트가 사라진 뒤에도 타이머가 동작하는 문제를 막습니다.
⑥ 공통 훅으로 분리한 기준
특정 기능이나 화면에만 쓰이지 않으며, 프로젝트 전체에서 같은 방식으로 사용할 수 있도록 분리했습니다.
FAQ
Q. 왜 전역 상태 관리로 Redux를 사용하지 않고 Custom Hook + TanStack Query 구조를 선택했나요?
이 프로젝트의 대부분 상태는 서버에서 비롯되는 데이터이거나, 화면 단위로 한정되는 로컬 상태였습니다. Redux를 도입할 경우 오히려 보일러플레이트와 전역 의존성이 증가한다고 판단했고, 서버 상태는 TanStack Query로, UI·도메인 로직은 Custom Hook으로 분리하는 구조가 더 적합하다고 판단했습니다.
Q. Custom Hook을 이렇게 많이 분리하면 오히려 복잡해지지 않나요?
각 훅은 하나의 책임만 갖도록 설계되어 있어, 개별 훅의 내부는 오히려 단순합니다. 복잡성은 컴포넌트에서 훅으로 이동했을 뿐이며, 이로 인해 화면 컴포넌트는 읽기 쉬워지고 변경 영향 범위도 명확해졌습니다.
Q. useDashboard와 useDashboardQuery를 따로 만든 이유는 무엇인가요?
대시보드는 집계 방식과 실시간 업데이트 방식이 혼재된 화면입니다. 직접 상태를 관리하는 방식과 Query 기반 서버 상태 관리 방식은 장단점이 명확히 달라, 두 방식을 모두 구현해보고 상황에 맞게 선택할 수 있도록 분리했습니다.
Q. 이미지나 입력 값처럼 서버 상태가 아닌 것도 Query나 Hook으로 관리한 이유는 무엇인가요?
이미지 로딩, 폼 입력, localStorage 상태 등은 반복적으로 사용되며 관리 포인트가 분산되기 쉬운 영역입니다. 이를 훅으로 표준화함으로써 구현 방식이 통일되고, 화면에서는 일관된 인터페이스로 사용할 수 있도록 했습니다.
Q. 이 구조는 실제 서비스 확장 시에도 그대로 사용할 수 있나요?
각 훅은 특정 화면에 종속되지 않도록 설계되어 있으며, 도메인 단위로 책임이 분리되어 있습니다. 기능 추가나 정책 변경 시에도 기존 UI를 거의 수정하지 않고 훅 내부만 조정할 수 있어, 실제 서비스 확장에 그대로 적용 가능한 구조입니다.