Back/Forward Cache (A.K.A. bfcache)

sejin kim·2022년 7월 17일
1
post-thumbnail

'bfcache는 또 뭔가요?'

사용자 경험을 향상시키기 위한 작업과 관련해 의견을 나누던 중, '뒤로가기로 이전 페이지에 돌아올 때 페이지가 새로 로딩돼서 불편하다'는 문제가 주요한 화두가 된 적이 있습니다. 요컨대 내용은 아래와 같았습니다.


  • 다른 사이트(타사)는 다른 페이지에 이동했다가 돌아오더라도 이전 페이지 상태가 그대로 깔끔하게 보존되어 있다.
  • 그런데 우리 사이트는 페이지도 새로고침되고, 스크롤 위치도 최상단에 잠시 머물러 있다가 이전 위치로 복원되는 게 눈에 보이는 등 영 느리고 불편하다.

개발자들간의 논의가 아니었기 때문에, 그것이 우리 사이트에서만 발생하는 현상은 아니며 bfcache의 적용 여부에 따른 차이라고 설명을 해야 했습니다. 다만 bfcache가 '아무튼 캐시'이기는 한데, 그냥 그렇게 설명해서는 당연히 아무도 이해하지 못할 것이기 때문에 어느 정도의 기술적인 설명이 불가피했습니다.

이미 과거에 HTTP Caching 같은 것들은 물론이고, 페이지 렌더링 방식 - SSR, CSR 같은 내용까지 설명이 되어 있던 상황인지라 'bfcache는 브라우저에서 최적화 목적으로 페이지 상태를 통째로 저장해 놓고 복원해 주는 것을 의미한다'는 내용을 골자로 사내 WIKI 문서까지 작성하면서 설명했습니다. (지금 이 글도 그 때 작성했던 글을 바탕으로 합니다.)

bfcache는 모든 상황에서 항상 적용되도록 할 수는 없고, 브라우저 엔진이나 최적화 상태 등 상황에 따라 적용이 될 수도, 안 될 수도 있는 등 개발자가 완벽하게 제어할 수 없는 영역이라는 내용이 공유된 뒤에야 비로소 이것이 우리 사이트만의 문제는 아니며, 다르게 접근해야 한다는 것을 이해할 수 있었습니다.

그러나, 통상 비교 대상이 될 만한 다른 사이트에서는 뒤로/앞으로 가기 탐색시 크게 거슬릴 만한 상황이 없었던 것도 사실이었습니다. 이는 그만큼 페이지 탐색, 재방문에 대한 고민이 바탕하고 있었기 때문이었을 것입니다.

하지만 그런 배경은 개발자들이나 고려할 수 있는 부분이지 비개발자, 일반 사용자의 입장에서는 아무래도 알기 어렵고 알아야 할 이유도 없는 것이었습니다.

즉, 문제가 있었던 것은 맞고, 그렇기 때문에 bfcache가 적용되지 않은 상황에서는 흐름이 자연스럽지 않아 불편함이 체감될 수 있으므로 해결 방안에 대한 고민이 필요하겠다는 합의에 이르게 되었습니다.

가급적 캐시의 적중률을 높이고, 페이지가 복원되더라도 이슈가 없게끔 하는 식으로 페이지를 bfcache에 최적화시켜야 한다는 과제가 주어지게 되었습니다.






bfcache의 기본 개념

Back/Forward Cache, 즉 bfcache는 사용자의 탐색Navigation 경험을 향상시키기 위한 브라우저 레벨의 최적화 기능을 일컫습니다. 다른 페이지로 이동하기 직전에 힙 메모리 영역까지 포함한 페이지의 전체 스냅샷을 메모리에 저장해 두고, 페이지에 다시 되돌아오려고 할 때 캐시로 페이지를 즉시 복원하게 됩니다.

특히 모바일에서 두드러지게 체감될 수 있는 부분인데, 뒤로 가기/앞으로 가기 동작을 통해 이전/다음 페이지로 이동했을 때 뭔가 페이지가 군더더기 없이 즉시 로드된 것 같다면 그건 bfcache에 의해 페이지가 복원된 경우일 수 있습니다.



