학습 목표
- JSON 타입이 TEXT 와 무엇이 다른지 이해하고, 경로식(
$.a.b[0],$**)으로 값을 꺼낸다->와->>의 차이를 정확히 알고, JSON_SET/INSERT/REPLACE/REMOVE/MERGE_PATCH 로 문서를 수정한다JSON_TABLE로 JSON 을 관계형 테이블처럼 펼쳐 JOIN·GROUP BY 를 적용한다- 생성 컬럼 인덱스와 멀티밸류 인덱스(8.0.17+) 로 JSON 검색을 빠르게 만든다
- 언제 JSON 을 쓰고 언제 정규화 컬럼을 써야 하는지 판단 기준을 갖는다
선행 스텝: Step 17 예상 소요: 70분
MySQL 8.0 신기능
JSON_TABLE— 8.0.4JSON_OVERLAPS,MEMBER OF, 멀티밸류 인덱스,JSON_SCHEMA_VALID— 8.0.17- 네이티브 JSON 타입 자체는 5.7 에 도입됐지만, 실무에서 쓸 만해진 건 8.0부터입니다.
⚠️ 안전 규칙 공용 테이블
products는 절대 변경하지 않습니다. JSON 수정 실습은 모두 사본s18_products에서 합니다.practice.sql첫 블록이 사본을 만들어 줍니다.
우리 스키마의 products.attrs 가 JSON 컬럼입니다. 카테고리마다 속성이 완전히 다릅니다.
JSON 을 TEXT 에 넣어도 동작은 합니다. 하지만 네이티브 JSON 타입은 네 가지가 다릅니다.
ERROR 3140).JSON_SET 으로 값 하나만 바꾸면 문서 전체를 다시 쓰지 않을 수 있습니다.결과
키가 정렬되고(b 뒤에 있던 a 가 앞으로), 중복 키 a 는 마지막 값 3만 남았습니다.
넣은 그대로 돌려받길 기대하면 안 됩니다.
JSON_TYPE() 으로 각 값의 타입을 확인할 수 있습니다.
결과
결과
행을 통째로 JSON 문서로 말아 올리는 것도 자주 씁니다 (API 응답 조립).
결과
attrs 가 JSON 컬럼이므로 문자열이 아니라 중첩 객체로 들어갔습니다.
만약 attrs 가 TEXT 였다면 "{\"size\":...}" 처럼 이스케이프된 문자열이 됐을 겁니다.
결과
$**.name 은 깊이에 상관없이 name 을 전부 찾아 배열로 돌려줍니다.
(루트의 "노트북" 과 spec.maker.name 의 "ACME" 둘 다)
와일드카드를 쓰면 결과가 항상 배열이 된다는 점을 기억하세요.
-> 와 ->> 의 차이 (중요)->> 를 "화살표 두 개 = 따옴표 벗김" 으로 외우세요.
결과
⚠️ 함정 —
=비교는 잘 되는데LIKE는 안 된다->의 결과에는 따옴표가 실제로 들어 있습니다. 그런데=비교는 우연히 잘 동작합니다. MySQL 이 오른쪽'cotton'을 JSON 으로 캐스팅해서 비교하기 때문입니다. 이것 때문에->를 써도 되는 줄 착각하게 됩니다. 하지만LIKE,CONCAT,CHAR_LENGTH처럼 값을 문자열로 다루는 순간 따옴표가 튀어나옵니다.
결과
eq_arrow 는 1(성공)인데 like_arrow 는 0(실패)입니다. CHAR_LENGTH 가 8 vs 6 — 따옴표 2개가 들어 있다는 증거입니다.
💡 실무 팁 — 규칙은 간단합니다 애플리케이션으로 값을 꺼낼 땐 언제나
->>.->는 "JSON 조각을 JSON 인 채로" 다룰 때만 쓰세요 (예:JSON_CONTAINS,MEMBER OF, 멀티밸류 인덱스의 대상).
attrs 가 NULL 인 행에서 값을 꺼내면 에러가 아니라 NULL 이 나옵니다. 존재하지 않는 키도 NULL 입니다.
이 "조용한 NULL" 이 JSON 의 양날의 검입니다. 컬럼명을 오타 내도 에러가 안 나고 그냥 NULL 이 나옵니다.
정규화 컬럼이었다면 Unknown column 에러로 즉시 잡혔을 실수입니다.
| 함수 | 키가 있으면 | 키가 없으면 |
|---|---|---|
JSON_SET | 수정 | 추가 (upsert) |
JSON_INSERT | 무시 | 추가 |
JSON_REPLACE | 수정 | 무시 |
JSON_REMOVE | 삭제 | 무시 |
한 줄로 셋을 비교해 봅시다. $.material 은 있고 $.new 는 없는 상태입니다.
결과 (보기 좋게 줄바꿈했습니다)
실제 UPDATE 는 사본 테이블에서 합니다.
결과
이 둘을 헷갈리면 데이터가 조용히 망가집니다.
JSON_MERGE_PATCH (RFC 7386) — 같은 키는 덮어쓰기. 값에 null 을 주면 그 키를 삭제. → 이게 여러분이 원하는 것JSON_MERGE_PRESERVE — 같은 키를 배열로 합침. → 대개 원하지 않는 결과결과
merge_patch 에서 "c":null 을 줬더니 c 키가 사라졌습니다. 그리고 b 는 재귀적으로 병합됐습니다.
merge_preserve 는 a 가 1 도 2 도 아닌 [1, 2] 배열이 돼 버렸습니다. 거의 항상 버그입니다.
💡 실무 팁 — 부분 업데이트(PATCH) API 구현
SET attrs = JSON_MERGE_PATCH(COALESCE(attrs, '{}'), ?)가 정석입니다.COALESCE를 빼면attrs가 NULL 인 행에서 결과가 통째로 NULL 이 됩니다. (JSON 함수는 인자가 하나라도 NULL 이면 대부분 NULL 을 반환합니다)
결과
JSON_CONTAINS_PATH(doc, 'one'|'all', path...) 는 키의 존재 여부만 봅니다 (값은 안 봄).
결과
노트북은 cpu+ram_gb 둘 다 있고, 스마트폰은 storage_gb 만 있어 all 조건에서 0 이 나왔습니다.
JSON_OVERLAPS (8.0.17+) 는 두 JSON 이 하나라도 겹치는 원소가 있는가를 봅니다. 배열판 IN 이라고 생각하세요.
결과
JSON_SEARCH 는 반대로 값으로 경로를 역추적합니다.
이 스텝에서 가장 중요한 기능입니다. JSON_TABLE 로 일단 테이블 모양으로 펼치고 나면, JOIN·GROUP BY·윈도우 함수 등 SQL 전체를 그대로 쓸 수 있습니다.
결과
경로를 '$.color[*]' 로 주면 배열 원소마다 한 행씩 생깁니다.
결과
상품 1이 색상 3개라 3행이 됐습니다. (green 은 18-5 에서 우리가 추가한 값입니다)
⚠️ 함정 — JSON_TABLE 은 암묵적으로 INNER JOIN 처럼 동작한다 위 쿼리는
FROM a, JSON_TABLE(...)즉 CROSS JOIN LATERAL 입니다.attrs가 NULL 이거나color배열이 없는 상품은 결과에서 통째로 사라집니다. 모든 상품을 남기려면LEFT JOIN JSON_TABLE(...) ON TRUE를 쓰세요.
FOR ORDINALITY 는 1부터 시작하는 순번을, NESTED PATH 는 중첩 배열을 펼칩니다.
결과
"가장 많이 쓰인 태그"를 구합니다. 태그가 정규화 테이블에 있었다면 썼을 쿼리와 똑같습니다.
결과
색상별 재고 합계도 같은 방식입니다.
테이블 컬럼이 아니라 리터럴 JSON 문자열도 펼칠 수 있습니다. API 응답이나 배치 입력을 처리할 때 유용합니다.
결과
값이 없을 때 NULL 대신 기본값을 줄 수 있습니다. (NULL ON EMPTY 가 기본값, ERROR ON EMPTY 도 가능)
결과
JSON 컬럼에는 직접 인덱스를 걸 수 없습니다. 우회로가 두 개 있습니다.
결과
💡 실무 팁 — 생성 컬럼 이름을 몰라도 인덱스를 탄다 MySQL 8 은 원래 표현식으로 검색해도 생성 컬럼 인덱스에 매칭시켜 줍니다. 아래 세 쿼리는 전부
key: idx_s18_cpu로 같은 인덱스를 탑니다 (직접 EXPLAIN 해 보세요).옵티마이저가 표현식을 정규화해서 매칭하기 때문입니다. 덕분에 애플리케이션 쿼리를 안 고쳐도 인덱스 효과를 얻습니다.
STORED대신VIRTUAL을 써도 인덱싱은 됩니다 (디스크를 안 먹는 대신 읽을 때 계산 비용).
한 행이 인덱스 레코드를 여러 개 갖는 유일한 인덱스 타입입니다. 배열의 각 원소가 인덱스 엔트리가 됩니다.
문법의 핵심은 CAST(... AS <type> ARRAY).
결과
JSON_OVERLAPS 도 인덱스를 탑니다 (type: range).
⚠️ 함정 — 멀티밸류 인덱스는 딱 3가지에서만 쓰인다
MEMBER OF(),JSON_CONTAINS(),JSON_OVERLAPS()— 이 셋뿐입니다.WHERE attrs ->> '$.tags' LIKE '%pro%'같은 건 절대 인덱스를 못 탑니다. 풀스캔입니다. 그 외 제약: 테이블당 개수 제한이 있고, 커버링 인덱스로 못 쓰며, PRIMARY/UNIQUE/외래키로는 못 만듭니다.
JSON 은 스키마가 없다는 게 장점이자 최대 약점입니다. 최소한의 방어선을 칠 수 있습니다.
결과
스마트폰은 cpu/ram_gb 가 없으니 스키마 위반(0)입니다. 왜 실패했는지는 REPORT 로 봅니다.
결과
CHECK 제약으로 걸면 아예 못 들어오게 막을 수 있습니다.
결과
이 스텝에서 가장 중요한 절입니다. JSON 은 강력하지만, 남용하면 관계형 DB를 쓸 이유가 사라집니다.
products.attrs 가 딱 그렇습니다.
노트북은 cpu/ram_gb, 셔츠는 color/size, 감귤은 origin/organic.
이걸 정규화 컬럼으로 만들면 컬럼 40개짜리 테이블에 NULL 이 90%인 참사가 벌어집니다.ALTER TABLE 하기 곤란할 때WHERE / JOIN / ORDER BY 에 쓰이는 값 → 인덱스가 필요하면 컬럼으로 빼세요| 질문 | 예 → | 아니오 → |
|---|---|---|
| 모든 행이 이 값을 갖는가? | 컬럼 | JSON 후보 |
| 이 값으로 검색·정렬·조인하는가? | 컬럼 (또는 생성 컬럼 + 인덱스) | JSON 가능 |
| FK/UNIQUE 제약이 필요한가? | 컬럼 | JSON 가능 |
| 값의 종류가 행마다 다른가? | JSON | 컬럼 |
| 속성이 계속 늘어나는가? | JSON | 컬럼 |
⚠️ 함정 — JSON 을 "스키마 설계 안 해도 되는 핑계"로 쓰지 마세요 실무에서 가장 흔한 실패 패턴입니다.
orders테이블에data JSON컬럼 하나 만들어 놓고 금액·상태·고객ID를 전부 그 안에 넣으면:
WHERE data->>'$.status' = 'PAID'→ 인덱스 없이 풀스캔- 금액 합계가 문자열로 저장돼
SUM()이 이상하게 동작- 고객ID에 FK 를 못 걸어 고아 데이터가 쌓임
- 오타 낸 키(
statuss)가 조용히 NULL 을 반환해 버그가 몇 달 뒤에 발견됨관계형 DB 안에서 스키마리스를 흉내 내면 두 세계의 단점만 갖게 됩니다.
💡 실무 팁 — 하이브리드가 정답 우리 스키마가 좋은 예입니다.
price,stock,status,category_id는 정규화 컬럼(검색·집계·FK 대상), 상품별로 제각각인 스펙만attrsJSON. 그리고 JSON 안의 값이 자주 검색되기 시작하면 생성 컬럼으로 승격시키세요. "JSON 에서 시작해 안정된 것부터 컬럼으로 빼낸다" 가 현실적인 진화 경로입니다.
| 분류 | 함수 | 비고 |
|---|---|---|
| 생성 | JSON_OBJECT / JSON_ARRAY / JSON_QUOTE | |
| 추출 | JSON_EXTRACT = -> | 결과가 JSON (따옴표 포함) |
| 추출 | JSON_UNQUOTE(JSON_EXTRACT()) = ->> | 결과가 문자열 ← 보통 이걸 쓴다 |
| 수정 | JSON_SET | 있으면 수정, 없으면 추가 |
| 수정 | JSON_INSERT / JSON_REPLACE | 없을 때만 / 있을 때만 |
| 수정 | JSON_REMOVE / JSON_ARRAY_APPEND | |
| 병합 | JSON_MERGE_PATCH | RFC 7386. 덮어쓰기, null 로 삭제 ← 보통 이걸 쓴다 |
| 병합 | JSON_MERGE_PRESERVE | 배열로 합침. 대개 원하지 않는 결과 |
| 검색 | JSON_CONTAINS / MEMBER OF (8.0.17) | 값 포함 여부. 멀티밸류 인덱스 사용 가능 |
| 검색 | JSON_OVERLAPS (8.0.17) | 교집합 존재 여부. 멀티밸류 인덱스 사용 가능 |
| 검색 | JSON_CONTAINS_PATH / JSON_SEARCH | 키 존재 / 값→경로 역추적 |
| 펼치기 | JSON_TABLE (8.0.4) | 가장 중요. 관계형으로 변환 |
| 검증 | JSON_SCHEMA_VALID (8.0.17) | CHECK 제약과 조합 |
| 기타 | JSON_TYPE / JSON_PRETTY / JSON_LENGTH / JSON_KEYS |
| 대상 | 방법 | 사용 가능한 조건 |
|---|---|---|
| 스칼라 값 | 생성 컬럼(STORED/VIRTUAL) + 일반 인덱스 | =, <, BETWEEN, ORDER BY … 전부 |
| 배열 | 멀티밸류 인덱스 CAST(... AS CHAR(n) ARRAY) | MEMBER OF, JSON_CONTAINS, JSON_OVERLAPS 만 |
-> 는 따옴표를 남긴다. = 비교는 되는데 LIKE/CONCAT 은 안 된다 → 항상 ->>JSON_TABLE 은 INNER JOIN 처럼 동작한다. attrs 가 NULL 인 행이 사라진다 → LEFT JOIN ... ON TRUE실습이 끝나면 사본 테이블을 지우세요.
exercise.sql 의 7문제를 풀어 보세요. 정답은 solution.sql 에 있습니다.
weight_kg)가 있는 상품만 뽑기organic: true 인 식품 찾기이 스텝은 SQL 파일 3개로 진행합니다. 먼저 practice.sql 을 통째로 실행해 사본 테이블 s18_products 를 만들고 18-1 ~ 18-10 의 예제를 순서대로 따라갑니다. 그다음 exercise.sql 의 7문제를 직접 풀어 보고, 마지막으로 solution.sql 로 정답과 해설을 확인합니다. 세 파일 모두 공용 테이블 products 는 읽기만 하고, 수정은 사본 s18_products 에서만 하도록 설계돼 있습니다.
강의 본문의 모든 예제를 그대로 담은 메인 실습 스크립트입니다. mysql ... shop --table < practice.sql 로 한 번에 실행하면 됩니다.
[18-0] 블록이 안전장치입니다. CREATE TABLE s18_products LIKE products; + INSERT INTO s18_products SELECT * FROM products; 로 사본을 만듭니다. 주석에 적힌 대로 LIKE 는 컬럼·인덱스·CHECK 는 복사하지만 FOREIGN KEY 는 복사하지 않아서, 사본은 categories 와 무관하게 자유롭게 고칠 수 있습니다. 이 블록을 건너뛰면 이후 모든 쿼리가 Table 's18_products' doesn't exist 로 죽습니다.[18-1] 의 '{not json}' INSERT 는 주석을 풀면 ERROR 3140 (Invalid JSON text) 이 납니다 — JSON 타입이 저장 시점에 문법을 검증한다는 증거입니다. [18-9] 끝의 INSERT INTO s18_specs VALUES (2, '{"cpu":"i3","ram_gb":2}'); 는 주석을 풀면 ERROR 3819 (Check constraint violated) 가 납니다. ram_gb 가 스키마의 "minimum": 4 를 못 넘기기 때문입니다. 둘 다 에러를 보는 것이 학습 목표이니 반드시 직접 풀어서 확인하세요.[18-4] 의 eq_arrow / like_arrow / len_arrow 비교 쿼리가 이 스텝의 핵심 함정입니다. (attrs -> '$.material') = 'cotton' 은 1 이 나오는데 LIKE 'cot%' 는 0, CHAR_LENGTH 는 8 vs 6 — 따옴표 2개가 실제로 들어 있다는 뜻입니다.[18-5] 의 UPDATE 들은 사본의 데이터를 실제로 바꿉니다. 상품 1에 JSON_SET 으로 season/reviewed 를 추가하고, JSON_ARRAY_APPEND 로 color 에 green 을 덧붙입니다. 그래서 뒤쪽 [18-7] (2) 의 색상 펼치기 결과에 green 이 등장합니다. 이어지는 세 번째 UPDATE 는 JSON_MERGE_PATCH(COALESCE(attrs, '{}'), '{"origin":"국내","organic":true}') 로 attrs 가 NULL 이던 상품 22를 패치합니다 — COALESCE 가 왜 필요한지를 보여 주는 실물 예제입니다(빼면 결과가 통째로 NULL). 순서 의존성이 있으니 스크립트를 위에서부터 순서대로 돌리세요.[18-8] 의 두 ALTER TABLE 은 생성 컬럼 cpu(STORED) + idx_s18_cpu, 그리고 멀티밸류 인덱스 idx_s18_tags ((CAST(attrs -> '$.tags' AS CHAR(20) ARRAY))) 를 만듭니다. 이 스크립트를 두 번 실행하면 [18-0] 이 테이블을 DROP 하고 다시 만들기 때문에 중복 에러는 나지 않습니다.[18-10] cleanup 두 줄(DROP TABLE ... s18_specs / s18_products)은 주석 처리돼 있습니다. 실습이 끝난 뒤 직접 실행해서 정리하세요.7문제의 문제문만 들어 있는 빈 연습 파일입니다. 각 문제 아래 -- 여기에 작성 자리에 직접 쿼리를 써 넣고 실행해 보세요. 파일 첫 주석대로 practice.sql 을 먼저 돌려 s18_products 사본을 만든 뒤 시작해야 합니다.
-> 로 꺼내도 숫자 타입은 따옴표가 안 붙는다는 점을 노린 것이고, 문제 2 의 힌트 JSON_LENGTH 는 $.tags 가 없는 행에서 NULL 을 돌려줘 자동으로 걸러진다는 점이 포인트입니다.$.size[*] 를 JSON_TABLE 로 펼쳐 GROUP BY 하는, 18-7 (4)번 예제의 응용입니다.attrs ->> '$.organic' = 'true' 같은 문자열 비교로 도망가지 말라는 경고입니다.product_id = 29 가 attrs 가 NULL 인 행이라는 점이 함정입니다. COALESCE(attrs, '{}') 를 빼면 JSON_MERGE_PATCH 결과가 통째로 NULL 이 되어 데이터가 사라집니다. 반드시 사본 s18_products 에서만 UPDATE 하세요.RANK())과 멀티밸류 인덱스 만들기입니다.7문제의 정답과 해설 주석이 함께 들어 있는 파일입니다. 문제를 먼저 풀어 본 뒤에 열어 보세요.
DROP TABLE IF EXISTS s18_products; → CREATE TABLE ... LIKE products; 로 사본을 다시 만듭니다. practice.sql 을 안 돌렸어도 단독 실행되게 하려는 배려지만, 뒤집어 말하면 practice.sql 로 해 둔 수정(green 색상 추가 등)과 [18-8] 의 인덱스가 전부 날아갑니다. 이 점을 알고 실행하세요.WHERE attrs -> '$.weight_kg' IS NOT NULL — 존재 여부를 NULL 검사로 대신하며, attrs 자체가 NULL 인 행도 함께 걸러집니다.WHERE attrs -> '$.organic' = TRUE 로, ->> 가 아니라 -> 를 쓰는 드문 경우입니다. JSON boolean 은 JSON 값끼리 비교해야 정확하기 때문입니다(18-4 의 "-> 는 JSON 조각을 JSON 인 채로 다룰 때만" 규칙의 실제 사례).JSON_MERGE_PATCH(COALESCE(attrs, '{}'), ...), 이미 키가 있는 24번에는 JSON_SET(attrs, '$.organic', TRUE) 로 나눠 씁니다.ADD INDEX idx_s18_size ( (CAST(attrs -> '$.size' AS CHAR(10) ARRAY)) ) 는 size 가 ["M","L","XL"] 같은 문자열 배열이라 CHAR(10) 으로 캐스팅합니다. 멀티밸류 인덱스의 CAST 대상 타입은 CHAR/BINARY/SIGNED/UNSIGNED/DATE/TIME/DATETIME/DECIMAL 뿐이라 INT ARRAY 라고 쓰면 문법 에러이고, SIGNED ARRAY 로 바꿔도 "M" 을 숫자로 변환할 수 없어 ALTER TABLE 이 실패합니다. 배열 원소의 타입에 맞춰 캐스팅해야 합니다.idx_s18_size 로, practice.sql [18-8] 이 만드는 idx_s18_tags 와 다릅니다. 다만 이 파일 상단이 테이블을 다시 만들기 때문에 idx_s18_cpu/idx_s18_tags 는 남아 있지 않습니다.