Free

Rails 의 CDN 캐시 포이즈닝을 Claude 가 찾아내다 — 세 가지 증상, 하나의 근본 원인

세 PR 의 공통 근본 원인: HTML body 는 로그인 상태·UA·flash 에 따라 달라지지만 CDN 의 cache key 는 그걸 모르기 때문에, 콘텐츠가 사용자 간에 섞인다. Claude 에게 PR 체인을 따라 읽게 하여 재사용 가능한 세 가지 패턴을 정리한다: lazy personalize frame, CSS 로 클라이언트에서 숨김, 캐시 경계 가드.


사용자가 사이트에 들어오면, 모든 공개 페이지 하단에 "Content missing" 한 줄이 떠 있었다. 처음 몇 건의 신고는 모두 iOS 에서 — 처음에는 Hotwire Native 클라이언트의 어떤 이상 동작이라고 생각했다. Cloudflare 로그를 파보고 나서야 깨달았다: 이건 클라이언트와 무관하다. CDN 캐시가 오염된 것이고, 3 단(Web / iOS / Android) 모두 걸릴 수 있다. iOS 가 먼저 표면화시켰을 뿐이다.

첫 번째 직감: Hotwire Native 의 버그. 두 번째 직감: Cloudflare 의 어떤 이상한 엣지 동작. 둘 다 아니었다. 이런 부류의 버그를 "CDN 문제" 로 보는 것 자체가 잘못이다 — CDN 은 시킨 대로 일하고 있을 뿐이다.

Claude 에게 PR 체인을 따라 읽게 하니, 연속된 세 PR 의 근본 원인이 모두 같았다: HTML body 가 로그인 상태, UA, flash 같은 요청 속성에 따라 달라지는데, CDN 의 cache key 는 그걸 모른다. 세 가지 다른 증상, 하나의 근본 원인.

함정 1: tab-badge turbo-frame 이 사용자 간에 누수

/, /topics, /square, /coins, /searches/app 페이지는 public_expires_in 으로 CDN 캐시를 탄다. 레이아웃에 이런 부분이 있다:

<% if mobile_hotwire? && user_signed_in? %>
  <%= render "shared/tab_badge" %>
<% end %>

shared/tab_badge 는 turbo-frame 을 렌더링한다:

<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>

의도는 명확하다: 로그인한 Hotwire Native 사용자만 탭바 하단에 미읽은 알림 빨간 점을 본다. Web 은 자체 렌더링 분기가 있어 필요 없고, 미로그인도 필요 없다.

문제: mobile_hotwire? && user_signed_in? 분기가 같은 URL 의 HTML 을 두 가지로 만든다. 그러나 CDN 의 cache key 는 URL 과 Accept 헤더 같은 것만 본다 — 사용자가 로그인했는지 여부는 모른다.

타임라인:

  1. 로그인한 Hotwire Native 사용자가 / 에 접근. CDN 이 처음 origin 을 친 후, tab-badge frame 이 들어간 HTML 을 캐시에 저장한다.
  2. 미로그인 방문자(Web / iOS / Android 어느 쪽이든)가 / 에 접근. CDN 이 같은 캐시를 히트시켜 그 HTML 을 그에게 준다.
  3. 브라우저가 turbo-frame 을 실행하고, src/notifications?badge_only=true 를 요청한다.
  4. NotificationsController 에는 before_action :authenticate_user! 가 있어, 미로그인을 보면 /users/sign_in 으로 302.
  5. Turbo 가 sign_in 페이지에서 id 가 tab_badge 인 frame 을 찾지 못해 "Content missing" 을 출력한다.

3 단(Web / iOS / Android) 미로그인 방문자 모두 걸릴 수 있다 — 어떤 cache slot 이든 로그인 사용자에게 먼저 채워지기만 하면. 신고가 iOS 에 집중된 것은, iOS 사용자의 로그인 비율이 가장 높아 캐시가 로그인 상태로 먼저 오염되기 쉽기 때문 — 미로그인 iOS 방문자가 오염된 항목에 부딪힐 확률이 가장 컸다. Web 과 Android 가 "괜찮았던" 게 아니라, 트리거 확률이 낮고 신고가 적었을 뿐이다. 본질적으로 사이트 전체의 재앙인 버그가 초기 데이터에 의해 "iOS 전용" 으로 위장되어 있었다.

수정은 두 단계, 모두 캐시가 로그인 상태로 분열되지 않게 만드는 것이 목적:

1 단계: 레이아웃의 && user_signed_in? 를 빼야 한다. 그러지 않으면 영원히 cache key 알아맞히기 게임을 하게 된다:

