개인 블로그 SEO 최적화 과정

태그
Blog
SEO
React
최종 수정일
Last updated September 5, 2026
Slug
crawler-dont-click-more
Date
August 19, 2026
Description
클라이언트 사이드 페이지네이션은 초기 HTML에 글 링크를 남기지 않아 크롤러가 따라갈 경로를 지웁니다. Search Console의 내부 링크 수치로 그 상태를 확인하고, 전체 글을 링크하는 아카이브 페이지로 해결하는 과정을 정리합니다.
Published
Published

notion image
왜 "더 보기"를 쓰는가
글 목록에 "더 보기" 버튼을 두는 것은 React에서 가장 자연스러운 선택입니다. 페이지를 옮기지 않으니 스크롤 위치가 유지되고, 구현도 useState 하나면 끝납니다. 저도 블로그 홈을 그렇게 만들었습니다.
먼저 이 패턴이 왜 표준처럼 자리 잡았는지부터 짚어야 합니다. 나쁜 선택이라서 쓰는 게 아니기 때문입니다.
다음은 제 블로그 홈의 글 목록 코드입니다. 흔히 보는 형태입니다.
const [visibleCount, setVisibleCount] = useState(POSTS_PER_PAGE) const visiblePosts = posts.slice(0, visibleCount) // ... <button onClick={() => setVisibleCount((prev) => prev + POSTS_PER_PAGE)}> Load More </button>
POSTS_PER_PAGE는 6입니다. 처음에는 6편만 그리고, 버튼을 누를 때마다 6편씩 늘립니다.
스크린샷: 홈 화면 하단. 글 6편이 끝나는 지점에 Load More 버튼이 있다
이 방식의 이점은 분명합니다. 페이지 이동이 없으니 스크롤 위치가 그대로 남고, 라우팅도 필요 없습니다. ?page=2 같은 URL을 설계하지 않아도 되고, 상태는 component 안에서 끝납니다. 서버는 글 전체를 한 번만 내려주면 됩니다.
정리하면 사용자 경험과 구현 편의 양쪽에서 합리적인 선택입니다. 문제는 이 화면을 보는 대상이 사람만이 아니라는 데 있습니다.
크롤러가 보는 것은 첫 HTML뿐
view-source로 홈 페이지의 HTML을 열어 글 링크를 세어 봤습니다. 여섯 개였습니다.
당연합니다. 서버가 그려서 보내는 것은 visibleCount가 아직 6인 상태의 화면입니다. 나머지 열일곱 편은 버튼을 누른 뒤에야 DOM에 들어옵니다.
크롤러는 이 버튼을 누르지 않습니다. 검색엔진은 HTML을 받아 그 안의 <a href>를 따라가며 사이트를 돌아다닙니다. 링크가 없는 곳으로는 갈 수 없습니다.
Google이 JavaScript를 실행하지 못하는 것은 아닙니다. 다만 렌더링은 별도 대기열에서 나중에 처리되고, 그마저도 클릭 같은 상호작용은 재현하지 않습니다. 버튼을 눌러야 나타나는 콘텐츠는 그 대상이 아닙니다.
이 패턴을 넣을 때 저는 SEO를 전혀 생각하지 못했습니다.
useStateslice 조합이 워낙 익숙해서, 이게 HTML에 무엇을 남기는지를 따져 볼 계기가 없었습니다. 화면에서는 스물세 편이 모두 보이니까요.

