프로덕션의 서비스 워커

세로 모드 스크린샷

요약

서비스 워커 라이브러리를 사용하여 Google I/O 2015 웹 앱을 빠르고 오프라인 우선으로 만드는 방법을 알아보세요.

개요

올해의 Google I/O 2015 웹 앱은 멋진 오디오/시각적 실험을 작성한 Instrument의 친구들이 디자인한 것을 바탕으로 Google의 Developer Relations팀에서 작성했습니다. 저희 팀의 임무는 I/O 웹 앱 (코드네임: IOWA)이 최신 웹에서 할 수 있는 모든 기능을 보여주는 것이었습니다. 필수 기능 목록의 최상위에는 전체 오프라인 우선 환경이 있었습니다.

최근 이 사이트의 다른 도움말을 읽어 보셨다면 서비스 워커를 접하셨을 것입니다. IOWA의 오프라인 지원이 서비스 워커를 크게 활용한다는 사실에 놀라지 않으셨을 것입니다. IOWA의 실제 요구사항에 따라 정적 리소스의 미리 캐싱을 자동화하는 sw-precache와 런타임 캐싱 및 대체 전략을 처리하는 sw-toolbox라는 두 가지 오프라인 사용 사례를 처리하는 두 가지 라이브러리를 개발했습니다.

라이브러리는 서로를 잘 보완하여 IOWA의 정적 콘텐츠 '셸'이 항상 캐시에서 직접 제공되고 동적 또는 원격 리소스가 네트워크에서 제공되며 필요한 경우 캐시된 응답 또는 정적 응답으로 대체되는 성능이 우수한 전략을 구현할 수 있었습니다.

sw-precache를 사용한 미리 캐싱

IOWA의 정적 리소스(HTML, JavaScript, CSS, 이미지)는 웹 애플리케이션의 핵심 셸을 제공합니다. 이러한 리소스를 캐시할 때 중요한 두 가지 요구사항이 있었습니다. 대부분의 정적 리소스가 캐시되고 최신 상태로 유지되도록 해야 했습니다. sw-precache는 이러한 요구사항을 염두에 두고 빌드되었습니다.

빌드 시간 통합

sw-precache를 IOWA의 gulp 기반 빌드 프로세스와 결합하고 일련의 글로브 패턴을 사용하여 IOWA에서 사용하는 모든 정적 리소스의 전체 목록을 생성합니다.

staticFileGlobs: [
    rootDir + '/bower_components/**/*.{html,js,css}',
    rootDir + '/elements/**',
    rootDir + '/fonts/**',
    rootDir + '/images/**',
    rootDir + '/scripts/**',
    rootDir + '/styles/**/*.css',
    rootDir + '/data-worker-scripts.js'
]

파일 이름 목록을 배열에 하드 코딩하고 이러한 파일이 변경될 때마다 캐시 버전 번호를 올리도록 기억하는 등의 대체 접근 방식은 특히 여러 팀원이 코드를 확인하는 상황에서 오류가 너무나도 발생하기 쉽습니다. 수동으로 유지 관리되는 배열에서 새 파일을 제외하여 오프라인 지원을 중단하고 싶은 사람은 없습니다. 빌드 시간 통합을 사용하면 이러한 문제를 걱정하지 않고 기존 파일을 변경하고 새 파일을 추가할 수 있었습니다.

캐시된 리소스 업데이트

sw-precache는 미리 캐시되는 각 리소스의 고유한 MD5 해시가 포함된 기본 서비스 워커 스크립트를 생성합니다. 기존 리소스가 변경되거나 새 리소스가 추가될 때마다 서비스 워커 스크립트가 다시 생성됩니다. 이렇게 하면 새 리소스가 캐시되고 오래된 리소스가 삭제되는 서비스 워커 업데이트 흐름이 자동으로 트리거됩니다. MD5 해시가 동일한 기존 리소스는 그대로 유지됩니다. 즉, 이전에 사이트를 방문한 사용자는 변경된 리소스의 최소 세트만 다운로드하므로 전체 캐시가 일괄적으로 만료되는 것보다 훨씬 효율적인 환경을 제공할 수 있습니다.

