브라우저별 HTTP 캐싱과 세션 상태 불일치 문제 해결하기

브라우저별 HTTP 캐싱과 세션 상태 불일치 문제 해결하기

상황

Rails 8 기반 블로그 애플리케이션에서 관리자 로그인 후 UI가 업데이트되지 않는 문제가 발생했다. 특히 Chrome 브라우저에서 문제가 심각했으며, Firefox에서는 정상 동작하는 브라우저별 차이도 확인되었다.

발생 증상

증상 Firefox Chrome
로그인 후 'Write Post' 버튼 표시 ✅ 정상 ❌ 미표시
로그인 후 'Logout' 버튼 표시 ✅ 정상 ❌ 미표시
게시글 방문 후 버튼 표시 ✅ 정상 ✅ 정상
세션 쿠키 생성 ✅ 정상 ✅ 정상
게시글 작성/수정 기능 ✅ 정상 ✅ 정상

문제의 핵심

  • 로그인은 성공함 (세션 쿠키 _blog_with_rails_session 확인됨)
  • 로그인 후 기능(게시글 작성/수정)은 정상 동작
  • 하지만 푸터의 관리자 메뉴가 업데이트되지 않음
  • 시크릿 모드에서도 동일한 문제 발생

비즈니스 영향

  • UX 혼란: 로그인했는데 로그인 버튼이 계속 표시됨
  • 관리 불편: 'Write Post' 버튼이 보이지 않아 URL 직접 입력 필요
  • 일관성 부재: 브라우저별로 다른 동작

원인분석

문제 탐색 과정

1차 가설: Turbo Drive 캐싱 문제

처음에는 Turbo Drive의 페이지 캐싱이 원인이라고 판단했다. Turbo는 페이지를 캐싱하여 빠른 네비게이션을 제공하는데, 세션 상태 변경이 캐시에 반영되지 않을 수 있다.

시도한 해결책:

# SessionsController
response.headers["Turbo-Visit-Control"] = "reload"
redirect_to_root_path

결과

  • ❌ 문제 지속

2차 가설: Turbo Stream을 통한 리다이렉트

Turbo Stream으로 JavaScript를 실행하여 페이지를 새로고침하려 했다.

시도한 해결책:

<%# create.turbo_stream.erb %>
<turbo-stream action="append" target="body">
  <template>
    <script>
      Turbo.cache.clear();
      window.location.href = "<%= root_path %>";
    </script>
  </template>
</turbo-stream>

결과

  • ❌ 리다이렉트 자체가 발생하지 않음

Turbo Stream 학습 포인트: Turbo Streams의 액션은 8가지만 존재한다: append, prepend, replace, update, remove, before, after, refresh :redirect:visit 같은 액션은 존재하지 않는다.

3차 가설: 일반 HTTP 리다이렉트 사용

Turbo를 완전히 비활성화하고 일반 폼 제출 방식으로 변경했다.

시도한 해결책:

<%# sessions/new.html.erb %>
<%= form_with url: login_path, method: :post, local: true do |form| %>
# SessionsController
redirect_to root_path, notice: "Logged in successfully."

결과

  • ✅ 리다이렉트는 성공
  • ❌ 여전히 'Login' 버튼이 표시됨

근본 원인 발견

서버 로그를 확인한 결과, 304 Not Modified 응답을 발견했다:

[796cc5d2] Started GET "/" for 39.124.186.141 at 2025-12-31 00:36:58 +0000
[796cc5d2] Processing by PostsController#index as HTML
[796cc5d2] Completed 304 Not Modified in 4ms

원인: PostsController#index에서 fresh_when을 사용하여 HTTP 캐싱을 구현하고 있었는데, 캐시 키에 세션 상태(admin_signed_in?)가 포함되지 않았다.

# 문제가 있던 코드
def index
  @posts = Post.order(published_at: :desc).page(params[:page]).per(10)

  latest_post = @posts.first
  cache_key = [ latest_post&.cache_key_with_version, params[:page], params[:category] ]
  fresh_when(etag: cache_key, last_modified: latest_post&.updated_at)
  response.headers["Cache-Control"] = "public, no-cache"
end

문제 발생 메커니즘

sequenceDiagram
    participant Browser
    participant Server

    Note over Browser,Server: 1. 로그인 전 루트 페이지 방문
    Browser->>Server: GET / (ETag 없음)
    Server->>Browser: 200 OK + ETag-A (Login 버튼 포함)
    Browser->>Browser: ETag-A 저장

    Note over Browser,Server: 2. 로그인 성공
    Browser->>Server: POST /login
    Server->>Browser: 302 Redirect to /

    Note over Browser,Server: 3. 루트 페이지로 리다이렉트
    Browser->>Server: GET / + If-None-Match: ETag-A
    Server->>Server: fresh_when 평가
