본문 바로가기

개발/Next.js

Next.js v15로 여러 음악 정보 뿌려주기 (with Genius API)

데이터를 어떻게 불러와서 처리할까 고민하다가 노래 가사에 대한 정보를 받아올 수 있는 api인 Genius API를 사용하여 노래가사와 음악 정보를 제공하도록 결정했다. api를 붙여 next.js 15버전으로 데이터를 뿌려주기 이전에 어떤 페이지와 컴포넌트를 만들지 먼저 설계했다.

Genius API를 사용하기 위해선 아래 링크에서 CREATE API Client 버튼을 클릭 후 계정을 등록하게 되면 ACCESS_TOKEN을 얻을 수 있는데 이 ACCESS_TOKEN을 헤더에 담아 요청만 하면된다.

https://genius.com/developers

 

대표적으로 이번 프로젝트로 아래와 같은 Component를 만들 예정이다.

  • 키워드를 이용해 노래제목, 앨범명, 아티스트를 입력했을때 데이터를 받아오는 Search Component
  • 받아온 음악 데이터를 하나하나 뿌려주기 위한 SongItem Component
  • SongItem Component에서 가사 보기 버튼 클릭을 했을때 모달창으로 해당 노래의 가사 정보를 띄워주는 Modal Component
  • SongItem Component를 클릭 시 해당 음악의 여러 정보를 상세히 볼 수 있는 페이지로 이동 경로는 (song/id)

해당 프로젝트에서 얻고자 하는 학습 목표는 다음과 같다.

  • 최대한 dynamic Page를 static page로 만들어보기.
  • fetch를 이용하여 데이터 페칭
  • 같은 요청을 다시 불러오지 않도록 페이지 캐싱 적용하기
  • Suspense Component로 스트리밍 구현
  • 스켈레톤 UI로 사용자 경험 올리기

 

현재 dynamic page로 빌드되는 아래 코드를 좀 더 개선해보자.

import SongListSkeleton from "@/components/skeleton/song-list-skeleton";
import SongItem from "@/components/song-item";
import { SearchSongData } from "@/types";
import { Suspense } from "react";

async function SearchResult({ q }: { q: string }) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/search?q=${q}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_GENIUS_ACCESS_TOKEN}`,
      },
      cache: "force-cache",
    }
  );

  if (!response.ok) {
    return <div>오류 발생...</div>;
  }

  const songs: SearchSongData[] = await response.json().then((response) => {
    return response.response.hits;
  });

  return (
    <div>
      {songs.map((song) => (
        <SongItem key={song.result.id} {...song.result} />
      ))}
    </div>
  );
}

export default async function Page({
  searchParams,
}: {
  searchParams: { q: string };
}) {
  return (
    <Suspense key={searchParams.q} fallback={<SongListSkeleton count={3} />}>
      <SearchResult q={searchParams.q || ""} />
    </Suspense>
  );
}

Server Component로, searchParams를 받아서 genius 서버한테 검색 api를 요청해 데이터를 받아와 해당 데이터를 뿌려주는 컴포넌트다.

먼저 위 Server Component가 dynamic page component로 빌드되는 이유는 동적함수인 쿼리스트링을 사용하기 때문에 위 함수는 static page로 만들 순 없다.

Dynamic Page - 풀 라우트 캐시 적용 X, 하지만, 데이터캐시는 적용할 수 있다.

fetch option에 cache: "force-cache" 로 설정하여 브라우저 캐시에 해당 요청이 있으면 캐시된 데이터를 사용할 수 있다.

 

 

generateStaticParams

Next.js 13버전 부터 지원된 generateStaticParams 함수는 정적인 파라미터를 내보내는 함수다.

이 함수의 특징은, 기존 Dynamic page 컴포넌트를 특정 데이터에 대해서 미리 정적으로 생성해주는 기능을 제공해 준다. 그럼 id가 1번인 데이터와 3번인 데이터는 빌드 타임에 미리 생성해놓고 1번과 3번을 제외한 다른 id에 대한 페이지는 dynamic page로써 동작한다. SSG와 같은 기능.

 

export function generateStaticParams() {
  return [{ id: "1" }, { id: "3" }];
}

export default async function Page({
  params,
}: {
  params: { id: string | string[] };
}) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/songs/${params.id}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_GENIUS_ACCESS_TOKEN}`,
      },
      cache: "force-cache",
    }
  );

  if (!response.ok) {
    notFound();
  }

  const song: SongData = await response
    .json()
    .then((response) => response.response.song);

  const {
    id,
    title,
    title_with_featured,
    artist_names,
    song_art_image_url,
    full_title,
  } = song;

  return (
    <div className={style.container} key={id}>
      <div
        className={style.cover_img_container}
        style={{ backgroundImage: `url('${song_art_image_url}')` }}
      >
        <Image src={song_art_image_url} alt={title} width={350} height={350} />
      </div>
      <div className={style.title}>{title}</div>
      <div className={style.title_with_featured}>{title_with_featured}</div>
      <div className={style.artist_names}>{artist_names}</div>
      <div className={style.full_title}>{full_title}</div>
    </div>
  );
}

 

