Bfcache 활성화 - Bfcache hwalseonghwa

좌충우돌 프론트엔드 이야기

저희 카모아 서비스는 Web(PC, Mobile)/Android/iOS를 모두 지원해서 최대한 많은 사람들이 저렴하게 렌트카를 이용하실 수 있도록 노력하고 있습니다!

프론트엔드 개발자로서는 앱이냐 웹이냐, 모바일이냐 PC냐, 심지어는 어떤 브라우저 엔진이냐에 따라서 정말 다양한 케이스를 마주치게 되기도 하는데요.

물론 기본적인 크로스 브라우징 처리를 하기는 한다지만, 간혹 예상치 못한 동작을 보이는 케이스가 가끔씩 출몰하는 덕분에(?) 카모아 개발자들은 매일매일 강제로 성장하는 중입니다.

이번 글에서는 저희를 한층 성장시킨 버그 하나를 소개드리고, 새롭게 배운 Back-Forward Cache에 대해 알아본 내용들도 공유드리려고 합니다.

닉네임이 안바껴요! 근데 아이폰에서만ㅎ

어느 날, Mac / iOS Safari에서만 발생하는 버그가 하나 공유되었는데 한마디로 ‘닉네임 수정이 안돼요’ 라는 내용이었습니다.

원래 카모아에서 닉네임을 수정하는 정상적인 플로우는 다음과 같습니다.

Safari 브라우저에서는 위 이미지 기준 마지막 화면에서 변경 전 닉네임이 노출이 되는 버그였죠.

저는 원인을 찾기 위해 버그가 재현되기 위한 전제조건을 최대한 좁혀보았습니다.

  1. Safari 브라우저 (Mac/iOS 전부)
  2. 페이지 간 뒤로가기 동작 발생 (‘설정 완료’ 버튼이 있는 뷰는 모달이어서 해당되지 않았음)

추가적으로 뒤로가기 했을 때 나오는 페이지에서는 JS가 실행되지 않고 이전에 렌더링 된 UI(닉네임도!)를 그대로 보여주고 있어서, 전체 페이지가 캐싱된 것으로 결론을 내렸습니다.

이후 ‘Safari page cache’ 키워드부터 시작해 구글링에 착수했습니다.

진짜 범인은? Back-Forward 캐시(bfcache)

범인의 이름은 Back/Forward Cache(bfcache).

뒤로가기/앞으로가기 동작을 할 때, 전체 페이지(JS 힙 메모리까지)를 캐싱해두는 역할을 하는 브라우저 내 캐시 중 하나였습니다.

문제가 된 상황에서는 bfcache에 의해 캐싱된 페이지를 노출했기 때문에
닉네임을 요청하는 스크립트를 다시 실행하지 않고 그대로 보여줬던 것입니다.

원인은 알겠고, 해결은 어떻게?

마음 같아서는 bfcache를 그냥 비활성화하고 싶지만, 사용자의 단말기를 뺏어서 직접 브라우저 설정을 바꾸지 않는 이상 어쨌거나 저희는 이것과 공존하는 방식을 찾아야 합니다.

만약 bfcache의 load/unload 시점만 잡아낼 수만 있다면, 마이페이지의 닉네임과 같이 필요한 정보만 재요청하면서 나머지 페이지는 캐싱된 것을 사용할 수 있게 되니까 성능상 이점까지 챙길 수 있는 오히려 좋은 해결책이 될 거라고 생각했습니다.

그리고 다행히 페이지가 캐시로부터 로드된 것인지 알아낼 수 있는 방법이 있었습니다.

pageshow / pagehide 이벤트를 활용한 캐싱 감지

이것 역시 구글링해 본 결과, 가장 보편적인 해결방안은 pageshow/pagehide 이벤트를 이용하는 것입니다.

해당 이벤트 객체에는 persisted라는 boolean 프로퍼티가 있는데,
이를 통해 캐시에 저장되는지(pagehide), 캐시로부터 복원되는지(pageshow) 구분할 수가 있는 것이죠.

