2026년 10월 29일 목요일
미단재

요약: 블로그 글을 SNS에 공유하면 모든 글이 같은 사이트 로고로 보이는 문제가 있었다. 글 제목을 얹은 1200×630 PNG를 PHP GD로 요청 시점에 만들고, 제목·사이트명 해시를 캐시 키로 삼아 파일 캐시에 저장하게 했다. 공개 글만 이미지를 만들고, 한글 폰트가 없는 서버에서는 사이트 공통 이미지로 폴백한다.

문제: 어느 글을 공유해도 같은 이미지

블로그 기능을 하나씩 직접 만드는 시리즈의 네 번째 글이다. 앞선 작업에서 SeoHelper로 og:image 메타를 출력하게 해 두었는데, 글 단위로 지정하는 값이 없으니 폴백(settings.og_default_image → site_logo)이 전부 같은 이미지를 가리켰다. 카카오톡이나 X에 링크를 붙이면 글이 달라도 미리보기 이미지는 늘 같았다.

글마다 이미지를 직접 만들어 올리는 방식은 글 수가 늘면 유지가 안 된다. 제목만 있으면 만들 수 있으니 서버에서 자동으로 만들기로 했다.

설계: 이미지를 파일로 저장하지 않고 URL로 요청받는다

글을 저장할 때 이미지를 미리 만들어 두면 제목 수정, 사이트명 변경, 일괄 재생성을 모두 따로 처리해야 한다. 그래서 og/{글번호}.png 라우트를 두고, 요청이 오면 그때 만들어 캐시한다.

라우트는 한 줄이다.

$routes->get('og/(:num)\\.png', 'Front\OgImageController::post/$1');

글 상세 컨트롤러(BoardController)는 공개 글일 때만 og:image를 이 URL로 바꾼다. 쿼리스트링의 v 값이 캐시 무효화 열쇠다.

$ogVersion = OgImageController::version($post['title'], (string) ($this->viewData['settings']['site_name'] ?? ''));
$ogPage    = [
    'og_type'         => 'article',
    'og_image'        => 'og/' . $post['id'] . '.png?v=' . $ogVersion,
    'og_image_width'  => OgImageGenerator::WIDTH,
    'og_image_height' => OgImageGenerator::HEIGHT,
];

version()은 제목과 사이트명을 이어 붙여 md5의 앞 8자리를 쓴다. 제목이나 사이트명이 바뀌면 URL이 바뀌므로, SNS 쪽 이미지 캐시도 새 URL로 다시 받아 간다.

public static function version(string $title, string $siteName): string
{
    return substr(md5($title . '|' . $siteName), 0, 8);
}

구현: GD로 제목을 그린다

OgImageGenerator는 1200×630 캔버스에 배경색, 포인트 막대, 제목, 사이트명을 차례로 그린다. 제목은 imagettftext()로 쓰고 글자 크기는 60, 최대 4줄이다.

줄바꿈에서 한 가지 정했다. 한글은 공백이 드물어서 단어 단위로 자르면 줄이 너무 길어지거나 한 줄에 못 담는다. 그래서 글자 단위로 폭을 재면서 넘치면 줄을 바꾸고, 4줄을 넘으면 마지막 줄을 …로 닫는다.

foreach ($chars as $i => $char) {
    if ($line !== '' && $this->width($line . $char, $size) > $maxWidth) {
        $lines[] = rtrim($line);
        $line    = ltrim($char);

        if (count($lines) === self::MAX_LINES) {
            // 남은 글자가 있으니 마지막 줄을 말줄임으로 닫는다
            $lines[self::MAX_LINES - 1] = $this->ellipsize($lines[self::MAX_LINES - 1], $size, $maxWidth);

            return $lines;
        }
    } else {
        $line .= $char;
    }
}

폭 측정은 imagettfbbox()가 맡는다. 실제로 이 시리즈 제목을 넣어 만든 결과가 아래 이미지다.

글자 단위 줄바꿈의 대가도 보인다. "만들기4"가 "만들 / 기4"로 잘렸다. 한글 단어 중간에서 끊기는 것이다. 지금은 이 정도를 허용했고, 필요하면 공백·구두점 위치를 우선하는 줄바꿈으로 고칠 수 있다.

