#TIL 45일차(Next.js 개인 과제)

앙꼬·2024년 7월 8일

부트캠프

목록 보기
44/59


개인 과제

주제

NextJS 를 이용해 나만의 포켓몬 도감을 만들어 봅시다.

요구사항

  • App router 기반, typescript 사용, tailwindcss 사용을 베이스로 한 Nextjs 14 버전으로 프로젝트가 구성되어야 합니다.
  • Layout 에서 Title, description 에 대한 Metadata 를 설정하고, 어플리케이션 전체에 적용될 UI 를 구현합니다.
  • 151번까지의 포켓몬 리스트를 보여주는 페이지를 구현합니다.
    • root 페이지에서 보여줘도 무방합니다.
    • 반드시 클라이언트 컴포넌트로 작성해주세요. (use client 사용)
    • 포켓몬 리스트 페이지에서 직접적으로 관련 api 를 호출하는 것이 아닌, nextjs api 폴더 내에서 해당 로직에 대한 api 를 구현해야 합니다. (포켓몬 리스트 페이지 → Next.js api 호출 → Nextjs 서버가 포켓몬 API 호출)
    • 151개의 포켓몬을 불러오는 API 는 로직 제공해드립니다. (직접 구현하셔도 무방합니다.)
  • 특정 포켓몬의 디테일을 보여주는 페이지를 구현합니다.
    • 다이나믹 페이지로 구성해주셔야 합니다. (힌트 : /[id])
    • 특정 포켓몬 디테일에 대한 정보를 가져오는 로직을 next.js api handler 를 통해서 구현하도록 합니다. (리스트와 동일)
    • 포켓몬 리스트에서 특정 포켓몬을 클릭했을 때 디테일페이지를 보여주고, 디테일 페이지에서 리스트로 돌아 갈 수 있는 로직을 만들어 주시면 더 좋을 것 같습니다.
  • 포켓몬 리스트와 상세페이지에서 항상 포켓몬들의 이미지를 보여주도록 합시다. Next.js 가 제공하는 Image 를 이용합니다.
  • 포켓몬 데이터에 대한 타입, 컴포넌트들의 props 에 대한 타입 등 어플리케이션 전체에 적절한 타입이 명시되어야 합니다.

제출 코드

/types/pokemon.ts

export type Pokemon = {
  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 } }[];
}
  • Pokemon 객체의 구조를 설명한다.
  • 각 포켓몬은 여러 개의 types(타입), abilities(능력), moves(기술)를 포함하며, 이들 각각은 이름과 한글 이름을 가지고 있다.

/api/pokemons/route.ts

import { NextResponse } from "next/server";
import axios from "axios";

const TOTAL_POKEMON = 151; // 전체 포켓몬 수

export const GET = async (request: Request) => {
  const { searchParams } = new URL(request.url);
  const offset = parseInt(searchParams.get("offset") || "0"); // 요청에서 offset 파라미터를 가져와 정수로 변환. 기본값은 0
  const limit = parseInt(searchParams.get("limit") || "20"); // 요청에서 limit 파라미터를 가져와 정수로 변환. 기본값은 20

  try {
    // 포켓몬 데이터를 지정된 범위로 가져오기 위한 배열 생성
    const range = Array.from(
      { length: limit },
      (_, index) => offset + index + 1
    ).filter((id) => id <= TOTAL_POKEMON); // offset부터 limit 개수만큼의 포켓몬 ID를 가진 배열을 생성하고, TOTAL_POKEMON을 넘지 않는 것으로 필터링

    // 지정된 범위의 포켓몬 데이터를 가져오는 Promise 배열 생성
    const allPokemonPromises = range.map((id) =>
      Promise.all([
        axios.get(`https://pokeapi.co/api/v2/pokemon/${id}`), // 포켓몬 데이터 API 요청
        axios.get(`https://pokeapi.co/api/v2/pokemon-species/${id}`), // 포켓몬 종(ex. 파이리, 이상해 등) 데이터 API 요청
      ])
    );

    // 모든 포켓몬 데이터 요청을 병렬로 처리하고 결과를 기다림
    const allPokemonResponses = await Promise.all(allPokemonPromises);

    // 각 포켓몬 데이터와 종 데이터를 합쳐서 새로운 배열 생성
    const allPokemonData = allPokemonResponses.map(
      ([response, speciesResponse], index) => {
        // 종 데이터에서 한국어 이름을 찾음
        const koreanName = speciesResponse.data.names.find(
          (name: any) => name.language.name === "ko"
        );

        // 포켓몬 데이터와 종 데이터를 합쳐서 새로운 객체 생성
        return { ...response.data, korean_name: koreanName?.name || null };
      }
    );

    // JSON 형태로 포켓몬 데이터 변환
    return NextResponse.json(allPokemonData);
  } catch (error) {
    return NextResponse.json({ error: "Failed to fetch data" });
  }
};
  • 요청 파라미터 처리
    • 요청에서 offset(시작점)과 limit(가져올 포켓몬 수) 파라미터를 받아, 기본값(offset: 0, limit: 20)을 설정한다.
  • 포켓몬 ID 범위 설정
    • 지정된 범위에 해당하는 포켓몬 ID 목록을 생성하고, 전체 포켓몬 수(TOTAL_POKEMON = 151)를 초과하지 않도록 필터링한다.
  • 포켓몬 데이터 요청
    • 각 포켓몬의 데이터와 해당 종(species) 데이터를 PokeAPI에서 가져오기 위해 API 요청을 병렬로 수행한다.
  • 데이터 처리
    • 포켓몬 데이터와 종 데이터를 합쳐서, 종 데이터에서 한국어 이름을 찾아 추가한다.
  • 응답 반환
    • 최종적으로 합쳐진 포켓몬 데이터를 JSON 형태로 반환한다.
      만약 데이터 요청에 실패하면, 에러 메시지를 포함한 JSON 응답을 반환한다.

