[Personal Project] 포켓몬 도감

liinyeye·2024년 7월 5일
1

Project

목록 보기
25/44
post-thumbnail

프로젝트 개요

필수 구현 사항

  • App router 기반, typescript 사용, tailwindcss 사용을 베이스로 한 Nextjs 14 버전으로 프로젝트
  • Layout 에서 Title, description 에 대한 Metadata 를 설정하고, 어플리케이션 전체에 적용될 UI 를 구현
  • 151번까지의 포켓몬 리스트를 보여주는 페이지를 구현
    • 반드시 클라이언트 컴포넌트로 작성 (use client 사용)
    • 포켓몬 리스트 페이지에서 직접적으로 관련 api 를 호출하는 것이 아닌, nextjs api 폴더 내에서 해당 로직에 대한 api 를 구현 (포켓몬 리스트 페이지 → Nextjs api 호출 → Nextjs 서버가 포켓몬 API 호출)
  • 특정 포켓몬의 디테일을 보여주는 페이지를 구현합니다.
    • 다이나믹 페이지로 구성
    • 특정 포켓몬 디테일에 대한 정보를 가져오는 로직을 nextjs api handler 를 통해서 구현
    • 단, 반드시 서버 컴포넌트로 작성 (use client 사용 금지)
  • 포켓몬 리스트와 상세페이지에서 포켓몬들의 이미지를 보여줄 때, Nextjs 가 제공하는 Image 를 이용
  • 포켓몬 데이터에 대한 타입, 컴포넌트들의 props 에 대한 타입 등 어플리케이션 전체에 적절한 타입 명시

선택 구현 사항

  • tanstack query 를 도입해서, api로 불러오는 데이터 캐시처리
  • 다이나믹 페이지 metadata 적용
  • axios 타입 지정
  • 무한스크롤 혹은 페이지네이션 구현 시도 ( api docs - https://pokeapi.co)
  • Supabase 등 원하는 데이터베이스에 내가 좋아하는 포켓몬을 등록 할 수 있는 로직을 구현 (api handler 를 통해서 로직을 구현)

프로젝트 결과물


🔗 웹사이트 : https://pokemon-app-six-rosy.vercel.app/
🔗 깃허브 링크 : https://github.com/yeliinbb/pokemon-app

기능 구현

tanstack query로 api 데이터 불러오기 & 캐시처리

queryClient를 useState 상태로 관리하는 이유

기존 방식처럼 const queryClient = new QueryClient() 이렇게 컴포넌트 안 혹은 밖에서 생성해줄 수도 있다. 하지만 이와 다르게 queryClient를 상태로 관리하는 이유는 컴포넌트가 렌더링될 때마다 queryClient 인스턴스를 재생성하지 않기 위해서이다.

useState의 콜백 함수로 넣어주는 이유

useState 훅에서 초기 상태를 설정할 때, 콜백 함수를 이용하면 이 콜백함수는 컴포넌트가 처음 렌더링될 때 한 번만 실행된다. 이후 상태가 변경되지 않는 한, 이 콜백함수는 다시 실행되지 않는다.

따라서 QueryClient는 컴포넌트의 첫 번째 렌더링 시에만 생성되며, 이후 컴포넌트가 리렌더링되더라도 동일한 QueryClient 인스턴스를 사용하게 된다.

defaultOptions

defaultOptions은 QueryClient를 생성할 때 기본 쿼리 동작을 설정할 수 있는 옵션이다. 이를 통해 애플리케이션 전역에서 모든 쿼리에 공통적으로 적용할 기본 설정을 정의할 수 있다.

데이터를 받아오는 빈도수를 낮추기 위해 staleTime을 높게 설정하여 api호출 횟수를 줄일 수 있다.

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

// QueryClient를 생성하고 기본 옵션을 설정합니다.
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5분동안 데이터는 "신선" 상태로 유지
      cacheTime: 1000 * 60 * 10, // 10분
      refetchOnWindowFocus: false,
      refetchOnReconnect: false,
      refetchInterval: 1000 * 60, // 1분
      retry: 3, // 기본값
      retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
      enabled: true, // 특정 조건에서만 실행할 경우 false
      onSuccess: data => console.log('쿼리 성공:', data),
      onError: error => console.error('쿼리 실패:', error),
      onSettled: (data, error) => console.log('쿼리 완료')
    },
    mutations: {
      // 변이가 성공적으로 완료되었을 때 호출되는 콜백 함수
      onSuccess: data => console.log('변이 성공:', data),
      // 변이가 실패했을 때 호출되는 콜백 함수
      onError: error => console.error('변이 실패:', error),
      // 변이가 성공하거나 실패한 후 항상 호출되는 콜백 함수
      onSettled: (data, error) => console.log('변이 완료')
    }
  }
});

