안녕하세요, 인프랩 프런트엔드 개발자 융디입니다.

최근 인프런 유저분들이 정성스럽게 작성해 주신 수강평질문&답변 페이지를 SNS에 공유 했을 때, 가장 먼저 보이는 링크 미리보기 이미지에 변화가 생겼다는 것을 눈치채신 분들이 계실까요?

혹시 눈치채지 못하셨다면 이 글을 읽기 전, 본인이 인프런에 작성한 자랑스러운 수강평과 질문&답변 게시글을 SNS 에 공유해보세요. 아마도 이 글을 더 흥미롭게 읽으시는데 도움이 되실 것이라 생각합니다.

그럼 이제, OG 이미지가 동적으로 생성되게 개발하면서 얻은 경험을 공유해보도록 하겠습니다.

시작 전, OG 이미지란?

오픈 그래프(OG) 이미지는 소셜 미디어에(예: Facebook 또는 Slack) 링크를 공유할 때, 웹페이지에 대한 정보를 미리 볼 수 있도록 표시되는 대표 이미지입니다. 링크의 미리보기 이미지를 통해, 시각적으로 사람들의 관심을 사로잡아 링크 클릭을 유도하는데 큰 영향을 줍니다.

인프런은 동적으로 생성되는 OG 이미지가 왜 필요할까?

인프런에는 강의를 수강한 학생과 강의를 제공하는 지식공유자 분들이 직접 작성한 좋은 콘텐츠가 많습니다. 이 콘텐츠를 외부에 공유할 때는 주로 링크 복사를 통해 SNS에 공유되는 경우가 많죠.

problem

SNS에 공유된 인프런 스터디 모집 글 예시

(스터디 모집글이지만, 이를 대표하는 OG 이미지는 인프런 슬로건만 보이게 되어 이 글이 스터디 모집글인지 한눈에 확인하기 어려운 상황)

하지만, 실제로 위와 같이 외부에 공유된 콘텐츠는 링크를 통해 직접 인프런 사이트에 접근해서 내용을 확인하지 않는 이상 어떤 내용을 담고 있는 콘텐츠인지 한 눈에 확인하기 어려운 경우가 많습니다.

graph

수강평 상세 페이지 referrer 지표

참고로 최근 수강평 상세 페이지의 유입 referrer 지표를 보면, SNS 공유를 통해 인프런 웹사이트에 접근하는 사용자가 큰 비중을 차지하고 있습니다. 데이터 지표로 이를 확인한 이상, 링크 공유 시 표시되는 미리보기 이미지를 이른 시일 내에 개선하고자 하는 니즈가 커졌습니다.

geek

GeekNews OG 이미지 예시

이를 개선하고자, 다른 웹사이트들의 OG 이미지를 참고하다가 매주 슬랙에 공유되는 GeekNews 링크의 OG 이미지가 딱 이 문제를 해결해 줄 것 같다는 느낌이 들었습니다.

그 이유는 평소 GeekNews 아티클을 골라 볼 때, OG 이미지에 있는 아티클의 제목과 간략한 내용을 확인하고 관심 있는 주제라면 자연스럽게 링크를 클릭하고 있었기 때문입니다.

이에 따라, 인프런 유저 콘텐츠 페이지에도 OG 이미지를 동적으로 생성하여 적용하기로 결정했습니다.

어떻게 콘텐츠마다 동적으로 OG 이미지 생성할까?

동적으로 OG 이미지를 만들 수 있는 다양한 방법 중, Vercel 에서 HTML/CSS를 SVG로 변환하여 빠르고 다이나믹하게 소셜 이미지를 만들 수 있는 오픈 소스 라이브러리 @vercel/og를 제공하는 것을 발견했습니다.

@vercel/og 라이브러리 사용하기

Vercel에서 제공하는 라이브러리인 @vercel/og 를 사용하는 방법은 매우 간단합니다.

공식 문서에 친절하게 나와 있는 대로 Next.js 프로젝트에 해당 의존성 라이브러리를 설치하고, 사용하고 있는 Next.js 프로젝트의 라우터 방식에 맞게 API 라우터를 생성하여 라이브러리 인터페이스에 맞게 코드를 작성해 주면 됩니다.

