Rails 8 + Hotwire Native로 iOS 앱을 만들면서 겪은 실전 이슈들을 정리했다. 공식 문서에 안 나오는 것들 위주로.
1. WKWebView 세션 쿠키가 앱 종료 시 날아간다
증상
앱을 완전히 종료(kill) 후 재실행하면 로그인이 풀린다.
원인
Rails 기본 cookie_store는 만료 시간이 없는 세션 쿠키를 생성한다. WKWebView는 앱 종료 시 세션 쿠키를 삭제할 수 있다.
해결
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
key: "_app_session",
expire_after: 30.days,
same_site: :lax
expire_after를 설정하면 영속 쿠키가 되어 앱 종료 후에도 유지된다.
same_site: :strict는 절대 사용하지 말 것. WKWebView에서 쿠키 전송이 안 된다.
2. API 컨트롤러에서 CSRF 토큰 문제
증상
네이티브 앱에서 POST /api/v1/device_tokens 호출 시 422 또는 CSRF 에러.
원인
API base controller가 ApplicationController를 상속하면서 protect_from_forgery가 적용된다. 네이티브 앱은 HTML meta 태그에서 CSRF 토큰을 읽을 수 없다.
해결
class Api::V1::BaseController < ApplicationController
skip_forgery_protection
before_action :authenticate_user!
end
웹 뷰 내 폼은 Turbo가 자동으로 CSRF 토큰을 첨부하므로 문제없다. API 네임스페이스만 skip하면 된다.
3. CSRF 토큰 만료 시 500 에러 대신 우아한 처리
증상
로그아웃 후 reset_session → 이전 페이지의 CSRF 토큰이 stale → 폼 제출 시 ActionController::InvalidAuthenticityToken 500 에러.
해결
class ApplicationController < ActionController::Base
rescue_from ActionController::InvalidAuthenticityToken, with: :handle_invalid_csrf
private
def handle_invalid_csrf
sign_out if user_signed_in?
respond_to do |format|
format.html { redirect_to new_session_path, alert: "세션이 만료되었습니다." }
format.json { render json: { error: "session_expired" }, status: :unauthorized }
format.any { head :unauthorized }
end
end
end
사용자는 500 페이지 대신 로그인 화면으로 부드럽게 이동한다.
4. ActionCable 인증이 session과 안 맞는다
증상
ActionCable WebSocket 연결이 인증 실패. reject_unauthorized_connection 발생.
원인
# 문제: cookies.encrypted[:user_id]를 체크하지만 실제로 설정된 적 없음
def find_verified_user
User.find_by(id: cookies.encrypted[:user_id] || request.session[:user_id])
end
sign_in 메서드는 session[:user_id]만 설정한다. cookies.encrypted[:user_id]는 한 번도 설정된 적 없다.
해결
def find_verified_user
User.find_by(id: request.session[:user_id])
end
인증 방식을 통일하자. session 기반이면 session만, cookie 기반이면 cookie만.
5. 로그인 페이지 모바일 웹 UX — 폼이 스크롤 아래로 밀린다
증상
모바일 웹에서 로그인 페이지 접속 시, 역할 소개 카드(운영자/선수/스태프) 3개가 먼저 보이고 실제 로그인 폼은 스크롤해야 보인다.
해결
<%# 모바일: 폼 먼저 / 데스크탑: 2컬럼 %>
<div class="flex flex-col-reverse gap-10 lg:grid lg:grid-cols-2 lg:items-start">
<%# 역할 카드: 데스크탑에서만 표시 %>
<div class="hidden lg:block">...</div>
<%# 로그인 폼: 항상 먼저 %>
<div class="mx-auto w-full max-w-md">...</div>
</div>
핵심: flex-col-reverse + hidden lg:block 조합으로 모바일에서는 폼만, 데스크탑에서는 2컬럼 레이아웃.
6. time_ago_in_words가 “약 1시간"으로 뭉개진다
증상
대회 당일 “다음 경기 32분 후"가 필요한데 “약 1시간 후"로 표시된다.
원인
Rails time_ago_in_words는 45분 이상이면 “about 1 hour"로 반올림한다.
해결
분 단위 직접 계산:
<% diff_minutes = ((scheduled_at - Time.current) / 60).round %>
<% if diff_minutes > 0 %>
<span class="text-amber-600">
(<%= diff_minutes >= 60 ? "#{diff_minutes / 60}시간 #{diff_minutes % 60}분 후" : "#{diff_minutes}분 후" %>)
</span>
<% elsif diff_minutes > -5 %>
<span class="text-emerald-600">(곧 시작)</span>
<% else %>
<span class="text-red-500">
(<%= (-diff_minutes) >= 60 ? "#{(-diff_minutes) / 60}시간 #{(-diff_minutes) % 60}분 전" : "#{-diff_minutes}분 전" %>)
</span>
<% end %>
추가로 한국어 distance_in_words 번역도 필요하다:
ko:
datetime:
distance_in_words:
x_minutes:
one: "1분"
other: "%{count}분"
about_x_hours:
one: "약 1시간"
other: "약 %{count}시간"
# ... 전체 키 필요
7. Turbo Frame 안의 링크가 외부 페이지로 안 간다
증상
스코어보드(turbo_frame_tag lazy load) 안의 “코트 맵” 버튼을 누르면 Content missing 에러. 페이지 이동이 안 된다.
원인
Turbo Frame 내부의 링크는 기본적으로 같은 frame 안에서 응답을 로드하려 한다. 외부 페이지를 frame 안에 넣으려 하니 turbo-frame ID가 없어서 실패.
해결
<%= link_to some_path,
data: { turbo_frame: "_top" } do %>
...
<% end %>
data-turbo-frame="_top"으로 전체 페이지 네비게이션을 강제한다.
8. 채팅 메시지 영역이 스크롤 안 된다 (flex 레이아웃 함정)
증상
채팅 메시지가 27개인데 스크롤바 없이 전체 페이지가 늘어난다. overflow-y-auto가 안 먹힌다.
원인
CSS flexbox에서 flex: 1인 자식은 기본 min-height: auto로 컨텐츠 크기만큼 커진다. overflow가 작동하려면 부모가 높이를 제한해야 한다.
해결
<!-- 부모: flex 컬럼 + 고정 높이 -->
<div class="flex flex-col" style="height: calc(100dvh - 10rem);">
<div class="shrink-0">헤더</div>
<!-- 핵심: min-h-0 + overflow-hidden -->
<div class="relative flex-1 min-h-0 overflow-hidden">
<div class="h-full overflow-y-auto">
<!-- 메시지들 -->
</div>
</div>
<div class="shrink-0">입력창</div>
</div>
min-h-0이 없으면 flex-1이 컨텐츠를 감싸지 않고 늘어난다. 이건 Tailwind CSS FAQ에도 나오는 흔한 함정이다.
9. 채팅 스크롤 FAB 버튼 (맨 위로 / 맨 아래로)
구현
Stimulus 컨트롤러에 스크롤 감지를 추가하고, 위치에 따라 FAB 버튼을 토글한다:
// chat_controller.js
_onScroll() {
const el = this.messagesTarget
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const distFromTop = el.scrollTop
// 맨 아래에서 200px 이상 떨어지면 "↓ 맨 아래로" 표시
this.scrollDownTarget.classList.toggle("hidden", distFromBottom < 200)
// 맨 위에서 300px 이상 내려가면 "↑ 맨 위로" 표시
this.scrollUpTarget.classList.toggle("hidden", distFromTop < 300)
}
goToBottom() {
this.messagesTarget.scrollTo({ top: this.messagesTarget.scrollHeight, behavior: "smooth" })
}
goToTop() {
this.messagesTarget.scrollTo({ top: 0, behavior: "smooth" })
}
FAB 버튼은 absolute 포지셔닝으로 메시지 영역 위에 떠 있게 한다:
<button data-chat-target="scrollDown"
data-action="click->chat#goToBottom"
class="hidden absolute bottom-3 right-3 z-10
flex h-9 w-9 items-center justify-center
rounded-full bg-emerald-500 text-white shadow-md">
<svg><!-- ↓ 아이콘 --></svg>
</button>
10. 대회 생성 시 단식/복식/혼합복식 선택
설계
기존 Tournament 모델에 match_type enum을 추가:
enum :match_type, { singles: 0, doubles: 1, mixed_doubles: 2 }, default: :singles
def players_per_team
singles? ? 1 : 2
end
format_summary에도 반영해서 대회 상세에 “복식 · 3세트 6게임 · 듀스 · 7점 타이브레이크” 형태로 표시.
이미 MatchPlayer에 team_side와 position이 있어서 한 팀에 여러 선수를 넣는 구조는 갖춰져 있었다. 모델 레벨에서 match_type 정보만 추가하면 됐다.
보너스: 채팅 메시지에 아바타 표시
프로필 이미지가 있으면 Active Storage variant, 없으면 이름 첫 글자 + MD5 해시 색상:
<% if sender_user&.profile_image&.attached? %>
<%= image_tag sender_user.profile_image.variant(resize_to_fill: [32, 32]),
class: "h-8 w-8 rounded-full object-cover" %>
<% else %>
<div class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold text-white"
style="background: #<%= Digest::MD5.hexdigest(name)[0..5] %>;">
<%= name.first %>
</div>
<% end %>
체크리스트
| # | 항목 | 확인 |
|---|---|---|
| 1 | session_store에 expire_after + same_site: :lax | |
| 2 | API 컨트롤러 skip_forgery_protection | |
| 3 | InvalidAuthenticityToken rescue 처리 | |
| 4 | ActionCable 인증이 sign_in 방식과 일치 | |
| 5 | allow_browser에서 네이티브 앱 User-Agent 제외 | |
| 6 | CSP에 외부 서비스(결제 등) frame-src 허용 | |
| 7 | Turbo Frame 내 외부 링크에 turbo_frame: "_top" | |
| 8 | flex 레이아웃 스크롤 영역에 min-h-0 | |
| 9 | 한국어 distance_in_words i18n 번역 | |
| 10 | 프로덕션 SSL + secure cookies 설정 |

💬 댓글
비밀번호를 기억해두면 나중에 내 댓글을 삭제할 수 있어요.