logo

DowanKim

3. 한글 url 은 항상 머리가 아프다.

2025년 10월 7일

포트폴리오 사이트

이제 블로그 페이지 및 시스템(마크다운 라이브러리, slug, 파이어베이스 오류 수정, 등..)을 구현해야 합니다.


Next.js 블로그 시스템 구현하기: 마크다운 렌더링과 동적 라우팅

1. 블로그 기능 구현 배경

Firebase 연결 후, 프로젝트별 트러블슈팅을 정리하는 블로그를 추가했습니다. 요구사항:

  • 프로젝트별로 블로그 글 그룹화
  • 마크다운으로 글 작성
  • 코드 하이라이팅
  • 한글 제목을 URL로 사용
  • 동적 라우팅으로 새 글이 즉시 반영

2. 필요한 라이브러리 설치

2.1 마크다운 관련 라이브러리

npm install react-markdown remark-gfm rehype-raw npm install react-syntax-highlighter @types/react-syntax-highlighter

설치한 라이브러리:

  • react-markdown: 마크다운을 React 컴포넌트로 렌더링
  • remark-gfm: GitHub Flavored Markdown 지원 (테이블, 체크리스트 등)
  • rehype-raw: HTML 태그 렌더링 지원
  • react-syntax-highlighter: 코드 하이라이팅

3. 블로그 페이지 구조 설계

3.1 라우트 구조

app/
├── blog/
│   ├── page.tsx              # 프로젝트 목록 페이지 (/blog)
│   └── [tag]/
│       ├── page.tsx           # 프로젝트별 글 목록 (/blog/[tag])
│       └── [slug]/
│           └── page.tsx       # 개별 글 상세 (/blog/[tag]/[slug])

3.2 데이터 구조

Firestore 컬렉션:

  • posts: 블로그 포스트

    • title: 제목
    • content: 마크다운 내용
    • tags: 태그 배열 (프로젝트 이름)
    • slug: URL 친화적 제목
    • published: 공개 여부
    • createdAt, updatedAt: 타임스탬프
  • projects: 프로젝트 정보

    • tag: 프로젝트 이름 (태그)
    • description: 프로젝트 소개글 (마크다운)

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

4.1 프로젝트 목록 페이지 (app/blog/page.tsx)

import Link from 'next/link' import { getAllTags, getAllProjects } from '@/lib/blog' import Header from '@/components/Home/Header/Header' // 항상 동적으로 렌더링하여 최신 데이터를 가져오도록 설정 export const dynamic = 'force-dynamic' export const revalidate = 0 export default async function BlogPage() { const tags = await getAllTags() const projects = await getAllProjects() // 프로젝트 정보가 있는 태그와 없는 태그를 구분 const projectMap = new Map(projects.map(p => [p.tag, p])) const projectTags = tags.map(tag => ({ tag, project: projectMap.get(tag) || null, })) return ( <> <Header /> <section className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <div className="max-w-[1200px] mx-auto"> <h1 className="text-5xl text-white mb-4 text-center">Projects</h1> <p className="text-xl text-center mb-12 opacity-80"> 프로젝트별 개발 과정을 블로그 형식으로 정리하였습니다.<br/> 프로젝트 클릭 시 해당 프로젝트의 블로그 글을 확인할 수 있습니다. </p> {projectTags.length === 0 ? ( <p className="text-center text-xl opacity-60 py-16"> 아직 작성된 글이 없습니다. </p> ) : ( <div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-8"> {projectTags.map(({ tag, project }) => ( <Link key={tag} href={`/blog/${encodeURIComponent(tag)}`} className="bg-[#1b1e26] p-8 rounded-2xl border border-[rgba(3,232,249,0.2)] transition-all duration-300 no-underline text-inherit block hover:border-[#03e8f9] hover:-translate-y-1 hover:shadow-[0_4px_12px_rgba(3,232,249,0.2)]" > <h2 className="text-2xl mb-4 text-white">{tag}</h2> {project && project.description && ( <p className="text-base text-white/70 leading-relaxed"> {(() => { // 첫 번째 줄만 가져오기 (엔터 전까지) const firstLine = project.description.split(/\r?\n/)[0] || '' // 필요시 길이 제한 (예: 100자) return firstLine.length > 100 ? `${firstLine.substring(0, 100)}...` : firstLine })()} </p> )} </Link> ))} </div> )} </div> </section> </> ) }

