2026-05-01·[디버깅 경험]
프론트엔드 캐시 정리 1: 브라우저 캐시와 캐시 레이어
프론트엔드 캐시를 하나의 문제로 묶어 보면 원인을 찾기 어렵습니다. 브라우저 레이어의 bfcache와 HTTP 캐시부터 분리해 살펴봅니다.
운영자가 공지사항 문구를 바꿨는데, 사용자 화면에서는 예전 문구가 보이는 상황이 있었습니다. 처음에는 단순히 "캐시 문제"라고 생각했습니다. 하지만 실제로 원인을 찾으려고 보니 어느 레이어의 캐시를 봐야 하는지 바로 특정하기 어려웠습니다.
프론트엔드에서 말하는 캐시는 한 종류가 아닙니다. 브라우저, HTTP, 프레임워크, 클라이언트 상태 관리 레이어가 각각 다른 방식으로 값을 재사용합니다. 이 레이어들을 하나의 "캐시"로 묶어 보면 문제가 발생한 위치를 놓치기 쉽습니다.
이 글에서는 그중 가장 아래쪽에 있는 브라우저 캐시부터 살펴봅니다. 특히 bfcache와 HTTP 캐시는 모두 브라우저에서 마주치지만, 동작 방식과 확인 지점이 완전히 다릅니다.

프론트엔드에서 마주치는 캐시는 한 장으로 그리면 이런 적층 구조에 가깝습니다. 같은 "캐시"라는 이름을 쓰지만 저장 대상, 무효화 방식, 확인 위치가 모두 다릅니다. 따라서 캐시 문제를 볼 때는 먼저 어떤 레이어에서 값이 재사용되고 있는지 분리해서 보는 과정이 필요합니다.
프론트엔드 캐시는 여러 레이어에 걸쳐 있다
우선 프론트엔드에서 자주 마주치는 캐시 레이어는 다음처럼 나눠볼 수 있습니다.
-
bfcache 브라우저가 뒤로 가기/앞으로 가기 시점에 페이지 상태를 통째로 복원하는 레이어입니다.
-
HTTP 캐시 Cache-Control, ETag 같은 규칙으로 응답을 재사용하는 레이어입니다.
-
Next.js 캐시 서버에서 계산한 결과나 렌더링 결과를 재사용하는 프레임워크 레이어입니다.
-
Next.js Router Cache 앱 내부 페이지 이동을 빠르게 만들기 위해 클라이언트 메모리에 들고 있는 레이어입니다.
-
TanStack Query 캐시 브라우저 안에서 서버 데이터를 다시 쓰기 위해 애플리케이션이 직접 관리하는 캐시입니다.
이 글에서는 1번과 2번, 즉 브라우저가 직접 처리하는 레이어만 다룹니다. 이 둘만 분리해도 "브라우저가 이전 페이지 상태를 복원한 것인지", "HTTP 응답 자체가 재사용된 것인지"를 구분할 수 있습니다.

