블로그 성능 개선 (이미지 로딩)
작성일: 2023-10-23Prerequisite
- 블로그 성능 분석
저번 글에서 수행했던 성능 분석 결과 중 이미지 로딩 개선에 대해 살펴보겠습니다.
이미지 로딩 개선
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