본문으로 건너뛰기
yceffort
PostsSeriesTagsAbout🧪 Research
EN

Tweaks

theme
accent palette
film grain
minimal mode
BACK TO INDEX
◆ ESSAY
--min
--year
KOoriginal

mailMail icongithubtwitter
yceffort
•
© 2026
•
https://yceffort.kr
BACK TO INDEX
◆ ESSAY

서버리스로 블로그 포스트 썸네일 생성하기

avatar
yceffort
2020-12-08 · 6분
6min
2020year
KOoriginal
nextjsserverlessbackend

사이트를 카카오톡, 페이스북 등 SNS에서 공유할 때 이른바 썸네일이 보이게 하기 위해서는 open graph tool를 사용해야 한다. 트위터의 경우에는 자체 정의된 twitter:* 시리즈의 무언가를 이용해야 한다. 내 모든 블로그 글에 이쁜 대문 이미지가 있으면 좋겠지만, 모든 것에 현실적으로 이미지를 만드는 것은 불가능하고 귀찮다. 그래서 주어진 이미지에 블로그의 메타 정보를 쓰는 방식으로 정적 이미지를 만든 다음, 이 이미지를 공유용 이미지로 서빙하는 방법에 대해서 고민해 보았다.

구성

og tag image 주소 요청 > 해당 주소로 cloudinary에 이미지가 있는지 확인

  • 있다면 그 주소로 리다이렉트
  • 없다면 > 블로그에 이미지 형태로 준비되어 있는 페이지 방문 > 해당 페이지 스크린샷 > 스크린샷 한 이미지를 cloudinary에 업로드 > 해당 이미지 주소로 리다이렉트

1. 정적인 이미지를 만들 페이지 구성하기

/generate-screenshot이라는 이름으로 페이지를 하나 만들고, 거기에 정적으로 생성할 이미지를 일단 웹페이지 형태로 만들어보았다.

https://yceffort.kr/generate-screenshot?tags=javascript&title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions

https://github.com/yceffort/yceffort-blog-v2/blob/master/pages/generate-screenshot.tsx

여기저기 글을 본 결과 최적의 사이즈는 1200x630으로 알려져 있으며, 해당 사이즈에 맞게 페이지를 구성하면 된다.

2. 해당 페이지를 스크린샷 찍기

이제 해당 페이지를 방문해서 스크린샷을 찍어야 한다. 첫 번째로 시도한 것은 nextjs에 api를 활용하여 vercel에서 시도하는 것이었다. 그러나 결과적으로 이 시도는 실패했는데, 일단 스크린샷을 찍기 위해서는 puppetter의 headless chrome instance를 띄워야 하는데 이 메모리가 생각보다 많이 들었다. 그리고 별개의 폰트도 설치해야 하는데 그 과정까지 vercel에서 할 수 없었으므로, firebase functions을 활용하기로 했다.

시작하는 방법은 여기에 잘 나와있다. 심지어 한글로 되어 있다.

exports.screenshot = functions.https.onRequest(async (req, res) => {
  const query = req.query
  const title = encodeURI(query.)
  const firebaseTitle = title.replace(/\//gi, '-')
  const screenshotRef = db.collection('screenshot')

  const exist = await screenshotRef.doc(firebaseTitle).get()

  if (exist.exists) {
    return res.redirect(exist.data().url)
  }

  try {
    const postUrl = `http://yceffort.kr/generate-screenshot?${queryString.stringify(
      query,
    )}`
    const screenshot = await takeScreenshot(postUrl)
    const uploadedImage = await putImage(title, screenshot)
    screenshotRef.doc(firebaseTitle).set({
      url: uploadedImage,
    })
    res.redirect(uploadedImage)
  } catch (e) {
    console.error(e)
    res.json({ error: e.toString() })
  }
})
const takeScreenshot = async function (url) {
  const chromiumPath = await chromium.executablePath

  const browser = await chromium.puppeteer.launch({
    executablePath: chromiumPath,
    args: chromium.args,
    defaultViewport: chromium.defaultViewport,
    headless: chromium.headless,
  })

  const page = await browser.newPage()
  await page.setViewport({height: 630, width: 1200})
  await page.goto(url)
  const buffer = await page.screenshot({encoding: 'base64'})
  await browser.close()
  return `data:image/png;base64,${buffer}`
}

여기서 겪은 삽질을 몇가지 소개해본다.

1) puppeteer는 무겁다

puppeteer를 npm install 로 설치해보면 꽤 시간이 걸린다는 것을 알 수 있다. 그래서 puppeteer를 puppeteer-core만 설치하고, cloud function에서 쓸 수 있는 다른 chromium 브라우저를 알아 봐야 한다. 그래서 https://github.com/alixaxel/chrome-aws-lambda 를 설치했다. 그리고 iltorb 도 함께 설치해 주어야 한다.

2) react metatag의 query escape

원래는 이 주소를 넘겼다.

https://us-central1-yceffort.cloudfunctions.net/screenshot?slug=2020%2F12%2Fmeasuring-performance-of-javascript-functions&tags=javascript&title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions

그러나 소스 보기로 해당 주소를 보면 아래와 같이 되어 있었다.

<meta
  property="og:image"
  content="https://us-central1-yceffort.cloudfunctions.net/screenshot?slug=2020%2F12%2Fmeasuring-performance-of-javascript-functions&amp;tags=javascript&amp;title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&amp;url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions"
/>

&가 &amp;로 escape 처리 되어 있는 것이다.

  • https://github.com/facebook/react/issues/13838
  • https://github.com/vercel/next.js/issues/2006

두가지 선택이 있었는데, query param 구조로 되어 있는 주소를 path variable 로 모두 바꾸거나, 혹은 받는 쪽에서 처리를 하는 것이다. path를 다 바꾸기는 넘 귀찮아서 아래와 같은 처리를 추가해주었다.

const query = Object.keys(context.query).map(
    (key) => (query[key.replace(/amp;/, '')] = context.query[key]),
  ) as any

3) 느린 속도

스크린샷을 찍고, 이미지를 업로드 해서 내려주는 최초의 과정은 느릴 수 밖에 없다.그러나 문제는 두번째 과정 이후 부터 있었다. 이미 이미지가 생성되었는지 확인하기 위해 cloudinary에 get 요청을 날리는데, 이 과정 또한 쓸데 없이 오래 걸렸다. 그래서 cloudinary에 찔러서 확인하는대신, 한번 생성된 이미지는 key와 value 형태로 주소를 firebase에 저장해 두어 더 빠르게 내려주었다.

저장

const uploadedImage = await putImage(title, screenshot)
screenshotRef.doc(firebaseTitle).set({
  url: uploadedImage,
})

불러오기

const exist = await screenshotRef.doc(firebaseTitle).get()

if (exist.exists) {
  return res.redirect(exist.data().url)
}

그렇다고 해서 속도문제가 완전히 해결된 것은 아니었다. 첫단계에서는 여전히 생성속도가 느리고, 주소에서 느껴지겠지만, 미국 동부를 거쳐서 왔다리 갔다리 해야 하기 때문에 여전히 좀 답답한면이 있다.

3. 메타 태그에 심기

<meta
  property="og:image"
  content="https://us-central1-yceffort.cloudfunctions.net/screenshot?slug=2020%2F12%2Fmeasuring-performance-of-javascript-functions&amp;tags=javascript&amp;title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&amp;url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions"
/>
<meta
  name="twitter:image"
  content="https://us-central1-yceffort.cloudfunctions.net/screenshot?slug=2020%2F12%2Fmeasuring-performance-of-javascript-functions&amp;tags=javascript&amp;title=%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%20%ED%95%A8%EC%88%98%EC%9D%98%20%EC%84%B1%EB%8A%A5%20%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0&amp;url=https%3A%2F%2Fyceffort.kr%2F2020%2F12%2Fmeasuring-performance-of-javascript-functions"
/>

결과

문제점

  • 여전히 좀 느리다. 당연히, 초기 생성단계에서는 느릴 수 밖에 없다. 이것을 어떻게 해결할 것인가가 관건이다. 글이 올라간 뒤에, github action으로 트리거 해서 생성할 것인가? 혹은 배포 단계에 이를 포함할 것인가?
  • firebase functions, firebase storage, 거기에 cloudinary까지 사용하고 있다. 코로나 시대에 줄어든 용돈으로, 과연 여기까지 커버할 수 있을 것인가? vercel은 언제 또 나에게 pro 버전으로 내 지갑을 재차 노릴 것인가?

참으로 무시무시한 일이 아닐 수 없다.

관련 글

  • ◆ Next.js의 현주소
    #nextjs#edge-computing#serverless

    Next.js Edge Runtime의 흥망성쇠

    Edge Middleware 야 잘 살고 있니?

    2026-03-16·25분
  • ◆ 웹 서비스 성능 분석
    #web-performance#nextjs#backend

    웹 서비스 성능 분석 (3)

    관심 가져주셔서 감사합니다. 🙇🏻‍♂️

    2025-07-01·56분
  • #nextjs#backend

    nextjs 서버사이드에서 absolute url 가져오기

    이번 달 포스팅이 더디네요... 반성합니다.

    2021-10-29·4분
  • #turbopack#nextjs#bundler

    Next.js turbopack에서 싱글톤이 두 개가 됐다: scope hoisting 버그와 순환 import

    Next.js 16 turbopack 프로덕션 빌드에서 모듈 스코프 싱글톤이 런타임에 두 개가 됐다. 같은 동기 구간에서 조건 판정이 뒤집히고, 응답이 도착해도 타임아웃이 나는 증상을 번들 산출물로 추적한 기록. scope hoisting의 부분 병합, 순환 import, 이미 고쳐져 있던 upstream 버그, 그리고 뒤늦게 돌린 단일 변수 실험까지.

    2026-08-19·28분

새 글을 놓치고 싶지 않으시다면 RSS로 구독해 주세요.

RSS 구독 →

yceffort — 프론트엔드 엔지니어입니다. 발표·기술 자문·기고 문의는 이곳에서 받고 있습니다.

← Back to the blogIssue on GitHub →