코드 설명:

  1. 동적 렌더링 설정

    export const dynamic = 'force-dynamic' export const revalidate = 0
    • force-dynamic: 매 요청마다 새로 렌더링
    • revalidate = 0: 캐시 재검증 시간 0초
    • 업데이트 되는 내용들이 바로바로 반영되게, force-dynamic을 사용하였습니다.
  2. 데이터 가져오기

    const tags = await getAllTags() const projects = await getAllProjects()
    • 서버 컴포넌트에서 Firebase 데이터를 직접 가져옴
    • getAllTags(): 포스트와 프로젝트에서 태그 추출
    • getAllProjects(): 프로젝트 정보 가져오기
  3. 태그와 프로젝트 매핑

    const projectMap = new Map(projects.map(p => [p.tag, p])) const projectTags = tags.map(tag => ({ tag, project: projectMap.get(tag) || null, }))
    • Map으로 태그 → 프로젝트 매핑
    • 프로젝트 정보가 있으면 표시, 없으면 null
  4. 한글 URL 인코딩

    href={`/blog/${encodeURIComponent(tag)}`}
    • encodeURIComponent()로 한글 태그를 URL-safe하게 변환
    • 예: "포트폴리오 사이트" → "%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4%20%EC%82%AC%EC%9D%B4%ED%8A%B8"
  5. 프로젝트 설명 첫 줄만 표시

    const firstLine = project.description.split(/\r?\n/)[0] || '' return firstLine.length > 100 ? `${firstLine.substring(0, 100)}...` : firstLine
    • split(/\r?\n/)로 첫 줄 추출
    • 100자 초과 시 말줄임표 추가

4.2 Firestore 함수: getAllTags()

lib/blog.ts:

// 모든 태그 목록 가져오기 (포스트에서 사용된 태그 + 프로젝트 컬렉션의 태그) export const getAllTags = async (): Promise<string[]> => { const tagSet = new Set<string>() // 포스트에서 태그 가져오기 const posts = await getPublishedPosts() posts.forEach(post => { if (post.tags) { post.tags.forEach(tag => tagSet.add(tag)) } }) // 프로젝트 컬렉션에서 태그 가져오기 const projects = await getAllProjects() projects.forEach(project => { if (project.tag) { tagSet.add(project.tag) } }) return Array.from(tagSet).sort() }

설명:

  • Set으로 중복 제거
  • 포스트 태그와 프로젝트 태그를 모두 포함
  • 알파벳 순 정렬

5. 프로젝트별 글 목록 페이지 구현

5.1 프로젝트 페이지 (app/blog/[tag]/page.tsx)

import { notFound } from 'next/navigation' import Link from 'next/link' import { getProjectByTag, getPostsByTagPaginated } from '@/lib/blog' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import Header from '@/components/Home/Header/Header' import PostList from './PostList' interface PageProps { params: { tag: string } } // 항상 동적으로 렌더링하여 최신 데이터를 가져오도록 설정 export const dynamic = 'force-dynamic' export const revalidate = 0 export default async function TagPage({ params }: PageProps) { // URL 인코딩된 태그를 디코딩 const decodedTag = decodeURIComponent(params.tag) const project = await getProjectByTag(decodedTag) // 초기 12개 포스트만 가져오기 (무한 스크롤을 위해) const initialResult = await getPostsByTagPaginated(decodedTag, 12) const initialPosts = initialResult.posts if (initialPosts.length === 0) { notFound() } return ( <> <Header /> <section className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <div className="max-w-[1200px] mx-auto"> {/* 네비게이션 버튼 */} <div className="mb-8"> <Link href="/blog" className="inline-flex items-center gap-2 px-4 py-2 bg-[rgba(3,232,249,0.1)] border border-[rgba(3,232,249,0.2)] rounded-lg text-[#03e8f9] no-underline transition-all duration-300 hover:bg-[rgba(3,232,249,0.2)] hover:border-[#03e8f9] hover:-translate-y-0.5" > <span className="text-xl"></span> <span>프로젝트 목록으로</span> </Link> </div> <h1 className="text-5xl text-white mb-8 text-center">{decodedTag}</h1> {/* 프로젝트 설명 (마크다운 렌더링) */} {project && project.description && ( <div className="bg-[#1b1e26] p-4 md:p-8 rounded-2xl border border-[rgba(3,232,249,0.2)] mb-12 leading-[1.8] overflow-hidden"> <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" style={{ wordBreak: 'break-all', overflowWrap: 'anywhere' }} /> ), }} > {project.description} </ReactMarkdown> </div> )} {/* 포스트 목록 (무한 스크롤) */} <PostList initialPosts={initialPosts} tag={decodedTag} /> </div> </section> </> ) }

