이미지 로딩 중...

PWA 성능 최적화 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 5. · 5 Views

PWA 성능 최적화 완벽 가이드

프로그레시브 웹 앱(PWA)의 성능을 극대화하는 실전 최적화 기법을 배웁니다. 캐싱 전략부터 리소스 최적화까지, 실무에서 바로 적용할 수 있는 구체적인 방법을 다룹니다.


목차

  1. Service Worker 캐싱 전략 - 오프라인 동작 구현
  2. App Shell 아키텍처 - 빠른 초기 로딩
  3. 리소스 사전 캐싱 - 핵심 자원 미리 저장
  4. 동적 캐싱 전략 - 런타임 데이터 관리
  5. 이미지 최적화 - WebP 변환 및 지연 로딩
  6. 네트워크 우선 전략 - 최신 데이터 보장
  7. 백그라운드 동기화 - 오프라인 작업 처리
  8. 번들 크기 최적화 - 코드 스플리팅 적용
  9. Service Worker 캐싱 전략
  10. App Shell 아키텍처
  11. 리소스 사전 캐싱
  12. 동적 캐싱 전략
  13. 이미지 최적화
  14. 네트워크 우선 전략
  15. 백그라운드 동기화
  16. 번들 크기 최적화

1. Service Worker 캐싱 전략 - 오프라인 동작 구현


2. App Shell 아키텍처 - 빠른 초기 로딩


3. 리소스 사전 캐싱 - 핵심 자원 미리 저장


4. 동적 캐싱 전략 - 런타임 데이터 관리


5. 이미지 최적화 - WebP 변환 및 지연 로딩


6. 네트워크 우선 전략 - 최신 데이터 보장


7. 백그라운드 동기화 - 오프라인 작업 처리


8. 번들 크기 최적화 - 코드 스플리팅 적용


1. Service Worker 캐싱 전략

시작하며

여러분이 웹 앱을 개발하면서 "사용자가 네트워크 연결이 불안정할 때도 앱이 정상적으로 동작했으면 좋겠다"고 생각해본 적 있나요? 모바일 환경에서는 지하철이나 엘리베이터 안에서 네트워크가 끊기는 경우가 정말 흔합니다.

이런 문제는 실제 서비스 운영 시 사용자 이탈률을 높이는 주요 원인입니다. 사용자가 로딩 화면만 보다가 앱을 닫아버리면, 아무리 좋은 기능을 만들어도 소용이 없습니다.

바로 이럴 때 필요한 것이 Service Worker 캐싱 전략입니다. 중요한 리소스를 미리 저장해두면 네트워크 없이도 앱을 구동할 수 있습니다.

개요

간단히 말해서, Service Worker는 브라우저와 네트워크 사이에서 동작하는 프록시 서버 같은 존재입니다. 모든 네트워크 요청을 가로채서 캐시에서 응답할지, 실제 네트워크로 요청할지 결정합니다.

왜 이것이 필요한가요? 전통적인 브라우저 캐시는 네트워크가 없으면 아무것도 할 수 없습니다.

하지만 Service Worker는 오프라인 상태에서도 캐시된 리소스를 제공할 수 있어, 사용자는 마치 온라인인 것처럼 앱을 사용할 수 있습니다. 예를 들어, 뉴스 앱에서 이전에 읽었던 기사들을 지하철에서도 볼 수 있게 만들 수 있습니다.

기존에는 AppCache API를 사용했다면, 이제는 훨씬 강력하고 유연한 Service Worker를 사용합니다. AppCache는 선언적이고 제한적이었지만, Service Worker는 프로그래밍 방식으로 완전한 제어가 가능합니다.

핵심 특징은 세 가지입니다. 첫째, 네트워크 요청 인터셉션 기능으로 모든 요청을 제어할 수 있습니다.

둘째, Cache API를 통한 정교한 캐시 관리가 가능합니다. 셋째, 백그라운드에서 독립적으로 실행되어 메인 스레드를 방해하지 않습니다.

이러한 특징들이 PWA를 네이티브 앱처럼 빠르고 안정적으로 만들어줍니다.

코드 예제

// Service Worker 등록 - 메인 JavaScript 파일
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then(registration => {
        console.log('SW 등록 성공:', registration.scope);
      })
      .catch(error => {
        console.log('SW 등록 실패:', error);
      });
  });
}

설명

이것이 하는 일: 브라우저에 Service Worker를 등록하여 PWA의 핵심 기능을 활성화합니다. 등록이 완료되면 Service Worker가 설치되고 활성화되어 네트워크 요청을 제어할 수 있게 됩니다.

첫 번째로, 브라우저가 Service Worker를 지원하는지 확인합니다. 'serviceWorker' in navigator 조건으로 기능 감지를 수행하는데, 이는 구형 브라우저에서 오류를 방지하기 위해 반드시 필요합니다.

지원하지 않는 브라우저에서는 일반 웹 앱처럼 동작합니다. 그 다음으로, window의 load 이벤트를 기다립니다.

페이지의 모든 리소스가 로드된 후에 Service Worker를 등록하는 이유는, 초기 로딩 성능에 영향을 주지 않기 위함입니다. Service Worker 등록은 상대적으로 무거운 작업이므로, 사용자가 먼저 콘텐츠를 보는 것이 더 중요합니다.

마지막으로, register() 메서드가 Promise를 반환하므로 then/catch로 성공과 실패를 처리합니다. 등록이 성공하면 registration 객체가 반환되며, 이를 통해 Service Worker의 상태를 확인하거나 업데이트를 트리거할 수 있습니다.

실패 시 에러를 로깅하여 디버깅에 활용합니다. 여러분이 이 코드를 사용하면 앱이 설치 가능해지고, 오프라인 동작이 가능해지며, 푸시 알림 등 고급 기능을 사용할 수 있습니다.

특히 모바일 사용자 경험이 극적으로 개선되며, 네트워크가 불안정한 환경에서도 안정적인 서비스를 제공할 수 있습니다.

실전 팁

