프로덕션의 서비스 워커

세로 모드 스크린샷

요약

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

개요

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

최근에 이 사이트에서 다른 기사를 읽은 적이 있다면 서비스 워커를 접한 경험이 있을 것입니다. 그리고 IOWA의 오프라인 지원이 이들에게 크게 의존하고 있다는 사실은 놀라운 일이 아닙니다. 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 해시가 동일한 기존 리소스는 그대로 유지됩니다. 즉, 이전에 사이트를 방문한 사용자는 변경된 리소스의 최소 세트만 다운로드하므로 전체 캐시가 일괄적으로 만료된 경우보다 훨씬 효율적인 환경을 제공할 수 있습니다.

사용자가 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 쿼리 매개변수를 무시하는 방법이 필요했습니다.

결국 캐시 일치를 확인하기 전에 일부 쿼리 매개변수를 제거하고 개발자가 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와 설명된 기법을 사용하여 자체 웹 애플리케이션을 구축하는 웹 개발자 커뮤니티를 기대하고 있습니다. 서비스 워커는 지금 바로 사용할 수 있는 점진적 개선사항으로, 올바르게 구성된 웹 앱의 일부로 사용하면 속도와 오프라인 이점이 사용자에게 큰 도움이 됩니다.