프로덕션의 서비스 워커

세로 모드 스크린샷

요약

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

개요

올해의 Google I/O 2015 웹 앱은 Google의 개발자 관계팀이 작성한 것으로, 멋진 오디오/시각적 실험을 작성한 Instrument의 친구들이 디자인한 내용을 바탕으로 작성되었습니다. 우리 팀의 사명은 I/O 웹 앱 (코드명 IOWA로 지칭할 것)이 최신 웹이 할 수 있는 모든 것을 보여주는 것이었습니다. 완전한 오프라인 우선 환경은 필수 기능 목록의 최우선순위를 차지했습니다.

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

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

sw-precache로 사전 캐싱

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

빌드 시간 통합

sw-precache를 IOWA의 gulp 기반 빌드 프로세스와 함께 사용하며, 일련의 glob 패턴을 사용하여 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 해시가 있는 모든 기존 리소스는 그대로 유지됩니다. 즉, 이전에 사이트를 방문한 사용자가 변경된 리소스의 최소한의 집합만 다운로드하므로 전체 캐시가 한 번에 만료된 경우보다 훨씬 더 효율적인 환경이 제공됩니다.

glob 패턴 중 하나와 일치하는 각 파일은 사용자가 IOWA를 처음 방문할 때 다운로드되고 캐시됩니다. Google은 페이지를 렌더링하는 데 필요한 중요한 리소스만 사전 캐시되도록 하기 위해 노력했습니다. 오디오/영상 실험에 사용된 미디어나 세션 발표자의 프로필 이미지와 같은 보조 콘텐츠는 의도적으로 사전 캐시되지 않았으며, 대신 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 애널리틱스 요청을 큐에 추가하고 네트워크 사용이 가능할 때 나중에 다시 재생하려고 시도하는 핸들러를 구현했습니다. 이 접근 방식에서 오프라인 상태가 된다고 해서 Google 애널리틱스가 제공하는 통계가 희생되는 것은 아닙니다. 적절한 이벤트 기여 분석 시간이 Google 애널리틱스 백엔드에 전달되도록 하기 위해 요청을 처음 시도한 후 경과한 시간으로 설정된 qt 매개변수를 큐에 추가된 각 요청에 추가했습니다. 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 경험의 핵심이었으므로 Google에서는 캐시된 데이터를 사용하는 것이 적절하다고 판단했습니다. 사용자가 로그아웃하면 이전에 캐시된 세션 데이터가 삭제됩니다.

    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_url에 있는 utm_source=web_app_manifest를 사용했습니다. 이전에 캐시된 응답과 일치했던 URL은 이러한 매개변수가 추가되면 부적중으로 표시됩니다.

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

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

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 도구와 자체 웹 애플리케이션을 구동하기 위해 설명하는 기술을 사용하는 웹 개발자 커뮤니티가 기대됩니다. 서비스 워커는 지금 바로 사용할 수 있는 점진적 개선 사항이며, 적절하게 구조화된 웹 앱의 일부로 사용하면 속도와 오프라인상의 이점이 사용자에게 크게 향상됩니다.