import { ImageResponse } from '@vercel/og';

export const config = {
  runtime: 'edge',
};

export default async function handler() {
  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,
      fonts: [
        {
          style: "normal",
          name: "Pretendard",
          data: boldFontBuffer,
          weight: 700,
        }
        ...
      ]
    },
  );
}

@vercel/og 라이브러리 사용 시, 참고해야 할 사항은 아래와 같습니다.

  • Node.js 16 이상만 지원합니다.
  • @vercel/og 는 Node.js 런타임을 지원하지 않습니다. Edge 런타임에서만 동작합니다.
  • 내부 핵심 엔진인 satori 가 허용하는 일부 HTML/CSS 기능만 지원합니다.

👀 라이브러리 사용 방법에 대한 자세한 내용은, @vercel/og 공식문서를 확인 해주세요.

웹 페이지에 동적 OG 이미지 적용하기

동적으로 생성되는 OG 이미지를 웹 페이지에 적용하는 방법 또한 매우 간단합니다.

<head>
  <title>인프런 - 수강평 상세 페이지</title>
  <meta
    property="og:image"
    content="https://inflearn.com/api/og/review/{id}"
  />
</head>

동적 OG 이미지를 적용할 페이지 HTML <meta> 태그의 og:image 속성 값을 OG 이미지 생성 API URL 주소로 변경하면, 끝! 입니다.

추가로 트위터 링크 공유 템플릿을 결정할 수 있는 twitter:card 메타 태그를 summary_large_image 로 설정하면, 트위터 게시글에서 링크 미리보기 썸네일 이미지가 더욱 돋보일 수 있게 됩니다.

summary

twitter:card summary 설정 시

large image

twitter:card summary_large_image 설정 시

아주 간단하지만, 이대로 마무리 하기에는 링크를 SNS, 슬랙 등에 공유할 때마다 매번 새로운 OG 이미지를 생성하기 위한 불필요한 요청과 응답이 오가게 됩니다.

콘텐츠의 내용이 변경되지 않았는데, 매번 새로운 이미지를 생성하는 과정이 필요할까요? (아니요~)

이미 생성된 이미지를 또 다시 생성하는 불필요한 과정을 없애고, 이미지를 더 빠른 속도로 유저에게 노출하기 위해, AWS CloudFront 라는 CDN 서비스 이용해서 이를 개선했습니다.

동적으로 생성된 이미지 응답 캐싱하기

인프런 서비스의 아키텍처 구성 중간에는 CloudFront(= CDN)가 존재합니다.

사용자의 요청이 매번 원본 서버까지 도달하지 않고, 이후 같은 요청이 왔을 때 CDN 에 캐싱된 응답을 반환하여 빠르게 사용자에게 필요한 자원을 제공하기 위해서죠.

cdn

CDN 캐싱 도식화

그렇다면, 동적으로 생성되는 OG 이미지의 요청 또한 캐싱할 수 있지 않을까요? 이것이 가능하다면 이미 생성한 기록이 있는 이미지의 경우 요청이 원본서버까지 도달하지 않고 CDN 단에서 바로 요청을 처리해 줄 수 있게 됩니다.

image generator

동적 OG 이미지 생성기 요청과 응답 도식화

수강평 ID를 기준으로 CDN 캐싱을 활성화 한다면, 리뷰 내용이 변경 되었을 때 어떻게 캐시된 이미지를 업데이트 할 수 있을까요?

DB 에서는 updatedAt 과 같이 최종 수정 시각을 저장하고 있습니다. 변경사항이 있을 때마다 해당 컬럼을 업데이트해 마지막 수정 시각을 갱신하는데요.

이를 CDN 캐시 키로 활용해서 og:image 값으로 설정할, URL 에 같이 삽입하면 됩니다.

🧞‍♂️ 작은 팁