말 그대로 스냅샷이기 때문에, 마치 동영상을 일시정지했다가 다시 재생하는 것처럼 페이지를 복원하는데, 페이지 리소스를 다시 다운로드하지 않으므로 네트워크 요청도 발생하지 않습니다. 스크롤 위치 또한 History.scrollRestoration을 통해 복원해주지 않아도 이미 위치가 기억되어 있습니다. 심지어 TimerPromise도 일시 정지되었다가, 복원시 이어서 실행됩니다. Task Queue에서 대기 중이었던 작업들이 모두 보존되었다가 다시 처리되는 것입니다.

하지만, 아래와 같이 개발자 입장에서는 여러모로 난감할 만한 특징들을 가지고 있기도 합니다.


  • 캐시를 완전히 비활성화할 수는 있지만, 무조건 반드시 캐시되도록 할 수는 없습니다. 캐시를 방해하는 요소를 회피하는 식의 최적화를 통해 적중률을 향상시킬 수는 있지만, 기본적으로 브라우저에 의해 제어됩니다.
  • 브라우저 종류 및 버전에 따라 bfcache를 적극적으로 적용하려는지에 대한 여부도 모두 다릅니다. 사용자 경험을 고려하면 최대한 다양한 환경과 상황에서 작동하는 것이 바람직하겠지만, 현재 시점까지도 제각기 캐시되는 상황이나 조건이 모두 달라 예측이 어렵습니다.
  • 캐시가 메모리에 저장되어 있는 시간도 브라우저마다 다릅니다. Chrome 같은 경우는 3분 간 유지되는데, 당연하지만 이것도 상황에 따라서는 보장되지 않을 수 있습니다.
  • pageshowpagehide 이벤트를 통해 bfcache를 관찰할 수 있습니다. 하지만 페이지가 bfcache에 의해 복원되었는지 여부는 정확히 알 수 있는 반면, 페이지 이동시에는 현재 페이지를 캐시'하려고' 했다는 사실만 알 수 있습니다.
  • 실제로 페이지가 새로 로드되지 않기 때문에(load 이벤트가 발생하지 않습니다), Google Analytics(GA)와 같이 사이트 성과 측정/분석 관련 스크립트가 있다면 페이지뷰(PV) 등이 수집되지 않아 누락이 발생할 수 있습니다. 때문에 이 경우 페이지가 복원되었을 때 수동으로 별도 호출하여 이벤트를 발생시켜야 할 수 있습니다. 성능 측정 데이터도 왜곡될 수 있으므로 메트릭을 세분화하는 식으로 대응해야 합니다.
  • 마찬가지로 이전 페이지에 돌아왔지만 민감한 사용자 정보 등이 그대로 노출된다거나, 어떤 초기화 기능이 동작하지 않아 페이지 상태가 적절히 갱신되지 않는 등의 문제가 있을 수 있습니다. 때문에 이런 경우에는 location.reload()를 통해 페이지를 강제로 새로고침하거나, 부분적인 업데이트 동작을 수행해야 할 수도 있습니다.





브라우저 호환성

브라우저에 따라 지원 여부가 모두 다릅니다. 데스크톱/모바일 여부나, same-site 여부, HTTPS 적용 여부 등에 따라서도 차이가 있을 수 있습니다.


  • Firefox, Safari의 경우 데스크톱과 모바일 모두 과거부터 기본적으로 지원해 왔습니다. MDN, WebKit Blog
  • Chrome의 경우 v86 부터 same-site인 경우에 한해 모바일(for Android)에서 점진적으로 적용하다가, v96 에 이르러서는 데스크톱/모바일 모두 적용하고 있습니다. chromestatus
  • Samsung Internet은 v15 부터 지원합니다. Samsung Developers

하지만 실제 사례를 통해 확인해 본 바에 의하면, 지원 범위에 포함되더라도 구체적으로 캐시가 되는 상황이나 조건은 모두 달랐습니다. 예를 들면 유사한 상황에서도 Android Chrome에선 동작하지 않는데, 같은 Chromium 엔진 기반인 Samsung Internet에선 동작하는 경우가 있기도 했습니다. 브라우저마다 구체적인 구현 내용이나 방향이 조금씩 다르기 때문일 것입니다.

