본 글은 2024. 11. 09. 에 진행된
GDGoC 건국-국민 연합 세션에서 발표된 내용입니다.
전체 발표 자료는
에서 확인해보실 수 있습니다.
Open Graph란
오픈그래프에 대해 알아보기 전에 잠시 html meta tag를 짚어보고 가겠습니다.
meta tag는 html 문서, 다시말해 현재는 웹페이지 그 자체의 부가정보들을 명시하는 영역입니다. Head 태그 밑에 표기되며 인코딩, viewport, 문서의 제목 설정 등 다양한 값들을 설정해줄 수 있습니다.
오픈그래프 태그는 메타태그의 subset, 일부분으로서 페이스북에서 썸네일이나 문서 제목 등을 판별하는 기준이 되는 규칙입니다.
2010년도에 페이스북이 제안하였으며 현재는 Twitter/X 계열을 제외한 거의 대부분의 사이트들이 오픈그래프 규격을 준수하고 있습니다.
오픈그래프의 경우 아래와 같이 사용할 수 있습니다.
메타태그를 통한 오픈그래프의 설정을 깊게 다루어보지는 않을 예정이라 이정도만 다루고 넘어가도록 하겠습니다.
동적 오픈그래프 생성
본론입니다.
dynamically generated open-graph, 동적으로 생성된 오픈그래프에 대해 다뤄보도록 하겠습니다.
중요한 점으로서, 오픈 그래프는 동적으로 생성될 수 있습니다.
이때 유의해야할 점으로 SPA에서는 동적 오픈그래프를 사용해보기 힘들다는 점이 있습니다.
정확히 말하자면 하나의 접속 링크에서 다양한 기능이 처리되는 경우 오픈그래프를 동적으로 생성해서 사용하기 어렵고, 이는 오픈그래프 이미지가 sns 서버에 보통 캐시되기 때문입니다.
따라서 오픈그래프를 동적으로 생성해서 사용하고자 하는 경우에는 접속 url에 키를 담거나 해서 조금씩 변경해줄 필요성이 있습니다.
사용 케이스에 대해 보면서 어떻게 활용가능한지 확인해보도록 하겠습니다.
고등학생때 기술실증용으로 만들어보았던 프로젝트인데, 이 프로젝트가 오픈그래프의 특성을 잘 반영한다고 생각합니다.
카카오톡에 없는 일부 이모티콘을 굉장히 높은 빈도로 사용했었기 때문에 당시 특정 문구가 입력되면 알아서 이모티콘을 출력해주는 카카오톡 봇을 개발해 친구들과 사용했었습니다.
그 당시 발행했던 추가 자료입니다.
2020년에 카카오톡 봇에 이미지를 담아서 전송하기 위한 기능은 매우 제한적이었는데, 아마 지금도 그러리라 생각합니다.
첫번째로는 카카오톡 내부 프로토콜인 LOCO프로토콜을 리버싱해서 다 까두신 분이 계셨기에 해당 라이브러리를 사용해 정상적인 카카오톡으로 위장, 이미지를 전송하는 방법이고
두번째로는 안드로이드 알림을 받아 text response를 줄 수 있는 카카오톡 챗봇 어플리케이션을 활용, 이미지를 웹 사이트의 opengraph에 담아서 반환하는 경우였습니다.
저는 당시 이중 후자를 사용해서 개발했었습니다.
구현하기 굉장히 쉬운편에 속했는데, 그냥 php에서 이모티콘이 미리 디렉토리에 다 적재되어있고 해당 디렉토리를 순회하면서 파일명과 입력 쿼리가 가장 유사한 이미지를 썸네일에 담아서 반환했습니다.
이렇듯 썸네일에 우리가 원하는 정보를 담아서 적재할 수 있다는 것을 알았으니, 본격적으로 조금 더 유용한 케이스들을 살펴보겠습니다.
동적 생성된 오픈그래프의 활용
백준 문제해결 관련 오픈채팅방에서는 그 특성상 문제에 대한 토의가 활발하게 일어납니다.
이때 백준 문제를 링크로 첨부하는 경우가 굉장히 잦은데, 그냥 백준 링크를 바로 첨부하였을 경우 썸네일에 별다른 정보가 표시되지 않습니다.
이를 해결하고자 kiwiyou 님께서 OG를 문제마다 생성해 문제에 대한 간단한 정보를 표시하도록 하는 서비스를 개발해주셨습니다.
solvedAC에서 판매되는 굿즈는 개개인의 프로필 현황을 그대로 프린팅한것같은 형태인데, 서로다른 N명의 프로필 정보를 이미지로 변환하기 위해서 OG를 만들때와 동일한 기술을 사용해서 이미지를 렌더링한 예제가 있습니다.
https://blog.shift.moe/2023/10/24/how-solved-ac-merch-is-born/
제가 아는 한 제일 대규모의 프로덕션에서 사용된 예시인데,
인프런의 경우 sns를 통한 유입이 많다는걸 자체적인 분석을 통해 알아내어 sns를 통한 바이럴 홍보에 조금 더 심혈을 기울이게 되었고, 그 과정에서 동적인 OG를 생성해 각 페이지가 어떤 역할을 하는지를 보다 명확하게 보여주는데 사용하고 있다고 합니다.
인프런의 경우 꽤나 큰 product인 만큼 OG를 만들때 얼마나 빠르게 만들수 있을지에 대한 고민이 많이 녹아있는 편이라서 얻어갈 부분들이 많은 편입니다.
중소규모 프로덕트라면 그냥 next/og 사용합시다...
동적 이미지 생성
위의 다양한 case들의 공통점은 이미지를 미리 준비해놓고 바꿔치기한 형태가 아니라, request에 따라 이미지를 직접 생성해서 적재 및 제공해주었다는 데에 있습니다.
즉 소스코드를 가지고 이미지를 생성하는데에 성공했다는 것인데, 이것이 어떻게 해결되었을까요?
위의 모든 예제들은 Vercel/satori 라이브러리를 통해 생성된 이미지입니다.
모 프로젝트의 사토리 양과는 아쉽게도 큰 연관이 없는듯 합니다.
다른 이미지 생성 라이브러리들과 비교해서 우위를 가지는 부분 중의 하나는 experimental하게나마 tailwindcss 지원을 하고 있다는 점이며, 이 라이브러리는 JSX 문법을 지키는 element를 넣어주면 SVG 규격으로 작성된 string을 반환해줍니다.
tailwindcss 지원을 통해 프론트엔드에서 사용하던 소스코드를 그대로 사용할 수도 있게 되었으며, 새로 작성하게 되더라도 react/next.js에 그대로 렌더링할 수 있어 사진 이미지의 모습을 쉽게 예측해볼 수 있다는 큰 장점이 있습니다.
동적인 이미지 생성의 장점은 엄청나게 넓은 편입니다.
블로그나 웹사이트의 썸네일을 동적 생성하는 예제로부터, JSX로 작성된 웹페이지의 내용물을 export해 인쇄하는데에도 사용해볼 수 있으리라 쉽게 기대할 수 있습니다.
소스코드 작성 및 테스트
satori 라이브러리에서 tailwindcss를 사용한 렌더링을 하기 위해서는, 기존의 JSX에서의 className 필드를 tw필드로 변경해주어야 합니다.
아래와 같이요
export default function Home() {
return (
<div tw="w-[640px] h-[360px] bg-gray-200 flex flex-col p-4">
<p tw="text-4xl p-0 m-0">Heasdfasdfslo WOrld</p>
<p tw="text-xl p-0 mt-4 mb-0 mx-0">lorem ipsum dolor sit amet</p>
<div tw="w-full h-[100px] bg-gray-300 flex">asdf</div>
</div>
);
}
하지만 이렇게 사용한다면 nextjs에서 png변환 전의 빠른 결과 확인을 기대할 수 없습니다. 이를 해결하고자 중간에서 className 필드를 tw필드로 옮겨주는 함수를 작성해볼 수 있습니다.
function satoriConvert(elem: JSX.Element) {
if (!React.isValidElement(elem)) {
return elem;
}
const elementWithProps = elem as ReactElement<{
className?: string;
tw?: string;
children?: any;
}>;
const newProps = {
...elementWithProps.props,
tw: elementWithProps.props.className,
};
delete newProps.className;
if (newProps.children) {
newProps.children = React.Children.map(newProps.children, satoriConvert);
}
return React.cloneElement(elementWithProps, newProps);
}
satoriConvert라 이름 붙여진 이 함수는 react JSX가 탐색 가능한 구조로 만들어져 있는 것을 이용해서 recursive하게 탐색하며 모든 className 필드를 tw필드로 변경합니다.
이 함수를 통해 변환시킨 JSX 를 satori에게 먹여주면, 그대로 svg를 뱉어냅니다.
이렇게 뱉어낸 결과물을 nextjs의 dangerouslySetInnerHTML 등을 사용해서 출력하면 사용할 수 있는데, 한발짝 더 나아가서 png로 바꾸어 사용해보도록 하겠습니다.
resvg-wasm 라이브러리를 사용하면 svg to png 변환이 가능합니다.
해당 파트를 간단히 작성하고, 그걸 buffer 형태로 변환 후 response 값에 담으면
호출시 이미지를 반환하는 간단한 엔드포인트를 만들어 줄 수 있습니다.
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const date = searchParams.get("date") || "";
const text = searchParams.get("text") || "";
const uploadedImageUrl = searchParams.get("uploadedImageUrl") || "";
const satori = await UseSatori(Card());
const png = await Png(satori);
const pngBuffer = await sharp(Buffer.from(png)).png().toBuffer();
return new NextResponse(pngBuffer, {
status: 200,
headers: {
"Content-Type": "image/png",
},
});
}
async function Png(satori: any) {
try {
const wasmPath = path.resolve(
"./node_modules/@resvg/resvg-wasm/index_bg.wasm"
);
const wasmBuffer = fs.readFileSync(wasmPath);
await initWasm(wasmBuffer);
} catch (e) {}
const resvg = new Resvg(satori, { fitTo: { mode: "width", value: 640 } });
const pngData = resvg.render();
return pngData.asPng();
}
이론적으로 보았을때 다음 파트는 서버사이드에서 실행되고, api처럼 동작하므로 별도 서버로 구성해 처리시키는게 좋겠습니다만, 테스트환경이므로 그냥 한 프로젝트에 몰아넣었습니다.
Png 함수가 resvg를 사용하는 부분이며, GET 함수에서 pngBuffer를 전달하고 있는 모습을 볼 수 있습니다.
최종 결과물은 아래와 같습니다.
왼쪽이 JSX를 통해 렌더링된 화면이고,
오른쪽이 동일한 JSX 함수를 satori + resvg + sharp 로 변환한 조합입니다.
거의 정확하게 출력되는것을 확인할 수 있습니다.
resvg와 sharp를 거치며 묘한 해상도 저하가 있는 편이라 해상도를 올려서 렌더링하는것을 추천드립니다.
임의로 JSX 함수를 변환했을 때에, JSX는 hot-reload됬지만 이미지가 캐시되어있어 남아있는것을 확인할 수 있고,
홈페이지를 리로딩할시 새로운 이미지를 받아오며 변경되는걸 볼 수 있습니다.
한계
satori가 client side에서 실행될수 있다고는 하지만, 반환되는 형태나 여러가지 최적화의 문제점으로 인해 사실상 server side에서 실행되야 합니다. nextjs를 서버로 사용하는 경험이 크진 않아서 어색함이 좀 큰 편입니다.
png rendering에서 본래 resvg-js를 사용할 계획이었는데,
이쪽이 정상동작 하지 않았습니다.
대체제로서 resvg-wasm쪽이 보다 매끄럽게 동작하는것을 확인했습니다. wasm의 경우 모바일 기기에서는 작동하지 않으므로, 기본적으로 백엔드에게 처리를 전가해야할 필요성이 있습니다.
이외에도 tailwindcss에서 모든 element에 flex를 강제해야한다는 등의 몇몇 제약사항이 있는 편입니다.
결론
대부분의 웹 서비스에서 모바일 접속이 데스크탑 접속보다 많아지는 지금, 모바일에서 폭넓게 사용되는 SNS를 위한 SEO 및 UI/UX경험은 매우 중요해졌습니다.
이제 satori 등의 기술을 활용해서, 썸네일에도 적절한 데이터를 담아 본인의 서비스를 홍보하고 사용자들에게 편리성을 제공해보시는건 어떨까 싶습니다.