테이블명만 남은 URL 정리하기 | DB 조회로 진짜 주소 찾기

게시글 하나가 여러 주소로 열리고 있었습니다. 표준 주소도 있었고, 예전 쿼리스트링 주소도 있었습니다. 그런데 가장 이상했던 것은 사이트 아이디도 없고 게시판 번호도 빠진 주소였습니다.

/detail.php?no=1226&tb=board&id=

이 주소만 보면 어느 사이트의 어느 게시판 글인지 알 수 없습니다. 그런데도 글은 정상으로 열렸습니다. 처음에는 “열리니까 괜찮은 것 아닌가”라고 생각했지만, 검색엔진 입장에서는 같은 글이 정체불명의 URL로 하나 더 생긴 상태였습니다.

이 글은 301 리다이렉트 코드를 하나 더 추가하는 방법만 설명하는 글이 아닙니다. URL에 필요한 값이 빠졌을 때 글번호로 DB를 조회해 진짜 주소를 복원하고, DB가 필요한 정규화 코드를 어디에 배치해야 하는지 정리한 글입니다.

DB 조회로 불완전한 URL을 복원하는 PHP 301 리다이렉트 코드

테이블명만 남은 이상한 URL

문제가 된 글은 아래 세 가지 주소로 접근할 수 있었습니다.

주소상태문제
/mysite/board/856/1226표준 주소정상
/detail.php?no=1226&tb=board_856&id=mysite예전 주소301 대상
/detail.php?no=1226&tb=board&id=기형 주소소속 정보 없음

세 번째 주소가 문제였습니다. id는 비어 있고, tb에는 board_856이 아니라 board만 남아 있었습니다. URL만 봐서는 게시판 번호도, 사이트 아이디도 알 수 없습니다.

그런데 글번호 1226만으로 글이 출력되고 있었습니다. 결국 서버는 URL의 소속 정보가 없어도 글번호만 보고 게시글을 찾아 보여주고 있던 것입니다.

같은 글이 여러 주소로 열린 원인

원인은 오래된 목록 링크였습니다. 일부 목록 페이지나 검색 결과 페이지가 게시글 링크를 만들 때 tb와 id 값을 제대로 넘기지 못하고 있었습니다.

<a href="/detail.php?no=1226&tb=board&id=">게시글 제목</a>

이런 링크가 하나만 있으면 단순 실수로 끝날 수 있습니다. 하지만 목록 페이지가 여러 개라면 같은 형태의 기형 URL이 계속 생성됩니다.

검색엔진이 이런 링크를 발견하면 실제 글과 같은 본문을 가진 다른 주소로 볼 수 있습니다.

표준 주소: /mysite/board/856/1226
기형 주소: /detail.php?no=1226&tb=board&id=

둘 다 200 OK로 열리면 중복 URL이 됩니다. 그래서 기형 URL도 표준 주소로 모아야 했습니다.

정답은 URL이 아니라 게시글 데이터

이 문제는 URL만 보고 해결할 수 없었습니다. tb=board에는 게시판 번호가 없고, id=도 비어 있기 때문입니다.

대신 게시글 DB에는 정답이 있었습니다.

posts.number   → 글번호
posts.board_no → 실제 게시판 번호
posts.owner_id → 실제 사이트 아이디

즉, 글번호만 있으면 진짜 주소를 복원할 수 있었습니다.

no=1226
→ DB 조회
→ owner_id = mysite
→ board_no = 856
→ /mysite/board/856/1226

URL에 들어온 값을 그대로 믿지 않고, 글 자체에게 소속 정보를 물어보는 방식으로 바꿨습니다.

글번호로 표준 주소 복원

기형 URL인지 먼저 판단했습니다. 글번호는 있는데 id가 비어 있고, tb 끝에 게시판 번호가 없으면 DB 조회가 필요하다고 봤습니다.

<?php
$parts = explode('_', $_GET['tb'] ?? '');
$postNo = (int)($_GET['no'] ?? 0);

$needsLookup = $postNo > 0
    && empty($_GET['id'])
    && !is_numeric(end($parts));
?>

그다음 글번호로 실제 소속 정보를 조회했습니다.

