Next.js 블로그 만들기 — 정적 블로그에 맞춤 추천 포스트 기능 추가
정적 블로그에서 "맞춤 추천"이 되나?
보통 "맞춤 추천"이라고 하면 서버에 사용자 행동 데이터를 쌓고, 추천 알고리즘을 돌려서 결과를 내려주는 구조를 떠올린다. Netflix, YouTube 같은 서비스가 대표적이다.
그런데 정적 블로그(output: 'export')에는 서버가 없다. 사용자 DB도 없고, API 엔드포인트도 없다. 그러면 추천은 포기해야 할까?
아니다. 핵심 아이디어는 간단하다:
사용자의 조회 이력을 localStorage에 저장하고, 클라이언트에서 태그 기반으로 점수를 매기면 된다.
접근 방식을 비교하면 이렇다:
| 방식 | 장점 | 단점 |
|---|---|---|
| 외부 추천 서비스 (Algolia Recommend 등) | 정교한 ML 기반 추천 | 비용, 외부 의존성, 설정 복잡 |
| 서버 사이드 협업 필터링 | 사용자 간 교차 추천 가능 | 서버 필요, 정적 사이트 불가 |
| localStorage + 태그 스코어링 | 의존성 제로, 구현 간단, 정적 사이트 호환 | 개인 이력 기반만 가능 |
글이 수십~수백 개 규모의 블로그라면 세 번째 방식으로 충분하다. 복잡한 ML 없이도 "이 사람이 최근에 본 글의 태그"를 기반으로 관련성 높은 글을 추천할 수 있다.
전체 구조
포스트 상세 페이지
─────────────────────────
ViewTracker → useViewHistory → localStorage에 { slug, tags, viewedAt } 저장
홈 페이지
─────────────────────────
useViewHistory (이력 읽기) → useRecommendedPosts (스코어링) → RecommendedPosts UI
크게 두 흐름이다:
- 저장: 사용자가 포스트를 방문하면
ViewTracker가 slug과 태그를 localStorage에 기록한다. - 추천: 홈 페이지에서 localStorage를 읽고, 태그 가중치를 계산해서 점수가 높은 포스트를 보여준다.
두 흐름 모두 클라이언트 사이드에서만 동작하므로 SSR/SSG와 충돌하지 않는다. 대신 하이드레이션 타이밍을 신경 써야 한다.
조회 이력 저장 — useViewHistory
localStorage를 읽고 쓰는 훅이다. SSR 환경에서 localStorage에 접근하면 에러가 나므로 useEffect 안에서만 접근한다.
'use client'
import { useState, useEffect, useCallback } from 'react'
const STORAGE_KEY = 'eco-log:view-history'
const MAX_ENTRIES = 50
interface ViewEntry {
readonly slug: string
readonly tags: readonly string[]
readonly viewedAt: number
}
interface UseViewHistoryReturn {
readonly history: readonly ViewEntry[]
readonly isMounted: boolean
readonly addView: (slug: string, tags: readonly string[]) => void
}
function readHistory(): readonly ViewEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed as readonly ViewEntry[]
} catch {
return []
}
}
function writeHistory(entries: readonly ViewEntry[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
} catch {
// localStorage 비활성화 또는 용량 초과 — 무시
}
}
export function useViewHistory(): UseViewHistoryReturn {
const [history, setHistory] = useState<readonly ViewEntry[]>([])
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setHistory(readHistory())
setIsMounted(true)
}, [])
const addView = useCallback((slug: string, tags: readonly string[]) => {
setHistory((prev) => {
const filtered = prev.filter((entry) => entry.slug !== slug)
const next: readonly ViewEntry[] = [
{ slug, tags, viewedAt: Date.now() },
...filtered,
].slice(0, MAX_ENTRIES)
writeHistory(next)
return next
})
}, [])
return { history, isMounted, addView } as const
}설계 포인트:
isMounted플래그 — 서버에서는false, 클라이언트 마운트 후true. 이 값으로 추천 섹션의 렌더링을 제어하면 하이드레이션 불일치를 피할 수 있다.- 같은 slug 재방문 시 갱신 —
filter로 기존 엔트리를 제거한 뒤 최신 타임스탬프로 다시 추가한다. 중복이 쌓이지 않는다. - 최대 50개 제한 —
slice(0, MAX_ENTRIES)로 오래된 이력을 자동 정리한다. localStorage 용량을 무한히 먹지 않는다. - try/catch로 graceful fallback — 시크릿 모드나 localStorage가 꽉 찬 경우에도 에러 없이 빈 배열을 반환한다.
태그 가중치 스코어링 — useRecommendedPosts
이력 데이터를 받아서 "어떤 포스트를 추천할지" 결정하는 훅이다. 핵심은 태그별 가중치 계산이다.
스코어링 알고리즘
단순히 "많이 본 태그"만 보면 일주일 전에 한 번 본 태그와 방금 본 태그가 같은 비중을 갖는다. 최근에 본 것일수록 관심사에 가까울 확률이 높으니, 시간 감쇠(time decay)를 적용한다.
태그 가중치 = Σ(recency_factor)
recency_factor = max(0.3, 1 - (경과시간 / 7일) × 0.7)
포스트 점수 = Σ(해당 포스트 태그들의 가중치)
- 방금 본 태그: recency_factor ≈ 1.0
- 3.5일 전 본 태그: recency_factor ≈ 0.65
- 7일 이상 지난 태그: recency_factor = 0.3 (하한선)
하한선 0.3을 둔 이유는, 오래된 관심사라도 완전히 무시하지 않기 위해서다. 7일 창(window)은 블로그 특성상 매일 방문하지 않는 독자를 고려한 값이다.
'use client'
import { useState, useMemo, useCallback } from 'react'
import type { PostMeta } from '@/types/post'
const POSTS_PER_PAGE = 5
const RECENCY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 // 7일
interface ViewEntry {
readonly slug: string
readonly tags: readonly string[]
readonly viewedAt: number
}
interface ScoredPost {
readonly post: PostMeta
readonly score: number
}
interface UseRecommendedPostsReturn {
readonly posts: readonly PostMeta[]
readonly totalPages: number
readonly currentPage: number
readonly goNext: () => void
readonly goPrev: () => void
}
function computeTagWeights(
history: readonly ViewEntry[],
): ReadonlyMap<string, number> {
const now = Date.now()
const weights = new Map<string, number>()
for (const entry of history) {
const age = now - entry.viewedAt
const recencyFactor = Math.max(0.3, 1 - (age / RECENCY_WINDOW_MS) * 0.7)
for (const tag of entry.tags) {
weights.set(tag, (weights.get(tag) ?? 0) + recencyFactor)
}
}
return weights
}
function scorePost(
post: PostMeta,
tagWeights: ReadonlyMap<string, number>,
): number {
let score = 0
for (const tag of post.tags) {
score += tagWeights.get(tag) ?? 0
}
return score
}
export function useRecommendedPosts(
allPosts: readonly PostMeta[],
history: readonly ViewEntry[],
): UseRecommendedPostsReturn {
const [currentPage, setCurrentPage] = useState(0)
const scoredPosts = useMemo(() => {
if (history.length === 0) return []
const viewedSlugs = new Set(history.map((e) => e.slug))
const tagWeights = computeTagWeights(history)
const scored: readonly ScoredPost[] = allPosts
.filter((post) => !viewedSlugs.has(post.slug))
.map((post) => ({ post, score: scorePost(post, tagWeights) }))
.filter((item) => item.score > 0)
return [...scored].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
return new Date(b.post.date).getTime() - new Date(a.post.date).getTime()
})
}, [allPosts, history])
const totalPages = Math.max(
1,
Math.ceil(scoredPosts.length / POSTS_PER_PAGE),
)
const posts = useMemo(() => {
const start = currentPage * POSTS_PER_PAGE
return scoredPosts.slice(start, start + POSTS_PER_PAGE).map((s) => s.post)
}, [scoredPosts, currentPage])
const goNext = useCallback(() => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages - 1))
}, [totalPages])
const goPrev = useCallback(() => {
setCurrentPage((prev) => Math.max(prev - 1, 0))
}, [])
return { posts, totalPages, currentPage, goNext, goPrev } as const
}설계 포인트:
- 이미 본 포스트 제외 —
viewedSlugs를 Set으로 만들어서 O(1) 조회. 이미 읽은 글을 추천하면 의미가 없다. - 점수 0인 포스트 필터링 — 조회 이력의 태그와 겹치는 게 하나도 없는 포스트는 추천 대상에서 제외한다.
useMemo로 캐싱 —allPosts와history가 바뀌지 않으면 스코어링을 재계산하지 않는다.- 페이지네이션 내장 — 추천 포스트가 많을 때 5개씩 나눠서 보여줄 수 있다.
컴포넌트 조립
ViewTracker — 보이지 않는 기록기
포스트 상세 페이지에 배치하는 "투명 컴포넌트"다. 렌더링 결과가 없고(return null), 마운트 시 조회 이력만 기록한다.
'use client'
import { useEffect } from 'react'
import { useViewHistory } from '@/hooks/useViewHistory'
interface ViewTrackerProps {
readonly slug: string
readonly tags: readonly string[]
}
export default function ViewTracker({ slug, tags }: ViewTrackerProps) {
const { addView } = useViewHistory()
useEffect(() => {
addView(slug, tags)
}, [slug, tags, addView])
return null
}'use client'를 선언했으므로 서버 컴포넌트인 포스트 페이지 안에 자연스럽게 배치할 수 있다. 서버 렌더링 시에는 useEffect가 실행되지 않으니 빌드 타임에도 안전하다.
RecommendedPosts — 추천 섹션 UI
두 훅을 조합해서 추천 리스트를 렌더링하는 컨테이너 컴포넌트다.
'use client'
import { useViewHistory } from '@/hooks/useViewHistory'
import { useRecommendedPosts } from '@/hooks/useRecommendedPosts'
import RecommendedPostItem from '@/components/home/RecommendedPostItem'
import type { PostMeta } from '@/types/post'
interface RecommendedPostsProps {
readonly allPosts: readonly PostMeta[]
}
export default function RecommendedPosts({ allPosts }: RecommendedPostsProps) {
const { history, isMounted } = useViewHistory()
const { posts, totalPages, currentPage, goNext, goPrev } =
useRecommendedPosts(allPosts, history)
if (!isMounted || posts.length === 0) return null
return (
<section className="mb-8">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold text-text">맞춤 추천</h2>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={goPrev}
disabled={currentPage === 0}
className="rounded px-2 py-1 text-sm text-text-secondary transition-colors hover:text-accent disabled:opacity-40 disabled:hover:text-text-secondary"
aria-label="이전 페이지"
>
←
</button>
<span className="text-xs text-text-tertiary">
{currentPage + 1} / {totalPages}
</span>
<button
type="button"
onClick={goNext}
disabled={currentPage === totalPages - 1}
className="rounded px-2 py-1 text-sm text-text-secondary transition-colors hover:text-accent disabled:opacity-40 disabled:hover:text-text-secondary"
aria-label="다음 페이지"
>
→
</button>
</div>
)}
</div>
<ul className="space-y-2">
{posts.map((post) => (
<RecommendedPostItem key={post.slug} post={post} />
))}
</ul>
</section>
)
}핵심은 if (!isMounted || posts.length === 0) return null 부분이다. 서버 렌더링 시에는 isMounted가 false이므로 아무것도 렌더링하지 않는다. 클라이언트에서 마운트된 후에야 localStorage를 읽고 추천 섹션을 보여준다. 이렇게 하면 하이드레이션 불일치도 없고, 추천할 게 없을 때 빈 공간이 잡히지도 않는다.
[💡 잠깐! 이 용어는?] 하이드레이션 불일치(Hydration Mismatch): 서버에서 렌더링한 HTML과 클라이언트에서 렌더링한 결과가 다를 때 React가 경고를 뱉는 현상. localStorage 같은 브라우저 전용 API를 서버에서 접근하면 발생한다.
홈/상세 페이지에 통합
포스트 상세 페이지
ViewTracker를 <article> 내부 첫 줄에 배치한다. 서버 컴포넌트 안에서 클라이언트 컴포넌트를 쓰는 구조라 별도 설정 없이 동작한다.
import ViewTracker from '@/components/post/ViewTracker'
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params
const post = getPostBySlug(slug)
return (
<article className="min-w-0 flex-1">
<ViewTracker slug={post.slug} tags={post.tags} />
<header>...</header>
<PostContent>...</PostContent>
</article>
)
}홈 페이지
RecommendedPosts를 CategoryTabs 바로 위에 배치한다. 서버에서 가져온 posts 배열을 그대로 넘긴다.
import RecommendedPosts from '@/components/home/RecommendedPosts'
export default function HomePage() {
const posts = getAllPostMetas()
return (
<section className="min-w-0 flex-1">
<RecommendedPosts allPosts={posts} />
<CategoryTabs ... />
</section>
)
}allPosts는 빌드 타임에 정적으로 생성된 전체 포스트 목록이다. 클라이언트로 직렬화되어 전달되고, useRecommendedPosts 훅 안에서 스코어링 대상으로 사용된다.
엣지 케이스 처리
| 상황 | 동작 |
|---|---|
| 신규 방문자 (이력 없음) | 추천 섹션 숨김 (return null) |
| 모든 포스트를 이미 봄 | 추천할 게 없으므로 섹션 숨김 |
| localStorage 비활성화 | try/catch로 빈 배열 반환, 섹션 숨김 |
| 같은 포스트 재방문 | 기존 엔트리 제거 → 최신 타임스탬프로 갱신 |
| 이력 50개 초과 | 오래된 것부터 자동 삭제 |
모든 케이스에서 에러 없이 graceful하게 동작한다. "추천할 게 없으면 안 보여주면 된다"가 원칙이다.
결과
추천 기능이 정상 동작하는지 확인할 포인트:
pnpm dev후 아무 포스트 2~3개 방문- 홈으로 돌아가서 "맞춤 추천" 섹션이 나타나는지 확인
- 추천 포스트가 이미 본 포스트와 겹치지 않는지 확인
- 6개 이상 추천 시 이전/다음 페이지네이션 동작 확인
- 시크릿 모드에서 추천 섹션이 안 보이는지 확인
- 서버 빌드(
pnpm build) 시 에러 없는지 확인
정적 블로그라도 클라이언트 사이드 로직만으로 충분히 의미 있는 추천을 구현할 수 있다. 외부 서비스 의존성 없이 localStorage와 태그 스코어링만으로 "이 블로그를 자주 보는 독자"에게 관련성 높은 글을 보여줄 수 있다. 추후 조회 이력이 아닌 스크롤 깊이나 체류 시간을 가중치에 반영하면 더 정교한 추천도 가능하다.
관심 있을 만한 포스트
Next.js 블로그 만들기 — 정적 블로그에 검색 기능 추가
빌드 타임 검색 인덱스 생성과 클라이언트 사이드 필터링으로 정적 블로그에 검색 기능을 구현하기. Cmd+K 단축키, 오버레이 UI까지.
Next.js 블로그 만들기 — 검색엔진 등록 4종 완전 정복
Google Search Console, Bing Webmaster Tools, 네이버 서치어드바이저, 다음 검색등록까지. Next.js 블로그를 검색엔진에 노출시키는 전체 과정을 정리했다.
Next.js 블로그 만들기 — 스크롤 프로그레스 바와 Canvas 렌더링 이슈 해결
스크롤 진행률 프로그레스 바 구현과 Canvas 커서 효과가 GNB backdrop-blur와 충돌하며 발생한 깜빡임 이슈 해결기.
Next.js 블로그 만들기 — TOC와 커서 효과로 디테일 살리기
IntersectionObserver 기반 TOC(Table of Contents)와 Canvas 커서 트레일 효과 구현기. 스크롤 하이라이팅, fixed 레이아웃 처리까지.
Next.js 블로그 만들기 — 카드 그리드와 포스트 상세 페이지
Velog 스타일 카드 UI와 MDX 렌더링 상세 페이지 구현. 반응형 그리드, SEO 메타데이터, 정적 사이트 생성까지.
Next.js 15로 개인 블로그 만들기 — 프로젝트 셋업
왜 직접 블로그를 만들었는지, 기술 스택 선정 이유와 프로젝트 초기 구성까지. Next.js 15 + Tailwind CSS v4 + MDX 기반 블로그의 시작.
Bun vs Node.js vs Deno — 뭐가 다른지, 그래서 뭘 쓰면 좋은지 (2026 기준)
런타임 3대장 비교: 호환성(Node), 속도/번들(Bun), 올인원/보안(Deno). 팀/프로덕트 상황별 선택 기준과 체크리스트까지 정리.
번들러(Bundle)란 뭐고, 왜 필요할까? — 요즘 번들러/빌드 툴 비교 가이드
번들러의 역할(모듈/의존성/트랜스파일/최적화)을 쉽게 설명하고, Vite·Rollup·esbuild·Webpack·Rspack·Turbopack 같은 도구를 상황별로 비교합니다.