사용자가 IOWA를 처음 방문할 때 글러브 패턴 중 하나와 일치하는 각 파일이 다운로드되고 캐시됩니다. 페이지 렌더링에 필요한 중요 리소스만 미리 캐시되도록 했습니다. 오디오/시각적 실험에 사용된 미디어 또는 세션 발표자의 프로필 이미지와 같은 보조 콘텐츠는 의도적으로 미리 캐시되지 않았으며 대신 sw-toolbox 라이브러리를 사용하여 이러한 리소스에 대한 오프라인 요청을 처리했습니다.

sw-toolbox: 모든 동적 요구사항을 충족합니다.

앞서 언급했듯이 사이트가 오프라인에서 작동하는 데 필요한 모든 리소스를 미리 캐시하는 것은 불가능합니다. 일부 리소스는 너무 크거나 자주 사용되지 않아 캐싱할 가치가 없으며, 다른 리소스는 원격 API 또는 서비스의 응답과 같이 동적입니다. 하지만 요청이 미리 캐시되지 않는다고 해서 반드시 NetworkError이 발생하는 것은 아닙니다. sw-toolbox를 사용하면 일부 리소스의 런타임 캐싱과 다른 리소스의 맞춤 대체를 처리하는 요청 핸들러를 유연하게 구현할 수 있습니다. 또한 푸시 알림에 대한 응답으로 이전에 캐시된 리소스를 업데이트하는 데도 사용했습니다.

다음은 sw-toolbox를 기반으로 빌드한 맞춤 요청 핸들러의 몇 가지 예입니다. 독립형 JavaScript 파일을 서비스 워커 범위로 가져오는 sw-precacheimportScripts parameter를 통해 기본 서비스 워커 스크립트와 쉽게 통합할 수 있었습니다.

시청각 실험

오디오/시각적 실험에는 sw-toolboxnetworkFirst 캐시 전략을 사용했습니다. 실험의 URL 패턴과 일치하는 모든 HTTP 요청은 먼저 네트워크에 대해 실행되며, 성공적인 응답이 반환되면 Cache Storage API를 사용하여 해당 응답이 저장됩니다. 네트워크를 사용할 수 없는 시점에 후속 요청이 이루어지면 이전에 캐시된 응답이 사용됩니다.

성공적인 네트워크 응답이 반환될 때마다 캐시가 자동으로 업데이트되므로 리소스의 버전을 지정하거나 항목을 만료시킬 필요가 없었습니다.

toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);

발표자 프로필 이미지

연사 프로필 이미지의 경우 이전에 캐시된 버전의 특정 연사 이미지를 사용할 수 있는 경우 표시하고, 사용할 수 없는 경우 네트워크로 대체하여 이미지를 검색하는 것이 목표였습니다. 네트워크 요청이 실패하면 최종 대체로 사전 캐시되어 항상 사용할 수 있는 일반 자리표시자 이미지를 사용했습니다. 이는 일반 자리표시자로 대체할 수 있는 이미지를 처리할 때 사용하는 일반적인 전략이며 sw-toolboxcacheFirstcacheOnly 핸들러를 체이닝하여 쉽게 구현할 수 있었습니다.

var DEFAULT_PROFILE_IMAGE = 'images/touch/homescreen96.png';

function profileImageRequest(request) {
    return toolbox.cacheFirst(request).catch(function() {
    return toolbox.cacheOnly(new Request(DEFAULT_PROFILE_IMAGE));
    });
}

toolbox.precache([DEFAULT_PROFILE_IMAGE]);
toolbox.router.get('/(.+)/images/speakers/(.*)',
                    profileImageRequest,
                    {origin: /.*\.googleapis\.com/});
세션 페이지의 프로필 이미지
세션 페이지의 프로필 이미지입니다.

사용자 일정 업데이트

IOWA의 주요 기능 중 하나는 로그인한 사용자가 참석할 예정인 세션 일정을 만들고 유지할 수 있도록 허용하는 것이었습니다. 예상대로 세션 업데이트는 백엔드 서버에 대한 HTTP POST 요청을 통해 이루어졌으며, Google은 사용자가 오프라인 상태일 때 이러한 상태 수정 요청을 처리하는 가장 좋은 방법을 찾는 데 시간을 보냈습니다. IndexedDB에서 실패한 요청을 대기열에 추가하는 함수와 IndexedDB에서 대기열에 추가된 요청을 확인하고 발견된 요청을 다시 시도하는 기본 웹페이지의 로직을 결합한 방법을 생각해 냈습니다.