window.addEventListener('pageshow', (e) => {
if(e.persisted) {
console.log("Nice to meet you. I'm from bfcache.");
}
}
window.addEventListener('pagehide', (e) => {
if(e.persisted) {
console.log("Bye.. I have to enter bfcache");
}
}

그런데.. 왜 사파리에서만 캐싱이 됐을까

문제의 원인과 해결책을 알아냈지만, 아직 끝이 아닙니다!

그냥 여기서 ‘새로운 지식을 얻었다 헤헤’ 하고 끝내기엔 한가지 찝찝한 구석이 있는데, 바로 사파리에서만 해당 캐싱이 발생하고 있다는 점이었습니다.

사파리에서만 bfcache가 지원되고 있어서 그런걸까요?

web.dev 문서에 따르면 Safari/Firefox는 예전부터 bfcache를 지원하고 있었다고 합니다.

‘그럼 그렇지!’ 하면서 파이어폭스에서 테스트해보니 역시 같은 캐싱이 일어났습니다.

그런데 문제는 그 다음 줄,

버전 86부터 Chrome은 소수의 사용자를 위해 Android에서 사이트 간 탐색을 위해 bfcache를 활성화했습니다. 후속 릴리스에서는 추가 지원이 천천히 롤아웃되었습니다. 버전 96부터 bfcache는 데스크톱 및 모바일의 모든 Chrome 사용자가 사용할 수 있습니다.

“버전 96부터 bfcache는 데스크톱 및 모바일의 모든 Chrome 사용자가 사용할 수 있습니다.”

..

“bfcache는 데스크톱 및 모바일의 모든 Chrome 사용자가 사용할 수 있습니다.”

.

bfcache는 모든 Chrome 사용자가 사용할 수 있습니다.

??!

크롬도 bfcache를 지원한다니, 근데 왜 우리 서비스는 안됐던 거지?

혼란스러움도 잠시 다행히 금방 결론을 찾을 수 있었습니다.

크롬은 언제 bfcache를 적용할까?

결론부터 말하자면, 크롬은 bfcache에 최적화된 사이트만 캐싱하려고 노력하는 것으로 보입니다.

web.dev 문서에는 bfcache를 최적화하는 방법에 대한 설명도 있으니 필요하신 분은 참고해주세요!

가장 중요한 우리 사이트가 얼마나 최적화 되어 있는지 알아보기 위한 방법으로는 프론트엔드 개발자의 빛과 소금, 크롬 개발자 도구를 이용하는 것이 있습니다.

개발자 도구 > Application > Back/forward cache

이 곳에서 현재 bfcache가 적용되는 페이지인지 테스트해볼 수 있고, 안된다면 어떤 이유에서 캐싱을 적용하지 않는지 친절하게 설명까지 달아줍니다.

아래 이미지에서 Pending Support, Not Actionable 등의 최적화를 위한 힌트를 주고 있는 것을 보실 수 있습니다.

Bfcache 활성화 - Bfcache hwalseonghwa

Back/forward cache의 약자로 브라우저 최적화 중 하나이다. 브라우저에서 bfcache로 어떻게 최적화 하는지 알고 있어야 BFCache를 최대한 사용하면서 navigating(뒤로/앞으로 가기)시에도 기존 코드가 잘 호출되게끔 할 수 있다.

마주할 수 있는 문제상황을 예시로 들면 아래와 같다.

문제 상황1. (뒤로가기/앞으로가기시) BFCache로 인해 로그를 전송하는 코드가 호출 되지 않음.

문제 상황2. (뒤로가기/앞으로가기시) 페이지 초기화를 위한 코드가 불리지 않음. 등등

아래 참고로 링크를 달아둔 Web.dev의 블로그 내용을 거의 이해하며 번역했다고 보면 된다.

해당 글에서 잘 알아야 할 핵심은BFCache 작동을 판단할 수 있는 API 부분과 BFCache를 위해 페이지 최적화 하는 팁 두가지 인것 같다. 나와 같이 BFCache가 뭔지 몰랐던 분들에게 도움이 되면 좋겠다.

정의: The bfcache is a snapshot of the entire page in memory(including the JavaScript heap)

- 사용자가 브라우저 내에서 뒤로가기/앞으로가기 했을 경우 이전 페이지를 전체 캐싱해서 in memory로(자바스크립트 heap 영역까지) 가지고 있는 것을 말한다.

- 전체 페이지를 in memory에 저장하므로 뒤로가기/앞으로가기 했을때 복구가 훨씬 빠르고, 그러다보니 사용자 경험을 굉장히 향상시킬 수 있다.

BFCache 사용 vs BFCache 사용하지 않은 경우

당연하지만, BFCache를 사용하는 것이 훨씬 빠른것을 볼 수 있다.

사용한 경우

사용하지 않은 경우

즉각적으로 이전 페이지 로드(메모리에서 복원)

페이지 로드를 위한 네트워크 요청

네트워크 요청 X

리소스 (다시) 다운로드

document (다시) 파싱

JS (다시) 실행

- navigation 속도를 높일 뿐만아니라 네트워크 요청을 덜하므로 데이터 사용을 줄일 수 있다.

- 크롬 사용 통계 데이터에 따르면, 데스크탑의 경우 10% 모바일의 경우 20%가 네비게이션에서 사용됨을 알 수 있다.

- bfcache 를 통해 데이터 전송과 페이지 로딩을 위한 시간을 줄일 수 있다.

BFCache vs HTTP Cache

- HTTP 캐시는 이전에 만들어진 요청에 대한 응답만 캐싱함.

- 페이지 로딩에 필요한 최적화를 HTTP캐시로 모두 하기는 어렵기 때문에

- BFCache를 사용하지 않고, 아무리 최적화를 잘 했다고 하더라도 BFCache를 사용한 경우가 무조건 빠르다.

브라우저별 BFCache 지원 상황

- Firefox, Safari → 몇 년전 부터 지원해 왔음(desktop, mobile 모두)

- Chrome

v86: Android(일부), cross-site navigation만 지원

v87: Android(전부), same-site navigation까지 지원 예정

BFCache 작동을 판단할 수 있는 API 🌟

pageshow / pagehide (Page Transition API)

- 거의 모든 브라우저에서 지원하는 이벤트(BFCache만큼 오래된 이벤트)

- pageshow

- 페이지가 처음 로드될 때, 그리고 bfcache에서 페이지가 복원 될 때 마다 load 이벤트가 불린 직후에 실행된다.

- persisted property를 이용해서 BFCache로 복구되었는지 확인 할 수 있다.

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

- pagehide

- 페이지가 정상적으로 로드되지 않거거나, 브라우저가 페이지를 bfcache에 저장하려고 할 때 발생한다.

- persisted property가 false면 bfcache에 저장되지 않았다는 뜻이다.

- 브라우저가 페이지를 캐시하려고 했지만 캐시할 수 없게 만드는 요인이 있을 수 있기 때문에,

- true라고 항상 bfcache에 캐싱되었다고 할 수는 없다. (=브라우저가 bf캐싱을 하려고 했다는 '의도'만 알 수 있다는 뜻)

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

freeze / resume (Page Lifecycle API)

아래 이미지는 페이지 라이프 사이클 이벤트(page lifecycle events). 크롬에만 있는 이벤트라고 생각 하면 된다.

Bfcache 활성화 - Bfcache hwalseonghwa

- 크롬 기반 브라우저에서만 지원되는 이벤트. (새로 생긴 이벤트)

- resume

- BFCache로 들어가고 나올때 호출되는 이벤트.(그 외의 상황에서도 호출되기도 함)

➡️ 어떤 상황에서도 호출될까? CPU 사용량을 최소화하기 위해 백그라운드 탭이 동결된 경우

- bfcache에서 페이지가 복원될 때(pageshow 이벤트 직전에), 그리고 사용자가 정지된 배경 탭을 다시 방문할 때도 실행된다.

- freeze

- pagehide 이벤트의 presisted 값이 true일 경우에, pagehide이벤트 다음으로 호출됨.

- pagehide와 동일하게 브라우저가 bfcache를 하려고 했다는 '의도'만 알 수 있다.

BFCache를 위해 페이지 최적화 하는 팁 🌟

모든 페이지가 bfcache에 저장되는 것은 아니다. 또한 bfcache에 저장되었다고 하더라도 무한정 그곳에 머무르지는 않을 것이다. 개발자들이 캐시 적중률을 극대화하기 위해 페이지를 bfcache에 적격(eligible)하게, 혹은 부적격(ineligible)하게 만드는 이유를 이해하는 것이 중요하다.

1) unload 이벤트 사용하지 않기

모든 브라우저에서 bfcache를 최적화하는 가장 중요한 방법은 unload 이벤트를 절대로 사용하지 않는 것이다. unload 이벤트는 bfcache 이전에 선행된다. 근데 unload 이벤트가 발생한 후에는 페이지가 더이상 존재하지 않을 것이라는 (합리적) 가정 하에 많은 페이지가 운영되기 때문에 브라우저를 개발하는 입장에서 unload가 있는 경우 bfcache를 할 지 말지가 큰 딜레마 였다고 한다.

그래서 브라우저는 페이지에 unload 이벤트 리스너가 추가되어 있는 경우 bfcache에 적합하지 않은 페이지로 판단하는 경우가 많다.

- firefox, safari, chrome 등등 브라우저별로 어떻게 bfcache에 넣을건지에 대한 알고리즘은 차이가 있지만 일단 unload 이벤트 리스너가 있다면, bfcache를 고려하는 부분이 있다는 것이다.

- unload 이벤트 보다는 pagehide 이벤트를 사용하도록 하자!

- pagehide 이벤트는 unload가 불리는 모든 경우에 다 불린다.

- 심지어 Lighthouse v6.2.0 에서 no-unload-listeners-audit 까지 추가했다고 함!!

- 근데 정~ unload 이벤트 리스너를 추가해야한다면, beforeunload 이벤트 리스너를 추가하도록 해라.

- beforeunload 이벤트 리스너는 브라우저가 bfcache에 부적격(ineligible)한 요소로 판단하지 않는다. (자세한 예시)

2) window.opener 참조를 피해라

