Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기

2026.01.27·수정 2026.09.12·약 67분·작성: 해비·블로그 소개

실행 범위와 설계 점검: 사례 코드와 독립 실습을 구분합니다

아래 원문은 개인 프로젝트의 설계 사례입니다. Firebase 설정, 인증 Provider, QueryClientProvider, 서비스·타입 모듈, 보안 규칙이 별도로 필요합니다. 코드 조각을 한 파일에 합치면 중복 함수와 미해결 import가 생깁니다. ZIP은 이 서비스를 복원하지 않고 useDebounce만 독립적으로 실행합니다.

  • 커스텀 훅은 로직을 공유합니다. useState를 사용하는 훅을 두 곳에서 호출하면 상태 인스턴스는 별개입니다. 인증 구독을 공유하려면 Provider 등 소유 지점을 정합니다.
  • 장바구니 무효화는 관련 캐시를 stale로 만들고 활성 쿼리의 재조회를 유도합니다. 즉시 최신 상태를 보장하는 트랜잭션이 아닙니다. 완료 시점을 기다려야 하면 invalidateQueries의 Promise를 반환하거나 함께 await합니다.
  • 포인트 mutation은 요청 시작 당시 사용자 id를 변수로 전달하고 성공 처리에도 그 id를 사용해야 계정 전환 중 다른 캐시를 갱신하지 않습니다. 회원가입·주문·리뷰·생일 훅도 성공 뒤 잔액·이력을 갱신하는 정책을 연결해야 합니다.
  • 직접 만든 대시보드의 clearInterval은 진행 중인 Promise를 취소하지 않습니다. 늦은 응답이 최신 결과를 덮지 않도록 요청 식별자·취소를 설계합니다. stats를 의존하는 콜백은 stats 변경마다 타이머를 재설정합니다.
  • 두 대시보드 응답을 합쳐도 같은 시점의 원자적 스냅샷은 아닙니다. 서버 기준 시각·버전을 확인해야 하며 사용자·조직별 데이터면 그 범위도 queryKey에 넣습니다.
  • useInput 예제는 문자열 필드용입니다. 체크박스 checked, 파일, 숫자 변환·검증은 별도 처리합니다. 초기값 props가 바뀌어도 useState가 자동 초기화되지는 않습니다.
  • localStorage의 JSON.parse as T는 런타임 검증이 아닙니다. 저장값 스키마 검사, key 변경 직후 편집, 다른 탭 동기화는 별도로 설계합니다.
  • URL을 Query에 담는 것과 이미지 바이너리 캐시는 다릅니다. 단순 포맷 함수에는 React 훅이 필요하지 않습니다.

App Router의 use client는 서버·클라이언트 모듈 경계를 표시합니다. Client Component도 초기 HTML 생성 과정에서 사전 렌더링될 수 있으므로 브라우저 API는 렌더 중 무조건 호출하지 않습니다. 모든 하위 훅 파일에 지시어가 필수인 것도 아닙니다.

독립 연습: 입력 즉시 값과 300ms 뒤 값을 비교합니다. 테스트는 연속 입력 시 이전 타이머 취소, delay 변경, 언마운트 정리, 두 인스턴스의 독립성을 확인합니다. debounce는 이미 시작된 네트워크 요청을 취소하지 않습니다.

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

전체 코드

App.test.tsx

import { afterEach, expect, test, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useDebounce } from './useDebounce';
afterEach(() => vi.useRealTimers());
test('new input cancels the prior timer and publishes only latest value', () => {
  vi.useFakeTimers();
  const { result, rerender, unmount } = renderHook(
    ({ value, delay }) => useDebounce(value, delay),
    { initialProps: { value: '', delay: 300 } },
  );
  rerender({ value: 'a', delay: 300 });
  act(() => vi.advanceTimersByTime(200));
  rerender({ value: 'ab', delay: 300 });
  act(() => vi.advanceTimersByTime(299));
  expect(result.current).toBe('');
  act(() => vi.advanceTimersByTime(1));
  expect(result.current).toBe('ab');
  rerender({ value: 'abc', delay: 300 });
  unmount();
  expect(vi.getTimerCount()).toBe(0);
});
test('instances do not share state and changed delay reschedules', () => {
  vi.useFakeTimers();
  const a = renderHook(({ v, d }) => useDebounce(v, d), {
    initialProps: { v: 'a', d: 300 },
  });
  const b = renderHook(() => useDebounce('b', 300));
  a.rerender({ v: 'new', d: 20 });
  act(() => vi.advanceTimersByTime(20));
  expect(a.result.current).toBe('new');
  expect(b.result.current).toBe('b');
});

