JSON 구조와 검증
문법상 유효한 JSON이 업무 구조를 위반하는 오류를 재현하고 JSON_SCHEMA_VALID과 원자 부분 수정 프로시저로 메타데이터 규칙을 만듭니다.
이 장은 게시글별 선택 메타데이터, JSON 요청 배치, JSON 경로 검색이 실제 요구일 때 읽는 선택 심화입니다. 7장의 인덱스와 12장의 관계형 모델을 먼저 이해해야 합니다. JSON은 관계형 모델을 대체하는 기본 저장 형식이 아니라 함께 읽히는 작은 선택 문서에 제한해 비교합니다.
JSON 열은 객체와 배열을 자연스럽게 담지만 아무 JSON이나 업무상 올바른 것은 아닙니다. 문자열이어야 할 라이선스에 배열이 들어가거나 필수 원본이 빠져도 JSON 문법 자체는 유효합니다. 애플리케이션 검증만 믿으면 가져오기와 운영 SQL이 다른 모양을 저장합니다.
회원 게시판의 외부 출처·라이선스·태그 같은 선택 메타데이터는 게시글마다 모양이 달라 JSON이 적합할 수 있습니다.
그래도 schema_version, 필수 키, 값 유형, 허용하지 않는 추가 키를 명시해야 읽기 주체가 안전하게 해석합니다.
MySQL 8.4의 JSON_SCHEMA_VALID을 검사에 사용해 모든 쓰기 주체에 최소 구조를 적용합니다.
post_metadata는 핵심 posts 행과 1:1입니다.
관계 검색의 중심인 제목과 status, 작성자, 게시 시각은 posts 열에 남기고 원본, 라이선스, external_id, 태그 같은 선택 메타데이터만 문서에 둡니다.
버전 1 스키마는 원본과 라이선스 문자열을 필수로 하고 태그는 최대 10개의 문자열 배열로 제한합니다.
부분 수정 프로시저는 문서를 읽어 JSON_SET으로 새 복사본을 만든 뒤 UPDATE합니다.
검사 오류 발생 시 기존 문서가 유지됩니다.
JSON 함수 기초
JSON은 키와 값을 묶은 객체, 순서가 있는 배열, 문자열·숫자·불리언·null 값으로 구성됩니다.
객체의 키와 문자열은 큰따옴표로 감싸고 마지막 항목 뒤에는 쉼표를 두지 않습니다.
JSON에는 주석 문법이 없으므로 설명이 필요한 값은 관계형 열이나 별도 문서에서 관리합니다.
| JSON 값 | 예 | MySQL에서 확인할 타입 |
|---|---|---|
| 객체 | {"source":"editorial"} | OBJECT |
| 배열 | ["mysql","index"] | ARRAY |
| 문자열 | "MIT" | STRING |
| 번호 | 92 | INTEGER 또는 DOUBLE |
| 불리언 | true | BOOLEAN |
| NULL | null | NULL |
문자열을 손으로 이어 붙이기보다 JSON_OBJECT와 JSON_ARRAY로 문서를 만들면 따옴표와 이스케이프 처리를 MySQL에 맡길 수 있습니다.
경로는 $에서 시작하며 $.source는 객체의 원본 키, $.tags[0]은 태그 배열의 첫 항목을 가리킵니다.
DROP TEMPORARY TABLE IF EXISTS json_intro;
CREATE TEMPORARY TABLE json_intro (
post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
metadata JSON NOT NULL
);
INSERT INTO json_intro VALUES (
1,
JSON_OBJECT(
'source', 'editorial',
'license', 'MIT',
'tags', JSON_ARRAY('mysql', 'index'),
'review', JSON_OBJECT('score', 92, 'published', TRUE)
)
);
SELECT
metadata->'$.source' AS source_json,
metadata->>'$.source' AS source_text,
JSON_VALUE(metadata, '$.review.score' RETURNING UNSIGNED) AS review_score,
JSON_EXTRACT(metadata, '$.tags[0]') AS first_tag_json,
JSON_CONTAINS(
JSON_EXTRACT(metadata, '$.tags'),
JSON_QUOTE('mysql')
) AS has_mysql
FROM json_intro
WHERE post_id = 1;source_json: "editorial"
source_text: editorial
review_score: 92
first_tag_json: "mysql"
has_mysql: 1JSON_EXTRACT와 ->는 JSON 값을 반환하므로 문자열에는 큰따옴표가 남습니다.
->>는 문자열을 SQL 텍스트로 꺼내고, JSON_VALUE는 반환 타입을 지정해 비교·정렬할 때 유용합니다.
경로가 없으면 대부분의 조회 함수가 SQL NULL을 반환합니다.
키가 존재하면서 값이 JSON null인 경우와는 다르므로 JSON_CONTAINS_PATH와 JSON_TYPE을 함께 사용합니다.
문서를 바꾸는 함수도 역할이 다릅니다.
JSON_SET은 키를 추가하거나 기존 값을 교체하고, JSON_INSERT는 경로가 없을 때만 추가하며, JSON_REPLACE는 이미 존재하는 경로만 바꿉니다.
JSON_REMOVE는 지정한 키나 배열 항목을 제거합니다.
UPDATE json_intro
SET metadata = JSON_SET(
metadata,
'$.license', 'Apache-2.0',
'$.reviewer', 'moderator-1'
)
WHERE post_id = 1;
UPDATE json_intro
SET metadata = JSON_INSERT(metadata, '$.source', 'ignored')
WHERE post_id = 1;
UPDATE json_intro
SET metadata = JSON_REPLACE(metadata, '$.review.score', 95)
WHERE post_id = 1;
UPDATE json_intro
SET metadata = JSON_REMOVE(metadata, '$.reviewer')
WHERE post_id = 1;
SELECT
metadata->>'$.source' AS source_text,
metadata->>'$.license' AS license_text,
JSON_VALUE(metadata, '$.review.score' RETURNING UNSIGNED) AS review_score,
JSON_CONTAINS_PATH(metadata, 'one', '$.reviewer') AS has_reviewer
FROM json_intro;최종 원본은 기존 값 editorial을 유지하고 라이선스는 Apache-2.0, 점수는 95, 검토자 존재 여부는 0입니다.
JSON_INSERT가 기존 원본을 덮지 않고 JSON_REMOVE가 검토자 경로 자체를 지운 결과입니다.
구조를 탐색할 때는 JSON_KEYS로 객체 키 목록, JSON_LENGTH로 객체·배열 항목 수, JSON_TYPE으로 값 타입을 확인합니다.
JSON_VALID은 문자열이 JSON 문법에 맞는지만 검사하고, JSON_PRETTY는 사람이 읽기 쉽게 표시할 뿐 저장 구조를 검증하지 않습니다.
SELECT
JSON_KEYS(metadata) AS root_keys,
JSON_LENGTH(metadata, '$.tags') AS tag_count,
JSON_TYPE(metadata->'$.review') AS review_type,
JSON_VALID(metadata) AS syntax_valid,
JSON_PRETTY(metadata) AS formatted_document
FROM json_intro;이 기본 함수가 값을 어떻게 반환하는지 확인한 뒤 스키마 검증으로 넘어갑니다. 함수가 정상 결과를 냈다는 사실은 원본·라이선스가 필수이고 태그가 문자열 배열이어야 한다는 업무 규칙까지 보장하지 않습니다.
문법을 통과한 구조 오류
JSON 유형 열은 깨진 문법을 막지만 객체 구조를 강제하지 않습니다.
JSON_EXTRACT 결과를 문자열이라고 가정한 API는 배열을 만나 실행 시점 분기를 추가하거나 잘못된 값을 노출합니다.
DROP TABLE IF EXISTS post_metadata_bad;
CREATE TABLE post_metadata_bad(
post_id BIGINT UNSIGNED PRIMARY KEY,metadata JSON NOT NULL
) ENGINE=InnoDB;
INSERT INTO post_metadata_bad VALUES
(1,JSON_OBJECT('license',JSON_ARRAY('MIT','Apache'),'tags',JSON_ARRAY(10,20))),
(2,JSON_OBJECT('source','provider-a','unexpected_secret','plain-text-token'));
SELECT post_id,JSON_TYPE(metadata->'$.license') license_type,
JSON_TYPE(metadata->'$.tags[0]') tag_type,
JSON_CONTAINS_PATH(metadata,'one','$.source') has_source
FROM post_metadata_bad;두 문서 모두 유효 JSON이지만 필수 키와 값 유형, 허용 필드 규칙을 어깁니다.
오류 실행 결과post 1: license=ARRAY, first topic=INTEGER, source=0
post 2: license missing, unexpected_secret present
JSON syntax errors=0
business-valid documents=0JSON_VALID은 파서가 읽을 수 있는지만 말합니다.
스키마 검증은 필수, 속성, 유형, maxItems, additionalProperties를 함께 평가합니다.
두 검증 수준을 같은 단어로 부르면 “JSON이 유효하다”는 보고가 과장됩니다.
JSON 스키마를 검사식의 리터럴로 넣으면 DDL과 함께 버전 관리됩니다. 구조 변경은 기존 행 호환성을 먼저 측정하고 새 제약 조건을 적용해야 합니다. 외부 연동이 임의 키를 보낼 수 있다면 원본 응답 보관과 선별된 메타데이터를 분리합니다.
JSON에 중복 저장하지 않을 열
post_id, 제목, 유형, 활성 상태처럼 FK·UNIQUE·핵심 필터가 사용하는 값은 관계형 열에 둡니다.
같은 값을 메타데이터에도 복사하면 어느 쪽이 원본인지 정해야 하고 불일치 검사가 생깁니다.
JSON 문서 구조 규칙
schema_version은 읽기 주체가 해석 규칙을 선택하는 값입니다.
숫자만 올리고 문서를 마이그레이션하지 않으면 안 됩니다.
갱신 프로시저가 새 구조로 변환하고 검사를 통과한 뒤 버전을 함께 바꿉니다.
JSON_SET은 지정 경로만 바꾼 새 문서를 반환합니다.
전체 문서를 클라이언트가 읽기-수정-쓰기하면 서로 다른 경로의 동시 변경이 손실 갱신으로 끝날 수 있습니다.
행 잠금 또는 SQL-쪽 부분 수정으로 직렬화합니다.
JSON NULL과 경로 없음 구분하기
키가 없다는 것과 키가 JSON NULL인 것은 다릅니다.
선택 필드를 제거할 때 JSON_REMOVE를 사용하고, NULL을 실제 값으로 허용할 때만 스키마 유형에 NULL을 포함합니다.
이 예제는 원본과 라이선스의 NULL을 허용하지 않습니다.
MySQL 8.4 메모:
JSON_SCHEMA_VALID은 지원하는 JSON 스키마 부분 집합과 성능 비용을 확인해야 합니다. 대형 JSON 문서에 복잡한 스키마를 적용하기 전 실제 삽입 지연 시간을 측정합니다.
버전별 JSON 스키마
DDL의 JSON 스키마는 additionalProperties=거짓으로 알 수 없음 키를 거절합니다. 원본 외부 응답은 이 테이블에 넣지 않습니다. 부분 수정 프로시저는 게시글과 메타데이터 행을 잠가 라이선스와 태그를 함께 갱신합니다.
DROP PROCEDURE IF EXISTS patch_post_metadata;
DROP TABLE IF EXISTS post_metadata;
CREATE TABLE post_metadata(
post_id BIGINT UNSIGNED PRIMARY KEY,
schema_version SMALLINT UNSIGNED NOT NULL,
metadata JSON NOT NULL,
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
CONSTRAINT fk_post_metadata_post FOREIGN KEY(post_id)
REFERENCES posts(id) ON DELETE CASCADE,
CONSTRAINT chk_post_metadata_version CHECK(schema_version=1),
CONSTRAINT chk_post_metadata_schema CHECK(JSON_SCHEMA_VALID(
'{"type":"object","required":["source","license"],"additionalProperties":false,
"properties":{"source":{"type":"string","minLength":1,"maxLength":80},
"license":{"type":"string","minLength":1,"maxLength":60},
"external_id":{"type":"string","maxLength":120},
"tags":{"type":"array","maxItems":10,"items":{"type":"string","minLength":1,"maxLength":40}}}}',
metadata
))
) ENGINE=InnoDB;
SET @post_id=(SELECT id FROM posts ORDER BY id LIMIT 1);
SET @other_post_id=(SELECT id FROM posts WHERE id<>@post_id ORDER BY id LIMIT 1);
INSERT INTO post_metadata(post_id,schema_version,metadata)
VALUES(@post_id,1,JSON_OBJECT(
'source','editorial','license','CC-BY-4.0','external_id','post-701',
'tags',JSON_ARRAY('mysql','index')));
DELIMITER //
CREATE PROCEDURE patch_post_metadata(
IN p_post BIGINT UNSIGNED,IN p_license VARCHAR(60),IN p_tags JSON)
BEGIN
DECLARE v_doc JSON; DECLARE v_next JSON;
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
SELECT metadata INTO v_doc FROM post_metadata
WHERE post_id=p_post FOR UPDATE;
IF v_doc IS NULL OR JSON_TYPE(p_tags)<>'ARRAY' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='metadata or tags unavailable';
END IF;
SET v_next=JSON_SET(v_doc,'$.license',p_license,'$.tags',p_tags);
UPDATE post_metadata SET metadata=v_next WHERE post_id=p_post;
COMMIT;
END//
DELIMITER ;
CALL patch_post_metadata(
@post_id,'CC-BY-SA-4.0',JSON_ARRAY('mysql','btree','query-plan'));
SELECT metadata FROM post_metadata WHERE post_id=@post_id;부분 수정 후에도 필수 키가 남고 태그는 문자열 세 개의 배열입니다.
개선 실행 결과schema version=1
source=editorial
license=CC-BY-SA-4.0
tags length=3
schema violations=0p_tags가 배열인지 프로시저에서 빠르게 확인하고 세부 항목 유형과 길이는 검사가 판단합니다.
숫자 항목을 넣으면 UPDATE 전체가 3819로 롤백됩니다.
스키마 리터럴이 길어지면 마이그레이션 파일에서 읽기 어렵습니다. 같은 JSON 스키마 산출물을 애플리케이션 검증기와 DB DDL 생성에 사용하되 릴리스 해시를 기록해 불일치를 감시할 수 있습니다.
부분 완료 갱신의 스토리지 동작과 의미
MySQL은 일부 JSON_SET 갱신을 제자리 부분 갱신으로 최적화할 수 있지만 설계는 물리 최적화에 의존하지 않습니다.
업무적으로는 행 단위 잠금과 검사를 통과한 완전 문서가 커밋됩니다.
JSON 오류 유형 검증
세 예제 데이터는 스키마 규칙 하나만 깨뜨립니다. 마지막 감사는 저장된 행 수 모두가 스키마를 만족하는지 다시 계산하고 핵심 부모와의 고아도 확인합니다.
INSERT INTO post_metadata(post_id,schema_version,metadata)
VALUES(@other_post_id,1,JSON_OBJECT('source','import-provider'));
-- ERROR 3819: required license missing
CALL patch_post_metadata(
@post_id,'MIT',JSON_ARRAY('mysql',42));
-- ERROR 3819: tag item must be string
UPDATE post_metadata
SET metadata=JSON_SET(metadata,'$.unexpected_secret','token')
WHERE post_id=@post_id;
-- ERROR 3819: additionalProperties false
SELECT post_id FROM post_metadata
WHERE NOT JSON_SCHEMA_VALID(
'{"type":"object","required":["source","license"],"additionalProperties":false,
"properties":{"source":{"type":"string","minLength":1,"maxLength":80},
"license":{"type":"string","minLength":1,"maxLength":60},
"external_id":{"type":"string","maxLength":120},
"tags":{"type":"array","maxItems":10,"items":{"type":"string","minLength":1,"maxLength":40}}}}',metadata);
SELECT m.post_id FROM post_metadata m
LEFT JOIN posts r ON r.id=m.post_id WHERE r.id IS NULL;세 잘못된 쓰기는 각각 검사에서 거절되고 기존 게시글 메타데이터 문서는 바뀌지 않습니다. 전체 스키마 재검증과 고아 쿼리는 0행입니다.
JSON 문서 크기, JSON_STORAGE_SIZE, 갱신 지연 시간을 관찰합니다.
메타데이터가 커져 수백 KB 원본 응답을 담기 시작하면 선별된 문서와 보관 대형 객체 경계가 무너진 것입니다.
스키마 버전 2 배포 순서
새 읽기 주체가 v1과 v2를 모두 이해하게 배포한 뒤 행을 배치 변환합니다. v2 제약 조건을 바로 v1 배제 형태로 바꾸지 말고 전환 기간에는 두 스키마를 허용하는 검사 또는 새 테이블을 사용합니다. 완료 일치 검사 뒤 이전 읽기 주체를 제거합니다.
외부 원본 응답 보관
원본 API 응답은 변경 불가 객체 스토리지에 체크섬과 함께 보관하고 DB에는 URI, fetched_at, parser_version을 둘 수 있습니다.
선별된 메타데이터는 검색과 화면에 필요한 작은 부분 집합만 가집니다.
중첩 문서가 관계를 대신하는 순간
태그가 단순 표시명 필터라면 배열이 가능하지만 태그 소유권, 이름 변경, 통계가 생기면 post_tag 연결이 적절합니다.
JSON 배열을 관계형 연결처럼 운영하기 시작하면 무결성과 마이그레이션 비용이 숨어듭니다.
JSON 열 선택 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 변화 | 게시글별 선택 필드가 정규 DDL보다 자주 변하는가? |
| 관계 | 문서 내부 값이 다른 테이블의 FK 대상이 아닌가? |
| 검색 | JSON 문서 대부분을 저장·반환하고 소수 경로만 필터링하는가? |
| 검증 | 버전이 있는 스키마와 마이그레이션 소유자가 있는가? |
| 크기 | 행 잠금과 백업에 적합한 범위가 제한된 JSON 문서인가? |
선택적이고 함께 읽는 메타데이터라면 JSON 문서가 응집도를 높입니다. 관계와 핵심 제약이 중요하면 열 또는 자식 테이블을 유지합니다. JSON을 고른 기록에는 스키마 산출물, 최대 크기, 승격할 경로 기준을 포함합니다.
연습 문제
버전 2에서 선택 검토 객체를 추가하세요.
검토는 reviewed_on DATE 형식 문자열과 검토자 문자열을 모두 가져야 합니다.
v1 행을 보존하면서 한 행을 v2로 마이그레이션하는 SQL을 작성합니다.
해설과 예시 답안
전이 검사가 버전별 스키마를 OR로 평가하게 바꾸고 v2 읽기 주체를 먼저 배포합니다.
JSON_SET으로 검토 객체를 넣은 뒤 schema_version을 같은 UPDATE에서 2로 올립니다.
ALTER TABLE post_metadata DROP CHECK chk_post_metadata_version,
ADD CONSTRAINT chk_post_metadata_version CHECK(schema_version IN(1,2));
ALTER TABLE post_metadata DROP CHECK chk_post_metadata_schema,
ADD CONSTRAINT chk_post_metadata_schema CHECK(
(schema_version=1 AND JSON_CONTAINS_PATH(metadata,'all','$.source','$.license'))
OR
(schema_version=2 AND JSON_SCHEMA_VALID(
'{"type":"object","required":["source","license","review"],
"properties":{"source":{"type":"string"},"license":{"type":"string"},
"review":{"type":"object","required":["reviewed_on","reviewer"],
"properties":{"reviewed_on":{"type":"string","format":"date"},
"reviewer":{"type":"string"}},"additionalProperties":false}},
"additionalProperties":true}',metadata)));
UPDATE post_metadata
SET metadata=JSON_SET(metadata,'$.review',JSON_OBJECT(
'reviewed_on','2026-07-17','reviewer','editor-1')),schema_version=2
WHERE post_id=@post_id;마이그레이션 행은 버전 2이며 검토의 두 필수 키를 가집니다.
review.reviewer를 제거하는 UPDATE는 검사에서 오류가 발생하고 v1 행은 전이 기간에 계속 읽힙니다.
핵심 정리
- JSON 유형 유효성과 업무 문서 구조 유효성은 다른 검증입니다.
JSON_SCHEMA_VALID검사가 필수·유형·알 수 없음 키 규칙을 모든 쓰기 주체에 적용합니다.- 핵심 관계 열은 부모 스키마에 남기고 게시글 메타데이터만 문서로 묶습니다.
- 스키마 버전 변경은 읽기 주체 호환과 행 마이그레이션 순서를 가진 릴리스입니다.
다음 문서에서는 JSON 배열 가져오기를 JSON_TABLE로 행 집합으로 펼치고 정규 테이블에 안전하게 적재합니다.