블로그 성능 개선 (이미지 로딩)

작성일: 2023-10-23

Prerequisite

  • 블로그 성능 분석

저번 글에서 수행했던 성능 분석 결과 중 이미지 로딩 개선에 대해 살펴보겠습니다.

이미지 로딩 개선

WebP 포맷으로 변환 후 Amazon S3 업로드

WebP는 인터넷에서 이미지가 로딩되는 시간을 단축하기 위해 구글이 출시한 파일 포맷입니다.

WebP를 사용하면 PNG, JPEG 등 기존 포맷보다 작은 파일 크기로 고품질 이미지를 표현할 수 있습니다. (구글 개발자는 무손실 WebP 이미지가 PNG 보다 파일 크기가 최대 26%까지 줄어들 수 있다고 강조합니다 링크)

이전에 작업했던 Notion에서 다운받은 이미지 파일을 Sharp 라이브러리를 사용해서 WebP 포맷으로 변환하는 코드를 작성했습니다.

_scripts/notion-import.js

...
async function downloadImages(path, imageUrls) {
  const s3Urls = await Promise.all(imageUrls.map(async (url, index) => {
    const ext = url.split(".").pop().split("?")[0] || "png"
    const format = "webp"
    const originalFileName = `${path}/${index + 1}.${ext}`
    const newFileName = `${path}/${index + 1}.${format}`

    await downloadImage(url, originalFileName)
    const fileContent = await fs.promises.readFile(originalFileName)
    const quality = 50

    return sharp(fileContent, { limitInputPixels: false, pages: -1 })
      .toFormat(format, { quality })
      .toBuffer()
      .then(async (outputBuffer) => {
        const params = {
          Bucket: "devshjeon-blog-images",
          Key: newFileName,
          Body: outputBuffer,
        }
        const uploadResult = await s3.upload(params).promise()
        return uploadResult.Location
      })
      .catch((err) => {
        console.error("이미지 변환 실패:", err)
      })
  }))

  await deleteAllFiles(path)

  return s3Urls
}

변환 시 이미지 quality 50으로 주어도 보는데 지장이 없다고 판단해서 파일 크기를 더 줄일 수 있었고, 코드 적용 후 Amazon S3에 업로드된 파일 크기가 줄어든 것을 확인했습니다.

Amazon S3 캐시 적용

블로그 글 특성 상 한 번 포스팅 후 오래도록 변경사항이 없기 때문에 이미지 캐시를 1개월 만기로 설정했습니다.

_scripts/notion-import.js

...
async function downloadImages(path, imageUrls) {
  const s3Urls = await Promise.all(imageUrls.map(async (url, index) => {
    const ext = url.split(".").pop().split("?")[0] || "png"
    const format = "webp"
    const originalFileName = `${path}/${index + 1}.${ext}`
    const newFileName = `${path}/${index + 1}.${format}`

    await downloadImage(url, originalFileName)
    const fileContent = await fs.promises.readFile(originalFileName)
    const quality = 50

    return sharp(fileContent, { limitInputPixels: false, pages: -1 })
      .toFormat(format, { quality })
      .toBuffer()
      .then(async (outputBuffer) => {
        const params = {
          Bucket: "devshjeon-blog-images",
          CacheControl: "max-age=25920000", // 60 * 60 * 24 * 30
          Key: newFileName,
          Body: outputBuffer,
        }
        const uploadResult = await s3.upload(params).promise()
        return uploadResult.Location
      })
      .catch((err) => {
        console.error("이미지 변환 실패:", err)
      })
  }))

  await deleteAllFiles(path)

  return s3Urls
}

스크린 밖에 있는 이미지 지연 로딩 적용

이미지가 많은 글의 경우 스크롤을 내리기 전까지 하단의 이미지를 확인할 수 없음에도 모든 이미지를 한번에 불러오게 되어 성능 저하로 이어졌습니다.

이를 해결하기 위해 지연 로딩을 적용하여 스크롤을 내릴 때 이미지를 불러 올 수 있도록 처리했습니다.

