브라운필드 SPA에서 캐시 불일치 대참사? 교묘한 문제를 실용적으로 해결한 비법 공개!
2026. 9. 1.
브라운필드 SPA에서 캐시 불일치 대참사? 교묘한 문제를 실용적으로 해결한 비법 공개!
title: "Fixing Delicate Cache Mismatches in a Brownfield SPA: A Pragmatic Solution" published: true description: "How we eliminated subtle stylesheet caching glitches during deployments on DEV without a massive rewrite." tags:
- webdev
- architecture
- webperf
- rails ai_disclosure: some_ai
초기에는 새로운 기능을 추가하는 '그린필드' 개발이 스포트라이트를 받기 마련이죠. 하지만 수년간 운영되어 온 프로덕션 애플리케이션에서는 기존 코드와 시스템 위에 끊임없이 개선을 더하는 브라운필드 문제 해결이 훨씬 더 중요하고 핵심적인 엔지니어링 작업입니다.
저희 DEV는 오픈소스 Forem 코드베이스를 기반으로 합니다. Rails 서버 렌더링, Fastly 엣지 캐싱, 그리고 InstantClick을 통한 가벼운 클라이언트 측 탐색을 조합한 하이브리드 아키텍처를 사용하고 있죠. 이 덕분에 100ms 미만의 빠른 페이지 전환 속도를 자랑하지만, 부분 페이지 스왑과 공격적인 엣지 캐싱이 맞물리면서 배포 시 예상치 못한 까다로운 문제들이 발생했습니다.
최근 저희는 웹 탐색 과정에서 발생하는 고질적인 캐싱 불일치 문제를 해결하는 PR #23789을 배포했습니다. 이 문제는 DEV 서비스 초기부터 존재했으며, 그동안 여러 차례 임시방편적인 해결책을 적용했지만 늘 불안정했죠. 이번 PR이 완벽한 해결책은 아니지만, 올바른 방향으로 나아가는 중요한 진전이라고 확신합니다.
문제점: 배포 전반의 캐시 불일치
새로운 CSS 업데이트를 배포할 때마다 Rails는 새로운 애셋 다이제스트 해시를 생성합니다. 예를 들어, views-v1.css는 views-v2.css로 교체되는 식이죠.
- 전체 페이지 로드: 사용자가 업데이트된 홈페이지를 로드합니다. 브라우저는 전체 HTML을 받고
<head>에서 최신v2스타일시트를 불러옵니다. - 내부 탐색 (부분 페이지 스왑): 사용자가 기사 링크를 클릭합니다. 이때 브라우저 전체를 새로고침하는 대신, InstantClick은 백그라운드에서 기사 내용을 요청하고 기존 DOM의
#page-content컨테이너만 교체합니다. - 엣지 캐시의 함정: DEV는 Fastly에서 서러게이트 키를 사용해 기사 HTML 조각을 매우 적극적으로 캐싱합니다. 문제는 많은 기사가 최신 배포 이전에 캐시되어 있었고, 이 캐시된 HTML 조각들은 여전히
v1스타일시트를 참조하고 있었다는 점입니다. 제가 실무에서 비슷한 상황을 겪었을 때, 배포 후 캐시 무효화가 제대로 안 되면 사용자들이 오래된 UI를 보거나 아예 깨진 화면을 마주하는 경우가 많았습니다. 특히 SPA에서 부분 업데이트를 쓰는 경우엔 이런 미묘한 타이밍 문제가 훨씬 더 빈번하게 발생하더군요.
기존 해결책이 취약했던 이유
오래된 CSS로 페이지가 렌더링되는 것을 막기 위해, 이전에는 클라이언트 측 로직을 추가하여 들어오는 페이지의 예상 스타일시트 경로를 검사하고 DOM에서 <link rel="stylesheet"> 요소를 동적으로 교체했습니다.
하지만 실제로는 매우 취약했습니다.
- 새로 배포된 홈페이지(
v2)에서 오래된 엣지 캐시 기사(v1)로 이동할 때, 클라이언트 스크립트는currentHref (v2)와expectedHref (v1)가 다르다는 것을 감지했습니다. - 스크립트는 들어오는 페이지가 "목표" 상태를 나타낸다고 가정하고, DOM을 강제로
v1스타일시트로 다운그레이드 시켰습니다. - 만약
v1애셋 파일이 배포 후 삭제되었다면 브라우저는 404 에러를 뿜었고, 설사 로드되었다 해도 세션 도중에 업데이트된 CSS가 제거되어 새로운 UI 구성 요소들이 갑자기 깨져버리는 문제가 발생했습니다. <head>와<body>에 걸쳐 여러 스타일시트 태그를 비동기적으로 교체하려다 보니 CSS 캐스케이드 경쟁 조건이 발생하고, FOUC(Flash Of Unstyled Content) 현상까지 나타났습니다.
해결책: 내부 탐색 파라미터의 재활용
이 문제를 단순히 클라이언트 측 DOM 변형 문제로 볼 것이 아니라, 근본적으로 캐시 파티셔닝 문제라는 사실을 깨달았습니다.
오랜 기간 Forem은 백그라운드 AJAX 요청 시 내부 쿼리 파라미터인 ?i=i를 조용히 전달해왔습니다. 이는 Rails가 전체 HTML 문서 쉘 대신 가벼운 부분 레이아웃을 렌더링하도록 알려주는 역할을 했죠.
PR #23789에서 우리는 이 파라미터를 재활용했습니다.
-
스타일 핑거프린팅: 초기 전체 페이지 로드 시, Rails는 핵심 스타일시트(
minimal,views,crayons)의 결합된 다이제스트로부터 결정론적인 10자리 해시를 계산하고, 이를<body>에data-style-fingerprint속성으로 추가합니다.<body ... data-style-fingerprint="cc9ed033eb"> -
내부 탐색 파라미터화: InstantClick이 링크를 미리 로드하거나 가져올 때, 현재 활성 세션의 핑거프린트를 읽어 내부 URL에 추가합니다.
https://dev.to/user/post?i=cc9ed033eb(배포 전환 중에 열려 있던 탭처럼
data-style-fingerprint가 없는 경우,?i=i로 우아하게 대체됩니다). -
Fastly에서의 자연스러운 캐시 파티셔닝: Fastly의 엣지 구성은 이미
i를 안전한 파라미터 목록에 화이트리스트로 등록해두고 있습니다. 이 쿼리 파라미터가 캐시 키의 일부가 되면서:v2세션의 사용자가/post?i=v2_fingerprint를 요청하면 Fastly는v2캐시된 조각이 있는지 확인합니다.- 만약 없다면, Fastly는
v2스타일로 컴파일된 최신 조각을 Rails에서 가져옵니다. - 오래된
v1캐시 조각은 단순히 무시되고 결국 자연스럽게 만료됩니다.
-
DOM 스왑 로직 삭제: 캐시 파티셔닝이 제대로 작동하면서, 들어오는 부분 조각들은 활성 DOM의 스타일시트 버전과 일치함이 보장되었습니다. 그 결과, 우리는 클라이언트 측 스타일시트 교체 스크립트를 완전히 삭제했습니다. 이제 브라우저의 활성
<link>태그는 사용자 세션 내내 정적으로 유지됩니다.
브라운필드 개발에서 얻은 교훈
- DOM이 아닌 캐시 계층에서 해결하라: 클라이언트 측 JavaScript에서 DOM 변형, 비동기 CSS 로드, 캐스케이드 우선순위 등을 조율하려 애쓰는 것은 대개 처음부터 엣지 캐시가 올바른 HTML 버전을 제공하도록 하는 것보다 훨씬 더 불안정한 방법입니다. 이 부분이 정말 중요합니다. 제가 여러 프로젝트를 경험하면서 DOM을 직접 조작하는 것보다 HTTP 계층이나 캐시 계층에서 문제를 푸는 것이 장기적으로 안정성이 훨씬 높다는 것을 깨달았습니다.
- 기존 프리미티브를 재활용하라: 새로운 라우터, 엣지 컴퓨팅 재작성, 또는 커스텀 HTTP 헤더가 필요 없었습니다. 기존
?i=...내부 파라미터를 재활용함으로써, 추가 인프라 오버헤드 없이 정확한 캐시 키잉을 구현할 수 있었죠. - 완벽한 재작성보다 실용적인 개선을 추구하라: 이 해결책이 롤링 배포 중 발생할 수 있는 모든 이론적인 엣지 케이스를 마법처럼 막아주는 것은 아닙니다. 하지만 가장 치명적인 문제를 확실하게 해결했습니다. 덕분에 시스템은 훨씬 덜 불안정해졌고, 이제 깔끔하고 예측 가능한 패턴 위에서 다음 개선을 이어나갈 수 있게 되었습니다.
즐거운 코딩 되세요!
원문: https://dev.to/devteam/fixing-delicate-cache-mismatches-in-a-brownfield-spa-a-pragmatic-solution-dk9 수집일: 2026-09-01 02:17:03