/api/pokemons/[id]/route.ts

import { NextResponse } from "next/server";
import axios from "axios";

interface ErrorWithDigest extends Error {
  digest?: string;
}

function hasDigestProperty(error: any): error is ErrorWithDigest {
  return typeof error === 'object' && 'digest' in error;
}

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

  try {
    const response = await axios.get(`https://pokeapi.co/api/v2/pokemon/${id}`);
    const speciesResponse = await axios.get(
      `https://pokeapi.co/api/v2/pokemon-species/${id}`,
    );

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

    const typesWithKoreanNames = await Promise.all(
      response.data.types.map(async (type: any) => {
        const typeResponse = await axios.get(type.type.url);
        const koreanTypeName =
          typeResponse.data.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.data.abilities.map(async (ability: any) => {
        const abilityResponse = await axios.get(ability.ability.url);
        const koreanAbilityName =
          abilityResponse.data.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.data.moves.map(async (move: any) => {
        const moveResponse = await axios.get(move.move.url);
        const koreanMoveName =
          moveResponse.data.names?.find(
            (name: any) => name.language.name === "ko",
          )?.name || move.move.name;
        return { ...move, move: { ...move.move, korean_name: koreanMoveName } };
      }),
    );

    const pokemonData = {
      ...response.data,
      korean_name: koreanName?.name || response.data.name,
      types: typesWithKoreanNames,
      abilities: abilitiesWithKoreanNames,
      moves: movesWithKoreanNames,
    };

    return NextResponse.json(pokemonData);
  } catch (error) {
    console.error("Error fetching Pokemon data:", error);

    // 타입 가드를 사용하여 error 객체에 digest 속성이 있는지 확인
    if (hasDigestProperty(error)) {
      console.error("Error digest:", error.digest);
    }

    return NextResponse.json({ error: "Failed to fetch data" });
  }
};
  • 타입 정의 및 타입 가드

    • ErrorWithDigest 인터페이스
      • Error 객체에 선택적으로 digest 속성을 추가할 수 있는 타입을 정의한다.
    • hasDigestProperty 함수
      • 주어진 오류 객체에 digest 속성이 있는지 확인하는 타입 가드 함수이다.
  • API 요청 처리

    • 포켓몬 기본 정보 가져오기
      • 주어진 포켓몬 ID로 PokeAPI에서 기본 포켓몬 정보를 가져옵니다.
    • 포켓몬 종 정보 가져오기
      • 같은 ID로 포켓몬 종 정보를 가져와 한국어 이름을 추출합니다.
  • 한국어 이름 추가

    • 각 타입, 능력, 기술의 URL에서 데이터를 가져와 한국어 이름을 추가한다.
  • 최종 데이터 생성 및 반환

    • 기본 포켓몬 데이터에 한국어 이름과 각 타입, 능력, 기술에 대한 한국어 이름을 포함한 데이터를 생성합니다.
    • 이 데이터를 JSON 형태로 반환합니다.
  • 오류 처리

    • API 요청 중 발생한 오류를 잡아 로그를 기록하고, digest 속성이 있는 경우 해당 정보를 추가로 로그에 출력한다.
    • 오류 발생 시에는 실패 메시지를 JSON 응답으로 반환한다.

/app/[main]/page.tsx

"use client";

import { Pokemon } from "@/types/pokemon";
import Link from "next/link";
import { useEffect, useState, useRef, useCallback } from "react";

const MainPage = () => {
  const [pokemonList, setPokemonList] = useState<Pokemon[]>([]); // 포켓몬 데이터를 저장하는 상태 변수
  const [isLoading, setIsLoading] = useState(false); // 로딩 상태를 나타내는 상태 변수
  const [offset, setOffset] = useState(0); // 현재 offset을 저장하는 상태 변수
  const [hasMore, setHasMore] = useState(true); // 더 불러올 데이터가 있는지 여부를 나타내는 상태 변수
  const loadMoreRef = useRef<HTMLDivElement>(null); // 무한 스크롤을 위해 참조할 요소

  // 포켓몬 데이터를 가져오는 함수
  const fetchPokemon = useCallback(async () => {
    if (!hasMore || isLoading) return; // 더 불러올 데이터가 없거나 이미 로딩 중이면 함수 종료

    setIsLoading(true); // 로딩 상태로 변경
    try {
      const response = await fetch(`/api/pokemons?offset=${offset}&limit=20`); // API 요청
      const data = await response.json(); // 응답 데이터를 JSON으로 파싱
      setPokemonList((prevList) => [...prevList, ...data]); // 기존 데이터에 새로운 데이터 추가
      if (data.length < 20) { 
        setHasMore(false); // 받아온 데이터가 20개 미만이면 더 이상 데이터가 없음을 설정
      }
      setOffset((prevOffset) => prevOffset + 20); // offset을 20만큼 증가
    } catch (error) {
      console.error("에러 발생", error);
    } finally {
      setIsLoading(false);
    }
  }, [isLoading, hasMore, offset]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !isLoading && hasMore) {
          fetchPokemon(); // 하단 요소가 화면에 나타나면 새로운 데이터를 요청
        }
      },
      { threshold: 1.0 }
    );

    if (loadMoreRef.current) observer.observe(loadMoreRef.current); // 하단 요소를 관찰

    return () => {
      if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); // 클린업 함수에서 관찰 해제
    };
  }, [isLoading, hasMore, fetchPokemon]);

  if (pokemonList.length === 0 && isLoading) {
    return <div>Loading...</div>; // 초기 로딩 상태 표시
  }

  return (
    <div className="w-full flex flex-col">
      <ul className="w-3/4 grid 2xl:grid-cols-6 xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2 m-auto">
        {pokemonList.map((pokemon) => (
          <Link key={pokemon.id} href={`/main/detail/${pokemon.id}`}>
            <li className="m-2 p-2 border rounded-lg cursor-pointer">
              <img src={pokemon.sprites.front_default} alt={pokemon.name} />
              <p className="font-semibold">{pokemon.korean_name}</p>
              <p>도감 번호: {pokemon.id}</p>
            </li>
          </Link>
        ))}
      </ul>
      <div ref={loadMoreRef} className="h-10"></div> {/* 무한 스크롤 트리거 요소 */}
      {isLoading && <div>Loading more...</div>}
    </div>
  );
};

