본문 바로가기

2023-07-03 Next.js + TanStack Query 포켓몬 도감 만들기

codeConnection 2024. 7. 4.

시연

프로젝트 셋업

터미널 열기

npx create-next-app@latest

cd 프로젝트폴더명 으로 디렉토리 이동

필요 패키지 설치

yarn add axios @tanstack/react-query

 

의존성 설치

yarn

폴더 구조 만들기

📦 pokenon_project 
├─ .eslintrc.json
├─ .gitignore
├─ README.md
├─ next.config.mjs
├─ package-lock.json
├─ package.json
├─ pokemon_project ✅
│  │  └─ types
│  │     └─ package.json 🔹
│  └─ src
│     └─ app
│        ├─ globals.css
│        └─ page.tsx
├─ postcss.config.mjs
├─ public
│  └─ pokemon_ball.gif 🔹
├─ src
│  ├─ app
│  │  ├─ api
│  │  │  └─ pokemons
│  │  │     ├─ [id]
│  │  │     │  └─ route.ts 🔹
│  │  │     └─ route.ts 🔹
│  │  ├─ favicon.ico
│  │  ├─ globals.css
│  │  ├─ layout.tsx
│  │  ├─ page.tsx
│  │  ├─ pokemons
│  │  │  └─ [id]
│  │  │     └─ page.tsx 🔹
│  │  └─ provider.tsx
│  ├─ components
│  │  ├─ LoadingSpinner.tsx 🔹
│  │  └─ PokemonPageClient.tsx 🔹
│  └─ hooks
│     ├─ useFetchPokemonId.ts 🔹
│     └─ useFetchPokemonList.ts 🔹
├─ tailwind.config.ts
├─ tsconfig.json
├─ types
│  └─ pokemon.ts 🔹
└─ yarn.lock

🔹 표시는 새로 만드는 파일.

스타일링 등 기본 세팅 지우기

  • public 폴더에 있는 아이콘 2개 삭제
  • app 폴더에 있는 파비콘 삭제
  • global.css에 있는 스타일링 내용 삭제 (TailWind Import 문은 계속 사용 할 것이면 일단 주석으로 놔둠)

API 반환값 타입 지정하기

// types/pokemon.ts
export type Pokemon = {
  // 1번 API : 포켓몬 기본 정보
  id: number;
  name: string;
  korean_name: string;
  height: number;
  weight: number;
  sprites: { front_default: string };
  types: { type: { name: string; korean_name: string } }[];
  abilities: { ability: { name: string; korean_name: string } }[];
  moves: { move: { name: string; korean_name: string } }[];
  // 2번 API : 포켓몬 한글 설명
  description: string;
};

포켓몬 기본 정보를 가져오는 API 하나, 포켓몬 한글 이름을 가져오는 API 하나 두 개를 호출할 것임.

 

썬더 클라이언트 등을 이용해 API 주소에 GET 요청을 보내 보고, 반환 값을 미리 파악하며 작성한다.

그런데 이 API의 반환 값은 살벌하기 때문에 필요한 값만 남겨 둔 형태가 위와 같다.

API GET 메소드 생성

공통 route.tsx

기본 데이터와 설명 데이터 2개.

// src/app/api/route.tsx

import { NextResponse } from "next/server";

const TOTAL_POKEMON = 151;

