원활한 데이터 흐름: NextJS와 TanStack Query로 서버 데이터를 클라이언트에 하이드레이션하기

ph8nt0m·2025년 3월 2일
0

API Architecture

목록 보기
4/4
post-thumbnail

안녕하세요! 이번 글에서는 NextJS와 TanStack Query를 함께 사용하여 서버에서 가져온 데이터를 클라이언트에 효율적으로 하이드레이션하는 방법에 대해 알아보겠습니다. 이전 글에서 다룬 서버 사이드 캐싱과 클라이언트 사이드 데이터 관리를 결합하여 완전한 데이터 흐름을 구축하는 방법을 살펴보겠습니다.

NextJS 애플리케이션에서 하이드레이션 이해하기

하이드레이션(Hydration)은 서버에서 렌더링된 HTML에 클라이언트 측 JavaScript를 "주입"하여 정적 HTML을 완전한 상호작용 가능한 애플리케이션으로 변환하는 과정입니다. NextJS에서는 이 과정이 자동으로 이루어지지만, 데이터 관리 측면에서는 몇 가지 고려해야 할 사항이 있습니다.

하이드레이션의 문제점

NextJS 애플리케이션에서 데이터 하이드레이션과 관련된 주요 문제점은 다음과 같습니다:

  1. 워터폴 요청: 클라이언트 컴포넌트가 마운트된 후 데이터를 가져오면 추가적인 네트워크 요청이 발생합니다.
  2. 깜빡임 현상: 서버에서 렌더링된 HTML이 클라이언트에서 다시 렌더링될 때 UI가 깜빡이는 현상이 발생할 수 있습니다.
  3. 중복 요청: 서버에서 이미 가져온 데이터를 클라이언트에서 다시 요청하는 경우가 발생합니다.

TanStack Query의 하이드레이션 솔루션

TanStack Query는 HydrationBoundary 컴포넌트와 dehydrate/hydrate 함수를 제공하여 이러한 문제를 해결합니다:

  1. 서버에서 데이터를 가져와 쿼리 캐시를 "탈수(dehydrate)"합니다.
  2. 탈수된 쿼리 캐시 상태를 클라이언트로 전송합니다.
  3. 클라이언트에서 이 상태를 "수화(hydrate)"하여 추가 네트워크 요청 없이 데이터를 사용할 수 있게 합니다.

서버 렌더링 데이터를 위한 HydrationBoundary 구현

NextJS 애플리케이션에서 TanStack Query의 하이드레이션을 구현하는 방법을 살펴보겠습니다.

기본 설정

먼저 필요한 패키지를 설치합니다:

npm install @tanstack/react-query @tanstack/react-query-devtools
# 또는
yarn add @tanstack/react-query @tanstack/react-query-devtools

QueryClient 설정

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000, // 1분
        gcTime: 5 * 60 * 1000, // 5분
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

HydrationBoundary 구현

// app/components/HydrationBoundary.tsx
'use client';

import { HydrationBoundary as TanStackHydrationBoundary, HydrationBoundaryProps } from '@tanstack/react-query';

export function HydrationBoundary(props: HydrationBoundaryProps) {
  return <TanStackHydrationBoundary {...props} />;
}

서버 컴포넌트에서 데이터 프리페치

// app/users/page.tsx
import { dehydrate } from '@tanstack/react-query';
import { HydrationBoundary } from '@/components/HydrationBoundary';
import { UserList } from '@/components/UserList';
import { getQueryClient } from '@/lib/getQueryClient';

// 사용자 데이터를 가져오는 함수
async function fetchUsers() {
  const res = await fetch('https://api.example.com/users');
  if (!res.ok) throw new Error('Failed to fetch users');
  return res.json();
}

export default async function UsersPage() {
  // 서버에서 QueryClient 인스턴스 생성
  const queryClient = getQueryClient();
  
  // 서버에서 데이터 프리페치
  await queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });
  
  // 쿼리 캐시 탈수
  const dehydratedState = dehydrate(queryClient);
  
  return (
    <HydrationBoundary state={dehydratedState}>
      <h1>사용자 목록</h1>
      <UserList />
    </HydrationBoundary>
  );
}

클라이언트 컴포넌트에서 데이터 사용

// app/components/UserList.tsx
'use client';

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

// 사용자 데이터를 가져오는 함수 (클라이언트에서 재사용)
async function fetchUsers() {
  const res = await fetch('https://api.example.com/users');
  if (!res.ok) throw new Error('Failed to fetch users');
  return res.json();
}

export function UserList() {
  // 하이드레이션된 데이터 사용
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });
  
  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>에러 발생: {error.message}</div>;
  
  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

getQueryClient 유틸리티

// lib/getQueryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { cache } from 'react';

// 서버 컴포넌트에서 QueryClient 인스턴스를 생성하는 함수
export const getQueryClient = cache(() => new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
      gcTime: 5 * 60 * 1000,
    },
  },
}));

