링크를 메신저나 SNS에 붙여 넣으면 제목과 함께 큰 이미지가 뜬다. 이 이미지를 정하는 것이 og:image 메타 태그다. dailbit.com은 원래 모든 페이지가 630×630 정사각형 로고 이미지 하나를 공유 이미지로 쓰고 있었다. 정사각형 이미지는 가로로 긴 미리보기 카드에서 위아래가 잘리거나 작게 표시되고, 어떤 글을 공유해도 같은 이미지가 떠서 무슨 내용인지 알 수 없었다.

그래서 블로그 글과 프로젝트 페이지마다 제목이 들어간 1200×630 이미지를 빌드할 때 자동으로 만들도록 바꿨다. 이 글은 그 과정에서 만난 문제와 해결 방법을 정리한다.

next/og와 Satori

Next.js에는 JSX로 이미지를 그리는 ImageResponse(next/og)가 들어 있다. 내부적으로는 Vercel의 Satori가 JSX와 일부 CSS를 SVG로 바꾸고, 그 SVG를 PNG로 렌더링한다. HTML과 CSS로 레이아웃을 짜듯이 이미지를 만들 수 있어서 편하다.

return new ImageResponse(
  (
    <div style={{ display: "flex", width: "100%", height: "100%", flexDirection: "column",
                  justifyContent: "space-between", padding: "72px 80px", background: "#080b12" }}>
      <span style={{ fontSize: 34, fontWeight: 800 }}>DailBit</span>
      <div style={{ display: "flex", fontSize: 72, fontWeight: 800 }}>{title}</div>
    </div>
  ),
  { width: 1200, height: 630, fonts }
);

Satori는 브라우저가 아니라서 CSS를 전부 지원하지 않는다. 레이아웃은 flexbox만 되고, 자식이 여러 개인 요소에는 display: "flex"를 명시해야 한다. 그리고 가장 큰 문제는 폰트였다.

폰트를 넘기지 않으면 한글이 네모로 나온다

아무 설정 없이 한글 제목을 넣으면 글자가 모두 빈 네모(두부 문자)로 나온다. Satori는 시스템 폰트를 쓰지 않는다. 사용할 폰트 파일을 fonts 옵션으로 직접 넘겨야 하고, 넘기지 않으면 기본 내장 폰트만 쓰는데 여기에는 한글 글리프가 없다.

이 사이트는 이미 @fontsource-variable/noto-sans-kr 패키지로 본문 폰트를 쓰고 있으니 그 파일을 넘기면 될 것 같았다. 그런데 패키지 안을 보면 파일이 전부 woff2다. Satori는 TTF, OTF, WOFF는 읽지만 woff2는 지원하지 않는다. woff2는 Brotli 압축과 글리프 데이터 변환이 들어간 포맷이라 풀려면 별도 디코더가 필요하다.

선택지는 세 가지였다.

  1. Noto Sans KR TTF를 저장소에 넣는다 → 한글 전체 폰트는 굵기 하나에도 수 MB라 저장소가 무거워진다.
  2. woff2를 TTF로 변환하는 도구를 빌드에 넣는다 → 의존성이 늘어난다.
  3. 빌드할 때 필요한 글자만 담은 TTF를 받아온다 → 처음에 채택한 방식. 이후 이 서브셋을 저장소에 두는 방식으로 바꿨다(아래 "업데이트" 참고).

Google Fonts의 text 파라미터

Google Fonts CSS API에는 text 파라미터가 있다. 이 파라미터에 글자를 넘기면 그 글자들만 들어 있는 작은 서브셋 폰트를 돌려준다. 또 요청에 최신 브라우저의 User-Agent가 없으면 woff2 대신 TTF 주소를 준다. 빌드 스크립트의 fetch는 브라우저 UA를 보내지 않으므로 자연스럽게 TTF를 받는다.

