웹 사이트를 만들 때 보통 디자인 도구(Figma 등)에서 만든 이미지들을 그냥 PNG나 JPEG로 뽑아서 아무 생각 없이 프로젝트에 넣곤 했습니다.

그러다 최근 진행했던 웹 서비스에서 큰 문제를 하나 만났습니다. 메인 화면에 들어가는 고화질 배너 이미지 몇 개를 PNG 파일 그대로 넣었더니, 이미지 파일 몇 개 용량만 합쳐도 5MB가 훌쩍 넘어가는 참사가 일어난 것입니다.

모바일로 접속했을 때 이미지가 위에서부터 천천히 스캔되듯이 지저분하게 내려오면서 로딩되는 모습을 보고 "아, 이미지 포맷부터 당장 바꿔야겠다"는 결심을 하게 되었습니다.

오늘은 구시대 포맷인 PNG/JPEG를 버리고 차세대 포맷인 WebP와 AVIF로 전환하면서 겪었던 과정과, 실무에서 브라우저 깨짐 없이 안전하게 적용했던 노하우를 공유해 봅니다.

1. 내가 부딪힌 문제: "화질은 포기하기 싫고, 용량은 너무 크다"

프로젝트 배너에 글씨와 그래픽이 섞여 있다 보니, 화질이 깨지는 JPEG 대신 깨끗하게 나오는 PNG 포맷을 사용했습니다. 하지만 PNG는 무손실 압축 방식이라 파일 용량이 어마무시하게 컸습니다.

  • 메인 배너 PNG 파일 1개 용량: 무려 2.4 MB
  • 문제점: 접속할 때마다 유저의 데이터와 시간을 엄청나게 갉아먹고 있었고, 네트워크가 조금만 느려도 화면이 텅 비어 보이는 현상 발생.

화질을 떨어뜨리지 않으면서 용량만 획기적으로 줄일 수 있는 방법을 찾다가 구글이 만든 WebP와 차세대 비디오 코덱 기반의 AVIF 포맷을 알게 되었습니다.

2. 해결책 1: HTML <picture> 태그로 구형 브라우저 호환성 지키기

WebP나 AVIF가 아무리 용량이 작고 좋아도, "혹시 최신 포맷을 지원하지 않는 구형 브라우저에서는 이미지가 엑박(X)으로 깨지면 어쩌지?" 하는 걱정이 들었습니다.

이 문제를 구원해 준 것이 바로 HTML5의 <picture> 태그였습니다. 브라우저에게 "네가 AVIF를 지원하면 AVIF를 보여주고, 안 되면 WebP를, 그것도 안 되면 기존 JPEG를 보여줘" 하고 우선순위를 정해주는 방식입니다.

HTML
 
<!-- ⭕ 내 프로젝트에 직접 적용한 크로스 브라우징 picture 태그 -->
<picture>
  <!-- 1. 가장 가볍고 최신인 AVIF 포맷을 최우선으로 시도 -->
  <source srcset="/images/main-banner.avif" type="image/avif" />
  
  <!-- 2. AVIF 미지원 시 WebP 포맷 사용 -->
  <source srcset="/images/main-banner.webp" type="image/webp" />
  
  <!-- 3. 최신 포맷을 둘 다 지원하지 않는 구형 브라우저용 기본 이미지 -->
  <img 
    src="/images/main-banner.jpg" 
    alt="메인 프로모션 배너" 
    width="1200" 
    height="600"
    loading="lazy"
  />
</picture>

이렇게 코드를 짜두니 에러 걱정 없이 마음 편하게 최신 이미지 포맷을 도입할 수 있었습니다.

<picture>, <source>, <img> 구조 예시

3. 해결책 2: Node.js(Sharp)로 자동 변환 스크립트 만들어 버리기

이미지가 한두 개가 아니다 보니 일일이 변환 사이트에 업로드해서 다운로드하는 작업이 너무 귀찮았습니다. 그래서 Node.js의 이미지 처리 라이브러리인 sharp를 활용해, 명령어 하나만 치면 프로젝트 내 모든 PNG 파일이 WebP와 AVIF로 자동 변환되는 스크립트를 직접 짜서 해결했습니다.

JavaScript
 
