RicoCheesethe studio log · v2.0
Live · KRRead posts
목록으로
뉴스PUBLISHED · 2026년 8월 31일·10 MIN READ

브라운필드 SPA의 미묘한 캐시 불일치 해결기: 실용적 접근

DEV가 배포 때마다 겪던 스타일시트 캐시 불일치를 대규모 재작성 없이 해결한 방법. DOM 조작 대신 엣지 캐시 분할로 문제를 옮겨 풀었다.

#webdev#architecture#webperf#rails
Fixing Delicate Cache Mismatches in a Brownfield SPA: A Pragmatic Solution

개요 #

DEV 팀이 오래 묵은 골칫거리 하나를 정리했다. 새 CSS를 배포할 때마다 일부 페이지가 옛날 스타일시트를 물고 오는 문제였다. 서비스 초창기부터 있었고, 그동안 손댄 수정들은 대부분 오래 못 갔다.

이번에 병합한 PR #23789는 접근 자체를 바꿨다. 브라우저에서 스타일시트 태그를 갈아끼우던 클라이언트 스크립트를 아예 지우고, 엣지 캐시가 처음부터 올바른 버전을 내려주도록 만들었다. 완벽한 해결은 아니라고 팀도 인정하지만, 가장 성가셨던 증상은 확실히 사라졌다.

문제가 생기는 지점 #

DEV는 오픈소스 Forem 코드베이스 위에서 돌아간다. Rails 서버 렌더링, Fastly 엣지 캐싱, 그리고 InstantClick 기반의 가벼운 클라이언트 내비게이션이 섞인 하이브리드 구조다. 페이지 전환은 100ms 아래로 떨어질 만큼 빠르지만, 부분 페이지 교체와 공격적인 엣지 캐싱이 만나면서 배포 때마다 까다로운 상황이 만들어졌다.

새 CSS를 배포하면 Rails가 애셋 다이제스트 해시를 새로 만든다. views-v1.cssviews-v2.css로 바뀌는 식이다. 여기서부터 어긋나기 시작한다.

  1. 전체 페이지 로드 — 사용자가 갱신된 홈페이지에 들어온다. 브라우저는 전체 HTML을 받고 <head>에서 최신 v2 스타일시트를 읽는다.
  2. 내부 이동(부분 교체) — 사용자가 글 링크를 누른다. 브라우저를 새로 고치는 대신 InstantClick이 백그라운드로 글을 받아와 #page-content 안쪽만 DOM에 갈아 넣는다.
  3. 엣지 캐시의 함정 — DEV는 글 HTML 조각을 서로게이트 키와 함께 Fastly에 적극적으로 캐싱한다. 그런데 상당수 글은 최신 배포 이전에 캐싱된 것들이라, 캐시된 조각은 여전히 v1 스타일시트를 가리키고 있었다.

안경을 고치는 사람의 손 Photo by Ksenia Chernaya on Pexels

이전 방식이 위태로웠던 이유 #

낡은 CSS로 페이지가 그려지는 걸 막으려고, 팀은 예전에 클라이언트 로직을 하나 붙여뒀다. 들어오는 페이지가 기대하는 스타일시트 경로를 읽어서 DOM의 <link rel="stylesheet"> 요소를 그때그때 바꿔치기하는 방식이었다.

실제로 돌려보니 곳곳이 부서졌다.

  • 최신 홈페이지(v2)에서 캐시된 옛날 글(v1)로 넘어가면, 스크립트는 currentHref (v2) !== expectedHref (v1)를 비교했다.
  • 스크립트는 새로 들어온 페이지가 "목표 상태"라고 가정했다. 그래서 DOM을 v1 스타일시트로 되돌리는 의도치 않은 다운그레이드를 시작했다.
  • 배포 후 v1 애셋 파일이 정리된 뒤였다면 브라우저는 404를 냈다. 파일이 남아 있어 로드에 성공하면 이번엔 새 UI 컴포넌트들이 세션 도중에 CSS를 뺏기고 깨졌다.
  • <head><body> 양쪽에 흩어진 여러 스타일시트 태그를 비동기로 교체하려다 보니 CSS 캐스케이드 경쟁 조건과 FOUC(flash of unstyled content)까지 따라왔다.

