전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

블로그 페이지 전환을 빠르게 — prefetch 캐시와 mutation queue

로컬에선 빨랐는데 배포하니 버벅였어요. hover는 저장하고 navigate는 소비하는 프리패치, 세대로 지우는 캐시, 그리고 프로미스 체인 하나로 지킨 저장-발행 순서 이야기입니다.

2026. 07. 14. 13:08:166분 읽기프로젝트/arcaC/성능 개선
#prefetch#캐시#SPA 전환#성능 최적화#mutation queue
조회 30
prefetch 캐시 썸네일

배포한 블로그를 폰으로 열어 본 날이었어요. 로컬에서 그렇게 매끈하던 화면이, 클릭마다 반 박자씩 늦게 따라왔습니다.

실망보다 먼저 온 건 부끄러움이었어요. 이걸 가족한테 쓰라고 줬구나, 싶어서요.

원인은 단순했습니다. 로컬에서는 DB가 같은 기계에 있었고, 배포하면 네트워크 너머에 있었어요. 그 차이가 클릭마다 쌓인 겁니다.

이 시리즈 이름은 "빠르게 느껴지게"예요. 실제 속도와 체감 속도는 다른 문제고, 승부는 체감에서 갈립니다.

첫 수 — 클릭하기 전에 받아두기

링크에 마우스를 올리는 순간과 클릭하는 순간 사이에는 시간이 있어요. 보통 수백 밀리초쯤 되죠.

그 시간에 다음 페이지를 미리 받아두면, 클릭 시점에는 이미 손에 있습니다.

여기서 역할을 명확히 갈랐어요. hover는 저장만 하고 navigate는 소비만 합니다.

이걸 안 가르면 이상한 일이 생겨요. hover가 화면을 바꾸려 하거나, navigate가 또 프리패치를 걸거나요. 각자 한 가지만 하면 순서가 꼬여도 안전합니다.

저장과 소비를 가른다는 게 코드로는 이런 모양이에요.

// hover — 저장만 합니다. 화면은 안 건드려요.
function onLinkHover(href) {
  if (cache.hasValid(href)) return;
  fetchPage(href).then(data => cache.set(href, data, generation));
}

// navigate — 소비만 합니다. 프리패치를 걸지 않아요.
function onNavigate(href) {
  const hit = cache.getValid(href);
  return hit ?? fetchPage(href);
}

이렇게 갈라 두면 순서가 꼬여도 안전해요. hover가 끝나기 전에 클릭이 들어와도 navigate는 그냥 없으면 직접 받으러 가니까요. 두 경로가 서로를 기다리지 않습니다.

반대로 hover가 화면을 조금이라도 건드리기 시작하면, 마우스가 지나갔을 뿐인데 화면이 깜빡이는 앱이 됩니다. 실제로 그런 앱들이 있죠.

그런데 캐시는 언젠가 거짓말을 합니다

미리 받아둔 데이터가 낡을 수 있어요. 글을 수정했는데 캐시에는 옛날 것이 있는 상황이요.

만료 시간을 두는 방법도 있는데, 이 경우엔 안 맞았습니다. 수정은 시간과 무관하게 일어나거든요.

그래서 세대 번호를 뒀어요.

// 무언가를 저장할 때마다 세대가 올라간다
generation += 1;

// 캐시 항목은 자기가 몇 세대에 담겼는지 안다
if (entry.generation !== generation) {  // 낡음 → 버림
  cache.delete(key);
}

그래서 무효화가 "무엇을 지울지 고르는 일"이 아니라 "세대가 다르면 전부 무효"가 됐어요.

세밀하게 지우는 것보다 효율은 떨어지는데, 훨씬 안전합니다. 지워야 할 걸 안 지우는 사고가 원천적으로 없거든요.

(캐시 무효화가 어려운 이유는 "무엇이 영향받는지" 계산이 어려워서예요. 그 계산을 포기하면 문제도 같이 사라집니다.)

세대 번호는 이렇게 돌아요.

let generation = 0;

// 무언가를 저장할 때마다 올립니다
function onMutationSuccess() {
  generation += 1;
}

// 캐시 항목은 자기가 몇 세대에 담겼는지 압니다
cache.set = (key, value) => map.set(key, { value, generation });

cache.getValid = (key) => {
  const entry = map.get(key);
  return entry && entry.generation === generation ? entry.value : undefined;
};
무효화 방식정확도틀렸을 때
영향받는 키만 지우기높음계산이 틀리면 낡은 값이 살아남음
시간 만료중간수정은 시간과 무관해서 안 맞음
세대 번호낮음(다 버림)최악이 "다시 받아옴"

세 번째 줄의 오른쪽이 이 선택의 전부예요. 틀렸을 때의 최악이 "한 번 더 요청"입니다. 첫 줄은 틀렸을 때 사용자가 잘못된 내용을 보게 되고요.

캐시 무효화가 어려운 이유는 "무엇이 영향받는지" 계산이 어려워서예요. 그 계산을 포기하면 문제도 같이 사라집니다.

프리패치에도 절제가 필요했어요

미리 받아두는 건 좋은데, 과하면 반대 효과가 납니다.

목록 화면에서 마우스가 지나가기만 해도 프리패치가 걸리면, 스크롤 한 번에 요청 수십 개가 나가요. 정작 클릭할 하나가 그 뒤에 줄을 서고요.

제약이유
짧은 지연 후에 시작스쳐 지나간 링크는 제외
이미 유효한 캐시가 있으면 안 함같은 걸 두 번 안 받음
한 번에 하나씩동시 요청이 본 요청을 밀어내지 않게