var DB_NAME = 'shed-offline-session-updates';

function queueFailedSessionUpdateRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, request.method);
    });
}

function handleSessionUpdateRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedSessionUpdateRequest(request);
    });
}

toolbox.router.put('/(.+)api/v1/user/schedule/(.+)',
                    handleSessionUpdateRequest);
toolbox.router.delete('/(.+)api/v1/user/schedule/(.+)',
                        handleSessionUpdateRequest);

재시도는 기본 페이지의 컨텍스트에서 이루어졌으므로 새 사용자 인증 정보가 포함되었음을 확신할 수 있었습니다. 재시도가 성공하면 이전에 대기열에 추가된 업데이트가 적용되었음을 사용자에게 알리는 메시지가 표시되었습니다.

simpleDB.open(QUEUED_SESSION_UPDATES_DB_NAME).then(function(db) {
    var replayPromises = [];
    return db.forEach(function(url, method) {
    var promise = IOWA.Request.xhrPromise(method, url, true).then(function() {
        return db.delete(url).then(function() {
        return true;
        });
    });
    replayPromises.push(promise);
    }).then(function() {
    if (replayPromises.length) {
        return Promise.all(replayPromises).then(function() {
        IOWA.Elements.Toast.showMessage(
            'My Schedule was updated with offline changes.');
        });
    }
    });
}).catch(function() {
    IOWA.Elements.Toast.showMessage(
    'Offline changes could not be applied to My Schedule.');
});

오프라인 Google 애널리틱스

마찬가지로 실패한 Google 애널리틱스 요청을 대기열에 추가하고 나중에 네트워크를 사용할 수 있게 되면 재생하려고 시도하는 핸들러를 구현했습니다. 이 접근 방식을 사용하면 오프라인 상태여도 Google 애널리틱스에서 제공하는 유용한 정보를 활용할 수 있습니다. 요청이 처음 시도된 후 경과된 시간으로 설정된 qt 매개변수를 각 대기열에 추가하여 적절한 이벤트 기여도를 Google 애널리틱스 백엔드로 전송했습니다. Google 애널리틱스는 최대 4시간까지의 qt 값을 공식적으로 지원하므로 서비스 워커가 시작될 때마다 최대한 빨리 이러한 요청을 재생하려고 노력했습니다.

var DB_NAME = 'offline-analytics';
var EXPIRATION_TIME_DELTA = 86400000;
var ORIGIN = /https?:\/\/((www|ssl)\.)?google-analytics\.com/;

function replayQueuedAnalyticsRequests() {
    simpleDB.open(DB_NAME).then(function(db) {
    db.forEach(function(url, originalTimestamp) {
        var timeDelta = Date.now() - originalTimestamp;
        var replayUrl = url + '&qt=' + timeDelta;
        fetch(replayUrl).then(function(response) {
        if (response.status >= 500) {
            return Response.error();
        }
        db.delete(url);
        }).catch(function(error) {
        if (timeDelta > EXPIRATION_TIME_DELTA) {
            db.delete(url);
        }
        });
    });
    });
}

function queueFailedAnalyticsRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, Date.now());
    });
}

function handleAnalyticsCollectionRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedAnalyticsRequest(request);
    });
}

toolbox.router.get('/collect',
                    handleAnalyticsCollectionRequest,
                    {origin: ORIGIN});
toolbox.router.get('/analytics.js',
                    toolbox.networkFirst,
                    {origin: ORIGIN});

replayQueuedAnalyticsRequests();

푸시 알림 방문 페이지

서비스 워커는 IOWA의 오프라인 기능을 처리하는 것뿐만 아니라 북마크된 세션의 업데이트에 관해 사용자에게 알리는 데 사용되는 푸시 알림도 지원했습니다. 이러한 알림과 연결된 방문 페이지에 업데이트된 세션 세부정보가 표시되었습니다. 이러한 방문 페이지는 이미 전체 사이트의 일부로 캐시되고 있었으므로 이미 오프라인에서 작동했지만 오프라인에서 보더라도 해당 페이지의 세션 세부정보가 최신 상태인지 확인해야 했습니다. 이를 위해 푸시 알림을 트리거한 업데이트로 이전에 캐시된 세션 메타데이터를 수정하고 결과를 캐시에 저장했습니다. 이 최신 정보는 온라인 또는 오프라인에서 다음에 세션 세부정보 페이지를 열 때 사용됩니다.

