About·GitHub·Email

2026-05-01·[디버깅 경험]

프론트엔드 캐시 정리 1: 브라우저 캐시와 캐시 레이어

프론트엔드 캐시를 이해하려면 먼저 bfcache와 HTTP 캐시를 분리해서 볼 필요가 있다.

운영자가 공지사항 문구를 바꿨는데, 사용자 화면에서는 예전 문구가 보이는 상황이 있었다. 처음엔 나도 그냥 "캐시 문제네" 하고 넘겼다. 그런데 막상 고치려고 보니 어디를 눌러야 하는지 특정이 안 됐다. 솔직히 그때 처음으로, 내가 캐시를 제대로 모르고 있다는 걸 실감했다.

프론트엔드에서 말하는 캐시는 한 종류가 아니다. 여러 레이어가 동시에 겹쳐서 움직이기 때문에, 이걸 한 덩어리처럼 보면 어디에서 무슨 일이 일어나는지 놓치기 쉽다.

이번 글에서는 그중에서도 가장 아래쪽, 즉 브라우저가 직접 처리하는 캐시부터 정리한다. 실제로 문제의 출발점이 이 레이어인 경우가 적지 않다.

프론트엔드 캐시 레이어 도식

프론트엔드에서 마주치는 캐시는 한 장으로 그리면 이런 적층 구조에 가깝다. 같은 "캐시"라는 이름을 쓰지만 실제로는 저장 대상도 다르고, 무효화 방식도 다르고, 확인해야 하는 지점도 전부 다르다. 그래서 상위 레이어를 의심하기 전에 하위 레이어부터 먼저 지워가며 보는 편이 안전하다.


프론트엔드 캐시는 레이어로 나눠서 봐야 한다

우선 이렇게 나눠서 볼 수 있다.

  1. bfcache 브라우저가 뒤로 가기/앞으로 가기 시점에 페이지 상태를 통째로 복원하는 레이어다.

  2. HTTP 캐시 Cache-Control, ETag 같은 규칙으로 응답을 재사용하는 레이어다.

  3. Next.js 캐시 서버에서 계산한 결과나 렌더링 결과를 재사용하는 프레임워크 레이어다.

  4. Next.js Router Cache 앱 내부 페이지 이동을 빠르게 만들기 위해 클라이언트 메모리에 들고 있는 레이어다.

  5. TanStack Query 캐시 브라우저 안에서 서버 데이터를 다시 쓰기 위해 애플리케이션이 직접 관리하는 캐시다.

이걸 한 번에 다 보면 복잡해진다. 그래서 이번 글에서는 1번과 2번, 즉 브라우저가 직접 쥐고 있는 레이어만 본다. 이 둘을 먼저 분리해두면 다음 글에서 Next.js 캐시를 볼 때도 구조가 더 선명해진다.

브라우저 캐시 지도

특히 브라우저 캐시 안에서도 bfcache와 HTTP 캐시는 완전히 다른 문제라는 점을 먼저 머리에 넣어두면 좋다. 하나는 페이지 상태 복원이고, 다른 하나는 응답 재사용이다.


bfcache는 페이지 상태 복원에 가깝다

bfcache를 처음 이해할 때 가장 흔한 오해는 이것을 파일 캐시 비슷한 것으로 생각하는 것이다. 나도 꽤 오래 그랬다. 그래서 bfcache가 걸려 있는 화면을 Cache-Control로 잡으려 했고, 당연히 아무 효과도 없었다. 실제로는 그와 다르다.

bfcacheHTML, CSS, JS 파일을 다시 안 받는 캐시라기보다, 사용자가 보고 있던 페이지 상태를 브라우저가 잠깐 보관했다가 그대로 복원하는 기능에 더 가깝다.

그래서 이런 일이 생긴다.

  • 뒤로 가기 했더니 페이지가 거의 즉시 뜬다.
  • 스크롤 위치가 그대로 살아 있다.
  • 입력 중이던 폼 값이 남아 있는 것처럼 보인다.
  • 심하면 "어? 이건 새로 그려진 게 아닌데?"라는 생각이 든다.

