본문 바로가기
Etc2026년 10월 12일18분 읽기

같은 주제 글이 두 개일 때 — 301·canonical·noindex·병합 선택 기준

YS
김영삼
조회 9
같은 주제 글이 두 개일 때 — 301·canonical·noindex·병합 선택 기준

같은 주제를 다룬 페이지가 둘이라면, 대부분의 경우 정답은 덜 충실한 쪽을 더 충실한 쪽으로 301 영구 이동하는 것이다. canonical은 두 페이지를 모두 살려 둘 이유가 있을 때만 쓰고, noindex는 링크 신호를 버리는 선택이며, 삭제는 마지막 수단이다.

구글이 영삼넷 글을 잘 색인하지 않아서 사이트 전체를 점검했다. 그 과정에서 제목이 똑같은 페이지가 수백 개 나왔다. 이 글은 그 중복을 어떤 기준으로 정리하기로 했는지에 대한 기록이다. 같은 점검에서 나온 사이트맵 lastmod 문제와 canonical 상속 문제는 다른 글에서 따로 다룬다.

3줄 요약
  • 전수 점검에서 제목이 같은 페이지가 203쌍(URL 439개) 나왔고, 대부분은 문장까지 같은 복사본이 아니라 같은 주제를 다른 문장으로 쓴 글이었다.
  • 이런 글은 같은 검색어를 놓고 서로 경쟁한다(키워드 카니발리제이션). 나는 쌍마다 더 충실한 쪽을 남기고 나머지를 비공개 + 301로 돌리기로 했다.
  • canonical은 힌트일 뿐이라 구글이 무시할 수 있고, noindex는 신호를 모으지 못한다. 그래서 "둘 다 살려야 하는 이유"가 없으면 301이 기본값이다.

PART 01점검에서 나온 숫자: 제목이 같은 페이지 203쌍

점검 방법은 단순했다. 사이트맵 인덱스 아래 자식 사이트맵 다섯 개에 들어 있는 주소 9,709개를 모두 구글봇 User-Agent로 병렬 요청하고, 응답마다 상태 코드, canonical, robots meta, title을 모으는 파이썬 스크립트를 돌렸다. 모은 결과를 title 기준으로 묶어 보니 제목이 똑같은 페이지가 203쌍, URL로는 439개가 나왔다.

그중 다국어 프로그램 페이지는 성격이 다르다. 내용은 언어별로 다른데 제목이 프로그램 이름만 찍혀서 같아 보였던 것이라, 이건 제목을 고치면 되는 문제였다. 그걸 빼고 나면 진짜 정리 대상은 세 묶음이었다.

203쌍
제목이 똑같은 페이지(URL 439개)
17묶음
기술노트 — 같은 글이 접두사 붙은 주소로 한 번 더(한 주제는 세 번, 이동 대상 18개)
81쌍
용어사전 — 같은 용어가 주소 두 개
74쌍
커뮤니티 Q&A — 같은 질문 두 번

기술노트 쪽은 같은 글이 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)가 마지막으로 붙는다. 각각 무엇을 남기고 무엇을 버리는지가 다르다.

중복·카니발리제이션 정리 판단표
상황고를 것이유
같은 글이 주소만 다르게 두 개301사람도 검색엔진도 한 주소로 모인다. 옛 주소로 들어온 링크도 살아남는다.
같은 주제, 한쪽이 확실히 더 충실함301약한 글의 신호를 강한 글로 넘기고 경쟁을 끝낸다.
두 글이 각자 쓸모 있는 내용을 나눠 가짐병합 후 301가장 좋은 결과지만 손이 많이 든다. 내용을 합친 뒤 남은 주소를 이동.
둘 다 사람에게 보여야 함(인쇄용, 필터 조합 등)canonical페이지는 유지하고 색인 대표만 지정. 단, 구글이 따르지 않을 수 있다.
사람에겐 필요하지만 검색에 나올 이유가 없음noindex색인에서만 빠진다. 다른 페이지로 신호를 모아 주지는 않는다.
내용이 틀렸거나 쓸모가 없음삭제(410/404)마지막 수단. 들어오던 링크와 신호가 함께 사라진다.

canonical을 기본값으로 쓰는 사람이 많은데, 나는 반대다. canonical은 구글 공식 문서에서도 강한 신호이긴 하지만 힌트로 다룬다. 다른 신호와 어긋나면 구글이 내가 지정한 대표를 무시하고 다른 쪽을 고를 수 있다. 게다가 canonical을 단 페이지는 사람이 계속 들어와서 볼 수 있으니, 같은 주제 글 두 개가 사이트 안에 그대로 남는다. 내부 링크도 둘로 쪼개진 채다.