export default MainPage;
  • 상태 관리

    • pokemonList: 포켓몬 데이터를 저장하는 상태
    • isLoading: 데이터를 불러오는 동안 로딩 상태를 관리
    • offset: 현재 불러온 포켓몬 데이터의 시작점을 관리
    • hasMore: 더 불러올 데이터가 있는지 여부를 관리
  • 포켓몬 데이터 가져오기

    • fetchPokemon 함수는 API에서 포켓몬 데이터를 가져와 상태에 추가한다.
    • offset과 limit을 사용해 데이터의 페이징을 처리하며, 더 이상 불러올 데이터가 없으면 hasMore를 false로 설정한다.
  • 무한 스크롤 구현

    • IntersectionObserver를 사용해 페이지 하단의 요소가 화면에 나타날 때 fetchPokemon을 호출하여 데이터를 추가로 가져온다.
    • loadMoreRef로 하단의 트리거 요소를 참조한다.
  • 렌더링

    • 포켓몬 목록을 그리드 형태로 화면에 표시하며, 각각의 포켓몬은 클릭 시 상세 페이지로 이동한다.
    • 데이터가 로딩 중일 때 "Loading..." 메시지를 표시한다.

/app/[main]/detail/[id]/page.tsx

import { Pokemon } from "@/types/pokemon";
import { Metadata } from "next";
import Link from "next/link";
import React from "react";