코드 설명:

  1. URL 디코딩

    const decodedTag = decodeURIComponent(params.tag)
    • encodeURIComponent()로 인코딩된 태그를 디코딩
    • 예: "%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4" → "포트폴리오"
  2. 프로젝트 설명 마크다운 렌더링

    <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" /> ), }} > {project.description} </ReactMarkdown>
    • remarkGfm: GitHub Flavored Markdown 지원
    • components.a: 링크를 새 창에서 열도록 커스터마이징
    • target="_blank", rel="noopener noreferrer"로 보안 강화
  3. 데이터 없을 때 처리

    if (initialPosts.length === 0) { notFound() }
    • notFound()로 404 페이지 표시

6. 블로그 글 상세 페이지 구현

6.1 글 상세 페이지 (app/blog/[tag]/[slug]/page.tsx)

import type { Metadata } from 'next' import { notFound } from 'next/navigation' import Link from 'next/link' import { getPostBySlug, getPostsByTag } from '@/lib/blog' import ReactMarkdown, { Components } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism' import Header from '@/components/Home/Header/Header' interface PageProps { params: { tag: string slug: string } } // 항상 동적으로 렌더링하여 최신 데이터를 가져오도록 설정 export const dynamic = 'force-dynamic' export const revalidate = 0 export default async function BlogPostPage({ params }: PageProps) { const decodedSlug = decodeURIComponent(params.slug) const decodedTag = decodeURIComponent(params.tag) const post = await getPostBySlug(decodedSlug) if (!post) { notFound() } // 같은 태그의 모든 포스트 가져오기 (이전/다음 포스트 찾기용) const allPosts = await getPostsByTag(decodedTag) const currentIndex = allPosts.findIndex(p => p.id === post.id || p.slug === post.slug) // 이전 포스트 (더 최신 포스트, 인덱스가 작음) const prevPost = currentIndex > 0 ? allPosts[currentIndex - 1] : null // 다음 포스트 (더 오래된 포스트, 인덱스가 큼) const nextPost = currentIndex < allPosts.length - 1 ? allPosts[currentIndex + 1] : null return ( <> <Header /> <article className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <div className="max-w-3xl mx-auto"> {/* 네비게이션 버튼 */} <div className="mb-8 flex flex-col sm:flex-row gap-4"> <Link href="/blog">프로젝트 목록으로</Link> <Link href={`/blog/${encodeURIComponent(decodedTag)}`}> {decodedTag} 목록으로 </Link> </div> {/* 글 헤더 */} <header className="mb-12 pb-8 border-b border-[rgba(3,232,249,0.2)]"> <h1 className="text-4xl text-white mb-4">{post.title}</h1> <p className="text-base text-white/60 mb-4"> {(() => { const date = post.createdAt instanceof Date ? post.createdAt : 'toDate' in post.createdAt ? (post.createdAt as { toDate: () => Date }).toDate() : new Date(post.createdAt as string | number) return date.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' }) })()} </p> {post.tags && post.tags.length > 0 && ( <div className="flex flex-wrap gap-2"> {post.tags.map((tag) => ( <span key={tag} className="bg-[rgba(3,232,249,0.1)] text-[#03e8f9] px-3 py-1 rounded text-sm"> {tag} </span> ))} </div> )} </header> {/* 마크다운 콘텐츠 */} <div className="leading-[1.8] text-lg"> <ReactMarkdown rehypePlugins={[rehypeRaw]} remarkPlugins={[remarkGfm]} components={{ a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" /> ), code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className || '') const language = match ? match[1] : '' const isInline = !match if (!isInline && match) { return ( <SyntaxHighlighter style={vscDarkPlus} language={language} PreTag="div" customStyle={{ background: 'transparent', padding: 0, margin: 0, border: 'none', boxShadow: 'none', }} > {String(children).replace(/\n$/, '')} </SyntaxHighlighter> ) } return ( <code className={className} {...props}> {children} </code> ) }, } satisfies Components} > {post.content} </ReactMarkdown> </div> </div> </article> </> ) }

코드 설명:

  1. Slug로 포스트 가져오기

    const decodedSlug = decodeURIComponent(params.slug) const post = await getPostBySlug(decodedSlug)
    • URL에서 slug 디코딩 후 Firestore에서 조회
  2. 이전/다음 포스트 찾기

    const allPosts = await getPostsByTag(decodedTag) const currentIndex = allPosts.findIndex(p => p.id === post.id || p.slug === post.slug) const prevPost = currentIndex > 0 ? allPosts[currentIndex - 1] : null const nextPost = currentIndex < allPosts.length - 1 ? allPosts[currentIndex + 1] : null
    • 같은 태그의 포스트 목록에서 현재 인덱스 찾기
    • 이전/다음 포스트 계산
  3. 날짜 표시

    const date = post.createdAt instanceof Date ? post.createdAt : 'toDate' in post.createdAt ? (post.createdAt as { toDate: () => Date }).toDate() : new Date(post.createdAt as string | number)
    • Firestore Timestamp를 Date로 변환
    • 서버/클라이언트 환경 모두 고려
  4. 코드 하이라이팅

    code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className || '') const language = match ? match[1] : '' const isInline = !match if (!isInline && match) { return ( <SyntaxHighlighter style={vscDarkPlus} language={language} PreTag="div" > {String(children).replace(/\n$/, '')} </SyntaxHighlighter> ) } return <code className={className} {...props}>{children}</code> }
    • className에서 언어 추출 (예: language-javascript)
    • 코드 블록만 하이라이팅, 인라인 코드는 기본 스타일
    • replace(/\n$/, '')로 마지막 줄바꿈 제거
  5. 타입 안전성

    } satisfies Components}
    • satisfies Components로 타입 체크
    • any 사용 없이 타입 안전성 확보