// QueryClientProvider를 사용하여 QueryClient를 애플리케이션에 제공합니다.
const Providers = ({ children }) => (
  <QueryClientProvider client={queryClient}>
    {children}
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
);

export default Providers;

staleTime과 cacheTime 차이?

staleTime은 데이터가 "오래된" 것으로 간주되기까지의 시간을 설정. 즉, 데이터가 "신선한" 상태로 유지되는 시간을 정의.

  • 기본값: 0 (즉시 오래된 것으로 간주)
  • 설명 : staleTime 동안 데이터는 "신선한" 상태로 유지
  • 용도 : 데이터가 자주 변하지 않는 경우 'staleTime'을 길게 설정하여 불필요한 네트워크 요청을 줄일 수 있다. (예시 - 사용자 프로필 정보 등 자주 변하지 않는 데이터 캐싱)
  • 사용 상황
    • 일정 기간 동안 같은 데이터를 보여줘야 하는 경우 : 대시보드, 실시간 뉴스 등
    • 자주 변하지 않는 데이터 : 사용자 프로필, 제품 카탈로그 등

cacheTime은 데이터가 캐시에 유지되는 시간을 설정. 이 시간이 지나면 데이터는 가비지 콜렉션의 대상이 됨.

  • 기본값 : 5 60 1000 (5분)
  • 설명 : cacheTime동안 데이터는 캐시에서 유지. 이 시간 동안 데이터는 메모리에 유지되며, 다시 쿼리될 때 반환될 수 있음. cacheTime이 지나면 데이터는 캐시에서 사라지며, 다음에 쿼리될 때는 네트워크 요청이 필요함.
  • 용도 : 메모리 사용량을 관리하기 위해 사용됨. 캐시 시간이 너무 길면 불필요한 메모리 사용이 증가할 수 있으며, 너무 짧으면 자주 네트워크 요청이 발생할 수 있음.

전체 provider 관리 컴포넌트

"use client";

import React, { PropsWithChildren, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

// 전체 provider 관리 컴포넌트
const Providers = ({ children }: PropsWithChildren) => {
  // useState안에 콜백 함수로 넣어줄 경우
  // 컴포넌트가 처음에 마운트될 때만 초기값이 세팅되고 이후에는 리렌더링 되지 않는다.
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: Infinity,
          },
        },
      })
  );
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
};

export default Providers;

다이내믹 페이지 metadata 적용

Dynamic Metadata 사용 방법

  • generateMetadata는 서버 컴포넌트에서만 사용 가능하다
  • fetch 요청 시 자동적으로 데이터가 캐싱되어, generateMetadata, generateStaticParams, Layouts, Pages, and Server Components에서 동일한 데이터를 사용할 수 있다. 따라서, generateMetadata 함수 안에서 fetch 요청을 하더라도 api호출이 다시 되지 않고 기존에 캐싱된 데이터를 사용하게 된다.
import type { Metadata, ResolvingMetadata } from 'next'
 
type Props = {
  params: { id: string }
  searchParams: { [key: string]: string | string[] | undefined }
}
 
export async function generateMetadata(
  { params, searchParams }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  // read route params
  const id = params.id
 
  // fetch data
  const product = await fetch(`https://.../${id}`).then((res) => res.json())
 
  // optionally access and extend (rather than replace) parent metadata
  const previousImages = (await parent).openGraph?.images || []
 
  return {
    title: product.title,
    openGraph: {
      images: ['/some-specific-page-image.jpg', ...previousImages],
    },
  }
}
 
export default function Page({ params, searchParams }: Props) {}

Root Layout.tsx

title 객체 안에 template과 default 속성을 이용하여, 각 페이지마다 다른 title을 보여줄 수 있다.

title: {
    template: "%s | Pokemon app",
    default: "Pokemon app",
  },

template 속성의 "%s | Pokemon app"에서 %s는 문자열 포맷팅을 위한 자리 표시자(placeholder)이며, 이는 나중에 특정 문자열을 삽입할 위치를 나타낸다.

이 구문은 종종 템플릿 문자열(template string)이나 포맷팅 문자열(format string)이라고 불리며, 동적으로 텍스트를 삽입할 때 사용된다.

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

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