async function getPokemon(id: number): Promise<Pokemon> {
  const res = await fetch(`http://localhost:3000/api/pokemons/${id}`);
  if (!res.ok) {
    throw new Error("데이터 불러오기 실패");
  }
  const data = await res.json();
  return data;
}

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

export default async function DetailPage({
  params,
}: {
  params: { id: number };
}) {
  const pokemon = await getPokemon(params.id);

  const pokemonId = String(pokemon.id).padStart(4, "0");

  return (
    <div className="w-full text-center">
      <div className="max-w-4xl mx-auto p-4">
        <div className="p-4 border mb-4">
          <h1 className="text-xl font-semibold">{pokemon.korean_name}</h1>
          <p>No. {pokemonId}</p>
        </div>
        <img
          className="mx-auto mb-4"
          src={pokemon.sprites.front_default}
          alt={pokemon.korean_name}
        />
        <p>이름: {pokemon.korean_name}</p>
        <p className="mb-6">: {pokemon.height / 10}m 무게: {pokemon.weight / 10}kg
        </p>
        <div className="flex flex-col items-center space-y-4">
          <div className="flex text-center">
            <h2 className="mr-3 text-lg font-semibold">타입:</h2>
            <ul className="flex">
              {pokemon.types.map((type, index) => (
                <li
                  key={index}
                  className="mr-4 px-2 bg-orange-500 text-white border rounded"
                >
                  {type.type.korean_name}
                </li>
              ))}
            </ul>
            <h2 className="mr-3 text-lg font-semibold">능력:</h2>
            <ul className="flex">
              {pokemon.abilities.map((ability, index) => (
                <li
                  key={index}
                  className="mr-4 px-2 bg-green-500 text-white border rounded"
                >
                  {ability.ability.korean_name}
                </li>
              ))}
            </ul>
          </div>
          <div className="text-center w-full">
            <h2 className="mt-4 text-lg font-semibold">기술:</h2>
            <ul className="flex flex-wrap justify-center mt-4 max-h-40 overflow-y-auto border p-2">
              {pokemon.moves.map((move, index) => (
                <li
                  key={index}
                  className="m-2 px-2 py-1 bg-gray-200 border rounded"
                >
                  {move.move.korean_name}
                </li>
              ))}
            </ul>
          </div>
        </div>
        <Link
          href="/"
          className="block mt-8 p-3 bg-cyan-500 text-white border rounded mx-auto w-32"
        >
          뒤로 가기
        </Link>
      </div>
    </div>
  );
}
  • 포켓몬 데이터 가져오기
    • getPokemon 함수는 주어진 포켓몬 ID를 사용해 API에서 포켓몬 데이터를 가져온다.
    • 데이터를 성공적으로 가져오지 못하면 오류를 발생시킨다.
  • 메타데이터 생성
    • generateMetadata 함수는 포켓몬의 한국어 이름을 기반으로 페이지의 메타데이터(제목과 설명)를 동적으로 생성한다.
    • 메타데이터는 페이지의 SEO와 소셜 미디어 미리보기 등에 사용된다.
  • 페이지 렌더링
    • DetailPage 컴포넌트는 포켓몬의 상세 정보를 화면에 표시한다.
    • 포켓몬의 이름, 도감 번호, 이미지, 타입, 능력, 기술 등을 보여준다.
    • 포켓몬의 각 타입, 능력, 기술은 한국어 이름으로 표시되며, 스타일링이 적용되어 있다.
  • 뒤로 가기 링크
    • 페이지 하단에는 사용자가 이전 페이지로 돌아갈 수 있는 "뒤로 가기" 버튼이 있다.
  • 클라이언트 컴포넌트 사용
    • 페이지에서 사용된 Link 컴포넌트는 클라이언트 측 내비게이션을 위해 사용되며, 페이지 간 이동 시 빠른 전환을 가능하게 한다.
profile
프론트 개발자 꿈꾸는 중

0개의 댓글