Please enable JavaScript to view the comments powered by Disqus.

Web Worker를 사용한 이미지 로딩

웹 워커(Web worker)

자바스크립트는 싱글 스레드로 동작하며 작성된 코드가 순서대로 실행된다. 하지만 자바스크립트로 작성된 웹 어플리케이션도 API 호출 같은 비동기 작업이 완료될 때까지 멈춰있지 않고 빠르게 실행되는 것이 가능하다. 그 이유는 자바스크립트를 실행하는 브라우저(또는 node.js) 런타임에서 이벤트 루프, 태스크 큐 등을 통해 비동기 작업을 실행하고 관리하기 때문이다.

하지만 그런 비동기 작업이 너무 많아진다면 사용자의 입출력을 처리하는 메인 스레드의 실행 속도에 문제가 생길 가능성이 높아진다. 예를 들어 메인 스레드에서 fetch API로 수백개의 이미지를 가져오는 비동기 코드를 실행하는 동시에 사용자는 화면에서 텍스트를 입력하거나 마우스 조작 등을 통해 이벤트를 발생시킨다고 가정해 보자. 그러면 얼마 후 fetch 가 완료되었을 때 실행할 수백 개의 콜백 함수가 비슷한 시점에 브라우저의 태스크 큐에 쌓이게 될 것이다. 그리고 동시에 UI 상호작용에 의한 컴포펀트 상태 업데이트 콜백 함수도 태스크 큐에 들어올 수 있다. 그렇게 많은 작업을 한꺼번에 처리하다 보면 UI 업데이트에는 지연이 생길 것이며, 사용자는 앱이 느리게 반응한다고 느낄 것이다.

그래서 브라우저에서는 자바스크립트로 작성된 어플리케이션의 멀티스레딩을 위해 웹 워커 API를 제공한다. 웹 워커에서 실행되는 스크립트는 메인 스레드에서 분리되어 독립된 스레드에서 실행되므로 앱이 실행되는 머신의 CPU, 메모리 리소스를 더 효율적으로 활용할 수 있다.

최신 버전의 메이저 웹 브라우저는 모두 웹 워커 API를 제공하며, IE는 10버전부터 제공한다.

웹 워커 사용법

메인 스레드와 웹 워커로 생성된 스레드 사이에서는 message 이벤트를 통해 데이터를 주고받는다. 아래는 웹 워커가 메인 스레드로부터 데이터로 문자열로 구성된 데이터를 전달받은 후 postMessage 메소드를 사용해 메인 스레드로 데이터를 보내는 간단한 예제 코드다.