위 코드는 songs/[id] page 컴포넌트에서 generateStaticParams 함수 이전에 npm run build 실행했을때 songs/[id] 파일은 dynamic page로써 빌드가 되지만

generateStaticParams 함수를 내보냈을때는 SSG로써 동작하는걸 볼 수 있다. 해당 기능은 자주 변경되지 않는 콘텐츠나 정적 데이터를 사용 가능할 때 사용하면 사용자는 좀 더 빠른 데이터를 얻을 뿐만 아니라 SEO 또한 정적으로 페이지를 생성해온 데이터 덕분에 향상 될 수 있다.

 

스켈레톤 UI + Suspense

import SongListSkeleton from "@/components/skeleton/song-list-skeleton";
import SongItem from "@/components/song-item";
import { SearchSongData } from "@/types";
import axios from "axios";
import { Suspense } from "react";

async function SearchResult({ q }: { q: string }) {
  const response = await axios.get(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/search?q=${q}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_GENIUS_ACCESS_TOKEN}`,
      },
    }
  );

  if (response.status !== 200) {
    return <div>오류 발생...</div>;
  }

  const songs: SearchSongData[] = await response.data.response.hits;
  return (
    <div>
      {songs.map((song) => (
        <SongItem key={song.result.id} {...song.result} />
      ))}
    </div>
  );
}

// 임시 type 처리
export default async function Page({ searchParams }: { searchParams: any }) {
  return (
    <Suspense key={searchParams.q} fallback={<SongListSkeleton count={3} />}>
      <SearchResult q={searchParams.q || ""} />
    </Suspense>
  );
}

위에서 만들어둔 SearchResult 컴포넌트의 데이터가 불러오기전에 스켈레톤 UI처리하기 위해 Suspense 컴포넌트레 fallback 속성에 SongListSkeleton 컴포넌트를 전달했다. Suspense에 key를 등록해야 key로 등록된 검색어인 q값이 달라질때마다 Suspense에 fallback으로 등록된 로딩상태를 보여주게 된다.

만약.. key를 지정안해주면 검색어가 달려저도 로딩상태가 보여지지 않는다는거 명심하자!

Suspense 컴포넌트는 페이지 내에서 하나의 컴포넌트만 스트리밍하는게 아니라 여러개의 비동기 컴포넌트를 동시 다발적으로 스트리밍할때 유용하다. 지금처럼!

그래서 따로 Loading 파일로 관리하는거보다는 이렇게 원하는 곳만 로딩처리할 수 있게 Suspense처리로 해주는게 보편적이다.

 

스켈레톤 UI는 간단히 아래와 같은 형태로 만들었으며, count에 3개를 넘겼으므로 3개가 보여진다.

 

스켈레톤 UI에 사용한 전반적인 코드는 다음과 같으니 참고 부탁드릴게요

import SongItemSkeleton from "./song-item-skeleton";

export default function SongListSkeleton({ count }: { count: number }) {
  return new Array(count)
    .fill(0)
    .map((_, idx) => <SongItemSkeleton key={`song-item-skeleton-${idx}`} />);
}


----



import style from "./song-item-skeleton.module.css";

export default function SongItemSkeleton() {
  return (
    <div className={style.container}>
      <div className={style.song_art_image_url}></div>
      <div className={style.info_container}>
        <div className={style.title}></div>
        <div className={style.title_with_featured}></div>
        <br />
        <div className={style.artist_names}></div>
      </div>
    </div>
  );
}

---

.container {
  display: flex;
  gap: 15px;
  padding: 20px 10px;
  border-bottom: 1px solid rgb(220, 220, 220);
}

.song_art_image_url {
  width: 80px;
  height: 105px;
  background-color: rgb(230, 230, 230);
}

.info_container {
  flex: 1;
}

.title,
.title_with_featured,
.artist_names {
  width: 100%;
  height: 20%;
  background-color: rgb(230, 230, 230);
}

'개발 > Next.js' 카테고리의 다른 글

Next.js + @tanstack/react-query 왜 같이 쓸까?  (0) 2024.10.29
Next.js App Router  (0) 2024.10.17
Next.js Page Router  (3) 2024.10.16