두 브랜드, 한 Rails 앱 — routes.rb host constraints + dev namespace + 스마트 helper + 세 가지 실전 함정(catch-all 슬러그, 내비 드리프트, 사인아웃 누락).
how2claude에는 사이트가 두 개 있다:
how2claude.com — 통용 사이트, Claude 활용 전 카테고리 커버(getting-started / prompting / use-cases / tools / comparisons / claude-code)how2claude.dev — 개발자 전용, claude-code 카테고리만 노출, 독립 브랜드, 평탄 URL, 다크 터미널 테마콘텐츠의 95%가 공유(같은 글 데이터), 표시의 20%가 분기 필요(브랜드, 내비, URL 구조, 테마). 두 Rails 레포는 분명히 수지 안 맞음—글 시스템, 결제, 계정, OAuth, i18n 전부 유지보수를 두 배로. 그렇다고 한 코드베이스로 두 도메인 돌리려면 경계를 많이 그어야 함: 라우트 어떻게 나누나, controller 재사용하나, view가 어느 도메인에서 도는지 어떻게 아나, layout 분기는 어디서.
Claude에게 이걸 끝내게 했다. 이 글은 분기 경계의 완전한 실록—5일 전 feature 작업, 지금 두 사이트 모두 프로덕션에 떠 있음.
공유와 분기 먼저 나열:
공유(한 코드, 두 도메인이 사용):
- 글 데이터 모델, Category, Series
- OAuth 로그인, Stripe 구독, x402 결제
- 계정 페이지 /accounts(같은 기능, 같은 UI)
- 가격 페이지 /pricing(같은 데이터, 같은 Stimulus 컨트롤)
- i18n locale 메커니즘, 19개 언어 파일
분기(두 도메인이 다름):
- 홈: .com 멀티 카테고리 브라우징 vs .dev 개발자 전용 디자인($ claude --master hero)
- URL 구조: .com /:category/:slug vs .dev /:slug(평탄)
- 노출 콘텐츠: .dev는 claude-code 카테고리 글만
- 테마: .com 흰 바탕 + orange 포인트 vs .dev 다크 monospace emerald
- 내비: .com 카테고리 링크 있음 vs .dev 단순화해서 Pricing + Sign in만
핵심 원칙: 데이터 층 건드리지 않기, controller 층 필요에 따라 분기, view / helper 층에서 호스트 인식.
모든 분기의 입구는 config/routes.rb:
# how2claude.dev — developer site (Claude Code only, flat URLs)
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
scope "(:locale)", locale: /zh|zh-TW|ja|ko|.../ do
root "dev/home#index", as: :dev_root
get "/pricing", to: "pages#pricing", as: :dev_pricing
get "/:slug", to: "dev/articles#show", as: :dev_article
end
end
# Public routes — default locale (en) has no prefix, others use /:locale
scope "(:locale)", locale: /zh|zh-TW|ja|ko|.../ do
root "pages#home"
get "/pricing", to: "pages#pricing", as: :pricing
get "/:category_slug", to: "categories#show", as: :category
get "/:category_slug/:slug", to: "articles#show", as: :article
end
주의 두 가지:
how2claude\.dev 접두는 \A로 고정, 포트는 (:\d+)?로 선택 매칭. how2claude.test는 로컬 개발 도메인(dnsmasq 또는 /etc/hosts로 127.0.0.1 매핑), 패턴에 추가하면 dev 라우트가 로컬에서도 동작.scope "(:locale)"로 감싸서, /zh, /ja URL 접두 메커니즘 두 사이트 공유, 재작성 안 함.같은 PagesController#pricing이 두 블록에 걸려 있음, 같은 컨트롤러 메서드가 다른 route helper로 호출: .com은 pricing_path, .dev는 dev_pricing_path. 이 alias 기법이 "helper 이름 충돌"을 회피(두 도메인의 /pricing이 같은 이름 helper를 쓰면 Rails가 뒤 정의로 앞 정의를 덮어씀).
처음에 Claude한테 이걸 시키면 각 페이지마다 Dev::XxxController를 만들려 함. 잘못됨—대부분 컨트롤러는 두 도메인 공유. 행동이 실제로 갈라질 때만 namespace.
실제로 둘만 만듦:
# app/controllers/dev/home_controller.rb
class Dev::HomeController < ApplicationController
allow_unauthenticated_access
layout "dev"
def index
@category = Category.find_by!(slug: "claude-code")
@series = @category.series.order(:position)
@standalone_articles = @category.articles
.where(series: nil)
.published
.order(:position)
first_free = @category.articles.published.where(free: true).order(:position).first
@cta_article = first_free || @category.articles.published.order(:position).first
end
end
# app/controllers/dev/articles_controller.rb
class Dev::ArticlesController < ApplicationController
include ContentGate
allow_unauthenticated_access
layout "dev"
def show
@category = Category.find_by!(slug: "claude-code")
@article = @category.articles.published.find_by!(slug: params[:slug])
@series = @article.series
@series_articles = @series.articles.published.order(:position) if @series
gate_content!(@article)
end
end
Dev articles는 Category.find_by!(slug: "claude-code")로 고정—이게 .dev 도메인의 데이터 층 제약. /:slug는 글 slug로 직접 찾음, .com처럼 /:category_slug/:slug 필요 없음.
ContentGate는 유료 게이트 concern, 두 사이트 공유—유료 규칙은 동일, 입구 경로만 다름.
view 템플릿에 if request.host.include?("how2claude.dev") 같은 판정 넣으면 안 됨—추하고 DRY 안 됨. helper로 추출:
# app/helpers/application_helper.rb
def on_dev_domain?
request.host.include?("how2claude.dev")
end
def smart_article_path(article, category)
if on_dev_domain?
dev_article_path(slug: article.slug)
else
article_path(category_slug: category.slug, slug: article.slug)
end
end
def smart_category_path(category)
if on_dev_domain?
dev_root_path
else
category_path(category_slug: category.slug)
end
end
그리고 모든 공유 view(articles/_row.html.erb, articles/show.html.erb, breadcrumb, 사이드바)는 smart_article_path(article, category)로 article_path(...) 대체.
smart_category_path는 .dev 도메인에서 dev_root_path 반환—.dev에는 별도 카테고리 페이지 없으니(사이트 전체가 한 카테고리), 카테고리 링크는 홈으로.
이 helper 세트로 view 템플릿 하나가 두 도메인에서 올바르게 동작, view 분기 필요 없음.
app/views/layouts/dev.html.erb와 application.html.erb는 분기됨—다크 vs 밝음, monospace vs sans, emerald vs orange. 그러나 모든 기능 링크는 메인 layout과 정렬:
<!-- layouts/dev.html.erb -->
<nav class="...">
<%= link_to "how2claude.dev", dev_root_path,
class: "font-mono font-bold text-emerald-400..." %>
<div class="flex items-center gap-4 text-sm">
<%= link_to t("pricing.page_title"), dev_pricing_path, class: "..." %>
<% if authenticated? %>
<%= link_to t("nav.account"), accounts_path, class: "..." %>
<% else %>
<%= link_to t("nav.sign_in"), new_session_path, class: "..." %>
<% end %>
<!-- locale dropdown -->
</div>
</nav>
시각은 분기, 그러나 Pricing / Sign in / Account / locale 전환 이 기능 링크들 하나도 빠지지 않음. 여기서 한번 밟힘(아래).
/:slug catch-all이 /pricing을 삼킴 (commit 23163cc)dev 라우트 초기 모습:
constraints(host: /how2claude\.dev/) do
scope "(:locale)" do
root "dev/home#index", as: :dev_root
get "/:slug", to: "dev/articles#show", as: :dev_article
end
end
how2claude.dev/pricing 접근 → /:slug에 매칭 → Dev::ArticlesController#show → Category.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing") → ActiveRecord::RecordNotFound → 404.
수정: /:slug 앞에 /pricing을 명시적으로:
constraints(host: /how2claude\.dev/) do
scope "(:locale)" do
root "dev/home#index", as: :dev_root
+ get "/pricing", to: "pages#pricing", as: :dev_pricing
get "/:slug", to: "dev/articles#show", as: :dev_article
end
end
Rails 라우트는 선언 순서대로 매칭, /pricing이 /:slug 앞에 있어야 먼저 매칭 성공.
보너스 함정: alias는 dev_pricing이어야 하고 pricing은 안 됨—.com 블록에 이미 as: :pricing이 있기 때문. Rails는 에러 안 냄, 뒤 정의가 앞 정의를 조용히 덮어씀, 결과 .com에서 pricing_path가 .dev 버전 URL을 생성. Claude가 이 스니펫 처음 쓸 때 as: :pricing으로 썼음, .com으로 돌아가서 /pricing 누르고서야 뭔가 이상한 걸 알아차림—pricing_path가 생성하는 URL 문자열은 /pricing 맞음, 하지만 라우트 타겟이 바뀜(두 라우트 다 PagesController#pricing이라 눈에 차이가 없었음).
규칙: 같은 action을 여러 라우트 블록에 걸 때, 각 블록에 고유 as 이름 써야 함.
eac4b2f)Claude한테 dev.html.erb 시켰을 때 단순화 버전 작성—브랜드 로고 + locale 전환기만. 이유: "dev 사이트는 콘텐츠 사이트, 내비는 깔끔해야".
출시 후 .dev에서 신규 계정 등록—Sign in 링크가 없음. /session/new 손으로 타이핑밖에 없음. 유료 사용자 왔음—Pricing 링크가 없음. /pricing 손으로 타이핑밖에.
이건 디자인 간결함이 아니라 기능 누락. commit eac4b2f가 .com의 핵심 내비를 복사, 스타일을 다크 테마로 조정.
규칙: 시각은 분기 가능(다크/밝음, monospace/sans), 기능은 정렬 필수. 한쪽 사이트에 있는 핵심 입구(Pricing, Sign in, Account, locale), 다른 쪽도 있어야 함, 명시적 제품 이유가 없는 한.
Claude에게 분기 layout 시킬 때 능동적으로 말하기: "모든 기능 입구 유지, 시각만 변경". 기본적으로 "단순화"하려 함, 그러나 사용자 쪽에서 "단순화"는 기능 누락.
/accounts에 Sign out 버튼 없음 (commit 92b34a8)이건 멀티 도메인과 직접 관련 없지만 같은 시기에 발견.
Rails 8의 session 템플릿이 DELETE /session 라우트를 생성하지만 버튼을 어디에도 노출하지 않음. 사용자 로그아웃 유일한 방법:
curl -X DELETE https://how2claude.com/session -H "Cookie: ..."
분명 안 됨. commit 92b34a8가 /accounts 하단에 muted한 "Sign out" button_to를 추가, turbo_confirm으로 오터치 방지.
규칙: feature 완전성 audit할 때 정방향 path만 보지 말 것. 등록, 로그인, 결제, 사용—이건 Claude가 전부 커버. 역방향 path(로그아웃, 구독 취소, 계정 삭제)는 누락되기 쉬움. Claude한테 "사용자의 모든 상태 전환"을 열거시키는 게 "계정 페이지 만들기"보다 더 넓게 커버.
프로덕션 도메인은 how2claude.com과 how2claude.dev, 로컬에서 둘 다 시뮬레이션 필요. /etc/hosts:
127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test
그리고 라우트 constraint에 .test를 dev 도메인 로컬 alias로 추가:
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
(위 예에서 how2claude.test는 사실상 .dev 로컬 alias. .com은 로컬에서 기본 localhost 또는 127.0.0.1—host 제약 없으니 기본 라우트 블록에 떨어짐.)
실제 개발: bin/rails s -b 127.0.0.1 -p 3000 띄우고, 브라우저에서 how2claude.test:3000로 .dev 라우트, localhost:3000로 .com 라우트 테스트. 두 도메인, 한 Rails 프로세스, 코드 수정하면 양쪽 다 reload.
Claude에게 Rails 하나로 두 브랜드 사이트 돌리게 하기 완전 체크리스트:
routes.rb에서 constraints(host:)로 dev 블록 감싸기, 기본 블록 앞에 배치(Rails는 선언 순서대로 매칭)./pricing 같은 구체 페이지는 /:slug catch-all 앞에 명시 선언, 아니면 삼킴.smart_article_path / on_dev_domain? helper가 view를 호스트 무관하게. view에 if request.host.include?(...) 쓰지 마./etc/hosts + .test 도메인으로 dev 도메인 시뮬레이션. 라우트 constraint에 .test를 dev alias로 추가.Rails 하나로 두 브랜드 사이트 돌리는 기술적 난도는 높지 않음—Rails의 constraints(host:) + helper 추상으로 충분. 진짜 주의해야 할 건 분기의 규율: 뭘 공유, 뭘 분기, 언제 분기, 언제 병합. Claude는 코드를 정확히 쓸 수 있음, 하지만 "두 사이트가 같게 보여야 하는지"는 대신 생각해주지 않음. 그건 네 제품 판단.