Next.js 블로그에 SEO와 자체 분석 시스템 붙이기
JSON-LD, 동적 Sitemap, 조회수 추적, 유입 경로 분석까지 - 직접 만든 블로그에 필요한 것들을 하나씩 붙여본 기록
발단
"구글 SEO가 뭐야?"라는 질문에서 시작했다. 블로그를 만들긴 했는데, 검색에 노출되는지, 사람들이 어디서 들어오는지 전혀 몰랐다.
이 글에서는 Next.js 블로그에 SEO 요소(Canonical URL, JSON-LD, 동적 Sitemap)를 추가하고, Supabase 기반 자체 분석 시스템을 구축한 과정을 정리한다.
초안은 Claude로 뽑았고, 흐름이랑 표현은 내가 다시 손봤다.
SEO 기초
SEO(Search Engine Optimization)는 검색엔진 최적화다. 구글 검색 결과에서 내 블로그가 더 높은 순위에 노출되도록 만드는 작업.
검색엔진이 페이지를 이해하는 방식
구글 봇이 웹페이지를 방문하면 크게 세 가지를 본다:
- 크롤링: 페이지가 존재하는지, 접근 가능한지 (robots.txt, sitemap.xml)
- 인덱싱: 페이지 내용이 뭔지 파악 (제목, 본문, 메타데이터)
- 랭킹: 검색어와 얼마나 관련 있는지 점수 매기기 (구조화 데이터, 백링크, 사용자 행동)
개발자가 직접 건드릴 수 있는 건 1번과 2번이다. 3번은 콘텐츠 품질과 시간이 해결해줘야 하는 영역.
기술적 SEO vs 콘텐츠 SEO
| 구분 | 기술적 SEO | 콘텐츠 SEO |
|---|---|---|
| 대상 | 검색엔진 봇 | 사람 (독자) |
| 목표 | 크롤링/인덱싱 최적화 | 검색 의도에 맞는 글 |
| 예시 | sitemap, 메타태그, 로딩 속도 | 키워드 선정, 제목 작성, 가독성 |
이 글에서 다루는 건 기술적 SEO다. 아무리 좋은 글을 써도 검색엔진이 제대로 인식 못 하면 노출이 안 된다.
내 블로그에 이미 있던 것들
- Open Graph / Twitter Card: SNS 공유 시 미리보기 카드
- sitemap.xml: 사이트 전체 페이지 목록
- robots.txt: 크롤러 접근 규칙
- RSS 피드: 구독 기능 (SEO 직접 영향은 적음)
기본은 갖춰져 있었는데, 막상 점검해보니 빠진 게 꽤 있었다.
누락된 SEO 요소들
Canonical URL
같은 페이지가 여러 URL로 접근 가능하면 검색엔진이 혼란스러워한다. /blog/my-post와 /blog/my-post?utm_source=twitter가 다른 페이지로 인식되는 식이다.
// generateMetadata에 추가
alternates: {
canonical: `${siteConfig.url}/blog/${post.slug}`,
}한 줄이면 해결.
JSON-LD 구조화 데이터
이게 없으면 구글 검색 결과에 Rich Snippet이 안 뜬다. 제목이랑 설명만 덜렁 나오는 거랑, 작성일/저자/카테고리가 같이 나오는 거랑 클릭률 차이가 크다.
src/lib/jsonld.ts를 만들어서 세 가지 스키마를 정의했다:
// Article 스키마 - 포스트 상세 페이지용
export function generateArticleJsonLd(post: Post, url: string) {
return {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.date,
author: { '@type': 'Person', name: siteConfig.author.name },
// ...
}
}Article: 포스트 상세 페이지WebSite: 사이트 전체 (검색창 포함)BreadcrumbList: 홈 → 블로그 → 카테고리 → 포스트 경로
BreadcrumbList가 포인트다. 검색 결과에 "example.com › 블로그 › Tech" 이런 식으로 경로가 표시된다.
동적 Sitemap
기존엔 public/sitemap.xml이 정적 파일이었다. 빌드 시점에 생성되니까 새 포스트를 추가해도 반영이 안 됐다. 카테고리랑 시리즈 페이지는 아예 빠져 있었고.
처음엔 정적 파일이랑 동적 라우트가 충돌해서 빌드가 터졌다. 정적 파일 지우고 나서야 해결됐다.
// src/app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [posts, series, categories, tags] = await Promise.all([
getAllPostsFlatAsync(),
getAllSeriesAsync(),
getAllCategoriesAsync(),
getAllTagsAsync(),
])
return [...staticPages, ...postPages, ...categoryPages, ...seriesPages, ...tagPages]
}이제 /sitemap.xml 접속하면 모든 페이지가 다 나온다.
조회수 추적
조회수가 없으니 어떤 글이 인기 있는지 알 수가 없었다. Google Analytics 붙이는 것도 방법이지만, 이미 Supabase 쓰고 있어서 직접 만들기로 했다.
DB 스키마
ALTER TABLE posts ADD COLUMN IF NOT EXISTS views INTEGER DEFAULT 0;
CREATE OR REPLACE FUNCTION increment_post_views(post_slug TEXT)
RETURNS INTEGER AS $$
UPDATE posts SET views = COALESCE(views, 0) + 1
WHERE slug = post_slug
RETURNING views;
$$ LANGUAGE sql;동시성 처리를 위해 함수로 만들었다. 직접 UPDATE 치면 race condition이 발생할 수 있다.
클라이언트 구현
// ViewCounter.tsx
useEffect(() => {
const viewedKey = `viewed_${slug}`
if (!sessionStorage.getItem(viewedKey)) {
fetch('/api/views', { method: 'POST', body: JSON.stringify({ slug }) })
sessionStorage.setItem(viewedKey, 'true')
}
}, [slug])세션 스토리지로 중복 방지. 새로고침해도 조회수가 올라가지 않는다.
유입 경로 분석
조회수만으론 부족했다. 어디서 들어오는지도 알고 싶었다.
Umami나 Vercel Analytics를 붙일까 했는데, 이왕 만드는 김에 직접 해보기로 했다. 나중에 바꿀 수도 있지만 일단은 이걸로.
수집 데이터
- referrer: 이전 페이지 URL (구글 검색인지, 트위터인지, 직접 접속인지)
- UTM 파라미터: 캠페인 추적용
- device: 모바일/태블릿/데스크톱
- browser: Chrome, Safari, Firefox 등
// Analytics.tsx
const referrer = document.referrer
const urlParams = new URLSearchParams(window.location.search)
const utmSource = urlParams.get('utm_source')
const device = getDeviceType()
const browser = getBrowser()
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({ slug, referrer, utmSource, device, browser, sessionId })
})세션 ID로 같은 방문자가 여러 페이지를 봐도 세션 단위로 구분할 수 있다.
통계 대시보드
수집한 데이터를 보여주는 페이지도 만들었다. /admin/stats에서 확인 가능.
표시 항목:
- 인기 포스트 Top 10
- 카테고리/시리즈별 포스트 수
- 월별 작성 추이
- 유입 경로별 통계
- 기기/브라우저 분포
- 일별 방문자 추이
차트는 라이브러리 안 쓰고 div 높이로 때웠다. 굳이 복잡하게 할 필요가 없었다.
덤으로 추가한 것들
읽기 진행률 바
스크롤하면 상단에 진행률 바가 표시된다.
const scrollProgress = (scrollTop / docHeight) * 100관련 포스트 추천
글 하단에 관련 글 3개 표시. 추천 로직:
- 같은 시리즈 (+10점)
- 같은 카테고리 (+5점)
- 태그 겹침 (+2점/개)
점수 높은 순으로 정렬.
정리
| 항목 | 이전 | 이후 |
|---|---|---|
| Canonical URL | 없음 | 모든 포스트 적용 |
| JSON-LD | 없음 | Article + WebSite + Breadcrumb |
| Sitemap | 정적 (일부 누락) | 동적 (전체 포함) |
| 조회수 | 없음 | 포스트별 추적 |
| 유입 분석 | 없음 | referrer, UTM, 기기, 브라우저 |
SEO 효과는 시간이 지나봐야 알겠지만, 최소한 구글이 인식할 수 있는 정보는 다 넣었다.
다음 과제
- 좋아요/반응 기능 (고민 중)
- YouTube/Twitter 임베드 컴포넌트
- 국가별 방문자 통계 (IP 기반, 프라이버시 고려 필요)
이 글이 어떠셨나요?
관련 포스트
Next.js + Supabase 블로그 성능 최적화: 정적 생성의 함정과 ISR
Vercel 배포 후 DB 수정이 반영되지 않고, 페이지 로딩도 느려진 문제를 ISR로 해결한 과정
2026. 01. 26. 오전 01:00Development바이브 코딩 프로젝트의 6개월 후
바이브 코딩으로 만든 프로젝트의 지속 가능성. 기술 부채 청산, 유지보수, 그리고 진짜 "완성"이란 무엇인가.
2026. 02. 15. 오후 10:00Development1인 개발자의 바이브 코딩 시스템
혼자서 팀처럼 개발하는 법. CLAUDE.md, CI, 셀프 코드리뷰를 활용한 규칙 기반 솔로 개발 시스템을 구축한다.
2026. 02. 11. 오후 10:00뉴스레터 구독
새 글이 올라오면 이메일로 알려드려요.