Safari에서는 과거 '페이지 로드가 아직 완료되지 않았을 수도 있고, 도중에 오류가 발생했을 수도 있으며, 페이지가 다른 URL로 리디렉션시키기 위해 존재하는 페이지였을 수도 있다'는 내용들을 사례로 들면서, 페이지가 일시 정지가 가능한 상태인지 파악하기가 어렵기 때문에 항상 캐시되도록 할 수는 없음을 설명하기도 했습니다.

그러므로 bfcache에 부적격하게끔 만드는 요소를 최대한 회피하여 최적화한다고 하더라도, 기본적으로 동작이 일관되지 않은 탓에 개발자의 입장에서는 캐싱 조건과 원리를 파악하는 데에 어려움이 있게 됩니다.

다만 이러한 문제에 대한 인식이 없었던 것은 아니어서, chromestatus 문서를 보면 bfcache가 실패한 이유에 대한 정보를 제공하는 NotRestoredReason API에 대한 논의가 이루어졌음을 알 수 있습니다.






PageTransitionEvent

다른 페이지로 이동할 때, 그리고 페이지에 다시 돌아왔을 때 Page Lifecycle APIPageTransitionEvent를 통해 bfcache를 관찰할 수 있습니다.


window.addEventListener('pageshow', (event) => {
    if (event.persisted) {
        console.log('This page was restored from the bfcache.');
    } else {
        console.log('This page was loaded normally.');
    }
});

pageshow 이벤트는 페이지가 새로 로드될 때와 복원될 때 모두 트리거됩니다. 이때 이벤트의 persisted 속성을 통해서 페이지가 bfcache에 의해 복원되었는지를 알 수 있습니다.

이벤트가 발생하기 직전 크로미움 기반 브라우저에서는 resume 이벤트도 발생하지만, 해당 이벤트는 백그라운드에서 정지되었던 탭에 다시 돌아왔다거나 하는 경우에도 발생하므로 bfcache를 관찰하기에는 적합하지 않습니다.


window.addEventListener('pagehide', (event) => {
    if (event.persisted) {
        console.log('This page *might* be entering the bfcache.');
    } else {
        console.log('This page will unload normally and be discarded.');
    }
});

반면 pagehide 이벤트는 사용자가 다른 페이지로 이동중일 때(unload 될 때) 트리거됩니다. 이때도 persisted 속성을 통해 bfcache를 관찰할 수 있지만, true라고 해도 페이지가 성공적으로 캐시되었는지는 장담할 수 없습니다. 위에서도 언급했듯 단지 브라우저가 페이지를 캐시하려고 했다는 사실만 알 수 있습니다. 대신 false라면 확실히 캐시되지 않았다는 것을 의미합니다.






bfcache에 적합하게 페이지 최적화하기

개발자가 완벽히 제어할 수는 없지만, 최대한 최적화를 시도해볼 수 있는 방법은 존재합니다. 무엇이 페이지를 bfcache에 부적격하게 만드는지를 이해하고, 이를 회피하여 개발하면 캐시 적중률을 높일 수 있습니다.



unload 이벤트 금지

기본적으로 unload 이벤트 리스너가 페이지에 존재하기만 해도 브라우저는 페이지가 bfcache에 부적격하다고 판단합니다.

unload 이벤트는 사용자가 페이지를 떠나면 더 이상 페이지가 존재하지 않을 것이라는 가정 하에 설계된 이벤트입니다. 하지만 모바일에서는 탭 닫기 동작 등에서 발생하지 않는 등 실질적으로 세션 종료를 판단하는 기준이 될 수 없었고, 이런 문제로 인해 모던 브라우저에서는 pagehide로 대체해야 하는 레거시로 취급됩니다. Legacy lifecycle APIs to avoid

때문에 unload 이벤트가 발생하면 브라우저는 두 가지 방법 중 하나를 택해야 했는데, bfcache를 포기하거나, unload 이벤트를 무시하고서라도 캐시를 시도하는 것이었습니다. 전자는 사용자 경험에 좋지 않고, 후자는 이벤트 자체가 부정되는 문제이니 어느 것도 좋은 해결책은 아니었겠지만, 결국 Firefox는 전자를, SafariChrome은 후자의 방법을 택하게 됩니다.