// convert.js (내가 직접 만든 이미지 자동 변환 스크립트)
const sharp = require('sharp');
const path = require('path');

const inputPath = path.join(__dirname, 'public/images/main-banner.png');

// PNG를 WebP로 자동 변환 (품질 80% 설정)
sharp(inputPath)
  .webp({ quality: 80 })
  .toFile(path.join(__dirname, 'public/images/main-banner.webp'))
  .then(() => console.log('✅ WebP 변환 성공!'));

// PNG를 AVIF로 자동 변환 (품질 75% 설정)
sharp(inputPath)
  .avif({ quality: 75 })
  .toFile(path.join(__dirname, 'public/images/main-banner.avif'))
  .then(() => console.log('🚀 AVIF 변환 성공!'));

터미널에서 node convert.js만 실행하면 몇 초 만에 고용량 PNG가 초경량 파일들로 쓱 변환되어 나와서 개발 생산성이 엄청나게 올라갔습니다.

4. 직접 눈으로 확인한 용량 비교: "2.4MB가 210KB가 되는 기적"

변환을 마치고 폴더 안에 있는 동일한 이미지의 포맷별 파일 용량을 직접 확인해 보았습니다.

  • 기존 원본 PNG: 2,400 KB (2.4MB)
  • WebP 변환 후: 420 KB (약 82% 용량 감소!)
  • AVIF 변환 후: 210 KB (약 91% 용량 감소!)

화질은 눈으로 봤을 때 원본 PNG와 차이를 구분할 수 없을 정도로 선명한데, 파일 용량은 무려 10분의 1 수준으로 줄어들었습니다. 그 결과 메인 페이지에 진입할 때 이미지가 버벅거리지 않고 즉시 뜨는 쾌적한 환경을 만들 수 있었습니다.

Node.js 환경에서 sharp 라이브러리로 PNG 이미지를 WebP와 AVIF로 자동 변환해 주는 스크립트 코드

 

5. 깨달은 점 및 요약

이전에는 프론트엔드 성능 최적화라고 하면 무조건 복잡한 코드 리팩토링이나 상태 관리 최적화만 생각했었습니다.

하지만 이번 경험을 통해 이미지 포맷을 WebP나 AVIF로 바꾸는 것 같은 단순한 시도가 웹사이트 성능에 얼마나 파격적인 영향을 주는지 깨달았습니다.

혹시 지금 만들고 계신 프로젝트의 로딩 속도가 답답하다면, 소스 코드보다 먼저 '내가 넣은 이미지 파일의 포맷과 용량'부터 점검해 보시면 좋을 것 같습니다!

프런트엔드 개발자라면 누구나 한 번쯤 겪어봤을 상황이 있습니다. "복잡한 JSON 데이터를 필터링하거나, 큰 이미지 파일을 처리할 때 화면이 잠시 멈춘다"는 사용자 불만 말이죠. 리액트나 뷰(Vue)를 아무리 잘 다뤄도, 메인 스레드(Main Thread)가 막히면 사용자에게는 '고장 난 사이트'로 보일 뿐입니다.

자바스크립트의 실행 환경인 브라우저는 기본적으로 싱글 스레드(Single-threaded)입니다. 즉, 한 번에 하나의 작업만 처리할 수 있습니다. 메인 스레드가 무거운 연산에 잡혀 있으면 화면을 그리는 일(렌더링)을 하지 못하게 되고, 이것이 바로 '화면 프리징'의 원인입니다.

오늘 소개할 Web Worker(웹 워커)는 브라우저의 이 한계를 돌파하는 강력한 도구입니다. 메인 스레드와 별개의 '백그라운드 스레드'에서 작업을 처리하여, 복잡한 연산 중에도 화면은 매끄럽게 움직이게 만들 수 있습니다.


1. 메인 스레드와 워커 스레드의 이해

웹 워커의 핵심은 '분리'입니다. 브라우저는 하나의 메인 스레드와 여러 개의 워커 스레드를 가질 수 있습니다. 메인 스레드는 UI를 그리고, 사용자의 입력을 받고, 네트워크 통신을 관리합니다. 반면, 워커 스레드는 순수하게 '계산'만 담당합니다.

1.1 왜 분리해야 하는가?