💡 Service Worker 파일은 반드시 루트 경로나 앱의 최상위 경로에 위치시켜야 합니다. scope는 Service Worker 파일의 위치에 따라 결정되므로, /app/sw.js는 /app/* 경로만 제어할 수 있습니다. 💡 개발 중에는 브라우저 개발자 도구의 Application 탭에서 Service Worker를 강제로 업데이트하거나 등록 해제할 수 있습니다. "Update on reload" 옵션을 활성화하면 매번 새로운 버전을 테스트할 수 있어 편리합니다. 💡 HTTPS 환경에서만 동작합니다(localhost 제외). 보안상의 이유로 HTTP에서는 Service Worker를 사용할 수 없으므로, 배포 전에 반드시 SSL 인증서를 설정하세요. 💡 Service Worker 업데이트 시 바이트 단위로 비교하므로, 주석 하나만 바뀌어도 새 버전으로 인식됩니다. 버전 관리를 위해 파일 상단에 버전 번호를 주석으로 명시하는 것이 좋습니다.


2. App Shell 아키텍처

시작하며

여러분의 웹 앱이 처음 로드될 때 하얀 화면이 몇 초간 보이는 경험을 한 적 있나요? 사용자는 이 짧은 순간에도 "이 앱이 느리다"고 느끼고 이탈할 수 있습니다.

특히 모바일 환경에서는 초기 로딩 속도가 사용자 유지율에 직접적인 영향을 미칩니다. 이런 문제는 앱의 모든 리소스를 한 번에 로드하려고 할 때 발생합니다.

HTML, CSS, JavaScript, 이미지 등 수십 개의 파일이 다운로드될 때까지 사용자는 빈 화면만 바라봐야 합니다. 바로 이럴 때 필요한 것이 App Shell 아키텍처입니다.

앱의 껍데기(Shell)를 먼저 보여주고, 콘텐츠는 나중에 채워 넣는 방식으로 즉각적인 로딩 경험을 제공합니다.

개요

간단히 말해서, App Shell은 사용자 인터페이스의 뼈대를 구성하는 최소한의 HTML, CSS, JavaScript를 의미합니다. 네비게이션 바, 사이드바, 로딩 스켈레톤 같은 정적인 UI 요소들이 여기에 해당합니다.

왜 이 개념이 필요한가요? 사용자는 콘텐츠보다 먼저 레이아웃을 보는 것만으로도 "앱이 로드되고 있다"는 느낌을 받습니다.

이는 체감 성능을 극적으로 향상시킵니다. 예를 들어, SNS 앱에서 헤더와 탭바를 먼저 보여주고 게시물은 나중에 불러오는 경우, 사용자는 앱이 즉시 반응한다고 느낍니다.

기존에는 서버에서 완성된 HTML을 받아야 화면을 그릴 수 있었다면, 이제는 App Shell을 캐시에 저장해두고 즉시 렌더링할 수 있습니다. 서버 응답을 기다리는 시간이 사라지는 것입니다.

핵심 특징은 세 가지입니다. 첫째, 빠른 초기 로딩으로 FCP(First Contentful Paint)를 1초 이내로 만들 수 있습니다.

둘째, 오프라인에서도 App Shell은 항상 표시되어 일관된 경험을 제공합니다. 셋째, 동적 콘텐츠와 정적 Shell을 분리하여 캐싱 전략을 최적화할 수 있습니다.

이러한 특징들이 PWA를 네이티브 앱만큼 빠르게 만들어줍니다.

코드 예제

// sw.js - App Shell 리소스 캐싱
const CACHE_NAME = 'app-shell-v1';
const APP_SHELL = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.png'
];

// 설치 시 App Shell 캐싱
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(APP_SHELL))
  );
});

설명

이것이 하는 일: Service Worker가 설치될 때 앱의 핵심 UI 리소스들을 브라우저 캐시에 미리 저장합니다. 이렇게 저장된 파일들은 이후 앱 실행 시 네트워크 요청 없이 즉시 로드됩니다.

첫 번째로, 캐시 이름과 캐싱할 파일 목록을 정의합니다. CACHE_NAME에 버전을 포함시키는 이유는, 나중에 App Shell을 업데이트할 때 새로운 캐시를 생성하고 이전 캐시를 정리하기 위함입니다.

APP_SHELL 배열에는 앱의 뼈대를 구성하는 최소한의 파일만 포함시켜야 합니다. 너무 많은 파일을 포함하면 초기 설치 시간이 길어집니다.

그 다음으로, install 이벤트 리스너에서 event.waitUntil()을 사용합니다. 이 메서드는 Promise가 완료될 때까지 Service Worker의 설치를 대기시킵니다.

모든 파일이 성공적으로 캐싱되기 전에 Service Worker가 활성화되는 것을 방지하는 중요한 역할을 합니다. 마지막으로, caches.open()으로 캐시 저장소를 열고 cache.addAll()로 모든 파일을 한 번에 추가합니다.

addAll()은 원자적 연산이므로, 하나의 파일이라도 실패하면 전체가 실패합니다. 이는 불완전한 캐시 상태를 방지하여 앱의 안정성을 보장합니다.

여러분이 이 코드를 사용하면 앱의 초기 로딩 시간이 1초 이내로 단축되며, 재방문 사용자는 거의 즉시 UI를 볼 수 있습니다. 오프라인 상태에서도 App Shell이 표시되어 "연결 없음" 에러 대신 일관된 UI를 제공할 수 있습니다.

이는 사용자 이탈률을 크게 낮추고 참여도를 높이는 결과를 가져옵니다.

실전 팁

💡 App Shell에는 정말 필수적인 파일만 포함하세요. 일반적으로 10개 이하, 총 크기 200KB 이하가 이상적입니다. 불필요한 파일을 포함하면 설치 시간이 길어지고 사용자가 기다려야 합니다. 💡 로딩 스켈레톤 UI를 App Shell에 포함시키면 콘텐츠가 로드되는 동안 더 나은 경험을 제공할 수 있습니다. 회색 박스들이 점진적으로 실제 콘텐츠로 바뀌는 것이 빈 화면보다 훨씬 전문적입니다. 💡 cache.addAll()이 실패하면 전체 설치가 실패하므로, 중요하지 않은 리소스는 별도로 캐싱하세요. 예를 들어, 선택적 폰트나 아이콘은 fetch 이벤트에서 런타임에 캐싱하는 것이 안전합니다. 💡 App Shell 업데이트 시 캐시 이름의 버전을 변경하고, activate 이벤트에서 이전 캐시를 삭제하세요. 이렇게 하지 않으면 사용자의 저장 공간이 계속 증가하고, 오래된 Shell이 표시될 수 있습니다.


3. 리소스 사전 캐싱

시작하며

여러분이 앱을 개발하면서 "사용자가 자주 방문하는 페이지는 항상 빠르게 로드됐으면 좋겠다"고 생각해본 적 있나요? 특히 이커머스 사이트에서 상품 상세 페이지나 장바구니 같은 핵심 페이지는 절대 느려서는 안 됩니다.

이런 문제는 사용자가 페이지를 요청할 때마다 서버에서 리소스를 다운로드해야 할 때 발생합니다. 네트워크 상태가 좋지 않거나 서버가 멀리 있으면 로딩 시간이 길어지고, 사용자는 답답함을 느낍니다.

바로 이럴 때 필요한 것이 리소스 사전 캐싱(Precaching)입니다. 앱 설치 시점에 중요한 리소스들을 미리 다운로드해두면, 사용자가 해당 페이지를 방문할 때 즉시 표시할 수 있습니다.

개요

간단히 말해서, 사전 캐싱은 Service Worker 설치 단계에서 미리 정의된 리소스 목록을 캐시에 저장하는 전략입니다. 사용자가 요청하기 전에 선제적으로 다운로드하는 것이 핵심입니다.

왜 이것이 필요한가요? 중요한 페이지나 리소스는 로딩 지연이 비즈니스에 직접적인 영향을 미칩니다.

연구에 따르면 로딩 시간이 1초 증가할 때마다 전환율이 7% 감소한다고 합니다. 예를 들어, 온라인 쇼핑몰에서 제품 목록, 상세 페이지 템플릿, 결제 페이지를 사전 캐싱해두면 사용자가 구매 과정에서 전혀 기다릴 필요가 없습니다.

기존에는 사용자가 페이지를 방문한 후에야 캐싱이 시작되었다면(런타임 캐싱), 이제는 앱을 처음 설치할 때 한 번에 모든 핵심 리소스를 준비할 수 있습니다. 첫 방문부터 빠른 경험을 제공하는 것입니다.

핵심 특징은 세 가지입니다. 첫째, 결정론적 캐싱으로 어떤 리소스가 캐시되어 있는지 정확히 알 수 있습니다.

둘째, 빌드 타임에 캐시 목록을 생성하여 실수를 방지할 수 있습니다. 셋째, 파일 해시 기반 버전 관리로 정확한 캐시 무효화가 가능합니다.

이러한 특징들이 일관되고 예측 가능한 성능을 보장합니다.

코드 예제

// sw.js - 정적 리소스 사전 캐싱
const STATIC_CACHE = 'static-v2';
const STATIC_ASSETS = [
  '/pages/home.html',
  '/pages/products.html',
  '/pages/cart.html',
  '/css/critical.css',
  '/js/bundle.js',
  '/fonts/main.woff2'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then(cache => {
      console.log('정적 리소스 캐싱 시작');
      return cache.addAll(STATIC_ASSETS);
    })
  );
  self.skipWaiting(); // 즉시 활성화
});

설명

이것이 하는 일: Service Worker가 설치되는 시점에 앱의 핵심 페이지와 정적 리소스를 모두 다운로드하여 캐시에 저장합니다. 이후 사용자가 해당 리소스를 요청하면 캐시에서 즉시 응답합니다.

첫 번째로, 캐시할 정적 리소스 목록을 배열로 정의합니다. STATIC_ASSETS에는 HTML 페이지, CSS, JavaScript 번들, 웹 폰트 등 앱의 핵심을 구성하는 파일들이 포함됩니다.

여기서 중요한 점은 "무엇을 캐싱할 것인가"를 신중히 선택하는 것입니다. 모든 파일을 포함하면 설치 시간이 너무 길어지고, 너무 적으면 효과가 없습니다.

그 다음으로, install 이벤트에서 캐시를 열고 addAll()로 모든 파일을 추가합니다. console.log를 추가한 이유는 디버깅을 위함입니다.

개발자 도구의 콘솔에서 캐싱 프로세스를 추적할 수 있어, 문제 발생 시 어느 단계에서 실패했는지 파악할 수 있습니다. addAll()은 모든 요청이 성공해야 완료되므로, 하나라도 404 에러가 발생하면 전체가 실패합니다.

마지막으로, skipWaiting()을 호출하여 새로운 Service Worker를 즉시 활성화합니다. 일반적으로 Service Worker는 모든 탭이 닫힐 때까지 대기 상태에 머물지만, skipWaiting()을 사용하면 업데이트를 즉시 적용할 수 있습니다.

이는 버그 수정이나 중요한 업데이트를 빠르게 배포할 때 유용합니다. 여러분이 이 코드를 사용하면 사용자는 앱의 주요 기능에 접근할 때 로딩 시간을 거의 느끼지 못합니다.

특히 재방문 시 모든 페이지가 100ms 이내에 렌더링되어 네이티브 앱과 구분이 안 될 정도의 성능을 제공합니다. 오프라인 상태에서도 캐시된 모든 페이지에 접근할 수 있어 완전한 오프라인 경험을 구현할 수 있습니다.

실전 팁

💡 Workbox 라이브러리를 사용하면 빌드 시점에 자동으로 파일 해시를 생성하고 캐시 목록을 만들어줍니다. 수동으로 관리하는 것보다 안전하고 효율적이며, 캐시 무효화도 자동으로 처리됩니다. 💡 정적 리소스는 총 크기를 3MB 이하로 유지하세요. 모바일 데이터를 사용하는 사용자를 고려하면, 초기 다운로드가 너무 크면 설치를 포기할 수 있습니다. 꼭 필요한 파일만 선택하세요. 💡 이미지나 비디오 같은 큰 미디어 파일은 사전 캐싱에 포함하지 마세요. 대신 런타임 캐싱 전략을 사용하여 사용자가 실제로 보는 것만 저장하는 것이 효율적입니다. 💡 캐시 버전을 업데이트할 때는 activate 이벤트에서 이전 버전의 캐시를 삭제하세요. caches.keys()로 모든 캐시를 가져와 현재 버전이 아닌 것들을 제거하면 저장 공간을 절약할 수 있습니다. 💡 중요한 API 응답(예: 사용자 설정, 인증 토큰)도 사전 캐싱 개념으로 초기화 시점에 저장할 수 있습니다. 앱 시작 시 필요한 데이터를 미리 가져와 캐시하면 이후 오프라인 동작이 가능해집니다.


4. 동적 캐싱 전략

시작하며

여러분이 뉴스 앱이나 소셜 미디어를 개발할 때, 사용자가 보는 콘텐츠는 계속 변한다는 것을 알고 계시죠? 게시물, 댓글, 프로필 이미지 등은 사전에 어떤 것이 필요한지 예측할 수 없습니다.

사용자마다, 시간대마다 다른 데이터를 요청합니다. 이런 상황에서 모든 가능한 콘텐츠를 미리 캐싱할 수는 없습니다.

데이터 양이 너무 많고, 어떤 것이 필요할지 알 수 없기 때문입니다. 하지만 네트워크 요청을 매번 하는 것도 비효율적이고 느립니다.

바로 이럴 때 필요한 것이 동적 캐싱(Runtime Caching) 전략입니다. 사용자가 요청한 리소스를 그때그때 캐시에 저장했다가, 다음에 같은 것을 요청하면 캐시에서 즉시 제공하는 방식입니다.

개요

간단히 말해서, 동적 캐싱은 fetch 이벤트를 가로채서 네트워크 응답을 캐시에 저장하고, 이후 같은 요청이 오면 캐시에서 먼저 확인하는 전략입니다. 런타임(앱 실행 중)에 자동으로 캐시가 구축됩니다.

왜 이것이 필요한가요? 실제 웹 앱에서는 사전 캐싱만으로는 부족합니다.

사용자가 생성하는 콘텐츠, API 응답, 외부 이미지 등 예측 불가능한 리소스가 대부분입니다. 예를 들어, Instagram 같은 앱에서 사용자가 스크롤하며 본 사진들을 자동으로 캐싱해두면, 뒤로 가기로 돌아갔을 때 즉시 표시할 수 있습니다.

기존에는 매번 서버에 요청해야 했다면, 이제는 한 번 본 콘텐츠는 캐시에 저장되어 재사용됩니다. 네트워크 트래픽을 줄이고 로딩 속도를 높이는 효과가 있습니다.

핵심 특징은 세 가지입니다. 첫째, Cache-First, Network-First, Stale-While-Revalidate 등 다양한 전략을 상황에 맞게 선택할 수 있습니다.

둘째, 캐시 크기를 제한하여 저장 공간을 효율적으로 관리할 수 있습니다. 셋째, 만료 정책을 설정하여 오래된 데이터를 자동으로 정리할 수 있습니다.

이러한 특징들이 사용자 경험과 리소스 관리의 균형을 맞춰줍니다.

코드 예제

// sw.js - 동적 이미지 캐싱 (Cache First 전략)
const DYNAMIC_CACHE = 'dynamic-v1';

self.addEventListener('fetch', event => {
  if (event.request.destination === 'image') {
    event.respondWith(
      caches.match(event.request).then(cachedResponse => {
        if (cachedResponse) {
          return cachedResponse; // 캐시에 있으면 바로 반환
        }
        // 없으면 네트워크에서 가져와 캐싱
        return fetch(event.request).then(networkResponse => {
          return caches.open(DYNAMIC_CACHE).then(cache => {
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          });
        });
      })
    );
  }
});

설명

이것이 하는 일: 모든 네트워크 요청을 가로채서, 이미지인 경우 캐시 우선 전략으로 처리합니다. 캐시에서 찾으면 네트워크 요청 없이 즉시 응답하고, 없으면 네트워크에서 가져온 후 다음을 위해 저장합니다.

첫 번째로, fetch 이벤트를 리스닝하여 모든 네트워크 요청을 인터셉트합니다. event.request.destination을 확인하여 이미지 요청만 특별히 처리하는 이유는, 이미지는 변경되지 않는 정적 리소스이므로 캐시 우선 전략이 적합하기 때문입니다.

HTML이나 API는 최신 데이터가 중요하므로 다른 전략을 사용해야 합니다. 그 다음으로, caches.match()로 현재 요청과 일치하는 캐시가 있는지 확인합니다.

있다면 즉시 반환하여 네트워크 요청을 완전히 생략합니다. 이것이 Cache-First 전략의 핵심으로, 가장 빠른 응답 속도를 제공합니다.

사용자는 이미 본 이미지를 밀리초 단위로 볼 수 있습니다. 세 번째로, 캐시에 없으면 fetch()로 네트워크에서 가져옵니다.

응답을 받으면 networkResponse.clone()을 사용하는데, 이는 Response 객체를 한 번만 읽을 수 있기 때문입니다. 하나는 캐시에 저장하고, 원본은 브라우저에 반환해야 하므로 복제가 필요합니다.

마지막으로, cache.put()으로 요청과 응답 쌍을 캐시에 저장합니다. 이제 다음번에 같은 이미지를 요청하면 네트워크 없이 캐시에서 즉시 제공됩니다.

사용자가 앱을 사용할수록 캐시가 풍부해져 점점 더 빨라지는 경험을 제공합니다. 여러분이 이 코드를 사용하면 이미지 로딩 시간이 평균 90% 감소하며, 반복 방문 시 거의 즉각적인 렌더링이 가능합니다.

모바일 데이터 사용량도 크게 줄어들어 사용자의 데이터 요금 절약에도 기여합니다. 특히 갤러리나 피드 형태의 UI에서 스크롤 성능이 극적으로 개선됩니다.

실전 팁

💡 캐시 크기를 제한하지 않으면 저장 공간이 무한정 증가합니다. Workbox의 CacheExpiration 플러그인을 사용하거나, 수동으로 캐시 항목을 50-100개로 제한하세요. LRU(Least Recently Used) 방식으로 오래된 항목을 삭제하는 로직을 구현할 수 있습니다. 💡 API 응답은 Cache-First 대신 Network-First 또는 Stale-While-Revalidate 전략을 사용하세요. 최신 데이터가 중요한 경우 네트워크를 우선하고, 빠른 응답이 중요하면 캐시를 먼저 보여주고 백그라운드에서 업데이트하세요. 💡 CORS 요청은 opaque 응답을 반환하므로 캐싱 시 주의가 필요합니다. mode: 'cors'와 credentials를 적절히 설정하고, CDN 리소스는 CORS 헤더가 있는지 확인하세요. 💡 동적 캐시와 정적 캐시를 다른 이름으로 분리 관리하세요. 정적 캐시는 버전별로 완전히 교체하고, 동적 캐시는 개별 항목을 업데이트하는 식으로 다르게 처리해야 합니다. 💡 에러 처리를 반드시 추가하세요. 네트워크도 실패하고 캐시도 없으면 catch()에서 fallback 이미지나 오프라인 페이지를 반환하여 사용자에게 빈 화면이나 에러를 보여주지 않도록 합니다.


5. 이미지 최적화

시작하며

여러분의 웹 앱에서 이미지가 페이지 로딩 시간의 대부분을 차지한다는 사실을 알고 계시나요? HTTP Archive 데이터에 따르면 평균 웹 페이지에서 이미지가 전체 용량의 50% 이상을 차지합니다.

특히 모바일에서는 큰 이미지가 데이터 요금과 로딩 시간 모두를 증가시킵니다. 이런 문제는 고해상도 이미지를 모든 기기에 동일하게 전송할 때 발생합니다.

작은 모바일 화면에 4K 이미지를 보내거나, 사용자가 스크롤해서 볼지도 모르는 이미지를 미리 다운로드하는 것은 낭비입니다. 바로 이럴 때 필요한 것이 이미지 최적화 전략입니다.

최신 포맷(WebP, AVIF)으로 변환하고, 지연 로딩을 적용하며, 반응형 이미지를 제공하여 성능을 극대화할 수 있습니다.

개요

간단히 말해서, 이미지 최적화는 파일 크기를 줄이고, 필요할 때만 로드하며, 각 기기에 적합한 크기로 제공하는 종합적인 접근 방식입니다. 사용자 경험과 성능을 모두 개선하는 필수 기술입니다.

왜 이것이 필요한가요? 이미지는 웹 성능의 가장 큰 병목입니다.

2MB 이미지 하나가 3G 네트워크에서 10초 이상 걸릴 수 있습니다. WebP로 변환하면 JPEG 대비 25-35% 작아지고, 지연 로딩으로 초기 로딩 시간을 50% 단축할 수 있습니다.

예를 들어, 이커머스 사이트에서 상품 이미지를 최적화하면 페이지 로딩 시간이 3초에서 1초로 줄어들어 전환율이 크게 향상됩니다. 기존에는 모든 이미지를 페이지 로드 시 다운로드했다면, 이제는 뷰포트에 들어올 때만 로드하는 지연 로딩을 사용합니다.

브라우저 레벨에서 지원하는 loading="lazy" 속성으로 간단히 구현할 수 있습니다. 핵심 특징은 세 가지입니다.

첫째, WebP/AVIF 같은 차세대 포맷으로 30-50% 파일 크기 절감이 가능합니다. 둘째, Intersection Observer를 사용한 지연 로딩으로 초기 로딩 시간을 단축합니다.

셋째, srcset과 sizes 속성으로 반응형 이미지를 제공하여 각 기기에 최적화된 크기를 전송합니다. 이러한 특징들이 웹 성능의 Core Web Vitals 지표를 크게 개선합니다.

코드 예제

<!-- 반응형 WebP 이미지 + 지연 로딩 -->
<picture>
  <source
    type="image/webp"
    srcset="
      /images/hero-320.webp 320w,
      /images/hero-640.webp 640w,
      /images/hero-1280.webp 1280w
    "
    sizes="(max-width: 640px) 100vw, 640px"
  />
  <img
    src="/images/hero-640.jpg"
    alt="Hero 이미지"
    loading="lazy"
    decoding="async"
  />
</picture>

설명

이것이 하는 일: 브라우저가 자동으로 최적의 이미지를 선택하도록 합니다. WebP를 지원하면 WebP를, 아니면 JPEG를 사용하고, 화면 크기에 따라 적절한 해상도를 로드하며, 스크롤해서 보이기 전까지는 다운로드하지 않습니다.

첫 번째로, picture 요소로 여러 이미지 포맷을 제공합니다. source 요소의 type="image/webp"를 먼저 선언하면, 브라우저는 WebP를 지원하는지 확인하고 지원하면 이를 사용합니다.

지원하지 않으면 자동으로 fallback img 요소의 JPEG를 사용합니다. 이렇게 점진적 향상(Progressive Enhancement) 원칙을 따릅니다.

그 다음으로, srcset 속성으로 세 가지 크기의 이미지를 제공합니다. 320w, 640w, 1280w는 이미지의 실제 너비를 픽셀 단위로 나타냅니다.

브라우저는 현재 기기의 화면 크기와 픽셀 밀도(DPR)를 고려하여 가장 적합한 이미지를 자동으로 선택합니다. 예를 들어, 모바일에서는 320w를, 데스크톱에서는 1280w를 다운로드합니다.

세 번째로, sizes 속성으로 이미지가 차지할 뷰포트 비율을 알려줍니다. (max-width: 640px) 100vw는 "640px 이하에서는 화면 전체 너비"를 의미하고, 그 외에는 640px 고정입니다.

이 정보로 브라우저는 실제 다운로드할 이미지 크기를 결정합니다. 마지막으로, loading="lazy"와 decoding="async"로 성능을 최적화합니다.

lazy는 이미지가 뷰포트에 가까워질 때까지 로딩을 지연시켜 초기 페이지 로드를 빠르게 합니다. async는 이미지 디코딩을 메인 스레드에서 분리하여 스크롤 성능을 향상시킵니다.

여러분이 이 코드를 사용하면 이미지 전송량이 평균 40-60% 감소하고, LCP(Largest Contentful Paint)가 크게 개선됩니다. 모바일 사용자는 데이터를 절약하고, 모든 사용자는 더 빠른 로딩을 경험합니다.

특히 이미지가 많은 갤러리나 제품 목록 페이지에서 초기 로딩 시간이 절반으로 줄어듭니다.

실전 팁

💡 중요한 히어로 이미지나 로고는 loading="lazy"를 사용하지 마세요. 첫 화면(Above the fold)의 이미지는 즉시 로드되어야 하며, lazy 로딩은 오히려 LCP를 지연시킬 수 있습니다. 💡 이미지 빌드 파이프라인에 Sharp나 ImageMagick 같은 도구를 통합하여 자동으로 여러 크기와 포맷을 생성하세요. 수동 관리는 오류가 발생하기 쉽고 유지보수가 어렵습니다. 💡 AVIF는 WebP보다 20% 더 작지만 인코딩 시간이 길고 브라우저 지원이 제한적입니다. picture 요소로 AVIF → WebP → JPEG 순서로 제공하면 최대 호환성과 최적 성능을 얻을 수 있습니다. 💡 fetchpriority="high" 속성을 LCP 이미지에 추가하면 브라우저에게 우선순위를 알려줄 수 있습니다. 이는 다른 리소스보다 먼저 다운로드하도록 힌트를 제공합니다. 💡 블러 업(Blur-up) 기법을 사용하세요. 작은 썸네일(~20KB)을 먼저 보여주고 블러 처리한 후, 원본이 로드되면 교체하는 방식으로 체감 성능을 크게 향상시킬 수 있습니다. Medium이나 Pinterest에서 사용하는 기법입니다.


6. 네트워크 우선 전략

시작하며

여러분이 뉴스 앱이나 주식 정보 앱을 개발할 때, 캐시된 오래된 데이터를 보여주면 안 되는 상황이 있죠? 실시간 정보, 사용자 계정 상태, 주문 내역 같은 데이터는 항상 최신이어야 합니다.

어제의 주식 가격을 보여주면 큰 문제가 됩니다. 이런 문제는 캐시 우선 전략을 모든 데이터에 일괄 적용할 때 발생합니다.

빠른 로딩은 좋지만, 데이터가 오래되면 사용자에게 잘못된 정보를 제공하게 됩니다. 바로 이럴 때 필요한 것이 네트워크 우선(Network First) 전략입니다.

먼저 서버에서 최신 데이터를 가져오려 시도하고, 네트워크가 실패하거나 느릴 때만 캐시를 사용하는 방식입니다.

개요

간단히 말해서, 네트워크 우선 전략은 항상 네트워크 요청을 먼저 시도하고, 실패하거나 타임아웃되면 캐시된 데이터를 fallback으로 제공하는 캐싱 전략입니다. 데이터 신선도를 최우선으로 하면서도 오프라인 대응이 가능합니다.

왜 이것이 필요한가요? 모든 데이터가 캐시에 적합한 것은 아닙니다.

API 응답, 사용자 생성 콘텐츠, 실시간 정보 등은 최신성이 중요합니다. 예를 들어, 소셜 미디어 피드에서 새 게시물을 확인할 때 오래된 캐시를 보여주면 사용자는 앱이 제대로 작동하지 않는다고 느낍니다.

네트워크 우선 전략을 사용하면 온라인일 때는 항상 최신 데이터를, 오프라인일 때는 캐시된 데이터를 제공할 수 있습니다. 기존 캐시 우선 전략이 "빠르지만 오래될 수 있다"면, 네트워크 우선은 "최신이지만 느릴 수 있다"입니다.

이제는 데이터의 특성에 따라 적절한 전략을 선택해야 합니다. 핵심 특징은 세 가지입니다.

첫째, 데이터 신선도를 보장하여 사용자에게 정확한 정보를 제공합니다. 둘째, 네트워크 실패 시 캐시로 대체하여 완전한 앱 중단을 방지합니다.

셋째, 타임아웃을 설정하여 느린 네트워크에서도 합리적인 응답 시간을 보장합니다. 이러한 특징들이 신뢰성과 성능의 균형을 맞춰줍니다.

코드 예제

// sw.js - API 요청에 네트워크 우선 전략 적용
const API_CACHE = 'api-v1';
const TIMEOUT = 3000; // 3초 타임아웃

self.addEventListener('fetch', event => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      Promise.race([
        fetch(event.request),
        new Promise((_, reject) =>
          setTimeout(() => reject(new Error('timeout')), TIMEOUT)
        )
      ])
      .then(response => {
        // 성공하면 캐시에 저장하고 반환
        const responseClone = response.clone();
        caches.open(API_CACHE).then(cache => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => caches.match(event.request)) // 실패 시 캐시 사용
    );
  }
});

설명

이것이 하는 일: API 요청이 들어오면 항상 서버에서 최신 데이터를 가져오려 시도합니다. 성공하면 그 결과를 캐시에 업데이트하고 반환하며, 실패하거나 너무 느리면 이전에 캐시된 데이터를 대신 제공합니다.

첫 번째로, URL에 '/api/'가 포함된 요청만 이 전략을 적용합니다. 모든 리소스에 네트워크 우선을 적용하면 불필요하게 느려지므로, API 같은 동적 데이터에만 선택적으로 사용합니다.

정적 리소스는 계속 캐시 우선 전략을 사용하는 것이 효율적입니다. 그 다음으로, Promise.race()로 네트워크 요청과 타임아웃을 경쟁시킵니다.

이것이 핵심 패턴인데, 네트워크가 3초 이내에 응답하면 그것을 사용하고, 그렇지 않으면 타임아웃이 reject되어 catch 블록으로 넘어갑니다. 이는 무한정 기다리지 않고 사용자에게 빠른 응답을 제공하기 위함입니다.

느린 3G 네트워크에서도 최대 3초만 기다리면 캐시된 데이터를 볼 수 있습니다. 세 번째로, 네트워크 요청이 성공하면 response.clone()으로 복제하여 캐시에 저장합니다.

이때 캐시 저장은 비동기로 처리하고 결과를 기다리지 않습니다(await 없음). 사용자는 즉시 응답을 받고, 캐시 업데이트는 백그라운드에서 일어나므로 성능에 영향을 주지 않습니다.

마지막으로, catch 블록에서 caches.match()로 캐시된 데이터를 찾아 반환합니다. 네트워크 실패, 타임아웃, 서버 에러 등 모든 실패 상황에서 이 로직이 실행됩니다.

캐시에도 없으면 undefined가 반환되어 브라우저가 네트워크 에러를 표시합니다. 여러분이 이 코드를 사용하면 온라인 상태에서는 항상 최신 데이터를 보고, 오프라인이거나 네트워크가 느릴 때는 이전 데이터로 앱을 계속 사용할 수 있습니다.

사용자는 "연결 없음" 에러 대신 실제 콘텐츠를 보며, 네트워크가 복구되면 자동으로 최신 데이터로 업데이트됩니다. 이는 완벽한 오프라인 경험을 제공하면서도 데이터 신선도를 유지하는 최적의 방법입니다.

실전 팁

💡 타임아웃 값은 네트워크 상황에 따라 조정하세요. Wi-Fi 환경의 웹 앱은 3초, 모바일 앱은 5초가 적절합니다. 너무 짧으면 불필요하게 캐시를 사용하고, 너무 길면 사용자가 오래 기다립니다. 💡 네트워크 응답의 상태 코드를 확인하여 성공한 응답만 캐싱하세요. response.ok를 체크하여 200-299 범위만 저장하고, 404나 500 에러는 캐싱하지 않아야 합니다. 에러를 캐싱하면 다음에도 에러를 보게 됩니다. 💡 Stale-While-Revalidate 전략도 고려하세요. 캐시를 즉시 반환하고 백그라운드에서 네트워크 요청을 보내 캐시를 업데이트하는 방식으로, 빠른 응답과 신선한 데이터를 모두 얻을 수 있습니다. 💡 POST, PUT, DELETE 같은 변경 요청은 절대 캐싱하지 마세요. event.request.method === 'GET' 조건을 추가하여 읽기 요청만 캐싱해야 합니다. 변경 요청을 캐싱하면 데이터 일관성이 깨집니다. 💡 캐시 버스팅을 위해 API 응답 헤더의 Cache-Control이나 ETag를 활용하세요. 서버가 max-age=0을 보내면 캐시를 무효화하고 항상 네트워크에서 가져오도록 로직을 추가할 수 있습니다.


7. 백그라운드 동기화

시작하며

여러분이 폼을 작성하다가 네트워크가 끊겼을 때, 입력한 데이터가 모두 사라진 경험이 있나요? 또는 "좋아요"를 눌렀는데 오프라인이라 반영되지 않아 나중에 다시 시도해야 했던 적이 있나요?

이런 상황은 사용자에게 큰 불편을 줍니다. 이런 문제는 네트워크 연결이 필수인 작업을 즉시 처리하려고 할 때 발생합니다.

사용자가 온라인 상태를 확인하고 다시 시도해야 하는 번거로움이 있고, 작성한 데이터를 잃어버릴 위험도 있습니다. 바로 이럴 때 필요한 것이 백그라운드 동기화(Background Sync)입니다.

오프라인에서 발생한 작업을 큐에 저장해두었다가, 네트워크가 복구되면 자동으로 처리하는 기술입니다.

개요

간단히 말해서, 백그라운드 동기화는 Service Worker의 sync 이벤트를 사용하여 네트워크 요청을 연기하고 재시도하는 메커니즘입니다. 사용자가 앱을 닫아도 백그라운드에서 자동으로 작업을 완료합니다.

왜 이것이 필요한가요? 사용자는 언제든지 오프라인이 될 수 있고, 그때마다 작업을 중단하면 안 됩니다.

특히 중요한 데이터 전송(폼 제출, 결제, 메시지 전송)은 반드시 성공해야 합니다. 예를 들어, 채팅 앱에서 메시지를 보냈는데 오프라인이면, 백그라운드 동기화가 자동으로 재시도하여 네트워크가 복구되는 순간 전송합니다.

사용자는 아무것도 할 필요가 없습니다. 기존에는 개발자가 수동으로 재시도 로직을 구현하거나, 사용자에게 "나중에 다시 시도하세요"라고 알려야 했다면, 이제는 브라우저가 자동으로 처리합니다.

심지어 앱이 닫혀 있어도 작동합니다. 핵심 특징은 세 가지입니다.

첫째, 네트워크 복구를 자동 감지하여 사용자 개입 없이 작업을 재개합니다. 둘째, 앱이 닫혀 있어도 Service Worker가 백그라운드에서 동작합니다.

셋째, 재시도 메커니즘이 내장되어 실패 시 자동으로 다시 시도합니다. 이러한 특징들이 네이티브 앱 수준의 안정성을 제공합니다.

코드 예제

// main.js - 백그라운드 동기화 등록
async function sendMessage(message) {
  if ('serviceWorker' in navigator && 'sync' in registration) {
    // IndexedDB에 메시지 저장
    await saveToIndexedDB('outbox', message);
    // 동기화 등록
    await registration.sync.register('send-messages');
    console.log('백그라운드 동기화 등록됨');
  } else {
    // 폴백: 즉시 전송 시도
    await fetch('/api/messages', {
      method: 'POST',
      body: JSON.stringify(message)
    });
  }
}

// sw.js - 동기화 이벤트 처리
self.addEventListener('sync', event => {
  if (event.tag === 'send-messages') {
    event.waitUntil(sendPendingMessages());
  }
});

async function sendPendingMessages() {
  const messages = await getFromIndexedDB('outbox');
  await Promise.all(
    messages.map(msg =>
      fetch('/api/messages', {
        method: 'POST',
        body: JSON.stringify(msg)
      }).then(() => deleteFromIndexedDB('outbox', msg.id))
    )
  );
}

설명

이것이 하는 일: 사용자가 오프라인에서 메시지를 보내려 하면 즉시 전송하지 않고 로컬 DB에 저장한 후 동기화를 예약합니다. 네트워크가 복구되면 브라우저가 자동으로 Service Worker를 깨워 저장된 모든 메시지를 전송합니다.

첫 번째로, 메인 JavaScript에서 백그라운드 동기화 지원 여부를 확인합니다. 'sync' in registration으로 기능 감지를 하는 이유는, 모든 브라우저가 아직 지원하지 않기 때문입니다(주로 Chrome/Edge).

지원하지 않으면 폴백으로 즉시 전송을 시도하여 기능 저하 없이 작동하도록 합니다. 그 다음으로, 메시지를 IndexedDB에 저장합니다.

로컬 스토리지가 아닌 IndexedDB를 사용하는 이유는, 복잡한 데이터 구조를 저장할 수 있고 용량 제한이 훨씬 크며, Service Worker에서 접근 가능하기 때문입니다. 'outbox'라는 저장소에 보내지 못한 메시지들을 큐처럼 쌓아둡니다.

세 번째로, sync.register()로 'send-messages'라는 태그로 동기화를 등록합니다. 이것은 브라우저에게 "네트워크가 복구되면 이 태그로 sync 이벤트를 발생시켜줘"라고 요청하는 것입니다.

네트워크가 이미 온라인이면 즉시 이벤트가 발생하고, 오프라인이면 복구될 때까지 기다립니다. 마지막으로, Service Worker의 sync 이벤트 리스너에서 실제 전송 로직을 실행합니다.

IndexedDB에서 모든 대기 중인 메시지를 가져와 Promise.all()로 병렬 전송합니다. 각 메시지가 성공적으로 전송되면 DB에서 삭제하여 중복 전송을 방지합니다.

실패하면 IndexedDB에 남아 있어 다음 동기화 때 다시 시도됩니다. 여러분이 이 코드를 사용하면 사용자는 네트워크 상태를 신경 쓰지 않고 앱을 자유롭게 사용할 수 있습니다.

지하철에서 메시지를 보내고 앱을 닫아도, 지상에 올라와 네트워크가 연결되는 순간 자동으로 전송됩니다. 이는 사용자 경험을 네이티브 앱 수준으로 끌어올리며, 데이터 손실 위험을 완전히 제거합니다.

실전 팁

💡 주기적 백그라운드 동기화(Periodic Background Sync)도 있습니다. 정기적으로 콘텐츠를 업데이트해야 하는 뉴스 앱 같은 경우 periodicSync.register()를 사용하여 12시간마다 자동으로 최신 기사를 가져올 수 있습니다. 💡 IndexedDB 작업은 복잡하므로 idb 라이브러리(Jake Archibald 제작)를 사용하세요. Promise 기반 API로 훨씬 간단하게 사용할 수 있으며, async/await와 완벽히 호환됩니다. 💡 동기화 작업은 최대 5분의 실행 시간 제한이 있습니다. 너무 많은 작업을 한 번에 처리하려 하면 중단될 수 있으므로, 배치 크기를 제한하거나 여러 sync 태그로 분할하세요. 💡 재시도 전략을 구현하세요. sendPendingMessages()에서 실패한 항목을 추적하고, 3번 이상 실패하면 사용자에게 알림을 보내거나 로그를 남기는 식으로 처리합니다. 무한 재시도는 배터리를 소모합니다. 💡 동기화 성공 시 UI를 업데이트하려면 BroadcastChannel API를 사용하세요. Service Worker에서 메시지를 보내면 열려 있는 모든 탭이 수신하여 UI를 갱신할 수 있습니다. 이렇게 하면 사용자가 전송 상태를 실시간으로 확인할 수 있습니다.


8. 번들 크기 최적화

시작하며

여러분의 앱이 실행되기까지 몇 초가 걸리나요? JavaScript 번들이 크면 다운로드 시간뿐만 아니라 파싱과 실행 시간도 길어집니다.

Lighthouse 보고서에서 "JavaScript 실행 시간 줄이기"라는 경고를 본 적이 있을 것입니다. 이런 문제는 모든 코드를 하나의 큰 번들로 묶을 때 발생합니다.

사용자가 홈 페이지만 보는데 관리자 페이지 코드까지 다운로드하는 것은 낭비입니다. 특히 React나 Vue 같은 프레임워크를 사용하면 번들 크기가 쉽게 500KB를 넘어갑니다.

바로 이럴 때 필요한 것이 번들 크기 최적화입니다. 코드 스플리팅, 트리 쉐이킹, 동적 임포트로 필요한 코드만 로드하여 초기 로딩 시간을 극적으로 단축할 수 있습니다.

개요

간단히 말해서, 번들 크기 최적화는 JavaScript를 작은 청크로 나누고, 사용하지 않는 코드를 제거하며, 필요한 시점에만 로드하는 종합적인 전략입니다. 초기 로딩 성능을 최우선으로 합니다.

왜 이것이 필요한가요? JavaScript는 다운로드뿐만 아니라 파싱과 컴파일 시간도 걸립니다.

300KB 번들은 모바일에서 파싱에만 1초 이상 소요될 수 있습니다. 코드 스플리팅으로 초기 번들을 100KB 이하로 줄이면 Time to Interactive(TTI)가 절반으로 감소합니다.

예를 들어, SPA에서 라우트별로 코드를 분할하면 홈 페이지는 50KB, 관리자 페이지는 별도로 로드하여 대부분 사용자가 더 빠른 경험을 얻습니다. 기존에는 모든 코드를 빌드 시점에 하나로 묶었다면, 이제는 동적 import()로 런타임에 필요한 부분만 가져올 수 있습니다.

Webpack과 Rollup 같은 번들러가 자동으로 청크를 생성합니다. 핵심 특징은 세 가지입니다.

첫째, 라우트 기반 코드 스플리팅으로 페이지별 번들을 분리합니다. 둘째, 트리 쉐이킹으로 사용하지 않는 코드를 빌드에서 제외합니다.

셋째, 동적 임포트로 조건부나 지연 로딩이 가능합니다. 이러한 특징들이 FCP와 TTI를 크게 개선하여 Core Web Vitals 점수를 향상시킵니다.

코드 예제

// React 라우터에서 동적 임포트로 코드 스플리팅
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 페이지별 지연 로딩
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Admin = lazy(() => import('./pages/Admin'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>로딩 중...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/admin" element={<Admin />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

설명

이것이 하는 일: React 앱을 페이지별로 분할하여 초기 로딩 시 홈 페이지 코드만 다운로드합니다. 사용자가 다른 페이지로 이동할 때 해당 청크를 필요한 시점에 다운로드하여 초기 번들 크기를 최소화합니다.

첫 번째로, React.lazy()로 컴포넌트를 감싸 동적 임포트로 변환합니다. import('./pages/Home')은 정적 임포트가 아니라 함수 호출로, 실행될 때까지 모듈을 로드하지 않습니다.

Webpack은 이를 감지하여 자동으로 별도 청크 파일(예: home.chunk.js)을 생성합니다. 이렇게 하면 초기 번들에서 이 컴포넌트 코드가 제외됩니다.

그 다음으로, Suspense 컴포넌트로 lazy 컴포넌트들을 감쌉니다. 동적 임포트는 비동기 작업이므로 로딩 시간이 필요한데, 이 시간 동안 fallback UI를 보여줍니다.

스피너나 스켈레톤 UI를 표시하여 사용자에게 "로딩 중"임을 알려주는 것입니다. fallback이 없으면 화면이 멈춘 것처럼 보입니다.

세 번째로, 라우트 설정에서 각 경로에 lazy 컴포넌트를 연결합니다. 사용자가 '/' 경로를 방문하면 Home 청크만 다운로드되고, '/admin'으로 이동하면 그때 Admin 청크를 가져옵니다.

관리자가 아닌 일반 사용자는 Admin 코드를 전혀 다운로드하지 않아 대역폭과 시간을 절약합니다. 마지막으로, 브라우저는 다운로드한 청크를 자동으로 캐싱합니다.

사용자가 페이지 사이를 이동해도 두 번째부터는 즉시 렌더링됩니다. Service Worker와 결합하면 완벽한 오프라인 지원도 가능합니다.

여러분이 이 코드를 사용하면 초기 JavaScript 번들이 보통 50-70% 감소합니다. 예를 들어, 500KB 번들이 150KB로 줄어들어 3G 네트워크에서 로딩 시간이 10초에서 3초로 단축됩니다.

Lighthouse 성능 점수가 크게 향상되고, 특히 모바일 사용자의 이탈률이 감소합니다. SEO에도 긍정적인 영향을 미치며, 서버 비용(대역폭)도 절감됩니다.

실전 팁

💡 webpack-bundle-analyzer를 사용하여 번들 구성을 시각화하세요. 어떤 라이브러리가 크기를 차지하는지 한눈에 보이며, moment.js 같은 큰 라이브러리를 day.js로 교체하는 등의 최적화 기회를 발견할 수 있습니다. 💡 공통 청크를 추출하여 캐싱 효율을 높이세요. Webpack의 splitChunks 설정으로 React 같은 벤더 라이브러리를 별도 청크로 분리하면, 앱 코드가 변경되어도 벤더 청크는 캐시에서 재사용됩니다. 💡 동적 임포트 시 웹팩 매직 코멘트를 사용하세요. import(/* webpackChunkName: "admin" */ './Admin')으로 청크 이름을 지정하면 디버깅이 쉬워지고, prefetch나 preload 힌트도 추가할 수 있습니다. 💡 조건부 기능에도 동적 임포트를 적용하세요. 예를 들어, 차트 라이브러리는 사용자가 "차트 보기" 버튼을 클릭할 때 import('chart.js')로 로드하면 수백 KB를 절약할 수 있습니다. 💡 트리 쉐이킹을 위해 ES6 모듈을 사용하고 side effects를 명시하세요. package.json에 "sideEffects": false를 추가하면 번들러가 더 공격적으로 사용하지 않는 코드를 제거할 수 있습니다. lodash 대신 lodash-es를 사용하는 것도 트리 쉐이킹에 유리합니다.


#JavaScript#PWA#ServiceWorker#Performance#Optimization

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

카테고리: JavaScript

언어: JavaScript

태그: JavaScript, PWA, ServiceWorker, Performance, Optimization

작성자: AI Generated

프리미엄 콘텐츠 - 3개월 무료 체험 가능