상황
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 |
| 세션 의존 콘텐츠 |
|
| 부분적 세션 의존 | 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을 사용하여 세션과 관련 없는 부분만 캐싱할 수 있다.
학습 포인트
- HTTP 캐싱의 동작 원리:
ETag,If-None-Match,304 Not Modified의 메커니즘 - 브라우저별 캐시 정책 차이: Chrome과 Firefox의 다른 동작
- Turbo와 HTTP 캐싱은 별개: Turbo 문제로 보였지만 실제로는 HTTP 캐싱 문제
- 로그 분석의 중요성: 304 응답을 통해 근본 원인 발견
향후 개선 방향
- [ ] Fragment Caching 도입으로 성능과 정확성 모두 확보하기
- [ ] 관리자 전용 페이지는 별도 레이아웃으로 분리하기
- [ ] CDN(예: Cloudflare) 캐시 설정 최적화
- [ ] 캐시 무효화 자동화 (로그인/로그아웃 시)