NextJS, Orval, Fetch, TanStack Query로 견고한 API 아키텍처 구축하기

ph8nt0m·2025년 3월 2일
0

API Architecture

목록 보기
1/4
post-thumbnail

안녕하세요! 오늘은 NextJS 애플리케이션에서 현대적이고 견고한 API 아키텍처를 구축하는 방법에 대해 알아보겠습니다. 대규모 웹 애플리케이션을 개발하다 보면 API 통신 관리가 복잡해지고, 타입 안전성과 캐싱 전략이 중요한 과제로 떠오릅니다. 이 글에서는 NextJS, Orval, TanStack Query를 조합하여 이러한 문제를 효과적으로 해결하는 방법을 소개합니다.

아키텍처 패턴 소개

  1. OpenAPI 명세: API의 엔드포인트, 요청/응답 스키마를 정의합니다.
  2. Orval: OpenAPI 명세를 기반으로 타입 안전한 API 클라이언트를 자동 생성합니다.
  3. Fetch 기반 HTTP 클라이언트: 실제 API 요청을 처리합니다.
  4. TanStack Query: 클라이언트 측 상태 관리 및 캐싱을 담당합니다.
  5. NextJS 서버 컴포넌트: 서버 측 데이터 프리페칭 및 캐싱을 처리합니다.

이 아키텍처의 핵심은 타입 안전성과 캐싱 전략의 최적화입니다. 서버와 클라이언트 모두에서 효율적인 데이터 관리가 가능하며, 개발자 경험도 향상됩니다.

각 기술의 역할 이해하기

NextJS

NextJS는 React 기반 프레임워크로, 서버 컴포넌트와 클라이언트 컴포넌트를 모두 지원합니다. API 아키텍처에서 NextJS는:

  • 서버 컴포넌트에서 데이터 프리페칭
  • 서버 사이드 캐싱 (cache: 'force-cache', next: { revalidate: 30 } 등)
  • 클라이언트와 서버 간 데이터 하이드레이션
    의 역할을 담당합니다.

Orval

Orval은 OpenAPI 명세를 기반으로 API 클라이언트 코드를 자동 생성하는 도구입니다:
TypeScript 타입 정의 자동 생성

  • TanStack Query 훅 자동 생성 (useQuery, useMutation)
  • 프리페치 함수 생성
  • 커스텀 HTTP 클라이언트 통합

TanStack Query

TanStack Query(React Query)는 클라이언트 측 데이터 페칭 및 캐싱 라이브러리입니다:

  • 선언적 데이터 페칭
  • 자동 캐싱 및 재검증
  • 로딩/에러 상태 관리
  • 낙관적 업데이트
  • 서버 상태와 클라이언트 상태 동기화

Fetch API

Fetch는 네트워크 요청을 보내는 웹 표준 API입니다:

  • 실제 HTTP 요청 처리
  • 인증 토큰 관리
  • 요청/응답 인터셉터
  • 에러 핸들링

프로젝트 구조 설정

효율적인 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 설정 파일

이 구조는 관심사 분리와 코드 재사용성을 촉진합니다.

OpenAPI 명세와 함께 Orval 구성하기

Orval을 설정하는 방법을 살펴보겠습니다

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

npm install orval @tanstack/react-query
# or
yarn add orval @tanstack/react-query

2. orval.config.ts 파일을 생성합니다:

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,
        },
      },
    },
  },
});

3. 커스텀 fetch 인스턴스를 생성합니다 (api/mutator/custom-instance.ts):

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;

4. Orval을 실행하여 API 클라이언트를 생성합니다:

npx orval

이 명령은 OpenAPI 명세를 기반으로 타입 정의와 API 클라이언트 코드를 자동으로 생성합니다.

NextJS 애플리케이션에서 TanStack Query 기본 설정

TanStack Query를 NextJS와 통합하는 방법을 알아보겠습니다.

1. QueryClient 설정 (configs/tanstack-query/query-client-options.ts):

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;

2. QueryClient 생성 함수 (configs/tanstack-query/get-query-client.ts):

import { QueryClient } from '@tanstack/react-query';
import queryClientOptions from './query-client-options';

function getQueryClient() {
  return new QueryClient(queryClientOptions);
}

export default getQueryClient;

3. 프리페치 유틸리티 (configs/tanstack-query/prefetch-query.ts):

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);
}

4. 앱 레이아웃에 QueryClientProvider 설정 (app/layout.tsx):

'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;

실제 사용 예시: 서버 컴포넌트와 클라이언트 컴포넌트

서버 컴포넌트에서 데이터 프리페칭 (app/reservation-status/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 { 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;

클라이언트 컴포넌트에서 데이터 사용 (app/reservation-status/reservation-status-content.tsx)

'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>
  );
}

유지보수성과 타입 안전성을 위한 이 아키텍처의 이점

이 아키텍처는 다음과 같은 여러 이점을 제공합니다:

1. 타입 안전성

  • OpenAPI 명세와 Orval을 통해 자동 생성된 타입 정의
  • API 응답 구조 변경 시 타입 오류로 즉시 감지
  • 자동 완성 및 타입 추론으로 개발자 경험 향상

2. 코드 생성 자동화

  • API 엔드포인트 추가/변경 시 코드 자동 생성
  • 반복적인 보일러플레이트 코드 감소
  • 일관된 API 클라이언트 패턴 유지

3. 효율적인 캐싱 전략

  • 서버 사이드 캐싱으로 초기 로딩 성능 향상
  • 클라이언트 사이드 캐싱으로 사용자 경험 개선
  • 캐시 무효화 및 재검증 전략 통합

4. 관심사 분리

  • API 통신 로직과 UI 로직 분리
  • 서버 컴포넌트와 클라이언트 컴포넌트의 명확한 역할 구분
  • 테스트 용이성 향상

5. 개발자 경험 향상

  • 일관된 API 호출 패턴
  • 디버깅 용이성 (React Query Devtools)
  • 코드 재사용성 증가

결론

NextJS, Orval, TanStack Query를 조합한 API 아키텍처는 현대적인 웹 애플리케이션 개발에 있어 강력한 솔루션을 제공합니다. 타입 안전성, 자동화된 코드 생성, 효율적인 캐싱 전략을 통해 개발자 경험을 향상시키고 애플리케이션의 성능과 유지보수성을 개선할 수 있습니다.

이 아키텍처는 특히 대규모 프로젝트나 복잡한 API 통신이 필요한 애플리케이션에서 그 가치를 발휘합니다. 다음 글에서는 Orval을 더 깊이 살펴보고, 타입 안전 API 클라이언트를 더 효과적으로 활용하는 방법에 대해 알아보겠습니다.

이 블로그 시리즈가 여러분의 NextJS 프로젝트에 도움이 되길 바랍니다. 질문이나 의견이 있으시면 댓글로 남겨주세요!

0개의 댓글

관련 채용 정보