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> </> ) }
코드 설명:
-
동적 렌더링 설정
export const dynamic = 'force-dynamic' export const revalidate = 0force-dynamic: 매 요청마다 새로 렌더링revalidate = 0: 캐시 재검증 시간 0초- 업데이트 되는 내용들이 바로바로 반영되게, force-dynamic을 사용하였습니다.
-
데이터 가져오기
const tags = await getAllTags() const projects = await getAllProjects()- 서버 컴포넌트에서 Firebase 데이터를 직접 가져옴
getAllTags(): 포스트와 프로젝트에서 태그 추출getAllProjects(): 프로젝트 정보 가져오기
-
태그와 프로젝트 매핑
const projectMap = new Map(projects.map(p => [p.tag, p])) const projectTags = tags.map(tag => ({ tag, project: projectMap.get(tag) || null, }))Map으로 태그 → 프로젝트 매핑- 프로젝트 정보가 있으면 표시, 없으면 null
-
한글 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"
-
프로젝트 설명 첫 줄만 표시
const firstLine = project.description.split(/\r?\n/)[0] || '' return firstLine.length > 100 ? `${firstLine.substring(0, 100)}...` : firstLinesplit(/\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> </> ) }
코드 설명:
-
URL 디코딩
const decodedTag = decodeURIComponent(params.tag)encodeURIComponent()로 인코딩된 태그를 디코딩- 예: "%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4" → "포트폴리오"
-
프로젝트 설명 마크다운 렌더링
<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"로 보안 강화
-
데이터 없을 때 처리
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> </> ) }
코드 설명:
-
Slug로 포스트 가져오기
const decodedSlug = decodeURIComponent(params.slug) const post = await getPostBySlug(decodedSlug)- URL에서 slug 디코딩 후 Firestore에서 조회
-
이전/다음 포스트 찾기
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- 같은 태그의 포스트 목록에서 현재 인덱스 찾기
- 이전/다음 포스트 계산
-
날짜 표시
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로 변환
- 서버/클라이언트 환경 모두 고려
-
코드 하이라이팅
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$/, '')로 마지막 줄바꿈 제거
-
타입 안전성
} 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가 다를 수 있음
해결:
-
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 } -
URL 생성 시 인코딩
// 링크 생성 시 <Link href={`/blog/${encodeURIComponent(tag)}/${encodeURIComponent(slug)}`}>encodeURIComponent()로 한글을 URL-safe하게 변환
-
페이지에서 디코딩
// 페이지 컴포넌트에서 const decodedTag = decodeURIComponent(params.tag) const decodedSlug = decodeURIComponent(params.slug)decodeURIComponent()로 원본 문자열 복원
-
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 접근 실패 시 빈 배열 반환으로 경로가 생성되지 않음
- 새로운 블로그 글이 추가되어도 경로가 생성되지 않음
해결:
-
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) // 런타임에 데이터 가져오기 } -
동적 렌더링 설정
export const dynamic = 'force-dynamic' export const revalidate = 0force-dynamic: 매 요청마다 새로 렌더링revalidate = 0: 캐시 재검증 시간 0초
-
런타임에 데이터 가져오기
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 접근 시 권한 에러 발생
- 에러 발생 시 앱이 크래시됨
원인:
- 보안 규칙이 올바르게 설정되지 않음
- 환경 변수가 설정되지 않음
- 에러 처리가 없어 앱이 크래시됨
- 등 .. 이유 알기 쉽지 않음..
해결:
-
보안 규칙 설정
// 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; } } } -
에러 처리 추가
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로 에러 처리- 실패 시 빈 배열 반환으로 앱 크래시 방지
-
환경 변수 확인
// 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. 마무리
블로그 시스템 구현을 완료했습니다:
- 프로젝트 목록 페이지: 태그별 프로젝트 카드 표시
- 프로젝트별 글 목록: 마크다운 프로젝트 설명과 글 목록
- 글 상세 페이지: 마크다운 렌더링, 코드 하이라이팅, 이전/다음 글 네비게이션
- 한글 URL 지원:
encodeURIComponent/decodeURIComponent로 처리 - 동적 렌더링: 새로운 글이 즉시 반영
- 에러 처리: 권한 에러 시 빈 배열 반환으로 크래시 방지
이제 관리자 페이지에서, 글을 쓰고 수정하고 삭제할 수 있는 기능을 구현해야 합니다.