지연 로딩에는 해당 문서를 가이드로 lazysizes 라이브러리를 사용했습니다.

_includes/lazyload.html

{% if include.image_src %}
  <img data-src="{{include.image_src}}" alt="" title="" width="500" height="300" class="lazyload" />
{% endif %}

위 코드 처럼 이미지 지연 html 템플릿을 만들었고, 템플릿에 맞게 Markdown을 구성했습니다.

_scripts/notion-import.js

function convertLazyImage(body) {
  const regex = /!\[([\s\S]*?)\]\(https:\/\/devshjeon-blog-images([\s\S]*?)\)/g
  return body.replace(regex, function(match) {
    return `{% include lazyload.html image_src="${match.split("(")[1].slice(0, -1)}" %}`
  })
}
...
// download image
const imageUrls = findImageUrl(body)
if (imageUrls.length > 0) {
  fs.mkdirSync(imagePath, { recursive: true })
  const s3Urls = await downloadImages(imagePath, imageUrls)
  body = replaceUrl(body, imageUrls, s3Urls)
}

body = convertLazyImage(body)

지연 로딩을 적용한 후 스크롤에 따라 로딩이 진행되는 것을 확인하였습니다.

CLS 개선을 위한 이미지 스켈레톤 적용

마지막으로 이미지 로딩에 따른 CLS(레이아웃 변경 횟수) 를 개선하기 위해 이미지에 스켈레톤을 적용했습니다.

스켈레톤 디자인은 서근님 블로그에 나와있는 내용을 참고했습니다.

_sass/layout.scss

/*========스켈레톤 애니메이션 시작========*/
.image-flex {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  text-align: justify;
}
@media (max-width: 767.98px) {
  .image-flex {
    display: grid;
  }
}
.image-item {
  padding: 1rem;
}
.image-item-img {
  position: relative;
  width: 100%;
}

/*스켈레톤 메인 컨테이너*/
.skeleton_loading {
  position: absolute;
  width: 100%;
  height: 100%;
  background: var(--bg-color);
  opacity: 1;
  transition: opacity 1s;
}
.skeleton_loading.fade {
  opacity: 0;
}
/* 스켈레톤 이미지 */
.skeleton_img {
  width: 100%;
  height: 100%;
}

/* 스켈레톤 텍스트 */
.skeleton_text {
  margin-bottom: 0.5rem;
  height: 1rem;
}
.skeleton_text:nth-child(1) {
  width: 50%;
  height: 1.5rem;
}
.skeleton_text:nth-child(2) {
  width: 20%;
  height: 0.8rem;
}
.skeleton_text:last-child {
  width: 80%;
}

.skeleton_loading * {
  background: linear-gradient(120deg, #e5e5e5 30%, #f0f0f0 38%, #f0f0f0 40%, #e5e5e5 48%);
  border-radius: 0.5rem;
  background-size: 200% 100%;
  background-position: 100% 0;
  animation: load 1s infinite;
}

@keyframes load {
  100% {
    background-position: -100% 0;
  }
}

_includes/lazyload.html

{% if include.image_src %}
<div class="image-flex">
  <div class="image-item">
    <div class="image-item-img">
      <div class="skeleton_loading">
        <div class="skeleton_img"></div>
      </div>
      <img data-src="{{include.image_src}}" alt="" title="" width="500" height="300" class="lazyload" />
    </div>
  </div>
</div>
{% endif %}

assets/js/just-the-docs.js

jtd.onReady(function(){
  initNav();
  ...
  window.onload = setTimeout(() => {
    document.querySelectorAll(".skeleton_loading").forEach(element => {
      element.classList.toggle("fade")
    })
  }, 300)
});

적용 후 이미지 로딩 전 스켈레톤이 생기는 것을 확인할 수 있습니다.

이미지 로딩 개선 후 성능 점수가 전보다 더 좋아졌습니다.

Reference

https://www.inflearn.com/course/lecture?courseSlug=인프콘2023-다시보기&unitId=177896&tab=curriculum