JSON 컬럼은 스키마를 미루는 빚이다

요구사항이 자주 바뀌어 JSON 컬럼에 속성을 몰아넣었더니, 1년 뒤엔 어떤 키가 있는지 아무도 몰라요. 조회도 느려졌고요. 빨리 가려던 선택이 빚이 되는 과정과 갚는 순서를 정리했어요.

요구사항이 자주 바뀌어서 속성들을 JSON 컬럼 하나에 넣었어요. 컬럼을 추가하는 마이그레이션 없이 필드를 늘릴 수 있으니 개발 속도가 확실히 빨랐어요.

1년이 지나니 그 컬럼 안에 수십 개의 키가 들어 있어요. 어떤 키가 언제 들어왔는지, 어떤 값이 들어갈 수 있는지 아무도 모르고요.

그리고 조회가 느려요. JSON 안의 값으로 필터링하는 쿼리들이 전부 풀스캔이에요. 통계도 없어서 옵티마이저가 계획을 못 세웁니다.

빨리 가려고 고른 선택이 시간이 지나면서 가장 느린 부분이 된 거예요. 그리고 이제는 되돌리기도 어려워요 — 어떤 코드가 어떤 키를 쓰는지 아무도 전체를 모르니까요.

왜 그런가

JSON 컬럼은 스키마를 없앤 게 아니라 DB 밖으로 옮긴 거예요.

스키마는 여전히 존재해요. 어떤 키가 있고 어떤 타입이어야 하는지에 대한 약속이요. 다만 그 약속이 DB 가 아니라 애플리케이션 코드와 사람의 머릿속에 있어요. DB 는 검증하지 않으니 오타 난 키, 문자열로 들어간 숫자, 있어야 하는데 없는 필드가 그대로 저장돼요.

성능 쪽 이유도 분명해요. 일반 컬럼은 값의 분포 통계가 쌓여서 옵티마이저가 "이 조건이면 몇 행"을 추정할 수 있어요. JSON 안의 키는 그런 통계가 없거나 매우 제한적이에요. 그래서 추정이 빗나가고 계획이 나빠져요.

인덱싱도 다릅니다. JSON 문서 전체에 GIN 인덱스를 걸면 키 존재나 포함 관계는 빠르게 찾을 수 있어요. 그런데 범위 조건이나 정렬에는 약해요. 특정 키만 자주 쓴다면 그 키의 표현식에 B-tree 인덱스를 거는 게 낫고요.

그리고 저장 비용이 있어요. JSON 은 키 이름을 행마다 반복해서 저장해요. 백만 행이면 키 이름도 백만 번이에요. 일반 컬럼은 이름이 스키마에 한 번만 있고요.

코드로 보면

자주 쓰는 키는 꺼내서 승격한다:

코드
· 문서 전체 인덱싱 — 존재·포함 조회에 강하다
 CREATE INDEX idx_attrs ON items USING gin (attrs);
 SELECT * FROM items WHERE attrs @> '{"color":"red"}';

· 특정 키를 자주 쓴다면 표현식 인덱스 (범위·정렬에 강하다)
 CREATE INDEX idx_price
 ON items (((attrs->>'price')::numeric));

· 결국 자주 쓰는 키는 컬럼으로 승격하는 게 맞다
 ALTER TABLE items ADD COLUMN price numeric;
 UPDATE items SET price = (attrs->>'price')::numeric;  -- 배치로

· 남기더라도 형태는 검증한다
 ALTER TABLE items ADD CONSTRAINT chk_attrs
 CHECK (jsonb_typeof(attrs) = 'object');

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

JSON 컬럼이 맞는 자리와 아닌 자리가 있어요. 이 구분이 이 편의 핵심이에요.

맞는 자리는 정말로 구조가 열려 있는 데이터예요. 외부 API 의 원본 응답을 통째로 보관하는 경우, 사용자가 정의하는 커스텀 필드, 감사 로그의 변경 전후 스냅샷 같은 것들이요. 이런 건 미리 컬럼을 정할 수 없고, 대개 조건 조회의 대상도 아니에요.