그러면 sitemap이 있으면 되지 않는가
여기서 자연스럽게 드는 반문이 있습니다. sitemap.xml에 전체 URL을 적어 두면 크롤러가 그걸 보고 찾아가지 않을까요.
어느정도는 맞았습니다. sitemap은 "이런 URL들이 있습니다"를 알리는 통로입니다. 링크로 닿지 않는 페이지를 알리는 데 실제로 쓰입니다. 하지만 sitemap이 하는 일은 거기까지입니다. URL의 존재를 알리는 것과, 크롤러가 그 URL을 가지러 오는 것은 별개입니다.
명단과 추천서의 차이로 볼 수 있습니다. sitemap은 단순한 명단입니다. 우리 사이트에 이런 페이지들이 있다는 목록이죠. 내부 링크는 추천서에 가깝습니다. 사이트 안의 다른 페이지가 이 글을 가리키고 있다는 사실 자체가, 이 글이 사이트에서 어느 정도 위치를 차지하는지를 말해 줍니다.
명단에만 올라 있고 아무도 가리키지 않는 페이지는 순서가 뒤로 밀립니다. 크롤 자원은 무한하지 않기 때문입니다.
순서가 밀린다는 것은 무슨 뜻인가
이 "순서"를 검색엔진 쪽에서는 crawl budget이라는 말로 부릅니다. 여기서 crawl budget이란 검색엔진이 한 사이트를 도는 데 쓰기로 정한 요청량을 말합니다.
budget이라는 단어가 이미 성격을 드러냅니다. 예산이 그렇듯 총량이 미리 정해져 있고, 어디에 쓸지 골라야 합니다. 사이트의 모든 페이지를 매번 다 읽어 주는 것이 아니라는 뜻입니다.
이 총량은 두 가지로 결정됩니다. 하나는 서버가 얼마나 견디는가입니다. 응답이 느리거나 오류가 잦으면 크롤러가 스스로 속도를 늦춥니다. 다른 하나는 검색엔진이 이 사이트를 얼마나 자주 보고 싶어 하는가입니다.
제 경우 첫 번째는 문제가 아니었습니다. 정적 페이지를 CDN에서 내보내니 응답은 충분히 빨랐습니다.
문제는 두 번째였습니다. 이 값은 사이트가 얼마나 자주 갱신되는지, 그리고 바깥에서 얼마나 참조되는지로 정해집니다. 개인 블로그는 대개 후자가 약합니다. 크롤러 입장에서는 굳이 서둘러 다시 올 이유가 없는 사이트인 셈입니다.
그래서 sitemap에 URL을 적어 두는 것만으로는 부족합니다. 명단에 이름을 올려도 예산이 배정되지 않으면 순번이 계속 밀립니다. 내부 링크는 그 예산을 어디에 쓸지 정할 때 참고되는 신호이고, 링크가 없는 페이지는 참고할 근거조차 없는 상태로 남습니다.
Google 문서는 페이지 수천 개 미만의 사이트라면 crawl budget을 걱정할 필요가 없다고 안내합니다. 스물세 편짜리 블로그는 당연히 여기 해당합니다. 그래서 저는 이 개념을 제 사이트와 무관한 것으로 여기고 있었습니다.
다만 그 안내는 "예산이 모자라 페이지를 못 읽는 일은 없다"는 뜻이지, "모든 페이지를 곧 읽는다"는 약속은 아니었습니다.
내부 링크가 실제로 몇 개였는가
Search Console에는 사이트의 링크 구조를 보여주는 화면이 있습니다. 여기서 내부 링크와 외부 링크를 각각 셀 수 있습니다.
스크린샷: Search Console 링크 화면. 외부 링크 총 1개, 내부 링크 총 10개
내부 링크 총 10개였습니다.
스물세 편을 썼는데 사이트 안에서 글을 가리키는 링크가 10개라는 숫자가 이상했습니다. 글마다 최소 한 번씩은 목록에 걸려 있어야 할 텐데, 그 절반도 안 되는 값이었습니다.
앞의 여섯 편 계산과 맞춰 보면 설명이 됩니다. 홈에서 나가는 글 링크가 여섯 개, 거기에 태그 페이지와 시리즈 글 몇 개가 더해진 숫자입니다. 나머지 열일곱 편은 사이트 어디에서도 링크되지 않은 상태였습니다.
색인 리포트를 열어 보니 스무 편이 "발견됨 - 현재 색인이 생성되지 않음" 상태였습니다. 그리고 그 스무 편 모두 최종 크롤링 날짜가 "해당사항 없음"이었습니다.
저는 이 문구를 "크롤링됨 - 현재 색인이 생성되지 않음"과 같은 뜻으로 알고 있었습니다.
둘은 완전히 다릅니다. "크롤링됨"은 Google이 와서 페이지를 읽고 나서 색인에 넣지 않기로 판단한 상태입니다. 품질이나 중복 문제일 수 있습니다. 반면 "발견됨"은 URL의 존재만 알고 아직 가지러 오지 않은 상태입니다.
즉 제 글들은 평가를 받고 떨어진 게 아니라, 아예 읽히지 않았습니다. 고칠 대상이 콘텐츠가 아니라 경로라는 뜻이었죠.

