안녕하세요! 오늘은 NextJS 애플리케이션에서 현대적이고 견고한 API 아키텍처를 구축하는 방법에 대해 알아보겠습니다. 대규모 웹 애플리케이션을 개발하다 보면 API 통신 관리가 복잡해지고, 타입 안전성과 캐싱 전략이 중요한 과제로 떠오릅니다. 이 글에서는 NextJS, Orval, TanStack Query를 조합하여 이러한 문제를 효과적으로 해결하는 방법을 소개합니다.
이 아키텍처의 핵심은 타입 안전성과 캐싱 전략의 최적화입니다. 서버와 클라이언트 모두에서 효율적인 데이터 관리가 가능하며, 개발자 경험도 향상됩니다.
NextJS는 React 기반 프레임워크로, 서버 컴포넌트와 클라이언트 컴포넌트를 모두 지원합니다. API 아키텍처에서 NextJS는:
Orval은 OpenAPI 명세를 기반으로 API 클라이언트 코드를 자동 생성하는 도구입니다:
TypeScript 타입 정의 자동 생성
TanStack Query(React Query)는 클라이언트 측 데이터 페칭 및 캐싱 라이브러리입니다:
Fetch는 네트워크 요청을 보내는 웹 표준 API입니다:
효율적인 API 아키텍처를 위한 프로젝트 구조는 다음과 같습니다:
project/
├── app/ # NextJS 앱 디렉토리
│ ├── layout.tsx # 앱 레이아웃 (QueryClientProvider 포함)
│ └── [feature]/ # 기능별 디렉토리
│ ├── page.tsx # 서버 컴포넌트 (데이터 프리페칭)
│ └── client.tsx # 클라이언트 컴포넌트 (useQuery 사용)
├── api/ # API 관련 코드
│ ├── generated/ # Orval로 생성된 코드
│ │ ├── [tag]/ # API 태그별 생성된 코드
│ │ └── model/ # 생성된 타입 정의
│ └── mutator/ # 커스텀 HTTP 클라이언트
│ └── custom-instance.ts
├── configs/ # 설정 파일
│ └── tanstack-query/ # TanStack Query 설정
│ ├── get-query-client.ts
│ ├── prefetch-query.ts
│ └── query-client-options.ts
├── openapi.json # OpenAPI 명세 파일
└── orval.config.ts # Orval 설정 파일
이 구조는 관심사 분리와 코드 재사용성을 촉진합니다.
Orval을 설정하는 방법을 살펴보겠습니다
npm install orval @tanstack/react-query
# or
yarn add orval @tanstack/react-query
import { defineConfig } from 'orval';
export default defineConfig({
api: {
input: `./openapi.json`, // OpenAPI 문서 경로
output: {
mode: 'tags-split',
target: './api/generated',
schemas: './api/model',
client: 'react-query',
prettier: true,
httpClient: 'fetch',
override: {
mutator: {
path: './api/mutator/custom-instance.ts',
name: 'customInstance',
},
query: {
useQuery: true,
usePrefetch: true,
signal: true,
},
fetch: {
includeHttpResponseReturnType: false,
},
},
},
},
});
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return String(error);
};
const logApiCall = (url: string, startTime: number, endTime: number) => {
const duration = endTime - startTime;
if (typeof window === 'undefined') {
console.log('📡 API Call:', {
url,
duration: `${duration}ms`,
});
}
};
const getAuthToken = () => {
if (typeof window !== 'undefined') {
return localStorage.getItem('auth_token');
}
return null;
};
export const customInstance = async <T>(url: string, options?: RequestInit): Promise<T> => {
const startTime = Date.now();
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url}`;
try {
const authToken = getAuthToken();
const headers = {
'Content-Type': 'application/json',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...options?.headers,
};
const response = await fetch(fullUrl, {
...options,
headers,
});
const endTime = Date.now();
const data = await response.json();
logApiCall(fullUrl, startTime, endTime);
return data as T;
} catch (error) {
const errorMessage = getErrorMessage(error);
console.error(`API call failed: ${errorMessage}`);
throw error;
}
};
export default customInstance;
npx orval
이 명령은 OpenAPI 명세를 기반으로 타입 정의와 API 클라이언트 코드를 자동으로 생성합니다.
TanStack Query를 NextJS와 통합하는 방법을 알아보겠습니다.
import { Query, defaultShouldDehydrateQuery } from '@tanstack/react-query';
const queryClientOptions = {
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분
retry: 1,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
dehydrate: {
// 기본적으로는 성공한 쿼리만 포함되지만,
// 여기서는 대기 중인 쿼리도 포함합니다
shouldDehydrateQuery: (query: Query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
};
export default queryClientOptions;
import { QueryClient } from '@tanstack/react-query';
import queryClientOptions from './query-client-options';
function getQueryClient() {
return new QueryClient(queryClientOptions);
}
export default getQueryClient;
import { type DehydratedState, dehydrate } from '@tanstack/react-query';
import getQueryClient from './get-query-client';
/**
* 서버 사이드에서 데이터를 프리페치하고 탈수화된 상태를 반환합니다
* @param prefetchFn - 프리페치를 수행하는 함수
* @returns HydrationBoundary에 전달될 탈수화된 상태
*/
export async function prefetchQuery(
prefetchFn: (queryClient: ReturnType<typeof getQueryClient>) => Promise<void>
): Promise<DehydratedState> {
const queryClient = getQueryClient();
// 프리페치 함수 실행
await prefetchFn(queryClient);
// 쿼리 캐시 탈수화
return dehydrate(queryClient);
}
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { PropsWithChildren, useEffect, useState } from 'react';
import getQueryClient from '@/configs/tanstack-query/get-query-client';
function AppProvider({ children }: PropsWithChildren) {
const queryClient = getQueryClient();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<QueryClientProvider client={queryClient}>
<div style={{ visibility: mounted ? 'visible' : 'hidden' }}>{children}</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
export default AppProvider;
import { HydrationBoundary } from '@tanstack/react-query';
import { prefetchQuery } from '@/configs/tanstack-query/prefetch-query';
const Layout = async ({ children }: { children: React.ReactNode }) => {
const dehydratedState = await prefetchQuery(async (queryClient) => {
const { prefetchGetApiUserReservation, getApiUserReservation } = await import(
'@/api/generated/users/users'
);
await prefetchGetApiUserReservation(queryClient, {
query: {
queryFn: () =>
getApiUserReservation({
cache: 'force-cache',
next: { revalidate: 30 },
}),
},
});
});
return <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>;
};
export default Layout;
'use client';
import { useGetApiUserReservation } from '@/api/generated/users/users';
import { format } from 'date-fns';
export default function ReservationStatusContent() {
const { data, isLoading } = useGetApiUserReservation();
if (isLoading) return <div>Loading...</div>;
const reservation = data?.result;
if (!reservation) {
return (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-yellow-700">예약 정보가 없습니다.</p>
</div>
);
}
// Format the reservation date
const formattedDate = reservation.reservationDtm
? format(new Date(reservation.reservationDtm), 'yyyy년 MM월 dd일 HH:mm')
: '날짜 정보 없음';
console.log('Reservation data from cache:', data);
return (
<div className="bg-white shadow-md rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">예약 상세 정보</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between border-b pb-2">
<span className="font-medium text-gray-600">예약 ID</span>
<span>{reservation.consultingReservationId}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="font-medium text-gray-600">예약 일시</span>
<span>{formattedDate}</span>
</div>
</div>
</div>
</div>
);
}
이 아키텍처는 다음과 같은 여러 이점을 제공합니다:
NextJS, Orval, TanStack Query를 조합한 API 아키텍처는 현대적인 웹 애플리케이션 개발에 있어 강력한 솔루션을 제공합니다. 타입 안전성, 자동화된 코드 생성, 효율적인 캐싱 전략을 통해 개발자 경험을 향상시키고 애플리케이션의 성능과 유지보수성을 개선할 수 있습니다.
이 아키텍처는 특히 대규모 프로젝트나 복잡한 API 통신이 필요한 애플리케이션에서 그 가치를 발휘합니다. 다음 글에서는 Orval을 더 깊이 살펴보고, 타입 안전 API 클라이언트를 더 효과적으로 활용하는 방법에 대해 알아보겠습니다.
이 블로그 시리즈가 여러분의 NextJS 프로젝트에 도움이 되길 바랍니다. 질문이나 의견이 있으시면 댓글로 남겨주세요!