개인 포트폴리오 웹사이트와 프로젝트별 트러블슈팅 블로그를 통합한 Next.js 기반 웹 애플리케이션입니다.
- 프로젝트 개요
- 주요 변경 사항
- 기술 스택
- 주요 기능
- 프로젝트 구조
- 설치 및 실행
- 환경 변수 설정
- Firebase 설정
- 배포 방법
- 주요 기능 상세 설명
- 코드 구조 설명
- API 및 데이터 구조
- 보안 규칙
- 트러블슈팅
이 프로젝트는 프론트엔드 개발자 Dowan Kim의 개인 포트폴리오 웹사이트와 프로젝트별 트러블슈팅 블로그를 통합한 웹 애플리케이션입니다.
- 포트폴리오 섹션: 개인 소개, 경력, 프로젝트 포트폴리오를 한눈에 볼 수 있는 랜딩 페이지
- 프로젝트별 블로그: 각 프로젝트(태그)별로 트러블슈팅 로그를 작성하고 관리할 수 있는 블로그 시스템
- 관리자 기능: Firebase Authentication을 통한 인증된 관리자만 글 작성/수정/삭제 가능
- 마크다운 지원: 블로그 글 작성 시 마크다운 에디터와 이미지 업로드 기능 제공
- 동적 렌더링: Next.js 서버 사이드 렌더링을 통해 실시간으로 Firebase 데이터를 가져와 표시
- Vercel 배포: Vercel 플랫폼을 통해 자동 배포 및 서버 사이드 렌더링 지원
- 이전: GitHub Pages (정적 사이트)
- 현재: Vercel (서버 사이드 렌더링)
- 이전: 정적 사이트 생성 (SSG) - 빌드 시점에 모든 페이지 생성
- 현재: 서버 사이드 렌더링 (SSR) - 요청 시점에 동적으로 렌더링
next.config.js에서output: 'export'제거- 모든 동적 라우트에서
generateStaticParams()제거 - 모든 페이지가 런타임에 동적으로 렌더링됨
- 마크다운 링크 새 창 열기: 프로젝트 설명과 블로그 글 내의 모든 링크가 새 창에서 열리도록 설정
- 프로젝트 설명 첫 줄 표시: 프로젝트 목록에서 설명의 첫 번째 줄만 표시
- 동적 라우트 즉시 반영: 새로운 블로그 글이 재배포 없이 즉시 반영됨
- ✅ 빌드 타임에 모든 경로를 생성할 필요 없음
- ✅ 새로운 콘텐츠가 즉시 반영됨
- ✅ 동적 라우트가 런타임에 자동으로 처리됨
- ✅ Firebase 데이터를 실시간으로 가져옴
- ✅ 자동 스케일링 지원 (Vercel)
- Next.js 14.2.0 - React 기반 프레임워크 (App Router 사용)
- React 18.3.0 - UI 라이브러리
- TypeScript 5.3.3 - 타입 안정성
- Tailwind CSS 3.4.1 - 유틸리티 기반 CSS 프레임워크
- CSS Modules - 컴포넌트 스코프 스타일링
- Firebase Firestore - NoSQL 데이터베이스 (블로그 포스트 및 프로젝트 정보 저장)
- Firebase Storage - 이미지 파일 저장
- Firebase Authentication - 사용자 인증
- react-markdown - 마크다운 렌더링
- @uiw/react-md-editor - 마크다운 에디터
- remark-gfm - GitHub Flavored Markdown 지원
- rehype-raw - HTML 태그 렌더링 지원
- react-syntax-highlighter - 코드 하이라이팅
- Vercel - 서버 사이드 렌더링 지원 플랫폼
- 자동 배포 - Git 푸시 시 자동으로 빌드 및 배포
- Home: 프로필 이미지, 소개, 소셜 미디어 링크
- About: 자기소개, 전공 분야, 직업 경험
- Career: 경력, 학력, 수상 내역, 자격증
- Work: 프로젝트 포트폴리오 (카테고리별 필터링)
- Contact: 연락처 및 소셜 미디어 링크
- 프로젝트 목록: 태그별로 그룹화된 프로젝트 목록 표시
- 프로젝트 페이지: 각 프로젝트의 소개글과 해당 프로젝트의 블로그 글 목록
- 블로그 글 상세: 마크다운으로 작성된 블로그 글 상세 보기
- 한글 URL 지원: 한글 제목을 URL-safe하게 변환하여 사용
- 마크다운 링크 새 창 열기: 프로젝트 설명과 블로그 글 내의 모든 링크가 새 창에서 열림
- 로그인/로그아웃: Firebase Authentication을 통한 인증
- 글 작성: 마크다운 에디터와 이미지 업로드 기능
- 글 수정/삭제: 기존 글 수정 및 삭제
- 프로젝트 관리: 각 프로젝트(태그)별 소개글 작성 및 수정
- 클립보드 이미지 붙여넣기: 스크린샷 복사 후 에디터에 붙여넣기로 자동 업로드
dowankim1024.github.io/
├── app/ # Next.js App Router
│ ├── admin/ # 관리자 페이지
│ │ ├── blog/
│ │ │ ├── edit/[id]/ # 글 수정 페이지
│ │ │ ├── projects/ # 프로젝트 관리 페이지
│ │ │ ├── write/ # 글 작성 페이지
│ │ │ └── page.tsx # 블로그 관리 대시보드
│ │ └── login/ # 로그인 페이지
│ ├── blog/ # 블로그 페이지
│ │ ├── [tag]/ # 프로젝트별 페이지
│ │ │ ├── [slug]/ # 개별 글 상세 페이지
│ │ │ └── page.tsx # 프로젝트 페이지
│ │ └── page.tsx # 프로젝트 목록 페이지
│ ├── globals.css # 전역 스타일
│ ├── layout.tsx # 루트 레이아웃
│ └── page.tsx # 메인 포트폴리오 페이지
├── components/ # React 컴포넌트
│ ├── AuthGuard/ # 인증 가드 컴포넌트
│ └── Home/ # 포트폴리오 섹션 컴포넌트
│ ├── About/ # About 섹션
│ ├── ArrowUp/ # 스크롤 업 버튼
│ ├── Career/ # Career 섹션
│ ├── Contact/ # Contact 섹션
│ ├── Header/ # 헤더 네비게이션
│ ├── Home/ # Home 섹션
│ └── Work/ # Work 섹션
├── lib/ # 유틸리티 및 API 함수
│ ├── auth.ts # Firebase Authentication 유틸리티
│ ├── blog.ts # 블로그 관련 Firestore 함수
│ └── firebase.ts # Firebase 초기화
├── types/ # TypeScript 타입 정의
│ └── blog.ts # 블로그 관련 타입
├── public/ # 정적 파일
│ └── images/ # 이미지 파일
├── .github/workflows/ # GitHub Actions 워크플로우
│ └── deploy.yml # 배포 자동화 스크립트 (현재 미사용)
├── next.config.js # Next.js 설정
├── vercel.json # Vercel 배포 설정
├── tailwind.config.js # Tailwind CSS 설정
├── tsconfig.json # TypeScript 설정
└── package.json # 프로젝트 의존성
- Node.js 20.x 이상
- npm 또는 yarn
- Firebase 프로젝트 (Firestore, Storage, Authentication 활성화)
# 저장소 클론
git clone https://github.com/dowankim1024/dowankim1024.github.io.git
# 프로젝트 디렉토리로 이동
cd dowankim1024.github.io
# 의존성 설치
npm install프로젝트 루트에 .env.local 파일을 생성하고 Firebase 설정 정보를 입력합니다:
NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_auth_domain
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_storage_bucket
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_idnpm run dev브라우저에서 http://localhost:3000을 열어 확인할 수 있습니다.
npm run build빌드는 .next/ 디렉토리에 생성되며, Vercel에 배포할 때 자동으로 서버에서 실행됩니다.
npm run build
npm run start로컬에서 프로덕션 빌드를 테스트할 수 있습니다.
- Firebase Console에 접속
- 새 프로젝트 생성
- 웹 앱 추가 (Firebase SDK 설정 정보 복사)
- Firestore Database 생성
- 보안 규칙 설정 (아래 보안 규칙 섹션 참조)
- 인덱스 생성:
posts컬렉션:published(오름차순),createdAt(내림차순) 복합 인덱스
- Storage 활성화
- 보안 규칙 설정 (아래 보안 규칙 섹션 참조)
- Authentication 활성화
- 이메일/비밀번호 로그인 방법 활성화
- 관리자 계정 생성
이 프로젝트는 Vercel을 통해 배포됩니다. Vercel은 Next.js의 서버 사이드 렌더링을 완벽하게 지원합니다.
- Vercel에 로그인
- "Add New Project" 클릭
- GitHub 저장소 연결
- 프로젝트 설정:
- Framework Preset: Next.js
- Root Directory:
./(루트 디렉토리) - Build Command:
npm run build(기본값) - Output Directory:
.next(자동 감지)
Vercel 프로젝트 설정에서 다음 환경 변수를 추가합니다:
NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_auth_domain
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_storage_bucket
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id
설정 위치: Settings → Environment Variables
main 브랜치에 푸시하면 자동으로 빌드 및 배포가 진행됩니다.
- 모든 푸시마다 프리뷰 배포 생성
main브랜치 푸시 시 프로덕션 배포
프로젝트 루트의 vercel.json 파일로 배포 설정을 관리합니다:
{
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"framework": "nextjs",
"regions": ["icn1"]
}- 렌더링 방식: 정적 사이트 생성(SSG)에서 서버 사이드 렌더링(SSR)으로 전환
- 동적 라우트: 빌드 타임이 아닌 런타임에 동적으로 렌더링
- Firebase 통합: 런타임에 Firebase에서 실시간으로 데이터 가져오기
- 자동 스케일링: 트래픽에 따라 자동으로 서버 리소스 확장
메인 랜딩 섹션으로, 프로필 이미지와 소개 텍스트를 표시합니다.
주요 기능:
- 스크롤 시 투명도 조절 애니메이션
- 소셜 미디어 링크 (GitHub, Blog, Instagram)
- Contact 섹션으로 스크롤 이동
코드 구조:
'use client'
import { useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import styles from './Home.module.css'
import { socialLinks } from './Home.constants'
export default function Home() {
const containerRef = useRef<HTMLDivElement>(null)
const [opacity, setOpacity] = useState(1)
// 스크롤 이벤트 리스너로 투명도 조절
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleScroll = () => {
const homeHeight = container.offsetHeight
setOpacity(1 - window.scrollY / homeHeight)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Contact 섹션으로 스크롤 이동
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
const element = document.getElementById('contact')
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}
return (
<section id="home" className={styles.section}>
<div className={styles.container} ref={containerRef} style={{ opacity }}>
{/* 프로필 이미지 */}
<Image
className={styles.avatar}
src="/images/projects/prof.jpeg"
alt="Dowan Kim's profile"
width={400}
height={500}
style={{ width: 'auto', height: 'auto' }}
/>
{/* 소개 텍스트 및 링크 */}
<div className={styles.content}>
{/* ... */}
</div>
</div>
</section>
)
}경력, 학력, 수상 내역, 자격증을 카드 형태로 표시합니다.
데이터 구조:
// Career.constants.ts
export interface Experience {
company: string
role: string
period: string
}
export interface Education {
school: string
major: string | string[] // 단일 또는 다중 전공
period: string
}
export interface Award {
year: string
title: string
award: string
}
export interface Certification {
name: string
description: string
period: string
}모든 프로젝트(태그)를 카드 형태로 표시합니다.
주요 로직:
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 (
<section className={styles.section}>
{/* 프로젝트 카드 렌더링 */}
{projectTags.map(({ tag, project }) => (
<Link href={`/blog/${encodeURIComponent(tag)}`}>
{/* ... */}
</Link>
))}
</section>
)
}특정 프로젝트의 소개글과 해당 프로젝트의 블로그 글 목록을 표시합니다.
주요 기능:
- 프로젝트 소개글 (마크다운 지원)
- 해당 태그의 블로그 글 목록
- 동적 렌더링: 요청 시점에 Firebase에서 데이터를 가져와 렌더링
- 마크다운 링크가 새 창에서 열림
코드 구조:
interface PageProps {
params: {
tag: string
}
}
export default async function TagPage({ params }: PageProps) {
const decodedTag = decodeURIComponent(params.tag)
const project = await getProjectByTag(decodedTag)
const posts = await getPostsByTag(decodedTag)
if (posts.length === 0) {
notFound()
}
return (
<section className={styles.section}>
<Header />
<div className={styles.container}>
<h1 className={styles.title}>{decodedTag}</h1>
{/* 프로젝트 소개글 (링크는 새 창에서 열림) */}
{project && project.description && (
<div className={styles.description}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}}
>
{project.description}
</ReactMarkdown>
</div>
)}
{/* 블로그 글 목록 */}
<div className={styles.posts}>
{posts.map((post) => (
<Link
key={post.id}
href={`/blog/${encodeURIComponent(decodedTag)}/${encodeURIComponent(post.slug || post.id || '')}`}
className={styles.postCard}
>
<h2 className={styles.postTitle}>{post.title}</h2>
{/* ... */}
</Link>
))}
</div>
</div>
</section>
)
}개별 블로그 글을 마크다운으로 렌더링합니다.
주요 기능:
- 마크다운 렌더링 (GitHub Flavored Markdown 지원)
- 코드 하이라이팅
- HTML 태그 지원
- 마크다운 내 링크가 새 창에서 열림
코드 구조:
<div className={styles.content}>
<ReactMarkdown
rehypePlugins={[rehypeRaw]}
remarkPlugins={[remarkGfm]}
components={{
a: ({ ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}}
>
{post.content}
</ReactMarkdown>
</div>주요 기능:
- 마크다운 에디터 (
@uiw/react-md-editor) - 이미지 업로드 (Firebase Storage)
- 클립보드 이미지 붙여넣기
- 태그 입력 (쉼표로 구분)
- 공개/비공개 설정
클립보드 이미지 붙여넣기 구현:
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.indexOf('image') !== -1) {
e.preventDefault()
const file = item.getAsFile()
if (!file) continue
setUploadingImage(true)
try {
const user = await getCurrentUser()
if (!user) {
alert('로그인이 필요합니다.')
return
}
// 이미지 업로드
const tempId = `temp_${Date.now()}`
const imageUrl = await uploadImage(file, tempId)
// 마크다운 형식으로 삽입 (커서 위치에)
const imageMarkdown = `\n\n`
// MDEditor 내부의 textarea에서 커서 위치 가져오기
let cursorPos = content.length
if (editorRef.current) {
const textarea = editorRef.current.querySelector('textarea') as HTMLTextAreaElement
if (textarea) {
cursorPos = textarea.selectionStart || content.length
}
}
const newContent = content.slice(0, cursorPos) + imageMarkdown + content.slice(cursorPos)
setContent(newContent)
// 커서 위치 업데이트
setTimeout(() => {
if (editorRef.current) {
const textarea = editorRef.current.querySelector('textarea') as HTMLTextAreaElement
if (textarea) {
const newCursorPos = cursorPos + imageMarkdown.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}
}
}, 0)
} catch (err) {
console.error('이미지 업로드 실패:', err)
alert('이미지 업로드에 실패했습니다.')
} finally {
setUploadingImage(false)
}
}
}
}
const editorElement = editorRef.current
if (editorElement) {
editorElement.addEventListener('paste', handlePaste)
return () => {
editorElement.removeEventListener('paste', handlePaste)
}
}
}, [content, images])Slug 생성 로직:
const generateSlug = (text: string): string => {
// 한글을 포함한 모든 문자를 URL-safe하게 변환
let slug = text
.trim()
.replace(/\s+/g, '-') // 공백을 하이픈으로
.replace(/-+/g, '-') // 연속된 하이픈을 하나로
.replace(/^-|-$/g, '') // 앞뒤 하이픈 제거
// 빈 문자열이면 타임스탬프 사용
if (!slug) {
slug = `post-${Date.now()}`
}
// 한글은 그대로 유지 (Next.js가 자동으로 URL 인코딩 처리)
return slug
}각 프로젝트(태그)별 소개글을 작성하고 수정할 수 있습니다.
주요 기능:
- 프로젝트 목록 표시
- 소개글 추가/수정
- 마크다운 지원
각 컴포넌트는 다음과 같은 구조로 구성됩니다:
ComponentName/
├── ComponentName.tsx # 메인 컴포넌트
├── ComponentName.module.css # 스타일 (CSS Modules)
├── ComponentName.constants.ts # 상수 데이터
└── index.ts # Export 파일
예시: Home 컴포넌트
// components/Home/Home/Home.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import styles from './Home.module.css'
import { socialLinks } from './Home.constants'
export default function Home() {
// 컴포넌트 로직
}
// components/Home/Home/Home.constants.ts
export const socialLinks = [
{
href: 'https://github.com/dowankim1024',
iconClass: 'fa-brands fa-github',
title: 'my github link',
},
// ...
]
// components/Home/Home/index.ts
export { default } from './Home'import { initializeApp, getApps, FirebaseApp } from 'firebase/app'
import { getAuth, Auth } from 'firebase/auth'
import { getFirestore, Firestore } from 'firebase/firestore'
import { getStorage, FirebaseStorage } from 'firebase/storage'
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}
// Firebase 초기화 (이미 초기화되어 있으면 재초기화 방지)
let app: FirebaseApp
if (getApps().length === 0) {
app = initializeApp(firebaseConfig)
} else {
app = getApps()[0]
}
export const auth: Auth = getAuth(app)
export const db: Firestore = getFirestore(app)
export const storage: FirebaseStorage = getStorage(app)주요 함수:
getPublishedPosts(): 공개된 포스트만 가져오기getAllPosts(): 모든 포스트 가져오기 (관리자용)getPostBySlug(): Slug로 포스트 가져오기getPostsByTag(): 특정 태그의 포스트 가져오기createPost(): 포스트 생성updatePost(): 포스트 업데이트deletePost(): 포스트 삭제uploadImage(): 이미지 업로드getAllProjects(): 모든 프로젝트 가져오기getProjectByTag(): 특정 태그의 프로젝트 가져오기upsertProject(): 프로젝트 생성 또는 업데이트getAllTags(): 모든 태그 목록 가져오기
예시: 포스트 생성 함수
export const createPost = async (
postData: Omit<BlogPost, 'id' | 'createdAt' | 'updatedAt'>
): Promise<string> => {
const now = Timestamp.now()
const docRef = await addDoc(collection(db, POSTS_COLLECTION), {
...postData,
createdAt: now,
updatedAt: now,
})
return docRef.id
}import { signInWithEmailAndPassword, signOut, onAuthStateChanged, User } from 'firebase/auth'
import { auth } from './firebase'
// 로그인
export const login = async (email: string, password: string): Promise<User> => {
const userCredential = await signInWithEmailAndPassword(auth, email, password)
return userCredential.user
}
// 로그아웃
export const logout = async (): Promise<void> => {
await signOut(auth)
}
// 현재 사용자 가져오기
export const getCurrentUser = (): Promise<User | null> => {
return new Promise((resolve) => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
unsubscribe()
resolve(user)
})
})
}import { Timestamp } from 'firebase/firestore'
export interface BlogPost {
id?: string
title: string
content: string // 마크다운 텍스트
images: string[] // Storage URL 배열
createdAt: Timestamp | Date
updatedAt: Timestamp | Date
author: string // 사용자 UID
tags?: string[]
published: boolean
slug?: string // URL 친화적인 제목
}
export interface Project {
id?: string
tag: string // 태그 이름 (프로젝트 이름)
description: string // 프로젝트 소개글
createdAt: Timestamp | Date
updatedAt: Timestamp | Date
}rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// posts 컬렉션 규칙
match /posts/{postId} {
// 공개된 포스트는 모든 사용자가 읽기 가능
allow read: if resource.data.published == true;
// 인증된 사용자는 모든 포스트 읽기 가능 (어드민용)
allow read: if request.auth != null;
// 인증된 사용자만 쓰기 가능
allow write: if request.auth != null;
}
// projects 컬렉션 규칙
match /projects/{projectId} {
// 모든 사용자가 읽기 가능
allow read: if true;
// 인증된 사용자만 쓰기 가능
allow write: if request.auth != null;
}
}
}rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
// blog-images 경로의 이미지
match /blog-images/{allPaths=**} {
// 모든 사용자가 읽기 가능
allow read: if true;
// 인증된 사용자만 쓰기 가능
allow write: if request.auth != null;
}
}
}변경 사항: 프로젝트가 GitHub Pages 정적 배포에서 Vercel 서버 사이드 렌더링으로 전환되었습니다.
주요 변경:
next.config.js에서output: 'export'제거- 모든 동적 라우트에서
generateStaticParams()제거 - 모든 페이지가 런타임에 동적으로 렌더링됨
- Firebase 데이터를 빌드 타임이 아닌 요청 시점에 가져옴
장점:
- 새로운 블로그 글이 즉시 반영됨 (재배포 불필요)
- 빌드 타임에 모든 경로를 생성할 필요 없음
- 동적 라우트가 런타임에 자동으로 처리됨
주의사항:
- Vercel 환경 변수 설정 필수
- Firebase 환경 변수가 Vercel 프로젝트 설정에 추가되어 있어야 함
문제: 한글 제목을 slug로 변환할 때 URL 인코딩이 제대로 되지 않습니다.
해결: generateStaticParams()에서 encodeURIComponent()를 사용하고, getPostBySlug()에서 decodeURIComponent()를 사용합니다.
// Slug 생성
const generateSlug = (text: string): string => {
let slug = text
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return slug || `post-${Date.now()}`
}
// Slug로 포스트 가져오기
export const getPostBySlug = async (slug: string): Promise<BlogPost | null> => {
const decodedSlug = decodeURIComponent(slug)
// ...
}문제: Vercel에 배포 후 동적 라우트(/blog/[tag], /blog/[tag]/[slug])에 접근 시 404 에러 발생
원인:
generateStaticParams()를 사용하면 빌드 시점에 생성된 경로만 접근 가능- 빌드 시점에 Firebase 접근 실패 시 빈 배열 반환으로 인해 경로가 생성되지 않음
해결:
generateStaticParams()를 완전히 제거하여 동적 렌더링으로 전환- 모든 동적 라우트가 런타임에 동적으로 처리되도록 설정
- 요청 시점에 Firebase에서 데이터를 가져와 렌더링
// ✅ 올바른 방법: generateStaticParams() 없이 동적 렌더링
export default async function TagPage({ params }: PageProps) {
const decodedTag = decodeURIComponent(params.tag)
const project = await getProjectByTag(decodedTag)
const posts = await getPostsByTag(decodedTag)
// 런타임에 데이터 가져오기
// ...
}문제: Firestore나 Storage에 접근할 때 권한 에러가 발생합니다.
해결:
- Firebase 콘솔에서 보안 규칙을 올바르게 설정
- 에러 처리를 추가하여 앱이 크래시하지 않도록 함
- Vercel 환경 변수가 올바르게 설정되어 있는지 확인
export const getAllPosts = async (): Promise<BlogPost[]> => {
try {
// ...
} catch (error) {
console.error('포스트 가져오기 실패:', error)
return []
}
}구현: ReactMarkdown의 components prop을 사용하여 링크 컴포넌트를 커스터마이징
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}}
>
{content}
</ReactMarkdown>적용 위치:
- 프로젝트 설명 (
app/blog/[tag]/page.tsx) - 블로그 글 내용 (
app/blog/[tag]/[slug]/page.tsx)
구현: 프로젝트 목록 페이지에서 프로젝트 설명의 첫 번째 줄(엔터 이전)만 표시
{project && project.description && (
<p className={styles.projectDescription}>
{(() => {
const firstLine = project.description.split(/\r?\n/)[0] || ''
return firstLine.length > 50
? `${firstLine.substring(0, 50)}...`
: firstLine
})()}
</p>
)}문제: 블로그 글에서 코드 블록을 작성했을 때, 코드가 모두 흰색으로 표시되어 타입별 색상 구분이 되지 않았습니다. 줄바꿈은 정상적으로 작동했지만, 코드 하이라이팅이 전혀 적용되지 않았습니다.
원인:
ReactMarkdown을 사용하여 마크다운을 렌더링하고 있었지만, 코드 블록에 대한 하이라이팅 처리가 없었습니다.react-syntax-highlighter패키지는 이미 설치되어 있었지만,ReactMarkdown의componentsprop에서 코드 블록을 커스터마이징하지 않아 기본 스타일만 적용되고 있었습니다.
해결 방법:
react-syntax-highlighter의Prism컴포넌트를 import하여 사용ReactMarkdown의componentsprop에서code컴포넌트를 커스터마이징- 코드 블록의
className에서 언어 정보를 추출하여SyntaxHighlighter에 전달 - 인라인 코드와 코드 블록을 구분하여 처리
사용 기술:
react-syntax-highlighter: 코드 하이라이팅 라이브러리Prism: Prism 기반 하이라이팅 엔진vscDarkPlus: VS Code Dark Plus 테마 (다크 테마에 적합)
구현 코드:
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
// ReactMarkdown components prop에서
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>
)
}사용 방법: 마크다운에서 코드 블록을 작성할 때 언어를 지정하면 해당 언어에 맞는 하이라이팅이 자동으로 적용됩니다:
```javascript
const hello = "world";
console.log(hello);
```
```python
def hello():
print("world")
```참고: 인라인 코드(백틱 하나)는 기존 스타일이 유지되고, 코드 블록(백틱 3개)만 하이라이팅이 적용됩니다.
문제: 코드 하이라이팅을 구현하면서 any 타입을 사용하여 타입 안전성을 해치고 있었습니다.
원인:
ReactMarkdown의componentsprop 타입이 복잡하여 정확한 타입을 지정하기 어려웠습니다.react-syntax-highlighter의styleprop 타입이 복잡하여 타입 캐스팅이 필요했습니다.
해결 방법:
react-markdown에서Components타입을 import하여 사용- TypeScript의 타입 추론을 활용하여 명시적 타입 지정 최소화
satisfies Components를 사용하여 타입 안전성 확보- 불필요한 props 전달을 제거하여 타입 충돌 방지
사용 기술:
- TypeScript
satisfies연산자: 타입 체크와 타입 추론을 동시에 활용 Components타입:react-markdown에서 제공하는 컴포넌트 타입 정의
구현 코드:
import ReactMarkdown, { Components } from 'react-markdown'
// any 타입 제거 전
code({ className, children, ...props }: any) {
// ...
}
// any 타입 제거 후
code: ({ className, children, ...props }) => {
// TypeScript가 자동으로 타입 추론
// ...
},
} satisfies Components // 타입 안전성 보장장점:
- 타입 안전성 향상: 컴파일 타임에 타입 오류를 잡을 수 있음
- 코드 가독성 향상: 명시적인 타입 정의로 코드 의도가 명확해짐
- 유지보수성 향상: 타입 변경 시 자동으로 오류 감지
문제: Firebase 앱이 여러 번 초기화되려고 시도하면 에러가 발생할 수 있습니다. 특히 개발 환경에서 Hot Module Replacement(HMR)가 발생할 때 문제가 생길 수 있습니다.
원인:
- Firebase는 한 번만 초기화되어야 하는데, 모듈이 여러 번 로드되면 재초기화를 시도합니다.
initializeApp()을 여러 번 호출하면 "Firebase: Firebase App named '[DEFAULT]' already exists" 에러가 발생합니다.
해결 방법:
getApps()를 사용하여 이미 초기화된 앱이 있는지 확인- 앱이 이미 초기화되어 있으면 기존 앱을 재사용
사용 기술:
- Firebase
getApps(): 초기화된 앱 목록 가져오기 - 조건부 초기화: 앱이 없을 때만 초기화
구현 코드:
import { initializeApp, getApps, FirebaseApp } from 'firebase/app'
import { getAuth, Auth } from 'firebase/auth'
import { getFirestore, Firestore } from 'firebase/firestore'
import { getStorage, FirebaseStorage } from 'firebase/storage'
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}
// Firebase 초기화 (이미 초기화되어 있으면 재초기화 방지)
let app: FirebaseApp
if (getApps().length === 0) {
app = initializeApp(firebaseConfig)
} else {
app = getApps()[0]
}
export const auth: Auth = getAuth(app)
export const db: Firestore = getFirestore(app)
export const storage: FirebaseStorage = getStorage(app)장점:
- 개발 환경에서 HMR 시에도 안정적으로 작동
- 프로덕션 환경에서도 중복 초기화 방지
- 에러 없이 Firebase 서비스를 사용 가능
문제: 새로운 블로그 글을 작성했는데 로컬 개발 서버(npm run dev)에서는 보이지만 배포된 사이트에서는 보이지 않습니다.
원인:
- Next.js 14 (App Router)는 기본적으로 페이지를 캐싱합니다
- 배포 환경에서는 빌드 시점의 데이터가 캐시되어 표시될 수 있습니다
- 로컬 개발 서버는 개발 모드로 실행되어 캐싱이 덜 적용되지만, 프로덕션 빌드는 성능 최적화를 위해 캐싱을 적극 활용합니다
- Firebase에서 데이터를 가져오더라도, Next.js가 페이지 컴포넌트 자체를 캐시할 수 있습니다
해결 방법: 블로그 관련 페이지에 동적 렌더링 설정을 추가하여 항상 최신 데이터를 가져오도록 설정합니다.
적용 위치:
/app/blog/page.tsx- 블로그 목록 페이지/app/blog/[tag]/page.tsx- 태그별 페이지/app/blog/[tag]/[slug]/page.tsx- 개별 포스트 페이지
구현 코드:
// 각 페이지 컴포넌트 상단에 추가
export const dynamic = 'force-dynamic' // 항상 동적 렌더링
export const revalidate = 0 // 캐시 재검증 시간 0초 (즉시 무효화)
export default async function BlogPage() {
// Firebase에서 최신 데이터 가져오기
const tags = await getAllTags()
const projects = await getAllProjects()
// ...
}설명:
export const dynamic = 'force-dynamic': 페이지가 매 요청마다 새로 렌더링되도록 강제합니다export const revalidate = 0: 캐시 재검증 시간을 0초로 설정하여 항상 최신 데이터를 가져옵니다
장점:
- 새로운 블로그 글이 즉시 반영됩니다 (재배포 불필요)
- Firebase에서 항상 최신 데이터를 가져옵니다
- 로컬과 배포 환경의 동작이 일치합니다
참고:
- Next.js App Router는 기본적으로 캐싱을 사용하여 성능을 최적화합니다
- 동적 데이터가 자주 변경되는 경우(예: 블로그 글)에는
force-dynamic설정이 필요합니다 - 정적 콘텐츠가 많은 경우에는 기본 캐싱을 유지하는 것이 성능상 유리합니다
문제: Firebase Firestore에서 가져온 데이터의 createdAt과 updatedAt 필드가 Timestamp 타입인데, 이를 JavaScript Date 객체로 변환해야 하는 경우가 많습니다. 또한 클라이언트와 서버에서 타입이 다를 수 있습니다.
원인:
- Firestore는
Timestamp타입을 사용하지만, 클라이언트에서는Date객체를 사용합니다. - 서버 사이드 렌더링(SSR)과 클라이언트 사이드에서 타입이 다를 수 있습니다.
Timestamp는toDate()메서드를 사용하여Date로 변환해야 합니다.
해결 방법:
- Firestore에서 데이터를 가져올 때
Timestamp.toDate()를 사용하여 변환 - 클라이언트에서도 타입을 확인하여 안전하게 변환
- 여러 타입을 고려한 유연한 변환 로직 구현
사용 기술:
- TypeScript 타입 가드:
instanceof를 사용한 타입 체크 - 조건부 타입 변환: 타입에 따라 적절한 변환 수행
구현 코드:
// lib/blog.ts에서
export const getPostBySlug = async (slug: string): Promise<BlogPost | null> => {
// ...
return {
id: docSnap.id,
...docSnap.data(),
createdAt: docSnap.data().createdAt.toDate(), // Timestamp를 Date로 변환
updatedAt: docSnap.data().updatedAt.toDate(),
} as BlogPost
}
// 페이지에서 날짜 표시
{(() => {
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'
})
})()}장점:
- 타입 안전성: 다양한 타입을 안전하게 처리
- 유연성: 서버와 클라이언트에서 모두 작동
- 에러 방지: 타입 변환 실패 시 기본값 사용
문제: 블로그 글 작성 시 이미지를 업로드하려면 파일 선택 다이얼로그를 열어야 하는데, 스크린샷을 복사한 후 바로 붙여넣기로 업로드하고 싶었습니다.
원인:
- 기본 마크다운 에디터는 클립보드 이미지 붙여넣기를 지원하지 않습니다.
- 사용자 경험을 개선하기 위해 클립보드 이벤트를 직접 처리해야 합니다.
해결 방법:
paste이벤트 리스너를 추가하여 클립보드 데이터 확인- 클립보드에 이미지가 있으면
preventDefault()로 기본 동작 방지 - 이미지를
File객체로 변환하여 Firebase Storage에 업로드 - 업로드된 이미지 URL을 마크다운 형식으로 에디터에 삽입
- 커서 위치를 유지하여 자연스러운 사용자 경험 제공
사용 기술:
- Clipboard API:
ClipboardEvent를 통한 클립보드 데이터 접근 - Firebase Storage: 이미지 파일 업로드
- DOM 조작: 커서 위치 추적 및 설정
구현 코드:
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.indexOf('image') !== -1) {
e.preventDefault()
const file = item.getAsFile()
if (!file) continue
setUploadingImage(true)
try {
const user = await getCurrentUser()
if (!user) {
alert('로그인이 필요합니다.')
return
}
// 이미지 업로드
const tempId = `temp_${Date.now()}`
const imageUrl = await uploadImage(file, tempId)
// 마크다운 형식으로 삽입
const imageMarkdown = `\n\n`
// 커서 위치 가져오기
let cursorPos = content.length
if (editorRef.current) {
const textarea = editorRef.current.querySelector('textarea') as HTMLTextAreaElement
if (textarea) {
cursorPos = textarea.selectionStart || content.length
}
}
// 커서 위치에 이미지 삽입
const newContent = content.slice(0, cursorPos) + imageMarkdown + content.slice(cursorPos)
setContent(newContent)
// 커서 위치 업데이트
setTimeout(() => {
if (editorRef.current) {
const textarea = editorRef.current.querySelector('textarea') as HTMLTextAreaElement
if (textarea) {
const newCursorPos = cursorPos + imageMarkdown.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}
}
}, 0)
} catch (err) {
console.error('이미지 업로드 실패:', err)
alert('이미지 업로드에 실패했습니다.')
} finally {
setUploadingImage(false)
}
}
}
}
const editorElement = editorRef.current
if (editorElement) {
editorElement.addEventListener('paste', handlePaste)
return () => {
editorElement.removeEventListener('paste', handlePaste)
}
}
}, [content, images])장점:
- 사용자 경험 향상: 스크린샷 복사 후 바로 붙여넣기로 업로드 가능
- 작업 효율성 향상: 파일 선택 다이얼로그를 열 필요 없음
- 자연스러운 워크플로우: 일반적인 문서 편집기처럼 작동
문제: 블로그 글의 코드 블록에 다음과 같은 스타일 문제가 발생했습니다:
- 코드 블록 안쪽에 푸른색 보더라인이 표시됨
- 패딩 영역에 배경색(
#1e1e1e)이 적용되어 깔끔하지 않음 react-syntax-highlighter가 인라인 스타일로 배경색과 패딩을 적용하여 CSS로 제어가 어려움
원인:
react-syntax-highlighter의SyntaxHighlighter컴포넌트가 최상위div요소에 인라인 스타일로background: #1e1e1e,padding: 1em,margin: 0.5em 0등을 적용- 인라인 스타일은 CSS의
!important로도 완전히 오버라이드할 수 없음 - 라이브러리가 생성하는 내부 요소들(
div,code,span등)에도 배경색과 테두리가 적용됨
해결 방법:
- CSS에서 모든 내부 요소의 스타일 제거:
!important를 사용하여 라이브러리 기본 스타일 강제 제거 customStyleprop 사용:SyntaxHighlighter컴포넌트의customStyleprop을 사용하여 인라인 스타일 오버라이드- 모든 하위 요소 타겟팅:
*,::before,::after선택자를 사용하여 모든 요소와 가상 요소의 스타일 제거
사용 기술:
- CSS
!important: 우선순위가 높은 스타일 적용 customStyleprop:react-syntax-highlighter의 인라인 스타일 오버라이드- CSS 선택자:
*,::before,::after를 사용한 포괄적 스타일 제거
구현 코드:
1. TypeScript/React 코드 (page.tsx):
<SyntaxHighlighter
style={vscDarkPlus}
language={language}
PreTag="div"
customStyle={{
background: 'transparent',
padding: 0,
margin: 0,
border: 'none',
boxShadow: 'none',
}}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>2. CSS 코드 (page.module.css):
/* react-syntax-highlighter 스타일 오버라이드 */
.content pre[class*="language-"] {
margin-bottom: 1.5rem;
border-radius: 0;
border: none !important;
box-shadow: none !important;
background-color: transparent !important;
background: transparent !important;
padding: 0 !important;
}
/* react-syntax-highlighter 내부 요소 스타일 제거 */
.content pre[class*="language-"] > div {
box-shadow: none !important;
border: none !important;
outline: none !important;
background-color: transparent !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
}
/* react-syntax-highlighter 내부 모든 요소의 border 제거 */
.content pre[class*="language-"] *,
.content pre[class*="language-"] *::before,
.content pre[class*="language-"] *::after {
border: none !important;
border-width: 0 !important;
outline: none !important;
box-shadow: none !important;
background-color: transparent !important;
}
/* react-syntax-highlighter의 code 태그 내부 요소 */
.content pre[class*="language-"] code,
.content pre[class*="language-"] code *,
.content pre[class*="language-"] code *::before,
.content pre[class*="language-"] code *::after {
border: none !important;
border-width: 0 !important;
outline: none !important;
background-color: transparent !important;
}
/* react-syntax-highlighter의 span 요소들 */
.content pre[class*="language-"] span,
.content pre[class*="language-"] span * {
border: none !important;
border-width: 0 !important;
outline: none !important;
background-color: transparent !important;
}핵심 포인트:
-
인라인 스타일과 CSS 우선순위:
- CSS 우선순위 규칙: 인라인 스타일 >
!important(클래스) > 클래스 선택자 > 태그 선택자 react-syntax-highlighter는 최상위div에 인라인 스타일(style="background: #1e1e1e; padding: 1em;")을 적용합니다.- CSS 클래스에
!important를 사용해도 인라인 스타일보다 우선순위가 낮아 오버라이드할 수 없습니다.
- CSS 우선순위 규칙: 인라인 스타일 >
-
customStyleprop이 인라인 스타일을 오버라이드하는 원리:customStyleprop은 라이브러리가 인라인 스타일을 생성할 때 사용하는 값입니다.- 라이브러리 내부에서 다음과 같이 동작합니다:
// 라이브러리 내부 로직 (의사 코드) const defaultStyle = { background: '#1e1e1e', padding: '1em', margin: '0.5em 0', } const finalStyle = { ...defaultStyle, // 기본 스타일 ...customStyle, // 우리가 전달한 customStyle (나중에 적용되어 덮어씀) } // 최종적으로 인라인 스타일로 적용 <div style={finalStyle}>
customStyle의 값이 기본 스타일을 덮어쓰므로, 인라인 스타일이 생성될 때 우리가 원하는 값으로 적용됩니다.- 결과적으로 인라인 스타일이 우리가 원하는 값으로 생성되므로, CSS로 제어할 필요가 없습니다.
-
CSS
!important의 역할:customStyle은 최상위div에만 적용되므로, 내부의code,span등 하위 요소는 여전히 라이브러리 기본 스타일이 적용됩니다.- 하위 요소들의 스타일을 제거하려면 CSS의
!important를 사용해야 합니다. - 하위 요소에는 인라인 스타일이 없거나, 있더라도 CSS
!important로 오버라이드할 수 있습니다.
-
포괄적 선택자 사용:
*선택자를 사용하여 모든 하위 요소를 타겟팅하고,::before,::after가상 요소도 포함하여 완전한 스타일 제거를 보장합니다. -
배경색과 패딩 동시 제거:
background-color와background속성을 모두transparent로 설정하고,padding과margin을0으로 설정하여 불필요한 공간과 색상을 제거합니다.
결과:
- 코드 블록의 배경색이 완전히 제거되어 투명하게 표시됨
- 패딩과 마진 영역의 색상이 사라져 깔끔한 디자인 구현
- 불필요한 테두리와 그림자가 모두 제거됨
- 코드 하이라이팅 기능은 유지되면서 스타일만 제거됨
주의사항:
customStyleprop은SyntaxHighlighter컴포넌트에만 적용되므로, 다른 스타일링 라이브러리를 사용할 때는 해당 라이브러리의 API를 확인해야 합니다.!important를 과도하게 사용하면 유지보수가 어려워질 수 있으므로, 필요한 경우에만 사용하는 것이 좋습니다.
다른 라이브러리에서 인라인 스타일 오버라이드 방법:
일반적으로 라이브러리의 인라인 스타일을 오버라이드하는 방법은 다음과 같습니다:
-
라이브러리가 제공하는 prop/옵션 사용 (권장):
- 라이브러리가
customStyle,style,theme등의 prop을 제공하는 경우 사용 - 예:
react-syntax-highlighter의customStyleprop
- 라이브러리가
-
CSS
!important사용 (인라인 스타일에는 효과 없음):⚠️ 중요: 인라인 스타일(style="...")은 CSS!important로도 오버라이드할 수 없습니다.- CSS 우선순위: 인라인 스타일 >
!important(클래스) > 클래스 선택자 > 태그 선택자 - 따라서 인라인 스타일이 있는 경우 CSS만으로는 해결할 수 없습니다.
-
JavaScript로 DOM 직접 조작 (런타임):
- 라이브러리가 prop을 제공하지 않는 경우 JavaScript로 직접 제거
useEffect(() => { const element = document.querySelector('.library-element') if (element) { element.style.background = 'transparent' element.style.padding = '0' } }, [])
-
CSS 변수 사용 (일부 라이브러리 지원):
- 일부 라이브러리는 CSS 변수를 통해 테마를 커스터마이징할 수 있습니다.
-
라이브러리 포크/수정:
- 극단적인 경우, 라이브러리를 포크해서 직접 수정하거나 다른 라이브러리로 교체
실제 상황별 해결 방법:
-
Case 1: 라이브러리가
customStyle같은 prop 제공// ✅ 가장 쉬운 방법 <Component customStyle={{ background: 'transparent' }} />
-
Case 2: 라이브러리가
styleprop 제공// ✅ 작동할 수도 있음 (라이브러리마다 다름) <Component style={{ background: 'transparent' }} />
-
Case 3: 라이브러리가 아무것도 제공하지 않음
// ⚠️ JavaScript로 직접 조작해야 함 useEffect(() => { const element = document.querySelector('.library-element') if (element) { element.style.background = 'transparent' } }, [])
결론:
- 인라인 스타일은 CSS만으로는 오버라이드할 수 없습니다.
- 가장 좋은 방법: 라이브러리가 제공하는 prop/옵션 사용
- 대안: JavaScript로 DOM 직접 조작
- 최후의 수단: 라이브러리 포크/수정 또는 다른 라이브러리 사용
문제: 블로그 포스트가 많아질수록 초기 로딩 시간이 길어지고, 사용자 경험이 저하될 가능성이 존재합니다.
무한 스크롤을 선택한 이유:
- 성능 개선: 초기 로딩 시 필요한 데이터만 가져와서 빠른 첫 화면 렌더링
- 사용자 경험 향상: 스크롤만으로 추가 콘텐츠를 자연스럽게 탐색 가능
- 네트워크 효율성: 필요한 만큼만 데이터를 가져와서 불필요한 데이터 전송 방지
- 모바일 친화적: 모바일 환경에서 페이지네이션 버튼보다 스크롤이 더 직관적
구현 방식 선택: 하이브리드 방식 (SSR + 클라이언트 사이드)
- SEO를 버릴수는 없어 초기 데이터를 SSR로 가져오면서, 블로그 글 순서 배치를 역순으로 하여 이 뒤에 데이터가 더 남아있다는 것을 사용자가 인지하게 하며 무한스크롤을 구현하고자 합니다.
선택 이유:
- 초기 데이터는 SSR: SEO 최적화 및 빠른 첫 화면 렌더링
- 추가 데이터는 클라이언트: 사용자 인터랙션에 따라 동적으로 로드
- 최적의 균형: SEO와 사용자 경험을 모두 고려
사용 기술:
- Firestore 페이지네이션:
limit(),startAfter()를 사용한 커서 기반 페이지네이션 - Intersection Observer API: 스크롤 감지를 위한 브라우저 네이티브 API
- React Hooks:
useState,useEffect,useCallback,useRef를 활용한 상태 관리 - Next.js 하이브리드 렌더링: 서버 컴포넌트와 클라이언트 컴포넌트 조합
구현 과정:
핵심 로직:
limit(pageSize + 1): 요청한 개수보다 1개 더 가져와서 다음 페이지 존재 여부 확인startAfter(lastDoc): 마지막 문서 이후부터 가져오기hasMore계산: 가져온 문서가pageSize + 1개면 다음 페이지가 있다는 의미
구현 코드:
export const getPostsByTagPaginated = async (
tag: string,
pageSize: number = 12,
lastDoc?: QueryDocumentSnapshot
): Promise<{ posts: BlogPost[], lastDoc: QueryDocumentSnapshot | null, hasMore: boolean }> => {
let q = query(
collection(db, POSTS_COLLECTION),
where('tags', 'array-contains', tag),
where('published', '==', true),
orderBy('createdAt', 'desc'),
limit(pageSize + 1) // 한 개 더 가져와서 hasMore 확인
)
if (lastDoc) {
q = query(
collection(db, POSTS_COLLECTION),
where('tags', 'array-contains', tag),
where('published', '==', true),
orderBy('createdAt', 'desc'),
startAfter(lastDoc), // 마지막 문서 이후부터
limit(pageSize + 1)
)
}
const snapshot = await getDocs(q)
const docs = snapshot.docs
const hasMore = docs.length > pageSize // 13개면 다음 페이지 있음
const postsToReturn = hasMore ? docs.slice(0, pageSize) : docs // 마지막 1개 제거
const posts = postsToReturn.map(doc => ({
id: doc.id,
...doc.data(),
createdAt: doc.data().createdAt.toDate(),
updatedAt: doc.data().updatedAt.toDate(),
})) as BlogPost[]
return {
posts,
lastDoc: postsToReturn.length > 0 ? docs[postsToReturn.length - 1] : null,
hasMore
}
}왜 pageSize + 1을 가져오는가?:
- 정확한
hasMore판단을 위해 다음 페이지 존재 여부를 확인해야 함 - 예: 12개를 요청했는데 13개가 오면 다음 페이지가 있다는 의미
- 마지막 1개는 반환하지 않고
hasMore판단에만 사용
서버 컴포넌트에서 초기 데이터만 가져오기:
export default async function TagPage({ params }: PageProps) {
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={styles.section}>
<div className={styles.container}>
<h1 className={styles.title}>{decodedTag}</h1>
{/* 프로젝트 설명 */}
<PostList initialPosts={initialPosts} tag={decodedTag} />
</div>
</section>
</>
)
}장점:
- 초기 12개는 서버에서 렌더링되어 SEO에 유리
- 빠른 첫 화면 렌더링
- 나머지는 클라이언트에서 동적으로 로드
상태 관리:
const [posts, setPosts] = useState<BlogPost[]>(initialPosts) // 초기 데이터로 시작
const [loading, setLoading] = useState(false) // 로딩 상태
const [hasMore, setHasMore] = useState(initialPosts.length === 12) // 초기 데이터가 12개면 다음 페이지 가능
const [lastDoc, setLastDoc] = useState<QueryDocumentSnapshot | null>(null) // 마지막 문서 스냅샷
const [isFirstLoad, setIsFirstLoad] = useState(true) // 첫 번째 로드인지 확인
const observerRef = useRef<HTMLDivElement>(null) // Intersection Observer 타겟useCallback 사용 이유:
- 의존성 배열 관리:
useEffect의 의존성 배열에 함수를 넣을 때, 함수가 매 렌더링마다 새로 생성되면useEffect가 불필요하게 재실행됨 - 메모이제이션:
useCallback으로 함수를 메모이제이션하여 의존성이 변경되지 않으면 같은 함수 참조 유지 - 무한 루프 방지:
useEffect의 의존성 배열에loadMore를 넣어야 하는데,useCallback없이는 매번 새로운 함수가 생성되어useEffect가 계속 재실행됨
구현 코드:
const loadMore = useCallback(async () => {
if (loading || !hasMore) return
setLoading(true)
try {
let result
// 첫 번째 로드이고 초기 데이터가 12개인 경우
if (isFirstLoad && initialPosts.length === 12) {
// 초기 데이터를 다시 가져와서 lastDoc을 얻음 (중복 방지)
const firstPageResult = await getPostsByTagPaginated(tag, 12)
if (firstPageResult.lastDoc) {
// 두 번째 페이지 가져오기
result = await getPostsByTagPaginated(tag, 12, firstPageResult.lastDoc)
} else {
result = { posts: [], lastDoc: null, hasMore: false }
}
setIsFirstLoad(false)
} else {
// 일반적인 경우
result = await getPostsByTagPaginated(tag, 12, lastDoc || undefined)
}
if (result.posts.length > 0) {
setPosts(prev => [...prev, ...result.posts]) // 기존 포스트에 추가
setLastDoc(result.lastDoc)
setHasMore(result.hasMore)
} else {
setHasMore(false)
}
} catch (error) {
console.error('포스트 로드 실패:', error)
setHasMore(false)
} finally {
setLoading(false)
}
}, [tag, lastDoc, loading, hasMore, isFirstLoad, initialPosts.length])lastDoc이란 무엇인가?:
lastDoc은 현재 받아온 페이지의 마지막 문서를 의미합니다. 전체 데이터의 마지막이 아닙니다.
예시:
- 전체 포스트가 50개라고 가정
- 첫 번째 페이지: 1번~12번 포스트를 가져옴 →
lastDoc= 12번 포스트 (이 페이지의 마지막) - 두 번째 페이지:
startAfter(12번)→ 13번~24번 포스트를 가져옴 →lastDoc= 24번 포스트 - 세 번째 페이지:
startAfter(24번)→ 25번~36번 포스트를 가져옴 →lastDoc= 36번 포스트
왜 필요한가?:
- Firestore는 "이 문서 이후부터 가져와라"라는 커서 기반 페이지네이션을 사용
lastDoc을 사용하여 다음 페이지의 시작점을 지정- 각 페이지마다 새로운
lastDoc이 생성됨
첫 번째 로드와 일반 로드를 분기 처리하는 이유:
핵심 문제: QueryDocumentSnapshot은 직렬화할 수 없어서 서버에서 클라이언트로 전달할 수 없습니다.
상황 분석:
-
서버에서 초기 데이터 가져오기:
// page.tsx (서버 컴포넌트) const initialResult = await getPostsByTagPaginated(decodedTag, 12) // initialResult = { // posts: [1번, 2번, ..., 12번], // lastDoc: 12번 포스트의 QueryDocumentSnapshot, ← 이게 문제! // hasMore: true // }
-
클라이언트로 전달:
<PostList initialPosts={initialResult.posts} tag={decodedTag} /> // ❌ lastDoc은 전달할 수 없음! // QueryDocumentSnapshot은 JSON으로 변환 불가 (직렬화 불가)
-
클라이언트에서의 상황:
// PostList.tsx (클라이언트 컴포넌트) const [lastDoc, setLastDoc] = useState<QueryDocumentSnapshot | null>(null) // 초기값이 null! ← 서버에서 전달받을 수 없으므로
-
첫 번째
loadMore호출 시 문제:// 만약 이렇게 하면? result = await getPostsByTagPaginated(tag, 12, lastDoc || undefined) // lastDoc이 null이므로 undefined가 전달됨 // → startAfter가 없으므로 첫 번째 페이지를 다시 가져옴 // → 초기 데이터와 중복! ❌
해결 방법: 분기 처리:
첫 번째 로드 (lastDoc이 없을 때):
if (isFirstLoad && initialPosts.length === 12) {
// 1. 초기 데이터를 다시 가져와서 lastDoc을 얻음
const firstPageResult = await getPostsByTagPaginated(tag, 12)
// firstPageResult.lastDoc = 12번 포스트의 QueryDocumentSnapshot
// 2. 이제 lastDoc을 사용해서 두 번째 페이지를 가져옴
result = await getPostsByTagPaginated(tag, 12, firstPageResult.lastDoc)
// → startAfter(12번) → 13번부터 시작 → 중복 없음! ✅
}일반적인 경우 (lastDoc이 있을 때):
else {
// 이미 lastDoc이 있으므로 바로 사용
result = await getPostsByTagPaginated(tag, 12, lastDoc || undefined)
// → startAfter(24번) → 25번부터 시작 → 정상 작동! ✅
}전체 흐름:
1. 서버에서 초기 데이터 가져오기
→ posts: [1~12번], lastDoc: 12번 (하지만 전달 불가)
2. 클라이언트로 전달
→ initialPosts: [1~12번]만 전달
→ lastDoc: null (전달 불가)
3. 첫 번째 loadMore 호출
→ lastDoc이 null이므로 초기 데이터를 다시 가져와서 lastDoc 획득
→ 그 lastDoc을 사용해서 두 번째 페이지(13~24번) 가져오기
→ lastDoc = 24번 저장
4. 두 번째 loadMore 호출
→ lastDoc = 24번이 있으므로 바로 사용
→ 세 번째 페이지(25~36번) 가져오기
→ lastDoc = 36번 저장
5. 세 번째 loadMore 호출
→ lastDoc = 36번이 있으므로 바로 사용
→ 네 번째 페이지(37~48번) 가져오기
...
정리:
- 분기 처리 이유: 서버에서 가져온
lastDoc을 클라이언트로 전달할 수 없기 때문 - 첫 번째 로드:
lastDoc이 없으므로 초기 데이터를 다시 가져와서lastDoc획득 - 두 번째 이후: 이미
lastDoc이 있으므로 바로 사용
왜 Intersection Observer를 사용하는가?:
- 성능:
scroll이벤트보다 효율적 (브라우저가 최적화) - 정확성: 요소가 뷰포트에 들어오는 시점을 정확히 감지
- 간단함: 스크롤 위치 계산 불필요
- 모바일 친화적: 모바일 브라우저에서도 잘 작동
구현 코드:
useEffect(() => {
const currentObserver = observerRef.current
if (!currentObserver || !hasMore || loading) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore() // 하단 요소가 보이면 다음 페이지 로드
}
},
{ threshold: 0.1 } // 10% 보이면 트리거
)
observer.observe(currentObserver)
return () => {
observer.disconnect() // cleanup
}
}, [hasMore, loading, loadMore])cleanup 함수에서 observerRef.current 사용 시 주의사항:
observerRef.current는 cleanup 시점에 변경될 수 있음- 따라서
useEffect내부에서currentObserver변수에 저장하여 사용 - cleanup 함수에서는 저장된 변수를 사용
스크롤 감지용 요소:
{hasMore && (
<div ref={observerRef} className={styles.observer}>
{loading && <p className={styles.loading}>로딩 중...</p>}
</div>
)}왜 별도 요소를 사용하는가?:
- 포스트 리스트의 마지막 요소를 직접 관찰하면 스크롤이 끝나기 전에 트리거될 수 있음
- 별도의 감지 요소를 사용하면 정확한 시점에 다음 페이지를 로드할 수 있음
필요한 인덱스:
- Collection:
posts - Fields:
tags(Array)published(Ascending)createdAt(Descending)
인덱스 생성 방법:
- 코드 실행 시 Firebase에서 인덱스 필요 에러 발생
- 에러 메시지에 인덱스 생성 링크 포함
- 링크 클릭하여 자동으로 인덱스 생성
- 인덱스 생성 완료 후 정상 작동
왜 인덱스가 필요한가?:
where와orderBy를 함께 사용하는 복합 쿼리는 인덱스가 필요- Firestore는 쿼리 성능을 위해 인덱스를 사용
- 인덱스 없이는 쿼리가 실패하거나 매우 느려짐
구현 결과:
- 초기 로딩: 서버에서 첫 12개만 가져와서 빠른 렌더링
- 무한 스크롤: 사용자가 스크롤하면 자동으로 다음 12개 로드
- 성능: 필요한 만큼만 데이터를 가져와서 효율적
- 사용자 경험: 자연스러운 스크롤 경험 제공
주의사항:
- 초기 데이터가 12개 미만이면
hasMore를false로 설정 - 첫 번째 로드 시 중복 방지를 위해 초기 데이터를 다시 가져와서
lastDoc획득 useCallback을 사용하여useEffect의 불필요한 재실행 방지- Intersection Observer cleanup을 제대로 처리하여 메모리 누수 방지
장점:
- ✅ SEO 최적화: 초기 데이터는 SSR로 제공
- ✅ 빠른 초기 로딩: 필요한 만큼만 먼저 로드
- ✅ 자연스러운 UX: 스크롤만으로 추가 콘텐츠 탐색
- ✅ 네트워크 효율성: 필요한 만큼만 데이터 전송
- ✅ 모바일 친화적: 페이지네이션 버튼보다 직관적
- 한상곤 교수님이 사이트를 보시고 피드백으로, 프로젝트가 너무 많고, 너가 메인으로 삼고 싶은 프로젝트가 무엇인지 정하는게 좋다고 의견을 주심.
- 이에 캐러셀을 통해 내가 당당하게 내밀고 싶은 프로젝트들을 사용자가 클릭하고 싶게 구현하고자 함.
// components/Home/Work/FeaturedCarousel.tsx
const [currentIndex, setCurrentIndex] = useState(0)
const [isAutoPlaying, setIsAutoPlaying] = useState(true)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (isAutoPlaying) {
intervalRef.current = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % projects.length)
}, 8000) // 8초 간격
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [isAutoPlaying, projects.length])
const goToSlide = (index: number) => {
setCurrentIndex(index)
setIsAutoPlaying(false)
setTimeout(() => setIsAutoPlaying(true), 5000) // 5초 후 자동재생 재개
}setInterval만 사용하면 수동 조작 직후 바로 다음 슬라이드로 넘어가는 문제가 발생 →isAutoPlaying플래그로 제어useRef에 interval ID를 저장해야 cleanup에서 정확히clearInterval가능- 수동 조작 시 자동 슬라이드를 잠시 멈추고 일정 시간 뒤 재개하면 UX가 자연스러움
<div className="flex transition-transform duration-500 ease-in-out"
style={{
transform: `translateX(calc(7.5% - ${currentIndex * 85}% - ${currentIndex * 3}rem))`,
}}
>
{/* 슬라이드 */}
</div>- 슬라이드 폭을 85%로 제한하고,
translateX에 7.5% 오프셋을 줘 처음 진입 시에도 중앙 정렬 - 현재 슬라이드 대비 옆 슬라이드를
brightness-85로 두어 “다음 컨텐츠가 있다”는 힌트 제공 - gradient overlay와 hover 확대 효과로 시인성과 인터랙션 강화
- 페이지 진입 직후부터 대표 프로젝트가 순환하며 시선을 끌어줌
- 사용자 조작 직후에도 자동 슬라이드가 자연스럽게 재개
- 좌우 버튼/인디케이터/peek 효과 덕분에 데스크톱·모바일 모두 직관적인 UI 완성
- 자동 전환 주기는 3~5초가 가장 자연스러우나, 포플사이트 특성상 사용자가 자세하게 읽을 수 있게 8초 설정.
- 수동 조작 후 재개 시간(5초)은 사용자 경험에 맞춰 조정 가능
- 이미지가 너무 어두우면
brightness·gradient를 가볍게 조절해 시인성을 확보
- 기존 GitHub Pages로 배포한 정적 포트폴리오는 구글에서 바로 검색됨
- 새로 만든 Next.js + Vercel 사이트는 아무리 검색해도 결과에 뜨지 않아 의문 발생
- GitHub Pages 버전은 오래 사용하면서 이미 크롤링·백링크가 쌓여 있음
- 새 도메인(
dowankim.site)은 구글에 “이 도메인을 인덱싱해도 된다”는 신호를 아직 주지 않은 상태 - Search Console에 소유권 인증이 없으면 구글봇이 사이트를 적극적으로 방문하지 않음
- Search Console 도메인 속성 생성
- “도메인(권장)” 옵션 선택 → TXT 레코드 인증 필요
- 잘못된 시도: 가비아 DNS에 TXT 입력
- 루트 도메인 네임서버를 Vercel로 변경한 상태였는데도, 습관적으로 가비아 DNS 화면에
google-site-verification=...TXT를 먼저 추가 - 하지만 가비아는 단순한 도메인 등록처로만 사용 중이고, 실제 DNS는
ns1.vercel-dns.com이 관리 → Search Console에서 계속 “소유권 확인 실패”
- 루트 도메인 네임서버를 Vercel로 변경한 상태였는데도, 습관적으로 가비아 DNS 화면에
- 원인 파악
- 네임서버가 Vercel이므로 TXT 레코드도 Vercel Dashboard의 DNS Records에 추가해야 함을 인지
- 정상 시도: Vercel DNS에 TXT 추가
Domains > dowankim.site > DNS Records에서 Type=TXT, Name=@, Value=google-site-verification=..., TTL 기본값 60으로 등록- 5~10분 후 Search Console에서 “확인” 재시도 → 소유권 인증 성공
- 크롤링 안내 파일 준비
app/sitemap.ts작성 →/sitemap.xml경로 자동 생성. 프로젝트, 블로그, 연락처 기본 경로를 포함app/robots.ts작성 →/robots.txt자동 생성. 모든 User-agent에allow: /,sitemap경로 명시
- Search Console에 사이트맵 제출
- 왼쪽 메뉴
Sitemaps>https://dowankim.site/sitemap.xml입력 후 제출 → “성공” 확인
- 왼쪽 메뉴
- 메타데이터·OG 태그 정비
- 왜 필요한가?
- 이전 GitHub Pages용 메타 태그가 그대로라 새 도메인 정보가 검색/SNS에 전달되지 않았음
og:image,description이 오래된 URL을 가리켜 공유 시 잘못된 썸네일 노출
- 어떻게 했나?
app/layout.tsxmetadataBase를https://dowankim.site로 지정title템플릿,description,keywords,openGraph,twitter정보를 새 URL/이미지(images/og.webp)로 갱신NEXT_PUBLIC_SITE_URL환경변수를 활용해 로컬·배포 모두 동일한 코드를 사용
app/blog/[tag]/[slug]/page.tsxgenerateMetadata함수에서 포스트 데이터를 조회- 마크다운을 텍스트로 정제해 160자 내외
description생성 - 대표 이미지가 있으면 절대 경로로 변환, 없으면 기본 OG 이미지 사용
canonicalURL,openGraph.article정보(태그, 발행일)까지 동적으로 주입
- 효과
- 검색엔진/크롤러가 최신 도메인과 페이지 정보를 정확히 인식
- SNS 공유 시 올바른 제목·설명·썸네일이 노출돼 클릭률 개선 기대
- 왜 필요한가?
- 메타데이터 & OG 기본 개념 메모
- 메타데이터란?
- HTML
<head>내에서 페이지를 설명하는 정보 세트 - 예:
<title>,<meta name="description">,<link rel="canonical">,<meta name="robots"> - 검색엔진이 색인할 때 제목·요약·원본 URL을 이해하는 데 사용
- HTML
- OG(Open Graph)란?
- Facebook이 만든 공유 미리보기 표준, 대부분의 SNS가 참고
og:title,og:description,og:image,og:url,article:published_time등- 없으면 잘못된 썸네일/텍스트가 노출되어 클릭률 저하
- 이번 프로젝트에서의 적용 포인트
app/layout.tsx에서 전역 기본값 정의 (제목 템플릿, 설명, OG/Twitter 이미지)generateMetadata로 페이지별(특히 블로그 글) 맞춤 메타 정보 생성.env.local및 Vercel 환경 변수에NEXT_PUBLIC_SITE_URL을 지정해 동일한 코드로 로컬·배포 모두 처리
- 기억할 것
- 메타데이터를 수정하면 로컬 개발 서버나 배포를 다시 실행해야 적용됨
- 새 페이지를 만들 때도
generateMetadata혹은metadataexport를 함께 고려할 것
- 메타데이터란?
- DNS TXT 인증 이후 Search Console에서 인덱싱을 요청할 수 있게 되었고, 구글 크롤러가 새 도메인을 방문하기 시작
- GitHub Pages 대비 노출이 느린 이유(새 도메인, 링크 부족)를 명확히 이해하고, 인덱싱을 위한 최소 절차를 완료
- 인덱싱 요청 후 실제 검색 노출까지는 며칠~수주가 걸릴 수 있음
- 외부 채널(Naver 블로그, GitHub README 등)에 새 도메인 링크를 걸어두면 크롤 빈도가 빨라짐
noindex,canonical설정이 올바른지,robots.txt가 차단하고 있지 않은지 주기적으로 확인
- 어드민 페이지에서 새로운 태그로 프로젝트를 생성했는데, 어드민 페이지에는 보이지만 블로그 페이지(
/blog)에는 표시되지 않음 - 실제로 해당 태그로 공개된 포스트도 있고, 프로젝트 정보도 정상적으로 저장되어 있음
getAllTags()함수가 공개된 포스트(getPublishedPosts())에서만 태그를 추출하고 있었음- 프로젝트 컬렉션(
projects)에 등록된 태그는 포함하지 않았음 - 따라서 프로젝트는 생성되었지만, 해당 태그로 작성된 포스트가 없거나 아직 반영되지 않은 경우 블로그 페이지에 표시되지 않음
getAllTags()함수를 수정하여 포스트의 태그와 프로젝트 컬렉션의 태그를 모두 포함하도록 변경
수정 전 코드:
// 모든 태그 목록 가져오기 (포스트에서 사용된 태그)
export const getAllTags = async (): Promise<string[]> => {
const posts = await getPublishedPosts()
const tagSet = new Set<string>()
posts.forEach(post => {
if (post.tags) {
post.tags.forEach(tag => tagSet.add(tag))
}
})
return Array.from(tagSet).sort()
}수정 후 코드:
// 모든 태그 목록 가져오기 (포스트에서 사용된 태그 + 프로젝트 컬렉션의 태그)
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()
}- 포스트 태그 추출: 기존과 동일하게 공개된 포스트에서 태그 추출
- 프로젝트 태그 추가:
getAllProjects()를 호출하여 프로젝트 컬렉션의 모든 태그를 가져와서 추가 - Set 사용: 중복 태그를 자동으로 제거하여 효율적으로 관리
- 프로젝트를 생성하면 해당 태그가 즉시 블로그 페이지에 표시됨
- 포스트가 없어도 프로젝트 정보만으로 블로그 목록에 나타남
- 어드민 페이지와 블로그 페이지의 태그 목록이 일치하게 됨
- 프로젝트 컬렉션의 태그와 포스트의 태그가 정확히 일치해야 함 (대소문자, 공백 등)
- 프로젝트를 삭제하면 해당 태그도 블로그 페이지에서 사라지지만, 포스트가 있으면 계속 표시됨
이 프로젝트는 개인 포트폴리오 용도로 제작되었습니다.
Dowan Kim
- GitHub: @dowankim1024
- Blog: Naver Blog
- Instagram: @dowan.kim_developer