Free

Claude에게 jsonb로 Rails 다국어 schema 짜게 하기

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를 제대로 하는 법.

Schema: jsonb 3개 + 제약 절충

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 직후 titlenil, 바로 이어 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에만.

Model 레이어: locale fallback 감싸기

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').

특정 locale 번역 있는 글

Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'

?는 jsonb의 "key 존재" 연산자. title 해시에 zh key 있는 글 반환.

특정 언어 제목으로 정렬

Article.order("title->>'en' ASC")

locale로 필터링 + 정렬

Article.where("title ? :loc", loc: "ja")
       .order("title->>'ja' ASC")

Claude가 default로 틀리게 쓰는 쿼리

# 틀림 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는 자동 인덱스 안 됨. 세 가지 일반 요구에 세 종류 인덱스 대응:

1. 존재 / @> 포함 쿼리 → GIN index

add_index :articles, :title, using: :gin

title ? 'zh', title @> '{"zh": ...}' 류 가속. GIN은 쓰기 느리고 용량 먹지만, jsonb의 "key/value 존재성" 쿼리엔 필수.

2. 특정 locale 정렬/정확 필터링 → expression index

add_index :articles, "(title->>'en')", name: "idx_articles_title_en"

title->>'en' = ?ORDER BY title->>'en'만 가속. 영어 제목으로만 정렬한다면(영어 사이트 알파벳 순 리스트 등), 영어 expression index 하나면 충분.

3. 고빈도 쿼리 없음 → 안 추가

jsonb 인덱스는 비싼 객체. 글 총량 작고(수천 이내) 쿼리가 전부 id/slug 기준이면 jsonb에 선제 인덱스 안 걸기. 명확한 쿼리 요구가 생길 때까지 기다림.

how2claude는 현재 title/summary/content에 jsonb 인덱스 안 걺 — 총량 작고 쿼리가 전부 slug 단건, 인덱스는 순수 용량 낭비. 어느 정도(예: 1만 글 + 벌크 쿼리 자주 등장) 성장하면 추가.

Claude가 default로 틀리는 4 방향

이런 schema 설계 시키면 Claude 첫 반응은 자주 틀림. 하나씩 잡음:

1. jsonb를 "schema 모르겠으니까 이렇게" 탈출구로 씀

"이 필드에 뭐 들어갈지 모르니까 jsonb로 해두겠음..."

바로잡기: jsonb가 맞는 건 구조 안정 + key 집합 개방적 또는 희소하게 넓은 상황 — 다국어, feature flag, 사용자 선호. "schema 설계 귀찮아" 변명 아님. 명확한 key 집합 있으면(예: 고정 5 필드), 일반 컬럼 써.

2. default: "{}" 문자열로

바로잡기: Rails가 이걸 문자열 리터럴 "{}"로 Postgres에 저장, 빈 json 아님. 마이그레이션 후 article.title["zh"] 터짐(문자열은 이렇게 subscript 안 됨). 반드시 default: {}(Ruby 해시 리터럴).

3. 모든 jsonb 컬럼에 null: false

바로잡기: 먼저 뭐가 필수인지 물어봐. title 필수(제목 없는 글 성립 안 함) → null: false. summary와 content 비어도 됨(초안 단계) → default: {}만, null: false 안 붙임. 제약 단계화, 일률 아님.

4. jsonb 필터링을 애플리케이션 레이어에서

"Article.all 하고 Ruby에서 .select { |a| a.title["zh"] }..."

바로잡기: jsonb의 핵심은 SQL 레이어 네이티브 지원. 필터 / 존재 판정 / 정렬 모두 Postgres 시킴, ->> / ? / @> 씀. Ruby로 끌어오면 테이블이 천 행 넘는 순간 성능 망함.

체크리스트

Claude에게 jsonb로 Rails 다국어 schema 짜게 하는 6조목:

  1. 19 테이블 / 19 컬럼 방안 먼저 배제. locale 간 표시 로직이 실질 다르지 않는 한 jsonb가 유일해.
  2. jsonb 3개 + 단계 제약: title null: false, default: {}; summary / content는 default: {}만.
  3. default: {}는 해시 리터럴, 문자열 아님. 문자열 "{}"은 타입 불일치 일으킴.
  4. Model은 fallback 접근자 제공: current → en → .values.first. 뷰 레이어는 절대 nil 안 봄.
  5. 쿼리는 SQL 레이어에서: title ? 'zh'(존재), title->>'zh'(값/정렬). Ruby로 끌어와 필터 안 함.
  6. 인덱스는 실제 쿼리 요구 생긴 뒤 추가. GIN 선제는 과잉 최적화, 총량 작을 때 slug/id 단건 조회면 jsonb 인덱스 불필요.

진짜 결정은 "jsonb 쓸까 말까"가 아님 — 19개 언어 상황이면 자명함. 진짜 결정은 어느 필드가 필수 vs 선택, model 레이어 fallback 체인 어떻게 갈지, 언제 "인덱스 아직 안 붙임"에서 "expression index 붙임"으로 전환할지. 이건 제품과 데이터량 판단, Claude는 코드 주지만 이 판단은 대신 안 해줌.