같은 주제를 다룬 페이지가 둘이라면, 대부분의 경우 정답은 덜 충실한 쪽을 더 충실한 쪽으로 301 영구 이동하는 것이다. canonical은 두 페이지를 모두 살려 둘 이유가 있을 때만 쓰고, noindex는 링크 신호를 버리는 선택이며, 삭제는 마지막 수단이다.
구글이 영삼넷 글을 잘 색인하지 않아서 사이트 전체를 점검했다. 그 과정에서 제목이 똑같은 페이지가 수백 개 나왔다. 이 글은 그 중복을 어떤 기준으로 정리하기로 했는지에 대한 기록이다. 같은 점검에서 나온 사이트맵 lastmod 문제와 canonical 상속 문제는 다른 글에서 따로 다룬다.
- 전수 점검에서 제목이 같은 페이지가 203쌍(URL 439개) 나왔고, 대부분은 문장까지 같은 복사본이 아니라 같은 주제를 다른 문장으로 쓴 글이었다.
- 이런 글은 같은 검색어를 놓고 서로 경쟁한다(키워드 카니발리제이션). 나는 쌍마다 더 충실한 쪽을 남기고 나머지를 비공개 + 301로 돌리기로 했다.
- canonical은 힌트일 뿐이라 구글이 무시할 수 있고, noindex는 신호를 모으지 못한다. 그래서 "둘 다 살려야 하는 이유"가 없으면 301이 기본값이다.
PART 01점검에서 나온 숫자: 제목이 같은 페이지 203쌍
점검 방법은 단순했다. 사이트맵 인덱스 아래 자식 사이트맵 다섯 개에 들어 있는 주소 9,709개를 모두 구글봇 User-Agent로 병렬 요청하고, 응답마다 상태 코드, canonical, robots meta, title을 모으는 파이썬 스크립트를 돌렸다. 모은 결과를 title 기준으로 묶어 보니 제목이 똑같은 페이지가 203쌍, URL로는 439개가 나왔다.
그중 다국어 프로그램 페이지는 성격이 다르다. 내용은 언어별로 다른데 제목이 프로그램 이름만 찍혀서 같아 보였던 것이라, 이건 제목을 고치면 되는 문제였다. 그걸 빼고 나면 진짜 정리 대상은 세 묶음이었다.
기술노트 쪽은 같은 글이 n22-, n23- 같은 접두사가 붙은 주소로 한 번 더 올라가 있었다. 예를 들어 "AI 파인튜닝 입문 — LoRA로 나만의 모델 만들기"라는 글이 주소만 다르게 두 개 있었다. 용어사전은 같은 용어가 questdb와 quest-db, valkey와 valkey-fork, replicate와 replicate-platform 식으로 주소 두 개를 갖고 있었다. 커뮤니티 Q&A는 같은 질문이 두 번 올라온 경우였다.
PART 02복사본과 카니발리제이션은 다른 문제다
중복 콘텐츠라고 하면 보통 같은 내용이 여러 주소로 열리는 상황을 떠올린다. 이 경우 구글이 알아서 하나를 대표로 고르고 나머지는 묶어 버리는 경우가 많다. Search Console에서는 "중복 페이지, 사용자가 표준 페이지를 선택하지 않음" 같은 상태로 보인다. 내가 대표 주소를 정해 주지 않았으니 구글이 대신 골랐다는 뜻이다.
키워드 카니발리제이션은 결이 조금 다르다. 내용은 다르지만 노리는 검색어가 같은 페이지 둘이 서로의 자리를 뺏는다. 각자 반쯤 쓸 만한 글이라 신호가 둘로 쪼개지고, 어느 쪽도 한 페이지로 모였을 때만큼 힘을 받지 못한다. 나는 이걸 구분하려고 제목이 같은 쌍마다 본문 유사도를 쟀다. 무거운 도구 없이 단어 몇 개씩 묶은 shingle을 만들어 자카드 유사도를 보는 정도로 충분했다.
// 제목이 같은 쌍을 찾고 본문 유사도(shingle 자카드)를 매긴다
const plain = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
function shingles(text, k = 4) {
const words = plain(text).toLowerCase().split(' ')
const set = new Set()
for (let i = 0; i + k <= words.length; i++) set.add(words.slice(i, i + k).join(' '))
return set
}
function jaccard(a, b) {
let inter = 0
for (const s of a) if (b.has(s)) inter++
return inter / (a.size + b.size - inter || 1)
}
// rows: SELECT id, slug, title, content FROM "Post" WHERE published
function findPairs(rows) {
const byTitle = new Map()
for (const r of rows) byTitle.set(r.title, [...(byTitle.get(r.title) || []), r])
const out = []
for (const group of byTitle.values()) {
if (group.length < 2) continue
const [a, b] = group
out.push({ title: a.title, a: a.slug, b: b.slug, sim: jaccard(shingles(a.content), shingles(b.content)) })
}
return out.sort((x, y) => y.sim - x.sim)
}유사도가 높은 쌍은 사실상 복사본이고, 낮은 쌍은 같은 주제를 다르게 쓴 글이다. 내가 놀란 건 낮은 쪽이 훨씬 많았다는 점이다. 다만 이 숫자를 처리 방식을 가르는 기준으로 쓰지는 않았다. 복사본이든 다른 문장이든 같은 검색어를 놓고 겹친다는 사실은 같기 때문이다. 유사도는 "얼마나 심각하게 겹쳤나"를 이해하는 용도였다.
제목만으로 묶으면 놓치는 쌍도 있다. 제목은 다르지만 사실상 같은 주제인 글이 그렇다. 그건 이번 점검 범위 밖이었고, 일단 제목이 똑같은 확실한 것부터 치우기로 했다.
PART 03네 가지 선택지와 상황별 판단표
겹치는 페이지를 정리하는 방법은 크게 네 가지다. 301 영구 이동, canonical 지정, noindex, 그리고 두 글을 하나로 합치는 병합. 여기에 삭제(404나 410)가 마지막으로 붙는다. 각각 무엇을 남기고 무엇을 버리는지가 다르다.
canonical을 기본값으로 쓰는 사람이 많은데, 나는 반대다. canonical은 구글 공식 문서에서도 강한 신호이긴 하지만 힌트로 다룬다. 다른 신호와 어긋나면 구글이 내가 지정한 대표를 무시하고 다른 쪽을 고를 수 있다. 게다가 canonical을 단 페이지는 사람이 계속 들어와서 볼 수 있으니, 같은 주제 글 두 개가 사이트 안에 그대로 남는다. 내부 링크도 둘로 쪼개진 채다.
noindex는 더 소극적이다. 색인에서 빠지긴 하지만 그 페이지가 받던 링크 신호를 다른 페이지로 넘겨주지 않는다. 그리고 noindex가 오래 유지된 페이지는 구글이 점점 덜 크롤링한다고 알려져 있어서, 그 안의 링크도 힘을 잃는다. 사람에겐 필요하고 검색엔진에겐 필요 없는 페이지, 예를 들어 검색 결과 페이지 같은 곳에 쓰는 도구다.
PART 04남길 쪽을 고르는 기준
301로 정한 다음 문제는 "어느 쪽을 남기나"다. 쌍이 170개가 넘으니 하나하나 읽고 고를 수는 없었다. 그래서 기계적으로 적용할 수 있는 순서를 정했다. 사람이 판단해야 하는 부분을 최대한 줄이는 게 목적이었다.
이렇게 하면 대상은 URL 173개다(기술노트 18 + 용어사전 81 + 커뮤니티 74). 비공개로 돌린 글은 공개 목록, 사이트맵, 내부 링크 생성 로직에서 자연스럽게 빠진다. 별도로 사이트맵을 손볼 필요가 없다는 게 이 방식의 장점이다. 이 글을 쓰는 지금 정리를 진행하는 중이다.
병합을 기본으로 하지 않은 이유는 솔직히 손이 너무 많이 들어서다. 병합이 가장 좋은 결과를 내는 건 맞다. 두 글의 좋은 문단을 합쳐 하나를 만들면 사람에게도 검색엔진에게도 제일 낫다. 하지만 173개를 사람 손으로 합치는 건 현실적이지 않았다. 그래서 우선 301로 경쟁부터 끝내고, 조회가 많은 주제는 나중에 남긴 글을 보강하는 순서로 가기로 했다.
PART 05Next.js에서 이동 목록으로 301 처리하기
영삼넷은 Next.js App Router에 Prisma와 PostgreSQL을 쓴다. 이동은 next.config의 redirects에 수백 줄을 박아 넣는 대신, 상세 페이지에서 글을 찾지 못했을 때 이동 목록을 확인하는 식으로 처리했다. App Router에서는 permanentRedirect()를 부르면 영구 이동 응답이 나간다.
// lib/redirect-map.ts — 정리 작업이 만든 이동 목록(파일로 보관, 되돌리기 쉬움)
import map from '@/data/merged-redirects.json' // { "n22-ai-finetuning-lora": "ai-finetuning-lora", ... }
export const mergedTo = (slug: string): string | undefined => (map as Record<string, string>)[slug]
// app/notes/[slug]/page.tsx
import { notFound, permanentRedirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { mergedTo } from '@/lib/redirect-map'
export default async function NotePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await prisma.post.findFirst({ where: { slug, published: true } })
if (!post) {
const target = mergedTo(slug)
if (target) permanentRedirect('/notes/' + encodeURIComponent(target))
notFound()
}
return <Article post={post} />
}이 방식의 좋은 점은 이동 목록이 데이터라는 것이다. 판단을 뒤집고 싶으면 목록에서 한 줄 빼고 글을 다시 공개하면 끝난다. 이동 대상이 다시 이동되는 체인도 목록을 만들 때 한 번에 검사할 수 있다. 목록 생성 스크립트에서 "값이 다시 키로 등장하는지"만 보면 된다.
generateMetadata를 쓰는 페이지라면 그쪽에서도 같은 조건으로 이동을 걸어 두는 게 안전하다. 메타데이터 단계에서 글을 못 찾고 엉뚱한 404 메타를 만들기 전에 이동시키는 편이 깔끔하다.
PART 06정리 전에 보는 체크리스트
같은 작업을 다시 하게 되면 아래 순서로 볼 생각이다. 특히 "지우지 않는다"와 "되돌릴 수 있게 남긴다"는 몇 번을 강조해도 지나치지 않다. 대량 정리는 판단 기준이 하나만 틀려도 수십 개가 한꺼번에 잘못 처리된다.
마지막 항목이 생각보다 중요하다. 이번 점검 전체를 다시 돌렸을 때 9,709개 주소가 모두 200에 자기 자신을 canonical로 갖는지 확인했고, 그 과정에서 예외 하나를 찾아 고쳤다. 정리 작업이 다른 곳을 건드리지 않았다는 걸 숫자로 확인하는 단계다.
- 같은 주제 글 두 개는 기본적으로 약한 쪽을 강한 쪽으로 301 영구 이동한다.
- canonical은 두 페이지를 모두 유지할 이유가 있을 때만 쓴다. 구글은 그것을 힌트로 다룬다.
- noindex는 색인에서 빼 줄 뿐 신호를 모아 주지 않는다. 중복 정리 도구로는 약하다.
- 삭제 대신 비공개 + 이동 목록 파일로 처리해서 언제든 되돌릴 수 있게 한다.
- 정리 후에는 전체를 다시 크롤링해서 숫자로 확인한다.
자주 묻는 질문
같은 주제의 블로그 글이 두 개 있으면 SEO에 불리한가요?
불리할 가능성이 높다. 두 글이 같은 검색어를 놓고 경쟁하면 신호가 둘로 나뉘고, 구글이 어느 쪽을 대표로 보여 줄지 흔들린다. 이를 키워드 카니발리제이션이라고 부르며, 한쪽으로 합치면 대체로 나아진다.
중복 페이지는 canonical과 301 중 무엇을 써야 하나요?
두 페이지를 모두 사람에게 보여 줘야 할 이유가 없다면 301이 낫다. canonical은 구글이 힌트로 다루기 때문에 따르지 않을 수 있고, 같은 내용의 페이지가 사이트에 그대로 남는다. 인쇄용 페이지처럼 둘 다 필요한 경우에만 canonical을 쓴다.
noindex로 중복 글을 숨기면 안 되나요?
색인에서는 빠지지만 그 페이지가 받던 링크 신호가 남긴 글로 넘어가지 않는다. 중복 정리 목적이라면 301로 신호를 한 주소에 모으는 편이 낫다. noindex는 사람에겐 필요하지만 검색에 나올 이유가 없는 페이지에 맞다.
Search Console의 "중복 페이지, 사용자가 표준 페이지를 선택하지 않음"은 무슨 뜻인가요?
내용이 비슷한 페이지가 여러 개 있는데 대표 주소를 지정하지 않아서 구글이 직접 하나를 골랐다는 뜻이다. 구글이 고른 주소가 내가 원하는 주소와 다를 수 있으니, 301이나 canonical로 대표를 명확히 해 주는 게 좋다.
301 리디렉션은 언제까지 유지해야 하나요?
가능하면 계속 유지하는 게 안전하다. 외부 사이트의 옛 링크나 북마크는 오래 남는다. 이동 목록을 데이터로 관리하면 유지 비용이 거의 없고, 체인이 생기지 않도록 목록을 갱신만 해 주면 된다.

댓글 0