이 설정을 통해:
1. 서버에서 데이터를 가져와 쿼리 캐시에 저장합니다.
2. 쿼리 캐시를 탈수하여 클라이언트로 전송합니다.
3. 클라이언트에서 캐시를 수화하여 추가 네트워크 요청 없이 데이터를 사용합니다.

서버 컴포넌트를 위한 프리페치 유틸리티 만들기

서버 컴포넌트에서 데이터 프리페치를 더 쉽게 관리하기 위한 유틸리티 함수를 만들어 보겠습니다.

기본 프리페치 유틸리티

// lib/prefetchQuery.ts
import { QueryClient, QueryKey, QueryFunction } from '@tanstack/react-query';
import { getQueryClient } from './getQueryClient';

export async function prefetchQuery<T>({
  queryKey,
  queryFn,
  staleTime,
}: {
  queryKey: QueryKey;
  queryFn: QueryFunction<T>;
  staleTime?: number;
}) {
  const queryClient = getQueryClient();
  
  return queryClient.prefetchQuery({
    queryKey,
    queryFn,
    staleTime,
  });
}

타입 안전한 프리페치 래퍼

API 엔드포인트별로 타입 안전한 프리페치 함수를 만들 수 있습니다:

// lib/queries/userQueries.ts
import { prefetchQuery } from '../prefetchQuery';

// 사용자 데이터 타입
interface User {
  id: number;
  name: string;
  email: string;
}

// 사용자 데이터를 가져오는 함수
export async function fetchUsers(): Promise<User[]> {
  const res = await fetch('https://api.example.com/users');
  if (!res.ok) throw new Error('Failed to fetch users');
  return res.json();
}

// 특정 사용자 데이터를 가져오는 함수
export async function fetchUser(userId: number): Promise<User> {
  const res = await fetch(`https://api.example.com/users/${userId}`);
  if (!res.ok) throw new Error(`Failed to fetch user ${userId}`);
  return res.json();
}

// 사용자 목록 프리페치
export function prefetchUsers() {
  return prefetchQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    staleTime: 60 * 1000, // 1분
  });
}

// 특정 사용자 프리페치
export function prefetchUser(userId: number) {
  return prefetchQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 30 * 1000, // 30초
  });
}

서버 컴포넌트에서 사용

// app/users/[id]/page.tsx
import { dehydrate } from '@tanstack/react-query';
import { HydrationBoundary } from '@/components/HydrationBoundary';
import { UserProfile } from '@/components/UserProfile';
import { getQueryClient } from '@/lib/getQueryClient';
import { prefetchUser } from '@/lib/queries/userQueries';

export default async function UserPage({ params }: { params: { id: string } }) {
  const userId = parseInt(params.id);
  
  // 사용자 데이터 프리페치
  await prefetchUser(userId);
  
  // 쿼리 캐시 탈수
  const queryClient = getQueryClient();
  const dehydratedState = dehydrate(queryClient);
  
  return (
    <HydrationBoundary state={dehydratedState}>
      <h1>사용자 프로필</h1>
      <UserProfile userId={userId} />
    </HydrationBoundary>
  );
}

클라이언트 컴포넌트에서 사용

// app/components/UserProfile.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { fetchUser } from '@/lib/queries/userQueries';

export function UserProfile({ userId }: { userId: number }) {
  // 하이드레이션된 데이터 사용
  const { data, isLoading, error } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
  });
  
  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>에러 발생: {error.message}</div>;
  
  return (
    <div>
      <h2>{data.name}</h2>
      <p>이메일: {data.email}</p>
    </div>
  );
}

초기 페이지 로드 경험 최적화

하이드레이션을 통해 초기 페이지 로드 경험을 최적화하는 몇 가지 전략을 살펴보겠습니다.

스트리밍 SSR 활용

NextJS 13 이상에서는 스트리밍 SSR을 활용하여 페이지의 일부를 먼저 렌더링하고 데이터가 필요한 부분은 나중에 스트리밍할 수 있습니다:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { dehydrate } from '@tanstack/react-query';
import { HydrationBoundary } from '@/components/HydrationBoundary';
import { UserStats } from '@/components/UserStats';
import { RecentActivity } from '@/components/RecentActivity';
import { getQueryClient } from '@/lib/getQueryClient';
import { prefetchUserStats } from '@/lib/queries/statsQueries';

// 로딩 컴포넌트
function StatsLoading() {
  return <div className="skeleton-loader">통계 로딩 중...</div>;
}

function ActivityLoading() {
  return <div className="skeleton-loader">활동 로딩 중...</div>;
}