<?php
if ($needsLookup) {
    $stmt = $pdo->prepare(
        'SELECT board_no, owner_id
         FROM posts
         WHERE number = ?
         LIMIT 1'
    );
    $stmt->execute([$postNo]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($row && $row['board_no'] && $row['owner_id']) {
        $url = 'https://example.com/'
             . rawurlencode($row['owner_id'])
             . '/board/'
             . (int)$row['board_no']
             . '/'
             . $postNo;

        header('Location: ' . $url, true, 301);
        exit;
    }
}
?>

이렇게 하면 아래처럼 정리됩니다.

/detail.php?no=1226&tb=board&id=
→ 301
→ /mysite/board/856/1226

사용자는 기존 링크로 들어와도 글을 볼 수 있고, 검색엔진은 최종적으로 표준 주소를 확인하게 됩니다.

글이 없을 때는 404 처리

글번호로 조회했는데 실제 글이 없다면 301로 보낼 수 없습니다. 대응할 콘텐츠가 없기 때문입니다.

<?php
if ($needsLookup) {
    $stmt = $pdo->prepare(
        'SELECT board_no, owner_id
         FROM posts
         WHERE number = ?
         LIMIT 1'
    );
    $stmt->execute([$postNo]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$row) {
        http_response_code(404);
        include __DIR__ . '/error/404.php';
        exit;
    }
}
?>

없는 글을 메인으로 보내면 소프트 404가 될 수 있습니다. 실제로 옮겨진 글이 있으면 301, 대응할 글이 없으면 404로 나눴습니다.

DB 조회 코드를 맨 위에 넣었다가 생긴 오류

진짜 삽질은 코드 자체보다 배치 위치에서 생겼습니다. 처음에는 정규화 코드를 파일 맨 위에 몰아두는 것이 깔끔해 보였습니다.

<?php
// 파일 맨 위
$stmt = $pdo->prepare('SELECT board_no, owner_id FROM posts WHERE number = ?');

하지만 이 위치에서는 $pdo가 아직 없었습니다. DB 연결은 공통 파일을 include한 뒤에 만들어지는 구조였기 때문입니다.

결과는 백색 화면과 치명적 오류였습니다.

Undefined variable: pdo
Call to a member function prepare() on null

정규화 코드라고 해서 모두 맨 위에 둘 수 있는 것은 아니었습니다. DB가 필요한 코드와 필요 없는 코드를 나눠야 했습니다.

정규화 코드 배치 순서

최종적으로는 아래 순서로 정리했습니다.

<?php

// 1. DB 없이 판단 가능한 정리
// 예: 허용 목록에 없는 쿼리 파라미터 제거

// 2. DB 없이 만들 수 있는 301
// 예: tb=board_856처럼 URL 안에서 게시판 번호를 뽑을 수 있는 경우

require __DIR__ . '/inc/config.php';
require __DIR__ . '/inc/db.php';
require __DIR__ . '/inc/function.php';

// 3. DB가 필요한 정리
// 예: no=1226만 보고 owner_id, board_no 조회

// 4. 실제 페이지 렌더링

이 순서를 잡은 이유는 두 가지였습니다.

첫째, DB 없이 처리할 수 있는 요청은 DB 연결 전에 끝낼 수 있습니다. 봇이 붙인 쓰레기 파라미터나 단순한 예전 URL은 가볍게 되돌려 보낼 수 있습니다.

둘째, DB가 필요한 코드는 연결과 공통 함수가 준비된 뒤에 실행해야 합니다. 그래야 치명적 오류를 피할 수 있습니다.

DB 없이 가능한 301 먼저 처리

예를 들어 tb=board_856처럼 URL 안에 게시판 번호가 이미 들어 있으면 DB 조회 없이 새 주소를 만들 수 있습니다.

<?php
if (isset($_GET['no'], $_GET['tb'], $_GET['id'])
    && preg_match('/^board_(\d+)$/', $_GET['tb'], $m)
    && $_GET['id'] !== '') {

    $siteId = preg_replace('/[^_A-Za-z0-9-]/', '', $_GET['id']);
    $boardNo = (int)$m[1];
    $postNo = (int)$_GET['no'];

    $url = 'https://example.com/'
         . rawurlencode($siteId)
         . '/board/'
         . $boardNo
         . '/'
         . $postNo;

    header('Location: ' . $url, true, 301);
    exit;
}
?>

이런 처리는 DB 연결 전에 해도 됩니다. URL 안에 필요한 값이 모두 있기 때문입니다.

/detail.php?no=1226&tb=board_856&id=mysite
→ /mysite/board/856/1226

반대로 tb=board&id=처럼 값이 빠진 경우는 DB 없이는 복원할 수 없습니다.

리다이렉트 루프 방지

여러 정규화 코드를 추가하면 루프 가능성도 확인해야 합니다. 목적지가 현재 주소와 같으면 리다이렉트하지 않도록 공통 함수를 사용했습니다.

<?php
function redirect301IfNeeded(string $target): void
{
    $current = (($_SERVER['HTTPS'] ?? '') === 'on' ? 'https://' : 'http://')
             . $_SERVER['HTTP_HOST']
             . $_SERVER['REQUEST_URI'];

    if (rtrim($current, '/') === rtrim($target, '/')) {
        return;
    }

    header('Location: ' . $target, true, 301);
    exit;
}
?>

경로만 비교해야 하는 경우에는 parse_url()로 나눠 확인했습니다.

<?php
$currentPath = strtok($_SERVER['REQUEST_URI'], '?');
$targetPath = parse_url($url, PHP_URL_PATH);

if ($currentPath !== $targetPath) {
    header('Location: ' . $url, true, 301);
    exit;
}
?>

리다이렉트를 추가한 뒤에는 같은 URL에서 301이 반복되지 않는지 반드시 확인했습니다.

curl로 응답 흐름 확인

브라우저로 보면 301을 자동으로 따라가기 때문에 실제 상태를 놓치기 쉽습니다. 그래서 curl로 확인했습니다.

curl -sIL "https://example.com/detail.php?no=1226&tb=board&id=" | grep -i "^HTTP\|^location"
curl -sIL "https://example.com/detail.php?no=1226&tb=board_856&id=mysite" | grep -i "^HTTP\|^location"
curl -sIL "https://example.com/mysite/board/856/1226" | grep -i "^HTTP\|^location"

기대 결과는 아래와 같았습니다.

/detail.php?no=1226&tb=board&id=
→ 301 → /mysite/board/856/1226

/detail.php?no=1226&tb=board_856&id=mysite
→ 301 → /mysite/board/856/1226

/mysite/board/856/1226
→ 200 OK

301이 두 번 이상 이어지면 중간 단계를 줄였습니다. 최종 주소로 바로 보내는 것이 목표였습니다.

적용 후 달라진 점

수정 후에는 글 하나가 여러 기형 URL로 열리는 문제가 줄었습니다.

  • id가 비어 있는 상세 URL을 표준 주소로 301 처리
  • tb=board처럼 번호 없는 테이블명 URL 정리
  • 예전 tb=board_856 주소도 새 주소로 통합
  • 글이 없으면 메인 301이 아니라 404 처리
  • 서치콘솔 중복 페이지 보고 감소
  • 잘못된 목록 링크가 있어도 받는 쪽에서 정규화 가능

가장 큰 장점은 링크를 만드는 모든 위치를 한 번에 고치지 못해도, 상세 페이지 입구에서 최종 주소를 정리할 수 있다는 점이었습니다.

DB 조회 리다이렉트 체크리스트

주소가 아니라 데이터에게 물어보기

이번 문제의 핵심은 URL에 들어온 값을 믿지 않는 것이었습니다. tb=board&id=처럼 주소 정보가 비어 있어도, 글번호가 있다면 DB가 진짜 소속을 알고 있었습니다.

그래서 글번호로 게시판 번호와 사이트 아이디를 조회하고, 그 값으로 표준 주소를 다시 만들었습니다. 어떤 이상한 조합으로 들어와도 결국 하나의 정답 주소로 모이게 한 것입니다.

다만 DB가 필요한 정규화는 코드 위치가 중요합니다. DB 연결 전에 실행하면 오류가 나고, 너무 늦게 실행하면 이미 본문이 출력된 뒤라 헤더를 보낼 수 없습니다.

정규화 코드는 “파일 맨 위에 몰아두기”가 아니라 “필요한 준비물이 로드된 직후”에 놓아야 했습니다. DB 없이 가능한 것은 먼저 처리하고, DB가 필요한 것은 연결 이후에 처리하는 식으로 나누는 것이 가장 안전했습니다.


관련 글

댓글 남기기