Next.js 12버전에서 동적으로 Opengraph image를 만들고 그 이미지를 og:image 태그에 주입시킨 경험을 공유합니다.
운영중인 서비스는 각 커뮤니티가 있고 커뮤니티에 여러 채널들이 존재하는 서비스입니다. 기존에는 SSR (Server Side Rendering) 방식으로 커뮤니티 아이디와 채널 아이디에 해당하는 커뮤니티 명, 채널 명을 조회한 후 HTML 문서의 og:title, og:description에 주입시켜 주었습니다. og:image 경우에는 정적인 이미지를 넣어주었습니다.

커뮤니티를 공유했을 때 제일 눈에 띄는 부분이 오픈그래프 이미지인데, 이미지에서 커뮤니티명과 채널명을 한 눈에 알 수 없었습니다. 이걸 해결하고자 커뮤니티 이름과 채널 명을 넣은 이미지를 만들고 해당 이미지를 og:image 에 주입시키는 것을 구현하기로 했습니다.

위 사이트는 og:image 에 뉴스의 제목과 내용을 동적으로 이미지를 만들어서 주입시켜 주고 있었는데, 이런 식으로 우리 서비스에도 적용하면 유저가 외부에서 링크를 공유했을 때 한눈에 어느 커뮤니티인지, 어느 채널인지 확인할 수 있을 것입니다.
개발중인 서비스는 Next.js 12 버전입니다.
Next.js 12 버전으로 개발하다보니 13버전 이후에 나온 기능들을 많이 사용하지 못해서 헤맨 부분들이 있습니다. 먼저, og:title 과 og:description, 정적으로 주입한 og:image 는 아래와 같이 구현했었습니다.
// /pages/community/[communityId]/index.jsx
const CommunityHomePage = () => {
...
return (
<Header_OG_Meta ogTitle={ogTitle} ogDescription={ogDescription} ogImage={ogImage} />
)
}
export async function getServerSideProps(context) {
const { community_id, channel_id } = context.query;
const host = context.req.headers.host;
const getOpengraphInfo = async ({ community_id, channel_id }) => {
try {
const response = await axios.get(`xxx`, {
params,
timeout: 2000,
});
return response;
} catch (error) {
console.error(error);
return null;
}
};
const response = await getOpengraphInfo({ community_id, channel_id });
const ogTitle = response.data.data?.community_title;
const ogDescription = response.data.data?.channel_title;
const ogImage = '이미지 주소'
return {
props: {
ogTitle,
ogDescription,
ogImage,
},
}
}
// Header_OG_Meta.jsx
import Head from 'next/head';
const Header_OG_Meta = ({ ogTitle, ogDescription, ogImage }) => {
return (
<Head>
<meta property="og:image" content={ogImage} />
<meta property="og:type" content="website" />
{ogTitle && <meta property="og:title" content={ogTitle} />}
{ogDescription && <meta property="og:description" content={ogDescription} />}
</Head>
);
};
export default Header_OG_Meta;
동적으로 OG 이미지를 만들 수 있는 방법 중 가장 쉬운 방법은 Vercel에서 HTML/CSS를 SVG로 변환하여 빠르고 편하게 만들 수 있는 @vercel/og 라이브러리를 사용하면 된다. 이 경우 Edge 런타임만 지원하기 때문에 Next.js 12 버전에서는 이 방법을 사용할 수 없다. 직접 라이브러리를 import 해보았지만 사용할 수 없었다.
따라서 satori를 사용하려 한다. satori는 HTML 및 CSS를 기반으로 한 동적 SVG 생성과 관련된 기능을 제공한다. 우리에게 친숙한 JSX 문법으로 SVG를 만들수 있다.
og image를 만들어서 리턴해줄 API route부터 생성한다.
// /pages/api/v1/og.js
export default async function handler(req, res) {
/** satori는 woff2 지원하지 않아서 woff 불러옴 */
const fontPath_600 = path.join(process.cwd(), 'public', 'fonts', 'Pretendard-SemiBold.woff');
const fontData_600 = readFileSync(fontPath_600);
const fontPath_700 = path.join(process.cwd(), 'public', 'fonts', 'Pretendard-Bold.woff');
const fontData_700 = readFileSync(fontPath_700);
}
라우트를 생성했고 satori에서 사용할 font를 로드해주었다. satori는 woff2를 지원하지 않기 때문에 woff 파일을 추가적으로 받아서 로드해주었다.
const community_name = req.query.community_name || '';
const channel_name = req.query.channel_name || '';
const community_image= req.query.community_image || '';
이미지에 주입할 커뮤니티 이름, 채널 이름, 커뮤니티 사진을 받아왔다. 이미지 태그의 src에 주입할 community_image는 상대경로는 안된다. 무조건 절대경로로 만들어주어야 한다.
또한, webp 확장자는 지원하지 않는데 webp 이미지가 들어올 케이스가 있다면 webp에 한해 png로 변경해주는 작업을 진행하였다.
import webp from 'webp-converter';
if (absoluteCommunityImage.match(/\.(webp)$/i)) {
try {
// webp 이미지를 png로 변환
const response = await fetch(absoluteCommunityImage);
const webpBuffer = Buffer.from(await response.arrayBuffer());
// Save the buffer to a temporary file
const tempWebpPath = path.join(process.cwd(), 'public', 'image.webp');
await fsPromises.writeFile(tempWebpPath, webpBuffer);
// Convert WebP to PNG
const tempPngPath = path.join(process.cwd(), 'public', 'image.png');
await webp.dwebp(tempWebpPath, tempPngPath, '-o');
// Read the converted PNG file
const pngBuffer = await fsPromises.readFile(tempPngPath);
absoluteCommunityImage = `data:image/png;base64,${pngBuffer.toString('base64')}`;
// Clean up temporary files
await fsPromises.unlink(tempWebpPath);
await fsPromises.unlink(tempPngPath);
absoluteCommunityImage = `data:image/png;base64,${pngBuffer.toString('base64')}`;
} catch (error) {
console.error('WebP to PNG conversion failed:', error);
}
}
이제 우리에게 익숙한 JSX 문법으로 이미지를 만들면 된다.
const svg = await satori(
<div
style={{
display: 'flex',
fontSize: 40,
color: 'black',
width: '100%',
height: '100%',
position: 'relative',
}}>
<img
src={absoluteCommunityImage}
width={313}
height={313}
style={{
position: 'absolute',
top: 0,
left: 0,
objectFit: 'cover',
}}
/>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
backgroundImage: 'linear-gradient(to right, rgba(34, 36, 39, 0.3), rgba(34, 36, 39, 1), rgba(34, 36, 39, 1))',
position: 'relative',
}}>
{/** 좌측 상단 클래스유 로고 사진 */}
<img
src="..."
width={137.87}
height={21.6}
style={{
position: 'absolute',
top: 30,
left: 30,
}}
/>
<div
style={{
display: 'flex',
position: 'absolute',
top: channel_name ? 82 : 115,
left: 258,
flexDirection: 'column',
width: '318px',
height: '230px',
overflow: 'hidden',
}}>
<p style={{ fontFamily: 'Pretendard-600', color: '#CACED2', fontSize: 15, margin: '0 0 8px 0' }}>
커뮤니티명
</p>
<p
style={{
fontFamily: 'Pretendard-700',
color: '#EEEFF0',
fontSize: 24,
margin: '0 0 20px 0',
}}>
{community_name}
</p>
<p style={{ fontFamily: 'Pretendard-600', color: '#CACED2', fontSize: 15, margin: '0 0 6px 0' }}>
{channel_name ? '채널명' : ''}
</p>
<p style={{ fontFamily: 'Pretendard-700', color: '#EEEFF0', fontSize: 20, margin: 0 }}>
{channel_name ? channel_name : ''}
</p>
</div>
</div>
</div>,
{
width: 600,
height: 312,
fonts: [
{
name: 'Pretendard-600',
data: fontData_600,
weight: 600,
style: 'normal',
},
{
name: 'Pretendard-700',
data: fontData_700,
weight: 600,
style: 'normal',
},
],
},
);
svg 형태로 리스폰스를 주게 되면 og:image로 인식 못하는 플랫폼들이 있었다. 따라서 이미지 형태(png)로 만들어줄 것이다. svg를 png로 변환하는 라이브러리로 resvg-js를 사용했다.
const resvg = new Resvg(svg, {});
const pngData = resvg.render();
const pngBuffer = pngData.asPng();
이렇게 만든 png 파일을 리턴해주면 된다.
res.setHeader('Content-Type', 'image/png');
res.status(200).send(pngBuffer);
이제 동적으로 이미지는 만들어졌으니까 기존에 주입했던 방식으로 주입하면 된다.
// /pages/community/[communityId]/index.jsx
... 생략
const getOgImageUrl = `https://${host}/api/v1/og?community_name=${ogTitle}&channel_name=${ogDescription}&community_image=${community_image}`;
return {
props: {
ogTitle,
ogDescription,
ogImage: getOgImageUrl,
},
}
이제 페이지를 열고 태그를 직접 확인하면 된다.