export default async function DashboardPage() {
  // 사용자 통계 데이터 프리페치
  const queryClient = getQueryClient();
  await prefetchUserStats();
  const dehydratedState = dehydrate(queryClient);
  
  return (
    <div className="dashboard">
      <h1>대시보드</h1>
      
      {/* 사용자 통계 - 하이드레이션 사용 */}
      <Suspense fallback={<StatsLoading />}>
        <HydrationBoundary state={dehydratedState}>
          <UserStats />
        </HydrationBoundary>
      </Suspense>
      
      {/* 최근 활동 - 별도의 스트리밍 */}
      <Suspense fallback={<ActivityLoading />}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

중첩된 하이드레이션 경계

복잡한 페이지에서는 여러 하이드레이션 경계를 사용하여 각 섹션을 독립적으로 관리할 수 있습니다:

// app/profile/page.tsx
import { Suspense } from 'react';
import { dehydrate } from '@tanstack/react-query';
import { HydrationBoundary } from '@/components/HydrationBoundary';
import { UserInfo } from '@/components/UserInfo';
import { UserPosts } from '@/components/UserPosts';
import { UserFriends } from '@/components/UserFriends';
import { getQueryClient } from '@/lib/getQueryClient';
import { prefetchUserInfo, prefetchUserPosts, prefetchUserFriends } from '@/lib/queries/userQueries';

export default async function ProfilePage() {
  const userId = 1; // 실제로는 인증된 사용자 ID
  
  // 사용자 정보 프리페치
  const infoQueryClient = getQueryClient();
  await prefetchUserInfo(userId);
  const infoState = dehydrate(infoQueryClient);
  
  // 사용자 게시물 프리페치
  const postsQueryClient = getQueryClient();
  await prefetchUserPosts(userId);
  const postsState = dehydrate(postsQueryClient);
  
  // 사용자 친구 프리페치
  const friendsQueryClient = getQueryClient();
  await prefetchUserFriends(userId);
  const friendsState = dehydrate(friendsQueryClient);
  
  return (
    <div className="profile">
      {/* 사용자 정보 섹션 */}
      <Suspense fallback={<div>사용자 정보 로딩 중...</div>}>
        <HydrationBoundary state={infoState}>
          <UserInfo userId={userId} />
        </HydrationBoundary>
      </Suspense>
      
      <div className="profile-content">
        {/* 사용자 게시물 섹션 */}
        <Suspense fallback={<div>게시물 로딩 중...</div>}>
          <HydrationBoundary state={postsState}>
            <UserPosts userId={userId} />
          </HydrationBoundary>
        </Suspense>
        
        {/* 사용자 친구 섹션 */}
        <Suspense fallback={<div>친구 목록 로딩 중...</div>}>
          <HydrationBoundary state={friendsState}>
            <UserFriends userId={userId} />
          </HydrationBoundary>
        </Suspense>
      </div>
    </div>
  );
}

점진적 하이드레이션

중요한 콘텐츠를 먼저 하이드레이션하고 덜 중요한 콘텐츠는 나중에 하이드레이션하는 전략을 사용할 수 있습니다:

// app/components/DeferredHydration.tsx
'use client';

import { useState, useEffect } from 'react';
import { HydrationBoundary, HydrationBoundaryProps } from '@tanstack/react-query';

export function DeferredHydration({ 
  children, 
  state, 
  deferTime = 1000 
}: HydrationBoundaryProps & { deferTime?: number }) {
  const [shouldHydrate, setShouldHydrate] = useState(false);
  
  useEffect(() => {
    const timer = setTimeout(() => {
      setShouldHydrate(true);
    }, deferTime);
    
    return () => clearTimeout(timer);
  }, [deferTime]);
  
  if (!shouldHydrate) {
    // 하이드레이션 전에는 서버에서 렌더링된 HTML만 표시
    return <>{children}</>;
  }
  
  // 지정된 시간 후 하이드레이션
  return <HydrationBoundary state={state}>{children}</HydrationBoundary>;
}

이 컴포넌트를 사용하여 중요하지 않은 콘텐츠의 하이드레이션을 지연시킬 수 있습니다:

// 중요한 콘텐츠는 즉시 하이드레이션
<HydrationBoundary state={criticalState}>
  <CriticalContent />
</HydrationBoundary>

// 덜 중요한 콘텐츠는 지연 하이드레이션
<DeferredHydration state={nonCriticalState} deferTime={2000}>
  <NonCriticalContent />
</DeferredHydration>

로딩 및 오류 상태 처리

하이드레이션을 사용할 때도 로딩 및 오류 상태를 적절히 처리하는 것이 중요합니다. TanStack Query와 NextJS를 함께 사용하여 이러한 상태를 효과적으로 처리하는 방법을 살펴보겠습니다.

서스펜스와 오류 경계 활용

NextJS 13 이상에서는 React의 Suspense와 Error Boundary를 활용하여 로딩 및 오류 상태를 선언적으로 처리할 수 있습니다:

// app/error.tsx
'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // 오류 로깅
    console.error(error);
  }, [error]);

  return (
    <div className="error-container">
      <h2>문제가 발생했습니다</h2>
      <p>{error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  );
}
// app/loading.tsx
export default function Loading() {
  return (
    <div className="loading-container">
      <div className="spinner"></div>
      <p>로딩 중...</p>
    </div>
  );
}

클라이언트 컴포넌트에서의 로딩 상태

클라이언트 컴포넌트에서는 TanStack Query의 상태 플래그를 활용하여 로딩 및 오류 상태를 처리할 수 있습니다:

// app/components/DataDisplay.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { fetchData } from '@/lib/queries/dataQueries';