<%# 모든 mobile_hotwire? 요청에 대해 렌더링 — 로그인 여부 무관.
    user_signed_in? 분기는 같은 URL 의 HTML 을 두 변형으로 쪼개고,
    어느 쪽이 CDN slot 을 차지하면 다음 사람에게 그 버전이 가게 된다. %>
<% if mobile_hotwire? %>
  <%= render "shared/tab_badge" %>
<% end %>

2 단계: /notifications?badge_only=true frame 엔드포인트는 미로그인 요청에 대해서도 구조적으로 동일한 200 frame 을 돌려줘야 한다. 302 면 안 된다. 동시에 엔드포인트 자체는 private, no-store 여야 한다 — CDN 이 한 사용자의 미읽음 수를 다른 사용자와 공유하지 않게.

class NotificationsController < ApplicationController
  before_action :authenticate_user!, except: [:index]

  def index
    if params[:badge_only] || params[:count_only]
      response.headers["Cache-Control"] = "private, no-store"
      redirect_to notifications_path and return unless turbo_frame_request?
      @unread_count = user_signed_in? ? current_user.notifications.unread.count : 0
      if params[:badge_only]
        render(partial: "notifications/tab_badge")
      else
        render(partial: "notifications/bell")
      end
      return
    end

    authenticate_user!
    # ... 정상 index 로직
  end
end

authenticate_user!index 에서만 제외된다는 점에 주의 — 그것도 frame 서브 요청에 대해서만. frame 이 아닌 요청은 여전히 redirect 를 탄다.

같은 PR 안에 모양이 똑같은 버그가 하나 더 있었다: 미로그인 Hotwire Native 클라이언트 사용자도 compose FAB(플로팅 버튼)를 보고 있었다 — 같은 오염, 로그인 상태로 오염된 캐시가 미로그인 방문자에게 누수된 것. 원래는 레이아웃에서 직접 user_signed_in? 로 stimulus controller div 를 렌더링할지 결정하고 있었다. 수정: FAB 도 lazy frame 으로 바꿔, /personalize/compose_fab 라는 private, no-store 엔드포인트가 렌더링하게 한다.

<%# 레이아웃은 항상 빈 frame 을 출력 %>
<% if mobile_hotwire? %>
  <turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# /personalize/compose_fab 템플릿 %>
<%= turbo_frame_tag "compose-fab" do %>
  <% if user_signed_in? %>
    <div data-controller="bridge--compose-fab" class="hidden"></div>
  <% end %>
<% end %>

레이아웃은 매번 빈 frame 을 토하고, frame 안의 엔드포인트가 로그인 상태에 따라 controller div 를 끼워넣을지 결정한다. 메인 페이지에는 "사용자별 분기" 마크업이 전혀 없다 — CDN 이 어떻게 캐싱해도 망가지지 않는다.

함정 2: flash 메시지가 다른 사용자에게 옮겨붙다

117 출시 며칠 후, 또 다른 신고: 미로그인 사용자가 홈에 들어왔는데 페이지 상단에 "로그인 성공" flash 배너가 떠 있어서 어리둥절해했다고.

같은 병, 다른 증상.

레이아웃에는:

<%= render "shared/_notice" %>

이 줄이 flash[:notice] / flash[:alert] 를 렌더링한다. redirect 후 첫 요청은 flash 를 가지고 온다, 예:

  • redirect_to root_path(로그인 성공 후)
  • redirect_to root_path(로그아웃 후)
  • Pundit 의 redirect_back(fallback_location: ...)

redirect 의 착륙지가 또 public_expires_in 경로면, flash 가 캐시된 HTML 에 구워진다. 다음 익명 방문자가 같은 URL 을 누르면 CDN 이 그 HTML 을 돌려준다 — 다른 사람의 "로그인 성공" 을 본다.

수정의 유혹은 캐시 가능한 모든 view 를 리팩토링해 flash 렌더링을 frame 으로 옮기는 것이다. 그러나 더 견고한 방법은 캐시 경계에서 막는 것: 이 응답이 flash 를 담고 있다면 public 캐시 안 함.

def public_expires_in(duration)
  return unless Rails.env.live?
  # flash 는 layout 에서 직접 렌더링되므로, 이 응답을 캐싱하면
  # 이전 사용자의 알림이 다음 익명 방문자에게 누수된다
  if flash.any?
    response.headers["Cache-Control"] = "private, no-store"
  else
    expires_in duration, public: true
  end
end

이 방식의 장점:

  1. view 를 안 바꿔도 된다public_expires_in 을 쓰는 모든 페이지가 자동으로 혜택을 본다
  2. 캐시를 낭비하지 않는다 — 다음에 flash 없는 요청이 오면 CDN 이 다시 origin 에서 깨끗한 HTML 을 가져와 캐시가 정상 작동한다
  3. flash 없는 일반 트래픽은 전혀 영향 없음 — 99% 의 요청은 그대로 캐싱된다

