From 2847cf52f8fd499578121070e37ffc0cbcaa29e9 Mon Sep 17 00:00:00 2001 From: ParkWonYeop Date: Mon, 29 Dec 2025 00:46:47 +0900 Subject: [PATCH] . --- src/app/posts/[slug]/page.tsx | 45 +++++++++++++----------- src/components/post/PostDetailClient.tsx | 39 +++++++------------- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/src/app/posts/[slug]/page.tsx b/src/app/posts/[slug]/page.tsx index 6ada444..993f586 100644 --- a/src/app/posts/[slug]/page.tsx +++ b/src/app/posts/[slug]/page.tsx @@ -1,18 +1,20 @@ import { Metadata } from 'next'; import PostDetailClient from '@/components/post/PostDetailClient'; +import { notFound } from 'next/navigation'; +import { Post } from '@/types'; -// 서버 사이드 메타데이터 생성을 위한 타입 type Props = { params: Promise<{ slug: string }>; }; -// 메타데이터용 데이터 패칭 (src/api/http.ts를 거치지 않고 직접 호출) -async function getPostForMetadata(slug: string) { +// 🛠️ 서버 사이드 데이터 패칭 함수 (Metadata와 Page 양쪽에서 재사용) +async function getPostFromServer(slug: string): Promise { const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; try { const res = await fetch(`${BASE_URL}/api/posts/${slug}`, { - // 메타데이터는 캐시해도 되지만, 글 수정 시 반영을 위해 적절한 시간 설정 + // 캐시 설정: 60초마다 갱신 (블로그 특성상 적절) + // 즉시 반영이 필요하다면 'no-store'로 설정하거나 revalidate: 0 사용 next: { revalidate: 60 }, }); @@ -21,15 +23,15 @@ async function getPostForMetadata(slug: string) { const json = await res.json(); return json.data; } catch (error) { - console.error('Metadata fetch error:', error); + console.error('Server fetch error:', error); return null; } } -// 🌟 핵심: 동적 메타데이터 생성 (SEO & 카톡 공유 미리보기) +// 🌟 메타데이터 생성 (SEO) export async function generateMetadata({ params }: Props): Promise { const { slug } = await params; - const post = await getPostForMetadata(slug); + const post = await getPostFromServer(slug); if (!post) { return { @@ -37,15 +39,14 @@ export async function generateMetadata({ params }: Props): Promise { }; } - // 본문 내용을 150자로 잘라서 설명으로 사용 (마크다운 제거) + // 본문 요약 및 이미지 추출 로직 const description = post.content ?.replace(/[#*`_~]/g, '') .replace(/\n/g, ' ') .substring(0, 150) + '...'; - // 썸네일 이미지 추출 (본문에 이미지가 있다면 첫 번째 이미지 사용) const imageMatch = post.content?.match(/!\[.*?\]\((.*?)\)/); - const imageUrl = imageMatch ? imageMatch[1] : '/og-image.png'; // 기본 이미지 + const imageUrl = imageMatch ? imageMatch[1] : '/og-image.png'; return { title: post.title, @@ -55,15 +56,9 @@ export async function generateMetadata({ params }: Props): Promise { description: description, url: `https://blog.wypark.me/posts/${slug}`, siteName: 'WYPark Blog', - images: [ - { - url: imageUrl, - width: 1200, - height: 630, - }, - ], + images: [{ url: imageUrl, width: 1200, height: 630 }], type: 'article', - publishedTime: post.updatedAt, + publishedTime: post.createdAt, // created_at 대신 createdAt 사용 주의 (타입 정의 따름) authors: ['WYPark'], }, twitter: { @@ -75,8 +70,18 @@ export async function generateMetadata({ params }: Props): Promise { }; } -// 실제 페이지 렌더링 +// 🌟 실제 페이지 렌더링 (SSR 적용) export default async function PostDetailPage({ params }: Props) { const { slug } = await params; - return ; + + // 1. 서버에서 데이터를 미리 가져옵니다. + const post = await getPostFromServer(slug); + + // 2. 데이터가 없으면 404 페이지로 보냅니다. (봇에게도 404 신호를 줌) + if (!post) { + notFound(); + } + + // 3. 가져온 데이터를 클라이언트 컴포넌트에 'initialPost'로 넘겨줍니다. + return ; } \ No newline at end of file diff --git a/src/components/post/PostDetailClient.tsx b/src/components/post/PostDetailClient.tsx index 10ba60e..bc50c9a 100644 --- a/src/components/post/PostDetailClient.tsx +++ b/src/components/post/PostDetailClient.tsx @@ -1,7 +1,7 @@ 'use client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getPost, deletePost } from '@/api/posts'; // 🛠️ getPostsByCategory 제거 +import { getPost, deletePost } from '@/api/posts'; import { getProfile } from '@/api/profile'; import MarkdownRenderer from '@/components/post/MarkdownRenderer'; import CommentList from '@/components/comment/CommentList'; @@ -10,34 +10,28 @@ import { Loader2, Calendar, Eye, Folder, User, Edit2, Trash2, ArrowLeft, AlertCi import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/store/authStore'; import Link from 'next/link'; +import { Post } from '@/types'; // 타입 임포트 interface PostDetailClientProps { slug: string; + initialPost: Post; // 🌟 서버에서 넘겨받는 초기 데이터 (필수) } -export default function PostDetailClient({ slug }: PostDetailClientProps) { +export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) { const router = useRouter(); const queryClient = useQueryClient(); const { role, _hasHydrated } = useAuthStore(); const isAdmin = _hasHydrated && role?.includes('ADMIN'); - // 1. 게시글 상세 조회 (이제 여기에 prevPost, nextPost가 포함됨) + // 1. 게시글 상세 조회 const { data: post, isLoading: isPostLoading, error } = useQuery({ queryKey: ['post', slug], queryFn: () => getPost(slug), enabled: !!slug, - retry: 1, + // 🌟 핵심: 서버에서 가져온 데이터를 초기값으로 사용하여 즉시 렌더링 (Hydration) + initialData: initialPost, }); - // 🗑️ 삭제됨: 더 이상 프론트에서 앞뒤 글을 찾기 위해 목록을 조회할 필요가 없음! - /* const { data: neighborPosts } = useQuery({ - queryKey: ['posts', 'category', post?.categoryName], - queryFn: () => getPostsByCategory(post!.categoryName, 0, 100), - enabled: !!post?.categoryName, - staleTime: 1000 * 60 * 5, - }); - */ - const { data: profile } = useQuery({ queryKey: ['profile'], queryFn: getProfile, @@ -55,6 +49,8 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) { }, }); + // 🌟 Loading 상태 처리를 제거하거나 조건을 완화합니다. + // initialData가 있으면 isLoading은 false가 되므로 바로 아래 컨텐츠가 렌더링됩니다. if (isPostLoading) { return (
@@ -63,6 +59,7 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) { ); } + // 에러 처리 if (error || !post) { const errorStatus = (error as any)?.response?.status; const errorMessage = (error as any)?.response?.data?.message || error?.message || '게시글을 찾을 수 없습니다.'; @@ -86,18 +83,9 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) { ); } - // 🗑️ 삭제됨: 인덱스 계산 로직 제거 - /* - const currentIndex = neighborPosts?.content.findIndex((p) => p.id === post.id) ?? -1; - const newerPost = ... - const olderPost = ... - */ - - // 🆕 백엔드 데이터 직접 사용 - // 보통 '이전 글'은 과거 글(prev), '다음 글'은 최신 글(next)입니다. - // 백엔드 구현에 따라 prevPost/nextPost 위치가 반대일 수 있으니 확인 후 위치만 바꿔주세요. - const prevPost = post.prevPost; // 이전 글 (왼쪽 버튼) - const nextPost = post.nextPost; // 다음 글 (오른쪽 버튼) + // 백엔드 데이터 사용 (이전글/다음글) + const prevPost = post.prevPost; + const nextPost = post.nextPost; const handleDelete = () => { if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) { @@ -149,7 +137,6 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
- {/* 🛠️ 네비게이션 영역 수정: prevPost / nextPost 직접 사용 */}