export const GET = async (request: Request) => {
  try {
    const allPokemonPromises = Array.from({ length: TOTAL_POKEMON }, (_, index) => {
      const id = index + 1;
      return Promise.all([
        fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then(res => res.json()),
        fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`).then(res => res.json())
      ]);
    });

    const allPokemonResponses = await Promise.all(allPokemonPromises);

    const allPokemonData = allPokemonResponses.map(([response, speciesResponse], index) => {
      const koreanName = speciesResponse.names.find(
          (name: any) => name.language.name === "ko"
      );
      return { ...response, korean_name: koreanName?.name || null };
    });

    return NextResponse.json(allPokemonData);
  } catch (error) {
    return NextResponse.json({ error: "Failed to fetch data" });
  }
};

Next.js의 API Router Handler 기능을 사용하여 GET 메소드 로직을 작성한다.

단순 GET 요청의 로직 치고는 다소 복잡해 보이는데, 포켓몬 API는 일반적인 API와는 다르게 전체적인 리스트를 보내주지 않는다.

포켓몬 한 마리씩 데이터를 보내주기 때문에 필요한 개수를 직접 설정해서 한 마리씩 데이터를 가져와 새로운 json으로 가공하여 반환하는 로직이다.

  • import { NextResponse } from "next/server";
    • Next.js에서 API 응답을 생성하는 데 이용된다.
  • const TOTAL_POKEMON = 151;
    • 151 마리의 포켓몬 데이터를 가져올 것이라는 상수를 선언한다.
    • 이 상수를 이용해서 뒤 로직에서 데이터를 가져 올 포켓몬의 마릿수를 조절한다.
  • export const GET = async (request: Request) => { ... }
    • GET 요청을 처리하는 비동기 함수 GET을 선언.
    • 이 함수는 Request 객체를 인자로 받아야 함.
  • try...catch
    • 데이터를 다루는 비동기 함수이기 때문에 에러 상황에 대비해서 try...catch 문으로 작성.
  • const allPokemonPromises = Array.from({ length: TOTAL_POKEMON }, (_, index)) => {...}
    • Array.from 메서드를 사용해서 배열의 length가 TOTAL_POKEMON(151)인 배열을 생성함.
    • 즉 151마리의 포켓몬 정보가 들어 갈 배열을 미리 만들어 놓는 것임.
    • 두번째 인자 (_, index)는 배열의 각 요소를 어떻게 생성할 지 정의하는 부분임.
      • 첫 번째 인자는 현재 요소의 값을 나타냄. _ 언더 스코어를 넣었기에 여기서는 사용하지 않겠다는 의미임.
      • 두 번째 인자 index는 현재 요소의 인덱스를 의미함.
  • const id = index + 1;
    • API 엔드 포인트에 반환 받을 포켓몬의 id값을 만들어 주기 위한 로직이다.
    • 포켓몬 API에서 포켓몬의 id는 0이 아닌 1부터 시작하기 때문에 +1로 시작한다. 컴퓨터의 index는 0부터 시작하기에, 자연스럽게 id는 0 + 1이 되어 1부터 시작하게 된다.
  • return Promise.all()
    • 두 개의 fetch를 보낼 것이기 때문에 Promise.all 메서드를 사용해서 두 개의 fetch 요청을 병렬로 처리한다.
    • Promise.all 메서드는 배열을 인자로 받고, 이 배열에는 Promise 객체가 포함된다.
  • fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then(res => res.json()),
  • fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`).then(res => res.json())
    • 배열 안에 포함된 fetch 요청이다.
    • 각각의 URL 엔드 포인트로 HTTP GET 요청을 보낸다.
    • 요청이 완료되면 응답 객체를 반환받는다.
    • then 메서드를 이용하여 응답 객체를 JSON 형식으로 파싱한다.
    • 이 과정은 비동기적으로 처리되고, Promise 객체를 반환한다.
    • Promise.all은 이 두 개의 Promise가 완료될 때까지 기다리고, 각각의 결과를 포함하는 새로운 Promise를 반환한다.
  • const allPokemonResponses = await Promise.all(allPokemonPromises);
    • 위 Promise.all 과정이 완료되면 그 결과를 담은 allPokemonPromises를 allPokemonResponses에 담는다.
  • const allPokemonData = allPokemonResponses.map(([response, speciesResponse], index) => {...})
    • 두 fetch 요청이 완료되어 담긴 allPokemonResponses 배열을 map 메서드로 순회한다.
    • 첫번째 인자에서 response는 response는 포켓몬 기본 데이터 정보이고, speciesResponse는 포켓몬 한글 설명이 담긴 API의 반환 값을 의미한다.
  • const koreanName = speciesResponse.names.find((name: any) => name.language.name === "ko");
    • speciesResponse에서 한국어 이름을 찾아서 각 포켓몬 데이터에 한국어 이름을 find 메서드로 찾아와서 response라는 배열에 korean_name 필드를 만들고 포켓몬의 한국 이름을 넣는다.
    • 그리고 이렇게 만들어진 새로운 배열 allPokemonData를 JSON 형식으로 클라이언트에게 보내준다.

디테일 route.tsx

// src/app/api/detail/[id]/route.tsx

const TOTAL_POKEMON = 151;

export const GET = async (request: Request, { params }: { params: { id: string } }) => {
  const { id } = params;

  try {
    const [response, speciesResponse] = await Promise.all([
      fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then(res => res.json()),
      fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`).then(res => res.json())
    ]);

    const koreanName = speciesResponse.names.find(
      (name: any) => name.language.name === "ko"
    );

    const koreanDescription = speciesResponse.flavor_text_entries.find(
      (entry: any) => entry.language.name === "ko"
    );

    const typesWithKoreanNames = await Promise.all(
      response.types.map(async (type: any) => {
        const typeResponse = await fetch(type.type.url).then(res => res.json());
        const koreanTypeName =
          typeResponse.names.find(
            (name: any) => name.language.name === "ko"
          )?.name || type.type.name;
        return { ...type, type: { ...type.type, korean_name: koreanTypeName } };
      })
    );

    const abilitiesWithKoreanNames = await Promise.all(
      response.abilities.map(async (ability: any) => {
        const abilityResponse = await fetch(ability.ability.url).then(res => res.json());
        const koreanAbilityName =
          abilityResponse.names.find(
            (name: any) => name.language.name === "ko"
          )?.name || ability.ability.name;
        return {
          ...ability,
          ability: { ...ability.ability, korean_name: koreanAbilityName },
        };
      })
    );

    const movesWithKoreanNames = await Promise.all(
      response.moves.map(async (move: any) => {
        const moveResponse = await fetch(move.move.url).then(res => res.json());
        const koreanMoveName =
          moveResponse.names.find(
            (name: any) => name.language.name === "ko"
          )?.name || move.move.name;
        return { ...move, move: { ...move.move, korean_name: koreanMoveName } };
      })
    );

    const pokemonData = {
      ...response,
      korean_name: koreanName?.name || response.name,
      description: koreanDescription?.flavor_text || "No description available",
      types: typesWithKoreanNames,
      abilities: abilitiesWithKoreanNames,
      moves: movesWithKoreanNames,
    };

    return new Response(JSON.stringify(pokemonData));
  } catch (error) {
    console.error("Error fetching Pokemon data:", error);
    return new Response(JSON.stringify({ error: "Failed to fetch data" }));
  }
};

리액트 쿼리 useQueryPokemonList 커스텀 훅 작성

fetch 함수를 작성했으니, 메인 페이지부터 포켓몬 리스트를 잘 불러오는지 확인해봐야겠다.

그런데 그냥 불러오면 트래픽이 아까우니 TanStack Query를 통해 캐싱해주겠다.

page에서 바로 작업해도 되지만, 코드를 분리해서 깔끔하게 유지하고 추후 프로젝트의 규모가 커졌을 때 재사용 할 수 있도록 커스텀 훅으로 제작하겠다.

 

참고로 Next.js에서는 TanStack Query를 클라이언트 컴포넌트에서만 사용할 수 있다.

그런데 우리 과제 조건에서 메인 페이지는 클라이언트 컴포넌트로, 디테일 페이지는 서버 컴포넌트로 작성하라고 했으니

메인 페이지는 리액트에서 사용하던 대로 사용하면 되겠다.

 

먼저 TanStack Query를 설치하고 아무런 세팅을 하지 않았으니 세팅부터 해주겠다.

단, 아래의 설정은 디테일 페이지를 위해 서버 컴포넌트에서도 사용하기 위한 설정이고, 리액트에서 하던 세팅 방법을 그대로 사용하면 그것은 클라이언트 컴포넌트에서만 사용이 가능하다.

Provider 생성

서버 컴포넌트에서도 사용할 수 있는 Provider를 생성한다.

// src/app/provider.tsx

"use client"

import { QueryClient ,QueryClientProvider, isServer } from "@tanstack/react-query";

function makeQueryClient() {
    return new QueryClient({
        defaultOptions: {
            queries: {
                staleTime: 60 * 1000,
            },
        },
    });
}

let browserQueryClient: QueryClient  | undefined = undefined

function getQueryClient() {
    if (isServer) {
        return makeQueryClient()
    } else {
        if(!browserQueryClient) {
            browserQueryClient = makeQueryClient()
        }
        return browserQueryClient
    }
}

const QueryProvider = ({ children }: { children: React.ReactNode }) => {

    const queryClient = getQueryClient()

    return (
        <QueryClientProvider client={queryClient}>
            {children}
        </QueryClientProvider>
    )
}

export default QueryProvider;

Provider 전달 설정

// src/app/layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import QueryProvider from "./provider";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className} style={{ maxWidth: "800px", margin: "0 auto" }}>
        
        <QueryProvider>
            {children}
        </QueryProvider>

      </body>
    </html>
  );
}

메인 페이지 작성

Image 컴포넌트 사용을 위한 외부 이미지 사용 허가 설정

// src/next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
    images: {
        remotePatterns: [
            {
                protocol: "https",
                hostname: "raw.githubusercontent.com",
            },
            {
                protocol: "https",
                hostname: "assets.pokemon.com",
            }
        ]
    }
};

export default nextConfig;

스타일링을 위한 Tailwind 설정

global.css

// global.css

@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  --foreground-rgb: 0, 0, 0;
  --background-start-rgb: 214, 219, 220;
  --background-end-rgb: 255, 255, 255;
}

@media (prefers-color-scheme: dark) {
  :root {
    --foreground-rgb: 255, 255, 255;
    --background-start-rgb: 0, 0, 0;
    --background-end-rgb: 0, 0, 0;
  }
}

body {
}

@layer utilities {
  .text-balance {
    text-wrap: balance;
  }
}

tailwind.config.ts

// tailwind.config.ts

import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      backgroundImage: {
        "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
        "gradient-conic":
          "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
      },
      keyframes: {
        bounceY: {
          '0%, 100%': { transform: 'translateY(0)' },
          '50%': { transform: 'translateY(-20px)' },
        },
      },
      animation: {
        bounceY: 'bounceY 1s infinite',
      },
    },
  },
  plugins: [],
};
export default config;

메인 페이지 작성

// src/app/page.tsx

'use client';

import Link from "next/link";
import Image from "next/image";
import { useQueryPokemonList } from "../../hooks/useQueryPokemonList";
import { useState, useEffect } from "react";

export default function MainPage() {

  const { data: pokemonList, isPending, isError } = useQueryPokemonList();
  const [searchText, setSearchText] = useState("");
  const [debouncedSearchText, setDebouncedSearchText] = useState("");

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedSearchText(searchText);
    }, 1000);

    return () => {
      clearTimeout(handler);
    };
  }, [searchText]);

  const filteredPokemonList = pokemonList?.filter(pokemon =>
    pokemon.korean_name.includes(debouncedSearchText)
  );

  if (isPending) return <div>로딩중</div>;
  if (isError) return <div>에러남</div>;

  return (
    <div className="bg-blue-900">
      <nav className=" top-0 w-full max-w-800px bg-purple-600 shadow-lg py-1 px-6 flex justify-center items-center gap-4 z-10">
        <h1 id="button-home" className="text-white font-bold text-2xl cursor-pointer">나만의 포켓몬 도감</h1>
        <input
          type="text"
          id="search-text"
          placeholder="포켓몬 이름을 검색하세요"
          className="p-1 bg-red-900 text-white border border-black rounded-md shadow-inner"
          value={searchText}
          onChange={(e) => setSearchText(e.target.value)}
        />
        <button id="search-button" className="text-white font-bold text-xl">🔍</button>
      </nav>
      <main id="main" className="mt-20 p-4 flex flex-wrap justify-center gap-4">
        <div className="poke-list flex flex-wrap justify-center gap-4">
          {filteredPokemonList && filteredPokemonList.map((pokemon) => (
            <Link href={`/detail/${pokemon.id}`} key={pokemon.id}>
              <div className="card w-48 min-h-48 bg-red-900 text-cyan-200 border border-black rounded-lg shadow-md p-2 cursor-pointer transform transition-all hover:scale-105">
                <h3 className="text-lg font-bold flex justify-between">
                  <span>{pokemon.korean_name}</span>
                  <span>No. {pokemon.id}</span>
                </h3>
                <Image src={pokemon.sprites.front_default} alt={pokemon.name} width={128} height={128} className="mx-auto" />
              </div>
            </Link>
          ))}
        </div>
      </main>
    </div>
  );
}

리액트 쿼리 useQueryPokemonId 커스텀 훅 작성

// src/hooks/useQueryPokemonId.ts

"use client";

import { useQuery } from "@tanstack/react-query";
import type { Pokemon } from "../types/pokemon";
import axios from "axios";

async function fetchPokemon(id: string): Promise<Pokemon> {
    try {
        const response = await axios.get(`http://localhost:3000/api/pokemons/${id}`);
        return response.data;
    } catch (error) {
        throw new Error("Failed to fetch Pokemon");
    }
}

export const useQueryPokemonId = (id: number) => {
    const { data, isPending, isError } = useQuery({
        queryKey: ['pokemonId', id],
        queryFn: () => fetchPokemon(id.toString()),
        staleTime: Infinity,
    });

    return { data, isPending, isError };
}

 

디테일 페이지 작성

import Image from 'next/image';
import Link from 'next/link';
import type { Pokemon } from '@/types/pokemon';
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query';
import { Metadata } from 'next';

async function fetchPokemon(id: string): Promise<Pokemon> {
  const response = await fetch(`http://localhost:3000/api/pokemons/${id}`);
  if (!response.ok) {
    throw new Error("Failed to fetch data");
  }
  const data: Pokemon = await response.json();
  return data;
}

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const pokemon = await fetchPokemon(params.id);
  return {
      title: `${pokemon.korean_name} - 포켓몬 도감`,
      description: `${pokemon.korean_name}의 상세 정보입니다.`,
  };
}

const PokemonPage = async ({ params }: { params: { id: string } }) => {
  const id = Number(params.id);

  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ['pokemon', id],
    queryFn: () => fetchPokemon(id.toString()),
  });

  const pokemon: Pokemon | undefined = await queryClient.getQueryData(['pokemon', id]);

  return (
    <div className="max-w-2xl mx-auto p-4">
      <HydrationBoundary state={dehydrate(queryClient)}>

        <div className="card-big max-w-2xl mx-auto bg-blue-900 text-cyan-200 border border-black rounded-lg shadow-md p-6 space-y-4">
          <h2 className="text-2xl font-bold text-center">{pokemon?.korean_name ?? '이름 없음'}</h2>
          <Image src={pokemon?.sprites.front_default ?? '/default-image.png'} alt={pokemon?.name ?? '포켓몬'} width={300} height={300} className="mx-auto" />
          <p className="text-center mb-4">{pokemon?.description ?? '설명 없음'}</p>
          <div className="card-stats space-y-2">
            <div className="info flex justify-between bg-black bg-opacity-75 p-2 rounded-md">
              <h3 className="height">키: {pokemon?.height ? pokemon.height / 10 : '정보 없음'}m</h3>
              <h3 className="weight">몸무게: {pokemon?.weight ? pokemon.weight / 10 : '정보 없음'}kg</h3>
            </div>
            <div className="types bg-black p-2 rounded-md flex justify-around">
              {pokemon?.types.map((type) => (
                <h3 key={type.type.name}>{type.type.korean_name}</h3>
              ))}
            </div>
            <div className="bg-black bg-opacity-75 p-2 rounded-md ">
              <div className="flex flex-wrap gap-2">
                {pokemon?.moves.map((move) => {
                  const colors = ['bg-red-500', 'bg-green-500', 'bg-blue-500', 'bg-yellow-500', 'bg-purple-500', 'bg-pink-500', 'bg-orange-500'];
                  const randomColor = colors[Math.floor(Math.random() * colors.length)];
                  return (
                    <span key={move.move.name} className={`inline-block ${randomColor} text-white px-3 py-1 rounded-full`}>
                      {move.move.korean_name}
                    </span>
                  );
                })}
              </div>
            </div>
          </div>
          <div className="mt-4 text-center">
            <Link href="/">뒤로 가기</Link>
          </div>
        </div>

      </HydrationBoundary>
    </div>
  );
};

export default PokemonPage;

 

동적 메타데이터를 생성한 김에 루트 폴더의 layout.tsx에서 정적 메타데이터를 수정하는 것을 마지막으로 프로젝트를 마감한다.

댓글