잘 들어간것을 확인했고, 해당 url을 입력해보았다.

이런 형식으로 잘 가져오는것을 확인했다.
혹시 몰라서 opengraph.xyz 라는 사이트에서 직접 확인해보았다.

우리가 사용하는 GetStream.io 플랫폼에서는 잘 나왔다.

하지만 슬랙, 카카오톡과 같은 플랫폼에서 나오지 않거나 잘리는 현상을 발견했다.

슬랙에서는 og image가 보이지 않았고,

카카오톡에서는 나오다가 마는 현상을 발견했다.
내가 opengraph 의 값을 가져오는 봇이라고 생각하고 URL의 HTML 문서를 직접 받아보았다.
포스트맨을 사용하여 URL의 리스폰스를 직접 까보았다.

쿼리 파라미터를 구분짓는 & 앤퍼센트가 & 로 변환되고 있었다. 그리고 띄어쓰기가 발생하다 보니 띄어쓰기 직전 까지 url을 인식하여 og image를 호출하여 카카오톡 og image가 짤린것이었다. 특수문자가 HTML 엔티티로 표기되어 치환이 필요했다.
encodeURIComponent는 URI Component를 안전하게 인코딩하게 위해 사용한다.
const uriComponent = "https://www.example.com/search?q=JavaScript & Web Development";
const encodedUriComponent = encodeURIComponent(uriComponent);
console.log(encodedUriComponent);
// 출력: "https%3A%2F%2Fwww.example.com%2Fsearch%3Fq%3DJavaScript%20%26%20Web%20Development"
encodeURIComponent() 함수는 URI Component 중에서 데이터를 나타내는 부분을 인코딩한다. 이 구성 요소에는 쿼리 문자열의 값, 경로의 일부, 해시 등이 포함될 수 있다. 이 함수는 URI 구성 요소 내에 존재하는 특수 문자들을 인코딩하여 안전한 형태로 만들어준다. 예를 들어 &, =, ? 등의 문자를 인코딩하여 URI가 제대로 해석되도록 도와준다.
따라서 쿼리 파라미터로 붙는 community_name, channel_name,community_image를 data라는 쿼리파라미터 하나로 인코딩시켜주었다.
const getOgImageUrl = `https://${host}/api/v1/og?data=${encodeURIComponent(
`community_name=${ogTitle}&channel_name=${ogDescription}&community_image=${community_image}`,
)}`;
그리고 og 이미지를 만들어주는 라우트에서 기존엔 params를 뽑아내었지만, 인코딩 하고 나서는 data의 parameter를 URLSeacrhParams를 사용해 뽑아내었다.
const data = req.query.data;
const params = new URLSearchParams(`?${data}`);
const community_name = params.get('community_name') || '';
const channel_name = params.get('channel_name') || '';
const community_image = params.get('community_image') || '';
파라미터를 인코딩한 이후 확인해보았다. 확인하는데 카카오톡 같은 경우 캐시가 남아있으므로 지우고 확인해야한다.

https://www.opengraph.xyz/ 를 맹신했었는데 이 사이트에서는 잘나와도 다른 플랫폼에서 잘 안나올 수 있으니까 꼭 여러 플랫폼에서 제공하는 디버거 툴에서 테스트를 하자.
슬랙에서도 제공한다.

image_url 필드 혹은 thumb 필드를 확인하면 되는것 같다.
인프랩 테크 - 인프런 콘텐츠에 동적으로 생성되는 Open Graph(OG) 이미지 적용하기
Next js dynamic opengraph 및 twitter card 이미지 생성하기
[개발삽질 satori]
자바스크립트 - encodeURIComponent