도서 페이지 SEO 강화: 검색엔진이 읽을 수 있는 책 만들기

문제를 발견하다
FullStackFamily에 도서 기능을 만든 지 꽤 됐습니다. 책도 올리고, 절마다 콘텐츠도 잘 보이고, 목차 내비게이션도 동작합니다. 그런데 어느 날 도서 페이지의 HTML 소스를 열어보고 당황했습니다.
<title>1.1 왜 개발자는 글을 써야 하는가 | 즐거운 블로그 가꾸기 | FullStackFamily</title> <meta name="description" content="..."/> <meta property="og:title" content="..."/> <meta property="og:description" content="..."/>
여기까지는 좋았는데, 그게 전부였습니다.
canonical URL이 없고, og:image도 없고, JSON-LD 구조화 데이터도 없습니다. SNS에 공유하면 썸네일 없이 텍스트만 나오는 상태였습니다. 같은 사이트의 블로그 게시글은 이미 전부 갖추고 있는데, 도서 페이지만 빠져있었습니다.
블로그 게시글 SEO 도서 페이지 SEO ────────────────── ────────────────── ✅ title ✅ title ✅ description ✅ description ✅ canonical URL ❌ (없음) ✅ og:image ❌ (없음) ✅ og:type ❌ (없음) ✅ og:locale ❌ (없음) ✅ twitter:card ❌ (없음) ✅ JSON-LD ❌ (없음)
블로그는 검색엔진에 "나 여기 있어요, 이런 글이에요, 이미지도 있어요"라고 자기소개를 잘 하고 있는데, 도서는 이름표도 없이 서 있는 셈이었습니다.
빠진 것들이 왜 중요한가
그냥 title하고 description만 있으면 되는 거 아닌가? 그렇지 않습니다.
1. 검색엔진을 위한 신호
┌────────────────────────────────────────────────┐ │ 구글 크롤러가 페이지를 방문하면... │ │ │ │ canonical URL → "이 페이지의 정본 주소가 뭐지?" │ │ og:type → "이건 글(article)? 책(book)?" │ │ JSON-LD → "저자가 누구고, 어느 책의 일부지?" │ │ │ │ → 검색 결과에 리치 스니펫으로 표시될 수 있음 │ └────────────────────────────────────────────────┘
canonical URL이 없으면 검색엔진이 같은 콘텐츠의 여러 URL을 별개의 페이지로 인식합니다. 검색 점수가 쪼개지는 거죠.
2. SNS 공유를 위한 미리보기
카카오톡이나 슬랙에 URL을 공유했을 때 뜨는 미리보기 카드. 이걸 결정하는 게 Open Graph 태그입니다.
og:image가 없을 때 og:image가 있을 때 ┌──────────────────┐ ┌──────────────────┐ │ 도서 | FullSta...│ │ ┌──────────────┐ │ │ │ │ │ 📊 이미지 │ │ │ (텍스트만 덜렁) │ │ └──────────────┘ │ │ │ │ 왜 개발자는 글을 │ └──────────────────┘ │ 써야 하는가 │ └──────────────────┘ 클릭률: 낮음 클릭률: 높음
og:image 하나 있고 없고로 클릭률이 2~3배 차이 납니다. 생각보다 큽니다.
3. 구조화 데이터(JSON-LD)
구글이 "이건 책의 한 챕터구나"를 이해하면, 검색 결과에 부가 정보를 표시해줍니다.
{ "@type": "Article", "isPartOf": { "@type": "Book", "name": "즐거운 블로그 가꾸기" } }
이걸 넣어두면 검색 결과에서 "즐거운 블로그 가꾸기 > 1.1 왜 개발자는 글을 써야 하는가" 같은 계층 구조로 보일 수 있습니다.
이미 있는 패턴을 재활용하다
블로그 게시글 페이지에 이미 완성된 SEO 패턴이 있었습니다. 처음부터 다시 만들 필요가 없었습니다.
블로그 게시글의 generateMetadata를 살펴보면 구조가 깔끔합니다.
// 블로그 게시글의 패턴 (이미 구현됨) const canonicalUrl = `${SEO_CONSTANTS.siteUrl}/@${slug}/posts/${id}` const ogImage = extractFirstImage(post.content || '') || SEO_CONSTANTS.defaultImage return { title: fullTitle, description, alternates: { canonical: canonicalUrl }, openGraph: { type: 'article', url: canonicalUrl, siteName: SEO_CONSTANTS.siteName, locale: SEO_CONSTANTS.locale, images: [{ url: ogImage, width: 1200, height: 630, alt: title }], }, twitter: { card: 'summary_large_image', images: [ogImage] }, }
여기서 눈여겨볼 건 extractFirstImage()와 폴백 체인입니다.
extractFirstImage()는 본문 마크다운에서 첫 번째 이미지를 자동으로 추출해 대표 이미지로 씁니다. 글 쓸 때 썸네일을 따로 지정할 필요가 없습니다.
// HTML <img> 태그와 마크다운  모두 지원 export function extractFirstImage(content: string): string | null { const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i) if (imgMatch) return imgMatch[1] const mdMatch = content.match(/!\[[^\]]*\]\(([^)]+)\)/) if (mdMatch) return mdMatch[1] return null }
그리고 폴백 체인. 이미지가 없을 때를 대비해서 단계적으로 대체합니다.
본문 첫 이미지 → 도서 커버 이미지 → 사이트 기본 이미지
도서 페이지에 적용하기
도서 절 상세 페이지
블로그의 "article" 패턴을 거의 그대로 쓰되, isPartOf로 "이 글은 이 책의 일부"라는 정보를 하나 더 넣었습니다.
도서 절 상세 페이지 SEO 구조 ┌──────────────────────────────────────────────────┐ │ <head> │ │ ├─ title: "절 제목 | 도서명 | FullStackFamily" │ │ ├─ canonical: /books/{slug}/{chapterSlug} │ │ ├─ og:type: article │ │ ├─ og:image: 본문 첫 이미지 or 커버 or 기본 │ │ ├─ twitter:card: summary_large_image │ │ └─ description: summary or 본문 160자 │ │ │ │ <body> │ │ └─ JSON-LD: Article + isPartOf(Book) │ └──────────────────────────────────────────────────┘
og:image 폴백은 이렇습니다.
const ogImage = extractFirstImage(chapter.content || '') || book.coverImage || OG_IMAGES.default
"즐거운 블로그 가꾸기"는 거의 모든 절에 다이어그램이 있어서 본문 첫 이미지가 자연스럽게 대표 이미지가 됩니다. 혹시 이미지 없는 절이 있으면 도서 커버가 대신 나가고요.
도서 소개 페이지
소개 페이지는 og:type을 book으로 설정하고, JSON-LD에 Book 스키마를 사용합니다.
도서 소개 페이지 SEO 구조 ┌──────────────────────────────────────────────────┐ │ <head> │ │ ├─ og:type: book (article이 아님!) │ │ ├─ og:image: 커버 이미지 or 기본 │ │ └─ canonical: /books/{slug} │ │ │ │ <body> │ │ └─ JSON-LD: Book (name, author, publisher, │ │ numberOfPages, image) │ └──────────────────────────────────────────────────┘
numberOfPages에 book.chapters.length를 넣었는데, 엄밀히 따지면 "절(section) 수"입니다. 하지만 검색엔진에 콘텐츠 규모를 알려주는 용도로는 이 정도면 됩니다.
적용 전후 비교
수정 후 HTML 소스를 다시 열어봤습니다.
| 항목 | Before | After |
|---|---|---|
| canonical | 없음 | /books/happy-blogging/ch01-01 |
| og:type | 없음 | article |
| og:image | 없음 | 본문 첫 이미지 자동 추출 |
| og:image:width/height | 없음 | 1200x630 |
| og:locale | 없음 | ko_KR |
| og:site_name | 없음 | FullStackFamily |
| twitter:card | 없음 | summary_large_image |
| twitter:image | 없음 | og:image와 동일 |
| JSON-LD | 없음 | Article + isPartOf: Book |
카카오톡에 링크를 보내면 이제 이미지가 포함된 카드가 뜹니다. 검색엔진도 "이건 '즐거운 블로그 가꾸기'라는 책의 1.1절이구나"를 알아봅니다.
같이 개선한 것: 우측 목차(TOC)
SEO를 손보다 보니 빠진 게 하나 더 눈에 들어왔습니다. 우측 목차가 없었습니다.
블로그 게시글이나 수업 도서에는 우측에 ##, ### 헤딩을 모아서 보여주는 플로팅 목차가 있거든요. 긴 글에서 원하는 부분으로 바로 갈 수 있어서 꽤 유용합니다. 전역 도서에만 이게 빠져있었습니다.
전역 도서 페이지 (Before) 전역 도서 페이지 (After) ┌─────────────────────┐ ┌─────────────────────┬────────┐ │ │ │ │ ## 1.1 │ │ 본문 콘텐츠 │ │ 본문 콘텐츠 │ ### a │ │ │ │ │ ### b │ │ (스크롤이 길면 │ │ │ ## 1.2 │ │ 길을 잃기 쉬움) │ │ │ ### c │ │ │ │ │ │ └─────────────────────┘ └─────────────────────┴────────┘
ContentTOC라는 공유 컴포넌트가 이미 있었습니다. import하고 레이아웃만 잡으면 끝.
<aside className="hidden xl:block absolute left-full ml-12 top-0 w-[240px]"> <div className="sticky top-[72px]"> <ContentTOC content={chapter.content} /> </div> </aside>
목차 개수가 20개를 넘으면 ###(h3)은 자동으로 숨겨서 가독성을 유지합니다. IntersectionObserver로 현재 읽고 있는 헤딩을 하이라이트하고, 클릭하면 해당 위치로 스크롤됩니다.
마무리
SEO 메타데이터는 사용자한테 직접 안 보이니까 놓치기 쉽습니다. "화면에서 잘 나오잖아" 하고 넘어가기 쉬운데, 검색엔진과 SNS 봇한테는 그 보이지 않는 태그가 전부거든요.
새 페이지를 만들면 title, description 넣고 끝내기 쉬운데, canonical이랑 og:image, JSON-LD까지 같이 확인하는 습관이 필요합니다. 그리고 블로그에서 이미 잘 돌아가고 있던 extractFirstImage(), SEO_CONSTANTS, ContentTOC 같은 유틸을 그대로 가져다 쓰니 작업이 빨랐습니다. 처음부터 만들었으면 한참 걸렸을 겁니다.
앞으로 쓸 도서 페이지 SEO 체크리스트를 정리해 뒀습니다.
□ <title>: 절 제목 | 도서명 | 사이트명 □ <meta description>: summary 또는 본문 160자 □ <link rel="canonical">: 정규 URL □ og:type: article (절) / book (소개) □ og:image: 본문 첫 이미지 → 커버 → 기본 □ og:url, og:site_name, og:locale □ twitter:card: summary_large_image □ JSON-LD: Article(isPartOf:Book) / Book
다음에 새 페이지를 추가할 때는 이 체크리스트부터 확인할 생각입니다.






댓글
댓글을 작성하려면 이 필요합니다.