[Next.js v14] 데이터 가져오기(Fetching)

·2024년 6월 25일
0

NextJS

목록 보기
22/26
post-thumbnail

📌 클라이언트 측 데이터 가져오기

/app/(content)/news/page.js에서 DUMMY_DATA를 불러오는 대신 백엔드에서 데이터를 가져올 예정

💎 /app/(content)/news/page.js

"use client";

import NewsList from "@/components/news-list";
import { useEffect, useState } from "react";

export default function NewsPage() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState();
  const [news, setNews] = useState();

  useEffect(() => {
    async function fetchNews() {
      setIsLoading(true);
      const response = await fetch("http://localhost:8080/news");
      console.log(response);

      if (!response.ok) {
        setError("뉴스를 가져오는데 실패했습니다.");
        setIsLoading(false);
      }

      const news = await response.json();
      setIsLoading(false);
      setNews(news);
    }
    fetchNews();
  }, []);

  if (isLoading) {
    return <p>Loading...</p>;
  }

  if (error) {
    return <p>{error}</p>;
  }

  let newsContent;

  if (news) {
    newsContent = <NewsList news={news} />;
  }

  return (
    <>
      <h1>News Page</h1>
      {newsContent}
    </>
  );
}

TanStack Query를 사용할 수도 있지만 가장 기본인 useEffect, useState, fetch를 이용해서 백엔드에서 데이터를 불러왔다.


📌 서버 측 데이터 가져오기

클라이언트 측에서 useEffect를 이용해 뉴스 데이터를 불러오기 때문에 서버에서 생성된 페이지의 내용에는 뉴스 데이터를 포함하지 않는다. → NextJS를 사용할 때 데이터를 가져오는 최선의 방법이 아니다.

리액트 서버 컴포넌트는 JSX 대신에 프로미스를 반환할 수 있으며 이는 전통적인 클라이언트 측 리액트 컴포넌트가 할 수 없는 일이다. → NextJS가 가능하게끔 도와줌.

💎 /app/(content)/news/page.js

import NewsList from "@/components/news-list";

export default async function NewsPage() {
  const response = await fetch("http://localhost:8080/news"); // 서버 측 컴포넌트이므로 이 요청을 컴포넌트 함수 내부에서 직접 보내고 있다.

  if (!response.ok) {
    throw new Error("뉴스 가져오기 실패");
  }
  const news = await response.json();

  return (
    <>
      <h1>News Page</h1>
      <NewsList news={news} />;
    </>
  );
}

비록 서버 측에서 실행되지만 fetch 함수를 여기서 사용할 수 있다. 그 이유는 다음과 같다.

  1. Node.js가 서버 측 코드를 실행하는 것을 지원한다.
  2. NextJS가 fetch 함수를 확장하여 몇 가지 추가 캐싱 관련 기능을 추가했다.

NextJS 기능을 사용하여 서버에서 직접 데이터를 가져오고 컴포넌트 함수 안에서 출력하는데 필요한 모든 코드가 완성된다.

위의 사진처럼 페이지 소스에서 모든 뉴스 내용을 확인할 수 있다. → SEO에 이점을 제공


➕ 왜 별도의 백엔드를 사용할까?

  1. /backend/data.db를 루트 프로젝트로 옮긴다.

  2. npm install better-sqlite3

  3. /lib/news.js 수정

    import sql from "better-sqlite3";
    
    const db = sql("data.db"); // 루트 프로젝트 폴더를 기준으로 상대 경로를 추가
    
    export function getAllNews() {
      const news = db.prepare("SELECT * FROM news").all(); // 모든 데이터를 news에 저장하고 반환하기 위함
      return news;
    }
  4. /app/(content)/news/page.js 수정

    import NewsList from "@/components/news-list";
    import { getAllNews } from "@/lib/news";
    
    export default async function NewsPage() {
      const news = getAllNews();
    
      return (
        <>
          <h1>News Page</h1>
          <NewsList news={news} />;
        </>
      );
    }

