이 글에서 다루는 것
앱에 두 가지 큰 걸 하며 배운 것을 정리한다. 특정 제품·수치는 빼고 일반 개념(Next App Router 렌더링, react-query 캐시, HTTP 조건부 요청)만 다룬다. 두 작업 모두 관통하는 주제는 "정적 캐시(ISR)를 지키면서 원하는 UX를 얻기" 였다.
오늘 다룬 것
| 작업 | 무엇을 하고 싶었나 | 한 일 | 결과 |
|---|---|---|---|
| 부트 스플래시 | 첫 화면부터 로고를 띄우되 봇엔 숨기기 | SSR·UA게이트 대신 인라인 블로킹 스크립트 주입 | 정적 렌더/ISR 유지 + 봇 은닉 |
| 오프라인 퍼스트 | 읽기 즉시성 + 트래픽 최소 + 내 쓰기 즉시반영 | persist·낙관적쓰기·invalidate·durable큐·ETag 조합 | 세 요구 동시 충족 |
1. 첫 페인트 전 노출 + 봇 은닉 = 인라인 스크립트
어떤 상황이었나. 앱 첫 진입에 네이티브 앱처럼 로고 스플래시를 첫 페인트부터 띄우고 싶었다. 그런데 크롤러 색인엔 그 스플래시가 안 들어가야 했다.
핵심 개념. 세 대안이 각각 막혔다. SSR 마크업은 첫 HTML에 실려 봇 색인에 샌다. UA 게이트(요청 헤더로 봇이면 스킵)는 공유 레이아웃에서 headers()를 읽는 순간 하위 라우트의 정적 생성/ISR을 강제로 dynamic으로 바꿔 정적 캐시를 통째로 무력화한다. ISR(Incremental Static Regeneration)은 페이지를 미리 정적으로 구워 두고 주기적으로만 갱신하는 방식인데, dynamic이 되면 매 요청 서버 렌더로 돌아가 느려진다. mount 이후에만 뜨는 클라이언트 스플래시는 서버에서 초기 상태가 비어 첫 페인트 갭이 생긴다. 정답은 <body> 최상단에 인라인 블로킹 스크립트를 넣어 스플래시 노드를 직접 그리는 것 — JS를 안 돌리는 봇 HTML엔 안 남고, 최신 React는 body 상단에 끼워 넣은 예상 밖 노드를 하이드레이션에서 tolerant하게 건너뛰어 mismatch도 없다.
한 일. SSR을 버리고 인라인 스크립트 주입으로 전환했다. 하이드레이션 뒤 클라이언트가 opacity로 스플래시를 걷어낸다. 인라인 스크립트에 문자열을 넣을 때 </script> 조기 종료를 막으려 <를 유니코드 이스케이프하고 textContent로 세팅했다.
교훈. 첫 페인트 전 노출 + 봇 은닉은 인라인 블로킹 스크립트가 정답이다. 공유 레이아웃에서 headers()/cookies()를 쓰기 전에 하위 라우트의 ISR 여부를 확인하라.
→ 첫 페인트 전 노출 + 봇 은닉 — 인라인 블로킹 스크립트
2. 오프라인 퍼스트 — 즉시 반영은 staleTime이 아니라 invalidate
어떤 상황이었나. 앱 전 페이지를 오프라인 퍼스트로 만들되 세 요구를 동시에 만족해야 했다: (1) 읽기 즉시성(새로고침해도 바로 뜸), (2) 백엔드 트래픽 최소, (3) 내 쓰기 즉시 반영. 순진하게 하면 세 요구가 충돌한다 — 즉시성을 위해 캐시 신선도 시간을 낮추면 트래픽이 폭증한다.
핵심 개념. 두 가지 통찰이 핵심이었다.
첫째, "내 쓰기 즉시 반영"은 staleTime을 낮추는 게 아니라 쓰기 후 invalidateQueries로 만든다. staleTime은 데이터를 몇 초간 신선하다고 믿고 백그라운드 refetch를 참을지를 정하고, invalidateQueries는 그 staleTime을 무시하고 활성 쿼리를 즉시 다시 가져온다. 그래서 staleTime은 신선도 SLA(예: 10초)로 유지해 트래픽을 억제하고, 쓰기가 성공하면 관련 쿼리만 스코프 무효화해 그 순간만 강제 refetch한다. 낙관적 업데이트로 화면을 먼저 바꾸고 실패하면 롤백을 얹으면 체감 즉시성까지 확보된다.
둘째, reload-durable 오프라인 뮤테이션 큐는 새로고침에도 살아남아야 한다. 오프라인에서 한 쓰기(좋아요·댓글)를 로컬에 쌓아 뒀다가 재접속·새로고침 후에도 재개되려면, persist가 그 뮤테이션 키를 저장하고, setMutationDefaults가 마운트된 컴포넌트 없이도 실행할 resume 경로를 주며, 재구성에 필요한 값(대상 id 등)을 variables에 실어야 한다. 뮤테이션 콜백은 mutationKey를 못 읽으므로 이 셋 중 하나만 빠져도 복원돼도 못 빠지는 stuck 큐가 된다.
한 일. 캐시를 로컬 DB(IndexedDB)에 persist(staleTime 유지, 포커스 refetch off), 낙관적 쓰기 + onError 롤백, 쓰기 후 스코프 invalidate, setMutationDefaults reload-durable 큐, 읽기 라우트에 ETag/304, persist 스냅샷 그룹별 LRU 축출을 단계별로 붙였다. 서버측 payload 캐시는 상세가 이미 ISR로 덮이고 리스트는 쿼리스트링 가짓수(cardinality)가 높아 캐시 히트가 안 나 과투자로 판정해 스킵했다. ETag는 대역폭만 아끼고 서버 연산은 못 아낀다는 한계도 확인했다.
교훈. 즉시 반영은 invalidate로, 신선도 SLA는 staleTime으로 — 역할이 다르다. 오프라인 큐는 persist + setMutationDefaults + variables 셋이 다 있어야 한다. persist·서버 캐시 최적화 전에 대상 페이지의 ISR 여부와 쿼리스트링 cardinality를 먼저 보라.
→ invalidateQueries vs staleTime — 내 쓰기 즉시 반영