JSON 구조와 검증
JSON 함수로 문서를 만들고 읽고 고치는 기본을 익힌 뒤, 문법상 유효한 JSON이 업무의 구조를 어기는 오류를 재현합니다. JSON_SCHEMA_VALID를 CHECK에 두고, 행을 잠그는 부분 수정 프로시저와 스키마 버전의 전환으로 메타데이터의 규칙을 만듭니다.
이 장은 게시글별 선택 메타데이터나 JSON 요청의 배치, JSON 경로의 검색이 실제 요구일 때 읽는 선택 심화입니다. 7장의 인덱스와 12장의 관계형 모델을 먼저 이해해야 하며, JSON을 관계형 모델을 대신하는 기본 저장 형식이 아니라 함께 읽히는 작은 선택 문서로 제한해 비교합니다.
JSON 열은 객체와 배열을 자연스럽게 담지만, 아무 JSON이나 업무상 올바른 것은 아닙니다. 문자열이어야 할 라이선스에 배열이 들어가거나 필수인 출처가 빠져도 JSON의 문법은 유효하므로, 애플리케이션의 검증만 믿으면 가져오기와 운영 SQL이 다른 모양을 저장합니다.
회원 게시판의 외부 출처와 라이선스, 태그 같은 선택 메타데이터는 게시글마다 모양이 달라 JSON이 알맞을 수 있습니다. 그래도 schema_version과 필수 키, 값의 타입, 허용하지 않는 추가 키를 명시해야 읽는 쪽이 안전하게 해석할 수 있으므로, MySQL 8.4의 JSON_SCHEMA_VALID를 CHECK에 두어 모든 쓰기 주체에 최소한의 구조를 적용합니다.
post_metadata 한 행은 posts 한 행을 참조하고, 게시글 하나에 메타데이터는 최대 한 행이며 모든 게시글에 반드시 있는 것은 아닙니다. 관계 검색의 중심인 제목과 status, 작성자, 게시 시각은 posts의 열에 남기고, 출처와 라이선스, external_id, 태그 같은 선택 메타데이터만 문서에 둡니다. 버전 1의 스키마는 출처와 라이선스 문자열을 필수로 하고, 태그는 최대 10개의 문자열 배열로 제한하며, 부분 수정 프로시저는 문서를 읽어 JSON_SET으로 새 복사본을 만든 뒤 UPDATE하므로 CHECK에 걸리면 기존 문서가 그대로 남습니다.
JSON 함수의 기초
JSON은 키와 값을 묶은 객체(object), 순서가 있는 배열(array), 그리고 문자열과 숫자, 불리언, 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]은 태그 배열의 첫 항목을 가리킵니다.
USE board_lab;
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 | source_text | review_score | first_tag_json | has_mysql
"editorial" | editorial | 92 | "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;source_text | license_text | review_score | has_reviewer
editorial | Apache-2.0 | 95 | 0JSON_INSERT는 이미 있는 출처를 덮지 않아 editorial이 남았고, JSON_REMOVE는 검토자의 경로 자체를 지웠습니다. 라이선스는 JSON_SET으로, 점수는 JSON_REPLACE로 바뀌었습니다.
구조를 살필 때는 JSON_KEYS로 객체의 키 목록을, JSON_LENGTH로 객체나 배열의 항목 수를, JSON_TYPE으로 값의 타입을 확인합니다. JSON_VALID는 문자열이 JSON 문법에 맞는지만 검사하고, JSON_PRETTY는 사람이 읽기 쉽게 보여 줄 뿐 저장 구조를 검증하지 않습니다.
SELECT
JSON_LENGTH(metadata, '$.tags') AS tag_count,
JSON_TYPE(metadata->'$.review') AS review_type,
JSON_VALID(metadata) AS syntax_valid,
JSON_KEYS(metadata) AS root_keys
FROM json_intro;tag_count | review_type | syntax_valid | root_keys
2 | OBJECT | 1 | (네 키의 배열, 순서는 아래 설명)root_keys는 source, license, tags, review의 네 키를 배열로 돌려줍니다. 다만 MySQL은 조회를 빠르게 하려고 객체의 키를 정렬해 저장하고, 그 순서는 릴리스마다 달라질 수 있다고 밝히므로, 넣은 순서나 특정 순서에 기대지 않습니다. 함수가 정상 결과를 냈다는 것만으로는, 출처와 라이선스가 필수이고 태그가 문자열 배열이어야 한다는 업무 규칙까지 보장되지 않습니다.
문법을 통과한 구조의 오류
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') AS license_type,
JSON_TYPE(metadata->'$.tags[0]') AS tag_type,
JSON_CONTAINS_PATH(metadata, 'one', '$.source') AS has_source
FROM post_metadata_bad
ORDER BY post_id;post_id | license_type | tag_type | has_source
1 | ARRAY | INTEGER | 0
2 | NULL | NULL | 1두 문서 모두 유효한 JSON이지만, 첫 문서는 라이선스가 배열이고 태그가 숫자이며 출처가 없고, 둘째 문서는 라이선스가 없고 허용하지 않을 비밀 키를 담았습니다. 경로가 없으면 ->가 NULL을 돌려주므로 JSON_TYPE도 NULL입니다.
JSON_VALID는 파서가 읽을 수 있는지만 말하고, 스키마 검증은 필수와 속성, 타입, maxItems, additionalProperties를 함께 평가합니다. 두 수준을 같은 말로 부르면 "JSON이 유효하다"는 보고가 실제보다 많은 것을 말하게 됩니다.
JSON 스키마를 CHECK의 리터럴로 넣으면 DDL과 함께 버전이 관리됩니다. 구조를 바꿀 때는 기존 행이 새 규칙을 지키는지 먼저 측정한 뒤 새 제약을 적용하고, 외부 연동이 임의의 키를 보낼 수 있다면 원래의 응답 보관과 골라낸 메타데이터를 나눕니다.
post_id와 제목, 유형, 활성 상태처럼 외래 키와 UNIQUE, 핵심 필터가 쓰는 값은 관계형 열에 둡니다. 같은 값을 메타데이터에도 복사하면 어느 쪽이 원본인지 정해야 하고, 불일치의 검사가 생깁니다.
JSON 문서의 구조 규칙
schema_version은 읽는 쪽이 해석 규칙을 고르는 값이므로, 숫자만 올리고 문서를 바꾸지 않으면 안 됩니다. 갱신 프로시저가 새 구조로 바꾸고 CHECK를 통과한 뒤 버전을 함께 바꿉니다.
JSON_SET은 지정한 경로만 바꾼 새 문서를 돌려줍니다. 클라이언트가 문서 전체를 읽고 고쳐 쓰면 서로 다른 경로의 동시 변경이 손실 갱신으로 끝날 수 있으므로, 행을 잠그거나 SQL 안에서 부분 수정으로 직렬화합니다.
키가 없는 것과 키의 값이 JSON의 null인 것은 다릅니다. 선택 필드를 지울 때는 JSON_REMOVE를 쓰고, null을 실제 값으로 허용할 때만 스키마의 타입에 null을 넣으며, 이 예제는 출처와 라이선스의 null을 허용하지 않습니다. JSON_SCHEMA_VALID가 지원하는 JSON 스키마의 범위와 성능 비용도 확인해야 하므로, 큰 문서에 복잡한 스키마를 적용하기 전에는 실제 삽입의 지연 시간을 측정합니다.
버전별 JSON 스키마
DDL의 JSON 스키마는 additionalProperties: false로 정의하지 않은 키를 거부하며, 외부의 원래 응답은 이 테이블에 넣지 않습니다. 부분 수정 프로시저는 메타데이터 행을 잠가 라이선스와 태그를 함께 바꾸고, 부모 게시글을 따로 SELECT ... FOR UPDATE하지는 않습니다.
뒤의 반례에는 메타데이터가 아직 없는 두 번째 게시글이 필요하므로, 12장 4편의 시드 게시글과 같은 작성자로 초안 게시글을 하나 만듭니다.
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 @author_id = (SELECT author_id FROM posts WHERE id = @post_id);
INSERT INTO posts (author_id, title, content, status, request_key)
VALUES (@author_id, 'JSON 메타데이터 실습', '메타데이터가 없는 두 번째 게시글',
'DRAFT', 'json-meta-002');
SET @other_post_id = LAST_INSERT_ID();
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 p_tags 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 schema_version,
metadata->>'$.source' AS source,
metadata->>'$.license' AS license,
JSON_LENGTH(metadata, '$.tags') AS tag_count
FROM post_metadata
WHERE post_id = @post_id;schema_version | source | license | tag_count
1 | editorial | CC-BY-SA-4.0 | 3부분 수정 뒤에도 필수 키가 남아 있고, 태그는 문자열 세 개의 배열입니다. 프로시저는 SQL의 NULL과 배열이 아닌 태그의 입력을 거부하고, CHECK는 항목의 타입과 길이, 최종 문서 전체를 검사하므로, 숫자가 섞인 태그를 넣으면 UPDATE 전체가 3819로 롤백됩니다. 이 프로시저도 바깥 트랜잭션 없이 호출하는 예제입니다.
스키마의 리터럴이 길어지면 마이그레이션 파일에서 읽기 어렵습니다. 같은 JSON 스키마의 산출물을 애플리케이션의 검증기와 데이터베이스의 DDL 생성에 함께 쓰고, 릴리스의 해시를 기록해 둘이 어긋나지 않는지 감시할 수 있습니다.
부분 갱신의 스토리지 동작과 의미
MySQL은 일부 JSON_SET의 갱신을 제자리의 부분 갱신으로 최적화할 수 있지만, 설계는 이런 물리적 최적화에 기대지 않습니다. 업무의 관점에서 커밋되는 것은, 행의 잠금 아래에서 CHECK를 통과한 완전한 문서입니다.
더 깊이: 제자리 부분 갱신이 일어나는 조건
MySQL 8.4 매뉴얼은 JSON 열의 부분 갱신이 일어나는 조건을 정해 둡니다. 열이 JSON 타입이어야 하고, UPDATE가 JSON_SET(), JSON_REPLACE(), JSON_REMOVE() 가운데 하나로 열을 고쳐야 하며, 문서 전체를 새 문자열로 대입하는 UPDATE는 해당하지 않습니다. 또 jcol = JSON_SET(jcol, ...)처럼 입력 열과 대상 열이 같아야 하고, 기존의 값을 새 값으로 바꾸기만 할 뿐 부모 객체나 배열에 새 원소를 더하지 않아야 합니다. 바꾸는 값은 원래 값보다 커서는 안 되는데, 앞선 부분 갱신이 남겨 둔 공간이 충분하면 예외입니다.
그래서 문서에 없던 키를 JSON_SET으로 더하거나 긴 문자열로 바꾸는 수정은, 같은 함수를 썼더라도 부분 갱신이 되지 않을 수 있습니다. 부분 갱신으로 비워진 공간은 JSON_STORAGE_FREE()로 확인할 수 있습니다. 테이블에서 일어난 부분 갱신과 바이너리 로그의 기록 방식은 별개의 일이어서, binlog_row_value_options를 PARTIAL_JSON으로 두면 바이너리 로그에 변경된 부분만 공간을 아끼는 간결한 형식으로 기록할 수 있습니다.
저장할 때 일어나는 정규화도 알아 둘 만합니다. MySQL은 JSON 열에 넣는 문서를 검증해 내부의 형식으로 바꾸는데, 이때 같은 키가 여러 번 나오면 왼쪽에서 오른쪽으로 읽어 마지막 것만 남기고 앞의 것을 버립니다. 그래서 {"x": 17, "x": "red"}는 오류 없이 {"x": "red"}로 저장되므로, 받은 원문의 해시나 서명을 나중에 다시 확인해야 한다면 원문을 따로 보관해야 합니다. 문서 하나의 크기는 서버가 저장할 때 max_allowed_packet의 값으로 제한됩니다.
JSON 오류 유형의 검증
세 반례는 스키마 규칙을 하나씩만 깨뜨립니다. 마지막 감사는 저장된 모든 행이 스키마를 지키는지 다시 계산하고, 핵심 부모와의 고아도 확인합니다.
INSERT INTO post_metadata (post_id, schema_version, metadata)
VALUES (@other_post_id, 1, JSON_OBJECT('source', 'import-provider'));
CALL patch_post_metadata(@post_id, 'MIT', JSON_ARRAY('mysql', 42));
UPDATE post_metadata
SET metadata = JSON_SET(metadata, '$.unexpected_secret', 'token')
WHERE post_id = @post_id;
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 AS m
LEFT JOIN posts AS r ON r.id = m.post_id
WHERE r.id IS NULL;ERROR 3819 (HY000): Check constraint 'chk_post_metadata_schema' is violated.
ERROR 3819 (HY000): Check constraint 'chk_post_metadata_schema' is violated.
ERROR 3819 (HY000): Check constraint 'chk_post_metadata_schema' is violated.라이선스가 없는 문서와 숫자 태그, 정의하지 않은 키는 차례로 같은 스키마 CHECK에서 거부되고, 기존 메타데이터 문서는 바뀌지 않습니다. 3819의 메시지는 어느 규칙이 깨졌는지 말해 주지 않지만, 직접 실행한 INSERT나 UPDATE가 실패한 바로 뒤에 SHOW WARNINGS를 실행하면 오류 3934의 메시지가 문서의 어느 위치가 스키마의 어느 규칙을 어겼는지 알려 줍니다. 저장하기 전에 원인을 미리 보려면 같은 스키마와 문서로 JSON_SCHEMA_VALIDATION_REPORT를 호출합니다. 전체 스키마의 재검증과 고아 쿼리는 빈 결과입니다.
운영에서는 문서의 크기와 JSON_STORAGE_SIZE, 갱신의 지연 시간을 지켜봅니다. 메타데이터가 커져 수백 KB의 원래 응답을 담기 시작했다면, 골라낸 작은 문서와 따로 보관할 큰 객체를 나누는 원칙이 무너진 것입니다.
스키마 버전 2는 새 읽는 쪽이 v1과 v2를 모두 이해하도록 먼저 배포한 뒤, 행을 배치로 바꿉니다. 전환 기간에는 v2의 제약을 v1을 배제하는 형태로 바로 바꾸지 말고 두 스키마를 모두 허용하는 CHECK나 새 테이블을 쓰며(연습 문제 1), 완료를 대조한 뒤 이전 읽는 쪽을 없앱니다.
외부 API의 원래 응답은 바뀌지 않는 객체 저장소에 체크섬과 함께 보관하고, 데이터베이스에는 URI와 fetched_at, parser_version을 둘 수 있습니다. 골라낸 메타데이터는 검색과 화면에 필요한 작은 부분 집합만 가집니다.
태그가 단순한 표시용 필터라면 배열로도 충분하지만, 태그의 소유와 이름 변경, 통계가 생기면 post_tags 같은 연결 테이블이 알맞습니다. JSON 배열을 관계형 연결처럼 운영하기 시작하면, 무결성과 마이그레이션의 비용이 보이지 않는 곳에 쌓입니다.
| 따져 볼 것 | 질문 |
|---|---|
| 변화 | 게시글별 선택 필드가 정규 DDL보다 자주 바뀌는가 |
| 관계 | 문서 안의 값이 다른 테이블의 외래 키 대상이 아닌가 |
| 검색 | 문서의 대부분을 저장하고 돌려주며, 소수의 경로만 거르는가 |
| 검증 | 버전이 있는 스키마와 마이그레이션의 담당자가 있는가 |
| 크기 | 행의 잠금과 백업에 알맞게 크기가 제한된 문서인가 |
선택적이고 함께 읽는 메타데이터라면 JSON 문서가 응집도를 높입니다. 관계와 핵심 제약이 중요하다면 열이나 자식 테이블을 유지하고, JSON을 고른 기록에는 스키마의 산출물과 최대 크기, 정규 열로 올릴 경로의 기준을 넣습니다.
정리
- JSON의 문법 유효성과 업무 문서의 구조 유효성은 서로 다른 검증입니다.
JSON_SCHEMA_VALID를 쓰는 CHECK가 필수와 타입, 정의하지 않은 키의 규칙을 모든 쓰기 주체에 적용합니다.- 경로가 없는 것과 값이 JSON의
null인 것은 다르므로, 함수의 반환값을 구분해 읽습니다. - 핵심 관계의 열은 부모 스키마에 남기고, 게시글의 선택 메타데이터만 문서로 묶습니다.
- 스키마 버전의 변경은 읽는 쪽의 호환과 행의 마이그레이션 순서를 가진 릴리스입니다.
연습 문제
1. 검토 객체를 더한 버전 2
버전 2에서 선택적인 검토 객체를 더해 보세요. 검토 객체를 넣는다면 reviewed_on의 YYYY-MM-DD 형태 문자열과 검토자 문자열을 모두 가져야 하며, 날짜의 실제 달력 유효성은 입력 단계에서 따로 검사합니다. v1의 행을 보존하면서 한 행을 v2로 옮기는 SQL을 작성합니다.
해설 보기
v2를 읽는 쪽을 먼저 배포하고, 전환용 CHECK가 버전별 전체 스키마를 OR로 평가하게 바꿉니다. 이 예제는 format: date에 달력의 검증을 맡기지 않으며, JSON_SET으로 검토 객체를 넣고 같은 UPDATE에서 버전을 2로 올립니다.
v1을 유지하며 선택 검토를 더하는 v2
v1과 v2를 읽을 소비자를 먼저 배포한다. 필드 조합을 검사한다. 각 단계의 값과 전환 이유를 비교합니다.
JSON SCHEMA · VERSION COMPATIBILITY / OPTIONAL OBJECT
v2에서도 review는 선택, 넣으면 내부 두 키가 필수다
v1의 원본·라이선스·태그 규칙은 그대로 유지합니다. 전환 CHECK는 버전별 전체 스키마를 OR로 허용하고, 같은 UPDATE에서 review 객체와 schema_version=2를 함께 저장합니다.
| 문서 부분 | v1 | v2 |
|---|---|---|
| source · license | source 1~80자 · license 1~60자 필수 문자열 | v1과 같음 |
| external_id · tags | external_id 최대 120자 · tags 최대 10개, 항목 1~40자 | v1과 같음 |
| 추가 키 | 정의하지 않은 키 거절 | review 외에는 같은 규칙 |
| review 객체 | 허용하지 않음 | 선택 객체; 넣으면 reviewed_on·reviewer 필수 |
- source · license
- v1: source 1~80자 · license 1~60자 필수 문자열v2: v1과 같음
- external_id · tags
- v1: external_id 최대 120자 · tags 최대 10개, 항목 1~40자v2: v1과 같음
- 추가 키
- v1: 정의하지 않은 키 거절v2: review 외에는 같은 규칙
- review 객체
- v1: 허용하지 않음v2: 선택 객체; 넣으면 reviewed_on·reviewer 필수
reviewed_on의 정규식은 문자열 형태만 확인합니다. 실제 달력 날짜 검증은 입력 경계의 책임입니다.
내부 키 삭제와 객체 전체 삭제의 결과는 다르다
v2 review에서 reviewer만 제거 → CHECK 거절
v2 review 객체 전체 제거 → 선택 객체이므로 허용
v1에 review 추가 → 정의하지 않은 키이므로 거절
reviewer는 문자열 타입이며 이 스키마는 빈 문자열을 별도로 금지하지 않습니다. reviewed_on의 YYYY-MM-DD 정규식은 형태만 검사합니다. v1·v2 읽기 호환 배포 → 전환 CHECK → 행 변환 → 대사 뒤 이전 읽기 주체 제거 순서입니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 호환 읽기부터 대사까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | v1 문서 영역에서 v2 문서 영역으로 한 변환 화살표가 향한다. v2 영역 안의 review 0..1 객체 영역에는 reviewed_on과 reviewer 두 필수 키가 있다. review 영역은 선택 객체를 조건부로 보인 것이며 항상 존재한다는 뜻이 아니다. reviewed_on은 형태 검사이고 달력·검토자 신원 규칙은 별도다. 실제 DDL의 외부 ID·태그·추가 키 조건은 원 표 그대로 읽는다. v1/v2 읽기 호환과 전환 CHECK는 먼저 배포하는 조건이다. |
| 표의 값 | source · license. v1: source 1~80자 · license 1~60자 필수 문자열. v2: v1과 같음. external_id · tags. v1: external_id 최대 120자 · tags 최대 10개, 항목 1~40자. v2: v1과 같음. 추가 키. v1: 정의하지 않은 키 거절. v2: review 외에는 같은 규칙. review 객체. v1: 허용하지 않음. v2: 선택 객체; 넣으면 reviewed_on·reviewer 필수. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호환 읽기 v1과 v2를 읽을 소비자를 먼저 배포한다. |
|
|
| 검사 확장 두 버전의 스키마를 허용한다. |
|
|
| 함께 변경 review와 버전을 한 UPDATE로 쓴다. |
|
|
| 대사 필드 조합을 검사한다. |
|
|
v1을 유지하며 선택 검토를 더하는 v2
v1과 v2를 읽을 소비자를 먼저 배포한다. 필드 조합을 검사한다. 각 단계의 값과 전환 이유를 비교합니다.
JSON SCHEMA · VERSION COMPATIBILITY / OPTIONAL OBJECT
v2에서도 review는 선택, 넣으면 내부 두 키가 필수다
v1의 원본·라이선스·태그 규칙은 그대로 유지합니다. 전환 CHECK는 버전별 전체 스키마를 OR로 허용하고, 같은 UPDATE에서 review 객체와 schema_version=2를 함께 저장합니다.
| 문서 부분 | v1 | v2 |
|---|---|---|
| source · license | source 1~80자 · license 1~60자 필수 문자열 | v1과 같음 |
| external_id · tags | external_id 최대 120자 · tags 최대 10개, 항목 1~40자 | v1과 같음 |
| 추가 키 | 정의하지 않은 키 거절 | review 외에는 같은 규칙 |
| review 객체 | 허용하지 않음 | 선택 객체; 넣으면 reviewed_on·reviewer 필수 |
- source · license
- v1: source 1~80자 · license 1~60자 필수 문자열v2: v1과 같음
- external_id · tags
- v1: external_id 최대 120자 · tags 최대 10개, 항목 1~40자v2: v1과 같음
- 추가 키
- v1: 정의하지 않은 키 거절v2: review 외에는 같은 규칙
- review 객체
- v1: 허용하지 않음v2: 선택 객체; 넣으면 reviewed_on·reviewer 필수
reviewed_on의 정규식은 문자열 형태만 확인합니다. 실제 달력 날짜 검증은 입력 경계의 책임입니다.
내부 키 삭제와 객체 전체 삭제의 결과는 다르다
v2 review에서 reviewer만 제거 → CHECK 거절
v2 review 객체 전체 제거 → 선택 객체이므로 허용
v1에 review 추가 → 정의하지 않은 키이므로 거절
reviewer는 문자열 타입이며 이 스키마는 빈 문자열을 별도로 금지하지 않습니다. reviewed_on의 YYYY-MM-DD 정규식은 형태만 검사합니다. v1·v2 읽기 호환 배포 → 전환 CHECK → 행 변환 → 대사 뒤 이전 읽기 주체 제거 순서입니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 호환 읽기부터 대사까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | v1 문서 영역에서 v2 문서 영역으로 한 변환 화살표가 향한다. v2 영역 안의 review 0..1 객체 영역에는 reviewed_on과 reviewer 두 필수 키가 있다. review 영역은 선택 객체를 조건부로 보인 것이며 항상 존재한다는 뜻이 아니다. reviewed_on은 형태 검사이고 달력·검토자 신원 규칙은 별도다. 실제 DDL의 외부 ID·태그·추가 키 조건은 원 표 그대로 읽는다. v1/v2 읽기 호환과 전환 CHECK는 먼저 배포하는 조건이다. |
| 표의 값 | source · license. v1: source 1~80자 · license 1~60자 필수 문자열. v2: v1과 같음. external_id · tags. v1: external_id 최대 120자 · tags 최대 10개, 항목 1~40자. v2: v1과 같음. 추가 키. v1: 정의하지 않은 키 거절. v2: review 외에는 같은 규칙. review 객체. v1: 허용하지 않음. v2: 선택 객체; 넣으면 reviewed_on·reviewer 필수. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호환 읽기 v1과 v2를 읽을 소비자를 먼저 배포한다. |
|
|
| 검사 확장 두 버전의 스키마를 허용한다. |
|
|
| 함께 변경 review와 버전을 한 UPDATE로 쓴다. |
|
|
| 대사 필드 조합을 검사한다. |
|
|
같은 이름의 CHECK를 한 ALTER 안에서 지우고 다시 만들지 않도록 두 문장으로 나누었습니다.
ALTER TABLE post_metadata
DROP CHECK chk_post_metadata_version,
DROP CHECK chk_post_metadata_schema;
ALTER TABLE post_metadata
ADD CONSTRAINT chk_post_metadata_version CHECK (schema_version IN (1, 2)),
ADD CONSTRAINT chk_post_metadata_schema CHECK (
(schema_version = 1 AND 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))
OR
(schema_version = 2 AND 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}},
"review":{"type":"object","required":["reviewed_on","reviewer"],
"additionalProperties":false,
"properties":{
"reviewed_on":{"type":"string",
"pattern":"^[0-9]{4}-[0-9]{2}-[0-9]{2}$"},
"reviewer":{"type":"string"}}}}}',
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;
SELECT schema_version, metadata->>'$.review.reviewer' AS reviewer
FROM post_metadata WHERE post_id = @post_id;
UPDATE post_metadata
SET metadata = JSON_REMOVE(metadata, '$.review.reviewer')
WHERE post_id = @post_id;schema_version | reviewer
2 | editor-1
ERROR 3819 (HY000): Check constraint 'chk_post_metadata_schema' is violated.옮긴 행은 버전 2이고 검토 객체의 두 필수 키를 가집니다. 검토 객체가 있는 상태에서 안쪽의 reviewer만 지우면 CHECK가 거부하지만, 검토 객체 전체를 지우는 것은 v2에서도 허용됩니다. 기존 v1의 행은 원래 스키마의 조건을 그대로 따릅니다.
2. 형태의 검사로 판단할 수 있는 것
출처와 라이선스, 나머지 키가 모두 유효한 v2 문서가 셋 있습니다. A는 검토 객체가 없고, B는 reviewed_on이 "2026-02-31"이고 reviewer가 빈 문자열인 검토 객체가 있으며, C는 검토 객체에 reviewed_on만 있습니다. 위의 JSON 스키마만 적용하면 어느 문서가 허용되고, 실제 입력 단계에서 더 확인해야 할 것은 무엇인가요?
해설 보기
A는 검토 객체 자체가 선택이므로 통과합니다. B도 날짜 문자열이 YYYY-MM-DD의 정규식에 맞고 reviewer가 문자열이므로 스키마의 형태 조건은 통과합니다. 그러나 2월 31일은 실제 날짜가 아니고, 빈 검토자가 업무상 유효한 사람인지도 따로 정해야 합니다. C는 검토 객체가 있으면서 필수인 reviewer 키가 없으므로 거부됩니다.
객체가 있는지, 안쪽의 필수 키가 있는지, 값이 업무상 뜻이 맞는지는 서로 다른 검사입니다. B 같은 문서를 막으려면 입력 단계에서 날짜를 실제 DATE로 바꾸어 보고, 검토자에 minLength를 더합니다.
다음 글 JSON_TABLE 일괄 처리에서는 JSON 배열로 들어온 가져오기를 JSON_TABLE로 행 집합으로 펼치고, 정규 테이블에 안전하게 적재합니다.