영삼
내 생각
canonical은 "두 페이지를 모두 살려 둬야 하는 이유"가 분명할 때만 쓴다. 이유를 한 문장으로 말하지 못하면 301이 맞다. 같은 주제의 블로그 글 두 개는 대부분 그 이유가 없다.

noindex는 더 소극적이다. 색인에서 빠지긴 하지만 그 페이지가 받던 링크 신호를 다른 페이지로 넘겨주지 않는다. 그리고 noindex가 오래 유지된 페이지는 구글이 점점 덜 크롤링한다고 알려져 있어서, 그 안의 링크도 힘을 잃는다. 사람에겐 필요하고 검색엔진에겐 필요 없는 페이지, 예를 들어 검색 결과 페이지 같은 곳에 쓰는 도구다.

302는 쓰지 말 것. 302는 "잠깐 옮겼다, 곧 돌아온다"는 뜻의 임시 이동이다. 영구히 합칠 생각이라면 301(또는 308)을 써야 검색엔진이 새 주소를 대표로 받아들인다. 또 A→B로 보냈는데 나중에 B를 다시 C로 보내면 A→B→C 체인이 생긴다. 옛 이동 목록을 갱신해서 A가 바로 C로 가게 해야 한다.

PART 04남길 쪽을 고르는 기준

301로 정한 다음 문제는 "어느 쪽을 남기나"다. 쌍이 170개가 넘으니 하나하나 읽고 고를 수는 없었다. 그래서 기계적으로 적용할 수 있는 순서를 정했다. 사람이 판단해야 하는 부분을 최대한 줄이는 게 목적이었다.

1
본문이 긴 쪽을 남긴다
노트와 용어사전은 태그를 뺀 본문 길이로 비교했다. 같은 주제라면 대체로 긴 쪽이 더 많은 질문에 답한다.
2
Q&A는 답변이 많은 쪽
커뮤니티 질문은 본문 길이보다 답변 수가 가치를 결정한다. 질문 글이 길어도 답이 없으면 남길 이유가 약하다.
3
동률이면 조회수
그래도 갈리지 않으면 실제로 사람이 더 많이 본 쪽을 남긴다. 이미 외부에서 링크를 더 받았을 가능성이 높다.
4
나머지는 지우지 않고 비공개
진 쪽은 삭제하지 않고 비공개로 돌린다. 데이터는 남아 있으니 판단이 틀렸으면 되살릴 수 있다.
5
그 주소를 남긴 글로 301
비공개가 된 주소로 들어오면 남긴 글로 영구 이동시킨다. 이동 목록은 파일로 따로 남겨 언제든 되돌릴 수 있게 한다.

이렇게 하면 대상은 URL 173개다(기술노트 18 + 용어사전 81 + 커뮤니티 74). 비공개로 돌린 글은 공개 목록, 사이트맵, 내부 링크 생성 로직에서 자연스럽게 빠진다. 별도로 사이트맵을 손볼 필요가 없다는 게 이 방식의 장점이다. 이 글을 쓰는 지금 정리를 진행하는 중이다.

병합을 기본으로 하지 않은 이유는 솔직히 손이 너무 많이 들어서다. 병합이 가장 좋은 결과를 내는 건 맞다. 두 글의 좋은 문단을 합쳐 하나를 만들면 사람에게도 검색엔진에게도 제일 낫다. 하지만 173개를 사람 손으로 합치는 건 현실적이지 않았다. 그래서 우선 301로 경쟁부터 끝내고, 조회가 많은 주제는 나중에 남긴 글을 보강하는 순서로 가기로 했다.

PART 05Next.js에서 이동 목록으로 301 처리하기

영삼넷은 Next.js App Router에 Prisma와 PostgreSQL을 쓴다. 이동은 next.config의 redirects에 수백 줄을 박아 넣는 대신, 상세 페이지에서 글을 찾지 못했을 때 이동 목록을 확인하는 식으로 처리했다. App Router에서는 permanentRedirect()를 부르면 영구 이동 응답이 나간다.

상세 페이지에서 이동 목록 확인 후 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 메타를 만들기 전에 이동시키는 편이 깔끔하다.

이동 후 확인은 스크립트로. 이동 목록의 옛 주소를 전부 요청해서 응답이 301(또는 308)인지, Location이 남긴 글의 주소인지, 그 주소가 200에 자기 자신을 canonical로 갖는지까지 한 번에 본다. 사람이 브라우저로 몇 개 눌러 보는 건 확인이 아니다.

PART 06정리 전에 보는 체크리스트