export function DataDisplay() {
  const { data, isLoading, isFetching, error, refetch } = useQuery({
    queryKey: ['data'],
    queryFn: fetchData,
  });

  if (isLoading) {
    return (
      <div className="loading-skeleton">
        <div className="skeleton-item"></div>
        <div className="skeleton-item"></div>
        <div className="skeleton-item"></div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="error-message">
        <p>데이터를 불러오는 중 오류가 발생했습니다</p>
        <button onClick={() => refetch()}>다시 시도</button>
      </div>
    );
  }

  return (
    <div className="data-container">
      {data.map(item => (
        <div key={item.id} className="data-item">
          <h3>{item.title}</h3>
          <p>{item.description}</p>
        </div>
      ))}
      {isFetching && <div className="refresh-indicator">새로고침 중...</div>}
    </div>
  );
}

로딩 상태 최적화

사용자 경험을 향상시키기 위해 로딩 상태를 최적화하는 몇 가지 전략을 살펴보겠습니다:

스켈레톤 UI 사용

데이터가 로드되는 동안 콘텐츠의 레이아웃을 미리 보여주는 스켈레톤 UI를 사용할 수 있습니다:

// app/components/SkeletonCard.tsx
export function SkeletonCard() {
  return (
    <div className="card skeleton">
      <div className="skeleton-image"></div>
      <div className="skeleton-title"></div>
      <div className="skeleton-text"></div>
      <div className="skeleton-text"></div>
    </div>
  );
}
// app/components/CardList.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { fetchCards } from '@/lib/queries/cardQueries';
import { Card } from './Card';
import { SkeletonCard } from './SkeletonCard';

export function CardList() {
  const { data, isLoading } = useQuery({
    queryKey: ['cards'],
    queryFn: fetchCards,
  });

  if (isLoading) {
    return (
      <div className="card-grid">
        {Array.from({ length: 6 }).map((_, index) => (
          <SkeletonCard key={index} />
        ))}
      </div>
    );
  }

  return (
    <div className="card-grid">
      {data.map(card => (
        <Card key={card.id} card={card} />
      ))}
    </div>
  );
}

로딩 지연 표시

매우 빠른 로딩의 경우 깜빡임을 방지하기 위해 로딩 표시기를 지연시킬 수 있습니다:

// app/components/DelayedLoadingIndicator.tsx
'use client';

import { useState, useEffect } from 'react';

export function DelayedLoadingIndicator({ 
  isLoading, 
  delayMs = 500 
}: { 
  isLoading: boolean; 
  delayMs?: number;
}) {
  const [showLoading, setShowLoading] = useState(false);

  useEffect(() => {
    if (!isLoading) {
      setShowLoading(false);
      return;
    }

    const timer = setTimeout(() => {
      if (isLoading) {
        setShowLoading(true);
      }
    }, delayMs);

    return () => clearTimeout(timer);
  }, [isLoading, delayMs]);

  if (!showLoading) {
    return null;
  }

  return (
    <div className="loading-indicator">
      <div className="spinner"></div>
      <p>로딩 중...</p>
    </div>
  );
}
// 사용 예
function DataComponent() {
  const { data, isLoading } = useQuery({
    queryKey: ['data'],
    queryFn: fetchData,
  });

  return (
    <div>
      <DelayedLoadingIndicator isLoading={isLoading} delayMs={300} />
      {!isLoading && data && (
        // 데이터 표시
      )}
    </div>
  );
}

오류 처리 전략

데이터 페칭 중 발생할 수 있는 오류를 효과적으로 처리하는 방법을 살펴보겠습니다:

재시도 메커니즘

TanStack Query는 기본적으로 오류 발생 시 재시도 메커니즘을 제공합니다:

const { data } = useQuery({
  queryKey: ['data'],
  queryFn: fetchData,
  retry: 3, // 최대 3번 재시도
  retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // 지수 백오프
});

사용자 친화적인 오류 메시지

오류 발생 시 사용자에게 친화적인 메시지를 표시하고 가능한 해결 방법을 제안할 수 있습니다:

// app/components/ErrorFallback.tsx
'use client';

interface ErrorFallbackProps {
  error: Error;
  resetErrorBoundary: () => void;
}

export function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
  // 오류 유형에 따라 다른 메시지 표시
  const errorMessage = getErrorMessage(error);
  
  return (
    <div className="error-container">
      <h2>문제가 발생했습니다</h2>
      <p>{errorMessage}</p>
      <div className="error-actions">
        <button onClick={resetErrorBoundary}>다시 시도</button>
        <button onClick={() => window.location.reload()}>페이지 새로고침</button>
      </div>
    </div>
  );
}

// 오류 유형에 따라 사용자 친화적인 메시지 반환
function getErrorMessage(error: Error): string {
  if (error.message.includes('network') || error.message.includes('fetch')) {
    return '네트워크 연결을 확인해 주세요.';
  }
  
  if (error.message.includes('timeout')) {
    return '서버 응답이 지연되고 있습니다. 잠시 후 다시 시도해 주세요.';
  }
  
  if (error.message.includes('404')) {
    return '요청한 데이터를 찾을 수 없습니다.';
  }
  
  if (error.message.includes('403')) {
    return '이 데이터에 접근할 권한이 없습니다.';
  }
  
  return '데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
}

