[프론트엔드] 이미지 최적화 시리즈(2): OffscreenCanvas과 함께 알아보는 메인 스레드 블로킹 해결기 (feat. Web Worker)

Woonil·2025년 12월 25일

프론트엔드

목록 보기
6/7

이 글에서는 이미지 최적화 과정을 메인 스레드에서 분리하여 사용자 경험(UX)을 개선한 과정을 공유하고자 한다.

🤯문제 상황

동아리 홈페이지 프로젝트에는 다중 이미지 업로드와 이미지 압축 및 리사이징을 지원하고 있다. 이때, 고해상도 이미지 여러 장을 업로드하는 경우 아래와 키보드 입력 처리가 멈추는 현상(프리징 현상)이 나타났다.

이미지 업로드 과정에 대한 퍼포먼스 측정 결과, 아래와 같이 메인 스레드의 이미지 리사이징 과정에서 Long Task들이 존재하는 것을 확인할 수 있었다.

기존 코드는 react-image-file-resizer 라이브러리를 통해 메인 스레드에서 이미지를 리사이징을 수행하고 있었다.

// src/_utils/resizeImageFile.ts
import Resizer from 'react-image-file-resizer';

interface IResizeFileProps {
    file: File;
    targetWidth: number;
    targetHeight: number;
    compressFormat: 'JPEG' | 'PNG' | 'WEBP';
    quality?: number; // 압축 품질 (0~100)
}

export default function resizeImageFile({
    file,
    targetWidth,
    targetHeight,
    compressFormat,
    quality,
}: ResizeFileProps): Promise<File> {
    return new Promise(resolve => {
        Resizer.imageFileResizer(
            file,
            targetWidth,
            targetHeight,
            compressFormat,
            quality,
            0,
            uri => {
                console.log('🚀 ~ newPromise ~ uri:', uri);
                resolve(uri as File);
            },
            'file',
        );
    });
}

해당 라이브러리는 Image 객체 로드, 캔버스 그리기(drawImage), Blob 변환(toBlob)이 모두 메인 스레드 점유하는 문제가 있었다. 따라서 리사이징 과정을 백그라운드에서 수행하면 좋겠다고 판단했으며, 이전에 유튜브를 통해 학습했던 Web Worker가 떠올랐다.

이제 Web Worker 개념을 활용하여 문제를 해결해가는 과정에서 학습한 개념부터 상세 내용을 살펴보자.

🤔개념

메인 스레드(Main Thread)와 블로킹(Blocking)

브라우저는 기본적으로 단일 메인 스레드에서 JavaScript 실행하며, 이곳에서 DOM 렌더링, 사용자 이벤트 처리를 모두 담당한다. 따라서, 만약 무거운 연산이 메인 스레드에서 실행되면, 브라우저는 해당 작업이 끝날 때까지 화면을 그리지 못하고 클릭과 같은 사용자 입력에도 반응할 수 없게 된다. 이를 블로킹(Blocking)이라고 한다.

Web Worker

Web Worker는 메인 스레드와 별개로 백그라운드에서 스크립트를 실행할 수 있는 브라우저 API이다. 무거운 작업을 워커 스레드로 위임하면 메인 스레드는 UI 렌더링에만 집중할 수 있어 쾌적한 사용자 경험을 제공할 수 있다.

OffscreenCanvas API

OffscreenCanvas는 DOM과 분리되어 메모리 상에서만 존재하는 캔버스이다. 일반적인 <canvas> 요소와 달리 DOM에 직접 접근할 필요가 없기 때문에 Web Worker(백그라운드 스레드)에서도 사용할 수 있다는 강력한 장점이 지닌다. 다시 말해, 이 API는 반드시 Web Worker에서 사용될 필요는 없지만 함께 사용된다면 더욱 그 진가를 발휘할 수 있다고 할 수 있다.

😎해결 과정

Web Worker 구현

canvas의 convertToBlob 메서드에는 확장자, 이미지 품질을 지정할 수 있다. 따라서 기본적으로 webp 확장자로 변환하고, 품질을 20% 낮추었다.

// src/workers/imageResizer.worker.ts
import { FIXED_RESIZED_IMAGE_WIDTH } from '../_constants/constants';

interface WorkerMessage {
    id: string;
    file: File;
    compressFormat?: 'WEBP' | 'JPEG' | 'PNG';
    quality?: number;
}