데이터 소스(데이터베이스, 파일)에서 직접 데이터를 가져온다. → React 서버 컴포넌트가 있기 때문에 가능하다. 클라이언트는 데이터베이스에 접근할 수 없기 때문이다. (리액트 서버 컴포넌트는 서버에서만 실행되기 때문에 가능하다.)


📖 '로딩 중' 폴백 표시하기

💎 /lib/news.js

export async function getAllNews() {
  const news = db.prepare("SELECT * FROM news").all(); // 모든 데이터를 news에 저장하고 반환하기 위함
  await new Promise((resolve) => setTimeout(resolve, 2000)); // 2초 지연
  return news;
}

💎 /app/(content)/news/loading.js

export default function NewsLoading() {
  return <p>Loading...</p>;
}


📖 전체 애플리케이션을 로컬 데이터 소스(데이터베이스)로 마이그레이션하기

💎 app/(content)/news/[slug]/page.js

import { notFound } from "next/navigation";
import Link from "next/link";
import { getNewsItem } from "@/lib/news";

export default async function NewsDetailPage({ params }) {
  const newsSlug = params.slug;
  const newsItem = await getNewsItem(newsSlug); // /lib/news.js의 함수 이용

  if (!newsItem) {
    notFound();
  }

  return (
    <article className="news-article">
      <header>
        <Link href={`/news/${newsItem.slug}/image`}>
          <img src={`/images/news/${newsItem.image}`} alt={newsItem.title} />
        </Link>
        <h1>{newsItem.title}</h1>
        <time dateTime={newsItem.date}>{newsItem.date}</time>
      </header>
      <p>{newsItem.content}</p>
    </article>
  );
}

💎 app/(content)/news/[slug]/image/page.js

import { notFound } from "next/navigation";
import { getNewsItem } from "@/lib/news";

export default async function ImagePage({ params }) {
  const newsItemSlug = params.slug;
  const newsItem = await getNewsItem(newsItemSlug);

  if (!newsItem) {
    notFound();
  }

  return (
    <div className="fullscreen-image">
      <img src={`/images/news/${newsItem.image}`} alt={newsItem.title} />
    </div>
  );
}

💎 app/(content)/news/[slug]/@modal/(.)image/page.js

"use client";

import { notFound, useRouter } from "next/navigation";

import { DUMMY_NEWS } from "@/dummy-news";

export default function InterceptedImagePage({ params }) {
  const router = useRouter();

  const newsItemSlug = params.slug;
  const newsItem = DUMMY_NEWS.find(
    (newsItem) => newsItem.slug === newsItemSlug
  );

  if (!newsItem) {
    notFound();
  }

  return (
    <>
      <div className="modal-backdrop" onClick={router.back} />
      <dialog className="modal" open>
        <div className="fullscreen-image">
          <img src={`/images/news/${newsItem.image}`} alt={newsItem.title} />
        </div>
      </dialog>
    </>
  );
}

이 컴포넌트는 'use client'를 이용해서 클라이언트 컴포넌트로서 사용하고 있다. 이 컴포넌트는 useRouter를 사용하기 때문에 클라이언트 컴포넌트로써 작동하고 있다.

그러나 useRouter를 사용하는 별도의 컴포넌트를 만들어 아웃소싱하는 방법을 사용하면 이 컴포넌트는 async/await을 사용할 수 있다.

// /components/modal-backdrop.js
"use client";

import { useRouter } from "next/navigation";

export default function ModalBackDrop() {
  const router = useRouter();
  return <div className="modal-backdrop" onClick={router.back} />;
}


// /app/(content)/news/[slug]/@modal/(.)image/page.js
import { notFound } from "next/navigation";

import ModalBackDrop from "@/components/modal-backdrop";
import { getNewsItem } from "@/lib/news";

export default async function InterceptedImagePage({ params }) {
  const newsItemSlug = params.slug;
  const newsItem = await getNewsItem(newsItemSlug);

  if (!newsItem) {
    notFound();
  }

  return (
    <>
      <ModalBackDrop />
      <dialog className="modal" open>
        <div className="fullscreen-image">
          <img src={`/images/news/${newsItem.image}`} alt={newsItem.title} />
        </div>
      </dialog>
    </>
  );
}

