--- id: db-jsonb-postgres-patterns title: Postgres JSONB — 인덱스 / 쿼리 / 마이그레이션 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [database, postgres, jsonb, vibe-coding] tech_stack: { language: "SQL / Postgres", applicable_to: ["Backend"] } applied_in: [] aliases: [JSONB, GIN index, jsonb_path_query, schemaless] --- # Postgres JSONB > **Schemaless 가 필요할 때만**. JSON 보다 JSONB (binary, indexable). GIN 인덱스로 빠른 검색. 하지만 정형 데이터는 컬럼이 항상 우월. ## 📖 핵심 개념 - JSON: text 저장, parse 매 query. 순서 / 공백 보존. - JSONB: binary, parse 한 번, 더 빠름. **사실상 항상 JSONB**. - GIN 인덱스: 모든 key/path 검색. - Operator: `->`, `->>`, `@>`, `?`, `#>`, `jsonb_path_query`. ## 💻 코드 패턴 ### 스키마 ```sql CREATE TABLE events ( id BIGSERIAL PRIMARY KEY, type TEXT NOT NULL, data JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ DEFAULT NOW() ); -- GIN 인덱스 (모든 key) CREATE INDEX events_data_gin ON events USING GIN (data); -- 또는 특정 path 만 (작고 빠름) CREATE INDEX events_user ON events ((data->>'userId')); ``` ### Read 패턴 ```sql -- 추출 (JSON 으로) vs (text 로) SELECT data->'user' FROM events; -- jsonb SELECT data->>'userId' FROM events; -- text -- nested SELECT data#>>'{user,address,city}' FROM events; -- Containment SELECT * FROM events WHERE data @> '{"userId": "u1"}'; -- Key 존재 SELECT * FROM events WHERE data ? 'userId'; -- jsonb_path (JSON Path) SELECT * FROM events WHERE data @? '$.items[*] ? (@.qty > 10)'; ``` ### Write 패턴 ```sql -- 통째 update UPDATE events SET data = '{"userId":"u1"}'::jsonb WHERE id = 1; -- 부분 update UPDATE events SET data = data || '{"shipped": true}'::jsonb WHERE id = 1; -- nested set UPDATE events SET data = jsonb_set(data, '{user,name}', '"Alice"', true) WHERE id = 1; -- 키 제거 UPDATE events SET data = data - 'temp' WHERE id = 1; ``` ### 배열 ```sql -- 길이 SELECT jsonb_array_length(data->'tags') FROM events; -- 펼치기 SELECT id, tag FROM events, jsonb_array_elements_text(data->'tags') AS tag; -- append UPDATE events SET data = jsonb_set(data, '{tags}', (data->'tags') || '"new"'::jsonb); ``` ### Generated column (자주 read 하는 키) ```sql ALTER TABLE events ADD COLUMN user_id UUID GENERATED ALWAYS AS ((data->>'userId')::UUID) STORED; CREATE INDEX events_user_id ON events(user_id); -- 일반 b-tree, 빠름 ``` ### TS — pg + zod 검증 ```ts const EventDataSchema = z.object({ userId: z.string().uuid(), items: z.array(z.object({ id: z.string(), qty: z.number() })), }); async function insertEvent(data: z.infer) { EventDataSchema.parse(data); // app 레벨 검증 return db.events.create({ data: { type: 'order', data } }); } ``` ### 마이그레이션 — JSONB 키 → 컬럼 ```sql -- 1. 컬럼 추가 ALTER TABLE events ADD COLUMN user_id UUID; -- 2. 백필 UPDATE events SET user_id = (data->>'userId')::UUID WHERE user_id IS NULL; -- 3. NOT NULL ALTER TABLE events ALTER COLUMN user_id SET NOT NULL; -- 4. 인덱스 CREATE INDEX events_user_id ON events(user_id); -- 5. 앱 코드 전환 후 JSONB 의 userId 키 제거 UPDATE events SET data = data - 'userId'; ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | 항상 같은 필드 | 일반 컬럼 | | 사용자별 다른 필드 (form builder) | JSONB | | 외부 API raw 저장 | JSONB | | 자주 read 하는 JSONB key | Generated column | | 자주 search 하는 key | Partial 인덱스 (`(data->>'k')`) | | Schemaless 강 필요 | MongoDB / Firestore 고려 | | Type safety 필요 | 컬럼 + 검증 | ## ❌ 안티패턴 - **모든 데이터 JSONB**: type safety / 인덱스 효율 잃음. 정형은 컬럼. - **JSON 사용 (B 안 붙임)**: parse 매번, 인덱스 약함. - **GIN 인덱스 모든 컬럼**: 큰 인덱스. 필요한 path 만. - **JSONB 안에 거대 nested object**: 1MB+ row 부담. - **Atomic 갱신 안 함**: read → modify → write race condition. - **검증 없음**: 잘못된 schema 가 흘러옴. - **NULL vs `'null'::jsonb` 혼동**: 다름. 명확히. ## 🤖 LLM 활용 힌트 - 항상 JSONB (J 아님). - Generated column 으로 자주 read 컬럼화. - Zod 같은 schema 검증 app 에서. ## 🔗 관련 문서 - [[Postgres_Performance_Tuning]] - [[DB_Migrations_Zero_Downtime]] - [[Schema_Validation_Zod_Patterns]]