App.tsx

import { useState } from 'react';
import { useDebounce } from './useDebounce';
export default function App() {
  const [value, setValue] = useState('');
  const delayed = useDebounce(value, 300);
  return (
    <main>
      <h1>커스텀 훅 실습</h1>
      <label>
        검색어
        <input value={value} onChange={(e) => setValue(e.target.value)} />
      </label>
      <p>즉시 값: {value}</p>
      <output>지연 값: {delayed}</output>
    </main>
  );
}

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.tsx"></script>
  </body>
</html>

main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

package.json

{
  "name": "react-practice-1200",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "typecheck": "tsc --noEmit",
    "build": "tsc --noEmit && vite build",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "typescript": "7.0.2",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

test-setup.ts

import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["vitest/globals"]
  },
  "include": ["*.ts", "*.tsx"]
}

useDebounce.ts

import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
  return debounced;
}

vite.config.ts

import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: { environment: 'jsdom', setupFiles: ['./test-setup.ts'] },
});

useDebounce.ts 전체 코드 보기

공식 근거: 커스텀 훅, Query Keys, use client 경계.

아래 ZIP은 이 글의 핵심 동작을 직접 확인하는 독립 실습입니다. 압축을 풀고 README의 순서대로 실행하세요. 실습 코드 ZIP 다운로드

프로젝트 개요

커스텀 훅의 입력 상태 이펙트 요청 반환 테스트 책임 분리 구조

이 프로젝트는 실제 서비스 운영을 전제로 설계된 개인 포트폴리오이며,
인증, 사용자 데이터, 장바구니, 포인트, 대시보드, 공통 유틸, 입력 처리 등
반복적으로 사용되는 로직을 모두 Custom Hook으로 분리하여 관리합니다.
각 훅은 책임 분리를 목표로 설계되었으며,
UI 컴포넌트가 화면 표현에 집중하도록 구성한 사례입니다. 아래 설계 점검과 제약을 함께 읽으세요.

useAuthUser

Firebase 로그인 상태 변화를 자동으로 감지하고, 현재 로그인된 유저 정보를 어디서든 쉽게 사용할 수 있게 만든 훅입니다. 인증 로직을 컴포넌트에서 완전히 분리하여, 화면에서는 로그인 여부만 신경 쓰도록 설계되었습니다.

아래는 useAuthUser 훅의 전체 코드입니다.

'use client';

import { useState, useEffect } from "react";
import { type User, onAuthStateChanged } from "firebase/auth";
import { auth } from "@/firebase/firebase";

export function useAuthUser() {
  const [user, setUser] = useState<User | null>(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 환경에서 브라우저 API 사용은 Effect 등 적절한 시점에 두어야 하며, 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

커스텀 훅이 UI와 상태 로직, API 요청을 분리하는 구조

장바구니 데이터를 서버 상태로 관리하기 위해 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 { useCallback } from 'react';
import { useQuery, useInfiniteQuery, 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: () => {
      if (!user?.uid) throw new Error('로그인이 필요합니다.');
      return PointService.getPointBalance(user.uid);
    },
    enabled: !!user,
    staleTime: 1000 * 60 * 5,
  });
};

