goodchuck 블로그

Open Graph 로 링크 미리보기 만들기 — 유튜브는 og 태그가 780KB 뒤에 있었다

카카오톡이나 슬랙에 링크를 붙이면 제목 · 설명 · 그림이 붙은 미리보기가 뜹니다. 이 미리보기의 재료는 대부분 그 페이지에 들어 있는 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:typewebsite · 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:image782,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) 을 넣어 버려지는지 확인합니다.

주소 안의 &amp; 도 풀어야 합니다. 위 유튜브 페이지의 og:image 값에도 …=&amp;rs=… 가 들어 있습니다. HTML 안이라 & 를 &amp; 로 적은 것이니, 그대로 요청하면 다른 주소가 됩니다.

문자셋. 국내 옛 사이트는 아직 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 &middot; Kenney &ndash; 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.38MB0.89초
GitHub 저장소약 0.39MB0.06초

유튜브는 제목을 얻으려면 1MB 가까이 받아야 해서, 링크 하나에 1초 가까이 걸립니다. 담을 때 뒤에서 받으니 화면이 멈추지는 않지만, 2개씩 줄을 세운 이유이기도 합니다.

아직 안 되는 것

  • 로그인이 필요한 페이지나 스크립트로 태그를 넣는 페이지는 카드를 못 가져옵니다. 미리보기 로봇과 같은 한계입니다.
  • 카드는 한 번 받으면 그대로입니다. 페이지가 바뀌었으면 메뉴의 공유 카드 다시 받기 로 받습니다.
  • 위에 적은 대로, 이미 작은 JPEG 를 다시 압축해 키우는 문제가 남아 있습니다.
  • 윈도우 라이트 모드에서 실제 링크로 확인했고, 맥과 다크 모드에서는 아직 확인하지 않았습니다.

정리

문제고친 것
유튜브는 태그가 780KB 뒤상한 512KB → 2MB, 6초 → 12초, </head> 에서 끊지 않음
사이트마다 다른 태그속성 순서 무관, 대체 태그, &amp; · EUC-KR · 기호 이름 풀기
목록마다 외부 그림 요청담을 때 한 번 받아 480px 로, 로컬 주소로만 표시 → 요청 0
켤 때 요청 몰림동시에 2개씩

Open Graph 는 "만드는 쪽" 이야기로 많이 다뤄지지만, 읽는 쪽을 직접 만들어 보면 만드는 쪽에서 왜 그렇게 하라고 하는지가 보입니다. 태그는 HTML 에 처음부터, 그림 주소는 절대 주소로, 그림은 적당한 크기로 두라는 이유입니다.