cache_key에 세션 상태 없음
ETag-A == 새 ETag Server->>Browser: 304 Not Modified Browser->>Browser: 캐시된 페이지 표시
(Login 버튼 표시) ❌

브라우저별 차이 원인

브라우저 캐시 정책 증상
Chrome 적극적 캐싱, 리다이렉트 후에도 If-None-Match 헤더 전송 304 반환 
→ 캐시된 페이지 표시
Firefox 상대적으로 덜 적극적, 리다이렉트 후 캐시 무시 경향 200 반환 
→ 새 페이지 표시

가설 및 해결방법

가설 설정

가설: fresh_when / stale?의 캐시 키에 세션 상태를 포함하거나, 캐싱을 비활성화하면 문제가 해결될 것이다.

근거:

  • HTTP 캐싱은 동일한 ETag에 대해 304를 반환
  • 세션 상태가 캐시 키에 없으면 로그인 전/후 동일한 ETag 생성
  • 브라우저는 304 응답 시 캐시된 콘텐츠를 사용

해결 전략

flowchart TD
    A[로그인 후 루트 페이지 요청] --> B{fresh_when 평가}

    subgraph "수정 전 (문제)"
        B --> C[cache_key: post만 포함]
        C --> D[ETag 동일]
        D --> E[304 Not Modified]
    end

    subgraph "수정 후 (해결)"
        B --> F[cache_key: post + admin_signed_in?]
        F --> G[ETag 다름]
        G --> H[200 OK + 새 콘텐츠]
    end

    style E fill:#f44,color:#fff
    style H fill:#4CAF50,color:#fff

두 가지 해결 방안

방안 장점 단점
A: 캐시 키에 세션 상태 추가 캐싱 혜택 유지 복잡도 증가
B: 캐싱 비활성화 단순함, 확실함 성능 저하 가능

세션 상태에 따라 다른 콘텐츠를 제공해야 하므로, 방안 B(캐싱 비활성화)를 선택했다.

해결시도 및 정량적 비교

# PostsController (app/controllers/posts_controller.rb)
class PostsController < ApplicationController
  def index
    @posts = Post.order(published_at: :desc)
    @posts = @posts.by_category(params[:category]) if params[:category].present?
    @posts = @posts.page(params[:page]).per(10)

    # HTTP 캐싱 비활성화 - 세션 상태 변경 시 항상 새로운 콘텐츠 제공
    response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
    response.headers["Pragma"] = "no-cache"
    response.headers["Expires"] = "0"

    if params[:page].present? || params[:category].present?
      render :show_all
    end
  end

  def show
    @recent_posts = Post.recent.limit(5)
    @archives = Post.yearly_archive_counts
    @caption = @post.cover_image_caption
    @prev_post = @post.previous_post
    @next_post = @post.next_post
    @recommended_posts = @post.recommended_posts(limit: 3)

    # HTTP 캐싱 비활성화
    response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
    response.headers["Pragma"] = "no-cache"
    response.headers["Expires"] = "0"
  end
end
# SessionsController (app/controllers/sessions_controller.rb)
class SessionsController < ApplicationController
  def new
    redirect_to root_path, notice: "Already logged in." if admin_signed_in?
  end

  def create
    admin_password = ENV["ADMIN_PASSWORD"] || "password"

    if params[:password] == admin_password
      session[:admin_id] = "admin"
      redirect_to root_path, notice: "Logged in successfully."
    else
      flash.now[:alert] = "Invalid password."
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    session[:admin_id] = nil
    redirect_to root_path, notice: "Logged out."
  end
end
<%# 로그인 폼 (app/views/sessions/new.html.erb) %>
<%= form_with url: login_path, method: :post, local: true do |form| %>
  <%# Turbo 비활성화, 일반 HTTP 폼 제출 %>
<% end %>

변경 사항 요약

파일 변경 내용
posts_controller.rb fresh_when / stale? 제거, no-store 캐시 헤더 추가
sessions/new.html.erb local: true 추가로 Turbo 비활성화

정량적 비교

응답 상태 코드 비교

시나리오 수정 전 수정 후
로그인 후 루트 페이지
304 Not Modified 200 OK
로그아웃 후 루트 페이지 304 Not Modified 200 OK

캐시 헤더 비교

# 수정 전
Cache-Control: public, no-cache
(ETag 기반 조건부 GET 허용)

