바쁜 현대인을 위한 간단 요약(TL;DR)
무손실 이미지를 압축하고 썸네일 이미지를 만들어 index 화면 로딩 속도를 개선한다.
대략 1mb 짜리 이미지를 100~200kb로 압축하고 또 그 이미지를 다시 줄여 결론적으로 10~20kb 용량의 썸네일 이미지로 대체하는 과정을 기록했다.
참고로 DB는 구글 파이어베이스(Firebase) 8 버전을 사용하고 있다.
새해에는 뭔갈 해야 할 것만 같은 초조함에 시달리다 못해 결국 미뤄놨던 숙제를 꺼냈다.
매일 보는 뉴스와 그날의 날씨를 다시 꺼내보기 쉽도록 취미삼아 만든 사이트가 하나 있다.(하지만 꺼내보기 어려움)
아래 링크한 '오늘의 날씨와 경제'라는 아주 직관적이고 재미없있는 이름의 사이트다.(weather&economy라서 weaco)
리액트와 Firebase(8버전)를 처음 접하고 연습삼아 만든 토이프로젝트다. 그래서 그런지 속은 엉망진창이다. 리액트 연습용이라서 디자인도 없이 그냥 코드부터 냅다 갈겨 버려서 디자인이랄 것도 없이 추레하다.(최대리 디자인 안하고 뭐하냐...) 뭐.. 혼자 사용하는 사이트다보니 귀차니즘에 쩔어 기술 부채를 마음 한켠에 고이 모셔놓고 매일 눈가리고 글만 업로드 하고 있다. 하지만 새해도 됐고 남들 다 하는 금연, 다이어트처럼 뭐라도 해야 되지 않겠냐 싶어 우선 이 숙변부터 제거하기로 했다. 디자인 부채도 부채지만 지금 그게 문제가 아니여. 당장 숨 넘어가기 전에 응급환자부터 살려야지.
0. 우선 크롬 개발자도구 중 Lighthouse를 이용해 현재 상태를 진단해보자
Lighthouse는 웹 페이지의 품질을 향상시키기 위한 오픈 소스 툴이다. 웹 페이지의 성능과 접근성, PWA(progressive web app), SEO 등 테스트를 통해 현재 상태를 보여주고 어떻게 하면 개선시킬 수 있는지 가이드까지 해 주는 혜자 툴이다.
사용법은 간단하다. 크롬에 이미 탑재되어 있기 때문에 개발자도구(F12키) 연 다음 Lighthouse 탭에서 분석 버튼 누름 끝이다. 참고로 IndexedDB에 저장된 데이터가 로드 성능에 영향을 줄 수 있기 때문에 시크릿 창을 띄워 분석하는 것을 추천한다.
자신에게 필요한 옵션을 선택해 주면 된다. 일단 데스크탑 성능만 분석해 봤다.
그 결과,
페이지 로드가 너무 느리다는 팩트 폭력을 처맞았다.
역시 예상대로 이미지 문제가 압도적이다. 이제 해결해보자.
용량이 큰 이미지 개선
재작년까지만 해도 이미지를 사용할 때 자체적으로 70~80% 정도 퀄리티로 직접 변환해서 업로드 했었다. 그래서 가로 세로 1000px 사이즈이긴 하지만 이미지 하나당 용량은 200kb 근처에서 놀았었다. '썸네일 이미지도 따로 있으면 더 빠를텐데 '라고 생각은 했지만 우선순위에서 밀려 여태 부채로 남아 있다. 것보다 큰 문제는, 작년 하반기부터 올렸던 이미지들에 있었다. 확인해보니 근래 이미지들이 약 1mb 정도로 큰 사이즈로 올라갔다.
작년 하반기부터 피그마에서 작업하기 시작했는데, 뭣도 모르고 jpg로 단순 export한 이미지였다. 피그마에서 단순 export 한 jpg는 무손실 수준으로 용량이 너무 크다.(1000px 짜리 정사각형 이미지가 무슨 1mb 가까이 나오냐고) 그걸 고대로 업로드 했으니 당연히 이전보다 느릴 수 밖에. 원래 좀 느렸던 탓에 더 느려지는 거에 무신경했달까. 이걸 참 늦게 눈치챘다. 가난에는 이자가 붙는다는 말도 있지 않나. 느린 사이트를 방치하기 시작하니 감각마저 무뎌져서 더 느려져도 거의 인지하지 못하는 수준이 되버렸다.😢
이미지 크기 문제를 해결하기 위해서 cdn을 이용하거나 이미지를 직접 압축하거나 썸네일용 이미지를 별도로 운영할 수 도 있다. weaco는 현재 firebase를 이용해 이미지를 관리하고 있기 때문에 cdn보다 업로드 할 때 우선 이미지 압축을 한 번 하고, 추가로 마음의 짐으로 남아 있는 썸네일용 이미지도 함께 저장하여 사용하도록 바꿔보려고 한다. 그리고 압축에 앞서 앞으로 이미지 타입은 'jpg'말고 'webp'를 이용하기로 했다.
1. webp 이미지 형식
lighthouse에 추천하는 '차세대 형식을 사용해 이미지 제공하기' 항목이 바로 이 내용이다. WebP 및 AVIF와 같은 이미지 형식은 PNG나 JPEG보다 압축률이 높기 때문에 다운로드가 빠르고 데이터 소비량도 적다고 한다. 자세한 내용은 여기에서 확인 가능하다. 한국어 페이지로 된 WebP 여기서 webp에 대해 추가 정보를 확인할 수 있다. 유튜브에서도 이용중인데, 썸네일 이미지 다운로드 해보신 분은 알겠지만 바로 WebP 파일이다. 현재 대부분의 브라우저에서 지원하고 있기도 하고 많이 써오던 jpeg, png, gif 보다는 압축을 더 잘하도록 설계되었다고 하니 한 번 이용해 보기로 결정.
지원하는 브라우저 목록 : https://caniuse.com/?search=webp
WebP 무손실 이미지는 PNG에 비해 크기가 26% 더 작습니다. WebP 손실 이미지는 동등한 SSIM 품질 색인에서 비슷한 JPEG 이미지보다 25~34% 더 작습니다.
무손실 WebP는 22% 추가 바이트의 비용으로 투명성을 지원(알파 채널이라고도 함)합니다. 손실 RGB 압축이 허용되는 경우 손실 WebP는 투명도도 지원하며, 일반적으로 PNG에 비해 3배 더 작은 파일 크기를 제공합니다.
[윈도우 or 맥 or 리눅스용]
만약 jpg를 webp로 변경하고 싶다면 아래 사이트에서 유틸리티를 다운로드 받을 수 있다.(포삽용 플러그인도 보인다)
https://developers.google.com/speed/webp/docs/precompiled
파일 리스트만 쭉 보고 싶다면 밑에 링크로 가면된다.(이전 버전들도 있음)
https://storage.googleapis.com/downloads.webmproject.org/releases/webp/index.html
잠깐, 여기서 '이게 무슨 소리야' 싶은 분들은 아래 [웹용], [피그마용]으로 패스하셔도 무방.
참고로 윈도우(64비트) 사용자라면 'libwebp-1.2.4-windows-x64.zip' 쓰라고 현재시점(2023년 1월) 문서에 적혀 있다.
그래서 직접 받고 압축풀어서 필요한 파일만 첨부했음.(윈도우용)
이미지를 webp로 변환하려면 아래 걸 받아서 쓰면 된다.
사용방법은 아래 있다.
간단한 자동 변환 스크립트를 짜서 쓰는 것도 괜찮은 방법이다.
# 간단 사용법
# cwebp가 위치한 디렉토리에서 실행
# -q 옵션이 바로 퀄리티 조절 옵션으로 1~100 사이 입력
./cwebp -q 75 image.png -o image.webp
변환에 성공한다면 이런 결과화면이 나온다. 퀄리티를 '75'로 설정했더니 1.687KB 짜리 파일이 242KB 가 되었다. 이미지 사이즈가 가로 1920px이기때문에 원본 사이즈로 보면 분명 차이가 있다. 점묘화처럼 1픽셀 점들이 세세하게 표현되어 있던 게 주변과 동화되면서 블러 처리한 것처럼 살짝 흐려지는 느낌이다. 경계선이 흐려진 것처럼 보이겠지만 사실 모든 면적의 픽셀들이 영향을 받은 것이다.
앞서 어두운 이미지를 변환해서 그런지 생각보다 차이가 드라마틱하지 않아 좀 더 작고 색채가 다채로운 이미지를 변환해 봤다.
구분이 좀 더 잘 되는 것 같다.(아래 확대샷)
[웹용]
'뭔소리야 난 모르겠고, 대충 한두장 빨리 바꾸고 싶다' 하시는 분들은 상대적으로 좀 느리지만 그냥 무료 웹사이트를 이용하자. (참고로 저랑 상관없는 사이트입니다. 그래서 2023년 1월 현재 잘되지만 미래에도 잘 된다고 보장할 수 없으니 만약 접속이 안된다면 구글링 해보세요.)
개인적으로 추천하는 squoosh.app
이미지 사이즈나 퀄리티 조절도 되고 압축 후 결과까지 양쪽으로 비교해서 볼 수 있어서 ui나 기능이나 빠지는 게 없다.
https://convertio.co/kr/jpg-webp/
이 사이트는 url을 잘 보면 알겠지만 바꾸기 쉽게 도메인을 만들어놨다.
사이즈나 퀄리티 조절은 안되고 단순히 변환만 가능하다.
참고로 jpg -> webp 변환된 결과물의 용량을 보니 퀄리티 수준은 85 정도 되는 것 같다.
jpg -> webp | https://convertio.co/kr/jpg-webp/ |
webp -> jpg | https://convertio.co/kr/webp-jpg/ |
png -> webp | https://convertio.co/kr/png-webp/ |
webp -> png | https://convertio.co/kr/webp-png/ |
gif -> webp | https://convertio.co/kr/gif-webp/ |
webp -> gif | https://convertio.co/kr/webp-gif/ |
[피그마용]
피그마로부터 원본 이미지 자체를 한번에 webp로 저장하기 위해서는 사람들이 만들어 둔 피그마 플러그인을 이용해야 한다. 포샵은 워낙 기능이 많고 무거운 공룡이라 피그마에는 상대적으로 없는 게 많다. 대신 플러그인 환경이 잘 갖춰져 있어서 찾아보면 사람들이 이미 만들어 둔 게 있는 편이다. webp 관련 플러그인도 여러개가 나온다. 그 중에 써본 건 아래 두개.
'Tinyimage'는 무료버전에 사용 한계가 생긴 거 같아서, 빨간 아이콘의 'WebP Exporter'를 이용했다.
단 이것도 역시 퀄리티 조절이 불가능하다는 단점이 있다.
이제 webp는 준비되었으니, 업로드 시 이미지를 한 번 압축해주는 라이브러리를 이용해보자.
2. Firebase Storage 기존 파일 백업
기존 storage에 업로드 되어 있는 파일들은 백업용으로 남겨뒀다. 테스트하다가 원본을 날려 먹을 순 없으니까.
firebase 콘솔에는 전체 다운로드 기능이 없는 건지 못 찾은 건지 보이질 않아서, 다운로드 기능을 코드로 간단하게 짜서 날짜별로 다운로드 받았다. api 호출을 대량으로 날렸더니 다운로드 안되고 소실되는 게 중간 중간 있어서 날짜별로 필요한 부분만 나눠서 받았다. 대략적인 코드는 아래에.
const handleImageDownload = () => {
console.log('이미지 다운로드 시작');
dbService
.collection("items")
.where("creatorId", "==", process.env.REACT_APP_ADMIN)
.where("date", ">=", startDate)
.where("date", "<=", endDate)
.orderBy("date", "desc")
.get() // firebase 8버전 깨알팁: 한 번 호출할 때 사용, 실시간 업데이트 수신은 onSnapshot
.then((snapshot) => {
console.log('이미지 갯수: ', snapshot.docs.length);
snapshot.docs.map((doc) => {
downloadImage(doc.data().attachmentUrl, doc.data().date);
});
});
}
async function downloadImage(url, date) {
console.log(`이미지 호출 : ${date}`);
const init = await fetch(url, {method: "get"});
const blob = await init.blob();
let fileName = `${date}.jpg`;
const createdUrl = URL.createObjectURL(await blob);
const a = document.createElement("a");
a.href = createdUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
}
3. 이미지 파일 업로드 시 이미지 압축 자동화
이제 본격적으로 이미지를 압축하는 로직을 넣어보자.
내가 이용한 라이브러리는 'Browser Image Compression' 이다.
설치와 사용법은 아래 npm 페이지에서 확인 할 수 있다.
Browser Image Compression
https://www.npmjs.com/package/browser-image-compression
- 옵션 사항
// you should provide one of maxSizeMB, maxWidthOrHeight in the options
const options: Options = {
maxSizeMB: number, // (default: Number.POSITIVE_INFINITY)
maxWidthOrHeight: number, // compressedFile will scale down by ratio to a point that width or height is smaller than maxWidthOrHeight (default: undefined)
// but, automatically reduce the size to smaller than the maximum Canvas size supported by each browser.
// Please check the Caveat part for details.
onProgress: Function, // optional, a function takes one progress argument (percentage from 0 to 100)
useWebWorker: boolean, // optional, use multi-thread web worker, fallback to run in main-thread (default: true)
signal: AbortSignal, // options, to abort / cancel the compression
// following options are for advanced users
maxIteration: number, // optional, max number of iteration to compress the image (default: 10)
exifOrientation: number, // optional, see https://stackoverflow.com/a/32490603/10395024
fileType: string, // optional, fileType override e.g., 'image/jpeg', 'image/png' (default: file.type)
initialQuality: number, // optional, initial quality value between 0 and 1 (default: 1)
alwaysKeepResolution: boolean // optional, only reduce quality, always keep width and height (default: false)
}
imageCompression(file: File, options: Options): Promise<File>
사용법을 참고해서 이미지를 압축하고 firebase storage 저장까지 쭈욱 완료하는 코드를 작성해서 적용했다. 이제 사진 업로드 할 때마다 압축하고 로그로 확인할 수 있게 되었다.(나만 쓰는 기능이라 로그는 남겨둠)
대략적인 코드는 아래에.
// 이미지 압축
async function handleImageUpload(event) {
const imageFile = event.target.files[0];
console.log(`originalFile size ${imageFile.size / 1024 / 1024} MB`);
if (imageFile.name.indexOf('.') > 0) {
setExt(imageFile.name.split('.').pop()); //원본 파일 확장자 저장
}
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 1000,
useWebWorker: true,
initialQuality: 0.7 //퀄리티 소수점 표기
}
try {
const compressedFile = await imageCompression(imageFile, options);
console.log(`compressedFile size ${compressedFile.size / 1024 / 1024} MB`);
const reader = new FileReader();
reader.onloadend = (finishedEvent) => {
const {
currentTarget: { result },
} = finishedEvent;
setAttachment(result);
};
reader.readAsDataURL(compressedFile);
} catch (error) {
console.log(error);
}
}
// submit 이벤트에 반응하도록 적용
const onSubmit = async (event) => {
event.preventDefault();
let attachmentUrl = "";
if (attachment !== "") {
const attachmentRef = storageService
.ref()
.child(`${userObj.uid}/${uuidv4()}${ext ? '.'+ext : '.jpg'}`);
const response = await attachmentRef.putString(attachment, "data_url");
attachmentUrl = await response.ref.getDownloadURL();
}
// 업로드 후 응답받은 이미지의 attachmentUrl을 이용
}
4. 기존 이미지 압축 후 다시 저장
내킨 김에 이미 올라가버린 1mb 가까이 되는 이미지들도 싹 압축시켜버렸다. 하나씩 하면 불편하니까 이것도 이미지 백업받을 때와 마찬가지로 기간별로 처리하는 로직으로 구성했다. (firebase 무료 버전을 사용 중이라 대량으로 호출하다 노출 중단될까 우려됨)
// 버튼 클릭 이벤트 적용
const handleImageCompression = async () => {
console.log('이미지 압축시작');
let items = await getItems();
console.log('전체 아이템: ',items)
items.forEach(async (item) => {
console.log('업데이트 호출 시작: ', item);
updateImage(item.id, item.url);
});
}
// firebase에서 이미지 데이터 가져오기
async function getItems() {
let count = 0;
const items = [];
return await new Promise((resolve) => {
dbService
.collection("items")
.where("creatorId", "==", process.env.REACT_APP_ADMIN)
.where("date", ">=", startDate)
.where("date", "<=", endDate)
.orderBy("date", "desc")
.get()
.then((snapshot) => {
snapshot.docs.forEach((doc) => {
count++;
items.push({num: count, id: doc.id, date: doc.data().date, url: doc.data().attachmentUrl});
});
resolve(items);
})
.catch((error) => {
console.log("Error getting documents: ", error);
});
});
}
// 가져온 이미지 압축을 위해 준비
async function updateImage(docId, url) {
let httpRef = await storageService.refFromURL(url);
if (httpRef.name.indexOf('.') > 0) {
setExt(httpRef.name.split('.').pop());
}
const init = await fetch(url, {method: "get"});
const blob = await init.blob();
compressedFile(blob, httpRef.name, docId);
}
// 이미지 압축
async function compressedFile(blob, originFileName, docId) {
console.log(`originalFile size ${blob.size / 1024 / 1024} MB`);
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 1000,
useWebWorker: true,
initialQuality: 0.7
}
try {
const compressedFile = await imageCompression(blob, options);
console.log(`compressedFile size ${compressedFile.size / 1024 / 1024} MB`);
console.log('원본 파일 이름: ', originFileName);
let res = await Promise.all([
ref.child(`${doc}/${originFileName}`).delete()
.then(() => {
console.log(originFileName + ' 삭제 완료');
})
.catch((error) => {
console.log('삭제 실패: ', error);
}),
ref.child(`${doc}/${uuidv4()}${ext ? '.'+ext : '.jpg'}`).put(compressedFile)
.then((snapshot) => {
return snapshot.ref.getDownloadURL();
}).catch((error) => {
console.log('재업로드 실패: ', error);
})
]);
updateAttachmentUrl(docId, res[1]);
} catch (error) {
console.log(error);
}
}
// 압축된 새로운 이미지 파일의 url 주소를 기존 아이템에 갱신
async function updateAttachmentUrl(docId, url) {
dbService.doc(`items/${docId}`).update({
attachmentUrl: url,
});
console.log('업데이트 완료. 변경 파일 URL: ', url);
}
뭔가 여러가지 한 거 같지만, 사이트에 유의미한 변화는 결국 이미지 webp로 변경하고 용량 압축 정도 밖에 없다.
이렇게만 하고 다시 한 번 lighthouse로 분석해봤다.
결과는,
오호.
제법 좋아졌다. 확실히 이미지가 문제인 사이트다보니 이미지만 개선해도 점수가 확 올라간다.
물론 여전히 이미지 크기를 적절하게 설정하라는 문구가 거슬린다. 그래서 또 썸네일 사이즈를 별도로 운영하면 케어되지 않을까 싶어 모든 이미지를 썸네일 사이즈로 변환하여 추가하는 작업도 진행했다.
5. 썸네일 이미지 추가 운영
작업 순서와 대략적인 코드는 아래와 같다.
1. 파이어베이스(Firebase Storage)에서 원본 이미지 다운로드
2. 원본 이미지를 썸네일 사이즈로 가공(300x300) - 클라이언트 사이드 작업
3. 파이어베이스(Firebase Storage)에 썸네일 이미지 업로드
4. 파이어베이스(Firebase Cloud Firestore)에 존재하는 개별 아이템마다 해당 썸네일 URL 추가
// 기존 데이터에 썸네일 이미지 없는 경우 썸네일 이미지 만들어 파이어베이스에 추가하기
const handleCreateThumbnail = async () => {
console.log('썸네일 이미지 추가하기 시작');
let items = await getItems();
console.log('전체 아이템: ',items)
items.forEach(async (item) => {
console.log('업데이트 호출 시작: ', item);
if (!item.thumbnailUrl) {
updateThumbnail(item.id, item.url);
}
});
}
// 이미지 불러와서 blob 형태로 전달
async function updateThumbnail(docId, url) {
let mainRef = await storageService.refFromURL(url);
const init = await fetch(url, {method: "get"});
const blob = await init.blob();
createThumbnail(blob, mainRef.name, docId);
}
// 썸네일 이미지 만들어 전달
async function createThumbnail(blob, originFileName, docId) {
try {
let thumbnailFile = await getThumbnailFile(blob);
let res = await new Promise((resolve) => {
ref.child(`thumbnails/${uuidv4()}.webp`).put(thumbnailFile)
.then((snapshot) => {
resolve(snapshot.ref.getDownloadURL());
}).catch((error) => {
console.log('썸네일 재업로드 실패: ', error);
})
});
updateThumbnailUrl(docId, res);
} catch (error) {
console.log(error);
}
}
// blob 전달받아서 썸네일 이미지 만들기
async function getThumbnailFile(blob) {
const reader = new FileReader();
return await new Promise((resolve) => {
reader.onloadend = (finishedEvent) => {
const {
currentTarget: { result },
} = finishedEvent;
const image = new Image();
image.src = result;
image.onload = function () {
const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 300;
canvas.getContext("2d").drawImage(image, 0, 0, 300, 300);
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/webp', 0.7);
}
}
reader.readAsDataURL(blob);
})
}
// 기존 파이어베이스 아이템에 썸네일 URL 추가 업데이트
async function updateThumbnailUrl(docId, thumbnailUrl) {
dbService.doc(`items/${docId}`).update({
thumbnailUrl: thumbnailUrl,
});
console.log('업데이트 완료. 변경 파일 URL: ', thumbnailUrl);
}
기존 아이템에 썸네일 이미지가 없으면 가로세로 300픽셀 이미지를 만들어서 추가해줬다. 구글님의 심기를 거스리지 않기 위해 1년 단위로 끊어서 작업했는데, 대략 300개 정도 api를 쌍(RW)으로 호출했는데, 몇 초만에 잘 되서 괜히 머쓱했다. (생각보다 관대하네)
이제 메인 페이지에 썸네일 이미지를 불러오도록 변경하고 다시 성능 분석을 해봤다.
결론적으로 성능점수가 '69'에서 '89'가 되었다. 가장 효율적으로 방법이라고는 말 못하겠지만 일단 1차원적으로 해결할 수 있는 부분은 처리된 것 같다. 이후에 캐시를 이용한다거나 서버를 분산하는 등 인프라를 개선하는 방법도 이용할 수 있을 것이다. 하지만 몇 십명(어쩌면 나 혼자?) 쓰는 사이트다보니 이미지 개선은 일단 이정도로 마무리한다. 닭 잡는데 소잡는 칼을 쓰는 것도 여러가지로 낭비다. 개선사항은 많기 때문에 언제나 선택과 집중이 필요하다. 이젠 자바스크립트 정리가 필요한 시점인 것 같다. 일단 부딪혀보고 다음글에서 또 정리 해 보겠다.
한참 혼자 뚝딱 거리면서 마무리 지었는데, 다음날 webp 검색하다가 아래 글을 봐버렸다.
imagemin이라는 라이브러리와 imagemin-webp 플러그인을 쓰면 좀 더 수월해 보인다.
차후에 imagemin 라이브러리로 리팩토링 해야겠다.(webp 플러그인: imagemin-webp)
하아.. 어디 내놔도 부끄러운 내 코드. 다시 손봐야겠다. 무식하니 끝없는 다람쥐 쳇바퀴 신세...🐿️
HyeonSeok Yang Medium
지식 공유 감사합니다.🙏
'개발 > Etc' 카테고리의 다른 글
Cumulative Layout Shift(누적 레이아웃 이동, CLS) (0) | 2023.01.11 |
---|---|
npm trends _ 라이브러리 선택할 때 유용한 서비스 (2) | 2023.01.11 |
웹 성능 개선 - 이미지 lazy loading(리액트) (0) | 2023.01.10 |
웹 성능 개선 - javascript편 (0) | 2023.01.09 |
코딩 열풍과 부트캠프 난립, 개발자 취업에 대한 단상 (2) | 2022.10.15 |