페이지 다시 로드 시 데이터 유지: 쿠키, IndexedDB 및 그 사이의 모든 것

게시 됨: 2022-03-11

내가 웹사이트를 방문하고 있다고 가정해 봅시다. 탐색 링크 중 하나를 마우스 오른쪽 버튼으로 클릭하고 새 창에서 링크를 열도록 선택합니다. 어떤 일이 일어나야 합니까? 내가 대부분의 사용자와 같다면 새 페이지에 링크를 직접 클릭한 것과 동일한 콘텐츠가 있을 것으로 기대합니다. 유일한 차이점은 페이지가 새 창에 표시된다는 것입니다. 그러나 웹 사이트가 단일 페이지 응용 프로그램(SPA)인 경우 이 경우에 대해 신중하게 계획하지 않는 한 이상한 결과가 나타날 수 있습니다.

SPA에서 일반적인 탐색 링크는 해시 표시(#)로 시작하는 조각 식별자인 경우가 많습니다. 링크를 직접 클릭해도 페이지가 새로고침되지 않으므로 JavaScript 변수에 저장된 모든 데이터가 유지됩니다. 그러나 새 탭이나 창에서 링크를 열면 브라우저가 페이지를 다시 로드하여 모든 JavaScript 변수를 다시 초기화합니다. 따라서 해당 데이터를 어떻게든 보존하기 위한 조치를 취하지 않는 한 해당 변수에 바인딩된 HTML 요소는 다르게 표시됩니다.

페이지 다시 로드 시 데이터 유지: 쿠키, IndexedDB 및 그 사이의 모든 것

페이지 다시 로드 시 데이터 유지: 쿠키, IndexedDB 및 그 사이의 모든 것
트위터

F5 키를 누르는 것과 같이 명시적으로 페이지를 다시 로드하는 경우에도 유사한 문제가 있습니다. 서버에서 변경 사항을 자동으로 푸시하는 메커니즘을 설정했기 때문에 F5 키를 누를 필요가 없다고 생각할 수도 있습니다. 그러나 내가 일반 사용자라면 여전히 페이지를 새로고침할 것입니다. 내 브라우저가 화면을 잘못 다시 칠한 것 같거나 최신 주식 시세를 가지고 있는지 확인하고 싶습니다.

API는 상태 비저장일 수 있지만 사람의 상호 작용은 그렇지 않습니다.

RESTful API를 통한 내부 요청과 달리 웹 사이트와 인간 사용자의 상호 작용은 상태 비저장이 아닙니다. 웹 사용자로서 귀하의 사이트 방문을 마치 전화 통화와 같은 세션으로 생각합니다. 내가 귀하의 영업 또는 지원 라인에 전화할 때 담당자가 통화 초기에 말한 내용을 기억하는 것과 같은 방식으로 브라우저가 내 세션에 대한 데이터를 기억할 것으로 기대합니다.

세션 데이터의 명백한 예는 내가 로그인했는지 여부와 로그인했다면 어떤 사용자로 로그인했는지입니다. 로그인 화면을 통과하면 사이트의 사용자별 페이지를 자유롭게 탐색할 수 있어야 합니다. 새 탭이나 창에서 링크를 열었을 때 다른 로그인 화면이 나타나면 사용자 친화적이지 않습니다.

또 다른 예는 전자 상거래 사이트의 장바구니 내용입니다. F5 키를 눌러 장바구니를 비우면 사용자가 화를 내기 쉽습니다.

PHP로 작성된 기존의 다중 페이지 애플리케이션에서 세션 데이터는 $_SESSION 슈퍼글로벌 배열에 저장됩니다. 그러나 SPA에서는 클라이언트 측 어딘가에 있어야 합니다. SPA에 세션 데이터를 저장하는 네 가지 주요 옵션이 있습니다.

  • 쿠키
  • 조각 식별자
  • 웹 스토리지
  • IndexedDB

4킬로바이트의 쿠키

쿠키는 브라우저에 있는 웹 저장소의 오래된 형태입니다. 그들은 원래 하나의 요청에서 서버에서 받은 데이터를 저장하고 후속 요청에서 서버로 다시 보내도록 의도되었습니다. 그러나 JavaScript에서는 쿠키를 사용하여 쿠키당 최대 4KB의 크기 제한까지 거의 모든 종류의 데이터를 저장할 수 있습니다. AngularJS는 쿠키 관리를 위한 ngCookies 모듈을 제공합니다. 모든 프레임워크에서 유사한 기능을 제공하는 js-cookies 패키지도 있습니다.

생성한 쿠키는 페이지 새로고침이든 Ajax 요청이든 모든 요청이 있을 때마다 서버로 전송됩니다. 그러나 저장해야 하는 기본 세션 데이터가 로그인한 사용자의 액세스 토큰인 경우 어쨌든 모든 요청에서 이를 서버로 보내길 원합니다. 이 자동 쿠키 전송을 Ajax 요청에 대한 액세스 토큰을 지정하는 표준 수단으로 사용하는 것은 자연스러운 일입니다.

이러한 방식으로 쿠키를 사용하는 것은 RESTful 아키텍처와 호환되지 않는다고 주장할 수 있습니다. 그러나 이 경우 API를 통한 각 요청은 여전히 ​​상태 비저장이며 일부 입력과 일부 출력이 있으므로 괜찮습니다. 입력 중 하나가 쿠키를 통해 재미있는 방식으로 전송되고 있다는 것입니다. 로그인 API 요청이 액세스 토큰을 쿠키로 되돌려 보내도록 할 수 있다면 클라이언트 측 코드에서 쿠키를 처리할 필요가 거의 없습니다. 다시 말하지만, 비정상적인 방식으로 반환되는 요청의 또 다른 출력일 뿐입니다.

쿠키는 웹 스토리지에 비해 한 가지 이점을 제공합니다. 로그인 양식에 "로그인 유지" 확인란을 제공할 수 있습니다. 의미 체계를 사용하면 선택하지 않은 상태로 두면 페이지를 새로고침하거나 새 탭이나 창에서 링크를 열 때 로그인 상태를 유지하지만 브라우저를 닫으면 로그아웃됩니다. 이것은 공유 컴퓨터를 사용하는 경우 중요한 안전 기능입니다. 나중에 살펴보겠지만 웹 스토리지는 이 동작을 지원하지 않습니다.

그렇다면 이 접근 방식은 실제로 어떻게 작동할까요? 서버 측에서 LoopBack을 사용하고 있다고 가정합니다. 기본 제공 사용자 모델을 확장하고 각 사용자에 대해 유지하려는 속성을 추가하여 Person 모델을 정의했습니다. REST를 통해 노출되도록 Person 모델을 구성했습니다. 이제 원하는 쿠키 동작을 달성하려면 server/server.js를 조정해야 합니다. 다음은 slc 루프백에 의해 생성된 것으로부터 시작하여 표시된 변경 사항과 함께 server/server.js입니다.

 var loopback = require('loopback'); var boot = require('loopback-boot'); var app = module.exports = loopback(); app.start = function() { // start the web server return app.listen(function() { app.emit('started'); var baseUrl = app.get('url').replace(/\/$/, ''); console.log('Web server listening at: %s', baseUrl); if (app.get('loopback-component-explorer')) { var explorerPath = app.get('loopback-component-explorer').mountPath; console.log('Browse your REST API at %s%s', baseUrl, explorerPath); } }); }; // start of first change app.use(loopback.cookieParser('secret')); // end of first change // Bootstrap the application, configure models, datasources and middleware. // Sub-apps like REST API are mounted via boot scripts. boot(app, __dirname, function(err) { if (err) throw err; // start of second change app.remotes().after('Person.login', function (ctx, next) { if (ctx.result.id) { var opts = {signed: true}; if (ctx.req.body.rememberme !== false) { opts.maxAge = 1209600000; } ctx.res.cookie('authorization', ctx.result.id, opts); } next(); }); app.remotes().after('Person.logout', function (ctx, next) { ctx.res.cookie('authorization', ''); next(); }); // end of second change // start the server if `$ node server.js` if (require.main === module) app.start(); });

첫 번째 변경은 '비밀'을 쿠키 서명 비밀로 사용하도록 쿠키 파서를 구성하여 서명된 쿠키를 활성화합니다. LoopBack이 쿠키 'authorization' 또는 'access_token' 중 하나에서 액세스 토큰을 찾기 때문에 이 작업을 수행해야 하지만 이러한 쿠키에 서명해야 합니다. 사실 이 요구는 무의미하다. 쿠키 서명은 쿠키가 수정되지 않았는지 확인하기 위한 것입니다. 그러나 액세스 토큰을 수정할 위험은 없습니다. 결국 일반 매개 변수로 서명되지 않은 형식으로 액세스 토큰을 보낼 수 있습니다. 따라서 서명된 쿠키를 다른 용도로 사용하지 않는 한 추측하기 어려운 쿠키 서명 비밀에 대해 걱정할 필요가 없습니다.

두 번째 변경 사항은 Person.login 및 Person.logout 메서드에 대한 일부 후처리를 설정합니다. Person.login 의 경우 결과 액세스 토큰을 가져와 서명된 쿠키 '권한 부여'로도 클라이언트에 보내고 싶습니다. 클라이언트는 2주 동안 쿠키를 유지할지 여부를 나타내는 자격 증명 매개변수인 Rememberme에 속성을 하나 더 추가할 수 있습니다. 기본값은 true입니다. 로그인 방법 자체는 이 속성을 무시하지만 후처리기는 이를 확인합니다.

Person.logout 의 경우 이 쿠키를 지우고 싶습니다.

StrongLoop API 탐색기에서 이러한 변경 결과를 바로 확인할 수 있습니다. 일반적으로 Person.login 요청 후에 액세스 토큰을 복사하여 오른쪽 상단의 양식에 붙여넣고 액세스 토큰 설정을 클릭해야 합니다. 그러나 이러한 변경 사항을 사용하면 이러한 작업을 수행할 필요가 없습니다. 액세스 토큰은 자동으로 쿠키 '인증'으로 저장되고 이후의 각 요청에서 다시 전송됩니다. Explorer가 Person.login의 응답 헤더를 표시할 때 JavaScript는 Set-Cookie 헤더를 볼 수 없기 때문에 쿠키를 생략합니다. 그러나 안심하십시오. 쿠키가 있습니다.

클라이언트 측에서 페이지를 새로고침하면 쿠키 '인증'이 존재하는지 확인할 수 있습니다. 그렇다면 현재 userId의 레코드를 업데이트해야 합니다. 아마도 이것을 하는 가장 쉬운 방법은 로그인 성공 시 별도의 쿠키에 userId를 저장하여 페이지를 새로고침할 때 검색할 수 있도록 하는 것입니다.

조각 식별자

SPA로 구현된 웹 사이트를 방문할 때 내 브라우저의 주소 표시줄에 있는 URL이 "https://example.com/#/my-photos/37"과 같이 보일 수 있습니다. 이것의 프래그먼트 식별자 부분인 "#/my-photos/37"은 이미 세션 데이터로 볼 수 있는 상태 정보의 모음입니다. 이 경우 ID가 37인 사진 중 하나를 보고 있을 것입니다.

프래그먼트 식별자 내에 다른 세션 데이터를 포함하기로 결정할 수 있습니다. 이전 섹션에서 쿠키 '인증'에 저장된 액세스 토큰을 사용하여 여전히 userId를 어떻게든 추적해야 한다는 점을 상기하십시오. 한 가지 옵션은 별도의 쿠키에 저장하는 것입니다. 그러나 또 다른 접근 방식은 프래그먼트 식별자에 이를 포함하는 것입니다. 내가 로그인하는 동안 내가 방문하는 모든 페이지에 "#/u/XXX"로 시작하는 조각 식별자가 있을 것이라고 결정할 수 있습니다. 여기서 XXX는 userId입니다. 따라서 이전 예에서 내 userId가 59인 경우 조각 식별자는 "#/u/59/my-photos/37"이 될 수 있습니다.

이론적으로 액세스 토큰 자체를 조각 식별자에 포함할 수 있으므로 쿠키나 웹 저장소가 필요하지 않습니다. 그러나 그것은 나쁜 생각일 것입니다. 그러면 내 액세스 토큰이 주소 표시줄에 표시됩니다. 카메라로 내 어깨 너머로 바라보는 사람은 누구나 화면의 스냅샷을 찍을 수 있고, 따라서 내 계정에 액세스할 수 있습니다.

마지막 참고 사항: 조각 식별자를 전혀 사용하지 않도록 SPA를 설정할 수 있습니다. 대신 "http://example.com/app/dashboard" 및 "http://example.com/app/my-photos/37"과 같은 일반 URL을 사용하며 서버는 이러한 URL에 대한 요청에 대한 응답으로 SPA. 그런 다음 SPA는 조각 식별자 대신 경로(예: "/app/dashboard" 또는 "/app/my-photos/37")를 기반으로 라우팅을 수행합니다. 탐색 링크의 클릭을 가로채고 History.pushState() 를 사용하여 새 URL을 푸시한 다음 평소와 같이 라우팅을 진행합니다. 또한 사용자가 뒤로 버튼을 클릭하는 것을 감지하기 위해 popstate 이벤트를 수신 대기하고 복원된 URL에 대한 라우팅을 다시 진행합니다. 이를 구현하는 방법에 대한 자세한 내용은 이 문서의 범위를 벗어납니다. 그러나 이 기술을 사용하면 분명히 조각 식별자 대신 경로에 세션 데이터를 저장할 수 있습니다.

웹 스토리지

웹 스토리지는 JavaScript가 브라우저 내에 데이터를 저장하는 메커니즘입니다. 쿠키와 마찬가지로 웹 저장소는 각 출처에 대해 분리되어 있습니다. 저장된 각 항목에는 이름과 값이 있으며 둘 다 문자열입니다. 그러나 웹 저장소는 서버에 완전히 보이지 않으며 쿠키보다 훨씬 더 큰 저장소 용량을 제공합니다. 웹 저장소에는 로컬 저장소와 세션 저장소의 두 가지 유형이 있습니다.

로컬 저장소 항목은 모든 창의 모든 탭에서 볼 수 있으며 브라우저를 닫은 후에도 유지됩니다. 이와 관련하여 만료 날짜가 아주 먼 미래의 쿠키처럼 작동합니다. 따라서 사용자가 로그인 양식에서 "로그인 유지"를 체크한 경우 액세스 토큰을 저장하는 데 적합합니다.

세션 저장소 항목은 생성된 탭 내에서만 볼 수 있으며 해당 탭을 닫으면 사라집니다. 이것은 쿠키의 수명을 다른 쿠키의 수명과 매우 다르게 만듭니다. 세션 쿠키는 여전히 모든 창의 모든 탭에서 볼 수 있습니다.

LoopBack용 AngularJS SDK를 사용하는 경우 클라이언트 측에서 자동으로 웹 저장소를 사용하여 액세스 토큰과 userId를 모두 저장합니다. 이것은 js/services/lb-services.js의 LoopBackAuth 서비스에서 발생합니다. RememberMe 매개변수가 false(일반적으로 "로그인 유지" 확인란이 선택되지 않음을 의미함)가 아닌 한 로컬 저장소를 사용하며, 이 경우 세션 저장소를 사용합니다.

결과적으로 "로그인 유지"를 선택하지 않고 로그인한 다음 새 탭이나 창에서 링크를 열면 거기에 로그인되지 않습니다. 아마도 로그인 화면이 보일 것입니다. 이것이 용인되는 행동인지 스스로 결정할 수 있습니다. 일부는 각각 다른 사용자로 로그인한 여러 탭을 가질 수 있는 좋은 기능이라고 생각할 수 있습니다. 또는 공유 컴퓨터를 더 이상 사용하는 사람이 거의 없다고 판단하여 "로그인 유지" 확인란을 모두 생략할 수 있습니다.

그렇다면 LoopBack용 AngularJS SDK를 사용하기로 결정했다면 세션 데이터 처리는 어떻게 될까요? 서버 측에서 이전과 동일한 상황이 있다고 가정합니다. 사용자 모델을 정의하고 사용자 모델을 확장했으며 REST를 통해 Person 모델을 노출했습니다. 쿠키를 사용하지 않으므로 앞에서 설명한 변경 사항이 필요하지 않습니다.

클라이언트 측에서 가장 바깥쪽 컨트롤러의 어딘가에 현재 로그인한 사용자의 userId를 보유하는 $scope.currentUserId와 같은 변수가 있거나 사용자가 로그인하지 않은 경우 null이 있을 수 있습니다. 그런 다음 페이지 다시 로드를 올바르게 처리하려면 해당 컨트롤러의 생성자 함수에 다음 명령문을 포함하기만 하면 됩니다.

 $scope.currentUserId = Person.getCurrentId();

정말 쉽습니다. 'Person'이 아직 없는 경우 컨트롤러의 종속 항목으로 추가합니다.

IndexedDB

IndexedDB는 브라우저에 많은 양의 데이터를 저장하기 위한 새로운 기능입니다. 이를 사용하여 직렬화할 필요 없이 객체 또는 배열과 같은 모든 JavaScript 유형의 데이터를 저장할 수 있습니다. 데이터베이스에 대한 모든 요청은 비동기식이므로 요청이 완료되면 콜백을 받습니다.

IndexedDB를 사용하여 서버의 데이터와 관련이 없는 구조화된 데이터를 저장할 수 있습니다. 예를 들어 캘린더, 할 일 목록 또는 로컬에서 플레이하는 저장된 게임이 있습니다. 이 경우 응용 프로그램은 실제로 로컬 응용 프로그램이고 웹 사이트는 해당 응용 프로그램을 전달하는 수단일 뿐입니다.

현재 Internet Explorer와 Safari는 IndexedDB를 부분적으로만 지원합니다. 다른 주요 브라우저는 완전히 지원합니다. 그러나 현재 한 가지 심각한 제한은 Firefox가 개인 탐색 모드에서 IndexedDB를 완전히 비활성화한다는 것입니다.

IndexedDB 사용의 구체적인 예로 Pavol Daniš의 슬라이딩 퍼즐 응용 프로그램을 가져와 각 이동 후 첫 번째 퍼즐인 AngularJS 로고를 기반으로 하는 Basic 3x3 슬라이딩 퍼즐의 상태를 저장하도록 조정해 보겠습니다. 페이지를 새로고침하면 이 첫 번째 퍼즐의 상태가 복원됩니다.

이러한 변경 사항으로 저장소의 분기를 설정했으며 모두 app/js/puzzle/slidingPuzzle.js에 있습니다. 보시다시피 IndexedDB의 기본적인 사용법도 상당히 관련되어 있습니다. 아래 하이라이트만 보여드릴게요. 먼저 페이지 로드 중에 함수 복원이 호출되어 IndexedDB 데이터베이스를 엽니다.

 /* * Tries to restore game */ this.restore = function(scope, storekey) { this.storekey = storekey; if (this.db) { this.restore2(scope); } else if (!window.indexedDB) { console.log('SlidingPuzzle: browser does not support indexedDB'); this.shuffle(); } else { var self = this; var request = window.indexedDB.open('SlidingPuzzleDatabase'); request.onerror = function(event) { console.log('SlidingPuzzle: error opening database, ' + request.error.name); scope.$apply(function() { self.shuffle(); }); }; request.onupgradeneeded = function(event) { event.target.result.createObjectStore('SlidingPuzzleStore'); }; request.onsuccess = function(event) { self.db = event.target.result; self.restore2(scope); }; } };

request.onupgradeneeded 이벤트는 데이터베이스가 아직 존재하지 않는 경우를 처리합니다. 이 경우 개체 저장소를 만듭니다.

데이터베이스가 열리면 restore2 함수가 호출되어 주어진 키(이 경우 실제로 'Basic' 상수가 됨)가 있는 레코드를 찾습니다.

 /* * Tries to restore game, once database has been opened */ this.restore2 = function(scope) { var transaction = this.db.transaction('SlidingPuzzleStore'); var objectStore = transaction.objectStore('SlidingPuzzleStore'); var self = this; var request = objectStore.get(this.storekey); request.onerror = function(event) { console.log('SlidingPuzzle: error reading from database, ' + request.error.name); scope.$apply(function() { self.shuffle(); }); }; request.onsuccess = function(event) { if (!request.result) { console.log('SlidingPuzzle: no saved game for ' + self.storekey); scope.$apply(function() { self.shuffle(); }); } else { scope.$apply(function() { self.grid = request.result; }); } }; }

그러한 레코드가 존재하는 경우 해당 값은 퍼즐의 그리드 배열을 대체합니다. 게임 복원에 오류가 있으면 이전과 같이 타일을 섞습니다. 그리드는 타일 개체의 3x3 배열이며 각 개체는 상당히 복잡합니다. IndexedDB의 가장 큰 장점은 직렬화하지 않고도 이러한 값을 저장하고 검색할 수 있다는 것입니다.

$apply 를 사용하여 모델이 변경되었음을 AngularJS에 알리므로 보기가 적절하게 업데이트됩니다. 업데이트가 DOM 이벤트 핸들러 내에서 발생하기 때문에 AngularJS가 변경 사항을 감지할 수 없습니다. IndexedDB를 사용하는 모든 AngularJS 응용 프로그램은 이러한 이유로 $apply를 사용해야 할 것입니다.

사용자에 의한 이동과 같이 그리드 배열을 변경하는 작업 후에는 업데이트된 그리드 값을 기반으로 적절한 키로 레코드를 추가하거나 업데이트하는 save 함수가 호출됩니다.

 /* * Tries to save game */ this.save = function() { if (!this.db) { return; } var transaction = this.db.transaction('SlidingPuzzleStore', 'readwrite'); var objectStore = transaction.objectStore('SlidingPuzzleStore'); var request = objectStore.put(this.grid, this.storekey); request.onerror = function(event) { console.log('SlidingPuzzle: error writing to database, ' + request.error.name); }; request.onsuccess = function(event) { // successful, no further action needed }; }

나머지 변경 사항은 적절한 시간에 위의 함수를 호출하는 것입니다. 모든 변경 사항을 보여주는 커밋을 검토할 수 있습니다. 세 가지 고급 퍼즐이 아니라 기본 퍼즐에 대해서만 복원이라고 합니다. 우리는 3개의 고급 퍼즐이 api 속성을 가지고 있다는 사실을 이용하므로 일반 셔플을 수행합니다.

고급 퍼즐도 저장하고 복원하려면 어떻게 해야 합니까? 그것은 약간의 구조 조정이 필요합니다. 각 고급 퍼즐에서 사용자는 이미지 소스 파일과 퍼즐 크기를 조정할 수 있습니다. 따라서 이 정보를 포함하도록 IndexedDB에 저장된 값을 향상시켜야 합니다. 더 중요한 것은 복원에서 업데이트할 방법이 필요하다는 것입니다. 이미 긴 예제에 대해서는 조금 많습니다.

결론

대부분의 경우 웹 저장소는 세션 데이터를 저장하는 데 가장 좋습니다. 모든 주요 브라우저에서 완벽하게 지원되며 쿠키보다 훨씬 더 큰 저장 용량을 제공합니다.

서버가 이미 쿠키를 사용하도록 설정되어 있거나 모든 창의 모든 탭에서 데이터에 액세스할 수 있어야 하지만 브라우저가 닫힐 때 쿠키가 삭제되도록 하려는 경우에도 쿠키를 사용합니다.

사용자가 보고 있는 사진의 ID와 같이 해당 페이지에 특정한 세션 데이터를 저장하기 위해 이미 조각 식별자를 사용하고 있습니다. 프래그먼트 식별자에 다른 세션 데이터를 포함할 수 있지만 웹 저장소나 쿠키에 비해 실제로 이점을 제공하지는 않습니다.

IndexedDB를 사용하려면 다른 기술보다 훨씬 더 많은 코딩이 필요할 수 있습니다. 그러나 저장하는 값이 직렬화하기 어려운 복잡한 JavaScript 객체이거나 트랜잭션 모델이 필요한 경우 가치가 있을 수 있습니다.