export const metadata: Metadata = {
  title: {
    template: "%s | Pokemon app",
    default: "Pokemon app",
  },
  description: "Let's go Pokemon!!!",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning={true}>
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Detail Page Metadata

export async function generateMetadata({ params: { id } }: DetailPageProps) {
  const pokemon: PokemonWithLike = await fetchPokemon(id)
  return {
    title: pokemon.korean_name,
    description: `${pokemon.korean_name} : ${id}`,
  }
}

트러블 슈팅

loading 컴포넌트 작동 안 됨

loading 컴포넌트 사용 방법

렌더링 하려는 페이지 폴더 안에서 layout, page, error 과 마찬가지로 loading 파일을 만들어준다. 이 때 주의할 점은 파일 이름은 변경하면 안 되며, 서버 컴포넌트에서만 사용 가능하다. 클라이언트 컴포넌트에서 적용이 되지 않는 이유는 데이터의 로딩 상태를 자동으로 확인할 수 없기 때문이다.

이 때 suspense로 감싸준 컴포넌트를 제외하고, 나머지 컴포넌트는 로딩 중에 미리 그려진다.

export default function Loading() {
  // You can add any UI inside Loading, including a Skeleton.
  return <LoadingSkeleton />
}

Server-Side Rendering (SSR) 과정

  1. First, all data for a given page is fetched on the server.
  2. The server then renders the HTML for the page.
  3. The HTML, CSS, and JavaScript for the page are sent to the client.
  4. A non-interactive user interface is shown using the generated HTML, and CSS.
  5. Finally, React hydrates the user interface to make it interactive.

오류 2 : AxiosRequestConfig 타입 지정

Object literal may only specify known properties, and liked does not exist in type 'AxiosRequestConfig any’

좋아요 기능 구현을 위한 useMutation 내부의 mutationFn 로직

사실 좋아요 기능에서는 기존 데이터에 좋아요 속성을 업데이트해서 저장해야하기 때문에 patch매서드를 사용해야하지만, axios에 대해 공부해보고자 아래 내용을 적어본다.

기존 코드

mutationFn: async ({ id, currentLiked }: MutationVariables) => {
      await axios.get(`/api/pokemons/${id}`, { liked: !currentLiked }, 
      );
    },

수정한 코드 : 쿼리 매개변수를 사용하여 변경

mutationFn: async ({ id, currentLiked }: MutationVariables) => {
    await axios.get(`/api/pokemons/${id}`, {
      params: { liked: !currentLiked }
    });
  },

GET 요청에서는 데이터(payload)를 본문에 포함하지 않으므로, 이 경우 해당 객체는 쿼리 매개변수나 요청 구성을 포함해야 한다. 따라서 { liked: !currentLiked } 이런 형태가 아닌, { params: { liked: !currentLiked }} 형태로 요청을 보내야 한다.
path/put/post 등 body를 함께 보내야하는 경우에는 추가하거나 업데이트 할 객체를 보내는 것이기 때문에 기존의 객체 형태로 보내는 것이 가능하다.

  • 요청 구성 객체 (Request Config Object)

    • HTTP 요청의 설정을 정의한다. 여기에는 URL, 매서드, 쿼리 매개변수, 응답 타입, 인증 정보 등 다양한 설정이 포함될 수 있다.
    • axios.get에서는 주로 params 속성을 사용하여 쿼리 매개변수를 전달한다.
  • 쿼리 매개변수 (Query Parameters)

    • URL의 일부분으로, 주로 서버에 특정 데이터를 요청할 때 사용됩니다.
    • 쿼리 매개변수는 URL에 ?key=value 형식으로 추가된다.
    • axios 라이브러리에서는 params 속성을 통해 쿼리 매개변수를 전달할 수 있다.

인스턴스 매서드

axios.get(url[, config])
axios.post(url[, data[, config]])
axios.put(url[, data[, config]])
axios.patch(url[, data[, config]])
axios.delete(url[, config])
axios.request(config)
axios.head(url[, config])
axios.options(url[, config])
axios.getUri([config])
mutationFn: async ({ id, currentLiked }: MutationVariables) => {
      // like 기능 구현 시, 해당 데이터만 업데이트해주는 것이기 때문에 put(덮어주기) 메서드 사용 권장
      // liked된 데이터를 저장해주는 공간 필요 -> 로컬 스토리지 or supabase 사용해서 저장 기능 구현 시도
      // 현재 이 로직은 작동하고 있지 않음 -> 따로 변경된 데이터를 저장해주고 있지 않기 때문에
      // await axios.patch(`/api/pokemons/${id}`, {
      //   params: { liked: !currentLiked },
      // })
      // 이런 식으로 쿼리스트링으로 보내는 방법도 있음.
      // const newLikedState = !currentLiked ? 'true' : 'false';
      // await axios.get(`/api/pokemons/${id}/liked=${newLikedState}`)
    },

앞으로 더 공부할 것들

  • tanstack qeury 공식 문서 보기
  • Tailwind css 동적 스타일 지정
  • 디테일 페이지 내에서 캐러셀 기능 추가
profile
웹 프론트엔드 UXUI

1개의 댓글

comment-user-thumbnail
2024년 7월 6일

우와 대박 예린님 퀄리티 장난아니네요...👍 👍 👍

답글 달기