og:image 값으로 설정할 URL 이 encoding 되어 있지 않으면, 슬랙 링크 미리보기 이미지가 출력되지 않을 수 있으니 주의하세요.

  1. 리뷰 내용이 변경 되었을 경우, updatedAt 필드가 갱신되며 og:image 값에 설정한 URL 의 updateKey도 동일한 값으로 변경됩니다.
  2. 이후에 사용자가 SNS 에 링크를 공유하면, 최신의 updateKey 를 가진 og:image URL 로 요청이 발생합니다.
  3. CDN 에 캐싱 된 이전 updateKey 의 이미지는 더 이상 요청되지 않습니다.

위 과정을 통해 별도로 이미지 응답에 대한 상태나 기록을 DB 에 보관하지 않고도, 항상 최신의 콘텐츠 내용을 담은 OG 이미지를 사용자에게 제공할 수 있게 되었습니다.

AWS CloudFront Cache 설정은 다음과 같이 적용하였습니다.

cloudfront

AWS CloudFront 세팅 예시

(CloudFront Cache Key 설정 예시)

  • TTL(Time To Live)

    • Min: 0
    • Max: 1년
    • Default: 0
    • default 및 min을 0으로 설정한 이유는, cache-control 헤더를 반환하지 않는 요청은 캐싱하지 않기 위함입니다.
  • Cache Key Settings

    • Query Strings - ALL
    • 어떤 query string 이든 cache key로 동작합니다.
    • 같은 URI의 이미지더라도, query string이 다르면 별도의 캐싱 대상으로 간주합니다.

마지막으로, 웹 페이지 <meta> 태그의 og:image 속성 값을 변경해줍니다.

<meta
  property="og:image"
  content="https://cdn.inflearn.com/api/og/review/{id}?updateKey={updateKey}"
/>

캐시 적용 결과

CDN 캐시 미적중 시: 1.271082084s

GET /community/api/v1/og/review/41887 HTTP/1.1

HTTP/1.1 200 OK
cache-control: no-cache, no-store
X-Cache: Miss from cloudfront
Elapsed time: 1.271082084s

CDN 캐시 적중 시: 0.102158167s

GET /community/api/v1/og/review/41887?updatedAt=1 HTTP/1.1

HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
X-Cache: Hit from cloudfront
Elapsed time: 0.102158167s

이미지 생성 요청의 캐시 적중 여부에 따른 응답 속도 차이가 보이시나요?

별도의 DB 같은 자원을 구축하지 않고, CDN을 붙이는 작업만으로 불필요한 요청과 응답을 줄임으로써 속도가 대략 13배 정도 빨라졌습니다. 하지만, 한 가지 아쉬운 부분이 있습니다. CDN 캐싱 전, 첫 이미지 생성에 대한 응답이 너무 느리다는 점입니다.

이를 개선하기 위해, 터미널로 간단하게 서버 부하 테스트와 성능 측정을 할 수 있는 도구 wrk로 초당 n건의 이미지를 생성할 때 서버가 어느 정도의 부하를 버틸 수 있는지를 측정하고, Node.js V8 Inspector 디버깅 도구를 사용하여 이미지 생성 작업의 병목 구간을 발견하여 첫 이미지 생성 속도를 개선하는 등의 작업을 진행했습니다.

그 과정에 대해서 하나씩 소개해드리겠습니다.

이미지 생성 최적화

인프랩은 Vercel 플랫폼 대신에 Next.js를 직접 AWS 클라우드에 구축하여 운영하고 있습니다. 동적 이미지 생성기 또한, standalone 컨테이너로 AWS ECS 클러스터에 배포됩니다.

그렇기 때문에 Edge 런타임에서만 동작하는 @vercel/og 라이브러리의 성능 이점을 누리지 못하고, 오히려 성능 저하와 함께 제약사항도 존재했습니다.

  • Edge 런타임은 모든 Node.js API를 지원하지 않으므로 일부 npm 패키지가 작동하지 않을 수 있습니다.
    • 예시: lodash-es, fs
  • Vercel 플랫폼에 배포 시 Edge 런타임은 서버리스 함수로 실행되지만, AWS 같은 클라우드 서비스로 자체 호스팅 시에는 Edge 런타임으로 전환되는 과정에서 오버헤드 같은 일부 성능 저하가 발생할 가능성이 있습니다.
  • 내부적으로 사용하고 있는 Datadog APM 연결 및 모니터링에 어려움이 있습니다.

