20180916

PWA lighthouse 점수 끌어올리기

Lighthouse 3.0이 Chrome 69 DevTools에 번들로 들어오면서 점수 체계가 꽤 빡빡해졌다. 사내 PWA 프로젝트 점수가 갑자기 떨어져서 원인 분석 + 개선한 기록. 2.x 시절 80점 찍던 앱이 3.0에선 65까지 떨어진다. 이게 버그가 아니라 기준 변화.

현재 상태 (개선 전)

  • Performance 71
  • Accessibility 88
  • Best Practices 93
  • PWA 77
  • SEO 90

Lighthouse 3.0에서 Performance 산출식이 바뀌었다. FCP, FMP, Speed Index, TTI, First CPU Idle, Estimated Input Latency — 이 6개의 가중 평균인데, 3.0부터 TTI와 Speed Index 가중치가 커졌다. 우리 앱은 initial JS 번들이 큰 편이라 TTI가 밀려서 점수 하락.

고친 것들 순서대로

1) manifest.json에 background_color 누락. 이게 없으면 Android 홈에서 앱 런치 시 splash screen이 안 뜨고, Lighthouse PWA 검사에서 "Splash screen" 항목이 감점. 이 한 줄 추가로 PWA 총점 +6.

{
  "name": "Reindeers Admin",
  "short_name": "RDAdmin",
  "start_url": "/?utm_source=homescreen",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0e47a1",
  "icons": [
    { "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
  ]
}

icons는 192와 512 둘 다 필수. purpose: "maskable"은 안드로이드 O 이상에서 아이콘 모양 테마 대응(safe zone 안에 로고 배치). 이 속성 모르는 팀이 많음.

2) Service Worker 등록 경로와 scope. 기본적으로 /sw.js는 최상위에 있어야 scope가 사이트 전체("/")로 잡힌다. 우리는 정적 파일 디렉터리 구조상 /static/sw.js에 있었는데 이 경우 기본 scope가 /static/이 되어 루트 경로들을 제어 못 함. 두 가지 해법이 있다.

  1. 빌드 산출물을 루트로 복사(또는 nginx location = /sw.js로 별칭)
  2. Service-Worker-Allowed 헤더로 scope 확장
# nginx
location = /sw.js {
    alias /var/www/app/static/sw.js;
    add_header Service-Worker-Allowed "/" always;
    add_header Cache-Control "no-cache" always;
}

Cache-Control도 중요. sw.js는 반드시 max-age 0 혹은 no-cache. sw는 변경되지 않으면 브라우저가 새 버전을 감지 못하고, 감지 경로가 이 파일 자체의 HTTP fetch 결과 diff라서 긴 캐시가 걸리면 업데이트 절대 안 내려간다.

3) offline fallback. 네트워크 죽었을 때 뭐라도 보여야 PWA "Works offline" 체크 통과. 가장 간단한 네트워크 우선 + 오프라인 fallback.

const RUNTIME_CACHE = 'rt-v3';
const OFFLINE_URL = '/offline.html';
const PRECACHE = ['/offline.html', '/static/icons/icon-192.png'];

self.addEventListener('install', e => {
  e.waitUntil(caches.open('core-v3').then(c => c.addAll(PRECACHE)));
  self.skipWaiting();
});

self.addEventListener('activate', e => {
  e.waitUntil(self.clients.claim());
});

self.addEventListener('fetch', e => {
  if (e.request.mode === 'navigate') {
    // 네트워크 우선, 실패 시 오프라인 페이지
    e.respondWith(
      fetch(e.request).catch(() => caches.match(OFFLINE_URL))
    );
    return;
  }
  // static 리소스는 stale-while-revalidate
  e.respondWith(
    caches.match(e.request).then(cached => {
      const fetchP = fetch(e.request).then(res => {
        const clone = res.clone();
        caches.open(RUNTIME_CACHE).then(c => c.put(e.request, clone));
        return res;
      }).catch(() => cached);
      return cached || fetchP;
    })
  );
});

navigate 요청(HTML)과 정적 리소스 전략을 분리. 네비게이션 실패는 오프라인 페이지, 정적 리소스는 stale-while-revalidate로 즉시 응답 + 백그라운드 갱신.

4) 이미지 lazy load — IntersectionObserver로 교체. 기존에는 scroll 이벤트에서 getBoundingClientRect로 체크하는 관용구. 이게 메인 스레드 점유가 쏠쏠했다. IntersectionObserver로 바꾸니 스크롤 이벤트 자체가 제거되고 TTI 800ms 감소. long task가 거의 사라졌다.

const io = new IntersectionObserver(entries => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target;
    img.src = img.dataset.src;
    if (img.dataset.srcset) img.srcset = img.dataset.srcset;
    io.unobserve(img);
  }
}, { rootMargin: '100px' });

document.querySelectorAll('img[data-src]').forEach(img => io.observe(img));

5) initial JS 번들 쪼개기. webpack 4의 splitChunks로 vendor/common/app 3분리. 라우트별 dynamic import(import('./page/orders'))로 관리자 권한 페이지를 떼어냄. 메인 route 초기 JS가 420KB → 180KB(parsed). TTI가 또 1.1초 빠짐.

6) 폰트 FOIT 제거. font-display: swap 선언과 <link rel="preload" as="font" crossorigin> 조합. FCP가 240ms 개선.

결과

  • Performance 71 → 91
  • Accessibility 88 → 95 (aria-label, color contrast 몇 개)
  • Best Practices 93 → 100
  • PWA 77 → 97
  • SEO 90 → 100 (meta description, hreflang)

남은 3점은 "maskable icon purpose" 속성이 Lighthouse 3.0 기준 아직 flaky하게 감지됨. 재검사하면 올라오기도 함. 미세한 문제라 일단 두기로.

교훈

  • Lighthouse 버전 올라갈 때마다 기준이 바뀐다. 점수 변화를 "회귀"로 오인하지 말 것
  • Performance는 JS 크기와 메인 스레드 점유가 거의 전부. Service Worker로 요리조리 캐시한다고 TTI가 극적으로 오지는 않는다
  • DevTools의 Coverage 탭으로 실제로 안 쓰는 코드 비율 확인. 대부분 40~60%가 첫 페이지에 미사용
  • CI에 lighthouse-ci 붙여서 PR마다 감시하면 회귀 예방에 효과적. 점수 임계값 넘으면 fail