메인 콘텐츠로 바로가기
노트 목록

OG 이미지가 삭제된 사진을 보여줄 때: 본문 기반 추출로 해결한 이야기

김성박640
목차18

문제를 발견한 순간

블로그 글을 쓰다가 이미지를 바꿨습니다. 첫 번째 이미지를 지우고 새 이미지를 올린 뒤 저장. 카카오톡으로 공유해보니 지운 이미지가 미리보기에 떡하니 나옵니다.

[내가 한 일] 1. 글에 이미지 A 업로드 → 에디터에 <img src="A.jpg"> 삽입 2. 마음에 안 들어서 <img> 태그 삭제 3. 새 이미지 B 업로드 → 에디터에 <img src="B.jpg"> 삽입 4. 저장 [기대] OG 이미지 = B.jpg (본문에 있는 이미지) [현실] OG 이미지 = A.jpg (본문에서 지웠는데?)

처음엔 카카오톡 캐시 문제인 줄 알았는데, HTML 소스를 까보니 진짜로 A.jpg가 박혀 있었습니다.


원인: "업로드 기록"과 "본문"의 괴리

FullStackFamily는 에디터에서 업로드된 파일을 unified_file 테이블에 기록합니다. 이미지를 올리면 서버에 저장되고 DB에 레코드가 남는데, 사용자가 에디터에서 <img> 태그를 지워도 이 레코드는 그대로입니다.

unified_file 테이블 본문(content) ┌──────┬─────────┬───────────┐ ┌─────────────────────────┐ │ id │ post_id │ url │ │ <p>오늘의 글입니다</p> │ ├──────┼─────────┼───────────┤ │ │ │ 1 │ 100 │ A.jpg │ ← 삭제됨 │ │ │ 2 │ 100 │ B.jpg │ ← 현재 │ <img src="B.jpg"> │ └──────┴─────────┴───────────┘ │ <p>본문 계속...</p> │ └─────────────────────────┘

기존 썸네일 로직을 보니 이랬습니다:

// 1순위: 본문에서 이미지 추출 thumbnailUrl = ContentThumbnailExtractor.extractFirstImageUrl(content); // 2순위: 추출 실패 시 unified_file에서 폴백 if (thumbnailUrl == null) { thumbnailUrl = thumbnailMap.get(postId); // ← 삭제된 이미지도 여기 있음 }

"폴백"이라는 이름이 붙어 있으니 안전장치 같지만, 이게 삭제된 이미지를 되살리는 셈입니다. 본문에 이미지가 없으면 그냥 빈 썸네일이 맞는 건데, 옛날 업로드 기록을 끌어와서 유령 이미지를 만들고 있었습니다.


두 번째 문제: 블로그 상세에 OG 태그 자체가 없었다

피드 썸네일만 문제가 아니었습니다. 블로그 글 상세 페이지의 <head>를 열어보니 og:image 메타 태그가 아예 빠져 있었습니다.

[커뮤니티 글 상세] → og:image 있음 [뉴스 상세] → og:image 있음 [블로그 글 상세] → og:image 없음 ← ???

카카오톡이나 슬랙이 링크 미리보기를 만들 때 og:image를 읽는데, 이게 없으면 크롤러가 페이지에서 아무 이미지나 가져가거나, 아예 빈 채로 보여줍니다. 커뮤니티 글에는 넣어놓고 블로그에는 빠뜨린 거였습니다.


해결: 본문이 진실의 원천

원칙은 간단합니다.

"지금 본문에 보이는 첫 번째 이미지 = 대표 이미지"

unified_file 테이블은 파일 관리용이지, 콘텐츠의 현재 상태와는 무관합니다.

수정 전: ┌─────────────────────────────────────────┐ │ 1. 본문에서 이미지 추출 │ │ 2. 실패 시 → unified_file 폴백 (위험!) │ └─────────────────────────────────────────┘ 수정 후: ┌─────────────────────────────────────────┐ │ 1. 본문에서 이미지 추출 │ │ 2. 없으면 → null (CSS 그라데이션 표시) │ └─────────────────────────────────────────┘

백엔드 피드 API와 프론트엔드 OG 메타 태그, 두 곳에 적용했습니다.


백엔드: ContentThumbnailExtractor

본문에서 이미지 URL을 뽑는 유틸리티입니다. 마크다운(![alt](url))과 HTML(<img src="url">) 두 형식을 처리합니다.

// 마크다운: ![alt](url) Pattern.compile("!\\[.*?]\\((https?://[^)]+)\\)"); // HTML: <img src="url"> Pattern.compile("<img\\s+[^>]*src=[\"'](https?://[^\"']+)[\"']");