겸사겸사 coins#show 가 자체적으로 expires_in 5.minutes, public: true 를 써서 helper 를 우회하고 있는 걸 발견. 일괄로 public_expires_in 을 타게 바꿨다 — 그러지 않으면 같은 flash 누수 버그가 거기서 다시 튀어나온다.

배포 후엔 반드시 cloudflare:purge_personalized_pages 를 한 번 돌려야 한다 — 이미 오염된 캐시 항목은 자동 만료되지 않고, 해당 URL 의 TTL(/topics 는 1 주)이 끝날 때까지 남는다.

함정 3: 로그인 사용자가 /topics 에서 "+ 새 토픽" 버튼을 못 봄

몇 시간 더 지나, 세 번째 증상: 로그인 사용자가 /topics 에 들어가면 "+ 새 토픽" 버튼이 안 보인다. 새로고침해도 마찬가지. 그런데 같은 사용자가 다른 페이지에서 /topics?r=1 처럼 임의 파라미터(캐시 우회용)를 붙이면 버튼이 돌아온다.

/topicspublic_expires_in 1.week 로 가장 강하게 캐싱된다. 원래 view:

<% unless mobile_hotwire? %>
  <a href="<%= new_topic_path %>" class="btn-new-topic">+ Create</a>
<% end %>

<% if mobile_hotwire? %>
  <% if user_signed_in? %>
    <button data-controller="bridge--new-topic">+</button>
  <% end %>
<% end %>

가드 두 개: UA 가드(web vs native), auth 가드(로그인 여부). CDN 은 둘 다 모른다 — 처음 origin 을 친 게 어떤 사용자인지에 따라 캐시 모양이 정해진다. 첫 익명 web 방문자는 "native bridge button 없음, web button 있음" 을 캐시에 넣고, 첫 로그인 native 방문자는 "bridge button 있음, web button 없음" 을 캐시에 넣는다 — 다음 사람의 운명은 cache slot 이 맞느냐에 달렸다.

117 와 같은 접근:

Web 버튼 — 분기 없이, 항상 렌더링:

<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>

.web-only 가 CSS 로 native UA 에서 숨긴다. CDN 이 캐싱하는 HTML 은 항상 같은 모양, 버튼은 항상 거기 있고, UA 가 표시 여부를 결정 — CSS 는 client-side 결정이라 cache 는 관여하지 않는다.

Native bridge 버튼 — lazy personalize frame 으로 옮긴다:

<% if mobile_hotwire? %>
  <turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# /personalize/topic_new_button 템플릿 %>
<%= turbo_frame_tag "topic-new-button" do %>
  <% if user_signed_in? %>
    <button data-controller="bridge--new-topic">+</button>
  <% end %>
<% end %>

메인 페이지 HTML 에는 <turbo-frame> 컨테이너와 web 버튼만 있다 — 둘 다 사용자별로 분기하지 않는다. "사용자에 따라 렌더링 여부를 결정" 하는 마크업은 모두 personalize 엔드포인트로 옮겨갔고, 그 엔드포인트는 private, no-store 라 절대 CDN 에 들어가지 않는다.

진짜 교훈: CDN cache poisoning 은 CDN 의 버그가 아니다

각 증상은 그 순간엔 모두 다른 버그처럼 보인다. "Content missing" 은 Hotwire Native 의 문제처럼, flash 누수는 cookies/session 설정 문제처럼, 버튼 사라짐은 view template 의 로직 분기 오류처럼.

세 PR 을 함께 놓고 보면 근본 원인은 하나뿐이다: HTML body 의 내용이 cache key 외부의 요청 속성으로 결정된다. CDN 의 일은 cache key 로 HTML 을 캐싱하는 것 — 그 HTML 이 사실은 로그인 사용자에게만, 또는 Hotwire Native 에게만, 또는 flash 가 있는 요청에만 옳다는 것을 알 도리가 없다.

이 부류를 근절하기 위한 재사용 가능한 세 가지 패턴:

  1. 변동을 캐시되는 body 밖으로 밀어낸다 — "사용자별 분기" 마크업은 모두 lazy turbo-frame 으로 바꾸고, private, no-store personalize 엔드포인트가 렌더링한다. 메인 페이지 HTML 은 불변, CDN 이 어떻게 캐시해도 옳다.

  2. 공통 버전을 렌더링하고 클라이언트에서 숨긴다 — 예를 들어 web 버튼은 항상 HTML 에 있고 CSS 의 .web-only 가 native 에서 숨긴다. 분기를 CSS/JS 층으로 밀어 cache key 안의 HTML 은 완전히 통일.

  3. 캐시 경계 가드 — 상태(flash 등)가 응답에 새어 들어간 요청은, 캐시 경계에서 Cache-Controlprivate, no-store 로 강등. 일반 요청은 무영향.

