[Next.js 블로그 만들기] - (5)

커버 이미지 자동 생성하기 (@vercel/og)

@vercel/og를 사용해 커버 이미지를 자동으로 생성합니다.

YEAHx4

YEAHx4

2024-10-25

지금까지 커버 이미지는 직접 png 파일로 만들어서 직접 연결시키는 방향이었습니다. 그런데 많은 경우 커버 이미지는 단순히 단색 배경에 간단한 글자입니다. 형식이 계속 반복되는 이미지를 계속 수작업으로 만들어서 넣어주는 것은 굉장히 피곤할 뿐 아니라 시간 낭비입니다. Vercel에서는 반복되고 번거로운 이미지를 자동으로 생성하는 라이브러리를 제공하고 있습니다.

@vercel/og

@vercel/og는 open graph에서 사용할 수 있도록 image generation을 제공합니다. 공식 문서에 따르면 쉽고 빠르게 성능 좋은 이미지를 만들 수 있습니다. 일단 npm을 통해 vercel/og를 설치합니다.

npm install @vercel/og

정상적인 작동을 위해서 Node.js 20 이상과 Next.js 12 이상을 사용해야 합니다. Next의 Api Routes를 통해 이미지를 받는 엔드포인트를 만들 수 있습니다. JSX 컴포넌트를 ImageResponse에 전달해서 JSX를 기반으로 이미지를 만들 수 있습니다. 간단한 스타일과 flex같은 CSS를 지원합니다. 아래의 코드를 /app/api/posts/image/route.tsx에 작성하고 브라우저에서 /api/posts/image로 접근하면 하얀 배경에 Hello가 적힌 이미지를 볼 수 있습니다.

import { ImageResponse } from "next/og";

export async function GET() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 40,
          color: "black",
          background: "white",
          width: "100%",
          height: "100%",
          padding: "50px 200px",
          textAlign: "center",
          justifyContent: "center",
          alignItems: "center",
        }}
      >
        👋 Hello
      </div>
    ),
    {
      width: 1200,
      height: 630,
    }
  );
}

커스터마이징

폰트 추가하기

기본적으로 sans 폰트를 사용하는데 커버 이미지에 쓰기엔 별로 예쁘지 않습니다. vercel/og 에서는 ttf파일(또는 다른 폰트 파일)을 통해 직접 폰트를 지정하는 기능을 제공합니다. 일단 사용할 ttf 파일을 /public/font/font.ttf에 놓습니다. 저는 상주곶감체를 쓸 예정이라 gotgam.ttf에 두었습니다. 그리고, ImageResponse의 설정에 폰트를 추가해야 합니다.

const font = await fetch(getUrl("/font/gotgam.ttf")).then((res) =>
  res.arrayBuffer()
);

// ...

  {
    width: 1200,
    height: 630,
    fonts: [
      {
        name: "gotgam",
        data: font,
        style: "normal",
      },
    ],
  }

//...

참고로 서버사이드에서 동작하기 때문에 fetch의 URL을 그냥 /font/example.ttf처럼 쓰면 제대로 불러오지 못합니다. 그래서 getUrl이라는 유틸 함수를 하나 사용했습니다. Host 헤더를 읽어서 서버사이드에서도 현재 URL을 읽을 수 있게 합니다.

import { headers } from "next/headers";
import { join } from "path";

export const getBaseUrl = () => {
  const headersList = headers();
  const host = headersList.get("host");
  const protocol = headersList.get("x-forwarded-proto") ?? "http";

  return `${protocol}://${host}`;
};

export const getUrl = (path: string) => {
  return join(getBaseUrl(), path);
};

파라미터

/api/posts/image로 GET 요청을 날리면 이미지를 보내주는데, 이미지의 내용을 직접 정할 수 있어야 합니다. 저는 주제목과 부제목을 사용할 예정이기 때문에 아래와 같은 파라미터를 정했습니다.

  • color: 글씨 색
  • bg : 배경색
  • title : 주제목 내용
  • sub : 부제목 내용
  • ts : 주제목 폰트 크기 (title size)
  • ss : 부제목 폰트 크기 (subtitle size)