두 패턴을 각각 돌려서 본문에서 더 앞에 나오는 쪽을 반환합니다. 에디터가 마크다운으로 저장하든 HTML로 저장하든, 섞어 쓰든 상관없습니다.

return mdIndex <= htmlIndex ? mdUrl : htmlUrl;

폴백 제거

HomeService에서 unified_file 폴백을 없앴습니다.

// 수정 전 thumbnailUrl = ContentThumbnailExtractor.extractFirstImageUrl(content); if (thumbnailUrl == null) { thumbnailUrl = thumbnailMap.get(postId); // 삭제된 이미지 위험 } // 수정 후 - 본문 이미지만 사용 thumbnailUrl = ContentThumbnailExtractor.extractFirstImageUrl(content); // null이면 UI에서 CSS 그라데이션 표시

뉴스 글은 예외로 뒀습니다. 뉴스는 외부 사이트에서 가져온 콘텐츠라 newsExtension.thumbnailUrl에 원본 사이트의 대표 이미지가 따로 있고, 사용자가 편집하는 게 아니라서 불일치 문제가 생기지 않습니다.

┌────────────────────────────────────────────────┐ │ 글 유형별 썸네일 소스 │ ├──────────┬─────────────────────────────────────┤ │ 블로그 │ 본문 첫 이미지 추출 │ │ 커뮤니티 │ 본문 첫 이미지 추출 │ │ Q&A │ 본문 첫 이미지 추출 │ │ 뉴스 │ newsExtension.thumbnailUrl (고정) │ └──────────┴─────────────────────────────────────┘

프론트엔드: generateMetadata에 OG 태그 추가

빠져 있던 OG 메타 태그를 Next.js generateMetadata에 추가했습니다. SSR 시점에 본문 첫 이미지를 추출합니다.

export function extractFirstImage(content: string): string | null { const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i) if (imgMatch?.[1]) return imgMatch[1] const mdMatch = content.match(/!\[[^\]]*\]\(([^)]+)\)/) if (mdMatch?.[1]) return mdMatch[1] return null }

generateMetadata에서 이렇게 씁니다:

const ogImage = extractFirstImage(post.content || '') || SEO_CONSTANTS.defaultImage // 이미지 없으면 사이트 기본 로고

크롤러가 읽는 HTML에 이런 태그가 생깁니다:

<meta property="og:image" content="https://...B.jpg"> <meta name="twitter:image" content="https://...B.jpg">

JSON-LD 구조화 데이터에도 같은 이미지를 넣어서 구글 검색 리치 스니펫에도 표시됩니다.


성능: 정규식으로 본문을 매번 스캔해도 괜찮을까

피드 API는 한 번에 20개 글의 썸네일을 추출합니다. 글 하나가 수천~수만 자일 수 있으니, 좀 걱정이 됐습니다.

측정해보니

┌───────────────────────────────────────────┐ │ 항목 │ 수치 │ ├──────────────────┼────────────────────────┤ │ 글 1건 평균 │ ~0.3ms │ │ 20건 (페이지) │ ~6ms │ │ API 전체 응답 │ ~200ms │ │ 이미지 추출 비중 │ 전체의 약 3% │ └──────────────────┴────────────────────────┘

전체 응답의 3%면 무시할 수준입니다. 그래도 혹시 모를 초장문 글을 위해 5KB 스캔 제한을 걸어뒀습니다.

private static final int MAX_SCAN_LENGTH = 5000; String scanTarget = content.length() > MAX_SCAN_LENGTH ? content.substring(0, MAX_SCAN_LENGTH) : content;

대표 이미지를 글의 5,000자(A4 3~4페이지) 뒤에 넣는 사람은 거의 없으니까요.

DB에 저장하는 방법도 생각했다

"글 저장할 때 대표 이미지 URL을 컬럼에 미리 넣어두면 되지 않나?" 당연히 떠오르는 생각이지만, 따져보면 런타임 추출 쪽이 나았습니다.

방식장점단점
DB 저장조회 시 연산 없음저장 시 추출 로직 필요, 기존 글 마이그레이션, 본문 수정 시 동기화
런타임 추출항상 최신 본문 기준, 마이그레이션 불필요조회마다 정규식 수행

0.3ms의 비용으로 동기화 걱정을 안 해도 된다면, 나는 런타임 추출을 택하겠습니다. 글이 수만 건 단위로 늘어나면 그때 DB 캐시를 붙여도 늦지 않습니다.


정규식에서 신경 쓴 부분들

non-greedy 매칭

"!\\[.*?]\\((https?://[^)]+)\\)" // ^^^ non-greedy