caches.open(toolbox.options.cacheName).then(function(cache) {
    cache.match('api/v1/schedule').then(function(response) {
    if (response) {
        parseResponseJSON(response).then(function(schedule) {
        sessions.forEach(function(session) {
            schedule.sessions[session.id] = session;
        });
        cache.put('api/v1/schedule',
                    new Response(JSON.stringify(schedule)));
        });
    } else {
        toolbox.cache('api/v1/schedule');
    }
    });
});

주의 사항 및 고려사항

물론 IOWA와 같은 규모의 프로젝트를 진행할 때는 몇 가지 문제가 발생할 수밖에 없습니다. 다음은 발생한 문제와 해결 방법입니다.

오래된 콘텐츠

서비스 워커를 통해 구현되든 표준 브라우저 캐시를 통해 구현되든 캐싱 전략을 계획할 때는 리소스를 최대한 빨리 전송하는 것과 최신 리소스를 전송하는 것 사이에서 절충이 필요합니다. sw-precache를 통해 애플리케이션 셸에 공격적인 캐시 우선 전략을 구현했습니다. 즉, 서비스 워커는 페이지의 HTML, JavaScript, CSS를 반환하기 전에 네트워크에서 업데이트를 확인하지 않습니다.

다행히 서비스 워커 수명 주기 이벤트를 활용하여 페이지가 이미 로드된 후 새 콘텐츠를 사용할 수 있는 시점을 감지할 수 있었습니다. 업데이트된 서비스 워커가 감지되면 최신 콘텐츠를 보려면 페이지를 새로고침해야 한다고 알리는 토스트 메시지가 사용자에게 표시됩니다.

if (navigator.serviceWorker && navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.onstatechange = function(e) {
    if (e.target.state === 'redundant') {
        var tapHandler = function() {
        window.location.reload();
        };
        IOWA.Elements.Toast.showMessage(
        'Tap here or refresh the page for the latest content.',
        tapHandler);
    }
    };
}
최신 콘텐츠 토스트
'최신 콘텐츠' 토스트

정적 콘텐츠가 정적인지 확인

sw-precache는 로컬 파일 콘텐츠의 MD5 해시를 사용하고 해시가 변경된 리소스만 가져옵니다. 즉, 리소스를 페이지에서 거의 즉시 사용할 수 있지만, 일단 캐시되면 업데이트된 서비스 워커 스크립트에서 새 해시가 할당될 때까지 캐시된 상태로 유지됩니다.

백엔드에서 컨퍼런스 기간 중 매일 라이브 스트림 YouTube 동영상 ID를 동적으로 업데이트해야 하므로 I/O 중에 이 동작과 관련된 문제가 발생했습니다. 기본 템플릿 파일이 정적이고 변경되지 않았기 때문에 서비스 워커 업데이트 흐름이 트리거되지 않았고, YouTube 동영상 업데이트와 관련하여 서버에서 동적으로 응답해야 했던 것이 결국 많은 사용자의 캐시된 응답이 되었습니다.

셸이 항상 정적이고 안전하게 미리 캐시될 수 있도록 웹 애플리케이션이 구성되고 셸을 수정하는 동적 리소스는 독립적으로 로드되도록 하면 이러한 유형의 문제를 방지할 수 있습니다.

미리 캐싱 요청 캐시 무효화

sw-precache가 미리 캐시할 리소스를 요청하면 파일의 MD5 해시가 변경되지 않았다고 생각하는 한 해당 응답을 무기한 사용합니다. 즉, 미리 캐시 요청에 대한 응답이 최신 응답이며 브라우저의 HTTP 캐시에서 반환되지 않는지 확인하는 것이 특히 중요합니다. 예, 서비스 워커에서 실행된 fetch() 요청은 브라우저의 HTTP 캐시에서 가져온 데이터로 응답할 수 있습니다.

사전 캐시된 응답이 브라우저의 HTTP 캐시가 아닌 네트워크에서 바로 가져오도록 sw-precache는 요청하는 각 URL에 캐시 무효화 쿼리 매개변수를 자동으로 추가합니다. sw-precache를 사용하지 않고 캐시 우선 응답 전략을 사용하는 경우 자체 코드에서 이와 유사한 작업을 실행해야 합니다.