예약 데이터를 사용한 완전한 예제 구축

이제 지금까지 배운 내용을 종합하여 예약 시스템을 위한 완전한 예제를 구축해 보겠습니다.

데이터 모델 정의

// types/reservation.ts
export interface Reservation {
  id: string;
  date: string;
  time: string;
  name: string;
  email: string;
  guests: number;
  status: 'confirmed' | 'pending' | 'cancelled';
  createdAt: string;
  updatedAt: string;
}

export interface CreateReservationDto {
  date: string;
  time: string;
  name: string;
  email: string;
  guests: number;
}

export interface AvailableTimeSlot {
  time: string;
  available: boolean;
}

export interface DateAvailability {
  date: string;
  timeSlots: AvailableTimeSlot[];
}

API 클라이언트 함수

// lib/api/reservationApi.ts
import { Reservation, CreateReservationDto, DateAvailability } from '@/types/reservation';

// 특정 날짜의 가용성 조회
export async function fetchAvailability(date: string): Promise<DateAvailability> {
  const res = await fetch(`/api/availability?date=${date}`);
  if (!res.ok) throw new Error('Failed to fetch availability');
  return res.json();
}

// 예약 목록 조회
export async function fetchReservations(): Promise<Reservation[]> {
  const res = await fetch('/api/reservations');
  if (!res.ok) throw new Error('Failed to fetch reservations');
  return res.json();
}

// 특정 예약 조회
export async function fetchReservation(id: string): Promise<Reservation> {
  const res = await fetch(`/api/reservations/${id}`);
  if (!res.ok) throw new Error(`Failed to fetch reservation ${id}`);
  return res.json();
}

// 예약 생성
export async function createReservation(data: CreateReservationDto): Promise<Reservation> {
  const res = await fetch('/api/reservations', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  
  if (!res.ok) {
    const error = await res.json();
    throw new Error(error.message || 'Failed to create reservation');
  }
  
  return res.json();
}

// 예약 취소
export async function cancelReservation(id: string): Promise<Reservation> {
  const res = await fetch(`/api/reservations/${id}/cancel`, {
    method: 'POST',
  });
  
  if (!res.ok) {
    const error = await res.json();
    throw new Error(error.message || `Failed to cancel reservation ${id}`);
  }
  
  return res.json();
}

쿼리 및 프리페치 함수

// lib/queries/reservationQueries.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { prefetchQuery } from '../prefetchQuery';
import { 
  fetchAvailability, 
  fetchReservations, 
  fetchReservation,
  createReservation,
  cancelReservation
} from '../api/reservationApi';
import { CreateReservationDto } from '@/types/reservation';

// 가용성 쿼리 훅
export function useAvailabilityQuery(date: string) {
  return useQuery({
    queryKey: ['availability', date],
    queryFn: () => fetchAvailability(date),
    staleTime: 5 * 60 * 1000, // 5분
  });
}

// 예약 목록 쿼리 훅
export function useReservationsQuery() {
  return useQuery({
    queryKey: ['reservations'],
    queryFn: fetchReservations,
    staleTime: 60 * 1000, // 1분
  });
}

// 특정 예약 쿼리 훅
export function useReservationQuery(id: string) {
  return useQuery({
    queryKey: ['reservations', id],
    queryFn: () => fetchReservation(id),
    staleTime: 60 * 1000, // 1분
  });
}

// 예약 생성 뮤테이션 훅
export function useCreateReservationMutation() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (data: CreateReservationDto) => createReservation(data),
    onSuccess: (data) => {
      // 예약 목록 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: ['reservations'] });
      
      // 해당 날짜의 가용성 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: ['availability', data.date] });
      
      // 새 예약 데이터를 캐시에 추가
      queryClient.setQueryData(['reservations', data.id], data);
    },
  });
}

// 예약 취소 뮤테이션 훅
export function useCancelReservationMutation() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (id: string) => cancelReservation(id),
    onSuccess: (data) => {
      // 예약 목록 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: ['reservations'] });
      
      // 해당 날짜의 가용성 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: ['availability', data.date] });
      
      // 취소된 예약 데이터 업데이트
      queryClient.setQueryData(['reservations', data.id], data);
    },
  });
}

// 서버 컴포넌트용 프리페치 함수
export function prefetchAvailability(date: string) {
  return prefetchQuery({
    queryKey: ['availability', date],
    queryFn: () => fetchAvailability(date),
    staleTime: 5 * 60 * 1000, // 5분
  });
}

export function prefetchReservations() {
  return prefetchQuery({
    queryKey: ['reservations'],
    queryFn: fetchReservations,
    staleTime: 60 * 1000, // 1분
  });
}

export function prefetchReservation(id: string) {
  return prefetchQuery({
    queryKey: ['reservations', id],
    queryFn: () => fetchReservation(id),
    staleTime: 60 * 1000, // 1분
  });
}