.*? 대신 .*(greedy)를 쓰면 ![첫번째](a.jpg) 글 내용 ![두번째](b.jpg) 같은 입력에서 첫번째](a.jpg) 글 내용 ![두번째를 alt 텍스트로 잡아버립니다. non-greedy로 바꾸면 가장 짧은 매치를 찾아서 첫 번째 이미지를 정확히 가져옵니다.

URL 프로토콜 제한

(https?://[^)]+) // http:// 또는 https://만

data:image/png;base64,... 같은 인라인 이미지나 상대 경로를 거릅니다. OG 이미지는 외부에서 접근할 수 있는 절대 URL이어야 의미가 있으니까요.

static Pattern 컴파일

private static final Pattern MARKDOWN_IMAGE_PATTERN = Pattern.compile(...); private static final Pattern HTML_IMG_PATTERN = Pattern.compile(...);

Pattern.compile()은 정규식을 내부 오토마톤으로 바꾸는 비용이 있어서, 클래스 로딩 시 한 번만 컴파일합니다. String.matches()를 쓰면 매번 컴파일하는데, 피드 API처럼 루프 안에서 20번 호출되면 낭비입니다.


같은 로직이 왜 백엔드와 프론트엔드 양쪽에 있나

본문에서 이미지 추출하는 코드가 Java와 TypeScript 양쪽에 있습니다. DRY 위반 아니냐고 할 수 있는데, 쓰이는 맥락이 다릅니다.

┌─────────────────────────────────────────────────────────────┐ │ 이미지 추출이 필요한 곳 │ ├─────────────────────┬───────────────────────────────────────┤ │ 백엔드 │ 피드 API의 썸네일 필드 │ │ (HomeService.java) │ → 카드 리스트에 작은 이미지 표시 │ ├─────────────────────┼───────────────────────────────────────┤ │ 프론트엔드 │ SSR에서 og:image 메타 태그 생성 │ │ (page.tsx) │ → 카카오톡/슬랙 링크 미리보기 │ ├─────────────────────┼───────────────────────────────────────┤ │ 프론트엔드 │ JSON-LD의 image 필드 │ │ (page.tsx) │ → 구글 검색 리치 스니펫 │ └─────────────────────┴───────────────────────────────────────┘

피드 API에서 20개 글의 본문 전체를 프론트로 내려보내서 클라이언트에서 파싱하게 하면 트래픽 낭비입니다. 반대로 상세 페이지는 이미 본문이 API 응답에 들어 있으니, OG 태그용으로 백엔드에 별도 필드를 추가할 이유가 없습니다.

목록 = 백엔드에서 추출, 상세 = 프론트에서 추출. 각각 효율적인 쪽에서 하는 겁니다.


이미지 없는 글은 어떻게 보이나

Q&A나 짧은 커뮤니티 글은 텍스트만 있는 경우가 많습니다. thumbnailUrl이 null이면 CSS 그라데이션 플레이스홀더를 보여줍니다.

┌──────────────────────────────────────┐ │ 이미지 있는 글 │ 이미지 없는 글 │ ├────────────────────┼─────────────────┤ │ ┌──────────────┐ │ ┌───────────┐ │ │ │ 실제 이미지 │ │ │ 그라데이션 │ │ │ │ (썸네일) │ │ │ (회→흰) │ │ │ └──────────────┘ │ └───────────┘ │ │ 제목... │ 제목... │ │ 본문 미리보기... │ 본문 미리보기... │ └────────────────────┴─────────────────┘

OG 이미지도 마찬가지로, 본문에 이미지가 없으면 사이트 기본 로고(/og-default.png)를 넣습니다. 빈 것보단 로고라도 있는 게 낫습니다.


정리

항목수정 전수정 후
피드 썸네일 소스unified_file 폴백 있음본문 첫 이미지만
블로그 OG 이미지메타 태그 없음본문 첫 이미지 또는 기본 이미지
삭제된 이미지 노출가능 (폴백에서)불가능
스캔 범위본문 전체앞 5KB

사용자가 에디터에서 이미지를 지웠으면, 그건 "이 이미지 안 쓸래"라는 의사 표현입니다. 시스템이 다른 데이터 소스에서 그걸 되살려서는 안 됩니다. 파일 업로드 기록은 파일 관리용이고, 콘텐츠의 의도를 알려면 본문을 봐야 합니다.

같은 원칙으로 블로그 상세 페이지에도 og:image, twitter:image, JSON-LD image를 붙였습니다. 어디서 공유하든 본문의 첫 이미지가 나옵니다.

댓글

불러오는 중...

댓글을 작성하려면 이 필요합니다.