세 패턴의 공통점: "캐시되는 HTML" 과 "사용자별 분기 마크업" 이 절대 겹치지 않게 한다.

수정 배포 후에 한 가지 더 할 일: CDN 의 오염된 항목은 자동 만료되지 않고 TTL 은 최장 1 주. 일회성 rake task cloudflare:purge_personalized_pages 를 작성해 모든 의심 경로를 능동적으로 purge 한다 — 그러지 않으면 캐시 자연 만료까지 버그가 계속 튀어나온다.

덧붙여: lazy frame 의 문제는 캐시뿐이 아니다

PR #122 는 같은 시기에 발견됐다. 모양은 같고 버그 유형은 다르다 — cache poisoning 과 같은 "안 보이는" 메커니즘을 공유하기 때문에 별도로 다룰 가치가 있다.

/following 페이지는 lazy turbo-frame 으로 다음 페이지를 로드한다(infinite scroll 의 정석). frame 의 srcset_load_more_path 라는 helper 가 계산한다 — 현재 controller/action 에서 다음 페이지 URL 을 결정.

def set_load_more_path(page:, anchor_id: nil)
    if controller_name == "coins" && action_name == "show"
        path = coin_symbol_path(...)
    elsif controller_name == "posts" && action_name == "hot"
        path = hot_path(...)
    # ... 분기 다수 ...
    elsif controller_name == "users" && action_name == "index"
        path = users_path(page: page, ...)
    # ...
    else
        path = posts_path(page: page, ...)  # ← 폴백
    end
end

posts#following 이라는 action 은 분기 목록에 없어서, 마지막 posts_path 폴백으로 떨어진다 — 즉 /posts, explore feed.

효과: /following 1 페이지는 정상(controller action 자체가 @posts = following_posts 를 세팅), 그러나 2 페이지 이후 lazy frame 이 슬그머니 /posts?page=2 를 가져와 explore feed 내용을 로드한다 — 팔로우하지 않는 사람의 글이 흘러들어온다.

수정은 짧다:

elsif controller_name == "posts" && action_name == "following"
    path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)

이건 앞의 세 cache poisoning 버그와 같은 카테고리가 아니다set_load_more_path 의 분기 오류는 CDN 과 무관. 그러나 같은 "잘 안 보이는" 메커니즘을 공유한다: lazy turbo-frame 이 로드하는 내용은 능동적으로 review 하지 않는다.

117 의 frame 은 오류 시 조용히 302, #119/#121 의 캐시 내용이 잘못돼 있어도 안 보이고, #122 의 frame 경로가 잘못돼 있어도 1 페이지만 봐서는 모른다. "동적 콘텐츠" 를 lazy frame 에 넣는 순간, frame 로드 후의 상태를 능동적으로 review 해야 한다 — Claude 가 PR review 단계에서 잡아준 게 바로 이 "frame 로드 후에 무슨 일이 일어나는가" 체크 항목이다.

자기 점검 체크리스트

Rails 앱 앞에 CDN(Cloudflare, Fastly, 어디든)이 있다면 아래 조합 중 하나는 거의 확실히 걸려 있다:

  • public_expires_in / expires_in ..., public: true 를 쓰는 view template 에 if user_signed_in? 같은 분기가 있는가?
  • flash 가 layout 에서 직접 렌더링되는가? public_expires_in 경로가 flash 가 있을 때 캐시되지 않는가?
  • Hotwire Native 앱이라면, mobile_hotwire? 로 가드된 마크업이 캐시 가능 경로에 등장하지 않는가?
  • turbo-frame src 엔드포인트의 controller 는 before_action :authenticate_user! 를 쓰는가? 미로그인 요청에서 302 하지 않는가?
  • lazy turbo-frame 의 src 경로가 helper 에서 계산되는가? 그 helper 에 폴백 분기가 있는가? 폴백이 잘못된 URL 인가?

이 체크리스트를 Claude 에게 한 바퀴 돌리게 하라 — 한 항목이라도 히트하면 다음 PR 거리다. 이런 버그는 자연적으로 떠오르지 않는다. lazy frame 은 조용히 실패하고, CDN 은 조용히 히트하고, CSS 는 조용히 숨기기 때문. 세 가지 사일런트 메커니즘이 겹치면 버그는 프로덕션에 몇 달이고 숨어 있을 수 있다.