export const usePointHistory = (limit: number = 50) => {
  const { user } = useAuth();
  const queryClient = useQueryClient();
  const uid = user?.uid;
  type Cursor = Parameters<typeof PointService.getPointHistory>[2];
  const query = useInfiniteQuery({
    queryKey: ['pointHistory', uid, limit],
    initialPageParam: undefined as Cursor,
    queryFn: async ({ pageParam }) => {
      if (!uid) throw new Error('로그인이 필요합니다.');
      const response = await PointService.getPointHistory(uid, limit, pageParam);
      if (!response.success) throw new Error('포인트 내역 조회에 실패했습니다.');
      return response;
    },
    getNextPageParam: (lastPage) =>
      lastPage.hasMore ? lastPage.lastDoc ?? undefined : undefined,
    enabled: !!uid,
  });
  const loadMore = useCallback(() => {
    if (!uid || !query.hasNextPage || query.isFetching) return;
    return query.fetchNextPage({ cancelRefetch: false });
  }, [uid, query.hasNextPage, query.isFetching, query.fetchNextPage]);
  const reset = useCallback(() => queryClient.resetQueries({
    queryKey: ['pointHistory', uid, limit], exact: true,
  }), [queryClient, uid, limit]);
  return {
    history: query.data?.pages.flatMap((page) => page.history) ?? [],
    isLoading: query.isLoading,
    isLoadingMore: query.isFetchingNextPage,
    hasMore: query.hasNextPage,
    error: query.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)을 분리하여 설계했습니다. 사용 목적과 갱신 방식이 다르기 때문에 조회 방식도 다르게 설계했습니다. 잔액은 화면에서 자주 사용되지만 실제 변경은 자주 일어나지 않고, 이력은 페이징과 누적 관리가 필요합니다.

② 포인트 이력 페이징 구조
useInfiniteQuery가 마지막 문서 커서와 누적 페이지를 사용자별 queryKey로 관리합니다. history는 캐시의 pages에서 계산하므로 캐시 무효화 후 응답과 계정 변경이 화면에 반영됩니다. 로딩 중에는 추가 요청을 막고 cancelRefetch: false로 연속 호출을 보호합니다.

③ 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<DashboardStats | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [lastUpdated, setLastUpdated] = useState<Date | null>(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 = (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: string): 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: string): 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 문자열을 Query 캐시에 보관합니다. 이미지 파일 자체의 다운로드와 재사용은 브라우저 HTTP 캐시 및 응답 헤더가 결정하며, 별도 preload 훅은 Image 객체로 선로딩을 시도합니다.

아래는 이미지 캐싱 도메인에서 사용하는 전체 코드입니다.

import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';

const preloadImage = (url: string): Promise<string> => {
  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: () => {
      if (!imageUrl) throw new Error('imageUrl is required');
      return 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],
    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] as const,
  product: (productId: string) => ['product-images', productId] as const,
  category: (categoryId: string) => ['category-images', categoryId] as const,
} as const;

① 이미지도 서버 상태로 취급
Query에는 이미지 URL을 저장합니다. 같은 URL의 이미지 요청이 실제로 재사용되는지는 브라우저 캐시 정책에 따라 달라집니다.

② Query Key 기반 캐시 분리
사용 목적에 따라 queryKey를 나누어 서로 다른 이미지 캐시가 섞이지 않도록 했습니다.

③ retry 비활성화 전략
이미지 로딩 실패가 화면 전체를 막을 정도는 아니기 때문에 재시도를 하지 않도록 설정했습니다. 이 URL 조회 쿼리는 실제 이미지 로딩 실패를 감지하지 않습니다. img.onerror도 URL을 반환하므로 실패 표시가 필요하면 프리로더의 오류 처리와 UI를 별도로 연결해야 합니다.

④ staleTime / gcTime 설정
자주 바뀌지 않는 특성을 고려해 캐시를 비교적 오래 유지하도록 설정했습니다. 이 시간 설정은 URL 문자열의 Query 캐시 수명이며 이미지 바이너리의 브라우저 캐시 수명과는 다릅니다.