param을 통해 .../image?color=ff0000&title=hello 같은 모습으로 전달할 수 있게 만들었습니다.

export async function GET(req: NextRequest) {
  const query = req.nextUrl.searchParams;
  const param = Object.fromEntries(query.entries());

  const { color, bg, title, sub, ts, ss } = param;

  const font = await fetch(getUrl("/font/gotgam.ttf")).then((res) =>
    res.arrayBuffer()
  );

// ...

이제 저 파라미터를 가지고 JSX를 만들어서 ImageResponse에 리턴하면 됩니다.

// ...

return new ImageResponse(
    (
      <div
        style={{
          color: `#${color || "000"}`,
          background: `#${bg || "fff"}`,
          width: "100%",
          height: "100%",
          padding: "50px 200px",
          textAlign: "center",
          justifyContent: "center",
          alignItems: "center",
          display: "flex",
          flexDirection: "column",
          fontFamily: "gotgam",
        }}
      >
        <span
          style={{
            fontSize: parseInt(ts || "72"),
            fontWeight: "bolder",
            marginBottom: 20,
          }}
        >
          {title}
        </span>
        <span
          style={{
            fontSize: parseInt(ss || "48"),
          }}
        >
          {sub}
        </span>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "gotgam",
          data: font,
          style: "normal",
        },
      ],
    }
  );
}

그럼 아래 URL로 한번 요청을 날려서 이미지를 만들어 봅시다.

/api/posts/image?title=Main%20Title&sub=Sub%20Title&ts=120&ss=72&bg=000&color=fff

cover example

잘 작동하네요! 한번 만들어진 이미지는 자동으로 캐싱되어 매번 새로 만들지 않습니다.

교체

이제 기존의 cover를 image generation으로 교체해야 합니다. 그렇다고 기존에 사용하던 방식을 완전히 폐지하고 싶지는 않습니다. 필요할 경우 이미지로도 커버 이미지를 사용하고 싶습니다. 그래서 frontmatter에 몇가지 파라미터를 더 추가했습니다. cover가 있을 경우 이전처럼 cover URL로 이미지를 표시하고 아니라면 frontmatter에서 다른 값들을 읽어서 커버 이미지를 만들어야 합니다. 아래 함수를 통해 frontmatter에서 URL을 만들 수 있습니다.

export const buildCoverUrl = (meta: PostMeta) => {
  let url = "/api/posts/image?";

  if (meta.coverTitle) url += `title=${encodeURIComponent(meta.coverTitle)}&`;
  if (meta.coverSub) url += `sub=${encodeURIComponent(meta.coverSub)}&`;
  if (meta.coverColor) url += `color=${encodeURIComponent(meta.coverColor)}&`;
  if (meta.coverBg) url += `bg=${encodeURIComponent(meta.coverBg)}&`;
  if (meta.coverTs) url += `ts=${meta.coverTs}&`;
  if (meta.coverSs) url += `ss=${meta.coverSs}&`;

  return url;
};

이제 이 URL을 커버 이미지 대신에 사용하면 되겠네요.

<img
  src={
    meta.cover
      ? `/img/cover/${meta.cover}`
      : buildCoverUrl(meta)
  }
  alt={meta.title}
  className="w-full h-48 object-cover"
/>

똑같은 코드를 포스트 페이지의 metadata에도 적용하면 됩니다.

이제 단순히 단색 배경에 글씨를 쓰기 위해서 다른 사이트나 프로그램을 켜서 이미지를 만들고, 그 이미지를 다운로드에서 public 폴더에 넣고, 그걸 다시 연결하는 작업을 하지 않아도 됩니다. 포스트 마크다운 파일을 벗어나지 않고도 편하게 커버 이미지를 만들 수 있게 되었습니다!