6.2 Firestore 함수: getPostBySlug()

lib/blog.ts:

// Slug로 포스트 가져오기 export const getPostBySlug = async (slug: string): Promise<BlogPost | null> => { // URL 인코딩된 slug를 디코딩 const decodedSlug = decodeURIComponent(slug) const q = query( collection(db, POSTS_COLLECTION), where('slug', '==', decodedSlug), where('published', '==', true) ) const snapshot = await getDocs(q) if (snapshot.empty) { return null } const doc = snapshot.docs[0] return { id: doc.id, ...doc.data(), createdAt: doc.data().createdAt.toDate(), updatedAt: doc.data().updatedAt.toDate(), } as BlogPost }

설명:

  • decodeURIComponent()로 slug 디코딩
  • where('slug', '==', decodedSlug)로 조회
  • where('published', '==', true)로 공개 글만 조회

7. 트러블슈팅

7.1 한글 URL 인코딩 문제

문제:

  • 한글 제목을 slug로 사용할 때 URL 인코딩이 제대로 되지 않음
  • 브라우저에서 한글 URL이 깨져 보이거나 접근 실패

원인:

  • Next.js 동적 라우트에서 한글을 그대로 사용하면 URL 인코딩이 불일치할 수 있음
  • Firestore에 저장된 slug와 URL의 slug가 다를 수 있음

