카카오톡이나 슬랙에 링크를 붙이면 제목 · 설명 · 그림이 붙은 미리보기가 뜹니다. 이 미리보기의 재료는 대부분 그 페이지에 들어 있는 Open Graph 태그입니다.
이 글은 두 부분입니다.
- Open Graph 가 무엇이고, 미리보기를 만드는 쪽이 어떻게 읽는지 정리합니다.
- 제 링크 모음 앱에서 미리보기를 직접 읽어 오며 생긴 문제와, 성능 면에서 고친 것을 적습니다. 유튜브는 og 태그가 페이지의 780KB 지점에 있어, "앞부분만 읽으면 된다"는 생각이 바로 깨졌습니다.
Open Graph 가 뭔가
Open Graph 는 페이지가 "나를 공유하면 이렇게 보여 달라"고 적어 두는 약속입니다. 페이스북이 만들었고 지금은 메신저 · SNS · 협업 도구 대부분이 읽습니다. 형식은 <head> 안의 <meta> 태그입니다.
<meta property="og:title" content="글 제목" />
<meta property="og:description" content="한두 문장 설명" />
<meta property="og:image" content="https://example.com/card.png" />
<meta property="og:url" content="https://example.com/posts/1" />
<meta property="og:type" content="article" />
| 태그 | 뜻 |
|---|---|
og:title | 카드에 뜨는 제목 |
og:description | 제목 아래 설명 |
og:image | 카드 그림. 절대 주소로 적는 게 원칙 |
og:url | 이 페이지의 대표 주소 |
og:type | website · article 같은 종류 |
og:image:width · og:image:height · og:image:alt | 그림 크기와 대체 글 |
X(트위터)는 자기 태그인 twitter:card · twitter:title · twitter:image 를 먼저 보고, 없으면 og 태그를 씁니다. 그래서 둘 중 하나만 있는 사이트도 흔합니다.
하나 헷갈리는 점은 속성 이름입니다. og 태그는 property="og:…" 가 원래 형식이고, twitter: 태그와 일반 설명은 name="…" 을 씁니다. 실제 페이지에는 둘이 섞여 있어서, 읽는 쪽은 둘 다 봐야 합니다.
미리보기 로봇은 어떻게 읽나
링크를 붙이면 메신저 서버가 그 주소의 HTML 을 받아 태그를 읽습니다. 이때 알아 둘 것이 세 가지 있습니다.
- 자바스크립트를 실행하지 않습니다. 화면을 그린 뒤에 스크립트로 태그를 넣는 페이지는 미리보기가 비어 나옵니다. 그래서 태그는 서버가 내려 주는 HTML 에 처음부터 있어야 합니다.
- 한 번 읽은 결과를 한동안 보관합니다. 태그를 고쳐도 이미 공유된 링크의 미리보기는 바로 안 바뀝니다. 페이스북의 공유 디버거, 카카오의 공유 디버거 같은 도구로 다시 읽게 할 수 있습니다.
- 그림은 따로 받습니다. 페이지를 읽은 뒤
og:image주소로 한 번 더 요청합니다. 그림이 느리거나 너무 크면 그림 없는 카드가 됩니다.
만드는 쪽에서 이 카드 그림을 서버에서 그려 내보내는 방법은 Next.js 공유 카드 캐시 글에 적었습니다. 이번 글은 반대로 읽는 쪽입니다.
읽는 쪽을 직접 만들어 보니
제가 쓰려고 만든 링크 모음 데스크탑 앱(linkdesk, Electron)에 이 미리보기를 붙였습니다. 링크가 쌓이니 제목만으로는 무슨 링크였는지 떠오르지 않아서입니다.
링크를 담으면 그 페이지를 한 번 받아 og 태그를 읽고, 그림을 받아 둡니다. 생각보다 챙길 게 많았고, 아래는 실제로 부딪힌 순서입니다.
문제 1: 앞부분만 읽으면 된다 — 유튜브에서 틀렸다
처음에는 제목만 가져왔고, 페이지 앞 512KB 까지만 읽었습니다. <title> 과 meta 태그는 <head> 에 있으니 맨 앞쪽일 거라고 생각했습니다.
유튜브 영상 페이지에서 태그 위치를 재 봤습니다. 아래 명령으로 누구나 확인할 수 있습니다.
curl -s -A "Mozilla/5.0" "https://www.youtube.com/watch?v=jNQXAC9IVRw" \
| grep -aboE '<title>|property="og:image"|</head>'
2026년 11월 8일에 잰 결과입니다. 숫자는 그 태그까지의 바이트 수입니다.
| 태그 | 위치 |
|---|---|
<title> | 780,724 |
og:image | 782,090 |
</head> | 788,657 |
| 페이지 전체 | 약 1.33MB |
<head> 안이긴 하지만, 그 앞 780KB 가 대부분 스크립트입니다. 512KB 에서 멈추면 제목도 카드도 얻지 못합니다.
고친 것: 상한을 2MB 로 올리고, 시간 제한도 6초에서 12초로 늘렸습니다. 받을 때마다 크기가 조금씩 달라서 넉넉하게 잡았습니다.
</head> 를 만나면 멈추는 방법도 있습니다. 이번 측정에서는 og 태그가 </head> 바로 앞이었지만, 개발 중 남긴 메모에는 </head> 뒤에서 봤다고 적혀 있습니다. 응답이 매번 같다는 보장이 없어 태그로 끊지 않고 바이트 상한만 둡니다.
상한까지만 읽는 코드는 이렇습니다. 응답을 통째로 받지 않고 스트림에서 읽다가 끊습니다.
async function readCapped(res, max) {
const reader = res.body.getReader()
const chunks = []
let size = 0
while (size < max) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
size += value.length
}
reader.cancel().catch(() => {}) // 나머지는 받지 않는다
return Buffer.concat(chunks)
}
AbortSignal.timeout(12000) 을 같이 넘겨 느린 사이트에 오래 붙잡히지 않게 했고, 응답의 content-type 이 HTML 이 아니면 읽지 않습니다. 동영상이나 PDF 주소를 담았을 때 수십 MB 를 받는 일을 막는 장치입니다.
문제 2: 태그를 읽는 법이 사이트마다 다르다
HTML 파서 라이브러리 없이 <meta> 태그만 정규식으로 골라 읽습니다. 필요한 태그가 몇 개뿐이라서입니다. 대신 아래를 챙겼습니다.
속성 순서와 따옴표. content 가 property 보다 앞에 오기도 하고, 작은따옴표나 따옴표 없는 값도 있습니다. 태그 하나의 속성을 전부 키 → 값으로 바꾼 뒤 property 나 name 을 봅니다.
대체 태그. og 태그가 없는 사이트를 위해 아래 순서로 찾습니다.
| 재료 | 찾는 순서 |
|---|---|
| 제목 | <title> → og:title → twitter:title |
| 설명 | og:description → twitter:description → description |
| 그림 | og:image:secure_url → og:image → twitter:image → twitter:image:src |
그림 주소. /img/card.png 같은 상대 주소가 옵니다. 페이지 주소를 기준으로 절대 주소로 바꾸고, http · https 가 아니면 버립니다. 테스트에는 javascript:alert(1) 을 넣어 버려지는지 확인합니다.
주소 안의 & 도 풀어야 합니다. 위 유튜브 페이지의 og:image 값에도 …=&rs=… 가 들어 있습니다. HTML 안이라 & 를 & 로 적은 것이니, 그대로 요청하면 다른 주소가 됩니다.
문자셋. 국내 옛 사이트는 아직 EUC-KR 이 많아 UTF-8 로만 읽으면 제목이 깨집니다. 응답 헤더의 charset 을 먼저 보고, 없으면 앞 4KB 안의 <meta charset> 을 봅니다.
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 4096))
const label =
/charset=["']?([\w-]+)/i.exec(String(contentType))?.[1] ??
/charset=["']?([\w-]+)/i.exec(head)?.[1]
try {
return new TextDecoder(label || 'utf-8').decode(bytes)
} catch {
return new TextDecoder('utf-8').decode(bytes) // charset=없는값 같은 경우
}
기호 이름. Assets · Kenney – Free 처럼 기호 이름이 그대로 든 제목이 있습니다. HTML 의 기호 이름은 2천 개가 넘지만, 제목에 실제로 나오는 것만 작은 표로 들고 풉니다. 모르는 이름은 지우지 않고 그대로 둡니다.
문제 3: 그림 — 매번 받으면 느리고, 원본은 너무 크다
이 부분이 성능에서 가장 컸습니다.
카드 그림을 원래 주소로 화면에 띄우면, 목록을 그릴 때마다 링크 수만큼 그 사이트들로 요청이 나갑니다. 링크가 수백 개면 화면을 열 때마다 수백 번입니다. 느리고, 무엇을 열어 보는지 그 사이트들에 계속 알리는 셈이기도 합니다.
고친 것: 그림은 링크를 담을 때 한 번만 받아, 폭 480px JPEG 로 줄여 앱 폴더에 둡니다. 목록 칸의 2배라 고해상도 화면에서도 흐리지 않습니다.
얼마나 줄어드는지 맥의 sips 로 같은 조건(폭 480px, JPEG 품질 82)을 재현해 쟀습니다. 앱은 Electron 의 nativeImage 로 줄이니 크기가 똑같지는 않습니다.
| 그림 | 원본 | 480px JPEG |
|---|---|---|
| GitHub 저장소 카드 (2800×1600 PNG) | 595,705바이트 | 19,556바이트 (약 1/30) |
| 유튜브 썸네일 (이미 480×360 JPEG) | 15,921바이트 | 35,734바이트 (오히려 2배) |
큰 PNG 는 30분의 1로 줄지만, 이미 작은 JPEG 는 다시 압축하면 오히려 커졌습니다. 지금 코드는 폭이 480px 보다 클 때만 줄이지만, 작아도 JPEG 로 다시 저장합니다. 이미 작은 JPEG 는 원본을 그대로 두는 게 맞고, 이 글을 쓰며 찾은 다음 개선거리입니다.
화면은 받아 둔 파일만 ld-preview:// 라는 앱 전용 주소로 엽니다.
protocol.handle('ld-preview', (req) => {
const name = decodeURIComponent(new URL(req.url).pathname.slice(1))
if (!/^[a-f0-9]{16}\.(jpg|png|gif|webp)$/.test(name)) return new Response(null, { status: 404 })
return net.fetch(pathToFileURL(path.join(dir, name)).href)
})
파일 이름은 링크 id 의 해시 16자리만 받아서, ../ 로 폴더 밖 파일을 열 수 없습니다. 화면의 CSP 도 img-src 'self' ld-preview: 로 두어 외부 그림 주소는 아예 뜨지 않게 했습니다. 이제 목록을 그릴 때 밖으로 나가는 요청은 0입니다.
문제 4: 한꺼번에 받으면 사이트에 몰린다
예전 판에서 담은 링크나 백업에서 가져온 링크는 카드가 없습니다. 앱을 켤 때 이걸 한 번에 다 받으면 같은 사이트에 수십 개 요청이 동시에 갑니다.
고친 것: 줄을 세워 동시에 2개씩만 받습니다. 어느 링크도 쓰지 않는 그림 파일은 앱을 켤 때 지웁니다.
받는 동안 화면에서 일이 일어날 수도 있습니다.
- 링크를 지우거나 주소를 바꿨으면 받은 결과를 버립니다.
- 사용자가 제목을 고쳤으면 페이지 제목으로 덮지 않습니다.
- 실패하면 카드 없이 제목과 주소로 보일 뿐, 링크는 그대로입니다.
페이지를 받는 데 드는 시간
같은 날 페이지 두 곳을 한 번씩 받아 봤습니다. 한 번 잰 값이라 참고만 해 주세요.
| 페이지 | 크기 | 걸린 시간 |
|---|---|---|
| 유튜브 영상 | 약 1.38MB | 0.89초 |
| GitHub 저장소 | 약 0.39MB | 0.06초 |
유튜브는 제목을 얻으려면 1MB 가까이 받아야 해서, 링크 하나에 1초 가까이 걸립니다. 담을 때 뒤에서 받으니 화면이 멈추지는 않지만, 2개씩 줄을 세운 이유이기도 합니다.
아직 안 되는 것
- 로그인이 필요한 페이지나 스크립트로 태그를 넣는 페이지는 카드를 못 가져옵니다. 미리보기 로봇과 같은 한계입니다.
- 카드는 한 번 받으면 그대로입니다. 페이지가 바뀌었으면 메뉴의
공유 카드 다시 받기로 받습니다. - 위에 적은 대로, 이미 작은 JPEG 를 다시 압축해 키우는 문제가 남아 있습니다.
- 윈도우 라이트 모드에서 실제 링크로 확인했고, 맥과 다크 모드에서는 아직 확인하지 않았습니다.
정리
| 문제 | 고친 것 |
|---|---|
| 유튜브는 태그가 780KB 뒤 | 상한 512KB → 2MB, 6초 → 12초, </head> 에서 끊지 않음 |
| 사이트마다 다른 태그 | 속성 순서 무관, 대체 태그, & · EUC-KR · 기호 이름 풀기 |
| 목록마다 외부 그림 요청 | 담을 때 한 번 받아 480px 로, 로컬 주소로만 표시 → 요청 0 |
| 켤 때 요청 몰림 | 동시에 2개씩 |
Open Graph 는 "만드는 쪽" 이야기로 많이 다뤄지지만, 읽는 쪽을 직접 만들어 보면 만드는 쪽에서 왜 그렇게 하라고 하는지가 보입니다. 태그는 HTML 에 처음부터, 그림 주소는 절대 주소로, 그림은 적당한 크기로 두라는 이유입니다.