해법: 내부 내비게이션 파라미터의 재활용 #

팀은 관점을 바꿨다. 이건 클라이언트에서 DOM을 조작하는 문제가 아니라 캐시 분할(cache partitioning) 문제였다.

Forem은 오래전부터 백그라운드 AJAX 요청에 ?i=i라는 내부 쿼리 파라미터를 조용히 붙여왔다. Rails가 전체 HTML 문서 껍데기 대신 가벼운 부분 레이아웃만 렌더링하도록 알려주는 용도였다. PR #23789는 이 파라미터에 새 역할을 얹었다.

1. 스타일 지문 만들기 #

전체 페이지를 처음 로드할 때 Rails가 핵심 스타일시트(minimal, views, crayons) 다이제스트를 합쳐 10자리 결정적 해시를 계산한다. 그리고 <body>data-style-fingerprint로 심는다.

untitled
html
<body ... data-style-fingerprint="cc9ed033eb">

2. 내부 이동에 지문 붙이기 #

InstantClick이 링크를 미리 받아오거나 요청할 때, 현재 세션의 지문을 읽어 내부 URL에 붙인다.

untitled
plaintext
https://dev.to/user/post?i=cc9ed033eb

배포 전환 시점에 열려 있던 탭처럼 data-style-fingerprint가 없는 경우에는 기존 ?i=i로 자연스럽게 되돌아간다.

3. Fastly에서 캐시가 알아서 갈라진다 #

Fastly 엣지 설정은 이미 i를 안전한 파라미터 목록에 올려두고 있었다. 쿼리 파라미터가 캐시 키의 일부라서, 별도 작업 없이 분할이 이뤄진다.

  • v2 세션의 사용자가 /post?i=v2_지문을 요청하면 Fastly는 v2 조각이 캐시에 있는지 확인한다.
  • 없으면 Rails에서 v2 스타일로 컴파일된 새 조각을 받아온다.
  • 낡은 v1 캐시 조각은 그냥 지나쳐 가고, 시간이 지나면 알아서 만료된다.

4. DOM 교체 로직 삭제 #

캐시 분할이 자리를 잡으면서, 들어오는 부분 조각은 현재 DOM의 스타일시트 버전과 항상 맞아떨어지게 됐다. 클라이언트의 스타일시트 교체 스크립트는 통째로 지웠다. 세션 내내 브라우저의 <link> 태그는 그대로 유지된다.

드라이버로 무언가를 고치는 사람 Photo by Tima Miroshnichenko on Pexels

브라운필드 개발에서 남은 교훈 #

Ben Halpern은 이번 작업에서 세 가지를 짚었다.

첫째, DOM 계층이 아니라 캐시 계층에서 풀 것. 클라이언트 자바스크립트로 DOM 변경과 비동기 CSS 로딩, 캐스케이드 우선순위를 한꺼번에 맞추려 들면 십중팔구 어딘가 부서진다. 엣지 캐시가 애초에 올바른 HTML 버전을 내려주게 만드는 편이 낫다.

둘째, 이미 있는 재료를 다시 쓸 것. 새 라우터도, 엣지 컴퓨팅 전면 재작성도, 커스텀 HTTP 헤더도 필요 없었다. 기존 ?i=... 내부 파라미터를 재활용한 것만으로 정확한 캐시 키를 얻었고, 추가 인프라 비용은 0이었다.

셋째, 완벽한 재작성보다 실용적 개선. 이 수정이 롤링 배포 중에 생길 수 있는 모든 이론적 엣지 케이스를 막아주지는 않는다. 다만 가장 방해가 되던 문제는 확실히 잡았다. 시스템은 훨씬 덜 위태로워졌고, 앞으로 쌓아 올릴 예측 가능한 패턴 하나를 확보했다.

화려한 신기능 이야기는 아니다. 몇 년치 코드와 운영 결정 위에 올라타 조금씩 고쳐 나가는 일, 성숙한 프로덕션 애플리케이션에서 정말 중요한 엔지니어링은 대개 이쪽이라는 게 글의 요지다.


이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.