폰트: 서버에 한글 폰트가 없을 수 있다

GD로 한글을 그리려면 TTF/TTC 파일이 필요하다. .env의 OG_FONT_PATH를 먼저 보고, 없으면 Noto Sans CJK, 나눔고딕, macOS의 Apple SD Gothic Neo 순서로 시스템 경로를 찾는다. 하나도 없으면 findFont()가 null을 돌려준다.

폰트가 없을 때의 처리는 두 군데에 있다.

  • 글 상세 컨트롤러는 폰트가 없으면 og_image를 설정하지 않는다. 그러면 SeoHelper가 사이트 공통 OG 이미지로 폴백한다.
  • OgImageController는 폰트가 없으면 404를 던진다.

이미지를 못 만든다고 페이지까지 깨지면 안 되므로, 이미지는 "있으면 좋은 것"으로 다뤘다.

캐시: 읽는 쪽이 반쯤 쓴 파일을 보면 안 된다

생성한 PNG는 writable/cache/og/{글번호}-{해시}.png에 저장한다. 같은 기능 묶음의 후속 커밋(OG 이미지 캐시 원자적 교체·옛 파일 정리·방문 로그 제외)에서 캐시 처리를 세 가지로 보강했다.

원자적 교체. 같은 글에 요청이 동시에 오면, 한쪽이 쓰는 중인 파일을 다른 쪽이 읽을 수 있다. 임시 파일에 먼저 쓰고 rename()으로 교체하면 읽는 쪽은 항상 완성된 파일만 본다.

$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $png) === false) {
    return;
}
if (! rename($tmp, $path)) {
    unlink($tmp);

    return;
}

옛 캐시 정리. 제목이 바뀔 때마다 해시가 달라지므로 파일이 계속 쌓인다. 새 파일을 저장한 뒤 같은 글번호의 다른 해시 파일을 지운다.

foreach (glob($dir . '/' . $postId . '-*.png') ?: [] as $old) {
    if ($old !== $path) {
        unlink($old);
    }
}

방문 로그 제외. 크롤러가 이미지를 받을 때마다 방문 기록이 쌓이면 통계가 오염된다. VisitLogFilter::EXCLUDED_PATHS에 og/*를 추가했다.

공개 범위: 비공개 글 제목이 이미지로 새면 안 된다

이미지는 제목을 담으므로, 비밀글의 제목이 og/글번호.png로 누구에게나 보이면 안 된다. PostModel::getPublicOgSource()가 비밀글, 비활성 글, 비활성 게시판, 회원 전용 게시판의 글을 모두 null로 돌려주고, 컨트롤러는 그때 404를 던진다. 목록·검색의 공개 조건과 같은 기준이다.

$row = $this->select('posts.title')
    ->join('boards', 'boards.id = posts.board_id', 'inner')
    ->where('posts.id', $postId)
    ->where('posts.is_secret', 0)
    ->where('posts.is_active', 1)
    ->where('boards.is_active', 1)
    ->where('boards.read_permission', 'guest')
    ->first();

테스트

  • 단위 테스트: 폰트 경로 탐색, 그리고 아주 긴 제목을 넣어도 1200×630 PNG가 나오는지 확인한다.
  • 기능 테스트: 비밀글은 og/{id}.png가 404, 공개 글은 200 image/png이고 글 상세 HTML에 og/{id}.png가 들어 있는지 확인한다.

CI 서버에 한글 폰트가 없을 수 있어서, 폰트가 필요한 테스트는 폰트가 없으면 markTestSkipped로 건너뛴다.

배운 점

  • 이미지를 저장 시점이 아니라 요청 시점에 만들면 제목 수정·사이트명 변경을 따로 처리할 필요가 없다. 해시가 URL에 들어가 있으면 캐시 무효화도 따라온다.
  • 캐시 파일은 쓰는 쪽보다 읽는 쪽을 먼저 생각한다. 임시 파일 + rename() 두 줄이 동시 요청 문제를 없앤다.
  • 제목이 들어가는 산출물은 공개 범위 판정이 곧 보안 경계다. 이미지 라우트도 목록과 같은 조건을 써야 한다.
  • 한글 줄바꿈은 글자 단위로 시작하되, 단어 중간에서 끊기는 문제는 남는다.

 

댓글 0개