SPA를 만들면 URL에 #이 붙는다는 인상 때문에, 해시 라우팅은 SEO에 나쁘다는 통념이 아주 널리 퍼져 있다. 반은 맞고 반은 틀리다. 결론부터 말하면, 콘텐츠를 # 뒤에 숨기면 재앙이지만, 필터나 정렬 같은 맥락 상태를 # 뒤에 두는 건 오히려 SEO에 이득이다. 이 손익 계산을 정확히 이해하면 앞 편에서 말한 중복 URL 문제를 아주 깔끔하게 풀 수 있다.
많은 사람이 해시는 무조건 나쁘다 아니면 요즘은 구글이 자바스크립트 잘 읽으니 상관없다 둘 중 하나로 뭉뚱그린다. 둘 다 틀렸다. 정답은 무엇을 해시 뒤에 두느냐에 전적으로 달렸다. 같은 # 기호라도 뒤에 콘텐츠를 두면 독이고 맥락을 두면 약이다. 그 기준선을 이번 편에서 명확히 긋는다.
![[실전 SEO 08] 해시(#) 라우팅은 SEO에 불리할까, SPA 주소의 손익 계산](https://img.thenullpage.com/posts/5562/5562_1_32bb28.webp)
1. 프래그먼트는 서버로 가지 않는다
먼저 반드시 알아야 할 기술적 사실 하나. URL에서 # 뒤에 오는 프래그먼트(fragment)는 브라우저 안에서만 쓰이고 서버로 전송되지 않는다. 즉 서버는 example.com/list#sort=recent 요청을 받아도 #sort=recent 부분을 애초에 보지도 못한다. 네트워크 요청에 그 부분이 실려가지 않기 때문이다.
구글봇도 기본적으로 프래그먼트를 무시하고, # 앞부분만을 하나의 URL로 취급한다. 다시 말해 #sort=recent와 #sort=popular는 구글에게 완전히 동일한 URL이다. 이 성질이 정확히 양날의 검이다. 무언가를 구글에게서 숨기고 싶을 때는 축복이지만, 반대로 보여주고 싶은 걸 여기 두면 그대로 재앙이 된다. 그래서 # 뒤에 무엇을 두느냐가 모든 걸 결정한다.
이 주제에서 사람들이 가장 자주 인용하는 게 요즘 구글은 자바스크립트를 잘 렌더링한다는 말이다. 사실이긴 하지만 오해를 부른다. 구글이 JS를 실행해 준다는 것과, 프래그먼트를 별개 URL로 인식한다는 것은 전혀 다른 얘기다. 렌더링을 아무리 잘해도 # 뒤는 여전히 같은 URL로 취급된다. 이 둘을 섞으면 판단이 어긋난다.
2. 그래서 콘텐츠를 #에 숨기면 안 된다
이 성질 때문에, 실제 콘텐츠를 프래그먼트 뒤에 두면 색인이 안 된다. 예전 해시뱅(#!) 방식 SPA들이 검색에서 통째로 증발했던 이유가 바로 이것이다. 구글이 # 뒤를 안 보니, 글 내용이 거기에만 존재하면 구글은 껍데기만 색인하는 셈이 된다.
그래서 개별 글의 주소는 반드시 서버가 인식하고 서버 사이드 렌더링(SSR)으로 내용을 내려주는 실제 경로여야 한다. 글 URL에 절대 #을 쓰면 안 되는 이유다. 요약하면, 색인시켜야 할 모든 것, 즉 글 본문과 게시판 목록은 # 앞의 진짜 경로에 있어야 하고, 서버가 그 경로에 대해 완성된 HTML을 응답할 수 있어야 한다. 이 조건을 어기는 순간 아무리 콘텐츠가 좋아도 검색에는 존재하지 않는 글이 된다.
프래그먼트가 서버로 안 간다는 성질은 분석 도구에서도 티가 난다. 서버 로그나 기본 설정의 접근 분석에는 # 뒤가 안 남는다. 그래서 필터별 사용량을 추적하고 싶다면 클라이언트 이벤트로 따로 수집해야 한다. 이건 단점이라기보다, 애초에 서버가 신경 쓸 필요 없는 상태라는 방증이기도 하다.
여기서 자주 나오는 반문이 있다. 그럼 앵커 링크처럼 페이지 안의 특정 위치로 가는 해시도 문제냐는 것이다. 그건 괜찮다. 그건 같은 문서 안의 위치 표시일 뿐 별개 콘텐츠가 아니기 때문이다. 문제가 되는 건 서로 다른 콘텐츠나 상태를 # 뒤에 담아 그것으로만 구분하려 할 때다. 위치 표시용 앵커와 라우팅용 해시를 구분하면 혼란이 없다.
3. 반대로 맥락 상태는 #가 이득이다
그런데 바로 그 안 본다는 성질이, 숨기고 싶은 것에는 축복이 된다. 필터, 정렬, 페이지 같은 보기 맥락을 프래그먼트에 담으면, 구글은 이 조합들을 전부 무시하고 # 앞의 클린 URL 하나만 본다. 앞 편에서 그렇게 골치였던 중복 URL이 아예 생기질 않는 것이다.
쿼리 파라미터로 하면 조합마다 새 URL이 생겨 크롤 예산을 태우지만, 프래그먼트로 하면 그 낭비가 원천 차단된다. 내가 필터와 정렬을 물음표에서 해시로 옮긴 이유가 정확히 이것이다. 중복 문서 유출을 막아 크롤 예산과 도메인 품질 양쪽에서 이득을 봤다. 그러면서 사용자 경험은 그대로다. 필터를 걸면 화면은 똑같이 바뀌고 주소도 공유 가능한데, 다만 그 상태가 봇에게만 안 보일 뿐이다. 사람에겐 다 주고 봇에겐 소음만 감추는, 꽤 이상적인 분리다.
콘텐츠는 실주소로 둔다는 원칙을 지키려면, 결국 서버가 각 글 경로에 대해 완성된 HTML을 줄 수 있어야 한다. 그래서 나는 SPA로 화면을 그리더라도 봇과 첫 방문자를 위해 서버가 같은 내용을 SSR로도 내려주게 만들었다. 화면 전환은 클라이언트가, 첫 응답과 색인용 HTML은 서버가 책임지는 이중 구조다.
한 가지 더. 맥락을 해시로 옮기면 캐싱에도 이득이 있다. 서버 입장에선 필터가 뭐든 같은 URL이라 응답을 캐시하기 쉽고 CDN도 한 버전만 저장하면 된다. 파라미터마다 URL이 갈리면 캐시가 조각나 적중률이 떨어진다. SEO뿐 아니라 성능 면에서도 맥락을 서버 URL에서 걷어내는 게 유리하다.
4. 전제, 글 발견 통로는 살아 있어야 한다
단, 결정적인 전제가 하나 있다. 맥락을 #로 숨기더라도, 글 자체를 발견하는 통로는 반드시 크롤 가능한 상태로 남아 있어야 한다. 보통 글 발견 통로는 세 가지다. SSR로 렌더되는 게시판 목록, 크롤 가능한 페이지네이션, 그리고 사이트맵이다. 이 셋이 봇에게 열려 있어야 한다.
이 세 통로가 살아 있으면 필터와 정렬을 #로 재배치해도 글이 사라지지 않는다. 그건 글을 숨기는 게 아니라 단지 보기 상태를 재배치하는 것일 뿐이라 글 손실은 0이다. 반대로 이 통로들까지 자바스크립트 뒤에 숨겨버리면 그때는 정말로 글이 안 걸린다. 그래서 나는 해시 전환을 할 때 반드시 이 세 통로가 봇에게 그대로 열려 있는지부터 확인했다. 맥락을 숨기는 것과 발견 경로를 막는 것은 전혀 다른 얘기이고, 이 둘을 헷갈리면 최적화가 아니라 사고가 된다.
발견 통로 세 가지가 살아 있는지는 봇 관점에서 직접 확인해야 한다. 자바스크립트를 끈 브라우저나 curl로 게시판 목록과 페이지네이션을 열어봐서 링크가 그대로 보이면 통과다. 사람 눈에 보인다고 봇에게도 보이는 게 아니니, 반드시 JS 없는 상태에서 발견 경로가 이어지는지를 눈으로 확인하는 절차를 거쳐야 한다.
발견 통로가 살아 있는지를 확인할 때 사이트맵만 믿으면 안 된다. 사이트맵은 발견을 돕는 보조일 뿐 봇은 여전히 링크를 타고 돌아다닌다. 그래서 게시판 목록과 페이지네이션이 실제 a 태그 링크로 연결돼 있어야 한다. 자바스크립트 onclick으로만 이동하고 진짜 링크가 없으면 사이트맵에 있어도 봇의 크롤 흐름은 거기서 끊긴다. 발견은 링크와 사이트맵 두 축으로 받쳐야 한다.
5. History API와 해시, 무엇을 쓸까
이상적으로는 History API의 pushState로 클린 URL을 쓰는 게 가장 낫다. 프래그먼트 없이 진짜 경로처럼 보이면서 SPA로 동작하기 때문이다. 다만 이건 서버가 그 경로들을 전부 받아 SSR로 응답할 수 있어야 성립한다. 서버 라우팅을 그만큼 갖춰야 한다는 뜻이다.
서버 라우팅까지 손대기 부담스럽고 대상이 색인시킬 필요가 전혀 없는 맥락 상태뿐이라면, 해시 방식이 훨씬 간단하고 안전하다. 요컨대 색인시켜야 할 것은 실주소로, 색인에서 감추고 싶은 맥락은 해시로. 이 한 줄 원칙만 지키면 해시는 적이 아니라 강력한 무기가 된다. 도구 자체에 좋고 나쁨은 없고, 어디에 쓰느냐만 있을 뿐이다. 다음 편부터는 크롤 예산의 또 다른 큰 축, 사이트맵을 제대로 만드는 법으로 넘어간다.
정리하면 해시 논쟁의 답은 좋다 나쁘다가 아니라 무엇을 담느냐다. 색인시킬 것은 실주소, 감출 맥락은 해시. 이 기준선만 있으면 SPA를 쓰면서도 SEO를 지킬 수 있다. 실제로 나는 이 원칙으로 필터의 편의와 크롤 예산 절약을 동시에 얻었다. 도구를 탓하기 전에 어디에 쓰는지를 먼저 점검하는 게 늘 먼저다.