따라서 Node.js runtime에서도 동작하는 라이브러리로 교체하여 성능 개선과 모니터링의 효율성을 높이기로 결정했습니다.

standalone 관련 내용이 궁금하시다면 랠릿 standalone 적용기를 참고해 주세요.

satori 직접 사용하기

Node.js Runtime 에서 HTML + CSS 를 SVG 로 변환하는 작업을 수행하기 위해선 @vercel/og 내부적으로 사용하는 satori 라이브러리를 직접 사용하는 방식을 택해야합니다.

satori 사용 방법은 @vercel/og 라이브러리 사용 방법과 크게 다르지 않습니다. @vercel/og 에서 SVG를 PNG로 변환하는 작업과 같은 세부적인 사항을 추상화해두었기 때문에, 추가로 작업이 필요한 부분은 satori 로 반환된 SVG를 PNG 이미지로 변환해주는 작업뿐입니다.

import satori from "satori";

export default async function handler(
  request: NextApiRequest,
  response: NextApiResponse
  ) {
  const svg = await satori(
    (
      <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,
        fonts: [
          {
            style: "normal",
            name: "Pretendard",
            data: boldFontBuffer,
            weight: 700,
          }
          ...
        ]
    },
  );
}

Resvg 대신 sharp 사용하기

@vercel/og 는 SVG를 PNG로 변환하는 작업을 수행할 때, Resvg 라이브러리를 사용하고 있어 저희도 동일하게 Resvg를 사용했습니다.

import { Resvg } from '@resvg/resvg-js'

...
const resvg = new Resvg(svg, {});
const pngData = resvg.render();
const pngBuffer = pngData.asPng();

return response.setHeader("content-type", "image/png").send(pngBuffer);

SVG를 PNG 이미지로 변환하는 것까지 성공! 그렇다면, 성능은 개선되었을지 기대하며 부하 테스트를 진행했습니다.

하지만, 결과는… @vercel/og 를 사용했을 때와 유사한 결과로 서버가 1RPS(Requests per Second) 조차 견디지 못합니다.

다른 PNG 변환 라이브러리를 찾아볼까 하며, 리서치를 하다가 sharp를 사용하여 PNG 변환을 한 예제코드를 보고 적용 해보기로 합니다.

import sharp from "sharp";

...
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
return response.setHeader("content-type", "image/png").send(pngBuffer);

결과는..? 0.4RPS에서 13RPS까지 개선되었습니다.

간단하게 조사한 결과, resvg-js 와 sharp 는 유사한 동작을 수행하지만 사용 목적이 다르다는 것을 확인했습니다.

  • resvg-js
    • SVG 의 그래픽 요소를 고해상도로 렌더링하고 높은 품질의 이미지를 생성하는 등의 래스터 이미지 변환에 주로 초점
  • sharp
    • Node.js 에서 이미지 리사이징, 회전, 자르기 등의 이미지 처리 작업에 주로 초점

인프런 OG 이미지는 고해상도의 이미지를 생성하는 것보단, 그저 SVG를 PNG로 변환하는 작업이 필요하기 때문에 sharp 가 더 적합했습니다. 성능 또한 resvg-js 를 사용했을 때보다 10배 이상 좋았기 때문에 sharp를 선택하지 않을 이유가 없었죠.

resvg-js Github 이슈에도 이 둘의 성능 차이에 대한 내용이 올라와 있습니다. 궁금하다면, 참고 해보세요! sharp is faster for me when mass converting SVGs to HQ PNGs

코드 내에서 병목 지점 찾기

Node.js inspector를 사용하여, 크롬 브라우저 DevTools로 웹소켓으로 연결된 Node.js(상의 Next.js) 서버의 CPU를 추적할 수 있습니다.

Performance 탭에서 녹화 버튼을 누르고, wrk로 서버 요청을 발생 시켜 프로파일링하면 아래와 같은 결과를 확인할 수 있는데요.

profiling1

Node.js CPU 프로파일링 결과

