Kyurasi

kyurasi.com/@junmannn_yoon

Next.js + Supabase 프로젝트 - (8) : 포스팅 상세 페이지 구현

저번 포스팅에 대한 요약, 이번 포스팅에서 이어질 내용에 대한 안내 후 진행하도록 하겠습니다. 이전 포스팅 요약 Next.js와 Supabase를 활용한 CRUD(생성, 조회, 수정, 삭제) 기능 구현에 대한 튜토리얼입니다. 주요 내용은 다음과 같습니다:Supabase 설정Supabase 프로젝트 생성테이블 구성 (예: posts 테이블)Next.js에서 Supabase 연동Supabase 클라이언트 설정환경변수로 URL과 키 관리CRUD 기능 구현createPost: 게시물 생성getPosts: 게시물 전체 조회getPost: 특정 게시물 조회updatePost: 게시물 수정deletePost: 게시물 삭제API 라우트 작성 (pages/api/ 폴더)각 기능별로 API 핸들러 작성프론트엔드에서 API..

Next.js + Supabase 프로젝트 - (8) : 포스팅 상세 페이지 구현

Next.js + Supabase 프로젝트 - (8) : 포스팅 상세 페이지 구현

📅
junmannnyoonjunmannnyoon
1
#next supabase#next tutorial#next 실습#next.js tutorial#react 실습#react 프로젝트#supabase react#개발자 포트폴리오 만들기#리엑트 프로젝트
💡이 글의 요약

저번 포스팅에 대한 요약, 이번 포스팅에서 이어질 내용에 대한 안내 후 진행하도록 하겠습니다. 이전 포스팅 요약 Next.js와 Supabase를 활용한 CRUD(생성, 조회, 수정, 삭제) 기능 구현에 대한 튜토리얼입니다. 주요 내용은 다음과 같습니다:Supabase 설정Supabase 프로젝트 생성테이블 구성 (예: posts 테이블)Next.js에서 Supabase 연동Supabase 클라이언트 설정환경변수로 URL과 키 관리CRUD 기능 구현createPost: 게시물 생성getPosts: 게시물 전체 조회getPost: 특정 게시물 조회updatePost: 게시물 수정deletePost: 게시물 삭제API 라우트 작성 (pages/api/ 폴더)각 기능별로 API 핸들러 작성프론트엔드에서 API..

저번 포스팅에 대한 요약, 이번 포스팅에서 이어질 내용에 대한 안내 후 진행하도록 하겠습니다.

 

이전 포스팅 요약

 

Next.js와 Supabase를 활용한 CRUD(생성, 조회, 수정, 삭제) 기능 구현에 대한 튜토리얼입니다. 주요 내용은 다음과 같습니다:

  1. Supabase 설정
    • Supabase 프로젝트 생성
    • 테이블 구성 (예: posts 테이블)
  2. Next.js에서 Supabase 연동
    • Supabase 클라이언트 설정
    • 환경변수로 URL과 키 관리
  3. CRUD 기능 구현
    • createPost: 게시물 생성
    • getPosts: 게시물 전체 조회
    • getPost: 특정 게시물 조회
    • updatePost: 게시물 수정
    • deletePost: 게시물 삭제
  4. API 라우트 작성 (pages/api/ 폴더)
    • 각 기능별로 API 핸들러 작성
  5. 프론트엔드에서 API 호출
    • fetch로 각 API 호출하여 CRUD 동작 구현

전반적으로 Next.js API Routes와 Supabase를 이용해 전체적인 CRUD 흐름을 직접 구성하는 방법을 설명한 실습 중심의 글입니다.

https://kyurasi.tistory.com/entry/Nextjs-Supabase-프로젝트-7-CRUD-구현-코드