두 번째 줄에는 함정이 있었어요. "이미 있으면 안 한다"를 세대 검사 없이 하면, 낡은 캐시가 있을 때 영영 갱신을 안 합니다. 그래서 존재 여부가 아니라 유효한 항목이 있는가로 봐야 해요.

절제 규칙은 이렇게 넣었어요.

const inflight = new Set();

function onLinkHover(href) {
  if (cache.hasValid(href)) return;     // 유효한 게 있으면 스킵
  if (inflight.has(href)) return;       // 이미 받는 중이면 스킵
  if (inflight.size >= MAX_PREFETCH) return;   // 동시 상한
  if (isSaveDataMode()) return;         // 데이터 절약 모드면 안 함

  inflight.add(href);
  fetchPage(href).finally(() => inflight.delete(href));
}

네 번째 줄은 나중에 추가했어요. 폰에서 데이터 절약 모드를 켠 사람에게 안 볼지도 모르는 페이지를 미리 받게 하는 건 실례잖아요.

그리고 첫 줄의 함정 — "이미 있으면 안 한다"를 세대 검사 없이 쓰면, 낡은 캐시가 있을 때 영영 갱신을 안 합니다. 존재 여부가 아니라 유효한 항목이 있는가로 봐야 해요.

낙관적 업데이트와는 다릅니다

둘을 헷갈리기 쉬운데 성격이 반대예요.

프리패치낙관적 업데이트
시점요청 전에 미리 받음응답 전에 미리 보여줌
다루는 것읽기쓰기
틀렸을 때버리면 끝되돌려야 함
위험도낮음 (낭비뿐)높음 (거짓말할 수 있음)

그래서 프리패치는 마음 편히 넣었고, 낙관적 업데이트는 한참 뒤에야 붙였어요. 되돌리는 경로까지 만들고 나서요.

그리고 이게 서버 쪽 문제를 가렸습니다

정직하게 적을 게 있어요. 이 작업들이 문제를 가리는 효과도 있었습니다.

클릭이 빨라 보이니까 서버가 여전히 느리다는 걸 한동안 못 느꼈어요. 프리패치가 걸리지 않는 첫 진입에서는 원래 속도가 그대로 나오는데, 그 경로를 제가 자주 안 밟았거든요.

나중에 첫 응답 시간을 재보고서야 알았습니다. 그때 서버 쪽을 손봤고요.

체감 최적화는 진짜 문제를 늦게 발견하게 만들 수 있어요. 둘 다 필요하지만, 순서를 착각하면 안 됩니다.

두 번째 문제 — 순서

여기서 다른 종류의 버그를 만났어요. 저장하고 바로 발행을 누르면 발행이 먼저 도착하는 경우가 있었습니다.

저장 요청 ──────────────→ (느림)
발행 요청 ──→ (빠름)
결과: 저장 전 내용이 발행됨

둘 다 성공했는데 결과가 틀린 거예요. 제일 찾기 어려운 종류의 버그죠.

그래서 이 연산들을 줄 세웠습니다. 프로미스 체인 하나에 이어 붙여서, 앞의 것이 끝나야 뒤의 것이 출발하게요.

queue = queue.then(() => save(patch));
queue = queue.then(() => publish());   // 저장이 끝난 뒤에 출발

동시성을 없앤 게 아니라 순서가 중요한 것들만 줄을 세운 거예요. 나머지는 그대로 병렬로 갑니다.

줄 세우기는 이렇게 했습니다.

let queue = Promise.resolve();

function enqueue(task) {
  queue = queue.then(task, task);   // 앞이 실패해도 뒤는 실행
  return queue;
}

// 순서가 중요한 것들만 큐에 넣습니다
enqueue(() => save(patch));
enqueue(() => publish());

// 조회처럼 순서가 상관없는 건 그냥 병렬로

두 번째 인자를 같이 넘긴 이유가 있어요. 앞의 작업이 실패했다고 큐 전체가 멈추면, 그 뒤로 아무것도 안 되는 상태가 됩니다. 실패는 각자 처리하고 줄은 계속 흐르게 했어요.

상황줄 세우기 없이줄 세운 뒤
저장 → 발행 연타발행이 먼저 도착할 수 있음항상 저장 다음
빠른 연속 저장마지막 요청이 먼저 끝날 수 있음보낸 순서대로
저장 실패 후 재시도순서 보장 없음앞의 결과를 보고 판단 가능

동시성을 없앤 게 아니라 순서가 중요한 것들만 줄을 세운 거예요. 이걸 구분 안 하고 전부 줄 세우면, 목록 조회 열 개가 순서대로 기다리는 앱이 됩니다.

셋 다 클라이언트 안의 이야기입니다

여기까지가 브라우저 안에서 할 수 있는 것들이에요. 그리고 한계가 명확합니다.

해결한 것못 하는 것
이미 도착한 다음의 반응도착하기까지의 시간
한 탭 안의 일관성여러 기기 동시 편집
체감 지연 감추기실제 전송량

오른쪽 열은 서버 쪽 문제예요. 이건 나중에 따로 잡았습니다.

그리고 여러 기기 문제는 여전히 서버의 버전 충돌 검사에 맡깁니다. 클라이언트 캐시는 탭 하나의 메모리 안에서만 유효하니까요.

체감 속도는 기다림을 견디게 해주지, 기다림을 없애지는 못해요.


다음 편은 아예 순서를 뒤집어요. 클릭보다 화면이 먼저 움직입니다 — 낙관적 업데이트요.

시리즈 이어읽기

빠르게 느껴지게

1 / 4

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.