서버 컴포넌트와 클라이언트 컴포넌트 통합

이제 서버 컴포넌트와 클라이언트 컴포넌트를 통합하여 완전한 예약 시스템 페이지를 구축해 보겠습니다.

예약 페이지 구현

// app/reservations/page.tsx
import { Suspense } from 'react';
import { prefetchReservations } from '@/lib/queries/reservationQueries';
import { ReservationList } from '@/components/ReservationList';
import { ReservationCalendar } from '@/components/ReservationCalendar';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';

export default async function ReservationsPage() {
  // 서버에서 예약 데이터 프리페치
  const queryClient = await prefetchReservations();
  
  return (
    <div className="reservations-page">
      <h1>예약 관리</h1>
      
      <div className="reservations-layout">
        <div className="reservations-sidebar">
          <Suspense fallback={<div>캘린더 로딩 중...</div>}>
            <ReservationCalendar />
          </Suspense>
        </div>
        
        <div className="reservations-main">
          <HydrationBoundary state={dehydrate(queryClient)}>
            <Suspense fallback={<div>예약 목록 로딩 중...</div>}>
              <ReservationList />
            </Suspense>
          </HydrationBoundary>
        </div>
      </div>
    </div>
  );
}

예약 목록 컴포넌트

// components/ReservationList.tsx
'use client';

import { useState } from 'react';
import { useReservationsQuery, useCancelReservationMutation } from '@/lib/queries/reservationQueries';
import { ReservationItem } from './ReservationItem';
import { ReservationFilter } from './ReservationFilter';
import { Reservation } from '@/types/reservation';

export function ReservationList() {
  const [filter, setFilter] = useState<'all' | 'confirmed' | 'pending' | 'cancelled'>('all');
  const { data: reservations, isLoading, error } = useReservationsQuery();
  const cancelMutation = useCancelReservationMutation();
  
  if (isLoading) {
    return (
      <div className="reservation-list-skeleton">
        {Array.from({ length: 5 }).map((_, index) => (
          <div key={index} className="reservation-item-skeleton">
            <div className="skeleton-line"></div>
            <div className="skeleton-line"></div>
            <div className="skeleton-line"></div>
          </div>
        ))}
      </div>
    );
  }
  
  if (error) {
    return (
      <div className="error-container">
        <p>예약 목록을 불러오는 중 오류가 발생했습니다.</p>
        <button onClick={() => window.location.reload()}>다시 시도</button>
      </div>
    );
  }
  
  // 필터링된 예약 목록
  const filteredReservations = reservations.filter(
    reservation => filter === 'all' || reservation.status === filter
  );
  
  // 예약 취소 핸들러
  const handleCancelReservation = (id: string) => {
    if (window.confirm('이 예약을 취소하시겠습니까?')) {
      cancelMutation.mutate(id);
    }
  };
  
  return (
    <div className="reservation-list">
      <div className="reservation-list-header">
        <h2>예약 목록</h2>
        <ReservationFilter currentFilter={filter} onFilterChange={setFilter} />
      </div>
      
      {filteredReservations.length === 0 ? (
        <div className="empty-list">
          <p>표시할 예약이 없습니다.</p>
        </div>
      ) : (
        <div className="reservation-items">
          {filteredReservations.map(reservation => (
            <ReservationItem
              key={reservation.id}
              reservation={reservation}
              onCancel={() => handleCancelReservation(reservation.id)}
              isCancelling={cancelMutation.isPending && cancelMutation.variables === reservation.id}
            />
          ))}
        </div>
      )}
    </div>
  );
}

예약 상세 페이지

// app/reservations/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { prefetchReservation } from '@/lib/queries/reservationQueries';
import { ReservationDetail } from '@/components/ReservationDetail';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';

interface ReservationDetailPageProps {
  params: {
    id: string;
  };
}

export default async function ReservationDetailPage({ params }: ReservationDetailPageProps) {
  try {
    // 서버에서 특정 예약 데이터 프리페치
    const queryClient = await prefetchReservation(params.id);
    
    return (
      <div className="reservation-detail-page">
        <HydrationBoundary state={dehydrate(queryClient)}>
          <Suspense fallback={<div>예약 정보 로딩 중...</div>}>
            <ReservationDetail id={params.id} />
          </Suspense>
        </HydrationBoundary>
      </div>
    );
  } catch (error) {
    // 예약을 찾을 수 없는 경우 404 페이지로 리디렉션
    notFound();
  }
}

예약 상세 컴포넌트

// components/ReservationDetail.tsx
'use client';

import { useRouter } from 'next/navigation';
import { useReservationQuery, useCancelReservationMutation } from '@/lib/queries/reservationQueries';
import { formatDate } from '@/lib/utils/dateUtils';

interface ReservationDetailProps {
  id: string;
}