아닌 자리는 사실 구조가 있는데 귀찮아서 미룬 경우예요. 상품의 가격, 사용자의 등급, 주문의 상태처럼 항상 존재하고 자주 조회되는 값이요. 이런 건 컬럼이어야 해요.

판단 기준은 간단해요. 그 키로 WHERE 조건을 걸거나 정렬하거나 집계한다면, 그건 컬럼이어야 할 값이에요.

PostgreSQL 이라면 json 과 jsonb 의 차이도 알아야 해요. json 은 입력 텍스트를 그대로 보관해서 매번 파싱해요. jsonb 는 파싱된 이진 형태로 저장해서 조회가 빠르고 인덱싱도 되지만, 키 순서가 안 보존되고 중복 키가 제거돼요. 저장할 때 조금 더 비싸고 읽을 때 훨씬 싸니, 조회할 데이터라면 jsonb 예요.

MySQL 은 JSON 컬럼에 직접 인덱스를 못 걸어요. 대신 생성 컬럼을 만들어 그 컬럼에 인덱스를 겁니다. 사실상 "자주 쓰는 키를 컬럼으로 승격"을 문법으로 지원하는 셈이에요.

그리고 부분 갱신 비용도 있어요. JSON 안의 값 하나를 바꿔도 문서 전체를 다시 써요. 큰 문서에서 작은 값을 자주 바꾸는 패턴이면 쓰기 증폭이 심해집니다. TOAST 로 압축 저장된 큰 문서면 압축 해제와 재압축까지 붙고요.

처방

  1. 그 키로 조건을 걸거나 정렬하거나 집계한다면 컬럼으로 승격하세요. 이게 유일하고 확실한 기준이에요.
  2. 승격은 단계적으로 하세요. 컬럼을 추가하고, 배치로 값을 채우고, 애플리케이션이 두 곳을 다 쓰게 한 뒤, 읽기를 컬럼으로 옮기고, 마지막에 JSON 에서 빼는 순서예요. 한 번에 하려다 실패하면 되돌리기 어려워요.
  3. 남기는 JSON 에도 최소한의 검증을 두세요. 타입이 객체인지, 필수 키가 있는지 정도는 제약 조건으로 걸 수 있어요. 스키마 검증 확장을 쓰는 방법도 있고요.
  4. 조회 패턴에 맞는 인덱스를 고르세요. 키 존재나 포함 조회면 GIN, 특정 키의 범위나 정렬이면 그 키의 표현식 인덱스.
  5. 어떤 키들이 실제로 들어 있는지 주기적으로 조사하세요. JSON 컬럼은 시간이 지날수록 아무도 모르는 상태가 되는데, 키 목록과 빈도를 뽑아 보는 것만으로 많은 게 드러나요.
  6. 큰 문서를 자주 부분 갱신하는 패턴이면 구조를 나누세요. 자주 바뀌는 부분과 안 바뀌는 부분을 분리하는 것만으로 쓰기 비용이 크게 줄어요.

지금 확인해 보세요

그 안에 대체 뭐가 들어 있나:

코드
· 어떤 키들이 얼마나 자주 나오나
SELECT key, count(*)
  FROM items, jsonb_object_keys(attrs) AS key
 GROUP BY key ORDER BY count(*) DESC;
 상위 키들은 컬럼 승격 후보다

· 한 키에 타입이 섞여 있지는 않은지
SELECT jsonb_typeof(attrs->'price') AS t, count(*)
  FROM items GROUP BY t;
 number 와 string 이 같이 나오면 캐스팅이 터진다

· JSON 조건 쿼리가 실제로 인덱스를 타는지
EXPLAIN (ANALYZE)
SELECT * FROM items WHERE attrs @> '{"color":"red"}';
 Bitmap Index Scan on idx_attrs 여야 한다

· 이 컬럼이 차지하는 크기
SELECT pg_size_pretty(sum(pg_column_size(attrs))) FROM items;

핵심 한 줄JSON 은 스키마를 없앤 게 아니라 미룬 것이다. 이자가 붙는다.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

트랜잭션·락·DB 운영 글 21편 모두 보기 →