Next.js + Supabase 프로젝트 - (7) : Supabase CRUD, UI 코드 구현 (해당 파일들 전체 코드 공개)이번 포스팅은 아래의 CRUD 구현에 대한 상세 설명으로 이어집니다.이번 포스팅은 내용은 컴팩트 하나, 코드를 첨부함에 따라 스크롤이 길 수는 있겠습니다ㅎㅎ 파일을 추가하는게 나을 수도 있kyurasi.tistory.com

 

 

 

이전 포스팅에서, http://localhost:3000/blog 로 접근 시에 생성해둔 모든 포스팅을 가져오는 페이지를 만들었었죠. 여기서 해당 포스팅이 제대로 작동을 하는지를 확인을 해야겠습니다. 

 

 

Next.js 블로그에서 한글 슬러그(Slug) URL 구현하기

Next.js App Router와 Supabase를 사용한 블로그 시스템을 개발하면서 SEO 친화적인 URL 구조를 위해 slug 기반 라우팅을 선택하고 구현한 과정을 정리했습니다.

 

핵심 주제:

  • 왜 ID 대신 slug를 선택했는지
  • 한글 slug를 어떻게 처리했는지
  • Next.js App Router에서 동적 라우팅 구현 방법
  • URL 인코딩 문제와 해결 과정

왜 Slug를 선택했나?

1. 기존 방식 vs Slug 방식 비교

블로그 포스트 상세 페이지 접근 방법을 고려할 때 두 가지 선택지가 있었습니다:

옵션 1: ID 기반 URL

❌ /blog/1
❌ /blog/2  
❌ /blog/123

옵션 2: Slug 기반 URL

✅ /blog/nextjs-blog-development
✅ /blog/supabase-setup-guide
✅ /blog/나의첫블로그-게시글

2. Slug를 선택한 이유

 SEO 최적화

// ID 기반: 검색엔진이 내용을 예측하기 어려움
/blog/1

// Slug 기반: URL만 봐도 내용을 알 수 있음
/blog/nextjs-app-router-guide

 사용자 경험 개선

  • URL을 보고 포스트 내용을 예상할 수 있음
  • 소셜 미디어 공유 시 더 신뢰성 있는 링크
  • 북마크할 때 의미있는 URL

 검색 엔진 친화적

  • 구글, 네이버 등 검색엔진이 URL의 키워드를 분석
  • 더 나은 검색 결과 순위
  • 크롤링 봇이 페이지 내용을 쉽게 파악

데이터베이스 설계

1. posts 테이블 구조

