처음에는 canonical 태그만 넣으면 될 줄 알았습니다. 같은 게시글이 여러 주소로 열려도 <link rel="canonical">이 정답 주소를 가리키고 있으면 검색엔진이 알아서 정리해줄 거라고 생각했습니다.
그런데 서치콘솔을 보니 중복 URL 보고가 계속 남아 있었습니다. 더 확인해보니 문제는 canonical 태그 자체가 아니었습니다. 잘못된 URL도 여전히 200 OK로 정상 출력되고 있었습니다.
이 글은 canonical 태그만 넣는 방법을 설명하는 글이 아닙니다. 같은 게시글이 여러 주소로 색인되던 문제를 DB에 저장된 실제 소속 정보로 검증하고, 잘못된 주소를 301 리다이렉트로 정규 URL에 합친 과정을 정리한 글입니다.
canonical만으로 부족했던 상황
검색엔진은 URL을 기준으로 페이지를 구분합니다. 본문 내용이 같더라도 주소가 다르면 서로 다른 페이지처럼 볼 수 있습니다.
그래서 처음에는 페이지마다 canonical 태그를 넣는 것으로 충분하다고 생각했습니다.
<link rel="canonical" href="https://example.com/siteA/board/721/1110">하지만 실제 운영에서는 조금 달랐습니다. canonical은 “이 주소를 대표로 봐달라”는 신호에 가깝습니다. 잘못된 URL이 계속 200 OK로 열리는 상태라면 검색엔진은 그 주소를 계속 발견하고 다시 판단합니다.
즉, canonical은 필요하지만 모든 중복 URL 문제를 혼자 해결해주지는 않았습니다.
쉽게 말하면 canonical은 검색엔진에게 대표 주소를 알려주는 표시입니다. 반면 301 리다이렉트는 잘못된 주소로 들어온 사용자와 검색엔진을 실제 대표 주소로 직접 보내는 처리입니다.
같은 게시글이 여러 주소로 열린 예
문제가 된 주소는 이런 식이었습니다.
/siteA/board/721/1110 ← 실제 소속
/siteB/board/856/1110 ← 다른 코너에서 접근
detail.php?post=1110&table=board_721
detail.php?post=1110&table=board_721&page=&start=&mode=0게시글 ID는 같은데 들어온 경로가 달랐습니다. 목록에서 들어가느냐, 검색에서 들어가느냐, 예전 링크로 들어오느냐에 따라 URL이 달라졌습니다.
문제는 어느 주소로 들어가도 글이 정상 출력됐다는 점입니다. 사용자 입장에서는 친절한 동작처럼 보일 수 있지만, 검색엔진 입장에서는 같은 글이 여러 페이지로 존재하는 구조가 됩니다.
특히 /siteB/board/856/1110처럼 실제 소속이 아닌 게시판 경로에서도 글이 열리는 것이 문제였습니다. URL에 적힌 게시판 번호와 글의 실제 게시판 번호가 달라도 서버가 그대로 보여주고 있었습니다.
서치콘솔에 남은 중복 URL 흔적
서치콘솔 페이지 보고서에서 중복 관련 항목이 계속 남았습니다. 구글이 대표 주소를 직접 고르는 상황이었고, 제가 원하는 주소가 아닌 URL이 후보로 잡히는 경우도 있었습니다.
이 문제는 서버 오류처럼 바로 티가 나지 않습니다. 페이지는 잘 열리고, 글도 보이고, 방문자도 큰 불편을 느끼지 않습니다. 그래서 검색 결과에 이상한 주소가 보이거나 서치콘솔 보고가 쌓인 뒤에야 알게 됩니다.
확인은 아래 순서로 했습니다.
- 서치콘솔에서 중복으로 잡힌 URL 확인
- 같은 게시글 ID가 다른 경로에서도 열리는지 직접 접속
- 개발자 도구 네트워크 탭에서
200 OK인지301인지 확인 - 페이지 소스에서 canonical URL 확인
- DB에 저장된 실제 게시판 번호와 URL의 게시판 번호 비교
확인해보니 핵심은 분명했습니다. canonical은 있어도 잘못된 URL이 계속 200 OK로 열리고 있었습니다.
정답 주소는 URL이 아니라 DB 값 기준
해결 기준은 URL이 아니라 DB에 있었습니다. 게시글 레코드에는 이미 이 글이 어느 게시판에 속해 있는지, 어떤 소유자 아래에 있는지 저장되어 있었습니다.
그래서 URL에 적힌 값을 그대로 믿지 않고, DB에 저장된 실제 값과 비교했습니다. 둘이 다르면 사용자가 잘못된 주소로 들어온 것이고, 실제 소속을 기준으로 만든 정규 주소로 301 리다이렉트했습니다.
<?php
$stmt = $pdo->prepare(
'SELECT board_no, owner_id FROM posts WHERE id = ? LIMIT 1'
);
$stmt->execute([$postId]);
$post = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$post) {
http_response_code(404);
exit;
}
if ((int)$post['board_no'] !== (int)$urlBoardNo
|| $post['owner_id'] !== $urlOwnerId) {
$correct = sprintf(
'https://example.com/%s/board/%d/%d',
rawurlencode($post['owner_id']),
$post['board_no'],
$postId
);
header('Location: ' . $correct, true, 301);
exit;
}
?>이 코드가 하는 일은 단순합니다. 게시글 ID로 실제 소속 정보를 가져오고, URL에 들어온 게시판 번호와 소유자 값이 DB 값과 같은지 비교합니다. 다르면 현재 주소는 정규 주소가 아니므로 DB 값을 기준으로 다시 만든 주소로 이동시킵니다.
여기서 중요한 점은 URL을 정답으로 믿지 않는 것입니다. URL은 사용자가 직접 바꿀 수도 있고, 예전 링크나 잘못 만든 내부 링크를 통해 들어올 수도 있습니다. 정답은 게시글이 실제로 저장된 데이터에 있습니다.
예전 쿼리스트링 주소도 301로 통합
예전 쿼리스트링 주소는 외부 링크가 남아 있을 수 있습니다. 그래서 무조건 404로 막기보다는, 가능한 경우 정규 주소로 보내는 편이 낫다고 판단했습니다.
<?php
if (isset($_GET['table'], $_GET['post'])
&& preg_match('/_(\d+)$/', $_GET['table'], $m)) {
$correct = sprintf(
'https://example.com/%s/board/%d/%d',
rawurlencode($ownerId),
(int)$m[1],
(int)$_GET['post']
);
header('Location: ' . $correct, true, 301);
exit;
}
?>예를 들어 detail.php?post=1110&table=board_721로 들어오면 테이블명에서 게시판 번호를 뽑아 새 주소 체계로 보냅니다.
이미 외부에 퍼진 주소라면 끊어내는 것보다 합치는 편이 좋았습니다. 사용자는 기존 링크로 들어와도 글을 볼 수 있고, 검색엔진에는 새 대표 주소를 알려줄 수 있습니다.
DB 검증 코드를 넣는 위치
소유권 검증을 넣을 때는 코드 위치도 중요했습니다. DB 함수나 $pdo 연결이 필요한 검증은 반드시 공통 파일 include 이후에 놓아야 합니다.
처음에는 이 코드를 파일 맨 위에 넣었다가 undefined function이나 DB 연결 오류를 만났습니다.
1. DB가 필요 없는 기본 검사
- 숫자 여부
- 빈 값 여부
- 파라미터 형식
2. 공통 파일 include
- DB 연결
- 공통 함수
- 설정값 로딩
3. DB가 필요한 검증
- 게시글 존재 확인
- 실제 게시판 번호 비교
- 소유자 값 비교
4. 잘못된 URL이면 301 이동SEO 수정이라고 해서 <head> 태그만 고치는 것이 아니었습니다. 기존 PHP 파일의 로딩 순서와 공통 함수 구조까지 같이 봐야 했습니다.
canonical과 301의 역할 분리
중복 URL 정리에서는 canonical과 301을 같은 역할로 보면 안 됩니다.
| 항목 | 역할 | 적합한 상황 |
|---|---|---|
| canonical | 대표 주소를 알려주는 신호 | 비슷한 페이지가 열릴 수 있지만 대표 URL을 지정할 때 |
| 301 | 실제 주소를 정규 URL로 이동 | 잘못된 URL을 더 이상 200으로 열지 않을 때 |
| 404 | 페이지 없음 | 대응할 실제 글이 없을 때 |
| 410 | 영구 삭제 | 다시 살릴 계획이 없는 삭제 URL |
같은 글이 여러 주소로 열리는 문제라면 canonical만 넣고 끝내기보다, 잘못된 주소가 실제로 301 이동하는지까지 확인하는 편이 안전했습니다.
위험한 사이트 구조
아래에 해당하는 사이트라면 같은 글이 여러 URL로 열리고 있을 가능성이 있습니다.
- 게시판이나 카테고리 구조를 여러 번 개편한 사이트
- 예전
detail.php?id=...형태의 주소와 새 주소가 같이 남아 있는 사이트 - 글 ID 하나로 여러 게시판 경로에서 접근할 수 있는 사이트
- 작성자 페이지, 검색 결과, 태그 페이지에서 상세 URL을 따로 만드는 사이트
- 서치콘솔에서 중복 URL 보고가 늘어나는 사이트
- canonical 태그는 넣었지만 잘못된 URL도 여전히
200 OK로 열리는 사이트
이런 경우에는 canonical 태그만 확인하지 말고 실제 응답 코드까지 확인해야 합니다.
수정 후 확인한 응답 코드
수정 후에는 “페이지가 보인다”에서 끝내지 않았습니다. 같은 글이 실제로 하나의 주소로 모이는지 확인했습니다.
curl -I https://example.com/siteA/board/721/1110
curl -I https://example.com/siteB/board/856/1110
curl -I "https://example.com/detail.php?post=1110&table=board_721"원하는 결과는 아래와 같았습니다.
/siteA/board/721/1110 → 200 OK
/siteB/board/856/1110 → 301 → /siteA/board/721/1110
detail.php?post=1110&table=board_721 → 301 → /siteA/board/721/1110정규 URL의 canonical도 자기 자신을 가리키는지 확인했습니다.
<link rel="canonical" href="https://example.com/siteA/board/721/1110">잘못된 URL에서 canonical만 보이는 것이 아니라 실제로 301 이동하는지가 핵심이었습니다.
적용 후 달라진 점
수정 후에는 게시글 하나당 대표 주소가 하나로 정리됐습니다.
- 실제 소속 게시판 URL만
200 OK로 유지 - 다른 게시판 경로는 정규 주소로 301 이동
- 예전
detail.php주소도 새 주소로 통합 - 서치콘솔 중복 URL 보고 감소
- 잘못된 내부 링크를 발견하기 쉬워짐
특히 좋았던 점은 어떤 주소로 들어오든 결국 같은 정답으로 모인다는 구조가 된 것입니다. 사용자에게는 기존 링크를 살려주고, 검색엔진에게는 대표 주소를 명확하게 알려줄 수 있었습니다.
중복 URL 점검 체크리스트
검색엔진에게 보여줄 주소는 하나로
SEO에서 중요한 것은 검색엔진을 속이는 기술이 아니라 헷갈리지 않게 정리해주는 일입니다. 같은 글을 여러 주소로 열어두면 검색엔진도 어떤 주소를 보여줘야 할지 판단해야 합니다.
이번 문제도 그랬습니다. 어떤 경로로 들어와도 글이 보이게 만든 것은 사용자 입장에서는 편해 보였지만, 검색엔진 입장에서는 대표 주소를 헷갈리게 만드는 구조였습니다.
그래서 저는 잘못된 주소를 없애기만 하지 않고, 모두 진짜 주소로 보내는 방식을 선택했습니다. 사용자는 예전 링크로 들어와도 글을 볼 수 있고, 검색엔진은 이 글의 대표 주소를 하나로 이해할 수 있습니다.
해결의 핵심은 canonical 태그를 추가하는 것에서 끝나지 않았습니다. 서버가 직접 정규 주소를 판단하게 만들고, 게시글의 실제 소속을 DB에서 확인한 뒤, 잘못된 URL은 301로 정답 주소에 모아주는 것이었습니다.