# 수정 후
Cache-Control: no-store, no-cache, must-revalidate, private
Pragma: no-cache
Expires: 0
(캐싱 완전 비활성화)

브라우저별 동작 비교

브라우저 수정 전 수정 후
Chrome ❌ Login 버튼 표시 ✅ Write Post/Logout 표시
Chrome (시크릿) ❌ Login 버튼 표시 ✅ Write Post/Logout 표시
Firefox ✅ 정상 ✅ 정상
Safari 미확인 ✅ 정상 예상

결론

핵심 교훈

HTTP 캐싱(fresh_when, stale?)을 사용할 때 세션 상태에 따라 다른 콘텐츠를 제공하는 페이지는 특별한 주의가 필요하다.

HTTP 캐싱과 세션 상태 체크리스트

✅ 캐싱 적용 전 확인할 것

  • [ ] 페이지에 세션 상태에 따라 다른 콘텐츠가 있는가?
  • [ ] admin_signed_in?, current_user 등을 뷰에서 사용하는가?
  • [ ] 로그인/로그아웃 후 UI가 변경되어야 하는가?

✅ 캐싱 적용 시 선택지

상황 권장 방법
세션 무관 콘텐츠 fresh_when + Cache-Control: public
세션 의존 콘텐츠

no-store 또는 캐시 키에 세션 상태 포함

부분적 세션 의존 Fragment Caching + Russian Doll Caching

✅ 캐시 헤더 가이드

# 완전 캐싱 비활성화 (세션 의존 페이지)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"

# 조건부 캐싱 (세션 상태 포함)
cache_key = [ @post.cache_key_with_version, admin_signed_in? ]
fresh_when(etag: cache_key, last_modified: @post.updated_at)
response.headers["Cache-Control"] = "private, no-cache"

Turbo와 HTTP 캐싱의 관계

계층 역할 캐시 위치
Turbo Drive 페이지 프리뷰, 복원 JavaScript 메모리
HTTP 캐싱 조건부 GET (304) 브라우저 디스크/메모리
CDN/프록시 엣지 캐싱 Cloudflare 등

이번 문제는 HTTP 캐싱 레벨에서 발생했으며, Turbo Drive와는 직접적인 관련이 없었다.

결과

적용 범위

컨트롤러 영향받는 엔드포인트 변경 사항
PostsController#index 캐싱 비활성화 /, /posts 캐싱 비활성화
PostsController#show /posts/:id 캐싱 비활성화
SessionsController /login 단순화

성과 요약

  • 로그인 후 즉시 관리자 메뉴 표시 (Write Post, Logout)
  • 모든 브라우저에서 일관된 동작 (Chrome, Firefox 등)
  • 시크릿 모드에서도 정상 동작
  • 코드 단순화 (Turbo Stream 로직 제거)

성능 트레이드오프

지표 수정 전 수정 후 영향
304 응답 비율 높음 0% 대역폭 증가
평균 응답 크기 0 (캐시) ~50KB 대역폭 증가
서버 렌더링 조건부 항상 CPU 증가
UX 일관성 핵심 개선

참고: 성능 최적화가 필요한 경우, Fragment Caching을 사용하여 세션과 관련 없는 부분만 캐싱할 수 있다.

학습 포인트

  1. HTTP 캐싱의 동작 원리: ETag, If-None-Match, 304 Not Modified의 메커니즘
  2. 브라우저별 캐시 정책 차이: Chrome과 Firefox의 다른 동작
  3. Turbo와 HTTP 캐싱은 별개: Turbo 문제로 보였지만 실제로는 HTTP 캐싱 문제
  4. 로그 분석의 중요성: 304 응답을 통해 근본 원인 발견

향후 개선 방향

  • [ ] Fragment Caching 도입으로 성능과 정확성 모두 확보하기
  • [ ] 관리자 전용 페이지는 별도 레이아웃으로 분리하기
  • [ ] CDN(예: Cloudflare) 캐시 설정 최적화
  • [ ] 캐시 무효화 자동화 (로그인/로그아웃 시)

참고 자료

Previous Post

한 해를 마무리하는 20가지 질문

스스로에게 2025년을 되돌아보는 시간에 관하여 이야기합니다.

한 해를 마무리하는 20가지 질문

Next Post

Rails 기반의 블로그에서 N+1 쿼리 문제 해결하기

ActiveRecord의 Eager Loading을 활용하여 데이터베이스 쿼리를 81.8% 감소시킨 최적화 과정에 대해 이야기합니다.

Rails 기반의 블로그에서 N+1 쿼리 문제 해결하기

Recommended Reading

scroll to top