Next.js APP에서 og:image 자동으로 생성하기
개발 이야기오늘은 og 태그가 뭐하는 놈인지, og:image는 어떻게 구현되는지, 그리고 Next.js에서 자동으로 og:image를 생성하려면 어떻게 해야하는지 글 하나로 끝내볼까 한다.
대략적인 og:image 소개
일단 og:image가 뭔지는 다들 알고 있으리라.. 믿긴 하는데, 설명하자면 og는 OpenGraph의 약자다. OpenGraph 프로토콜은 페이스북이 만들어 2010년에 발표했으며, 간략히 설명하여 SNS에서 링크 공유할 때 미리보기를 만들어주는 기능을 한다.
og:title - 링크의 제목.
og:type - 링크의 타입. 다른것도 많지만 주로 website나 article 위주로 사용하게 될 것이다.
og:image - 오늘의 주제인 미리보기 이미지.
og:url - 링크 URL
위와 같은 내용을, 다음과 같이 사용한다.
<html prefix="og: https://ogp.me/ns#">
<head>
<title>Next.js APP에서 og:image 자동으로 생성하기</title>
<meta property="og:title" content="The Rock" />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://miriya.vercel.app/blog/MKkWLVdLpZo0tARvG8za/" />
<meta property="og:image" content="https://ia.media-imdb.com/images/rock.jpg" />
...
</head>
...
</html>
위 내용이 핵심 요소고, 자세한건 링크에 들어가서 정독 해보자.
아무튼 저 위에 내용 처럼 HTML 헤더 부분을 채우면, '잘 세팅했을 경우' URL을 복사해서 페이스북에 붙여넣으면 다음과 같이 뜨게 된다.
요런 느낌으로다가 미리보기가 잘 나오면 성공이다. 만약 '잘 세팅하지 못했을 경우', 다음과 같이 애매하게 나오게 된다.
글 쓰기 창에서부터 미리보기 이미지가 나오지 않는다.
디버깅
이럼 이제 어떻게 해야 하냐면, 페이스북 공유 디버거를 사용해서 문제를 파악하면 된다. 링크로 들어가서, 공유 디버거 인풋 창에 내가 미리보기를 보여주길 원하는 URL을 입력하고 디버그 버튼을 눌러보자.
잠시 기다리면 이런 식으로 화면이 뜨는데, '링크 미리 보기'에 이미지가 나와야 하는데 안나오고 있다. 맨 위 '해결해야 하는 경고' 부분에 뭐가 문제인지 애매하게 나온다.
og:image 속성은 다른 태그에서 추정할 수 있는 값이더라도 명확하게 입력해야 합니다.
뭔 말이냐면, 그냥 솔직히 까놓고 말해서 '니 og:image 대체 뭔 소린지 모르겠다' 라는 말이다.
저거 영문 버전으로 구글 검색해봐도 솔직히 디버깅에 도움되는 정보는 나오지 않을 것이다.
이제 맨 아래로 내려가서 '스크래이퍼에게 표시되는 URL 그대로 보기'를 클릭해보자. 여길 확인해야한다.
<!DOCTYPE html>
<html id="__next_error__">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta charset="utf-8">
<meta name="next-size-adjust">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<script src="/_next/static/chunks/polyfills-c67a75d1b6f99dc8.js" nomodule=""></script>
</head>
<body>
<script src="/_next/static/chunks/webpack-8326ca315476a05e.js" async=""></script><script src="/_next/static/chunks/2443530c-4b70f557379d49d2.js" async=""></script><script src="/_next/static/chunks/4477-23fdafbc4605174a.js" async=""></script><script src="/_next/static/chunks/main-app-c00b468b3276aa8b.js" async=""></script>
</body>
</html>
보면 응당 있어야 할 <meta property="og:image" content="https://..." />
이 보이지 않는다. 위 예제는 내가 대충 존재하지 않는 URL인 https://miriya.vercel.app/blog/ㅇㄴㅁㄹ
을 입력해서 404 페이지가 뜬거고, 404 페이지에 og:image가 없기 때문에 페이스북이 알아먹지 못한 것이다.
og:image 메타태그가 정상적으로 들어갔고, 해당 URL을 복붙해서 브라우저에서 열릴 때 비로소 페이스북 스크래이퍼가 og:image를 긁어다가 미리보기에 사용할 수 있는 것이다.
CSR 환경에서의 어려움
만약 Next.js가 아니라 Creative React App (이하 CRA) 등의 CSR(Client Side Rendering) 방식을 사용하고 있을 경우, 일반적인 방식으론 og:image를 제대로 줄 수 없다. CRA에선 public/index.html 에 수동으로 메타태그를 집어넣어야 하는데, 웹 서비스 전체를 대표하는 og:image 한장을 넣는데는 문제가 없다. 하지만 페이지별로 미리보기를 다르게 주는게 불가능하다.
뭐 어쩔텐가, useEffect 안에서 라우트 변화를 감지하여 setAttribute로 직접 넣을건가? 이렇게 작업하면 우리 로컬에서 개발할 때는 정상적으로 보일 것이다. 개발자도구를 열어서 소스를 확인해보면 메타태그가 잘 들어가 있겠지. 하지만 구글 봇이 우리 웹 사이트에 들어오면 바로 보이는 html 파일에는 메타태그가 없기 때문에 프로덕션에서는 적용이 안되는 뭣같은 경우가 생긴다. CSR 방식은 다들 알다시피 껍데기뿐인 html 파일을 로딩한 다음, 그 위에서 js를 돌려 그려내는 방식이기 때문에 처음 받은 html과 나중에 그려진 html 파일의 차이가 생길 수 밖에 없다.
그럼 setAttribute 방식은 재껴야 할 것이고, 렌더링 전에 html을 바꿔줘야 한다. 이 부분은 이 포스팅의 주제와 벗어나는 부분이기 때문에 다루지 않을 것이다. react-snap 라이브러리, 그리고 react prerendering seo
등의 키워드로 검색해서 알아서 해보자. react-helmet
은 대안이 될 수 없다. 나는 Next.js의 SSR로 도망갈란다.
og:image 자동으로 생성하기
이 강좌의 자동 생성 부분을 제대로 진행하려면, 먼저 미리 만들어둔 한장짜리 og:image가 제대로 나오는지 확인 먼저 하는게 순서다. 공식문서 Metadata - SEO 도 읽고 오자.
자 그럼 딸기든 파인애플이든 야짤이든 미리 만들어둔 og:image가 페이스북 디버거에 제대로 나오는걸 확인했다는 가정하에 다음 이야기를 적어보겠다.
현재 내 프로젝트의 폴더 구조는 다음과 같다.
저기 보이는 opengraph-image.tsx 파일에 아래 내용을 붙여넣어보자.
import { ImageResponse } from 'next/server';
export const alt = 'About Acme';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
export const runtime = 'edge';
export default function og() {
return new ImageResponse(
(
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
예아 잘 나온다
</div>
),
{
...size,
},
);
}
위는 공식문서에 나오는 내용을 복붙한 다음, 작동 안하는 폰트 부분을 제거한 버전이다. Next.js가 업데이트가 빠르다 보니 공식 문서가 이런 식으로 대충 비어있는 부분이 많다.
저장까지 했으면 브라우저로 해당 페이지를 열어보자.
예를 들어 위 문서를 열어보라는 뜻이다.
이 상태에서 개발자 도구를 열어보자.
위 스샷에 보이는 것 처럼 <meta property="og:image" ... />
가 나와야 한다. 저게 안나온다면 뭔가 작살났겠지. 이제 og:image에 보이는 content URL을 복사해서 브라우저에 붙여넣어보자. URL 뒤가 jpg나 png로 끝나지 않는다고 걱정 안해도 된다.
이렇게 나와야 한다. 저 이미지에 우클릭해서 저장해보면 png 파일로 잘 저장되는걸 볼 수 있다. 우리가 아까 복붙한 JS 파일로 인해 이미지가 리턴된 것이다.
그러면 이제 남은건 글 제목이나 이미지를 넣어서 화려하게 꾸미는 일이다. 내 경우에는 아래와 같이 블로그 글 제목을 가져오는 코드를 짜봤다.
import { ImageResponse } from 'next/server';
import { getPost } from './utils';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
export const runtime = 'edge';
interface Props {
params: {
postId: string;
};
}
const og = async ({ params: { postId } }: Props) => {
try {
const postData = await getPost(postId);
return new ImageResponse(
(
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{postData.title}
</div>
),
{
...size,
},
);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
return null;
}
};
export default og;
postId 받아서, 글 데이터 받아오고, 글 데이터에서 제목 만들어 넣은것 빼곤 컨벤션만 조금 다르니 알아서 이해하자.
새로고침해보면 이런 식으로 실제 글 제목이 들어가는걸 알 수 있다. 데이터 관련된 부분은 이거 참고하면 되고, 꾸미기 관련된 부분은 아래에 내 풀 코드 참고해보자.
/* eslint-disable @next/next/no-img-element */
import { ImageResponse } from 'next/server';
import { getPost } from './utils';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
export const runtime = 'edge';
interface Props {
params: {
postId: string;
};
}
// https://og-playground.vercel.app/
// https://github.com/vercel/satori#documentation
// https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation/og-image-examples#load-files-in-node.js-runtime
const og = async ({ params: { postId } }: Props) => {
try {
const postData = await getPost(postId);
// woff2는 지원 안됨, ttf/otf가 컴파일이 빠르다.
const font = await fetch(new URL(`${process.env.NEXT_PUBLIC_LOCAL_FETCH_URL}/fonts/SpoqaHanSansNeo-Bold.ttf`)).then(
(res) => res.arrayBuffer(),
);
// zIndex 지원 안됨, 나중에 올라오는게 위에 올라간다.
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src={postData.hero ?? 'https://ik.imagekit.io/miriya/tr:w-1200,c-at_max/mycar/hL0XAW5GmEpx7Vn5czGs.jpg'}
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
width: '100%',
objectFit: 'cover',
}}
alt=''
/>
<div
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: 'rgba(0 0 0, 0.3)',
}}
/>
<div
style={{
position: 'absolute',
top: 0,
right: '14px',
bottom: 0,
left: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<p
style={{
padding: '14px 32px',
fontSize: 92,
fontWeight: 500,
fontFamily: 'Spoqa',
color: 'white',
textAlign: 'center',
backgroundColor: 'rgba(0 0 0, 0.3)',
}}
>
{postData.title}
</p>
</div>
</div>
),
{
...size,
fonts: [
{
name: 'Spoqa',
data: font,
weight: 700,
style: 'normal',
},
],
},
);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
return null;
}
};
export default og;
위 코드가 잘 동작하면 이렇게 보인다.
여기에서 참고할 점은 다음과 같다.
- Satori 라이브러리는 z-index를 따로 지원하지 않는다. 대신 뒤에 오는 엘리먼트가 위에 쌓이는 형태다.
- 흔히 폰트 파일로 woff2 파일을 쓰는게 좋지 않냐고 생각할 수 있는데, 공식 문서에서는 렌더링이 빠르다고 ttf/otf 파일을 추천한다.
- 페이스북 og:image 사이즈는 1200x530px를 쓰면 된다.
- backdrop-filter가 안먹혀서 팬시한 블러 효과는 낼 수 없다.
사용 가능한 CSS 스펙에 대해서는 Vercel의 HTML/CSS => SVG 라이브러리인 Satori의 문서를 참고하자. 실시간으로 변하는 모습을 보면서 테스트해보려면 OG Playground에서 맘껏 해보자. 보면 텍스트에 컬러 그라데이션을 넣는 등 참고할만한게 좀 보인다.
으악 용량이 너무 크다
원래 여기까지만 쓰고 강좌를 마치려고 했는데, 생성된 OG image가 1.4MB 사이즈나 되더라. 아 형 이건 좀 아니지 않을까.. export const contentType = 'image/png';
이 부분을 수정하는건 효과가 없었다.
Satori 라이브러리를 좀 뒤져보니 이런 이슈가 있더라. 나랑 동일하게, 뒷 배경에 화려한 이미지를 넣을 경우 용량이 900kb에 육박하는 문제라 JPG도 지원하면 안되느냐는 말이다. @napi-rs/image라는 라이브러리에서 svg 인풋을 jpeg 아웃풋으로 변환할 수 있어 이걸 쓰면 된다고 한다. 용량이나 이런 문제들 때문에 Satori 라이브러리에서 jpeg 지원을 해주는 것은 당분간 기대하기 힘들어보인다.
import satori from "satori";
import { Transformer } from "@napi-rs/image"
export async function createOG() {
const svg = await satori(<div>test</div>, { width: 600, height: 400 })
const trasformer = Transformer.fromSvg(svg); // satori result
const jpeg = await trasformer.jpeg();
return jpeg; // buffer
}
이슈에서 언급해준 코드와 함께 좀 정리를 해보면 다음과 같다.
- ImageResponse : Element와 옵션을 인풋으로 받아 Readable stream으로 리턴한다.
- ImageResponse : 내부적으로 @vercel/og를 SVG 빌딩용으로 쓰는 것 같다.
- @vercel/og : 내부적으로 Satori와 resvg를 사용하여 HTML + CSS => SVG => PNG로 컨버팅한다.
- Satori : Element와 옵션을 인풋으로 받아 text 형태의 svg를 리턴한다.
- resvg.js : svg를 png로 컨버팅한다.
- @napi-rs/image : svg를 jpeg로 컨버팅한다.
으아 킹받는다..
ImageResponse에 Satori의 리턴 값을 그대로 넣는건 불가능하고, Satori의 text 리턴 값을 stream으로 변환하여 리턴. ImageResponse는 버리고 사용해야 할것 같다.
opengraph-image.tsx 파일을 ImageResponse 없이 동작시켜내는 것이 첫째, 이게 잘 되면 Satori의 text 리턴 값을 @napi-rs/image를 이용하여 jpeg로 컨버팅해서 동작시켜내는 것이 둘째다.
셋째로 기존에 만들어두었던 Next의 generateMetadata를 튜닝해서 어울리게 만들어야한다.
몇번 트라이해보다가 너무 귀찮아서 용량에 대해 더 알아봤는데, 공식 문서를 보니 8mb 까지 가능하다더라. 그리고 og:image의 경우 클라이언트에 바로 받는게 아니라 페이스북 측에서 어차피 열화시켜 제공하기 때문에 문제가 없다는 결론이 나왔다. 따라서 그냥 쓰자.