해결:

  1. Slug 생성 시 인코딩

    // 글 작성 시 slug 생성 const generateSlug = (text: string): string => { let slug = text .trim() .replace(/\s+/g, '-') // 공백을 하이픈으로 .replace(/-+/g, '-') // 연속된 하이픈을 하나로 .replace(/^-|-$/g, '') // 앞뒤 하이픈 제거 // 빈 문자열이면 타임스탬프 사용 if (!slug) { slug = `post-${Date.now()}` } // 한글은 그대로 유지 (Next.js가 자동으로 URL 인코딩 처리) return slug }
  2. URL 생성 시 인코딩

    // 링크 생성 시 <Link href={`/blog/${encodeURIComponent(tag)}/${encodeURIComponent(slug)}`}>
    • encodeURIComponent()로 한글을 URL-safe하게 변환
  3. 페이지에서 디코딩

    // 페이지 컴포넌트에서 const decodedTag = decodeURIComponent(params.tag) const decodedSlug = decodeURIComponent(params.slug)
    • decodeURIComponent()로 원본 문자열 복원
  4. Firestore 조회 시 디코딩된 값 사용

    export const getPostBySlug = async (slug: string): Promise<BlogPost | null> => { const decodedSlug = decodeURIComponent(slug) const q = query( collection(db, POSTS_COLLECTION), where('slug', '==', decodedSlug), where('published', '==', true) ) // ... }

결과:

  • 한글 제목이 URL에 올바르게 인코딩됨
  • Firestore 조회가 정확히 작동함
  • 브라우저에서 한글 URL이 정상 표시됨

7.2 Vercel 배포 시 404 에러

문제:

  • Vercel 배포 후 동적 라우트(/blog/[tag], /blog/[tag]/[slug]) 접근 시 404 발생

원인:

  • generateStaticParams()를 사용하면 빌드 시점에 생성된 경로만 접근 가능
  • 빌드 시 Firebase 접근 실패 시 빈 배열 반환으로 경로가 생성되지 않음
  • 새로운 블로그 글이 추가되어도 경로가 생성되지 않음

해결:

  1. generateStaticParams() 제거

    // ❌ 이전 방식 (제거) export async function generateStaticParams() { const tags = await getAllTags() return tags.map(tag => ({ tag })) } // ✅ 새로운 방식 (동적 렌더링) export default async function TagPage({ params }: PageProps) { const decodedTag = decodeURIComponent(params.tag) // 런타임에 데이터 가져오기 }
  2. 동적 렌더링 설정

    export const dynamic = 'force-dynamic' export const revalidate = 0
    • force-dynamic: 매 요청마다 새로 렌더링
    • revalidate = 0: 캐시 재검증 시간 0초
  3. 런타임에 데이터 가져오기

    export default async function TagPage({ params }: PageProps) { const decodedTag = decodeURIComponent(params.tag) const project = await getProjectByTag(decodedTag) const posts = await getPostsByTag(decodedTag) // ... }
    • 요청 시점에 Firebase에서 최신 데이터 조회

결과:

  • 새로운 블로그 글이 즉시 반영됨 (재배포 불필요)
  • 동적 라우트가 런타임에 처리됨
  • 404 에러 해결

7.3 Firebase 권한 에러

문제:

  • Firestore나 Storage 접근 시 권한 에러 발생
  • 에러 발생 시 앱이 크래시됨

원인:

  • 보안 규칙이 올바르게 설정되지 않음
  • 환경 변수가 설정되지 않음
  • 에러 처리가 없어 앱이 크래시됨
  • 등 .. 이유 알기 쉽지 않음..

해결:

  1. 보안 규칙 설정

    // Firestore 보안 규칙 rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /posts/{postId} { // 공개된 포스트는 모든 사용자가 읽기 가능 allow read: if resource.data.published == true; // 인증된 사용자는 모든 포스트 읽기 가능 (어드민용) allow read: if request.auth != null; // 인증된 사용자만 쓰기 가능 allow write: if request.auth != null; } } }
  2. 에러 처리 추가

    export const getAllPosts = async (): Promise<BlogPost[]> => { try { const q = query( collection(db, POSTS_COLLECTION), orderBy('createdAt', 'desc') ) const snapshot = await getDocs(q) return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data(), createdAt: doc.data().createdAt.toDate(), updatedAt: doc.data().updatedAt.toDate(), })) as BlogPost[] } catch (error) { console.error('포스트 가져오기 실패:', error) // 권한 에러 등으로 실패하면 빈 배열 반환 return [] } }
    • try-catch로 에러 처리
    • 실패 시 빈 배열 반환으로 앱 크래시 방지
  3. 환경 변수 확인

    // lib/firebase.ts const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, // ... }
    • Vercel 프로젝트 설정에서 환경 변수 확인
    • 로컬 .env.local 파일 확인

결과:

  • 권한 에러가 발생해도 앱이 크래시하지 않음
  • 보안 규칙으로 접근 제어
  • 에러 로그로 문제 파악 가능

8. 마무리

블로그 시스템 구현을 완료했습니다:

  1. 프로젝트 목록 페이지: 태그별 프로젝트 카드 표시
  2. 프로젝트별 글 목록: 마크다운 프로젝트 설명과 글 목록
  3. 글 상세 페이지: 마크다운 렌더링, 코드 하이라이팅, 이전/다음 글 네비게이션
  4. 한글 URL 지원: encodeURIComponent/decodeURIComponent로 처리
  5. 동적 렌더링: 새로운 글이 즉시 반영
  6. 에러 처리: 권한 에러 시 빈 배열 반환으로 크래시 방지

이제 관리자 페이지에서, 글을 쓰고 수정하고 삭제할 수 있는 기능을 구현해야 합니다.