사용자가 웹 페이지에서 사진을 선택한 후에 이미지를 미리 볼 수 있도록 하는 기능을 구현했으나, DOM에 이미지가 렌더링될 때 텍스트 입력이 지연되는 문제가 발생함.
처리해주는 작업
1. 이미지를 Base64로 인코딩 한 후, MD5로 이미지 해시
2. Blob 객체로 클라이언트에서 <img> 요소에 넣을 URL 생성
총 22개(27MB)의 이미지를 업로드한 후에 바로 에디터에 포커스가 되도록 했지만, 이미지가 화면에 나타나고 텍스트가 입력되기까지 사용자가 체감할 정도로 프레임이 저하됨.
메인 스레드에서 처리
1. React useTransition() 사용하기
에디터에 텍스트를 입력하는 작업과 이미지를 화면에 나타내주는 작업 중 후자의 작업은 후순위로 미뤄도 상관없기 때문에 이미지 State를 업데이트하는 함수를 startTransition()에 넣으면 해결될 거라 생각함. 하지만, 문제가 해결되지 않음.
이미지 용량이 크기 때문에 실제 DOM에 페인팅하는 과정에서 성능이 저하되어 useTransition()을 사용해도 문제가 해결되지 않는 것으로 추정됨.
2. 최적화 1: Web Worker 사용
미리보기 화면에 보이는 이미지의 개수에 따라서 성능 측정 결과가 달라질 수 있으므로 Grid에 가장 많은 이미지가 보이는 뷰포트(18개, 890px)에서 성능을 측정함.
1. 메인 스레드에서 작업을 처리했을 때2.Web Worker에서 작업을 처리했을 때1. 메인 스레드에서 작업을 처리했을 때: (좌) 첫 렌더링 시, (우) 모달창을 열 때2.Web Worker에서 작업을 처리했을 때: (좌) 첫 렌더링 시, (우) 모달창을 열 때
메인 스레드에서 처리했을 때와 Web Worker에서 처리했을 때, 첫 렌더링 시 페인팅에 소요되는 시간이 절반으로 줄었지만, 모달창을 열 때는 두 경우 모두 동일해 사실상 최적화의 의미가 없다고 볼 수 있음. 사용자가 체감할 수 있는 텍스트 입력 버벅임과 모달창 스크롤 프레임 저하 문제도 해결되지 않아 사용자 경험을 향상시키기 위해서는 페인팅 시간을 유의미하게 줄여야 한다는 결론에 도달할 수 있음.
'대표사진 변경' 버튼을 누르고 한참 후에야 모달 창이 나타나는 문제는 여전함
3. State 업데이트를 한번에 한다면?
Worker 인스턴스의 개수에 맞게 데이터를 분할했기 때문에 인스턴스가 처리한 데이터를 바로 State에 반영하면 컴포넌트 재렌더링이 여러 번 일어나는 문제점이 있음. Worker로부터 받은 데이터를 한꺼번에 모아 State를 업데이트하면 렌더링 횟수를 줄여 성능이 더 좋아지지 않을까?
const resolved = await Promise.all(
workers.current.map((wk, i): Promise<Images[]> => {
const startI = L * i + i;
let endI = startI + L + 1;
if (endI > images.length) {
endI = images.length;
}
const imagePacking = [...images].slice(startI, endI);
// message 이벤트에 Promise 래핑을 해서 결과를 모두 받을 때까지 대기
return new Promise((resolve) => {
wk.addEventListener("message", function callee(e) {
resolve(e.data);
wk.removeEventListener("message", callee);
});
wk.postMessage(imagePacking);
});
})
);
}
// worker에서 받아온 데이터를 모아서 한번에 업데이트
setImages((prev) => [...prev, ...resolved.flat()]);
State를 한번에 업데이트한 결과
이렇게 한꺼번에 State를 업데이트를 했을 때, 메인 스레드에서 작업을 처리했을 때와 비슷하게 성능 저하가 발생해 Web Worker를 사용한 의미가 없어짐. 현재 상황에서는 State 변경 때문이 아니라 DOM 페인팅 과정에서 성능 저하가 발생하기 때문에 이러한 문제가 발생함.
4. 최적화 2: OffscreenCanvas로 이미지 압축
최적화 과정을 통해 State 변경 그 자체보다 DOM에 이미지를 페인팅하는 과정이 성능에 더욱 큰 영향을 미침을 알 수 있었음. 그렇다면, 이미지를 압축하거나 사이즈를 줄인다면 DOM 페인팅 소요 시간을 더욱 줄일 수 있지 않을까?
이미지 처리를 메인 스레드에서도 할 수 있지만, 그렇다면 이제까지 Web Worker를 이용했던 의미가 없어짐. Canvas를 이용하면 이미지 사이즈와 용량을 줄일 수 있는데, Web Worker에서는 DOM을 조작할 수 없기 때문에 canvas에서의 작업도 불가능함. 아직 실험중인 기능이지만 Web Worker에서 사용할 수 있는 OffscreenCanvas를 사용해보기로 함.
OffscreenCanvas API 지원 현황
A. React에서 OffscreenCanvas 사용하기
canvas는 HTML5에서 각종 애니메이션 등 그래픽 효과를 가능하게 해주는 API이지만, 메인 스레드에서 작동되기 때문에 canvas에서의 작업량이 많다면 메인 스레드에서 계산되는 다른 작업들의 성능도 떨어지는 문제점이 있음. OffscreenCanvas는 Web Worker에서 사용 가능한 canvas로, canvas에서의 무거운 작업을 Web Worker에서 처리하도록 할 수 있어 성능 향상을 꾀할 수 있음.
postMessage()에 OffscreenCanvas와 그외에 Worker에서 처리할 데이터를 보내줌. 한편, postMessage()의 transfer 배열에 OffscreenCanvas를 지정해주지 않으면 아래와 같은 오류가 뜨면서 canvas가 전달되지 않으므로 주의!
하지만, offscreenCanvas는 Transferable 객체이므로 Worker에 전달된 후에 작업을 마치고 offscreenCanvas를 다시 메인 스레드에 넘겨줘야 이후에 메인 스레드에서 다시 Worker로 offscreenCanvas를 전달할 수 있음.
self.addEventListener("message", async ({ data }: MessageEvent) => {
const { imagePaking, canvas } = data;
/* ... */
// canvas 통제 권한을 다시 메인 스레드로 전달함
self.postMessage({ processed, canvas }, [canvas]);
});
B. Web Worker에서 이미지 처리하기
// 1. 축소된 이미지 크기를 반환
function downsizeDimensions(width: number, height: number, maxSize: number) {
const ratio = height / width;
if (width > height) {
const W = width > maxSize ? maxSize : width;
const H = W * ratio;
return [W, H];
}
const H = height > maxSize ? maxSize : height;
const W = H / ratio;
return [W, H];
}
interface compressOptions {
maxSize?: number;
convertTo?: "image/png" | "image/webp";
}
// 2. 이미지 축소 및 압축 처리
function compressImages(
images: File[],
canvas: any,
options?: compressOptions
) {
const ctx = canvas.getContext("2d"); // a. Canvas 2D Context 생성
const incompatible = ["svg+xml", "gif"];
const { maxSize = 1000, convertTo = "image/png" } = options || {};
return Promise.all(
images.map(async (img) => {
const extension = img.type.split("/")[1]; // createImageBitmap() 미지원 포맷 - SVG, GIF
if (incompatible.includes(extension)) return img;
const image = await createImageBitmap(img);
const { width, height } = image;
const [W, H] = downsizeDimensions(width, height, maxSize);
// b. 축소된 크기로 이미지를 Canvas에 그림
canvas.width = W;
canvas.height = H;
ctx.drawImage(image, 0, 0, W, H);
// c. Canvas에 그려진 이미지를 압축해 Blob 객체로 변환
return canvas.convertToBlob({ quality: 0.75, type: convertTo });
})
);
}
self.addEventListener("message", async ({ data }: MessageEvent<WorkerData>) => {
const { images, canvas } = data;
// 1. 이미지 축소 및 압축 처리
const compressed = await compressImages(images, canvas);
// 2. 이미지에 대한 정보 얻음(URL, MD5)
const processed = await extractImageInfos(compressed);
self.postMessage({ images: processed });
});
1. createImageBitmap()를 이용해 이미지 정보를 담은 ImageBitmap 객체를 생성함. JPG, JPEG, PNG, WEBP 이미지 파일은 ImageBitmap 객체로 변환할 수 있지만, SVG, GIF 형식은 온전하게 변환하는 것이 불가능하므로 해당 포맷은 변환 없이 반환해줌. #
2. ImageBitmap 객체에는 이미지의 높이, 너비 정보가 담겨 있으므로 이 정보를 이용해 이미지 비율에 맞춰 canvas의 높이, 너비를 지정함.
4. convertToBlob() 메서드로 사이즈를 줄일 이미지를 다시 Blob 객체로 변환함. option 객체의 quality 값을 지정해 화질을 줄여 이미지 용량을 더 줄일 수 있음.
C. 최적화 결과
1. 이미지를 압축하지 않았을 때(페인팅: 평균 1379ms)2. offscreenCanvas로 이미지를 압축했을 때(페인팅: 평균 180ms)
이미지를 압축했을 때는 스크립트 실행 시간이 근소하게 늘어났지만 페인팅 시간이 1/8 정도로 줄어들었음을 확인할 수 있음. '대표사진 변경' 버튼을 눌러 모달창을 띄울 때는 더더욱 극적인 성능 향상을 관찰할 수 있었음.
이미지 압축을 하기 전에는 버튼을 누르고 모달창이 뜨기까지 체감될 정도로 시간이 지연되어 사용자 경험이 최악이었지만 이미지를 압축하고 난 후에는 모달창이 뜰 때까지 지연 시간이 거의 체감되지 않아 사용자 경험이 무척 좋아짐. 성능 분석 결과로도 이미지 압축 전에는 모달창 페인팅 시간이 2300~3000ms 정도였지만, 압축 후에는 100~200ms 정도로 유의미한 성능 향상을 관찰할 수 있음.
Chrome 103버전부터 offscreenCanvas.convertToBlob()에서 webp 형식으로 이미지를 변환할 수 있는 기능이 추가됨. 이미지에 따라 압축률이 다를 수 있으나 테스트에서 사용한 이미지(총 27.1MB)가 1553KB로 1/17 수준으로 줄어들었음을 확인할 수 있음. 페인팅 시간에 유의미한 변화는 없었지만 서버 트래픽 부담을 줄이는데는 도움이 될 것이라고 생각됨.
5. 최종 결과
페인팅 시간은 줄어들었지만, ① Worker에서 처리하는 작업이 더 늘어나 유휴 상태 시간이 4배 가까이 증가함. 그로 인해 이미지를 첨부하고 나서 첫 페인팅까지 시간이 오히려 눈에 띄게 증가하는 문제가 생김. 또한, ② 1MB 이하의 이미지는 이미지 크기 조정, 압축 이후 오히려 용량이 증가하는 문제도 발견됨. 1MB 이상인 이미지만 크기 조정, 압축을 하도록 절충한 결과, 첫 렌더링 및 모달창을 열 때의페인팅 시간을 1/10로 줄일 수 있었음.
A. 첫 렌더링 시
0. Web Worker 미사용1. Web Worker + offscreenCanvas: 모든 이미지 처리2. Web Worker + offscreenCanvas: 1MB 이상만 처리3. Web Worker + offscreenCanvas: 모든 이미지 webp
B. 모달 창을 열 때
0. Web Worker 미사용1. Web Worker + offscreenCanvas: 모든 이미지 처리2. Web Worker + offscreenCanvas: 1MB 이상만 처리3. Web Worker + offscreenCanvas: 모든 이미지 webp
Web Worker를 이용한 이미지 렌더링 성능 개선
사용자가 웹 페이지에서 사진을 선택한 후에 이미지를 미리 볼 수 있도록 하는 기능을 구현했으나, DOM에 이미지가 렌더링될 때 텍스트 입력이 지연되는 문제가 발생함.
처리해주는 작업
1. 이미지를 Base64로 인코딩 한 후, MD5로 이미지 해시
2. Blob 객체로 클라이언트에서 <img> 요소에 넣을 URL 생성
총 22개(27MB)의 이미지를 업로드한 후에 바로 에디터에 포커스가 되도록 했지만, 이미지가 화면에 나타나고 텍스트가 입력되기까지 사용자가 체감할 정도로 프레임이 저하됨.
1. React useTransition() 사용하기
에디터에 텍스트를 입력하는 작업과 이미지를 화면에 나타내주는 작업 중 후자의 작업은 후순위로 미뤄도 상관없기 때문에 이미지 State를 업데이트하는 함수를 startTransition()에 넣으면 해결될 거라 생각함. 하지만, 문제가 해결되지 않음.
이미지 용량이 크기 때문에 실제 DOM에 페인팅하는 과정에서 성능이 저하되어 useTransition()을 사용해도 문제가 해결되지 않는 것으로 추정됨.
2. 최적화 1: Web Worker 사용
미리보기 화면에 보이는 이미지의 개수에 따라서 성능 측정 결과가 달라질 수 있으므로 Grid에 가장 많은 이미지가 보이는 뷰포트(18개, 890px)에서 성능을 측정함.
메인 스레드에서 처리했을 때와 Web Worker에서 처리했을 때, 첫 렌더링 시 페인팅에 소요되는 시간이 절반으로 줄었지만, 모달창을 열 때는 두 경우 모두 동일해 사실상 최적화의 의미가 없다고 볼 수 있음. 사용자가 체감할 수 있는 텍스트 입력 버벅임과 모달창 스크롤 프레임 저하 문제도 해결되지 않아 사용자 경험을 향상시키기 위해서는 페인팅 시간을 유의미하게 줄여야 한다는 결론에 도달할 수 있음.
3. State 업데이트를 한번에 한다면?
Worker 인스턴스의 개수에 맞게 데이터를 분할했기 때문에 인스턴스가 처리한 데이터를 바로 State에 반영하면 컴포넌트 재렌더링이 여러 번 일어나는 문제점이 있음. Worker로부터 받은 데이터를 한꺼번에 모아 State를 업데이트하면 렌더링 횟수를 줄여 성능이 더 좋아지지 않을까?
이렇게 한꺼번에 State를 업데이트를 했을 때, 메인 스레드에서 작업을 처리했을 때와 비슷하게 성능 저하가 발생해 Web Worker를 사용한 의미가 없어짐. 현재 상황에서는 State 변경 때문이 아니라 DOM 페인팅 과정에서 성능 저하가 발생하기 때문에 이러한 문제가 발생함.
4. 최적화 2: OffscreenCanvas로 이미지 압축
최적화 과정을 통해 State 변경 그 자체보다 DOM에 이미지를 페인팅하는 과정이 성능에 더욱 큰 영향을 미침을 알 수 있었음. 그렇다면, 이미지를 압축하거나 사이즈를 줄인다면 DOM 페인팅 소요 시간을 더욱 줄일 수 있지 않을까?
이미지 처리를 메인 스레드에서도 할 수 있지만, 그렇다면 이제까지 Web Worker를 이용했던 의미가 없어짐. Canvas를 이용하면 이미지 사이즈와 용량을 줄일 수 있는데, Web Worker에서는 DOM을 조작할 수 없기 때문에 canvas에서의 작업도 불가능함. 아직 실험중인 기능이지만 Web Worker에서 사용할 수 있는 OffscreenCanvas를 사용해보기로 함.
A. React에서 OffscreenCanvas 사용하기
canvas는 HTML5에서 각종 애니메이션 등 그래픽 효과를 가능하게 해주는 API이지만, 메인 스레드에서 작동되기 때문에 canvas에서의 작업량이 많다면 메인 스레드에서 계산되는 다른 작업들의 성능도 떨어지는 문제점이 있음. OffscreenCanvas는 Web Worker에서 사용 가능한 canvas로, canvas에서의 무거운 작업을 Web Worker에서 처리하도록 할 수 있어 성능 향상을 꾀할 수 있음.
transferControlToOffscreen() 메서드로 Web Worker에서 canvas를 사용할 수 있도록 함. 이때, 하나의 canvas에는 하나의 Worker만 접근할 수 있으므로 둘 이상의 Worker에 하나의 canvas만을 전달하지 않도록 주의!
postMessage()에 OffscreenCanvas와 그외에 Worker에서 처리할 데이터를 보내줌. 한편, postMessage()의 transfer 배열에 OffscreenCanvas를 지정해주지 않으면 아래와 같은 오류가 뜨면서 canvas가 전달되지 않으므로 주의!
React Ref 객체를 사용하는 방법은?
이벤트 핸들러에서 각각의 Worker에 전달할 canvas 요소를 생성하지 않고, 아래와 같이 canvas 요소를 미리 렌더링해두고 Ref 객체에 저장해둔 canvas를 Worker에 전달하는 방법도 있음.
하지만, offscreenCanvas는 Transferable 객체이므로 Worker에 전달된 후에 작업을 마치고 offscreenCanvas를 다시 메인 스레드에 넘겨줘야 이후에 메인 스레드에서 다시 Worker로 offscreenCanvas를 전달할 수 있음.
B. Web Worker에서 이미지 처리하기
1. createImageBitmap()를 이용해 이미지 정보를 담은 ImageBitmap 객체를 생성함. JPG, JPEG, PNG, WEBP 이미지 파일은 ImageBitmap 객체로 변환할 수 있지만, SVG, GIF 형식은 온전하게 변환하는 것이 불가능하므로 해당 포맷은 변환 없이 반환해줌. #
2. ImageBitmap 객체에는 이미지의 높이, 너비 정보가 담겨 있으므로 이 정보를 이용해 이미지 비율에 맞춰 canvas의 높이, 너비를 지정함.
3. CanvasRenderingContext2D.drawImage() 메서드를 이용해 canvas에 꽉 차게 이미지를 나타냄.
4. convertToBlob() 메서드로 사이즈를 줄일 이미지를 다시 Blob 객체로 변환함. option 객체의 quality 값을 지정해 화질을 줄여 이미지 용량을 더 줄일 수 있음.
C. 최적화 결과
이미지를 압축했을 때는 스크립트 실행 시간이 근소하게 늘어났지만 페인팅 시간이 1/8 정도로 줄어들었음을 확인할 수 있음. '대표사진 변경' 버튼을 눌러 모달창을 띄울 때는 더더욱 극적인 성능 향상을 관찰할 수 있었음.
이미지 압축을 하기 전에는 버튼을 누르고 모달창이 뜨기까지 체감될 정도로 시간이 지연되어 사용자 경험이 최악이었지만 이미지를 압축하고 난 후에는 모달창이 뜰 때까지 지연 시간이 거의 체감되지 않아 사용자 경험이 무척 좋아짐. 성능 분석 결과로도 이미지 압축 전에는 모달창 페인팅 시간이 2300~3000ms 정도였지만, 압축 후에는 100~200ms 정도로 유의미한 성능 향상을 관찰할 수 있음.
D. webp 포맷으로 변환
Chrome 103버전부터 offscreenCanvas.convertToBlob()에서 webp 형식으로 이미지를 변환할 수 있는 기능이 추가됨. 이미지에 따라 압축률이 다를 수 있으나 테스트에서 사용한 이미지(총 27.1MB)가 1553KB로 1/17 수준으로 줄어들었음을 확인할 수 있음. 페인팅 시간에 유의미한 변화는 없었지만 서버 트래픽 부담을 줄이는데는 도움이 될 것이라고 생각됨.
5. 최종 결과
페인팅 시간은 줄어들었지만, ① Worker에서 처리하는 작업이 더 늘어나 유휴 상태 시간이 4배 가까이 증가함. 그로 인해 이미지를 첨부하고 나서 첫 페인팅까지 시간이 오히려 눈에 띄게 증가하는 문제가 생김. 또한, ② 1MB 이하의 이미지는 이미지 크기 조정, 압축 이후 오히려 용량이 증가하는 문제도 발견됨. 1MB 이상인 이미지만 크기 조정, 압축을 하도록 절충한 결과, 첫 렌더링 및 모달창을 열 때의 페인팅 시간을 1/10로 줄일 수 있었음.
A. 첫 렌더링 시
B. 모달 창을 열 때
[참고]
Wait for several web workers to finish
How to compress an image via Javascript in the browser?
'JavaScript > React' 카테고리의 다른 글