몇몇 브라우저에서는(Chrome v86 포함) window.open()이나, rel="noopener"을 사용하지 않고 target=_blank를 통해 새 창을 여는 코드를 작성한 경우, 열린 창에는 열게한 창의 window object에 대한 참조가 있다. 이렇게 window.opener가 null이 아닌 참조가 되어 있는 경우 bfcache에 안전하게 넣을 수 없다.왜냐하면 보안 문제가 있을 뿐만 아니라 bfcache에 액세스를 시도하는 모든 페이지가 깨질 수 있기 때문이다.

- 즉, rel="noopener"를 사용하여 target=_blank를 통해 새 창을 열도록 하자! (window.opener reference를 만들지 않음)

- 만약에 새창을 열고, window.postMessage()를 통해 그것을 제어하거나 window object를 직접 참조할 필요가 있다면, 열린 창이나 열게한 창 모두 bfcache할 자격이 없다.

3) (navigate 하기 전에) 항상 open된 connection 닫기

setTimeout(), promise와 같이 아직 코드가 진행중(?) 인데, 다른 페이지로 이동한 경우에는 브라우저는 진행중인 setTimeout나 promise 코드를 일시정지하고 다시 BFCache에서 복원될 때, 진행 중이던 코드를 재개한다.

scheduled된 JavaScript task들이 단지 DOM API에 접근한다거나, 해당 페이지에만 영향을 주는 고립된 API라면 일시정지 했다가 다시 재개하는게 문제가 되지 않을 것이다. 그러나 scheduled된 task가 same-origin의 다른 페이지에서도 엑세스 할 수 있는 API의 경우(예: IndexedDB, Web Locks, WebSockets, etc.) 작업을 일시 중지했을때, 다른 탭의 코드가 실행되지 않을 수 있기 때문에 문제가 발생할 수 있다.