// Satori는 woff2를 읽지 못하므로 빌드 시 필요한 글자만 담긴 TTF 서브셋을 받아온다.
// 네트워크가 막힌 빌드 환경에서도 실패하지 않도록 실패 시 null을 반환한다.
async function loadKoreanFont(text: string, weight: number): Promise<ArrayBuffer | null> {
  try {
    const cssUrl = `https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@${weight}&text=${encodeURIComponent(text)}`;
    const css = await (await fetch(cssUrl)).text();
    const fontUrl = css.match(/src: url\((.+?)\) format\('(?:opentype|truetype)'\)/)?.[1];
    if (!fontUrl) return null;
    const res = await fetch(fontUrl);
    return res.ok ? await res.arrayBuffer() : null;
  } catch {
    return null;
  }
}

이미지에 들어갈 문자열(라벨, 제목, 설명, 하단 문구)을 모두 이어 붙여 text로 넘기면, 제목이 "React 웹 프레임워크 선택 가이드"인 이미지에는 그 글자들만 담긴 작은 서브셋 폰트가 내려온다. 굵기는 제목용 800과 본문용 400 두 가지를 받았다.

네트워크가 없으면? 깨진 글자 대신 영문만

이 방식은 빌드 환경에서 Google Fonts에 접속할 수 있어야 한다. 사내망이나 일부 CI처럼 외부 접속이 막힌 곳에서는 실패한다. 공유 이미지 때문에 사이트 배포 전체가 실패하는 것은 과하다고 판단해, 폰트를 받지 못하면 한글이 들어간 요소를 그리지 않고 영문 레이아웃으로 대체하도록 했다.

const hasNonLatin = (text: string) => /[^\u0000-ɏ -]/.test(text);

const [bold, regular, icon] = await Promise.all([
  loadKoreanFont(allText, 800),
  loadKoreanFont(allText, 400),
  readFile(path.join(process.cwd(), "app/icon.png"), "base64"),
]);
const fontsReady = bold && regular;
// 폰트가 없는데 한글이 있으면 두부 문자 대신 빈 문자열로
const safe = (text: string) => (fontsReady || !hasNonLatin(text) ? text : "");

제목이 한글이면 대신 "Daily ideas, built bit by bit" 같은 기본 문구가 들어간다. 이상적이지는 않지만 네모 글자가 가득한 이미지보다는 낫다. 정규식은 라틴 문자와 일반 구두점 범위 밖의 글자가 있는지만 본다.

static export에서 이미지 파일로 내보내기

Next.js에는 opengraph-image.tsx 파일 규칙도 있다. 폴더에 이 파일을 두면 해당 경로의 공유 이미지를 자동으로 만들고 메타 태그도 붙여 준다. 하지만 이 사이트는 이 규칙 대신 Route Handler로 .png 파일을 직접 만드는 방식을 택했다.

app/og/default.png/route.tsx          → out/og/default.png
app/og/blog/[file]/route.tsx          → out/og/blog/<slug>.png
app/og/projects/[file]/route.tsx      → out/og/projects/<slug>.png
// app/og/blog/[file]/route.tsx
export const dynamic = "force-static";
export const dynamicParams = false;

export function generateStaticParams() {
  // 경로 파라미터에 확장자까지 넣어 결과 파일 이름을 .png로 만든다
  return getAllPosts().map(({ slug }) => ({ file: `${slug}.png` }));
}