이건 응답을 다시 받지 않는 수준을 넘어, 이전에 보던 화면을 다시 꺼내오는 동작에 더 가깝다. 새 요청이 안 가는 것이 아니라 브라우저가 이전 페이지 상태 자체를 복원하는 경우가 있기 때문에, HTTP 캐시와는 확인해야 하는 지점도 달라진다.


복원 동작을 설계할 때 확인할 점

여기서 핵심은 "복원 코드를 많이 짜는 것"이 아니다. 오히려 브라우저 기본 복원을 최대한 방해하지 않는 쪽이 먼저다.

보통 먼저 보는 건 이런 것들이다.

unload를 가능하면 쓰지 않는다

페이지를 떠날 때 무언가 정리하려고 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에서 다시 꺼냈다는 뜻이다.

이게 중요한 이유는 복원된 직후에 다시 동기화해야 하는 데이터가 있을 수 있기 때문이다. 예를 들어 읽지 않은 알림 개수나 실시간 잔액 같은 값은 화면 상태는 복원하되, 숫자만 다시 확인하고 싶을 수 있다. 그럴 때 이 시점에 가벼운 갱신을 걸 수 있다.

커스텀 스크롤이나 필터 상태는 URL이나 sessionStorage에 둔다

브라우저가 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는 페이지 상태를 복원한다.
  • HTTP 캐시는 응답을 재사용한다.

뒤로 가기 했더니 스크롤이 살아 있는 건 보통 bfcache 쪽이고, 새로고침했는데도 공지사항 API 응답이 옛날 값처럼 보이는 건 HTTP 캐시 쪽일 가능성이 크다.


브라우저 캐시를 구분하는 기준

브라우저 캐시를 볼 때는 일단 증상부터 분리하는 편이 좋다.

뒤로 가기에서만 이상하고, 스크롤이나 입력값이 살아 있다면 bfcache 쪽을 먼저 본다. 이 경우엔 서버가 응답을 잘못 줬다기보다, 브라우저의 페이지 상태 복원을 잘못 해석한 경우가 많다.

반대로 새로고침을 해도 예전 데이터가 보인다면 Network 탭을 먼저 연다. from memory cache, from disk cache, 304 Not Modified 같은 표시가 있는지 본다. 그리고 DevTools에서 Disable cache를 켠 뒤 hard reload를 해본다. 이 단계만으로도 브라우저 레이어에서 설명되는 문제인지, 애플리케이션 레이어까지 봐야 하는 문제인지 범위를 상당히 좁힐 수 있다.

가끔은 이 비교 하나로 끝나기도 했다.

  1. 뒤로 가기에서는 예전 상태가 보인다.
  2. hard reload를 하면 최신 데이터가 보인다.

이러면 브라우저 복원 계층을 먼저 보는 게 맞다.

반대로

  1. hard reload를 해도 예전 값이 보인다.
  2. 응답 헤더에 캐시 정책이 있다.

이러면 HTTP 캐시나 그 위의 레이어를 봐야 한다.


이 단계는 아직 애플리케이션 캐시가 아니다

브라우저 캐시 때문에 생기는 버그는 애플리케이션 코드 바깥에서 시작되는 경우가 많다. 뒤로 가기에서만 이상하게 화면이 복원되거나, 분명 다시 요청할 줄 알았는데 응답이 재사용되는 상황은 프레임워크 이전에 브라우저가 이미 무언가를 하고 있기 때문이다.

브라우저 캐시에 대한 이해가 있어야, 그 위에 얹혀 있는 애플리케이션 캐시도 제대로 다룰 수 있다. 이 레이어를 건너뛰면 상위 캐시를 잘못 의심하기 쉽다.

레이어를 하나씩 지워가는 방식으로 좁혀나가니까 어디를 봐야 하는지가 명확해진다는 걸, 이번에 직접 부딪히면서 다시 느꼈다. 캐시가 어렵다기보다는, 섞어서 볼 때 어렵다는 게 더 정확한 것 같다.

다음 글에서는 브라우저 바깥으로 한 층 올라가서, Next.js 캐시와 App Router 쪽 구조를 이어서 정리한다.

이 시리즈는 아래 순서로 이어진다.

  1. 브라우저 캐시와 캐시 레이어
  2. Next.js 캐시와 15·16 비교
  3. TanStack Query와 SWR

참고

MDN - HTTP caching MDN - Cache-Control web.dev - Back/forward cache