💎 /app/(content)/archive/@latest/default.js

// /app/(content)/archive/@latest/default.js
import NewsList from "@/components/news-list";
import { getLatestNews } from "@/lib/news";

export default async function LatestNewsPage() {
  const latestNews = await getLatestNews();

  return (
    <>
      <h2>Latest News</h2>
      <NewsList news={latestNews} />
    </>
  );
}


// /app/(content)/archive/loading.js
export default function ArchiveLoading() {
  return <p>Loading...</p>;
}

💎 /app/(content)/archive/@archive/[[...filter]]/page.js

import Link from "next/link";

import NewsList from "@/components/news-list";
import {
  getAvailableNewsMonths,
  getAvailableNewsYears,
  getNewsForYear,
  getNewsForYearAndMonth,
} from "@/lib/news";

export default async function FilteredNewsPage({ params }) {
  const filter = params.filter;

  const selectedYear = filter?.[0];
  const selectedMonth = filter?.[1];

  let news;
  let links = await getAvailableNewsYears(); // await 추가

  if (selectedYear && !selectedMonth) {
    news = await getNewsForYear(selectedYear); // await 추가
    links = getAvailableNewsMonths(selectedYear);
  }

  if (selectedYear && selectedMonth) {
    // await 추가
    news = await getNewsForYearAndMonth(selectedYear, selectedMonth);
    links = [];
  }

  let newsContent = <p>No news found for the selected period.</p>;

  if (news && news.length > 0) {
    newsContent = <NewsList news={news} />;
  }

  const availableYears = await getAvailableNewsYears(); // await 추가

  if (
    // DB의 내용과 비교하므로 숫자로 selectedYears/Month가 포함되어있는지 파악하는게 아니라 문자열로 파악해야한다.
    (selectedYear && !availableYears.includes(selectedYear)) ||
    (selectedMonth &&
      !getAvailableNewsMonths(selectedYear).includes(selectedMonth))
  ) {
    throw new Error("Invalid filter.");
  }

  return (
    <>
      <header id="archive-header">
        <nav>
          <ul>
            {links.map((link) => {
              const href = selectedYear
                ? `/archive/${selectedYear}/${link}`
                : `/archive/${link}`;

              return (
                <li key={link}>
                  <Link href={href}>{link}</Link>
                </li>
              );
            })}
          </ul>
        </nav>
      </header>
      {newsContent}
    </>
  );
}

📖 서스펜스가 있는 세분화된 데이터 가져오기

리액트에서 제공하는 서스펜스 컴포넌트를 사용하여 어떤 종류의 데이터를 기다리고 싶은지와 어떤 상황에서 로딩 대체 화면이 표시되어야 하는지를 NextJS에 자세히 알려주는 방법이다.

💎 /app/(content)/archive/@archive/[[...filter]]/page.js

import Link from "next/link";

import NewsList from "@/components/news-list";
import {
  getAvailableNewsMonths,
  getAvailableNewsYears,
  getNewsForYear,
  getNewsForYearAndMonth,
} from "@/lib/news";
import { Suspense } from "react";

async function FilteredNews({ selectedYear, selectedMonth }) {
  // FilteredNews를 표현하는 컴포넌트로 별도로 다른 곳에 정의되어도 된다.
  let news;

  if (selectedYear && !selectedMonth) {
    news = await getNewsForYear(selectedYear);
  } else if (selectedYear && selectedMonth) {
    news = await getNewsForYearAndMonth(selectedYear, selectedMonth);
  }

  let newsContent = <p>No news found for the selected period.</p>;

  if (news && news.length > 0) {
    newsContent = <NewsList news={news} />;
  }

  return newsContent;
}