프로파일링 결과를 통해 발견한 것은, addFonts() 라는 함수가 매요청마다 호출된다는 것이었습니다. 이 문제에 대해 디버깅해본 바는 다음과 같습니다.

  1. satori 라이브러리 내부에서 한 번 메모리에 로드한 폰트를 캐싱하지 않는지에 대해 의심
  2. satori 내부 코드에서, options.fonts 객체 메모리 주소를 key 값으로 WeakMap을 전역변수에 선언하여 한 번 로딩 된 폰트를 캐싱하는 로직 확인
  3. 하지만, 프로파일링 결과 addFonts() 를 매번 호출하고 있어 캐싱한 폰트를 재사용하지 않고 있는지에 대해 의심
  4. 라이브러리 디버깅을 통해, fontCache.set() 만 실행되어 fontCache 가 쌓이기만 하고 실제로 캐시가 사용 되지 않고 있음을 확인

satori 라이브러리 내에 이런 문제를 발견하여, 이슈는 올렸지만 당장 문제가 해결될 것을 기대할 수 없기 때문에 실제 코드 내에서 해결할 방법을 찾았습니다.

먼저 저희가 파악한 해당 코드의 문제 원인은 다음과 같았습니다.

  • options.fonts 객체의 메모리 주소가 fontCachekey 값 인 것이 핵심 원인
  • satori() 호출 시 전달하는 options.fonts 객체의 메모리 주소가 매 요청마다 바뀌고 있음
  • 호출할 때마다 메모리 주소가 바뀌므로 fontCache 로 설정한 key 값 또한 바뀌게 되면서 캐시를 찾지 못함
  • 매번 불필요하게 addFonts() 를 호출하여 병목 발생

그렇기 때문에 options.fonts 객체의 메모리 주소를 고정하면 되지 않을까? 라는 결론에 도달하여, 해당 옵션 값을 변수로 선언 후 할당하도록 코드를 수정했습니다.

satori options 수정 전

...
const svg = await satori(
  (
    <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,
      fonts: [
        {
          style: "normal",
          name: "Pretendard",
          data: boldFontBuffer,
          weight: 700,
        },
        ...
      ],
    }
  );
...

satori options 수정 후

const satoriOptions: SatoriOptions = {
  width: 1200,
  height: 630,
  fonts: [
    {
      style: "normal",
      name: "Pretendard",
      data: boldFontBuffer,
      weight: 700,
    },
    ...
  ],
};

...
const svg = await satori(
  (
    <div
      style={{
        fontSize: 40,
        color: 'black',
        background: 'white',
        width: '100%',
        height: '100%',
        padding: '50px 200px',
        textAlign: 'center',
        justifyContent: 'center',
        alignItems: 'center',
        }}
      >
      👋 Hello
    </div>
    ),
    satoriOptions
  );
...

성능개선 결과

Node.js 프로파일링 결과, addFonts() 는 1회만 호출되고 이후에는 호출되지 않는 것을 확인했습니다.

profiling2

Node.js CPU 프로파일링 결과 최종

wrk 부하 테스트 결과, satori 라이브러리의 커스텀 폰트(options.fonts) 설정을 변수로 할당 것으로 RPS 가 약 1.8~2배 이상 향상된 것을 확인할 수 있었습니다.

마무리

글에 다 포함되지 않은 코드의 자잘한 최적화 작업까지 포함해서, OG 이미지 생성 시 서버 부하를 1RPS 에서 35RPS 까지 성능을 향상시킬 수 있었습니다.

동적 OG 이미지 생성이라는 간단할 것만 같았던 작업을 진행하며,

  • 서버 작업 시 부하 테스트 등을 수시로 진행하며, 서버의 성능 확인하기
  • Node.js CPU 프로파일링 등을 통해, 코드의 병목 지점 찾고 개선하기

와 같은 작업을 통해, 서비스 사용 유저들의 서비스 사용 경험을 개선하는 좋은 경험을 할 수 있었습니다.

함께 OG 이미지 PoC 작업과 성능 최적화 작업을 진행해주신 인프랩 Devops 팀 제이크와 제이스에게 감사 인사드리며, 글을 마무리 해보겠습니다.

끝까지 읽어주신 여러분 감사합니다. 🙏