Next.js에서 Open Graph 이미지 생성하기

2025-04-04

Next.js에서 Open Graph 이미지를 다이나믹하게 생성하는 방법을 공부하면서 겪었던 시행착오와 해결 과정을 정리한 글입니다. 공식 문서를 따라 구현했지만 예상치 못한 문제가 있었고, 이를 디버깅하면서 알게 된 Edge Runtime의 특성과 동작 방식에 대해 정리해 두었습니다.

목표

  • OG 이미지용 opengraph-image.tsx 하나로 동적 이미지 생성
  • API로부터 받아온 데이터를 이미지로 표시
  • 커스텀 폰트와 배경 이미지 사용
  • Edge 환경 대응 (환경변수 미사용)

폴더 구조 및 기본 세팅

/public
  └─ open-bg.png
  └─ IropkeBatang.woff

/app/epigrams/[id]/opengraph-image.tsx

ImageResponse

Open Graph 이미지를 생성하는 데 사용된 next/og 패키지의 ImageResponse는 내부적으로 Satori라는 라이브러리를 사용합니다. Satori는 HTML과 CSS를 기반으로 SVG를 생성하는 라이브러리지만, CSS 지원에 일부 제약이 있습니다. 사용 가능한 스타일 속성은 완전한 CSS와 다르며, 몇몇 속성은 아예 지원하지 않거나, 제한적으로만 동작합니다. 실제 어떤 스타일 속성을 사용할 수 있는지는 Satori github에서 확인할 수 있습니다.

https://github.com/vercel/satori

작업 과정과 시행착오

작업은 Next.js 공식 문서의 ImageResponse 예제를 참고하여 시작했습니다. 공식 예제에서는 readFile, join 같은 Node.js 전용 API를 사용하고 있었고, 저도 자연스럽게 그 방식을 따라 사용했습니다.

Metadata Files: opengraph-image and twitter-image

처음에는 아무런 문제 없이 잘 작동한다고 생각했습니다. 로컬 환경에서는 Node.js 런타임이기 때문에, 파일 시스템 API도 잘 동작했고 환경변수도 문제없이 읽혔습니다.

하지만 배포 후 이미지가 로딩되지 않는 문제가 발생했습니다. vercel logs를 확인해 보니, 이미지나 폰트를 불러오는 URL이 잘못되어 있었습니다. 이때 처음으로 "이 파일이 Node.js가 아니라 Edge 환경에서 실행되고 있는 게 아닐까?"라는 의문이 들었습니다.

공식 문서를 다시 살펴보니, opengraph-image.tsx에서 dynamic segment (params)를 사용할 경우, 해당 이미지는 빌드 시점에 정적으로 캐시 되지 않고 런타임에 생성된다는 내용을 확인할 수 있었습니다. 결국 이 파일은 배포 후 요청 시점에 Edge Function으로 실행되고 있었던 것입니다.

By default, generated images are statically optimized (generated at build time and cached) unless they use Dynamic APIs or uncached data.

이로 인해 아래와 같은 문제가 발생했습니다:

  • readFile 같은 Node.js 전용 API는 사용할 수 없습니다
  • process.env.APP_URL 같은 일반 환경변수도 읽히지 않습니다
  • 상대 경로로 fetch('/open-bg.png') 등은 실패합니다

해결책

해결을 위해 아래와 같은 방식으로 코드를 수정했습니다.

  • 이미지와 폰트파일은 public 폴더로 이동
  • process.env.APP_URL 대신 headers에서 host명을 가져와서 사용
  • 리소스 요청은 위 URL로 절대경로로 요청하며, fetch로 요청

전체코드

import { ImageResponse } from 'next/og';
import { headers } from 'next/headers';
import { truncateText } from '@/utils/truncateText';

export const contentType = 'image/png';
export const alt = '에피그램';
export const size = { width: 1200, height: 630 };

const API_URL = '...';
const FONT_PATH = '/IropkeBatang.woff';
const FONT_NAME = 'Iropke';
const FONT_SIZE = 100;
const FONT_COLOR = 'black';
const BG_PATH = '/open-bg.png';
const BG_COLOR = '#fafafa';
const FALLBACK_CONTENT = '에피그램';
const CONTENT_MAX_LENGTH = 8;

export default async function Image({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const headersList = await headers();
  const host = headersList.get('host');
  const protocol = host?.includes('localhost') ? 'http' : 'https';
  const APP_URL = `${protocol}://${host}`;

  let fontData: ArrayBuffer | undefined;
  let bgBase64: string | undefined;
  let content = FALLBACK_CONTENT;

  try {
    const [fontRes, bgRes, dataRes] = await Promise.all([
      fetch(`${APP_URL}${FONT_PATH}`),
      fetch(`${APP_URL}${BG_PATH}`),
      fetch(`${API_URL}/${id}`),
    ]);

    if (!fontRes.ok) throw new Error('Font fetch failed');
    if (!bgRes.ok) throw new Error('Background fetch failed');
    if (!dataRes.ok) throw new Error('Data fetch failed');

    const [font, bg, data] = await Promise.all([
      fontRes.arrayBuffer(),
      bgRes.arrayBuffer(),
      dataRes.json(),
    ]);

    fontData = font;
    bgBase64 = `data:image/png;base64,${Buffer.from(bg).toString('base64')}`;

    if (data.content) {
      content = truncateText(data.content, CONTENT_MAX_LENGTH);
    }
  } catch (error) {
    console.error('Fail to generate og-image', error);
  }

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          fontSize: FONT_SIZE,
          color: FONT_COLOR,
          backgroundImage: bgBase64 ? `url(${bgBase64})` : undefined,
          backgroundSize: 'cover',
          backgroundPosition: 'center',
          backgroundColor: BG_COLOR,
        }}
      >
        {content}
      </div>
    ),
    {
      ...size,
      fonts: fontData
        ? [
            {
              name: FONT_NAME,
              data: fontData,
              style: 'normal',
              weight: 400,
            },
          ]
        : [],
    },
  );
}

적용모습

오픈그래프 이미지

마무리

이번 작업을 통해 Next.js에서의 렌더링 환경 차이와 그로 인한 제약, 그리고 이를 해결하기 위한 실제적인 대응 방식을 알게 된 좋은 경험이었습니다. Edge Runtime이라는 환경 특성을 직접 부딪히며 체감할 수 있었고, 로컬과 배포 환경의 동작 차이를 구체적으로 이해할 수 있었습니다.