한편 사용자가 페이지를 떠날 때 '정말 나가시겠습니까? 저장되지 않은 내용은 사라집니다' 같은 경고 메시지를 띄울 때 사용하는 beforeunload 이벤트는 ChromeSafari에서 bfcache에 영향을 주지는 않는 반면, Firefox에서는 여전히 부적격하다고 판단합니다.

그러므로 아래와 같이 필요한 경우에 한해 조건부로 이벤트를 추가하고 제거하는 식으로 사용할 것을 권장하고 있습니다.


function beforeUnloadListener(event) {
    event.preventDefault();
    return event.returnValue = '정말 나가시겠습니까?';
};

// 페이지에 저장하지 않은 변경 사항이 있는 경우 등에만 실행
onPageHasUnsavedChanges(() => {
    window.addEventListener('beforeunload', beforeUnloadListener);
});

// 문제가 해결되었다면 이벤트 리스너 제거
onAllChangesSaved(() => {
    window.removeEventListener('beforeunload', beforeUnloadListener);
});


window.opener 참조 제거

window.open() 또는 앵커 태그의 target="_blank" 속성으로 새 창/탭에서 열린 페이지는 자신의 부모 페이지에 대한 참조 window.opener를 가지는데, 이것이 null이 아닐 경우 bfcache에 부적격한 것으로 판단됩니다.

따라서 rel="noopener"를 명시하여 window.opener가 생성되지 않도록 해야 하는데, 이는 Tabnabbing 보안 취약점 공격과도 연관된 부분이기도 하므로 항상 opener는 null이 되어야 한다고 생각하는 편이 바람직합니다.



페이지 이동 직전 열려 있는 연결 닫기

아래와 같은 경우에는 브라우저가 bfcache를 시도하지 않게 될 수 있습니다. 반드시 그런 것은 아니지만, 문제의 가능성이 있어 최대한 보수적으로 동작하도록 의도한 부분이라고 할 수 있습니다. 때문에 현재로서는 캐시 직전/직후에 발생하는 PageTransitionEvent를 활용하여 작업을 미리 중단/재개하거나 제거/추가하는 편이 바람직합니다.


  • Fetch, XMLHttpRequest가 pending 상태일 경우
  • WebSocket, WebRTC, IndexedDB 연결이 열려 있는 경우
  • 그 외의 특정 API : Chromium source code





bfcache 비활성화하기

바람직하지는 않지만, bfcache를 비활성화하는 방법은 있습니다. 최상위 페이지의 Response header에 캐시를 거부하는 Cache-Control 디렉티브를 명시하는 것입니다.


Cache-Control: no-store

그러나 이것은 HTTP Caching 맥락에서의 디렉티브이지, 사실 bfcache와는 직접적인 연관은 없는 부분이라는 점에서 의아함을 느낄 수 있는 부분입니다. 다만 어쨌든 현재는 Cache-Control 헤더가 bfcache까지 포괄하고 있으므로, 저렇게 설정하게 되면 다른 것들도 캐시되지 않게 될 것이므로 의도하지 않은 변경이 발생하게 됩니다.

그래서 필요한 경우 개발자가 직접 bfcache를 명시적으로 비활성화할 수 있도록 해야 한다는 내용의 이슈가 WHATWG의 HTML Living Standard에 관한 토론에서 제기된 적이 있습니다. 그러나 몇 년이 지난 현재까지도 마땅한 방안이 존재하지 않는 상황입니다.






bfcache 테스트하기

Chrome DevTools에서 bfcache를 재현해 보려면 먼저 아래와 같은 플래그 설정을 확인해야 합니다. 단순 활성/비활성화가 아니라 캐싱 조건도 어느 정도 제어할 수도 있는 것을 알 수 있습니다.


chrome://flags


아래와 같이 특정 페이지가 bfcache에 적격한지, 어떤 요소가 부적격하게 만드는지 등을 어느 정도 확인해볼 수 있는 도구도 제공됩니다.



문제는 세 유형으로 분류되어 보고됩니다.

  • Actionable : 문제를 수정하면 캐싱을 활성화 할 수 있음
  • Pending Support : 아직 브라우저(Chrome)가 기능을 지원하지 않아 캐싱이 불가함
  • Not Actionable : 페이지에서 제어할 수 없는 요소로 인해 캐싱이 불가함





참고 링크

profile
퇴고를 좋아하는 주니어 웹 개발자입니다.

0개의 댓글