장애 요약
2025년 상반기, 사용자 프로필 메타데이터를 JSONB로 저장하는 테이블에서 데이터 쓰기 지연이 초단위에서 수초로 악화되었습니다. 초당 처리량(throughput)이 8,000 QPS에서 2,000 QPS로 떨어졌고, 데이터베이스 CPU는 70% 이상 유지되었습니다. 원인은 인덱스 설계의 근거 없는 선택이었습니다.
언제 JSONB에 Expression B-tree를 선택해야 하는가?
특정 키만 일관되게 조회·수정한다면 GIN이 아니라 Expression B-tree 인덱스를 먼저 검토해야 합니다. 우리는 사용자 프로필의 status, tier, updated_at 세 필드를 WHERE 절과 ORDER BY에서 반복적으로 참조했는데, 모든 필드에 대해 GIN 인덱스(jsonb_ops)를 생성했습니다.
핵심 수치:
- GIN 인덱스 크기: 테이블 3.2GB 대비 약 2.8GB (인덱스가 테이블 크기에 근접)
- Expression B-tree 인덱스(
data->'status'등)로 전환 후: 각 인덱스 약 80~120MB (GIN 대비 약 20분의 1) - INSERT 지연: GIN 환경에서 평균 8.5ms → B-tree 전환 후 1.2ms
이 비율은 PostgreSQL 공식 문서의 JSONB 인덱싱 가이드와 실제 프로덕션 측정값이 일치했습니다. 특정 키의 고정적 검색에서는 B-tree가 GIN보다 dramatically faster인 것을 직접 겪었습니다.
인덱스 선택 기준:
- Expression B-tree: 고정된 키 (
data->'status',data->>'tier')를 WHERE/ORDER BY에서 반복 사용 - GIN (
jsonb_path_ops): 임의의 키를 포함(@>) 검색하거나 스키마가 완전히 동적일 때
우리는 처음부터 쿼리 분석을 건너뛰고 "JSONB면 GIN이지"라는 관성으로 인덱스를 설계했습니다.
GIN 인덱스 유지비용이 쓰기 성능을 왜 죽일까?
JSONB의 모든 업데이트는 행 전체에 대한 락(row-level lock)을 발생시킵니다. 우리 테이블에는 GIN 인덱스 3개가 있었는데, 각 INSERT/UPDATE마다 3개 인덱스 엔트리를 모두 갱신해야 했습니다.
pg_stat_user_indexes를 조회한 결과:
idx_name | idx_scan | idx_tup_read | idx_tup_fetch
────────────────────────────────────────┼──────────┼──────────────┼───────────────
idx_profile_data_gin | 12450 | 2847361 | 892103
idx_profile_data_gin_status | 680 | 18450 | 1823
idx_profile_data_gin_tier | 620 | 19203 | 892
GIN 인덱스 메인(idx_profile_data_gin)의 스캔 횟수가 다른 두 인덱스보다 18배 많았고, 대부분은 쓰기 후 유지비용(maintenance)이었습니다. 읽기는 상대적으로 적었는데, GIN의 높은 갱신 비용만 남겨진 상태였습니다.
JSONB 파싱 오버헤드 자체는 JSON(텍스트)보다 약간 느리다고 알려져 있지만, 실제 병목은 인덱스 갱신이었습니다. 3개의 GIN 인덱스가 각각 대규모 B+ 트리를 유지하느라 I/O 경합이 심했습니다.
쿼리 로그와 실제 패턴 재현
원인 확정을 위해 2주간의 슬로우 쿼리 로그(slow query log)와 EXPLAIN ANALYZE를 분석했습니다.
-- 실제 자주 나오는 쿼리 (전체의 약 68%)
SELECT id, email, data FROM profiles
WHERE (data->>'status')::text = 'active'
AND (data->>'tier')::text IN ('premium', 'enterprise')
ORDER BY (data->>'updated_at')::timestamp DESC
LIMIT 100;
GIN 환경에서의 EXPLAIN 결과(간략):
Seq Scan on profiles (cost=0.00..285000.00 rows=1200)
Filter: ((data->>'status') = 'active' ...)
Planning Time: 0.234 ms
Execution Time: 3847.123 ms
인덱스를 사용하지 않고 풀 테이블 스캔을 했습니다. GIN은 ->>(텍스트 추출) 다음의 형변환(::text, ::timestamp)을 최적화하지 못했습니다.
Expression B-tree 인덱스 생성 후:
CREATE INDEX idx_profile_status ON profiles
USING btree ((data->>'status'));
CREATE INDEX idx_profile_updated ON profiles
USING btree ((data->>'updated_at')::timestamp DESC);
동일 쿼리의 EXPLAIN:
Index Scan using idx_profile_status on profiles (cost=0.42..1823.45 rows=1200)
Index Cond: ((data->>'status') = 'active')
Planning Time: 0.089 ms
Execution Time: 47.234 ms
응답 시간이 3,847ms → 47ms로 약 80배 개선되었습니다. 동시성 환경에서는 개별 쿼리 시간뿐 아니라 락 경합도 크게 줄었습니다.
정규화 판단: 언제 JSONB를 버려야 하나?
3주차 분석에서 "hot fields"를 식별했습니다. status, tier, updated_at, last_login이 전체 쿼리의 87%를 차지했습니다. 이들을 JSONB에서 빼내기로 결정했습니다.
마이그레이션 전략:
-- 1단계: 정규화 컬럼 추가
ALTER TABLE profiles ADD COLUMN
status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE profiles ADD COLUMN
tier TEXT NOT NULL DEFAULT 'free';
ALTER TABLE profiles ADD COLUMN
updated_at TIMESTAMP NOT NULL DEFAULT now();
-- 2단계: 기존 JSONB 데이터로 채우기
UPDATE profiles SET status = (data->>'status'),
tier = (data->>'tier'),
updated_at = (data->>'updated_at')::timestamp;
-- 3단계: 인덱스 생성 (Expression 아닌 일반 B-tree)
CREATE INDEX idx_profile_status ON profiles(status);
CREATE INDEX idx_profile_tier ON profiles(tier);
CREATE INDEX idx_profile_updated ON profiles(updated_at DESC);
-- 4단계: JSONB는 "long-tail 필드"만 유지
-- ALTER TABLE profiles DROP COLUMN data;
-- 아직 data에는 부가 메타데이터(notes, custom_attrs 등) 남김
결과:
- INSERT: 2,000 QPS → 7,800 QPS (회복)
- 인덱스 크기: 2.8GB GIN 3개 → 정규화 인덱스 240MB + JSONB 보조 인덱스 60MB
- 쓰기 지연: 8.5ms → 1.1ms
- 읽기 응답(p50): 270ms → 23ms
정규화 기준:
- 추출할 것: WHERE/ORDER BY에 주기적으로 등장, 범위 검색 필요, 업데이트 빈도 높음
- JSONB에 남길 것: 변동성 높음, 조회 빈도 낮음, 스키마 불안정적 (polymorphic 필드)
우리 사례에서는 약 30%의 데이터 구조만 정규화했고, 70%의 "long-tail" 메타필드는 여전히 JSONB에 두었습니다.
락과 동시성: JSONB 부분 업데이트의 함정
한 가지 더 발견한 문제: JSONB의 부분 업데이트(jsonb_set())도 행 전체 락을 발생시킵니다.
-- 이 쿼리도 해당 행 전체에 ExclusiveLock 발생
UPDATE profiles SET data = jsonb_set(data, '{notes}', '"new note"')
WHERE id = 123;
pg_locks 모니터링 결과, 동시에 5개 이상의 JSONB 부분 업데이트가 들어오면 락 대기가 누적되었습니다. 읽기 쿼리들까지 blocked state에 빠졌습니다.
해결 방식:
- 정규화 필드는 별도 UPDATE (독립적 락)
- JSONB는 자주 변하지 않는 필드에만 사용
- 필요하면 분리된 테이블(1:N)로 구조화
핵심 정리
- 인덱스 선택은 쿼리 패턴 분석이 먼저: 고정 키 조회는 Expression B-tree(GIN 대비 인덱스 20분의 1, 쓰기 7배 빠름), 동적/포함 검색만 GIN 사용
- 정규화 기준: 2~3주 프로덕션 데이터 분석 → WHERE/ORDER BY 히트율 높은 필드 추출 → hot fields는 정규화, long-tail은 JSONB 유지
- JSONB 부분 업데이트는 행 전체 락: 부분 수정 빈도가 높다면 독립적 테이블 분리 검토
- 모니터링 필수:
pg_stat_user_indexes,EXPLAIN ANALYZE BUFFERS,pg_locks로 인덱스 히트율과 락 경합 주기적 검증 - 트레이드오프 문서화: 정규화 vs JSONB 선택의 근거(읽기/쓰기 비율, 스키마 변동성)를 팀과 공유하고, 분기마다 재평가