안녕하세요! 이전 글에서는 NextJS, Orval, TanStack Query를 활용한 현대적인 API 아키텍처의 기본 구조에 대해 알아보았습니다. 이번 글에서는 Orval을 더 깊이 살펴보고, OpenAPI 명세를 기반으로 타입 안전한 API 클라이언트를 생성하고 커스터마이징하는 방법에 대해 자세히 알아보겠습니다.
Orval은 OpenAPI 명세를 기반으로 TypeScript 타입과 API 클라이언트 코드를 자동으로 생성해주는 강력한 도구입니다. 이를 통해 백엔드 API와 프론트엔드 코드 간의 일관성을 유지하고, 타입 안전성을 보장할 수 있습니다.
먼저 Orval을 설치하고 기본 설정을 살펴보겠습니다:
npm install orval --save-dev
# or
yarn add orval --dev
프로젝트 루트에 orval.config.ts 파일을 생성합니다:
import { defineConfig } from 'orval';
export default defineConfig({
api: {
input: {
target: './openapi.json',
// 또는 원격 URL을 사용할 수도 있습니다
// target: 'https://api.example.com/openapi.json',
},
output: {
mode: 'tags-split', // API 태그별로 파일 분리
target: './api/generated',
schemas: './api/model', // 타입 정의 저장 위치
client: 'react-query', // TanStack Query 통합
prettier: true, // Prettier로 코드 포맷팅
override: {
// 추가 설정들
},
},
},
});
Orval은 다양한 고급 구성 옵션을 제공합니다:
export default defineConfig({
api: {
input: {
target: './openapi.json',
validation: true, // OpenAPI 스키마 유효성 검사
},
output: {
mode: 'tags-split',
target: './api/generated',
schemas: './api/model',
client: 'react-query',
prettier: true,
clean: true, // 생성 전 기존 파일 정리
tslint: true, // TSLint 적용
tsconfig: './tsconfig.json', // 사용할 tsconfig 경로
mock: true, // 목 데이터 생성
mockFolder: './mocks', // 목 데이터 저장 위치
override: {
title: (title) => `${title}Api`, // 타이틀 변환
operations: {
// 특정 작업 재정의
'get-users': {
mutator: {
path: './api/mutator/custom-instance.ts',
name: 'customInstance',
},
},
},
components: {
// 특정 컴포넌트 스키마 재정의
schemas: {
'User': {
transform: {
// 스키마 변환 로직
}
}
}
},
query: {
useQuery: true,
useInfinite: true,
useInfiniteQueryParam: 'cursor',
options: {
staleTime: 10000,
},
},
},
},
},
});
여러 API를 사용하는 경우 다음과 같이 구성할 수 있습니다:
export default defineConfig({
mainApi: {
input: './openapi/main-api.json',
output: {
mode: 'tags-split',
target: './api/main/generated',
schemas: './api/main/model',
client: 'react-query',
},
},
secondaryApi: {
input: './openapi/secondary-api.json',
output: {
mode: 'tags-split',
target: './api/secondary/generated',
schemas: './api/secondary/model',
client: 'react-query',
},
},
});
Orval은 기본적으로 axios나 fetch를 사용하지만, 커스텀 HTTP 클라이언트를 설정할 수 있습니다. 우리는 fetch를 기반으로 한 커스텀 인스턴스를 만들어 보겠습니다:
// api/mutator/custom-instance.ts
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// 요청 타이머 (성능 측정용)
const startTimer = () => {
return Date.now();
};
const endTimer = (start: number, url: string) => {
const duration = Date.now() - start;
console.log(`📡 API Call to ${url} took ${duration}ms`);
return duration;
};
// 인증 토큰 관리
const getAuthToken = () => {
if (typeof window !== 'undefined') {
return localStorage.getItem('auth_token');
}
return null;
};
// 커스텀 fetch 인스턴스
export const customInstance = async <T>(url: string, options?: RequestInit): Promise<T> => {
const startTime = startTimer();
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,
});
endTimer(startTime, fullUrl);
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data as T;
} catch (error) {
console.error(`API call to ${fullUrl} failed:`, error);
throw error;
}
};
export default customInstance;
그리고 Orval 설정에서 이 커스텀 인스턴스를 사용하도록 지정합니다:
// orval.config.ts
export default defineConfig({
api: {
// ...
output: {
// ...
httpClient: 'fetch',
override: {
mutator: {
path: './api/mutator/custom-instance.ts',
name: 'customInstance',
},
},
},
},
});
Orval은 생성된 코드를 커스터마이징할 수 있는 다양한 옵션을 제공합니다:
특정 API 엔드포인트에 대한 동작을 재정의할 수 있습니다:
export default defineConfig({
api: {
// ...
output: {
// ...
override: {
operations: {
'getUserById': {
query: {
useQuery: true,
options: {
// 이 엔드포인트에 대한 특별한 설정
staleTime: 1000 * 60 * 10, // 10분
cacheTime: 1000 * 60 * 30, // 30분
},
},
},
'createUser': {
mutator: {
// 특정 엔드포인트에 대한 다른 mutator 사용
path: './api/mutator/special-instance.ts',
name: 'specialInstance',
},
},
},
},
},
},
});
API 응답을 자동으로 변환할 수 있습니다:
export default defineConfig({
api: {
// ...
output: {
// ...
override: {
transformer: {
// 응답 데이터 변환
responseDataTransformer: (response, operation) => {
if (operation === 'getUsers') {
return `${response}.data`;
}
return response;
},
},
},
},
},
});
생성된 타입 정의를 커스터마이징할 수 있습니다:
export default defineConfig({
api: {
// ...
output: {
// ...
override: {
components: {
schemas: {
'User': {
// User 스키마에 추가 속성 정의
properties: {
fullName: {
type: 'string',
description: '사용자 전체 이름',
},
},
// 특정 속성 제외
exclude: ['password'],
},
},
},
},
},
},
});
Orval은 TanStack Query와의 통합을 위한 다양한 옵션을 제공합니다:
export default defineConfig({
api: {
// ...
output: {
// ...
client: 'react-query',
override: {
query: {
useQuery: true, // useQuery 훅 생성
useMutation: true, // useMutation 훅 생성
useInfinite: true, // useInfiniteQuery 훅 생성
useInfiniteQueryParam: 'cursor', // 무한 쿼리 파라미터 이름
usePrefetch: true, // prefetch 함수 생성
options: {
// 기본 옵션
staleTime: 1000 * 60 * 5, // 5분
cacheTime: 1000 * 60 * 30, // 30분
},
signal: true, // AbortSignal 지원
},
},
},
},
});
Orval이 생성한 쿼리 훅은 다음과 같이 사용할 수 있습니다:
// 기본 쿼리 사용
import { useGetUsers } from '@/api/generated/users/users';
function UserList() {
const { data, isLoading, error } = useGetUsers();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data?.result.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// 파라미터가 있는 쿼리 사용
import { useGetUserById } from '@/api/generated/users/users';
function UserDetail({ userId }: { userId: string }) {
const { data, isLoading } = useGetUserById(userId);
if (isLoading) return <div>Loading...</div>;
return <div>{data?.result.name}</div>;
}
// 뮤테이션 사용
import { useCreateUser } from '@/api/generated/users/users';
function CreateUserForm() {
const { mutate, isLoading } = useCreateUser();
const handleSubmit = (userData) => {
mutate(userData, {
onSuccess: (data) => {
console.log('User created:', data);
},
});
};
return (
<form onSubmit={handleSubmit}>
{/* 폼 내용 */}
</form>
);
}
NextJS 서버 컴포넌트에서 Orval이 생성한 프리페치 함수를 사용할 수 있습니다:
// app/users/layout.tsx
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 { prefetchGetUsers, getUsers } = await import('@/api/generated/users/users');
await prefetchGetUsers(queryClient, {
query: {
queryFn: () =>
getUsers({
cache: 'force-cache',
next: { revalidate: 60 }, // 1분마다 재검증
}),
},
});
});
return <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>;
};
export default Layout;
앞서 살펴본 기본적인 커스텀 인스턴스를 더 발전시켜 보겠습니다:
// api/mutator/advanced-instance.ts
import { refreshToken } from '@/lib/auth';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// 요청 재시도 로직
const retryFetch = async <T>(
url: string,
options: RequestInit,
retries = 3,
backoff = 300
): Promise<T> => {
try {
const response = await fetch(url, options);
if (response.ok) {
return await response.json();
}
// 401 Unauthorized - 토큰 갱신 시도
if (response.status === 401) {
const newToken = await refreshToken();
if (newToken) {
// 새 토큰으로 헤더 업데이트
const newOptions = {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newToken}`,
},
};
return retryFetch(url, newOptions, retries, backoff);
}
}
throw new Error(`API error: ${response.status} ${response.statusText}`);
} catch (error) {
if (retries > 0) {
// 지수 백오프로 재시도
await new Promise(resolve => setTimeout(resolve, backoff));
return retryFetch(url, options, retries - 1, backoff * 2);
}
throw error;
}
};
// 요청 캐시 (메모리)
const requestCache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 5; // 5분
// 캐시 키 생성
const getCacheKey = (url: string, options?: RequestInit) => {
const method = options?.method || 'GET';
const body = options?.body ? JSON.stringify(options.body) : '';
return `${method}:${url}:${body}`;
};
export const advancedInstance = async <T>(url: string, options?: RequestInit): Promise<T> => {
const startTime = Date.now();
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url}`;
// GET 요청에 대한 캐싱
if ((!options?.method || options.method === 'GET') && typeof window !== 'undefined') {
const cacheKey = getCacheKey(fullUrl, options);
const cachedResponse = requestCache.get(cacheKey);
if (cachedResponse && Date.now() - cachedResponse.timestamp < CACHE_TTL) {
console.log(`📦 Cache hit for ${fullUrl}`);
return cachedResponse.data as T;
}
}
try {
const authToken = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...options?.headers,
};
const response = await retryFetch<T>(fullUrl, {
...options,
headers,
}, 3, 300);
// 성공한 GET 요청 캐싱
if ((!options?.method || options.method === 'GET') && typeof window !== 'undefined') {
const cacheKey = getCacheKey(fullUrl, options);
requestCache.set(cacheKey, {
data: response,
timestamp: Date.now(),
});
}
const duration = Date.now() - startTime;
console.log(`📡 API Call to ${fullUrl} took ${duration}ms`);
return response;
} catch (error) {
console.error(`API call to ${fullUrl} failed:`, error);
throw error;
}
};
export default advancedInstance;
전역 오류 처리
// api/mutator/error-handler.ts
export class ApiError extends Error {
status: number;
data: any;
constructor(status: number, message: string, data?: any) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
export const handleApiError = (error: unknown) => {
if (error instanceof ApiError) {
// 상태 코드별 처리
switch (error.status) {
case 401:
// 인증 오류 처리
console.error('Authentication error:', error.message);
// 로그인 페이지로 리디렉션
window.location.href = '/login';
break;
case 403:
// 권한 오류 처리
console.error('Authorization error:', error.message);
break;
case 404:
// 리소스 없음 처리
console.error('Resource not found:', error.message);
break;
case 500:
// 서버 오류 처리
console.error('Server error:', error.message);
break;
default:
console.error('API error:', error.message);
}
} else {
// 네트워크 오류 등 기타 오류 처리
console.error('Unexpected error:', error);
}
};
요청과 응답을 가로채서 처리하는 인터셉터를 구현해 보겠습니다:
// api/mutator/intercepted-instance.ts
import { ApiError, handleApiError } from './error-handler';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// 요청 인터셉터
const requestInterceptor = (url: string, options?: RequestInit) => {
// 요청 로깅
console.log(`🚀 Request: ${options?.method || 'GET'} ${url}`);
// 요청 변환
const authToken = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...options?.headers,
};
// 디바이스 정보 추가
headers['X-Client-Platform'] = navigator.platform;
headers['X-Client-Version'] = '1.0.0'; // 앱 버전
return {
url,
options: {
...options,
headers,
},
};
};
// 응답 인터셉터
const responseInterceptor = async (response: Response) => {
// 응답 로깅
console.log(`📥 Response: ${response.status} ${response.url}`);
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch (e) {
errorData = { message: response.statusText };
}
throw new ApiError(
response.status,
errorData.message || `API error: ${response.status}`,
errorData
);
}
return response.json();
};
export const interceptedInstance = async <T>(url: string, options?: RequestInit): Promise<T> => {
try {
// 요청 인터셉터 적용
const intercepted = requestInterceptor(
url.startsWith('http') ? url : `${API_BASE_URL}${url}`,
options
);
// 요청 실행
const response = await fetch(intercepted.url, intercepted.options);
// 응답 인터셉터 적용
return await responseInterceptor(response);
} catch (error) {
// 오류 처리
handleApiError(error);
throw error;
}
};
export default interceptedInstance;
여러 환경(개발, 테스트, 프로덕션)에서 일관된 API 클라이언트를 사용하기 위한 팩토리 패턴을 구현해 보겠습니다:
// api/mutator/client-factory.ts
type Environment = 'development' | 'test' | 'production';
// 환경별 설정
const envConfig = {
development: {
baseUrl: 'http://localhost:3001',
timeout: 10000,
retries: 3,
},
test: {
baseUrl: 'https://test-api.example.com',
timeout: 5000,
retries: 1,
},
production: {
baseUrl: 'https://api.example.com',
timeout: 15000,
retries: 3,
},
};
// 현재 환경 감지
const getCurrentEnvironment = (): Environment => {
if (process.env.NODE_ENV === 'test') return 'test';
if (process.env.NODE_ENV === 'production') return 'production';
return 'development';
};
// API 클라이언트 팩토리
export const createApiClient = (env?: Environment) => {
const environment = env || getCurrentEnvironment();
const config = envConfig[environment];
return async <T>(url: string, options?: RequestInit): Promise<T> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
const fullUrl = url.startsWith('http') ? url : `${config.baseUrl}${url}`;
const response = await fetch(fullUrl, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
console.error(`API call failed in ${environment} environment:`, error);
throw error;
}
};
};
// 기본 클라이언트 인스턴스
export const apiClient = createApiClient();
대규모 애플리케이션에서 API 모듈을 구성하는 방법을 살펴보겠습니다:
// api/index.ts
import { apiClient } from './mutator/client-factory';
// 사용자 관련 API
export const userApi = {
// Orval로 생성된 함수 재노출
...require('./generated/users/users'),
// 추가 커스텀 함수
async getUserProfile() {
return apiClient<UserProfile>('/api/user/profile');
},
async updateUserPreferences(preferences: UserPreferences) {
return apiClient<void>('/api/user/preferences', {
method: 'PUT',
body: JSON.stringify(preferences),
});
},
};
// 인증 관련 API
export const authApi = {
...require('./generated/auth/auth'),
async checkSession() {
return apiClient<{ valid: boolean }>('/api/auth/session');
},
};
// 단일 진입점으로 모든 API 노출
export const api = {
user: userApi,
auth: authApi,
// 다른 API 모듈들...
};
API 클라이언트를 효과적으로 테스트하기 위한 모킹 방법을 살펴보겠습니다:
// api/mutator/__mocks__/custom-instance.ts
const mockResponses = new Map<string, any>();
// 모의 응답 설정
export const setMockResponse = (url: string, data: any) => {
mockResponses.set(url, data);
};
// 모의 응답 초기화
export const resetMockResponses = () => {
mockResponses.clear();
};
// 모의 fetch 인스턴스
export const customInstance = async <T>(url: string, options?: RequestInit): Promise<T> => {
console.log(`🔍 Mock API Call: ${options?.method || 'GET'} ${url}`);
// URL에 대한 모의 응답이 있는지 확인
if (mockResponses.has(url)) {
return mockResponses.get(url);
}
// 기본 모의 응답
return {
success: true,
message: 'This is a mock response',
data: null,
} as unknown as T;
};
export default customInstance;
컴포넌트에서 API 클라이언트 사용
// app/users/user-profile.tsx
'use client';
import { useGetApiUserProfile } from '@/api/generated/users/users';
import { useUpdateApiUserProfile } from '@/api/generated/users/users';
export default function UserProfile() {
// 쿼리 사용
const {
data: profile,
isLoading,
error
} = useGetApiUserProfile();
// 뮤테이션 사용
const {
mutate: updateProfile,
isPending: isUpdating
} = useUpdateApiUserProfile();
const handleSubmit = (formData) => {
updateProfile(formData, {
onSuccess: () => {
alert('프로필이 업데이트되었습니다.');
},
onError: (error) => {
alert(`오류 발생: ${error.message}`);
},
});
};
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>오류: {error.message}</div>;
return (
<div>
<h1>사용자 프로필</h1>
<form onSubmit={handleSubmit}>
{/* 폼 필드들 */}
<button type="submit" disabled={isUpdating}>
{isUpdating ? '업데이트 중...' : '저장'}
</button>
</form>
</div>
);
}
// app/users/[id]/page.tsx
import { HydrationBoundary } from '@tanstack/react-query';
import { prefetchQuery } from '@/configs/tanstack-query/prefetch-query';
import UserDetailContent from './user-detail-content';
export default async function UserDetailPage({ params }: { params: { id: string } }) {
const userId = params.id;
const dehydratedState = await prefetchQuery(async (queryClient) => {
const { prefetchGetApiUserById, getApiUserById } = await import(
'@/api/generated/users/users'
);
await prefetchGetApiUserById(queryClient, userId, {
query: {
queryFn: () =>
getApiUserById(userId, {
cache: 'force-cache',
next: { revalidate: 60 }, // 1분마다 재검증
}),
},
});
});
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">사용자 상세 정보</h1>
<HydrationBoundary state={dehydratedState}>
<UserDetailContent userId={userId} />
</HydrationBoundary>
</div>
);
}
// app/users/[id]/user-detail-content.tsx
'use client';
import { useGetApiUserById } from '@/api/generated/users/users';
export default function UserDetailContent({ userId }: { userId: string }) {
// 서버에서 프리페치된 데이터를 사용
const { data, isLoading } = useGetApiUserById(userId);
if (isLoading) return <div>로딩 중...</div>;
const user = data?.result;
if (!user) {
return <div>사용자를 찾을 수 없습니다.</div>;
}
return (
<div className="bg-white shadow-md rounded-lg p-6">
<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>{user.id}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="font-medium text-gray-600">이름</span>
<span>{user.name}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="font-medium text-gray-600">이메일</span>
<span>{user.email}</span>
</div>
</div>
</div>
</div>
);
}
데이터가 변경되었을 때 관련 쿼리를 무효화하고 리페칭하는 방법을 살펴보겠습니다:
// app/users/user-list-with-actions.tsx
'use client';
import { useQueryClient } from '@tanstack/react-query';
import {
useGetApiUsers,
useDeleteApiUser,
getGetApiUsersQueryKey
} from '@/api/generated/users/users';
export default function UserListWithActions() {
const queryClient = useQueryClient();
const { data, isLoading } = useGetApiUsers();
const { mutate: deleteUser } = useDeleteApiUser({
onSuccess: () => {
// 사용자 목록 쿼리 무효화
queryClient.invalidateQueries({
queryKey: getGetApiUsersQueryKey(),
});
},
});
const handleDelete = (userId: string) => {
if (confirm('정말 삭제하시겠습니까?')) {
deleteUser(userId);
}
};
if (isLoading) return <div>로딩 중...</div>;
return (
<div>
<h1>사용자 목록</h1>
<ul>
{data?.result.map(user => (
<li key={user.id} className="flex justify-between items-center p-2 border-b">
<span>{user.name}</span>
<button
onClick={() => handleDelete(user.id)}
className="bg-red-500 text-white px-2 py-1 rounded"
>
삭제
</button>
</li>
))}
</ul>
</div>
);
}
사용자 경험을 향상시키기 위한 낙관적 업데이트 구현 방법:
// app/todos/todo-list.tsx
'use client';
import { useQueryClient } from '@tanstack/react-query';
import {
useGetApiTodos,
useUpdateApiTodoStatus,
getGetApiTodosQueryKey
} from '@/api/generated/todos/todos';
export default function TodoList() {
const queryClient = useQueryClient();
const { data, isLoading } = useGetApiTodos();
const { mutate: updateStatus } = useUpdateApiTodoStatus({
// 낙관적 업데이트
onMutate: async ({ todoId, status }) => {
// 진행 중인 리페칭 취소
await queryClient.cancelQueries({
queryKey: getGetApiTodosQueryKey(),
});
// 이전 상태 저장
const previousTodos = queryClient.getQueryData(getGetApiTodosQueryKey());
// 낙관적으로 캐시 업데이트
queryClient.setQueryData(getGetApiTodosQueryKey(), (old: any) => {
return {
...old,
result: old.result.map(todo =>
todo.id === todoId ? { ...todo, status } : todo
),
};
});
// 이전 상태 반환 (롤백용)
return { previousTodos };
},
// 오류 발생 시 롤백
onError: (err, variables, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(
getGetApiTodosQueryKey(),
context.previousTodos
);
}
},
// 성공 또는 실패 후 리페칭
onSettled: () => {
queryClient.invalidateQueries({
queryKey: getGetApiTodosQueryKey(),
});
},
});
const handleStatusToggle = (todoId: string, currentStatus: string) => {
const newStatus = currentStatus === 'completed' ? 'pending' : 'completed';
updateStatus({ todoId, status: newStatus });
};
if (isLoading) return <div>로딩 중...</div>;
return (
<div>
<h1>할 일 목록</h1>
<ul>
{data?.result.map(todo => (
<li key={todo.id} className="flex items-center p-2 border-b">
<input
type="checkbox"
checked={todo.status === 'completed'}
onChange={() => handleStatusToggle(todo.id, todo.status)}
className="mr-2"
/>
<span className={todo.status === 'completed' ? 'line-through' : ''}>
{todo.title}
</span>
</li>
))}
</ul>
</div>
);
}
한 쿼리의 결과에 따라 다른 쿼리를 실행하는 의존적 쿼리 패턴:
// app/posts/[id]/post-with-comments.tsx
'use client';
import {
useGetApiPost,
useGetApiPostComments
} from '@/api/generated/posts/posts';
export default function PostWithComments({ postId }: { postId: string }) {
// 첫 번째 쿼리: 게시물 정보 가져오기
const {
data: postData,
isLoading: isLoadingPost,
error: postError
} = useGetApiPost(postId);
// 두 번째 쿼리: 게시물이 있을 때만 댓글 가져오기
const {
data: commentsData,
isLoading: isLoadingComments
} = useGetApiPostComments(postId, {
query: {
// 게시물이 로드되었을 때만 활성화
enabled: !!postData,
},
});
if (isLoadingPost) return <div>게시물 로딩 중...</div>;
if (postError) return <div>오류: {postError.message}</div>;
const post = postData?.result;
const comments = commentsData?.result || [];
return (
<div className="space-y-6">
<div className="bg-white shadow-md rounded-lg p-6">
<h1 className="text-2xl font-bold">{post.title}</h1>
<p className="mt-2">{post.content}</p>
</div>
<div className="bg-white shadow-md rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">댓글</h2>
{isLoadingComments ? (
<div>댓글 로딩 중...</div>
) : comments.length > 0 ? (
<ul className="space-y-4">
{comments.map(comment => (
<li key={comment.id} className="border-b pb-2">
<p className="font-medium">{comment.author}</p>
<p className="text-gray-700">{comment.content}</p>
</li>
))}
</ul>
) : (
<p>댓글이 없습니다.</p>
)}
</div>
</div>
);
}
이 글에서는 NextJS 애플리케이션에서 Orval을 활용하여 타입 안전한 API 클라이언트를 생성하고, TanStack Query와 통합하는 방법을 살펴보았습니다. 또한 커스텀 fetch 인스턴스 설정, 오류 처리, 요청/응답 인터셉터, 그리고 애플리케이션 전반에 걸친 API 클라이언트 일관성 유지 방법에 대해서도 알아보았습니다.
이러한 접근 방식은 다음과 같은 이점을 제공합니다:
다음 글에서는 NextJS와 fetch를 이용한 서버 사이드 캐싱에 대해 더 자세히 알아보겠습니다. 서버 컴포넌트에서의 데이터 페칭, 캐시 옵션, 재검증 전략 등을 다룰 예정입니다.