export function ReservationDetail({ id }: ReservationDetailProps) {
  const router = useRouter();
  const { data: reservation, isLoading, error } = useReservationQuery(id);
  const cancelMutation = useCancelReservationMutation();
  
  if (isLoading) {
    return (
      <div className="reservation-detail-skeleton">
        <div className="skeleton-header"></div>
        <div className="skeleton-info">
          <div className="skeleton-line"></div>
          <div className="skeleton-line"></div>
          <div className="skeleton-line"></div>
          <div className="skeleton-line"></div>
        </div>
      </div>
    );
  }
  
  if (error) {
    return (
      <div className="error-container">
        <p>예약 정보를 불러오는 중 오류가 발생했습니다.</p>
        <button onClick={() => router.refresh()}>다시 시도</button>
        <button onClick={() => router.back()}>뒤로 가기</button>
      </div>
    );
  }
  
  // 예약 취소 핸들러
  const handleCancelReservation = () => {
    if (window.confirm('이 예약을 취소하시겠습니까?')) {
      cancelMutation.mutate(id, {
        onSuccess: () => {
          alert('예약이 취소되었습니다.');
        },
      });
    }
  };
  
  // 상태에 따른 배지 스타일
  const getStatusBadgeClass = (status: string) => {
    switch (status) {
      case 'confirmed': return 'badge-success';
      case 'pending': return 'badge-warning';
      case 'cancelled': return 'badge-danger';
      default: return 'badge-default';
    }
  };
  
  // 상태에 따른 한글 텍스트
  const getStatusText = (status: string) => {
    switch (status) {
      case 'confirmed': return '확정됨';
      case 'pending': return '대기 중';
      case 'cancelled': return '취소됨';
      default: return '알 수 없음';
    }
  };
  
  return (
    <div className="reservation-detail">
      <div className="reservation-detail-header">
        <h1>예약 상세 정보</h1>
        <span className={`status-badge ${getStatusBadgeClass(reservation.status)}`}>
          {getStatusText(reservation.status)}
        </span>
      </div>
      
      <div className="reservation-detail-info">
        <div className="info-group">
          <h2>예약 정보</h2>
          <div className="info-row">
            <span className="info-label">예약 ID:</span>
            <span className="info-value">{reservation.id}</span>
          </div>
          <div className="info-row">
            <span className="info-label">날짜:</span>
            <span className="info-value">{formatDate(reservation.date)}</span>
          </div>
          <div className="info-row">
            <span className="info-label">시간:</span>
            <span className="info-value">{reservation.time}</span>
          </div>
          <div className="info-row">
            <span className="info-label">인원:</span>
            <span className="info-value">{reservation.guests}</span>
          </div>
        </div>
        
        <div className="info-group">
          <h2>고객 정보</h2>
          <div className="info-row">
            <span className="info-label">이름:</span>
            <span className="info-value">{reservation.name}</span>
          </div>
          <div className="info-row">
            <span className="info-label">이메일:</span>
            <span className="info-value">{reservation.email}</span>
          </div>
        </div>
        
        <div className="info-group">
          <h2>예약 이력</h2>
          <div className="info-row">
            <span className="info-label">생성일:</span>
            <span className="info-value">{formatDate(reservation.createdAt)}</span>
          </div>
          <div className="info-row">
            <span className="info-label">최종 수정일:</span>
            <span className="info-value">{formatDate(reservation.updatedAt)}</span>
          </div>
        </div>
      </div>
      
      <div className="reservation-detail-actions">
        <button 
          className="button-back" 
          onClick={() => router.back()}
        >
          뒤로 가기
        </button>
        
        {reservation.status !== 'cancelled' && (
          <button 
            className="button-cancel" 
            onClick={handleCancelReservation}
            disabled={cancelMutation.isPending}
          >
            {cancelMutation.isPending ? '취소 중...' : '예약 취소'}
          </button>
        )}
      </div>
    </div>
  );
}

성능 최적화 및 모범 사례

지금까지 구현한 하이드레이션 기반 아키텍처를 더욱 최적화하기 위한 몇 가지 모범 사례를 살펴보겠습니다.

쿼리 키 관리

대규모 애플리케이션에서는 쿼리 키를 체계적으로 관리하는 것이 중요합니다:

// lib/queryKeys.ts
export const queryKeys = {
  reservations: {
    all: ['reservations'] as const,
    lists: () => [...queryKeys.reservations.all, 'list'] as const,
    detail: (id: string) => [...queryKeys.reservations.all, 'detail', id] as const,
  },
  availability: {
    all: ['availability'] as const,
    byDate: (date: string) => [...queryKeys.availability.all, date] as const,
  },
};

이렇게 정의된 쿼리 키를 사용하면 일관성을 유지하고 오타로 인한 문제를 방지할 수 있습니다:

// 사용 예
const { data } = useQuery({
  queryKey: queryKeys.reservations.detail(id),
  queryFn: () => fetchReservation(id),
});

// 무효화 예
queryClient.invalidateQueries({
  queryKey: queryKeys.reservations.all,
});

선택적 하이드레이션

모든 데이터를 하이드레이션할 필요는 없습니다. 필요한 데이터만 선택적으로 하이드레이션하여 초기 페이지 로드 시간을 최적화할 수 있습니다:

// 선택적 하이드레이션 예
export default async function ReservationsPage() {
  const queryClient = await prefetchReservations();
  
  // 필요한 데이터만 선택하여 하이드레이션
  const dehydratedState = dehydrate(queryClient, {
    shouldDehydrateQuery: query => {
      // 예약 목록 쿼리만 하이드레이션
      return query.queryKey[0] === 'reservations' && query.queryKey.length === 1;
    },
  });
  
  return (
    <HydrationBoundary state={dehydratedState}>
      {/* 컴포넌트 */}
    </HydrationBoundary>
  );
}

중첩된 하이드레이션 경계

복잡한 페이지에서는 중첩된 하이드레이션 경계를 사용하여 데이터를 더 효율적으로 관리할 수 있습니다:

export default async function ComplexPage() {
  const mainQueryClient = await prefetchMainData();
  
  return (
    <HydrationBoundary state={dehydrate(mainQueryClient)}>
      <MainContent />
      
      <Suspense fallback={<SidebarSkeleton />}>
        {/* 사이드바 데이터는 별도로 프리페치하고 하이드레이션 */}
        <SidebarData />
      </Suspense>
    </HydrationBoundary>
  );
}

// 중첩된 하이드레이션 경계를 사용하는 컴포넌트
async function SidebarData() {
  const sidebarQueryClient = await prefetchSidebarData();
  
  return (
    <HydrationBoundary state={dehydrate(sidebarQueryClient)}>
      <Sidebar />
    </HydrationBoundary>
  );
}

데이터 변환 최적화

하이드레이션된 데이터의 크기를 최소화하기 위해 필요한 데이터만 선택하여 전송할 수 있습니다:

// 데이터 변환 함수
function transformReservation(reservation: Reservation) {
  // 필요한 필드만 선택
  const { id, date, time, name, status } = reservation;
  return { id, date, time, name, status };
}

// 변환된 데이터로 쿼리 설정
export function useReservationsQuery() {
  return useQuery({
    queryKey: ['reservations'],
    queryFn: fetchReservations,
    select: (data) => data.map(transformReservation), // 데이터 변환
    staleTime: 60 * 1000,
  });
}

캐시 지속성

사용자 세션 간에 쿼리 캐시를 유지하여 반복 방문 시 성능을 향상시킬 수 있습니다:

// app/providers.tsx
'use client';

import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => {
    const client = new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 60 * 1000,
        },
      },
    });
    
    // 브라우저 환경에서만 실행
    if (typeof window !== 'undefined') {
      const persister = createSyncStoragePersister({
        storage: window.localStorage,
      });
      
      persistQueryClient({
        queryClient: client,
        persister,
        maxAge: 24 * 60 * 60 * 1000, // 24시간
      });
    }
    
    return client;
  });
  
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

결론

이 블로그 포스트에서는 NextJS와 TanStack Query를 사용하여 서버 데이터를 클라이언트에 하이드레이션하는 방법을 살펴보았습니다. 이러한 접근 방식은 다음과 같은 이점을 제공합니다:

  1. 최적의 사용자 경험: 서버에서 데이터를 미리 가져와 클라이언트에 하이드레이션함으로써 초기 로딩 시간을 단축하고 사용자 경험을 향상시킵니다.

  2. 코드 일관성: 서버와 클라이언트 모두에서 동일한 데이터 접근 패턴을 사용하여 코드 일관성을 유지합니다.

  3. 타입 안전성: TypeScript와 함께 사용하면 데이터 구조에 대한 타입 안전성을 보장할 수 있습니다.

  4. 효율적인 캐싱: TanStack Query의 강력한 캐싱 메커니즘을 활용하여 불필요한 네트워크 요청을 줄이고 애플리케이션 성능을 향상시킵니다.

  5. 점진적 향상: 서버 렌더링과 클라이언트 하이드레이션을 결합하여 점진적 향상을 제공합니다.

NextJS의 App Router와 TanStack Query의 하이드레이션 기능을 함께 사용하면 현대적이고 성능이 뛰어난 웹 애플리케이션을 구축할 수 있습니다. 이 접근 방식은 특히 데이터 중심 애플리케이션에서 유용하며, 사용자에게 빠르고 반응성이 뛰어난 경험을 제공합니다.

하이드레이션 기반 아키텍처를 구현할 때는 데이터 일관성, 성능 최적화, 오류 처리 등 여러 측면을 고려해야 합니다. 이 블로그 포스트에서 제시한 패턴과 모범 사례를 따르면 이러한 과제를 효과적으로 해결하고 견고한 애플리케이션을 구축할 수 있습니다.

NextJS와 TanStack Query를 함께 사용하여 서버 데이터를 클라이언트에 하이드레이션하는 방법을 마스터하면 현대적인 웹 개발의 최전선에 서게 될 것입니다. 이 기술 스택은 계속 발전하고 있으므로, 공식 문서를 정기적으로 확인하고 커뮤니티의 모범 사례를 따르는 것이 중요합니다.

0개의 댓글

관련 채용 정보