export default async function FilteredNewsPage({ params }) {
  const filter = params.filter;

  const selectedYear = filter?.[0];
  const selectedMonth = filter?.[1];

  const availableYears = await getAvailableNewsYears();
  let links = availableYears;

  if (selectedYear && !selectedMonth) {
    links = getAvailableNewsMonths(selectedYear);
  }

  if (selectedYear && selectedMonth) {
    links = [];
  }

  if (
    (selectedYear && !availableYears.includes(selectedYear)) ||
    (selectedMonth &&
      !getAvailableNewsMonths(selectedYear).includes(selectedMonth))
  ) {
    throw new Error("Invalid filter.");
  }

  return (
    <>
      <header id="archive-header">
        <nav>
          <ul>
            {links.map((link) => {
              const href = selectedYear
                ? `/archive/${selectedYear}/${link}`
                : `/archive/${link}`;

              return (
                <li key={link}>
                  <Link href={href}>{link}</Link>
                </li>
              );
            })}
          </ul>
        </nav>
      </header>
      <Suspense fallback={<p>Loading news...</p>}>
        <FilteredNews
          selectedYear={selectedYear}
          selectedMonth={selectedMonth}
        />
      </Suspense>
    </>
  );
}

데이터를 가져오는 로직을 별도의 리액트 서버 컴포넌트로 옮겨 선택한 연과 월의 데이터를 불러오기 때문에 리액트 서스펜스 훅을 사용할 수 있다.

위의 방식도 약간의 로딩이 발생하는 것을 알 수 있다. 왜냐하면 <header> 부분이 다시 렌더링 될 때까지 약간의 로딩시간이 필요하기 때문이다. 이를 다시 새로운 컴포넌트로 나눠서 해결할 수 있다.


import Link from "next/link";

import NewsList from "@/components/news-list";
import {
  getAvailableNewsMonths,
  getAvailableNewsYears,
  getNewsForYear,
  getNewsForYearAndMonth,
} from "@/lib/news";
import { Suspense } from "react";

async function FilteredHeader({ selectedYear, selectedMonth }) {
  const availableYears = await getAvailableNewsYears();
  let links = availableYears;

  if (selectedYear && !selectedMonth) {
    links = getAvailableNewsMonths(selectedYear);
  }

  if (selectedYear && selectedMonth) {
    links = [];
  }

  if (
    (selectedYear && !availableYears.includes(selectedYear)) ||
    (selectedMonth &&
      !getAvailableNewsMonths(selectedYear).includes(selectedMonth))
  ) {
    throw new Error("Invalid filter.");
  }

  return (
    <header id="archive-header">
      <nav>
        <ul>
          {links.map((link) => {
            const href = selectedYear
              ? `/archive/${selectedYear}/${link}`
              : `/archive/${link}`;

            return (
              <li key={link}>
                <Link href={href}>{link}</Link>
              </li>
            );
          })}
        </ul>
      </nav>
    </header>
  );
}

async function FilteredNews({ selectedYear, selectedMonth }) {
  // FilteredNews를 표현하는 컴포넌트로 별도로 다른 곳에 정의되어도 된다.
  let news;

  if (selectedYear && !selectedMonth) {
    news = await getNewsForYear(selectedYear);
  } else if (selectedYear && selectedMonth) {
    news = await getNewsForYearAndMonth(selectedYear, selectedMonth);
  }

  let newsContent = <p>No news found for the selected period.</p>;

  if (news && news.length > 0) {
    newsContent = <NewsList news={news} />;
  }

  return newsContent;
}

export default async function FilteredNewsPage({ params }) {
  const filter = params.filter;

  const selectedYear = filter?.[0];
  const selectedMonth = filter?.[1];

  return (
    <>
      <Suspense fallback={<p>Loading filter...</p>}>
        <FilteredHeader
          selectedYear={selectedYear}
          selectedMonth={selectedMonth}
        />
      </Suspense>
      <Suspense fallback={<p>Loading news...</p>}>
        <FilteredNews
          selectedYear={selectedYear}
          selectedMonth={selectedMonth}
        />
      </Suspense>
    </>
  );
}

0개의 댓글

관련 채용 정보