Free

Claude에게 Rails 하나로 두 브랜드 사이트 돌리게 하기 (how2claude.com + how2claude.dev)

두 브랜드, 한 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 층에서 호스트 인식.

아키텍처: routes.rb의 host constraint 블록

모든 분기의 입구는 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

주의 두 가지:

  1. host 정규식은 엄격히how2claude\.dev 접두는 \A로 고정, 포트는 (:\d+)?로 선택 매칭. how2claude.test는 로컬 개발 도메인(dnsmasq 또는 /etc/hosts로 127.0.0.1 매핑), 패턴에 추가하면 dev 라우트가 로컬에서도 동작.
  2. locale scope는 host constraint 안에 중첩—두 라우트 블록 다 scope "(:locale)"로 감싸서, /zh, /ja URL 접두 메커니즘 두 사이트 공유, 재작성 안 함.

같은 PagesController#pricing이 두 블록에 걸려 있음, 같은 컨트롤러 메서드가 다른 route helper로 호출: .compricing_path, .devdev_pricing_path. 이 alias 기법이 "helper 이름 충돌"을 회피(두 도메인의 /pricing이 같은 이름 helper를 쓰면 Rails가 뒤 정의로 앞 정의를 덮어씀).

Dev 네임스페이스 컨트롤러: 분기 필요할 때만 만들기

처음에 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, 두 사이트 공유—유료 규칙은 동일, 입구 경로만 다름.

스마트 helper: view가 호스트를 모르게

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 분기 필요 없음.

Layout 분기: 테마 + 내비, 그러나 규율이 필요

app/views/layouts/dev.html.erbapplication.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 전환 이 기능 링크들 하나도 빠지지 않음. 여기서 한번 밟힘(아래).

구덩이 #1: /: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#showCategory.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 이름 써야 함.

구덩이 #2: dev 내비에 Pricing + Sign in 누락 (commit 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 시킬 때 능동적으로 말하기: "모든 기능 입구 유지, 시각만 변경". 기본적으로 "단순화"하려 함, 그러나 사용자 쪽에서 "단순화"는 기능 누락.

구덩이 #3: /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.comhow2claude.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 하나로 두 브랜드 사이트 돌리게 하기 완전 체크리스트:

  1. 공유 vs 분기를 먼저 명확히—어떤 모듈이 양쪽 같고, 어떤 게 다른지. 바로 namespace 열지 마.
  2. routes.rb에서 constraints(host:)로 dev 블록 감싸기, 기본 블록 앞에 배치(Rails는 선언 순서대로 매칭).
  3. dev namespace controller는 행동 분기할 때만 만들기. 재사용 가능한 controller(PagesController, AccountsController)는 두 라우트 블록에 걸고, 고유 as 이름으로 충돌 회피.
  4. /pricing 같은 구체 페이지는 /:slug catch-all 앞에 명시 선언, 아니면 삼킴.
  5. smart_article_path / on_dev_domain? helper가 view를 호스트 무관하게. view에 if request.host.include?(...) 쓰지 마.
  6. layout은 시각 분기, 기능 입구는 정렬 필수. Claude한테 "모든 기능 유지, 시각만 변경"이라 말하기—기본 단순화는 기능 누락.
  7. 역방향 path 별도 audit. 로그아웃, 취소, 삭제 누락되기 쉬움, Claude한테 "사용자의 모든 상태 전환" 열거시키는 게 "각 페이지 만들기"보다 더 넓게 커버.
  8. 로컬은 /etc/hosts + .test 도메인으로 dev 도메인 시뮬레이션. 라우트 constraint에 .test를 dev alias로 추가.

Rails 하나로 두 브랜드 사이트 돌리는 기술적 난도는 높지 않음—Rails의 constraints(host:) + helper 추상으로 충분. 진짜 주의해야 할 건 분기의 규율: 뭘 공유, 뭘 분기, 언제 분기, 언제 병합. Claude는 코드를 정확히 쓸 수 있음, 하지만 "두 사이트가 같게 보여야 하는지"는 대신 생각해주지 않음. 그건 네 제품 판단.