
NextJS 를 이용해 나만의 포켓몬 도감을 만들어 봅시다.
use client 사용)/[id])/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 } }[];
}
/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" });
}
};
/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" });
}
};
타입 정의 및 타입 가드
API 요청 처리
한국어 이름 추가
최종 데이터 생성 및 반환
오류 처리
/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;
상태 관리
포켓몬 데이터 가져오기
무한 스크롤 구현
렌더링
/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>
);
}