같은 작업을 다시 하게 되면 아래 순서로 볼 생각이다. 특히 "지우지 않는다"와 "되돌릴 수 있게 남긴다"는 몇 번을 강조해도 지나치지 않다. 대량 정리는 판단 기준이 하나만 틀려도 수십 개가 한꺼번에 잘못 처리된다.

중복 정리 체크리스트
✓사이트맵 전체를 크롤링해 title·canonical·상태 코드를 모았다
✓제목이 같은 쌍을 묶고, 성격이 다른 묶음(다국어 제목 문제 등)은 따로 뺐다
✓쌍마다 유사도를 재서 복사본인지 다른 문장인지 파악했다
✓남길 쪽 기준(본문 길이, 답변 수, 조회수)을 문서로 정하고 기계적으로 적용했다
✓진 쪽은 삭제하지 않고 비공개로 돌렸다
✓이동 목록을 파일로 남기고, 체인이 없는지 검사했다
✓302가 아니라 301/308로 나가는지 확인했다
✓정리 후 다시 크롤링해 남긴 글이 200 + 자기 자신 canonical인지 봤다

마지막 항목이 생각보다 중요하다. 이번 점검 전체를 다시 돌렸을 때 9,709개 주소가 모두 200에 자기 자신을 canonical로 갖는지 확인했고, 그 과정에서 예외 하나를 찾아 고쳤다. 정리 작업이 다른 곳을 건드리지 않았다는 걸 숫자로 확인하는 단계다.

영삼
내 생각
이번 청소의 진짜 원인은 기술이 아니라 내 글쓰기 습관이었다. 한동안 많이 쓰는 데 집중했고, 그 결과 같은 주제를 두 번, 같은 용어를 두 주소로 만들었다. 많이 쓰는 것보다 겹치지 않게 쓰는 것이 훨씬 중요하다. 대량으로 만든 글은 결국 이런 청소 비용을 남긴다. 그래서 이제는 글을 올리는 발행 스크립트가 같은 제목이 이미 있으면 멈추게 해 두었다.
중복 페이지를 정리하며 세운 원칙
  1. 같은 주제 글 두 개는 기본적으로 약한 쪽을 강한 쪽으로 301 영구 이동한다.
  2. canonical은 두 페이지를 모두 유지할 이유가 있을 때만 쓴다. 구글은 그것을 힌트로 다룬다.
  3. noindex는 색인에서 빼 줄 뿐 신호를 모아 주지 않는다. 중복 정리 도구로는 약하다.
  4. 삭제 대신 비공개 + 이동 목록 파일로 처리해서 언제든 되돌릴 수 있게 한다.
  5. 정리 후에는 전체를 다시 크롤링해서 숫자로 확인한다.

자주 묻는 질문

같은 주제의 블로그 글이 두 개 있으면 SEO에 불리한가요?

불리할 가능성이 높다. 두 글이 같은 검색어를 놓고 경쟁하면 신호가 둘로 나뉘고, 구글이 어느 쪽을 대표로 보여 줄지 흔들린다. 이를 키워드 카니발리제이션이라고 부르며, 한쪽으로 합치면 대체로 나아진다.

중복 페이지는 canonical과 301 중 무엇을 써야 하나요?

두 페이지를 모두 사람에게 보여 줘야 할 이유가 없다면 301이 낫다. canonical은 구글이 힌트로 다루기 때문에 따르지 않을 수 있고, 같은 내용의 페이지가 사이트에 그대로 남는다. 인쇄용 페이지처럼 둘 다 필요한 경우에만 canonical을 쓴다.

noindex로 중복 글을 숨기면 안 되나요?

색인에서는 빠지지만 그 페이지가 받던 링크 신호가 남긴 글로 넘어가지 않는다. 중복 정리 목적이라면 301로 신호를 한 주소에 모으는 편이 낫다. noindex는 사람에겐 필요하지만 검색에 나올 이유가 없는 페이지에 맞다.

Search Console의 "중복 페이지, 사용자가 표준 페이지를 선택하지 않음"은 무슨 뜻인가요?

내용이 비슷한 페이지가 여러 개 있는데 대표 주소를 지정하지 않아서 구글이 직접 하나를 골랐다는 뜻이다. 구글이 고른 주소가 내가 원하는 주소와 다를 수 있으니, 301이나 canonical로 대표를 명확히 해 주는 게 좋다.

301 리디렉션은 언제까지 유지해야 하나요?

가능하면 계속 유지하는 게 안전하다. 외부 사이트의 옛 링크나 북마크는 오래 남는다. 이동 목록을 데이터로 관리하면 유지 비용이 거의 없고, 체인이 생기지 않도록 목록을 갱신만 해 주면 된다.

참고 자료

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록