export async function GET(_req: Request, { params }: { params: Promise<{ file: string }> }) {
  const { file } = await params;
  const post = getAllPosts().find((p) => `${p.slug}.png` === file);
  if (!post) return new Response("Not found", { status: 404 });
  return renderOgImage({ label: "DAILBIT NOTES", title: post.title,
                         footer: `${post.date} · ${post.tags.map((t) => `#${t}`).join(" ")}` });
}

이렇게 한 이유는 두 가지다.

  1. 결과물이 확장자가 있는 평범한 PNG 파일이다. 정적 호스팅에서 Content-Type이 확장자로 정해지므로 확실하게 image/png로 제공된다.
  2. 메타데이터를 한곳에서 관리할 수 있다. 각 페이지의 generateMetadata에서 /og/blog/${slug}.png 경로와 1200×630 크기를 직접 지정한다. 파일 규칙을 쓰면 루트에 둔 이미지가 하위 페이지 전체에 적용되고 metadata 객체의 이미지 설정보다 우선한다. 원래의 630×630 로고가 모든 페이지에 붙어 있던 것도 루트의 app/opengraph-image.png 때문이었다.

빌드 로그에는 이미지 경로가 페이지처럼 나열되고, out/og/ 아래에 블로그 글 수 + 프로젝트 수 + 기본 이미지 1장의 PNG가 생긴다. file 명령으로 확인하면 모두 1200 x 630이다.

레이아웃에서 신경 쓴 것

  • 제목 길이에 따라 글자 크기 조절: 16자 이하는 88px, 28자 이하는 72px, 그보다 길면 60px. 긴 제목이 세 줄을 넘지 않게 했다.
  • wordBreak: "keep-all": 한글은 기본적으로 글자 단위로 줄이 바뀐다. "테스트입니다"가 "테스"와 "트입니다"로 나뉘는 걸 막으려면 단어 단위 줄바꿈을 지정해야 한다. 처음 렌더링에서 프로젝트 설명이 단어 중간에서 끊겨 이 옵션을 추가했다.
  • 설명은 70자에서 자르기: 공유 이미지는 썸네일 크기로 보이는 경우가 많아 긴 문장은 읽히지 않는다.
  • 로고는 base64로 인라인: Satori의 <img>는 외부 URL도 받지만, 빌드 중에 로컬 파일을 읽어 data URL로 넣는 편이 네트워크 의존이 없다.

업데이트(2026-10-16): 서브셋을 저장소에 두는 방식으로

위에서 설명한 "빌드할 때 Google Fonts에서 받아오는" 방식은 지금 사이트에서 쓰지 않는다. 커밋 메시지에 남긴 이유는 간헐적인 OG 라우트 prerender 실패를 없애기 위해서였다. 지금은 lib/fonts에 Noto Sans KR의 400, 800 서브셋 TTF를 두고 lib/og.tsx가 빌드 중에 읽는다. 빌드 중에 네트워크를 쓰지 않는다.

서브셋의 실제 크기는 다음과 같다(2026-10-23 확인).

파일 크기
NotoSansKR-400.subset.ttf 552,192바이트(약 539KiB)
NotoSansKR-800.subset.ttf 551,704바이트(약 539KiB)

포함 글자는 ASCII, KS X 1001 한글 2,350자, 라틴·구두점·기호·한글 자모, 그리고 제작 시점의 content/·lib/ 텍스트에 쓰인 글자다. 목록에 없는 글자(예: 드문 한글)를 제목에 쓰면 빈 칸으로 그려질 수 있어서, 그런 글자가 필요하면 서브셋을 다시 만들어야 한다. 위에서 선택지 1번으로 적은 "저장소가 무거워진다"는 걱정은 글자를 줄인 서브셋이라 굵기마다 약 540KB 수준으로 끝났다. 그래도 네트워크 실패 시 영문으로 대체하는 아래의 폴백 코드는 현재 방식에서는 필요하지 않다.

현재 빌드가 만든 공유 이미지는 out/og/blog/ 아래에 글마다 한 장씩, 지금 18개이고 한 장당 평균 약 76KB(PNG, 1200×630)다. 그중 두 장이 아래와 같다.

홀리맨 글의 공유 이미지(1200×630). 어두운 배경에 글 제목이 큰 글씨로 들어간다

이 글의 공유 이미지(1200×630). 같은 레이아웃에 이 글의 제목이 들어간다

정리

문제 해결
한글이 네모로 나옴 fonts 옵션에 한글 폰트 전달
fontsource 패키지는 woff2뿐 TTF 서브셋을 사용(처음엔 Google Fonts text 파라미터로 다운로드, 지금은 저장소의 lib/fonts)
빌드 환경에 네트워크가 없을 수 있음 실패 시 한글 요소를 숨기고 영문 레이아웃으로
파일 이름과 Content-Type Route Handler + 파라미터에 .png 포함
한글 줄바꿈 wordBreak: "keep-all"

공유 이미지는 사이트 안에서는 보이지 않지만, 사이트 밖에서 사람들이 가장 먼저 보는 얼굴이다. 페이지마다 제목이 들어간 이미지를 갖추고 나니, 같은 링크라도 무슨 글인지 한눈에 알 수 있게 되었다.