⑤ 프리로드 훅 분리
화면에 들어오기 전에 필요한 이미지를 미리 불러올 수 있도록 했습니다. 실제 렌더링과 이미지 선로딩 역할을 분리했습니다.

⑥ 단순 ref 캐시 대비 장점
컴포넌트가 사라지면 캐시도 함께 사라지지만, Query 캐시는 앱 전체에서 공유되어 여러 화면에서 재사용할 수 있습니다. 여러 화면에서 반복 사용되는 이미지 특성에 맞춰 Query 방식을 선택했습니다.

useInput

폼 입력 처리를 단일 입력용과 다중 입력용으로 나누어 정리한 훅입니다. 실제 서비스에서 자주 쓰는 폼 구조에 맞게 확장해서 사용할 수 있는 입력 상태 관리 방식입니다.

아래는 이 프로젝트에서 사용하는 다중 입력 폼 전용 훅의 전체 코드입니다.

"use client";

import { useState } from "react";

export default function useInputs(initialState: Record<string, string> = {}) {
  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, useRef } from 'react';
import type { Dispatch, SetStateAction } from 'react';

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, Dispatch<SetStateAction<T>>] {
  // initialValue는 마운트 시 기본값입니다. 변경된 기본값은 재마운트로 적용합니다.
  const initialRef = useRef(initialValue);
  const [storedValue, setStoredValue] = useState<T>(() => initialValue);
  const [loadedKey, setLoadedKey] = useState<string | null>(null);

  useEffect(() => {
    let nextValue = initialRef.current;
    try {
      const item = window.localStorage.getItem(key);
      if (item !== null) nextValue = JSON.parse(item) as T;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
    }
    setStoredValue(() => nextValue);
    setLoadedKey(key);
  }, [key]);

  useEffect(() => {
    if (loadedKey !== key) return;
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, loadedKey, storedValue]);

  return [loadedKey === key ? storedValue : initialRef.current, setStoredValue];
}

export function useDebounce<T>(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 환경 고려
서버와 첫 클라이언트 렌더에는 같은 초기값을 사용하고, 마운트 후 Effect에서 저장값을 읽습니다. 이 예제는 같은 탭의 훅 인스턴스 하나를 위한 구조이며, 여러 탭·인스턴스 간 동기화나 저장 데이터의 런타임 스키마 검증은 별도로 구현해야 합니다.

③ 함수형 업데이트 지원
이전 값을 기준으로 값을 바꿀 때도 문제없이 동작합니다.

④ useDebounce의 역할
값이 계속 바뀔 때 바로 반영하지 않고 잠시 기다려 불필요한 계산과 요청을 줄입니다. 검색어 입력, 자동완성, 필터 UI에서 주로 사용됩니다.

⑤ 타이머 정리 처리
컴포넌트가 사라진 뒤에도 타이머가 동작하는 문제를 막습니다.

⑥ 공통 훅으로 분리한 기준
특정 기능이나 화면에만 쓰이지 않으며, 프로젝트 전체에서 같은 방식으로 사용할 수 있도록 분리했습니다.

커스텀 훅이 실제 인증 상태와 연결되는 흐름은 Firebase Auth Context 인증 상태 관리 글에서 더 구체적으로 확인할 수 있습니다.

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를 거의 수정하지 않고 훅 내부만 조정할 수 있어, 실제 서비스에서는 권한 검사, 동시성, 서비스 계약, 실패 복구를 별도로 검증해야 합니다.

이 글이 도움이 되었나요?

조회 중

React 학습 순서

필수 19개 · 전체 23개

읽음 기록 관리

전체 과정 목차 (23개)
  1. 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
  2. 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
  3. 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
  4. 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
  5. 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
  6. 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
  7. 선택 참고 · React state 업데이트 안됨 문제 해결
  8. 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
  9. 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
  10. 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
  11. 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
  12. 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
  13. 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
  14. 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
  15. 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
  16. 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
  17. 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
  18. 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기 현재 글
  19. 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
  20. 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
  21. 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
  22. 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
  23. 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기