Next.js에서 동적으로 생성되는 OpenGraph 이미지 구현해보기

2025년 9월 1일

OpenGraph란?

The Open Graph protocol enables any web page to become a rich object in a social graph. For instance, this is used on Facebook to allow any web page to have the same functionality as any other object on Facebook.

(출처: https://ogp.me)

OpenGraph Protocol(이하 OpenGraph)이란 Facebook에서 처음 제안한 메타데이터 규약으로, 웹사이트가 SNS에서 공유될 때 더 많은 정보와 미리 보기를 제공하기 위해 만들어졌다. 현재 OpenGraph는 Facebook뿐만 아니라 Twitter, 카카오톡 등 많은 플랫폼에서 활용되고 있다. 특정 플랫폼(Discord)에서 표시되는 OpenGraph의 형태는 다음과 같다.

discord.png

위 사진과 같은 정보를 보여주려면 사전에 메타데이터를 정의하여 해당 플랫폼이 해석할 수 있도록 알려주어야 한다. 이를 위해 필수적인 메타데이터 속성은 다음과 같다.

  • og:title - 보여줄 제목 (e.g., Node.js로 만든 Worker Application으로 시계열 데이터...)
  • og:type - 웹사이트 종류 (e.g., website, article, video 등)
  • og:image - 보여줄 이미지
  • og:url - 웹사이트 URL

이 외에도 다양한 메타데이터 속성이 있지만, 이미지 생성을 중점으로 다루기 위해 생략하도록 하겠다. 자세한 내용은 이곳에서 참조할 수 있다.

OpenGraph 이미지 생성하기

보통 OpenGraph 이미지의 경우 정적인 이미지 하나를 넣어두는 경우가 많다. 하지만 이번에는 이미지를 상황에 맞게 동적으로 생성하여 사용자에게 의미 있는 정보를 전달해 보고자 한다.

이를 위해선 먼저 이미지를 생성할 수 있어야 하는데, Satori와 같은 라이브러리를 활용하여 정적 HTML을 이미지로 변환할 수 있다. 하지만 우리는 이를 보다 쉽게 구현할 수 있도록 고안된 @vercel/og 라이브러리를 활용하여 이미지 생성 기능을 구현해 볼 예정이다.

그런 다음 이미지 생성이 되었다면 이를 og:image태그에 적용해야 한다. Next.js에서는 이를 도와주는 Handler가 존재하는데, 이를 활용하여 기능을 구현해 보겠다.

1. 파일 생성

Next.js가 인식할 수 있도록 OpenGraph 이미지를 생성하고자 하는 Route segment에 opengraph-image.tsx파일을 생성한다. (e.g., app/records/[slug]/opengraph-image.tsx) 기본 뼈대는 다음과 같이 구성할 수 있다.

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image() {
    return new ImageResponse(
        (
            <div></div> // content
        ),
        {
            ...size // options
        }
    );
}

2. 내용 구성

내용 구성 방법 자체는 기존 HTML 문법과 별반 다르지 않다. 다만, CSS의 경우 모든 property를 지원하지는 않으므로(e.g., grid, z-index) 만약 지원 유무를 확인하고 싶다면 이곳을 참고하자. 폰트의 경우에도 ttf,otf,woff형식만 호환이 된다고 문서에 명시되어 있다. 또한 성능을 위해선 woff보단 ttfotf를 사용할 것을 권장하고 있다.

구현은 앞서 생성한 파일에 React Component를 작성하는 것처럼 HTML 코드를 작성하면 된다.

return new ImageResponse(
        (
            <div id="main" style={{ display: "flex", flexDirection: "column", ... }}>
                <Header>
                    {/* @ts-expect-error Satori supports arraybuffer as src, so it can be ignored */}
                    <img id="profile" src={profileSrc} alt="profile" style={{ width: 72, height: 72, borderRadius: "50%" }} />
                </Header>
                <Content>
                    <h1 id="title" style={{ ... }}>
                        {record.title}
                    </h1>
                    <div id="tags" style={{ ... }}>
                        {record.tags.map((tag: Tag) => (
                            <Badge key={tag.name}>{tag.name}</Badge>
                        ))}
                    </div>
                </Content>
                <Footer>
                    <span>{toDateString(record.created_at)}</span>
                    <span style={{ color: "#6d6d6d" }}>suhan.io</span>
                </Footer>
            </div >
        ), { ... }
);

참고로 Styling의 경우 기존 CSS Module 방식을 사용할 수 없어서 각 태그에 inline으로 작성하는 방식으로 대체했다. <Header /><Content />와 같은 태그의 경우 별도로 생성한 JSX Element로, 코드 가독성과 재사용성(e.g., <Badge />)을 위해 따로 분리했다.

에셋(이미지, 폰트 등)의 경우 node:fs를 통해 파일시스템에서 파일을 불러오는 방식으로 구현할 수 있다.

const maruBuriRegular = await readFile(join(process.cwd(), "public/fonts/MaruBuri-Regular.otf")); // font
const profileData = await readFile(join(process.cwd(), "public/profile.png")); // image
const profileSrc = Uint8Array.from(profileData).buffer;

전체 코드는 이곳에서 확인할 수 있다.

3. 동적 데이터 삽입

내용 구성이 완료되었다면 이제 외부에서 데이터를 가져와서 주입해야 한다. 구현하고자 하는 방향에 따라 다르지만 지금의 경우 게시글에 대한 OpenGraph 이미지를 구성해야 하기에 slugparams로 받아와 데이터 fetch 작업을 통해 데이터를 불러온다.

export default async function Image({ params }: { params: { slug: string } }) {
    const { data, error } = await fetchRecord(encodeURIComponent(decodeURIComponent(params.slug)));
    if (!data || error) throw new Error(error?.message);

    const record = { ...data, tags: data.tags.flatMap((t: { tag: Tag }) => t.tag) };
    
    return new ImageResponse(...);
}

코드를 보면 알 수 있듯이 파라미터로 받은 slug를 통해 내부적으로 fetchRecord()함수를 호출하여 데이터를 불러온 후, 이를 가공하여 ImageResponse에 넘겨준다.

<div id="tags" style={{ ... }}>
    {record.tags.map((tag: Tag) => (
        <Badge key={tag.name}>{tag.name}</Badge>
    ))}
</div>

넘겨준 데이터의 경우 이런 식으로 기존 React Component와 동일한 방식으로 활용할 수 있다.

적용 확인

모든 구현이 정상적으로 완료되었다면 이제 해당 페이지를 SNS나 다른 웹사이트에 공유해 보자. 만약 아직 배포하지 않은 상태라면, 개발자 도구를 통해 <head>태그 내에 있는 <meta property="og:image" content="..." />와 같은 태그를 찾아 그 안에 있는 URL로 직접 접속하면 된다.

Troubleshooting

기능을 배포하고 사용하던 중 갑자기 Vercel 대시보드에 Error Rate가 상승하는 현상을 발견했다. 배포 직후 며칠간 아무 문제가 없었는데, 갑자기 어느 시점을 기점으로 이러한 현상이 발생하여 매우 당황했다. 그리하여 로그를 살펴보니 사전에 정의해놓은 에셋들이 불러와지지 않고 있었다. vercel.png 문제가 되기 시작한 커밋은 SEO 관련 수정사항 커밋으로, 보다시피 OpenGraph 이미지와는 아무런 관계가 없어 배포 환경이 변경되어 발생한 문제로밖에 추측할 수 없었다. 모종의 이유로 해당 커밋 이후 배포 환경이 변경되었고, 변경된 환경으로는 node:fs를 통한 파일시스템에 접근이 불가능해져 해당 오류가 발생한다고 보고, node:fs를 통한 접근 말고 fetch를 통해 접근하여 Edge 런타임 위에서 돌아가도록 수정해보았다.

하지만 수정 후 빌드 과정에서 다른 오류를 만나게 되었다. Function 용량 관련 문제로, 내가 사용 중인 플랜(Hobby)의 최대 용량(1MB)을 초과하여 빌드 자체가 이루어지지 않았다. deploy.png Function을 Edge 런타임 위에 돌리기 위해 파일 접근 방식을 fetch로 변경하면서 빌드 시점에 해당 파일들이 번들에 포함되면서 전체 크기가 증가하여 빌드가 실패하는 문제였다. 따라서 부득이하게 아래와 같이 외부에서 파일을 가져오는 방식으로 수정하여 번들 크기를 줄임(2.31 MB -> 861 kB)으로써 빌드에 성공할 수 있었다.

const maruBuriRegular = await fetch(`${process.env.NEXT_PUBLIC_BASEURL}/fonts/MaruBuri-Regular.otf`).then((res) => res.arrayBuffer());
const maruBuriSemiBold = await fetch(`${process.env.NEXT_PUBLIC_BASEURL}/fonts/MaruBuri-SemiBold.otf`).then((res) => res.arrayBuffer());

마무리

이번 글에서는 OpenGraph의 기본 개념부터 Next.js에서 동적으로 OpenGraph 이미지를 생성하고 적용하는 과정까지 정리해보았다. 단순히 구현 과정을 넘어, 그 과정에서 마주친 문제와 해결 방안까지 함께 기록해 보았는데, 혹시 누군가가 이 글을 읽게 된다면 도움이 되길 바란다.

댓글