그러므로 대부분 브라우저에서 아래와 같이 connection이 open된 경우에는 bfcache에 해당 페이지를 넣으려고 하지 않는다.

- Pages with an unfinished IndexedDB transaction

- Pages with in-progress fetch() or XMLHttpRequest

- Pages with an open WebSocket or WebRTC connection

pagehide나 freeze 이벤트에서 항상 connection을 close하고, observer를 제거하거나 연결을 끊는 것이 좋다. 이렇게 하면 다른 열린 탭에 영향을 미치지 않고 페이지를 안전하게 캐시할 수 있다. (당연하겠지만, pageshow나 resume 이벤트에서 bfcache로 부터 복구됬을때 커넥션을 다시 연결 해주도록 하면 된다.)

현재 Chrome에서는 페이지를 bfcache에 최대 3분 동안 유지할 수 있다. 테스트(Puppeteer or WebDriver)를 하기 충분한 시간이다. 테스트는 navigating 후 페이지 뒤로 가기를 했을 때, pageshow 이벤트의 persisted 속성 값이 true인지 확인하면 된다.

정상적인 조건에서 페이지는 테스트를 실행할 수 있을 만큼 충분히 오랫동안 캐시에 남아있겠지만, 시스템 메모리가 부족한 경우 캐시된 데이터가 조용히 지워질 수 있다. 즉, 테스트 실패가 반드시 페이지를 캐시할 수 없다는 것을 의미하지는 않으므로 테스트 실패 기준을 적절하게 구성해야 한다.

