Loading
Loading
블로그 개발 일지 #7 - 블로그 페이지
2024년 11월 25일
이전에 연결한 API를 활용하여 블로그 페이지를 동적으로 구현하고자 합니다. 이번 글에서는 게시글 목록 페이지에 API를 연동하여 무한 스크롤이 제대로 동작하는지 확인한 과정을 다루겠습니다.
이전에는 임시 데이터를 이용해 무한 스크롤 기능을 구현했었는데, 이번에는 실제 API와 연동하여 데이터가 정상적으로 표시되는지 확인했습니다.
아래는 API를 통해 게시글 목록을 가져와 페이지에 출력하는 코드입니다.
import { useInfiniteQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useRef, useEffect } from "react"; import { getPostList } from "@/services/post.api"; import { Post } from "@/types/blogType"; import Image from "next/image"; export default function Page() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ["posts"], queryFn: getPostList, getNextPageParam: (lastPage) => (!lastPage.isLast ? lastPage.nextPage : undefined), initialPageParam: 0, }); const loadMoreRef = useRef(null); useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, { threshold: 0.5 } ); if (loadMoreRef.current) { observer.observe(loadMoreRef.current); } return () => { if (loadMoreRef.current) { observer.unobserve(loadMoreRef.current); } }; }, [hasNextPage, isFetchingNextPage, fetchNextPage]); return ( <div className="mx-auto flex flex-col px-5 py-8"> <section className="flex flex-col gap-8"> {data?.pages.map((page, pageIndex) => page.posts.map((blog: Post) => ( <article key={`${pageIndex}-${blog.id}`} className="flex flex-col gap-4"> <Link href={`/blog/${blog.title.replace(/\s+/g, "-")}`}> {blog.coverImg && ( <div className="relative flex h-96 w-full items-center justify-center"> <Image src={blog.coverImg} alt="cover image" className="object-cover" fill sizes="300" /> </div> )} <h2 className="text-3xl font-bold"> <span className="pr-2">[{blog.category}]</span> {blog.title} </h2> <p className="line-clamp-4 text-lg">{blog.content}</p> </Link> <div className="flex gap-1 text-gray-500"> <p>{new Date(blog.createdAt).toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", })}</p> <span>.</span> <p>댓글 {blog._count?.comments || 0}</p> <span>.</span> <p>좋아요 {blog.likes}</p> </div> </article> )) )} </section> <div ref={loadMoreRef} className="flex justify-center py-4"> {isFetchingNextPage && <p>로딩 중...</p>} </div> </div> ); }
React Query를 이용한 데이터 페칭
useInfiniteQuery
를 사용해 무한 스크롤을 구현했습니다. API로부터 게시글 데이터를 받아오며, getNextPageParam
을 통해 다음 페이지의 데이터를 요청할 수 있도록 설정했습니다.
Intersection Observer를 이용한 무한 스크롤
IntersectionObserver
를 사용하여 스크롤의 끝부분에 도달할 때 자동으로 다음 페이지 데이터를 불러오도록 했습니다. loadMoreRef
에 연결된 요소가 화면에 나타날 때 fetchNextPage
가 호출됩니다.
게시글 출력
API에서 받아온 게시글 목록을 순회하며 화면에 표시합니다. 각 게시글은 제목, 내용, 작성일, 댓글 수, 좋아요 수, 그리고 (있다면) 커버 이미지를 포함하고 있습니다.
게시글 목록에서 특정 게시글을 클릭하면 해당 게시글의 상세 페이지로 이동하게 됩니다. 이번에는 게시글 상세 페이지를 API와 연동하여 구현한 내용을 다루겠습니다. 또한, 서버사이드에서 데이터를 받아와 클라이언트 페이지에 전달하는 방식도 설명하겠습니다.
React Query를 이용한 게시글 데이터 페칭
useQuery
를 사용하여 특정 게시글의 데이터를 API로부터 받아옵니다. title
을 이용해 해당 게시글을 식별합니다.
게시글 출력
API로 받아온 게시글의 제목, 작성일, 태그, 카테고리, 본문 등을 화면에 표시합니다. 본문은 ReactMarkdown
을 이용해 마크다운 형식으로 렌더링하며, 코드 블럭은 react-syntax-highlighter
를 사용해 하이라이팅합니다.
서버사이드 데이터 페칭
서버사이드에서 게시글 데이터를 미리 받아와 클라이언트 페이지에 전달하기 위해 prefetchQuery
를 사용하여 데이터를 사전에 로드합니다. 이 방법은 페이지 로딩 시 초기 데이터를 준비하는 데 유용합니다.
게시글 좋아요 기능 구현
사용자가 게시글에 좋아요를 클릭하면, postLike
테이블에 해당 사용자의 좋아요 정보가 기록됩니다. 게시글 페이지에 접속할 때마다 서버에서 사용자가 이미 좋아요를 눌렀는지 확인하여, 그에 맞는 하트 아이콘 상태를 동적으로 표시합니다.
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; import ClientPage from "./_components/ClientPage"; import { getPost } from "@/services/post.api"; export default async function Page({ params }: { params: { title: string } }) { const queryClient = new QueryClient(); const title = params.title; await queryClient.prefetchQuery({ queryKey: ["post", title], queryFn: () => getPost(title), }); return ( <HydrationBoundary state={dehydrate(queryClient)}> <ClientPage title={title} /> </HydrationBoundary> ); }
이제 블로그 페이지에서 새로운 게시글을 작성할 수 있는 게시글 작성 페이지에 대해 설명하겠습니다. 게시글 작성 페이지에서는 마크다운을 사용하여 콘텐츠를 작성하고, 이미지 업로드 및 미리보기 기능을 제공합니다.
MarkdownEditor
컴포넌트를 사용하여 사용자는 마크다운 형식으로 콘텐츠를 쉽게 작성할 수 있습니다. 이를 통해 작성자는 텍스트 스타일을 풍부하게 표현하고, 코드 블럭, 리스트, 링크 등 다양한 요소를 직관적으로 추가할 수 있습니다. 마크다운은 간단한 문법으로 복잡한 서식을 지원하기 때문에 블로그 게시글 작성에 매우 적합합니다.
아래는 게시글 작성 페이지의 코드입니다.
import axios from "axios"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import MarkdownEditor from "./_components/MarkdownEditor"; export default async function Page({ searchParams }: { searchParams: { title: string } }) { const slug = searchParams.title; const accessToken = cookies().get("accessToken")?.value ?? ""; if (accessToken) { const res = await axios.get(`${process.env.NEXT_PUBLIC_BASE_URL}/users/me`, { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!res.data.isAdmin) redirect("/blog"); } else { redirect("/blog"); } return <MarkdownEditor slug={slug} />; }
사용자 인증 및 권한 확인
사용자가 게시글 작성 페이지에 접근하려고 할 때, accessToken
을 통해 사용자가 인증되었는지 확인합니다. 인증되지 않았거나 관리자가 아닌 경우, 블로그 목록 페이지로 리디렉션합니다.
MarkdownEditor
컴포넌트 렌더링
인증된 사용자에게만 게시글 작성 페이지를 렌더링하며, 이미 작성된 게시글을 수정하려는 경우 slug
값을 이용해 해당 게시글 데이터를 불러옵니다.
MarkdownEditor
컴포넌트는 사용자가 마크다운 형식으로 게시글을 작성할 수 있도록 돕는 에디터입니다. 이미지 업로드 및 미리보기 기능도 제공하여 사용자가 작성 중인 콘텐츠를 미리 확인할 수 있습니다.
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useForm, Controller } from "react-hook-form"; import ReactMarkdown from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { writePost } from "@/services/post.api"; import PreviewModal from "./PreviewModal"; export default function MarkdownEditor({ slug }: { slug?: string }) { const router = useRouter(); const queryClient = useQueryClient(); const { handleSubmit, control, watch } = useForm({ defaultValues: { title: "", category: "", content: "", tags: [] }, }); const markdown = watch("content"); const completeWritingMutation = useMutation({ mutationFn: (data) => writePost(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["posts"] }); router.push("/blog"); }, }); return ( <form onSubmit={handleSubmit((data) => completeWritingMutation.mutate(data))} className="flex h-dvh w-full"> <div className="flex max-w-[50%] basis-1/2 flex-col bg-background-primary"> {/* 제목 및 본문 작성 부분 */} </div> <div className="flex max-w-[50%] basis-1/2 flex-col gap-4 overflow-y-auto bg-gray-100 p-[64px_40px_40px]"> {/* 마크다운 미리보기 */} <ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>{markdown}</ReactMarkdown> </div> </form> ); }