브라우저 캐시 안에서도 bfcache와 HTTP 캐시는 서로 다른 문제입니다. 하나는 페이지 상태 복원이고, 다른 하나는 응답 재사용입니다.
bfcache는 페이지 상태 복원에 가깝다
bfcache를 처음 이해할 때 흔한 오해는 이것을 파일 캐시처럼 생각하는 것입니다. 하지만 bfcache는 Cache-Control로 제어하는 HTTP 응답 캐시와 다른 층에 있습니다.
bfcache는 HTML, CSS, JS 파일을 다시 받지 않는 캐시라기보다, 사용자가 보고 있던 페이지 상태를 브라우저가 잠깐 보관했다가 그대로 복원하는 기능에 가깝습니다.
그래서 다음과 같은 현상이 나타납니다.
- 뒤로 가기 했더니 페이지가 거의 즉시 뜬다.
- 스크롤 위치가 그대로 살아 있다.
- 입력 중이던 폼 값이 남아 있는 것처럼 보인다.
- 심하면 "어? 이건 새로 그려진 게 아닌데?"라는 생각이 든다.
이 동작은 응답을 다시 받지 않는 수준을 넘어, 이전에 보던 화면 자체를 다시 꺼내오는 것에 가깝습니다. 브라우저가 이전 페이지 상태를 복원하는 경우이므로, HTTP 캐시와 확인해야 하는 지점도 달라집니다.
bfcache를 다룰 때 확인할 지점
뒤로 가기 복원이 기대와 다르게 동작한다고 해서 바로 복원 코드를 추가하는 것이 항상 좋은 선택은 아닙니다. 먼저 브라우저가 기본적으로 제공하는 복원을 방해하고 있는 코드가 없는지 확인할 필요가 있습니다.
실무에서 먼저 확인해볼 만한 지점은 다음과 같습니다.
unload는 bfcache를 막을 수 있습니다
페이지를 떠날 때 무언가 정리하려고 unload 이벤트를 걸어두는 코드가 있다면, 그 코드가 복원을 막고 있을 수 있습니다. 브라우저 입장에서는 페이지를 bfcache에 올리기 어려운 상황이 되고, 사용자는 뒤로 가기에서도 페이지가 새로 로드되는 경험을 하게 됩니다.
beforeunload도 비슷합니다. 저장되지 않은 변경사항 경고처럼 필요한 경우가 아니라면 모든 페이지에 공통으로 넣을 이유는 크지 않습니다.
정리 로직이 필요하다면 pagehide 이벤트가 더 적절한 선택지가 될 수 있습니다.
window.addEventListener('pagehide', () => {
sessionStorage.setItem('notice-list-scroll', String(window.scrollY));
});
이 코드는 페이지가 사라질 때 스크롤 값을 보조적으로 저장하는 예시입니다. 브라우저가 bfcache로 잘 복원해주는 상황이라면 필요하지 않습니다. 다만 커스텀 스크롤 컨테이너를 사용하거나 기본 복원이 UI 구조와 잘 맞지 않는 경우에는 보조 장치로 사용할 수 있습니다.
pageshow로 복원 여부를 구분할 수 있습니다
페이지가 새로 만들어진 것인지, bfcache에서 복원된 것인지 구분하고 싶다면 pageshow 이벤트를 사용할 수 있습니다.
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
console.log('bfcache에서 복원됨');
}
});
event.persisted === true면 브라우저가 페이지를 새로 만든 것이 아니라, bfcache에서 다시 꺼냈다는 의미입니다.
이 구분은 복원 직후 일부 데이터만 다시 동기화해야 할 때 유용합니다. 예를 들어 읽지 않은 알림 개수나 실시간 잔액처럼, 화면 상태는 그대로 복원하되 숫자만 다시 맞추고 싶은 값이 있을 수 있습니다. 이런 값은 pageshow 시점에 가벼운 갱신을 걸 수 있습니다.
커스텀 스크롤이나 필터 상태는 별도 저장이 필요할 수 있습니다
브라우저는 window 스크롤을 비교적 잘 복원합니다. 하지만 앱에서 별도 스크롤 영역을 만들거나 탭/필터 상태를 컴포넌트 내부 state에만 들고 있으면 복원 기준이 모호해질 수 있습니다.
이런 상태는 URL로 올려두는 방식이 단순합니다.
const params = new URLSearchParams(window.location.search);
params.set('tab', currentTab);
params.set('page', String(currentPage));
history.replaceState(null, '', `?${params.toString()}`);
이렇게 해두면 새로고침을 하든 뒤로 가기를 하든 상태 복원 방식이 명확해집니다. 페이지마다 필터나 탭 상태가 다르게 복원되는 문제도 줄어듭니다.
URL에 올리기 애매한 값은 sessionStorage에 둘 수 있습니다.
window.addEventListener('pageshow', () => {
const saved = sessionStorage.getItem('notice-list-scroll');
if (saved) {
window.scrollTo(0, Number(saved));
}
});
다만 이런 코드는 브라우저 기본 스크롤 복원을 덮어쓸 수 있습니다. 모든 페이지에 습관적으로 넣기보다는, 기본 동작으로 부족한 부분만 보완하는 방식이 안전합니다.
HTTP 캐시는 응답 재사용 규칙이다
bfcache를 분리해서 보고 나면 HTTP 캐시와의 차이도 더 명확해집니다. 둘 다 브라우저 안에서 마주치는 캐시지만, 실제로는 완전히 다른 층입니다.
HTTP 캐시는 말 그대로 응답을 다시 받지 않거나 덜 받기 위한 규칙입니다. 서버가 응답 헤더로 "이 응답은 60초 동안 다시 받아오지 않아도 된다"라고 말하면, 브라우저나 CDN이 그 규칙에 따라 움직입니다.
예를 들어 다음과 같은 응답이 있다고 가정해보겠습니다.
Cache-Control: max-age=60, stale-while-revalidate=300
ETag: "notice-list-v42"
이런 상황에서는 같은 /api/notices 요청이 들어와도 브라우저가 네트워크를 거의 사용하지 않고 저장된 JSON을 먼저 반환할 수 있습니다. 또는 서버에 ETag를 보내서 "내가 가진 버전이 아직 유효한가?"만 확인한 뒤 304 Not Modified를 받을 수도 있습니다.
이 지점에서 bfcache와의 차이가 분명해집니다.
- bfcache는 페이지 상태를 복원합니다.
- HTTP 캐시는 응답을 재사용합니다.
뒤로 가기 했더니 스크롤이 살아 있는 현상은 보통 bfcache 쪽에 가깝습니다. 반대로 새로고침했는데도 공지사항 API 응답이 예전 값처럼 보인다면 HTTP 캐시 쪽일 가능성이 커집니다.
브라우저 캐시를 구분하는 기준
브라우저 캐시를 볼 때는 먼저 증상을 나누는 것이 좋습니다.
뒤로 가기에서만 이상하고, 스크롤이나 입력값이 살아 있다면 bfcache 쪽을 먼저 봅니다. 이 경우에는 서버가 응답을 잘못 줬다기보다, 브라우저의 페이지 상태 복원을 잘못 해석한 경우가 많습니다.
반대로 새로고침을 해도 예전 데이터가 보인다면 Network 탭을 먼저 확인합니다. from memory cache, from disk cache, 304 Not Modified 같은 표시가 있는지 보고, DevTools에서 Disable cache를 켠 뒤 hard reload도 비교해볼 수 있습니다. 이 정도만 해도 브라우저 레이어에서 설명되는 문제인지, 애플리케이션 레이어까지 봐야 하는 문제인지 범위가 꽤 좁혀집니다.
간단한 비교만으로도 원인이 좁혀지는 경우가 있습니다.
- 뒤로 가기에서는 예전 상태가 보인다.
- hard reload를 하면 최신 데이터가 보인다.
이 경우에는 브라우저 복원 계층을 먼저 봅니다.
반대로
- hard reload를 해도 예전 값이 보인다.
- 응답 헤더에 캐시 정책이 있다.
이 경우에는 HTTP 캐시나 그 위의 레이어로 넘어갑니다.
이 단계는 아직 애플리케이션 캐시가 아니다
브라우저 캐시 때문에 생기는 버그는 애플리케이션 코드 바깥에서 시작되는 경우가 많습니다. 뒤로 가기에서만 화면이 이상하게 복원되거나, 다시 요청할 줄 알았는데 응답이 재사용되는 상황은 프레임워크 이전에 브라우저가 이미 무언가를 하고 있기 때문입니다.
브라우저 캐시를 분리해서 보면 그 위에 얹힌 애플리케이션 캐시도 덜 헷갈립니다. 이 레이어를 건너뛰면 상위 캐시를 잘못 의심하기 쉽습니다.
결국 캐시 문제를 다룰 때 중요한 것은 캐시를 하나의 덩어리로 보지 않는 것입니다. 브라우저가 페이지 상태를 복원한 것인지, HTTP 응답을 재사용한 것인지부터 분리하면 이후 레이어를 확인하는 순서도 훨씬 명확해집니다.
참고
MDN - HTTP caching MDN - Cache-Control web.dev - Back/forward cache