Rails 다국어 schema: jsonb가 19 테이블/컬럼 이김, 단계 제약, ->> 쿼리, 인덱스 타이밍.
how2claude는 모든 글을 19개 언어(zh / en / ja / ko / ar / ...)로 동시 발행하고, 이걸 데이터 레이어에서 어떻게 저장할지는 먼저 정해야 한다. Claude한테 이 테이블 설계 맡기면 보통 19개 string 컬럼 주거나, 더 심하면 19개 테이블 — "언어마다 articles 테이블 하나" — 를 제안한다. 둘 다 함정.
정답은 테이블 1개 + jsonb 컬럼 3개, 각 jsonb 안에 locale을 key로 값을 담는 것. 이 글은 왜 이 방식이 이기는지, schema 작성법, 쿼리 법, 인덱스 타이밍, Claude가 default로 틀리는 4가지 방향을 다룬다.
후보 방안 다 나열:
방안 A: 19개 테이블(articles_zh, articles_en, ...)
- 글 하나 가져오려면 join 19번 / 쿼리 19번
- 20번째 언어 추가 = CREATE TABLE
- 글 하나 삭제 → 테이블 19개 캐스케이드
- 장점: 단일 테이블 쿼리 빠름
- 현실: 언어마다 표시 로직이 정말 다른 경우(거의 없음) 아니면 과도한 분리
방안 B: string 컬럼 19개(title_zh, title_en, ..., summary_zh, summary_en, ...)
- 테이블이 엄청 넓어짐(콘텐츠 3종 × 19개 언어 = 57 컬럼)
- 대부분 셀이 NULL(태국어 번역은 마지막에 되거나 영원히 안 되거나)
- 20번째 언어 추가 = ALTER TABLE ADD COLUMN × 3
- 행 내 필드 희소, IO 효율 나쁨
방안 C: jsonb 컬럼 3개(title jsonb, summary jsonb, content jsonb)
- 글 하나당 행 하나, locale이 jsonb 내부 key
- 언어 추가는 schema 안 건드림(그냥 새 key 삽입)
- Postgres 일등시민 — 원자 쿼리, 인덱스, 부분 조회 지원
- 쿼리 문법 약간 시끄러움(title->>'zh')이지만 래핑 가능
C 선택. 이 글 나머지는 C를 제대로 하는 법.
how2claude의 실제 articles 테이블(발췌):
create_table :articles do |t|
t.jsonb :title, default: {}, null: false
t.jsonb :summary, default: {}
t.jsonb :content, default: {}
t.string :slug, null: false
t.boolean :free, default: false, null: false
# ... 기타 bigint, timestamp 등
end
제약이 다른 이유:
title: null: false + default: {}. title은 필수 — 글에는 적어도 한 언어의 제목이 있어야 함. en만이라도. jsonb 전체가 NULL이면 안 됨.summary / content: default: {}만, NULL 허용. 이 둘은 비어 있을 수 있음(생성 직후 콘텐츠 작성 전), 그래서 풀어줌.default: {}는 필수. 이거 생략하면 Article.new 직후 title이 nil, 바로 이어 article.title["en"]은 터짐: NoMethodError: undefined method '[]' for nil:NilClass. default를 빈 해시로 하면 모든 접근이 안전.
Claude한테 이 migration 쓰라고 하면 흔히:
- default: {} 빠뜨림 → 위 nil 터짐
- null: false 전부 추가 → summary가 NULL이 될 수 없어서, 제목 먼저 만들고 콘텐츠 나중에 채우는 flow 막힘
- default: "{}"(문자열 리터럴) 작성 → Postgres가 문자열 "{}"을 저장, 빈 json 아님, 쿼리 시 타입 불일치
규칙: jsonb 컬럼의 default는 Ruby 해시 리터럴 {}이어야 함, 문자열 아님. null: false는 필수 jsonb에만.
schema만으론 부족. model이 fallback-aware 접근자 제공해야:
class Article < ApplicationRecord
validates :title, presence: true
def title_for(locale = I18n.locale)
title[locale.to_s] || title["en"] || title.values.first
end
def summary_for(locale = I18n.locale)
summary[locale.to_s] || summary["en"]
end
def content_for(locale = I18n.locale)
content[locale.to_s] || content["en"]
end
end
3단계 fallback: 현재 locale → en → 뭐라도 있는 값. 중국어 사용자가 일본어 글 볼 때 일본어 없으면 영어로 떨어지고, 영어도 없으면 "적어도 있는 값"으로 떨어짐, 절대 nil 아님.
validates :title, presence: true는 {}을 자동 처리 — ActiveRecord는 빈 해시를 blank로 취급, 빈 title은 검증 통과 못 함. null: false + presence 조합의 이득.
->> vs ->Postgres jsonb의 일상적 두 연산자:
title->>'zh' → text 반환(문자열)title->'zh' → jsonb 반환(json 타입 유지)99% ->> 쓴다 — 문자열 꺼내 씀. ->는 중첩 계속할 때만(예: content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
?는 jsonb의 "key 존재" 연산자. title 해시에 zh key 있는 글 반환.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# 틀림 1: jsonb 전체를 문자열로 비교
Article.where(title: '{"zh": "..."}')
# 틀림 2: Ruby 레이어에서 필터링
Article.all.select { |a| a.title["zh"].present? } # 전체 테이블을 메모리로
# 틀림 3: 연산자 없이 = 비교
Article.where("title->>'zh' = ?", "어떤 제목") # 이건 맞음, 근데 Claude가 틀림 1과 섞음
규칙: 필터 / 정렬 / 존재 체크는 모두 SQL 레이어에서 ->> 나 ? 연산자로. 사용자에게 보여주는 값 가져올 때만 Ruby 레이어 article.title["zh"].
jsonb는 자동 인덱스 안 됨. 세 가지 일반 요구에 세 종류 인덱스 대응:
add_index :articles, :title, using: :gin
title ? 'zh', title @> '{"zh": ...}' 류 가속. GIN은 쓰기 느리고 용량 먹지만, jsonb의 "key/value 존재성" 쿼리엔 필수.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
title->>'en' = ? 나 ORDER BY title->>'en'만 가속. 영어 제목으로만 정렬한다면(영어 사이트 알파벳 순 리스트 등), 영어 expression index 하나면 충분.
jsonb 인덱스는 비싼 객체. 글 총량 작고(수천 이내) 쿼리가 전부 id/slug 기준이면 jsonb에 선제 인덱스 안 걸기. 명확한 쿼리 요구가 생길 때까지 기다림.
how2claude는 현재 title/summary/content에 jsonb 인덱스 안 걺 — 총량 작고 쿼리가 전부 slug 단건, 인덱스는 순수 용량 낭비. 어느 정도(예: 1만 글 + 벌크 쿼리 자주 등장) 성장하면 추가.
이런 schema 설계 시키면 Claude 첫 반응은 자주 틀림. 하나씩 잡음:
"이 필드에 뭐 들어갈지 모르니까 jsonb로 해두겠음..."
바로잡기: jsonb가 맞는 건 구조 안정 + key 집합 개방적 또는 희소하게 넓은 상황 — 다국어, feature flag, 사용자 선호. "schema 설계 귀찮아" 변명 아님. 명확한 key 집합 있으면(예: 고정 5 필드), 일반 컬럼 써.
default: "{}" 문자열로바로잡기: Rails가 이걸 문자열 리터럴 "{}"로 Postgres에 저장, 빈 json 아님. 마이그레이션 후 article.title["zh"] 터짐(문자열은 이렇게 subscript 안 됨). 반드시 default: {}(Ruby 해시 리터럴).
null: false바로잡기: 먼저 뭐가 필수인지 물어봐. title 필수(제목 없는 글 성립 안 함) → null: false. summary와 content 비어도 됨(초안 단계) → default: {}만, null: false 안 붙임. 제약 단계화, 일률 아님.
"
Article.all하고 Ruby에서.select { |a| a.title["zh"] }..."
바로잡기: jsonb의 핵심은 SQL 레이어 네이티브 지원. 필터 / 존재 판정 / 정렬 모두 Postgres 시킴, ->> / ? / @> 씀. Ruby로 끌어오면 테이블이 천 행 넘는 순간 성능 망함.
Claude에게 jsonb로 Rails 다국어 schema 짜게 하는 6조목:
null: false, default: {}; summary / content는 default: {}만.default: {}는 해시 리터럴, 문자열 아님. 문자열 "{}"은 타입 불일치 일으킴.current → en → .values.first. 뷰 레이어는 절대 nil 안 봄.title ? 'zh'(존재), title->>'zh'(값/정렬). Ruby로 끌어와 필터 안 함.진짜 결정은 "jsonb 쓸까 말까"가 아님 — 19개 언어 상황이면 자명함. 진짜 결정은 어느 필드가 필수 vs 선택, model 레이어 fallback 체인 어떻게 갈지, 언제 "인덱스 아직 안 붙임"에서 "expression index 붙임"으로 전환할지. 이건 제품과 데이터량 판단, Claude는 코드 주지만 이 판단은 대신 안 해줌.