// sample_worker.ts
addEventListener('message', async function (e: MessageEvent<string[]>) {
  const urls = e.data; 
  postMessage(urls.map(v => v.toUpperCase());
});

웹 워커의 소스에서 사용하는 addEventListener, postMessage 모두 전역 객체(window)의 메소드다.

구현된 웹 워커는 메인 스레드에서 객체로 선언되어 사용한다.

// main.ts
const worker = new Worker('./sample_worker.js') // 웹 워커의 소스가 타입스크립트라면 자바스크립트로의 transpile 과정이 반드시 필요하다. 
worker.postMessage(['str', 'ing', 'sample'])

worker.onmessage = e => {
	// 주고받는 데이터는 모두 이벤트 객체의 data 속성을 통해 전달된다.
	console.log(e.data) // ['STR', 'ING', 'SAMPLE'] 
}

메인 스레드에서 웹 워커 스레드로의 데이터 전송은 postMessage를 사용하며, 데이터를 받을 때는 워커 객체의 onmessage 속성에 이벤트 핸들러를 할당해서 사용한다. 웹 워커의 기본적인 사용법은 이렇듯 간단하다. (더 자세한 내용은 MDN 문서 참조)

이미지 로딩 워커

예제 앱으로 이미지를 로딩하는 웹 워커를 React와 함께 구현해 볼 것이다.

  • 웹 워커에서 fetch API로 이미지를 가져온 후 URL.createObjectURL API를 사용해 문자열 형태로 메인 스레드로 전달한다.
  • CPU 코어 수만큼 웹 워커 인스턴스를 만들어 작업을 분할한다.
  • 메인 스레드에서 모든 워커의 작업이 완료되었음을 확인한 후 이미지를 표시한다.

구현된 앱은 아래 링크에서 확인할 수 있다.

https://rhostem.github.io/image-load-worker

이미지 로드 웹 워커 구현

https://picsum.photos에서 제공하는 랜덤한 이미지를 fetch API를 사용해서 가져온 후 Response 객체를 ObjectURL로 변환해서 메인 스레드로 전달한다.

// ImageLoadWorker.ts
self.addEventListener('message', async function (e: MessageEvent<string[]>) {
  const urls = e.data; // 메인 스레드로부터 전달받은 이미지 URL 배열

  const images = await Promise.all(
    urls.map(async (url) => {
      try {
        const response = await fetch(url);
        const fileBlob = await response.blob();

        if (/image\/.+/.test(fileBlob.type)) {
          return URL.createObjectURL(fileBlob);
        }
      } catch (e) {
        return null;
      }
    }),
  );

  self.postMessage(images);
});

fetchResponse 객체는 JSON 형식으로 구성된 객체가 아니라 이진 데이터 스트림이므로 자바스크립트에서 사용할 수 있는 형태로 변환이 필요하다. Body 인터페이스를 구현한 Response 객체는 arrayBuffer, blob, json, formData 등의 메소드를 가지고 있는데, 여기서는 데이터를 Blob 객체로 변환하는 blob 메소드를 사용했다.

Blob은 파일류 객체를 표현하는 불변(immutable), 원시(raw) 데이터다. 자바스크립트 네이티브 형식에는 없는 데이터를 표현할 수 있다.

이미지 데이터를 담고 있는 Blob 객체는 DOM에 직접 연결할 수 없으므로, ObjectURL로 변환하는 과정을 한번 더 거친다.

URL.createObjectURL(fileBlob)

이미지 데이터를 ObjectURL로 변환하면 HTML img 태그의 src 속성에 할당하거나, style 속성의 background-image에 할당해서 이미지를 표시할 수 있다.

ObjectURL은 웹 페이지가 unload될 때 자동으로 할당 해제된다. 하지만 React처럼 페이지가 동적으로 업데이트 되는 앱에서는 URL.revokeObjectURL 메소드로 직접 할당 해제해줘야 메모리 누수를 막을 수 있다.

메인 스레드에서 웹 워커 인스턴스 생성 및 작업 처리

웹 워커가 백그라운드에서 동시에 작업을 진행한다고 해서 100개의 이미지를 불러오는데 워커 인스턴스도 100개를 만들어서 사용하면 오히려 더 느릴 수 있다. 앱이 실행되는 환경의 CPU 코어 수만큼 만들어서 사용하는 것이 가장 좋다. 그리고 그 값은 navigator.hardwareConcurrency를 통해 가져올 수 있다. hardwareConcurrency 값은 사용자의 컴퓨터에서 스레드를 실행하는 데 사용할 수 있는 논리 프로세서의 수를 리턴한다.

const maxWorkers = navigator.hardwareConcurrency || 2; // Safari 브라우저에서는 지원하지 않는다

프로세서 수만큼 웹 워커 인스턴스를 만들어 둔다.

const [workers, setWorkers] = useState<Worker[]>([]);

useEffect(() => {
  if (workers.length === 0) {
    setWorkers(
      new Array(maxWorkers)
        .fill(undefined)
        .map(
          () => new Worker(new URL('./ImageLoadWorker.js', import.meta.url)),
        ),
    );
  }

  return () => {
    workers.forEach((worker) => worker.terminate());
  };
}, [maxWorkers, workers]);

그리고 이미지 주소를 웹 워커에 보내고 결과를 돌려받는 함수를 구현한다.

// 1개의 웹 워커에서 가져올 이미지의 수 = (전체 이미지 개수) / (웹 워커 개수)
const chunkSizeForWorker = useMemo(
    () => Math.ceil(images.length / maxWorkers),
    [images.length, maxWorkers],
  );

const loadAllImagesAtOnce = useCallback(
  async (imageUrls) => { // fetch로 가져올 이미지 주소 배열을 파라미터로 전달받음

    if (window.Worker) {
			// ObjectURL을 저장하기 위한 배열을 미리 생성해 둔다.
      setImageBlobs(new Array(imageUrls.length).fill(undefined)); // 웹 워커에서 fetch가 실패했을 때 null을 리턴하게 했으므로, undefined 값은 이미지 로딩 중이라는 의미도 가진다.

      const imageChunks = [];

			// 웹 워커들에 할당하기 위해 이미지 배열을 분리해서 저장해 둔다.
      for (let i = 0; i < maxWorkers; i++) {
        const startIndex = i * chunkSizeForWorker;
        imageChunks.push(
          imageUrls.slice(startIndex, startIndex + chunkSizeForWorker),
        );
      }

			// 이미지 로딩 작업이 끝날 때까지 기다릴 Promise 객체 배열을 구성한다.
      const imagePromises = imageChunks.map(
        (chunk, chunkIndex) =>
          new Promise<string[]>((resolve) => {
            const chunkWorker = workers[chunkIndex];

            if (chunkWorker) {
							// 워커에 이미지 배열을 전달한다.
              chunkWorker.postMessage(chunk);

              chunkWorker.onmessage = (e) => {
								// 워커에서 ObjectURL이 전송되면 Promise를 해결한다.
                resolve(e.data);
              };
            }
          }),
      );

			// 모든 워커의 작업이 끝날 때까지 기다린다.
      const imageBlobsChunks = await Promise.all(imagePromises);

			// 2차원 배열을 1차원 배열로 변환하는 작업을 거친다
      const allImageBlobs = imageBlobsChunks.reduce(
        (result, chunk) => [...result, ...chunk],
        [],
      );

			// ObjectURL 배열로 상태를 업데이트한다.
      setImageBlobs(allImageBlobs);
    }
  },
  [chunkSizeForWorker, maxWorkers, workers],
);

여러 개의 워커를 생성하면 개발자 도구에서 메모리가 할당되고 네트워크 입출력이 발생하는 것을 확인할 수 있다.

웹 워커 인스턴스들의 메모리, 네트워크 사용량

구현이 완료된 소스는 https://github.com/rhostem/image-load-worker 에서 확인 가능하다.

결과 비교

아래 영상은 웹 워커를 통해 이미지를 가져와서 한꺼번에 렌더링하는 것과 이미지 URL을 DOM style 속성에 직접 할당해서 불러오는 것의 비교 화면이다.

imageload comparison between image worker and direct url

웹 워커를 사용한 목록은 모든 이미지를 가져온 후 한꺼번에 업데이트되고, 직접 가져오게 한 목록에서는 이미지 로딩이 완료되는대로 표시됨을 확인할 수 있다.

웹 워커를 사용했을 때의 장점을 정리하면 다음과 같다.

  • 화면 업데이트를 최소화하여 성능을 향상시킨다.
  • 이미지 로딩 작업이 다른 DOM의 업데이트와 UI 상호작용에 미치는 영향을 최소화한다.
  • Image 객체를 생성하지 않고도 이미지 로딩 상태, 성공, 실패 여부를 UI에 동적으로 업데이트할 수 있다.

웹 워커를 사용하지 않아도 웹 개발과 성능에 큰 문제가 없는 경우가 대부분이다. 하지만 메인 스레드에서 많은 메모리를 사용하고 있고 성능에 영향을 미치는 것 같다면 백그라운드로 진행해도 되는 작업을 웹 워커에서 처리하는 방법을 고려해볼 수 있다.