chrome에서 bfcache 테스트

현재 크롬에서는 mobile에서만 bfcache 사용 가능함. desktop에서 bfcache 테스트를 하고 싶다면 #back-forward-cache flag를 켜라.

최상위 페이지 응답 헤더의 Cache-Control을 no-store로 설정. 아래와 같이 설정해주는 것이 아닌, 다른 캐싱 설정은 bfcache 자격을 판단 하는 데에는 전혀 영향을 미치지 않음. 그러니까 bfcache를 안하고 싶으면 아래와 같이 설정해줘야함.

ㅋㅋ 그치만 위의 설정이 다른 캐싱 성능에 영향을 줄 수는 있다.

bfcache를 사용하면 성능 측정이 덜 될 수 있음

사이트 방문을 추적하기 위해서 분석도구를 이용하는데, bfcache로 인해 전체 페이지뷰 수가 감소하는 것을 발견할 수 있었다고 함. 대부분의 인기있는 분석 라이브러리는 bfcache 복원을 새로운 페이지뷰로 추적하지 않기 때문이다. 이것까지 새로운 페이지뷰로 추가하려면 pageshow 이벤트의 persisted 속성을 이용해서 처리해주면 된다.

// Send a pageview when the page is first loaded. gtag('event', 'page_view') window.addEventListener('pageshow', function(event) { if (event.persisted === true) { // Send another pageview if the page is restored from bfcache. gtag('event', 'page_view') } });

성능 지표에 부정적인 영향을 미칠 수 있음

특히 페이지 로드 시간을 측정하는 지표에 부정적인 영향을 미칠 수 있다.

bfcache 네비게이션은 새로운 페이지 로드를 하지 않고 기존 페이지를 복원하므로, 수집된 페이지 로드의 총 수가 감소한다. 그리고 bfcache를 이용하여 페이지를 복원한 속도를 가장 빠른 속도로 판단 할 수 있다.

이런 부분은 navigation 타입을 잘 분기처리(navigate, reload, back_forward, or prerender)하여 판단하면 된다.