메인 스레드에서 for문을 1억 번 돌린다면? 브라우저는 그 1억 번의 루프가 끝날 때까지 아무것도 하지 못합니다. 버튼을 눌러도 반응이 없고, 애니메이션도 멈춥니다. 하지만 이 작업을 워커 스레드로 넘기면, 워커가 연산하는 동안 메인 스레드는 유유히 사용자의 마우스 클릭을 처리하고 애니메이션을 그릴 수 있습니다.


2. Web Worker 구현하기: 3단계 프로세스

웹 워커는 메인 스레드와 직접적인 메모리 공유를 하지 않습니다. 대신 '메시지(Message)'를 주고받는 방식으로 데이터를 전달합니다.

2.1 워커 파일 생성 (worker.js)

먼저, 별도의 파일에 무거운 작업 내용을 작성합니다.

JavaScript
 
// worker.js
self.onmessage = function(e) {
  const data = e.data;
  
  // 여기서 아주 무거운 연산 수행
  const result = heavyCalculation(data);
  
  // 결과를 메인 스레드로 반환
  self.postMessage(result);
};

function heavyCalculation(data) {
  // 예: 수만 개의 데이터를 처리하는 복잡한 알고리즘
  return data.map(item => item * 2);
}

2.2 메인 스레드에서 워커 호출하기

이제 메인 스레드에서 이 워커를 실행하고 메시지를 보냅니다.

JavaScript
 
// main.js
const worker = new Worker('worker.js');

// 연산 시작
worker.postMessage([1, 2, 3, 4, 5]);

// 결과 받기
worker.onmessage = function(e) {
  console.log('연산 결과:', e.data);
};

이 간단한 코드로 복잡한 로직을 메인 스레드 밖으로 안전하게 격리할 수 있습니다.


3. 웹 워커가 가지는 제약 사항과 주의점

웹 워커는 매우 강력하지만, 모든 곳에 사용할 수는 없습니다. 반드시 알아야 할 한계점이 존재합니다.

  1. DOM 접근 불가: 웹 워커는 DOM을 전혀 조작할 수 없습니다. document.querySelector나 window에 접근하려고 하면 에러가 발생합니다. 오직 연산(CPU 집약적 작업)만 가능합니다.
  2. 메모리 격리: 메인 스레드와 메모리를 공유하지 않습니다. 데이터를 전달할 때마다 데이터를 '복사'해서 보냅니다. (아주 큰 데이터를 주고받을 경우 복사 비용이 발생할 수 있음)
  3. 파일 경로 제약: 워커 파일은 반드시 메인 스레드와 동일한 오리진(Origin)에 있어야 합니다. (CORS 정책 적용)

4. 실전 활용 사례: 언제 도입해야 할까?

모든 함수를 웹 워커로 옮기는 것은 낭비입니다. 다음 상황일 때 도입을 강력히 권장합니다.

  • 이미지/비디오 처리: Canvas API를 활용한 필터 적용이나 압축 작업.
  • 복잡한 데이터 정제: 수만 개의 JSON 데이터를 필터링하거나 변환해야 하는 대시보드.
  • 암호화/복호화: JWT 토큰 암호화나 파일 암호화 작업 등 CPU를 많이 쓰는 작업.
  • 실시간 텍스트 분석: 사용자가 입력하는 텍스트의 맞춤법 검사나 복잡한 정규식 검색.

5. 생산성을 높이는 도구: Comlink

순수하게 웹 워커를 다루면 postMessage와 onmessage를 매번 작성해야 해서 코드가 다소 지저분해집니다. 이때 구글에서 만든 Comlink 라이브러리를 사용하면 마치 일반 함수를 호출하듯 웹 워커를 사용할 수 있습니다.

JavaScript
 
// worker.js
import * as Comlink from 'comlink';

const obj = {
  doWork(a, b) { return a + b; }
};

Comlink.expose(obj);

// main.js
import * as Comlink from 'comlink';

async function init() {
  const worker = new Worker('worker.js');
  const api = Comlink.wrap(worker);
  
  // 그냥 함수처럼 호출하면 끝!
  const result = await api.doWork(10, 20);
  console.log(result);
}

이렇게 하면 복잡한 메시지 핸들링 로직을 추상화하여 코드의 가독성을 크게 높일 수 있습니다.

+ Recent posts