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

문제를 발견한 순간
블로그 글을 쓰다가 이미지를 바꿨습니다. 첫 번째 이미지를 지우고 새 이미지를 올린 뒤 저장. 카카오톡으로 공유해보니 지운 이미지가 미리보기에 떡하니 나옵니다.
[내가 한 일] 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을 뽑는 유틸리티입니다. 마크다운()과 HTML(<img src="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) 글 내용 ![두번째를 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를 붙였습니다. 어디서 공유하든 본문의 첫 이미지가 나옵니다.






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