/app/(content)/news/page.js에서 DUMMY_DATA를 불러오는 대신 백엔드에서 데이터를 가져올 예정
"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가 가능하게끔 도와줌.
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 함수를 여기서 사용할 수 있다. 그 이유는 다음과 같다.
NextJS 기능을 사용하여 서버에서 직접 데이터를 가져오고 컴포넌트 함수 안에서 출력하는데 필요한 모든 코드가 완성된다.
위의 사진처럼 페이지 소스에서 모든 뉴스 내용을 확인할 수 있다. → SEO에 이점을 제공
/backend/data.db를 루트 프로젝트로 옮긴다.
npm install better-sqlite3
/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;
}
/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 서버 컴포넌트가 있기 때문에 가능하다. 클라이언트는 데이터베이스에 접근할 수 없기 때문이다. (리액트 서버 컴포넌트는 서버에서만 실행되기 때문에 가능하다.)
export async function getAllNews() {
const news = db.prepare("SELECT * FROM news").all(); // 모든 데이터를 news에 저장하고 반환하기 위함
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2초 지연
return news;
}
export default function NewsLoading() {
return <p>Loading...</p>;
}
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>
);
}
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>
);
}
"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
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>;
}
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에 자세히 알려주는 방법이다.
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>
</>
);
}