self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
    const { id, file, compressFormat = 'WEBP', quality = 0.8 } = e.data;

    try {
        // 1. 이미지를 비트맵으로 로드
        const bitmap = await createImageBitmap(file);
        const { width, height } = bitmap;

        // 2. 리사이징 목표 크기 계산 (이번 최적화 이전에 수행한 이미지 비율 유지하는 로직)
        const targetWidth = FIXED_RESIZED_IMAGE_WIDTH;
        <const targetHeight = Math.round((height / width) * FIXED_RESIZED_IMAGE_WIDTH);

        // 3. OffscreenCanvas 생성
        const canvas = new OffscreenCanvas(targetWidth, targetHeight);
        const ctx = canvas.getContext('2d');

        if (!ctx) {
            throw new Error('OffscreenCanvas context creation failed');
        }

        // 4. 리사이징된 이미지 그리기
        ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);

        // 5. Blob으로 변환 (압축)
        const blob = await canvas.convertToBlob({
            type: compressFormat === 'WEBP' ? 'image/webp' : 'image/jpeg',
            quality,
        });

        // 6. File 객체 재생성
        // File 생성자는 최신 브라우저 및 Web Worker 환경에서 지원됨
        const resizedFile = new File([blob], file.name, {
            type: compressFormat === 'WEBP' ? 'image/webp' : 'image/jpeg',
            lastModified: Date.now(),
        });

        bitmap.close();

        self.postMessage({ id, success: true, file: resizedFile });
    } catch (error) {
        console.error('Worker image resize error:', error);
        self.postMessage({ id, success: false, error });
    }
};

커스텀 훅으로 분리

// src/hooks/useImageResizer.ts
import { useEffect, useRef, useCallback } from 'react';
import { nanoid } from 'nanoid';

interface WorkerResponse {
    id: string;
    success: boolean;
    file?: File;
    error?: Error;
}

interface ResizeOptions {
    file: File;
    compressFormat?: 'WEBP' | 'JPEG' | 'PNG';
    quality?: number;
}

export default function useImageResizer() {
    const workerRef = useRef<Worker | null>(null);
    const pendingPromises = useRef<Map<string, { resolve: (file: File) => void; reject: (err: Error) => void }>>(
        new Map(),
    );

    useEffect(() => {
        // Web Worker 인스턴스 생성
        // cf) Vite는 import.meta.url을 사용하여 워커 파일을 모듈로 로드하는 것을 지원함. 워커의 경로는 상대경로로 작성해야 함.
        try {
            workerRef.current = new Worker(new URL('../_workers/imageResizer.worker.ts', import.meta.url), {
                type: 'module',
            });

            // 메시지 수신 핸들러
            workerRef.current.onmessage = (event: MessageEvent<WorkerResponse>) => {
                const { id, success, file, error } = event.data;
                const resolver = pendingPromises.current.get(id);

                if (resolver) {
                    if (success && file) {
                        resolver.resolve(file);
                    } else {
                        resolver.reject(error || new Error('Image resizing failed in worker'));
                    }
                    pendingPromises.current.delete(id);
                }
            };

            workerRef.current.onerror = error => {
                console.error('Worker error:', error);
            };
        } catch (error) {
            console.error('Failed to initialize image resizer worker:', error);
        }

        return () => {
            // 컴포넌트 언마운트 시 워커 종료
            workerRef.current?.terminate();
            workerRef.current = null;
            pendingPromises.current.clear();
        };
    }, []);

    const resizeImage = useCallback(
        ({ file, compressFormat = 'WEBP', quality = 0.8 }: ResizeOptions): Promise<File> => {
            return new Promise((resolve, reject) => {
                if (!workerRef.current) {
                    // 워커 초기화 실패 시 또는 지원하지 않는 환경 처리
                    reject(new Error('Image resizer worker is not ready'));
                    return;
                }

                const id = nanoid();
                pendingPromises.current.set(id, { resolve, reject });

                workerRef.current.postMessage({
                    id,
                    file,
                    compressFormat,
                    quality,
                });
            });
        },
        [],
    );

    return { resizeImage };
}

😎결과

퍼포먼스 탭에서 리사이징을 메인 스레드 대신 워커가 수행했음을 확인할 수 있다.

콘솔을 통해 리사이징에 소요된 시간을 측정했다. 물론 현재 상황에서는 소요 시간보다 TBT(Total Blocking Time)과 같은 지표가 더 의미있을지 모른다. 하지만 페이지 로드 과정이 아닌 특정 상황에서의 TBT를 측정하는 방법은 알지 못하여 우선 동일 조건 하에 리사이징 소요 시간을 측정해보았다.

  • 개선 전
  • 개선 후

무엇보다도 UI 상호작용이 끊김없이 부드럽게 개선된 점이 돋보였다.

결론적으로, 이번 최적화를 통해 얻은 개선점은 다음과 같다.

  • 대용량 이미지에 대한 업로드 속도 대폭 단축
  • UI 렌더링 끊김 최소화

배운 점

이번 리팩토링을 통해 단일 스레드로 동작하는 JavaScript 환경에서 OffscreenCanvas와 Web Worker의 조합이 얼마나 강력한지 체감할 수 있었다. 단순히 라이브러리를 사용하는 것을 넘어, 브라우저의 동작 원리를 이해하고 적절한 기술을 도입함으로써 사용자가 쾌적하게 느낄 수 있는 서비스를 만들 수 있었다.

참고자료
올리브영 테크블로그

profile
무엇이든 최선을 다하고자 노력합니다:)

0개의 댓글