아카이브 페이지라는 해법
경로가 문제라면 경로를 만들면 됩니다. 모든 글을 한 페이지에서 링크하는 아카이브를 따로 두는 방식입니다.
제 블로그에는 태그별 목록 페이지(/tags/react 같은)는 있었지만 그 위의 인덱스가 없었습니다. /tags로 들어가면 아무것도 없었습니다. 그 자리에 태그별로 글을 묶어 나열하는 페이지를 만들었습니다.
다음은 그 페이지의 핵심 부분입니다. 태그마다 section을 만들고 해당 글을 전부 링크합니다.
{groups.map(({ tag, posts: tagPosts }) => ( <section key={tag.slug}> <h2> <Link href={`/tags/${tag.slug}`}>{tag.name}</Link> </h2> <ul> {tagPosts.map((post) => ( <li key={post.pageId}> <Link href={`/${post.slug}`}>{post.title}</Link> </li> ))} </ul> </section> ))}
여기서 중요한 것은 useState가 없다는 점입니다. 잘라내지 않으니 getStaticProps가 넘겨준 글이 전부 HTML에 들어갑니다.
스크린샷: /tags 페이지. 태그별로 글이 나열되어 있다
배포 후 이 페이지의 HTML에서 글 링크를 다시 세어 봤습니다. 스물세 개, 전부 들어 있었습니다. 크롤러가 이 페이지 하나만 읽으면 사이트의 모든 글로 가는 경로를 얻습니다.
기존 태그 페이지 아홉 개도 sitemap에 함께 넣었습니다. 인덱스에서 태그 페이지로, 태그 페이지에서 글로 이어지는 층이 하나 더 생긴 셈입니다.
그래도 "더 보기"를 버릴 필요는 없다
홈의 "더 보기"는 그대로 두었습니다.
바꿀 수도 있었습니다. 서버에서 스물세 편을 모두 그려 보내면 홈 HTML에도 링크가 다 들어갑니다. 하지만 그러면 첫 화면에 필요 없는 카드 열일곱 개가 실려 나가고, 이미지도 그만큼 따라옵니다. 사람이 먼저 보는 화면에서 치르기에는 아까운 비용입니다.
두 화면의 역할을 나누는 편이 낫다고 판단했습니다. 홈은 사람이 최근 글을 훑는 자리이고, 아카이브는 크롤러와 전체 목록을 찾는 사람이 쓰는 자리입니다. 하나의 페이지가 두 요구를 모두 만족시키려다 양쪽 다 어중간해지는 것보다, 목적이 다른 페이지를 하나 더 두는 쪽이 단순합니다.
정리하면 문제는 "더 보기" 패턴 자체가 아니라, 그 패턴만 두고 다른 경로를 만들지 않은 데 있었습니다. 클라이언트 사이드에서 늘어나는 목록은 초기 HTML에 링크를 남기지 않는다는 사실만 알고 있으면, 아카이브 한 장으로 메울 수 있는 문제입니다.
다만 링크 구조를 고쳤다고 색인이 곧바로 늘어나지는 않을 것입니다. 제 글이 여러분께 닿았으려나요? 같은 화면에서 확인한 외부 링크는 한 개였습니다. 크롤러가 사이트를 얼마나 자주 방문할지는 결국 바깥에서 이 사이트를 얼마나 가리키느냐에 달려 있고, 그건 페이지 구조를 손봐서 만들 수 있는 종류의 값이 아닙니다.
경로를 열어 두는 일은 필요조건이지 충분조건이 아니었습니다. 이 화면이 HTML에 무엇을 남기는지부터 확인하는 습관이 생길 것 같습니다.

참고 자료