-- Supabase에서 생성한 posts 테이블
CREATE TABLE posts (
    id BIGSERIAL PRIMARY KEY,           -- 자동 증가 ID
    title TEXT NOT NULL,                -- 포스트 제목
    slug TEXT UNIQUE NOT NULL,          -- URL용 슬러그 (핵심!)
    content TEXT,                       -- 포스트 내용
    excerpt TEXT,                       -- 요약
    featured_image TEXT,                -- 대표 이미지
    tags TEXT[],                        -- 태그 배열
    published BOOLEAN DEFAULT false,    -- 발행 여부
    published_at TIMESTAMPTZ,          -- 발행 시간
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 슬러그 유니크 인덱스 (중복 방지)
CREATE UNIQUE INDEX posts_slug_idx ON posts(slug);

핵심 포인트: slug 컬럼을 UNIQUE로 설정하여 중복 방지

2. 슬러그 생성 규칙

포스트 작성 시 제목을 기반으로 슬러그를 자동 생성:

// 슬러그 생성 함수 (예시)
function generateSlug(title: string): string {
    return title
        .toLowerCase()                    // 소문자 변환
        .replace(/\s+/g, '-')            // 공백을 하이픈으로
        .replace(/[^\w\-가-힣]/g, '')     // 특수문자 제거 (한글 유지)
        .trim();
}

// 예시
generateSlug("나의첫블로그 게시글");        // → "나의첫블로그-게시글"
generateSlug("Next.js App Router 가이드"); // → "nextjs-app-router-가이드"

️ Next.js App Router 구현

1. 파일 구조 설계

app/
├── blog/
│   ├── page.tsx              # 블로그 목록 페이지
│   ├── write/
│   │   └── page.tsx          # 포스트 작성 페이지
│   └── [slug]/               #   동적 라우팅
│       └── page.tsx          # 개별 포스트 페이지

핵심: [slug] 폴더명으로 동적 라우팅 설정

2. 블로그 목록 페이지 구현

포스트 목록에서 각 포스트로 연결하는 링크 생성:

// app/blog/page.tsx
import Link from 'next/link';

export default async function BlogPage() {
    const supabase = await createServerSupabaseClient();
    
    // 발행된 포스트 조회
    const { data: posts, error } = await supabase
        .from('posts')
        .select('*')
        .eq('published', true)
        .order('published_at', { ascending: false });

    return (
        <div className="max-w-6xl mx-auto p-6">
            <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
                {posts?.map((post) => (
                    <Link
                        key={post.id}
                        href={`/blog/${post.slug}`}  //   핵심: slug로 링크 생성
                        className="block"
                    >
                        <article className="bg-white rounded-lg shadow-md hover:shadow-lg transition-all">
                            <div className="p-6">
                                <h3 className="text-xl font-semibold mb-2">
                                    {post.title}
                                </h3>
                                <p className="text-gray-600 mb-4">
                                    {post.excerpt}
                                </p>
                                
                                {/* 태그 표시 */}
                                {post.tags && (
                                    <div className="flex gap-2">
                                        {post.tags.map((tag: string, index: number) => (
                                            <span key={index} className="px-2 py-1 bg-blue-100 text-blue-600 rounded">
                                                {tag}
                                            </span>
                                        ))}
                                    </div>
                                )}
                            </div>
                        </article>
                    </Link>
                ))}
            </div>
        </div>
    );
}

핵심 포인트:

  • href={/blog/${post.slug}}: 데이터베이스의 slug 값을 직접 사용
  • Next.js Link 컴포넌트로 클라이언트 사이드 네비게이션

3. 동적 라우팅 페이지 구현

// app/blog/[slug]/page.tsx
interface PostPageProps {
    params: Promise<{
        slug: string;  // URL의 [slug] 부분이 여기로 전달됨
    }>;
}

export default async function PostPage({ params }: PostPageProps) {
    const resolvedParams = await params;
    const supabase = await createServerSupabaseClient();

    //   핵심: slug로 포스트 조회
    const { data: post, error } = await supabase
        .from("posts")
        .select("*")
        .eq("slug", resolvedParams.slug)  // slug 컬럼으로 검색
        .eq("published", true)
        .single();

    if (error || !post) {
        return (
            <div className="max-w-4xl mx-auto p-6">
                <div className="text-center py-12">
                    <h1 className="text-2xl font-bold mb-4">
                        포스트를 찾을 수 없습니다
                    </h1>
                    <Link href="/blog" className="text-blue-600">
                        블로그 목록으로 돌아가기
                    </Link>
                </div>
            </div>
        );
    }

    return (
        <div className="max-w-4xl mx-auto p-6">
            {/* 뒤로가기 */}
            <Link href="/blog" className="text-blue-600 mb-8 inline-block">
                ← 블로그 목록으로 돌아가기
            </Link>

            <article className="prose prose-lg max-w-none">
                {/* 포스트 헤더 */}
                <header className="mb-8">
                    {/* 태그들 */}
                    {post.tags && post.tags.length > 0 && (
                        <div className="flex gap-2 mb-4">
                            {post.tags.map((tag: string, index: number) => (
                                <span key={index} className="px-3 py-1 bg-blue-100 text-blue-600 rounded-full">
                                    {tag}
                                </span>
                            ))}
                        </div>
                    )}

                    {/* 제목 */}
                    <h1 className="text-4xl font-bold mb-4">
                        {post.title}
                    </h1>

                    {/* 발행일 */}
                    {post.published_at && (
                        <time className="text-gray-600">
                            {new Date(post.published_at).toLocaleDateString('ko-KR')}
                        </time>
                    )}

                    {/* 대표 이미지 */}
                    {post.featured_image && (
                        <div className="relative w-full h-96 mt-6 mb-8">
                            <Image
                                src={post.featured_image}
                                alt={post.title}
                                fill
                                className="object-cover rounded-lg"
                                priority
                            />
                        </div>
                    )}
                </header>

                {/* 포스트 내용 */}
                <div 
                    className="prose-headings:text-gray-900 prose-a:text-blue-600"
                    dangerouslySetInnerHTML={{ __html: post.content }}
                />
            </article>
        </div>
    );
}

한글 슬러그 처리 과정

1. 사용자 플로우

  1. 포스트 작성: 제목 “나의첫블로그 게시글” → slug “나의첫블로그-게시글”
  2. 데이터베이스 저장: slug 컬럼에 한글 값 저장
  3. 목록 페이지: href="/blog/나의첫블로그-게시글" 링크 생성
  4. 사용자 클릭: Next.js 라우터가 /blog/[slug] 페이지로 라우팅
  5. 브라우저 URL: 자동으로 %EB%82%98%EC%9D%98%EC%B2%AB%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B2%8C%EC%8B%9C%EA%B8%80로 인코딩
  6. 서버에서 받는 params: 인코딩된 값

2. URL 인코딩 문제 발견

브라우저는 한글 URL을 자동으로 퍼센트 인코딩하므로:

// 사용자가 클릭한 링크
href="/blog/나의첫블로그-게시글"

// 브라우저가 실제로 요청하는 URL
GET /blog/%EB%82%98%EC%9D%98%EC%B2%AB%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B2%8C%EC%8B%9C%EA%B8%80

// 서버에서 받는 params.slug
"%EB%82%98%EC%9D%98%EC%B2%AB%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B2%8C%EC%8B%9C%EA%B8%80"

// 데이터베이스에 저장된 slug
"나의첫블로그-게시글"

// 결과: 매칭 실패!

3. 해결 방법: URL 디코딩

// app/blog/[slug]/page.tsx 수정
export default async function PostPage({ params }: PostPageProps) {
    const resolvedParams = await params;
    const supabase = await createServerSupabaseClient();

    //   핵심: URL 디코딩 추가
    const decodedSlug = decodeURIComponent(resolvedParams.slug);

    // 디코딩된 값으로 데이터베이스 조회
    const { data: post, error } = await supabase
        .from("posts")
        .select("*")
        .eq("slug", decodedSlug)  // ✅ 한글 원본 값으로 검색
        .eq("published", true)
        .single();

4. SEO 메타데이터도 동일하게 처리

// SEO 메타데이터 생성 함수
export async function generateMetadata({ params }: PostPageProps) {
    const resolvedParams = await params;
    const supabase = await createServerSupabaseClient();

    //   여기서도 디코딩 필요
    const decodedSlug = decodeURIComponent(resolvedParams.slug);

    const { data: post } = await supabase
        .from("posts")
        .select("title, excerpt, meta_description, featured_image")
        .eq("slug", decodedSlug)
        .eq("published", true)
        .single();

    if (!post) {
        return { title: "포스트를 찾을 수 없습니다" };
    }

    return {
        title: post.title,
        description: post.meta_description || post.excerpt || "큐라시의 블로그 포스트",
        openGraph: {
            title: post.title,
            description: post.meta_description || post.excerpt,
            images: post.featured_image ? [post.featured_image] : [],
        },
        twitter: {
            card: 'summary_large_image',
            title: post.title,
            description: post.meta_description || post.excerpt,
            images: post.featured_image ? [post.featured_image] : [],
        }
    };
}

✅ 최종 결과

1. 완성된 URL 구조

✅ SEO 친화적 URL
/blog/나의첫블로그-게시글
/blog/nextjs-app-router-완벽가이드
/blog/supabase-데이터베이스-설정방법

✅ 자동 URL 인코딩/디코딩 처리
브라우저: /blog/나의첫블로그-게시글
→ 인코딩: %EB%82%98%EC%9D%98%EC%B2%AB%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B2%8C%EC%8B%9C%EA%B8%80
→ 서버에서 디코딩: 나의첫블로그-게시글
→ 데이터베이스 조회 성공!

2. 실제 테스트 결과

# 1. 목록 페이지 접속
GET /blog → 포스트 목록 표시

# 2. 포스트 클릭 (한글 slug)
클릭: /blog/나의첫블로그-게시글
→ 브라우저 자동 인코딩
→ 서버에서 디코딩 후 DB 조회
→ 포스트 상세 페이지 정상 표시

# 3. SEO 메타데이터 정상 생성
<title>나의첫블로그 게시글</title>
<meta property="og:title" content="나의첫블로그 게시글"/>
<meta property="og:description" content="유럽여행중 콜로세움 방문기"/>

핵심 학습 포인트

1. Slug 선택 이유

  • SEO 최적화 (검색엔진이 URL 키워드 분석)
  • 사용자 경험 개선 (의미있는 URL)
  • 소셜 미디어 공유 시 신뢰성

2. Next.js App Router 활용

  • [slug] 폴더로 동적 라우팅 구현
  • Server Components에서 직접 데이터베이스 조회
  • generateMetadata로 SEO 메타데이터 생성

3. 한글 URL 처리

  • 브라우저의 자동 퍼센트 인코딩 이해
  • decodeURIComponent()로 원본 값 복원
  • 메인 컴포넌트와 메타데이터 함수 모두 처리 필요

4. 데이터베이스 설계

  • slug 컬럼을 UNIQUE로 설정
  • 제목 기반 자동 슬러그 생성
  • URL 친화적인 문자열 처리

추가 고려사항

1. 슬러그 생성 최적화

// 더 정교한 슬러그 생성 함수
function generateSlug(title: string): string {
    return title
        .toLowerCase()
        .replace(/\s+/g, '-')                    // 공백 → 하이픈
        .replace(/[^\w\-가-힣ㄱ-ㅎㅏ-ㅣ]/g, '')   // 특수문자 제거 (한글 모든 유니코드 유지)
        .replace(/-+/g, '-')                     // 연속 하이픈 → 단일 하이픈
        .replace(/^-|-$/g, '')                   // 앞뒤 하이픈 제거
        .trim();
}

2. 중복 슬러그 처리

// 중복 슬러그 발생 시 숫자 추가
async function createUniqueSlug(baseSlug: string): Promise<string> {
    let slug = baseSlug;
    let counter = 1;
    
    while (await isSlugExists(slug)) {
        slug = `${baseSlug}-${counter}`;
        counter++;
    }
    
    return slug;
}

3. 프로덕션 최적화

// 개발 환경에서만 디버깅 로그
if (process.env.NODE_ENV === 'development') {
    console.log('원본 슬러그:', resolvedParams.slug);
    console.log('디코딩된 슬러그:', decodedSlug);
}

마무리

이제 SEO 친화적이고 한글을 완벽 지원하는 Next.js 블로그 시스템이 완성되었습니다!

구현된 기능들:

  • ✅ Slug 기반 동적 라우팅 (/blog/[slug])
  • ✅ 한글 URL 완벽 지원 (/blog/나의첫블로그-게시글)
  • ✅ 자동 URL 인코딩/디코딩 처리
  • ✅ SEO 메타데이터 자동 생성
  • ✅ 소셜 미디어 공유 최적화
  • ✅ 사용자 친화적인 에러 처리

 

 

 

 

마지막 수정: 2025. 7. 11.