캐시 무효화를 위한 더 나은 해결 방법은 미리 캐싱하는 데 사용되는 각 Request캐시 모드reload로 설정하는 것입니다. 이렇게 하면 응답이 네트워크에서 가져옵니다. 하지만 이 문서를 작성하는 시점에는 Chrome에서 캐시 모드 옵션이 지원되지 않습니다.

로그인 및 로그아웃 지원

IOWA를 사용하면 사용자가 Google 계정으로 로그인하고 맞춤설정된 이벤트 일정을 업데이트할 수 있지만, 나중에 로그아웃할 수도 있습니다. 맞춤설정된 응답 데이터를 캐시하는 것은 분명히 까다로운 주제이며 항상 하나의 올바른 접근 방식이 있는 것은 아닙니다.

오프라인 상태에서도 개인 일정을 확인하는 것이 IOWA 환경의 핵심이므로 캐시된 데이터를 사용하는 것이 적절하다고 판단했습니다. 사용자가 로그아웃하면 이전에 캐시된 세션 데이터가 삭제되었습니다.

    self.addEventListener('message', function(event) {
      if (event.data === 'clear-cached-user-data') {
        caches.open(toolbox.options.cacheName).then(function(cache) {
          cache.keys().then(function(requests) {
            return requests.filter(function(request) {
              return request.url.indexOf('api/v1/user/') !== -1;
            });
          }).then(function(userDataRequests) {
            userDataRequests.forEach(function(userDataRequest) {
              cache.delete(userDataRequest);
            });
          });
        });
      }
    });

추가 쿼리 매개변수에 주의하세요.

서비스 워커는 캐시된 응답을 확인할 때 요청 URL을 키로 사용합니다. 기본적으로 요청 URL은 URL의 search 부분에 있는 모든 쿼리 매개변수를 포함하여 캐시된 응답을 저장하는 데 사용된 URL과 정확하게 일치해야 합니다.

이로 인해 개발 과정에서 URL 매개변수를 사용하여 트래픽 출처를 추적하기 시작했을 때 문제가 발생했습니다. 예를 들어 알림 중 하나를 클릭할 때 열리는 URL에 utm_source=notification 매개변수를 추가하고 웹 앱 매니페스트start_urlutm_source=web_app_manifest를 사용했습니다. 이전에 캐시된 응답과 일치하는 URL이 이러한 매개변수가 추가될 때 누락된 것으로 표시되었습니다.

이는 Cache.match()를 호출할 때 사용할 수 있는 ignoreSearch 옵션으로 부분적으로 해결됩니다. 안타깝게도 Chrome은 ignoreSearch아직 지원하지 않으며 지원하더라도'전부 또는 전혀'의 동작입니다. 의미 있는 URL 쿼리 매개변수를 고려하면서 일부 URL 쿼리 매개변수를 무시하는 방법이 필요했습니다.

결국 sw-precache를 확장하여 캐시 일치를 확인하기 전에 일부 쿼리 매개변수를 제거하고 개발자가 ignoreUrlParametersMatching 옵션을 통해 무시되는 매개변수를 맞춤설정할 수 있도록 했습니다. 다음은 기본 구현입니다.

function stripIgnoredUrlParameters(originalUrl, ignoredRegexes) {
    var url = new URL(originalUrl);

    url.search = url.search.slice(1)
    .split('&')
    .map(function(kv) {
        return kv.split('=');
    })
    .filter(function(kv) {
        return ignoredRegexes.every(function(ignoredRegex) {
        return !ignoredRegex.test(kv[0]);
        });
    })
    .map(function(kv) {
        return kv.join('=');
    })
    .join('&');

    return url.toString();
}

이번 결정이 미치는 영향

Google I/O 웹 앱의 서비스 워커 통합은 지금까지 배포된 것 중 가장 복잡한 실제 사용 사례일 수 있습니다. Google에서 만든 도구인 sw-precachesw-toolbox와 설명된 기법을 사용하여 자체 웹 애플리케이션을 구축하는 웹 개발자 커뮤니티를 기대하고 있습니다. 서비스 워커는 지금 바로 사용할 수 있는 점진적 개선사항이며 올바르게 구성된 웹 앱의 일부로 사용하면 속도와 오프라인 이점이 사용자에게 큰 도움이 됩니다.