JSON_TABLE 일괄 처리
JSON 배열로 들어온 게시글을 JSON_TABLE로 행 집합으로 펼치고, 항목별 오류를 코드로 보존한 뒤 검증이 끝난 배치만 posts에 적재합니다. 같은 배치의 재전송과 고친 요청의 재시도, 다른 본문의 충돌을 구분하는 방법을 다룹니다.
외부 편집 도구가 게시글 여러 건을 JSON 배열로 보내면, 한 문서로 받는 것이 편합니다. 그렇다고 JSON 요청 본문을 그대로 영구적인 원본으로 삼으면, 작성자의 외래 키와 상태, 게시 시각, 멱등 요청 키의 규칙을 쿼리마다 다시 구현해야 합니다. 반대로 JSON_TABLE의 결과를 곧바로 최종 테이블에 넣으면, 어느 항목에서 왜 오류가 났는지 남기기 어렵습니다.
안전한 가져오기는 원문 배치를 먼저 고정하고, 배열을 임시 적재의 행으로 펼치며, 오류를 item_key별로 모은 다음, 전체 검증이 끝났을 때만 정규 스키마로 옮깁니다. 같은 배치 UUID가 다시 오면 이미 만든 게시글 ID를 돌려줍니다.
요청 본문은 게시글 배열이며, 각 항목은 itemKey, requestKey, title, content, status, publishedAt을 갖습니다. JSON_TABLE은 지정한 타입으로 투영하는데, NULL ON ERROR는 변환 오류를 NULL로 바꿀 뿐 모든 오류나 잘림 경고를 없애지는 않습니다. 아래 예제는 수신 단계에서 루트 객체와 posts 배열, 원문 필드의 타입과 길이를 먼저 검증했다는 전제입니다.
post_import_batch는 원본 JSON과 상태를, post_import_rejection은 항목의 오류를, post_import_item은 외부 항목과 만들어진 게시글의 대응을 보존하며, 완료된 배치의 원본 JSON은 고치지 않습니다.
직접 정규화의 오류 추적 한계
JSON_TABLE의 타입 변환 오류가 문장을 멈추면, 호출하는 쪽은 잘못된 항목의 위치를 추측해야 합니다. 변환을 느슨하게 하면, 요청 키가 없거나 허용하지 않는 상태이거나 PUBLISHED인데 publishedAt이 없는 행이 최종 제약에서 제각각의 오류를 냅니다.
SET @bad_payload = JSON_OBJECT('posts', JSON_ARRAY(
JSON_OBJECT('itemKey', 'a', 'requestKey', 'post-001', 'title', '정상 게시글',
'content', '본문', 'status', 'PUBLISHED',
'publishedAt', '2026-07-17 10:00:00'),
JSON_OBJECT('itemKey', 'b', 'title', '요청 키 없는 게시글',
'content', '본문', 'status', 'PUBLISHED', 'publishedAt', 'not-a-date')
));
SELECT x.* FROM JSON_TABLE(@bad_payload, '$.posts[*]' COLUMNS (
item_key VARCHAR(80) PATH '$.itemKey',
request_key VARCHAR(80) PATH '$.requestKey',
title VARCHAR(200) PATH '$.title',
content TEXT PATH '$.content',
published_at DATETIME(6) PATH '$.publishedAt' ERROR ON ERROR
)) AS x;두 번째 항목의 'not-a-date'를 DATETIME으로 바꾸다 오류가 나면서 문장 전체가 실패하고, 정상인 첫 항목의 행도 돌려받지 못합니다. 요청 키가 없다는 문제나 게시 시각의 규칙 위반은 보고되지 않고, 재시도를 위한 식별도 남지 않습니다.
수신과 파싱, 업무 검증, 최종 쓰기를 한 문장에 겹치면 오류를 나눌 수 있는 지점이 하나뿐입니다. 임시 적재는 잘못된 값을 영구히 쌓아 두는 곳이 아니라, 오류를 구조화해 호출하는 쪽에 돌려주는 격리 구간입니다.
NULL ON ERROR를 고르면, 그 NULL이 원래의 입력인지 변환의 오류인지를 원본 항목과 함께 판단해야 합니다. 아래 프로시저는 투영된 NULL과 상태와 게시 시각의 조합, 요청 키의 중복을 검사하며, 원문의 타입과 길이를 완전하게 비교하는 검증기는 아니어서 일부 숫자에서 문자열로의 변환이나 잘림은 이 검사만으로 구별하지 못합니다.
이 예제는 한 항목이라도 잘못되면 최종 삽입을 0건으로 두는, 전체 성공 아니면 전체 취소의 정책을 씁니다. 부분 성공이 필요하다면 항목의 상태와 재제출의 키를 따로 설계하고, 이미 성공한 항목을 다시 만들지 않아야 합니다.
더 깊이: JSON_TABLE이 빠진 값과 잘못된 값을 다루는 규칙
JSON_TABLE은 열마다 두 가지 상황을 따로 다룹니다. 경로에 값이 없으면 ON EMPTY 절이 적용되고, 객체나 배열을 스칼라 열에 담으려 하거나 'asd'를 정수 열로 바꾸는 것처럼 열의 타입으로 바꾸다 실패하면 ON ERROR 절이 적용됩니다. MySQL 8.4 매뉴얼에 따르면 두 절의 기본은 모두 NULL이므로, 위 예처럼 ERROR ON ERROR를 적지 않았다면 'not-a-date'는 오류 없이 NULL이 됩니다. 그래서 빠진 값과 잘못된 값이 결과에서는 똑같이 NULL로 보입니다. 두 절은 ON EMPTY, ON ERROR의 순서로 적으며, 반대 순서는 비표준이라 사용 중단되었고 경고가 납니다.
잘림은 이 두 절과 따로 처리됩니다. 3.14159를 DECIMAL(10,1) 열에 담는 것처럼 값이 잘리면 ON ERROR 절과 상관없이 경고가 나고, 한 문장에서 여러 값이 잘려도 경고는 한 번만 나옵니다. 그래서 경고의 수만 세어서는 몇 개의 항목이 잘렸는지 알 수 없고, 이 글의 프로시저가 원문의 길이를 수신 단계에서 먼저 검증한다고 전제한 것도 이 때문입니다.
열의 종류로 이런 구별을 도울 수도 있습니다. FOR ORDINALITY 열은 1부터 시작하는 UNSIGNED INT 번호를 매기므로, 뒤의 프로시저가 쓰는 ordinal_no처럼 배열 안의 위치를 오류 보고에 남길 수 있습니다. EXISTS PATH 열은 그 경로에 데이터가 있으면 1, 없으면 0을 돌려주어, 키가 아예 없는 항목을 따로 골라낼 수 있습니다. 배열 안의 배열은 NESTED PATH로 펼치는데, 짝이 없으면 바깥 행을 남기는 외부 조인처럼 동작하고, 나란히 둔 두 NESTED PATH의 결과 행 수는 곱이 아니라 합이 됩니다.
원본 문서와 정규 행
batch_id와 요청 본문의 해시는 재전송인지를 판단합니다. 같은 UUID에 다른 본문이 오면 재처리가 아니라 충돌이며, 완료된 배치의 항목 매핑은 클라이언트가 응답을 잃어도 게시글 ID를 다시 얻을 수 있게 합니다.
JSON_TABLE은 문서를 관계형의 행 집합으로 옮기는 지점이고, 그 뒤의 작성자와 상태, 요청 키의 검사는 평범한 SQL로 합니다. posts에는 JSON 가져오기의 세부를 복사하지 않고, 배치와 항목의 매핑만 따로 남깁니다.
오류는 MISSING_REQUIRED, INVALID_STATUS, INVALID_PUBLISH_TIME 같은 안정된 코드로 저장하고, 화면이 언어별 문구를 만듭니다. 원본 항목은 접근 권한과 보존 기간을 제한합니다.
COLUMNS에 NULL ON ERROR를 명시하면 엄격한 변환으로 문장이 멈추는 것을 피할 수 있지만, 원문의 타입과 길이, 루트 구조의 검증은 여전히 수신 단계가 해야 합니다. 배열이 없어 0행이 나오면, 지금의 프로시저는 항목이 없는 COMPLETED 배치를 만들 수 있습니다.
단계별 JSON 일괄 처리
프로시저는 임시 테이블 parsed_import를 만들고, 필수 값과 상태, 게시 시각의 조합을 검증합니다. 거부할 사유가 있으면 항목의 오류와 REJECTED 상태를 함께 커밋하고 게시글과 매핑은 만들지 않으며, 오류가 없으면 커서로 게시글을 하나씩 INSERT하고 LAST_INSERT_ID를 매핑에 기록합니다. 여기서 커서(cursor)는 12장 4편에서 다음 페이지의 시작점으로 넘기던 값과 다른 뜻으로, 저장 프로시저 안에서 DECLARE ... CURSOR FOR로 SELECT 하나에 이름을 붙여 두고 OPEN으로 그 결과를 연 뒤 FETCH로 한 행씩 변수에 꺼내고 CLOSE로 닫는 도구입니다. 더 꺼낼 행이 없으면 FETCH가 NOT FOUND 조건을 일으키므로, 그 처리기가 v_done을 TRUE로 바꾸고 반복문은 이 값을 보고 끝납니다.
DROP PROCEDURE IF EXISTS process_post_import;
DROP TABLE IF EXISTS post_import_rejection;
DROP TABLE IF EXISTS post_import_item;
DROP TABLE IF EXISTS post_import_batch;
CREATE TABLE post_import_batch (
batch_id BINARY(16) PRIMARY KEY,
author_id BIGINT UNSIGNED NOT NULL,
payload JSON NOT NULL,
payload_sha256 BINARY(32) NOT NULL,
batch_status VARCHAR(16) NOT NULL DEFAULT 'STAGED',
created_at DATETIME(6) NOT NULL,
processed_at DATETIME(6) NULL,
FOREIGN KEY (author_id) REFERENCES members (id) ON DELETE RESTRICT,
CHECK (batch_status IN ('STAGED', 'REJECTED', 'COMPLETED'))
) ENGINE=InnoDB;
CREATE TABLE post_import_item (
batch_id BINARY(16) NOT NULL,
item_key VARCHAR(80) NOT NULL,
post_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (batch_id, item_key),
UNIQUE KEY uq_import_post (post_id),
FOREIGN KEY (batch_id) REFERENCES post_import_batch (batch_id) ON DELETE RESTRICT,
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE RESTRICT
) ENGINE=InnoDB;
CREATE TABLE post_import_rejection (
batch_id BINARY(16) NOT NULL,
item_key VARCHAR(80) NOT NULL,
error_code VARCHAR(40) NOT NULL,
raw_item JSON NOT NULL,
PRIMARY KEY (batch_id, item_key, error_code),
FOREIGN KEY (batch_id) REFERENCES post_import_batch (batch_id) ON DELETE CASCADE
) ENGINE=InnoDB;
DELIMITER //
CREATE PROCEDURE process_post_import(IN p_batch BINARY(16))
proc: BEGIN
DECLARE v_batch_status VARCHAR(16);
DECLARE v_author BIGINT UNSIGNED;
DECLARE v_payload JSON;
DECLARE v_done BOOLEAN DEFAULT FALSE;
DECLARE v_batch_found BOOLEAN DEFAULT TRUE;
DECLARE v_key VARCHAR(80);
DECLARE v_request_key VARCHAR(80);
DECLARE v_title VARCHAR(200);
DECLARE v_content LONGTEXT;
DECLARE v_post_status VARCHAR(16);
DECLARE v_published DATETIME(6);
DECLARE v_post BIGINT UNSIGNED;
DECLARE cur CURSOR FOR
SELECT item_key, request_key, title, content, post_status, published_at
FROM parsed_import ORDER BY ordinal_no;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
BEGIN
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_batch_found = FALSE;
SELECT batch_status, author_id, payload
INTO v_batch_status, v_author, v_payload
FROM post_import_batch
WHERE batch_id = p_batch
FOR UPDATE;
END;
IF v_batch_found = FALSE THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'import batch not found';
END IF;
IF v_batch_status = 'COMPLETED' THEN
COMMIT;
SELECT item_key, post_id FROM post_import_item
WHERE batch_id = p_batch ORDER BY item_key;
LEAVE proc;
END IF;
DELETE FROM post_import_rejection WHERE batch_id = p_batch;
DROP TEMPORARY TABLE IF EXISTS parsed_import;
CREATE TEMPORARY TABLE parsed_import (
ordinal_no INT UNSIGNED,
item_key VARCHAR(80),
request_key VARCHAR(80),
title VARCHAR(200),
content LONGTEXT,
post_status VARCHAR(16),
published_at DATETIME(6),
raw_item JSON,
PRIMARY KEY (ordinal_no)
);
INSERT INTO parsed_import
SELECT j.ordinal_no, j.item_key, j.request_key, j.title, j.content,
j.post_status, j.published_at, j.raw_item
FROM JSON_TABLE(v_payload, '$.posts[*]' COLUMNS (
ordinal_no FOR ORDINALITY,
item_key VARCHAR(80) PATH '$.itemKey' NULL ON ERROR,
request_key VARCHAR(80) PATH '$.requestKey' NULL ON ERROR,
title VARCHAR(200) PATH '$.title' NULL ON ERROR,
content TEXT PATH '$.content' NULL ON ERROR,
post_status VARCHAR(16) PATH '$.status' NULL ON ERROR,
published_at DATETIME(6) PATH '$.publishedAt' NULL ON EMPTY NULL ON ERROR,
raw_item JSON PATH '$'
)) AS j;
INSERT INTO post_import_rejection
SELECT p_batch, COALESCE(item_key, CONCAT('#', ordinal_no)),
'MISSING_REQUIRED', raw_item
FROM parsed_import
WHERE item_key IS NULL OR request_key IS NULL OR title IS NULL
OR content IS NULL OR post_status IS NULL;
INSERT IGNORE INTO post_import_rejection
SELECT p_batch, COALESCE(item_key, CONCAT('#', ordinal_no)),
'INVALID_STATUS', raw_item
FROM parsed_import
WHERE post_status NOT IN ('DRAFT', 'PUBLISHED', 'HIDDEN');
INSERT IGNORE INTO post_import_rejection
SELECT p_batch, COALESCE(item_key, CONCAT('#', ordinal_no)),
'INVALID_PUBLISH_TIME', raw_item
FROM parsed_import
WHERE (post_status = 'PUBLISHED') <> (published_at IS NOT NULL);
INSERT IGNORE INTO post_import_rejection
SELECT p_batch, COALESCE(p.item_key, CONCAT('#', p.ordinal_no)),
'DUPLICATE_REQUEST_KEY', p.raw_item
FROM (
SELECT ordinal_no, item_key, request_key, raw_item,
COUNT(*) OVER (PARTITION BY request_key) AS request_count
FROM parsed_import
) AS p
WHERE p.request_key IS NOT NULL AND p.request_count > 1;
INSERT IGNORE INTO post_import_rejection
SELECT p_batch, COALESCE(p.item_key, CONCAT('#', p.ordinal_no)),
'ALREADY_EXISTS', p.raw_item
FROM parsed_import AS p
JOIN posts AS x
ON x.author_id = v_author AND x.request_key = p.request_key;
IF EXISTS (SELECT 1 FROM post_import_rejection WHERE batch_id = p_batch) THEN
UPDATE post_import_batch
SET batch_status = 'REJECTED', processed_at = CURRENT_TIMESTAMP(6)
WHERE batch_id = p_batch;
COMMIT;
DROP TEMPORARY TABLE parsed_import;
SELECT item_key, error_code FROM post_import_rejection
WHERE batch_id = p_batch ORDER BY item_key, error_code;
LEAVE proc;
END IF;
SET v_done = FALSE;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_key, v_request_key, v_title, v_content,
v_post_status, v_published;
IF v_done THEN LEAVE read_loop; END IF;
INSERT INTO posts
(author_id, title, content, status, view_count, created_at,
updated_at, published_at, version, request_key)
VALUES
(v_author, v_title, v_content, v_post_status, 0, CURRENT_TIMESTAMP(6),
CURRENT_TIMESTAMP(6), v_published, 0, v_request_key);
SET v_post = LAST_INSERT_ID();
INSERT INTO post_import_item VALUES (p_batch, v_key, v_post);
END LOOP;
CLOSE cur;
UPDATE post_import_batch
SET batch_status = 'COMPLETED', processed_at = CURRENT_TIMESTAMP(6)
WHERE batch_id = p_batch;
COMMIT;
DROP TEMPORARY TABLE parsed_import;
SELECT item_key, post_id FROM post_import_item
WHERE batch_id = p_batch ORDER BY item_key;
END//
DELIMITER ;커서는 프로시저 앞에서 선언하지만, 대상인 임시 테이블은 OPEN하기 전에만 있으면 됩니다. 프로시저 단위의 NOT FOUND 처리기는 커서의 끝을 알리고, 배치를 읽는 SELECT ... INTO는 안쪽 블록의 처리기가 따로 받아 없는 배치를 판정합니다.
이제 가져오기의 작성자와 세 배치를 준비해 차례로 처리합니다. 정상 배치와, 게시 시각이 빠진 항목이 섞인 배치, 그 오류를 고쳐 새 UUID로 다시 낸 배치입니다. 공개 게시글의 게시 시각을 실행 시각 하루 뒤로 둔 것은, posts의 CHECK가 게시 시각이 작성 시각보다 이르지 않기를 요구하기 때문입니다. 작성자를 넣는 INSERT의 ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)는, 같은 이메일의 활성 회원이 이미 있어 UNIQUE가 충돌하면 새 행을 만들지 않고 기존 행의 id를 다음 LAST_INSERT_ID()가 돌려줄 값으로 기억시킵니다. 그래서 예제를 다시 실행해도 @import_author_id는 처음 만든 작성자를 가리킵니다.
INSERT INTO members (email, password_hash, name, status, created_at)
VALUES ('[email protected]', 'not-a-real-hash',
'JSON 가져오기 예제 작성자', 'ACTIVE', CURRENT_TIMESTAMP(6))
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id);
SET @import_author_id = LAST_INSERT_ID();
DELETE FROM posts
WHERE author_id = @import_author_id AND request_key LIKE 'ch18-2-%';
SET @completed_batch_id = UUID_TO_BIN('018f0000-0000-7000-8000-000000000201');
SET @completed_payload = JSON_OBJECT('posts', JSON_ARRAY(
JSON_OBJECT(
'itemKey', 'normal-1', 'requestKey', 'ch18-2-normal-1',
'title', '게시판 사용 규칙', 'content', '회원 게시판의 사용 규칙을 정리합니다.',
'status', 'PUBLISHED', 'publishedAt',
DATE_FORMAT(CURRENT_TIMESTAMP(6) + INTERVAL 1 DAY, '%Y-%m-%d %H:%i:%s.%f')
),
JSON_OBJECT(
'itemKey', 'normal-2', 'requestKey', 'ch18-2-normal-2',
'title', '다음 글 초안', 'content', '다음 게시글의 초안을 저장합니다.',
'status', 'DRAFT'
)
));
INSERT INTO post_import_batch
(batch_id, author_id, payload, payload_sha256, batch_status, created_at)
VALUES (@completed_batch_id, @import_author_id, @completed_payload,
UNHEX(SHA2(CAST(@completed_payload AS CHAR), 256)),
'STAGED', CURRENT_TIMESTAMP(6));
CALL process_post_import(@completed_batch_id);
SET @posts_before_replay = (
SELECT COUNT(*) FROM posts
WHERE author_id = @import_author_id
AND request_key IN ('ch18-2-normal-1', 'ch18-2-normal-2')
);
CALL process_post_import(@completed_batch_id);
SET @posts_after_replay = (
SELECT COUNT(*) FROM posts
WHERE author_id = @import_author_id
AND request_key IN ('ch18-2-normal-1', 'ch18-2-normal-2')
);
SET @rejected_batch_id = UUID_TO_BIN('018f0000-0000-7000-8000-000000000202');
SET @rejected_payload = JSON_OBJECT('posts', JSON_ARRAY(
JSON_OBJECT(
'itemKey', 'retry-1', 'requestKey', 'ch18-2-retry-1',
'title', '회원 가입 안내', 'content', '회원 가입 순서를 안내합니다.',
'status', 'PUBLISHED', 'publishedAt',
DATE_FORMAT(CURRENT_TIMESTAMP(6) + INTERVAL 1 DAY, '%Y-%m-%d %H:%i:%s.%f')
),
JSON_OBJECT(
'itemKey', 'retry-2',
'title', '게시판 이용 안내', 'content', '게시판 이용 순서를 안내합니다.',
'status', 'PUBLISHED'
)
));
INSERT INTO post_import_batch
(batch_id, author_id, payload, payload_sha256, batch_status, created_at)
VALUES (@rejected_batch_id, @import_author_id, @rejected_payload,
UNHEX(SHA2(CAST(@rejected_payload AS CHAR), 256)),
'STAGED', CURRENT_TIMESTAMP(6));
CALL process_post_import(@rejected_batch_id);
SET @retry_batch_id = UUID_TO_BIN('018f0000-0000-7000-8000-000000000203');
SET @retry_payload = JSON_SET(
@rejected_payload,
'$.posts[1].requestKey', 'ch18-2-retry-2',
'$.posts[1].publishedAt',
DATE_FORMAT(CURRENT_TIMESTAMP(6) + INTERVAL 1 DAY, '%Y-%m-%d %H:%i:%s.%f')
);
INSERT INTO post_import_batch
(batch_id, author_id, payload, payload_sha256, batch_status, created_at)
VALUES (@retry_batch_id, @import_author_id, @retry_payload,
UNHEX(SHA2(CAST(@retry_payload AS CHAR), 256)),
'STAGED', CURRENT_TIMESTAMP(6));
CALL process_post_import(@retry_batch_id);
SELECT fixture, batch_status, input_items, item_mappings, rejections
FROM (
SELECT 'normal' AS fixture, b.batch_status,
JSON_LENGTH(b.payload, '$.posts') AS input_items,
(SELECT COUNT(*) FROM post_import_item AS i
WHERE i.batch_id = b.batch_id) AS item_mappings,
(SELECT COUNT(*) FROM post_import_rejection AS r
WHERE r.batch_id = b.batch_id) AS rejections
FROM post_import_batch AS b WHERE b.batch_id = @completed_batch_id
UNION ALL
SELECT 'rejected', b.batch_status, JSON_LENGTH(b.payload, '$.posts'),
(SELECT COUNT(*) FROM post_import_item AS i WHERE i.batch_id = b.batch_id),
(SELECT COUNT(*) FROM post_import_rejection AS r WHERE r.batch_id = b.batch_id)
FROM post_import_batch AS b WHERE b.batch_id = @rejected_batch_id
UNION ALL
SELECT 'retry', b.batch_status, JSON_LENGTH(b.payload, '$.posts'),
(SELECT COUNT(*) FROM post_import_item AS i WHERE i.batch_id = b.batch_id),
(SELECT COUNT(*) FROM post_import_rejection AS r WHERE r.batch_id = b.batch_id)
FROM post_import_batch AS b WHERE b.batch_id = @retry_batch_id
) AS fixture_result
ORDER BY FIELD(fixture, 'normal', 'rejected', 'retry');
SELECT @posts_after_replay - @posts_before_replay AS replay_created_posts;세 번째 CALL, 곧 혼합 배치의 처리는 거부된 항목의 오류 코드를 돌려줍니다. 정렬은 item_key와 error_code의 순서입니다.
item_key | error_code
retry-2 | INVALID_PUBLISH_TIME
retry-2 | MISSING_REQUIRED
fixture | batch_status | input_items | item_mappings | rejections
normal | COMPLETED | 2 | 2 | 0
rejected | REJECTED | 2 | 0 | 2
retry | COMPLETED | 2 | 2 | 0
replay_created_posts
0정상 배치는 두 항목과 매핑을 커밋했고, 같은 UUID의 재호출은 저장된 매핑만 돌려주어 게시글을 하나도 더 만들지 않았습니다. 혼합 배치는 첫 항목이 정상이었어도 둘째 항목에 요청 키가 없고 PUBLISHED인데 게시 시각이 없어 전체가 REJECTED로 남았으며, 그 오류를 고친 요청은 새 배치에서만 게시글을 만들었습니다. 다른 CALL의 결과에 나오는 post_id는 기존 게시글의 수에 따라 달라지므로 싣지 않았습니다.
없는 batch_id는 import batch not found로 트랜잭션 전체를 롤백하며 COMPLETED 상태를 만들지 않습니다. 커서는 대량의 가져오기에서 느릴 수 있지만 항목과 자동 생성 ID의 매핑을 분명히 보여 주며, 운영에서는 클라이언트가 준 안정된 requestKey를 (author_id, request_key)의 UNIQUE로 받아 집합 단위로 INSERT한 뒤 조인할 수 있습니다.
REJECTED인 배치는 고치지 않고, 클라이언트가 고친 요청 본문에 새 batch_id를 씁니다. 같은 ID의 원본 JSON을 덮어쓰면, 처음의 오류 기록과 재시도의 판단이 사라지기 때문입니다.
수신 엔드포인트는 INSERT하기 전에 정규화한 JSON 요청 본문의 SHA-256 해시를 계산하고, batch_id가 이미 있으면 저장된 해시와 비교해 다르면 409 충돌을 돌려줍니다(연습 문제 1). JSON의 키 순서를 정규화하는 규칙은 한 구현에서 고정합니다.
가져오기 호출의 종료와 보존 데이터
두 항목의 오류를 모은다. 같은 UUID·해시의 기존 결과를 반환한다. 각 단계의 값과 전환 이유를 비교합니다.
JSON IMPORT · REJECTION COMMIT / EXCEPTION ROLLBACK
검증 거절은 오류를 커밋하고 SQL 예외는 호출 변경을 롤백한다
최종 게시글의 전체 성공·전체 취소 정책과 오류 기록의 보존은 서로 다릅니다. 수집한 오류는 REJECTED와 함께 남기며, SQL 예외는 시작 전 배치 상태를 유지하고 오류를 재전달합니다.
| 호출 경로 | 트랜잭션 결과 | 보존·반환하는 것 |
|---|---|---|
| 이미 COMPLETED | 기존 배치 확인 후 COMMIT | 기존 item_key ↔ post_id 반환 |
| 업무 검증 통과 | 게시글·매핑·COMPLETED를 함께 COMMIT | 새 게시글 ID 매핑 반환 |
| 수집한 검증 오류 있음 | 오류 행·REJECTED를 COMMIT | 오류 코드 반환; 새 게시글·매핑 없음 |
| SQL 예외 | 현재 호출의 변경 ROLLBACK | 오류 재전달; 시작 전 배치 상태 유지 |
- 이미 COMPLETED
- 트랜잭션 결과: 기존 배치 확인 후 COMMIT보존·반환하는 것: 기존 item_key ↔ post_id 반환
- 업무 검증 통과
- 트랜잭션 결과: 게시글·매핑·COMPLETED를 함께 COMMIT보존·반환하는 것: 새 게시글 ID 매핑 반환
- 수집한 검증 오류 있음
- 트랜잭션 결과: 오류 행·REJECTED를 COMMIT보존·반환하는 것: 오류 코드 반환; 새 게시글·매핑 없음
- SQL 예외
- 트랜잭션 결과: 현재 호출의 변경 ROLLBACK보존·반환하는 것: 오류 재전달; 시작 전 배치 상태 유지
SQL 예외로 롤백한 호출이 REJECTED 오류 기록까지 새로 남기는 것은 아닙니다. 루트 구조·원문 타입·길이 검증은 수신 경계의 전제입니다.
같은 입력 2항목 · 정상·거절·새 UUID 수정 제출
정상 COMPLETED
매핑 2 · 거절 0
같은 UUID 재호출: 새 게시글 0
혼합 REJECTED
매핑 0 · 거절 코드 2
정상인 항목도 새로 만들지 않음
수정본 COMPLETED
새 UUID · 매핑 2 · 거절 0
기존 거절 원본은 보존
루트 posts 배열·원문 타입·길이는 수신 경계가 먼저 검증합니다. item_key 중복 등 미수집 오류는 최종 쓰기에서 롤백될 수 있습니다. 같은 UUID의 다른 해시는 수신 답안이 SIGNAL로 거절하며 해시 재계산·작성자 접근 검사는 호출자의 책임입니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 혼합 입력 검증부터 완료 요청 재시도까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 새 배치를 처리하는 여섯 노드, 여섯 간선. 임시 파싱 검증 뒤 수집 오류가 있으면 REJECTED/오류 행을 COMMIT하며 최종 게시글은 0이다. 없으면 게시글/매핑을 쓰고 COMPLETED를 COMMIT한다. 점선은 COMMIT 전 파싱/INSERT SQL 예외가 ROLLBACK으로 가는 별도 경로다. 이미 COMPLETED인 배치의 재호출은 원 표의 기존 매핑 반환으로 읽는다. COMMIT 뒤 임시 테이블 정리 오류까지 전부 되돌린다는 의미가 아니다. 모바일의 아니요 경로는 거절 노드를 통과하지 않는다. |
| 표의 값 | 이미 COMPLETED. 트랜잭션 결과: 기존 배치 확인 후 COMMIT. 보존·반환하는 것: 기존 item_key ↔ post_id 반환. 업무 검증 통과. 트랜잭션 결과: 게시글·매핑·COMPLETED를 함께 COMMIT. 보존·반환하는 것: 새 게시글 ID 매핑 반환. 수집한 검증 오류 있음. 트랜잭션 결과: 오류 행·REJECTED를 COMMIT. 보존·반환하는 것: 오류 코드 반환; 새 게시글·매핑 없음. SQL 예외. 트랜잭션 결과: 현재 호출의 변경 ROLLBACK. 보존·반환하는 것: 오류 재전달; 시작 전 배치 상태 유지. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 혼합 입력 검증 두 항목의 오류를 모은다. |
|
|
| 거절 확정 오류 기록과 배치 상태를 저장한다. |
|
|
| 새 요청 수정본 원 거절 이력을 유지하고 새 UUID로 처리한다. |
|
|
| 완료 요청 재시도 같은 UUID·해시의 기존 결과를 반환한다. |
|
|
가져오기 호출의 종료와 보존 데이터
두 항목의 오류를 모은다. 같은 UUID·해시의 기존 결과를 반환한다. 각 단계의 값과 전환 이유를 비교합니다.
JSON IMPORT · REJECTION COMMIT / EXCEPTION ROLLBACK
검증 거절은 오류를 커밋하고 SQL 예외는 호출 변경을 롤백한다
최종 게시글의 전체 성공·전체 취소 정책과 오류 기록의 보존은 서로 다릅니다. 수집한 오류는 REJECTED와 함께 남기며, SQL 예외는 시작 전 배치 상태를 유지하고 오류를 재전달합니다.
| 호출 경로 | 트랜잭션 결과 | 보존·반환하는 것 |
|---|---|---|
| 이미 COMPLETED | 기존 배치 확인 후 COMMIT | 기존 item_key ↔ post_id 반환 |
| 업무 검증 통과 | 게시글·매핑·COMPLETED를 함께 COMMIT | 새 게시글 ID 매핑 반환 |
| 수집한 검증 오류 있음 | 오류 행·REJECTED를 COMMIT | 오류 코드 반환; 새 게시글·매핑 없음 |
| SQL 예외 | 현재 호출의 변경 ROLLBACK | 오류 재전달; 시작 전 배치 상태 유지 |
- 이미 COMPLETED
- 트랜잭션 결과: 기존 배치 확인 후 COMMIT보존·반환하는 것: 기존 item_key ↔ post_id 반환
- 업무 검증 통과
- 트랜잭션 결과: 게시글·매핑·COMPLETED를 함께 COMMIT보존·반환하는 것: 새 게시글 ID 매핑 반환
- 수집한 검증 오류 있음
- 트랜잭션 결과: 오류 행·REJECTED를 COMMIT보존·반환하는 것: 오류 코드 반환; 새 게시글·매핑 없음
- SQL 예외
- 트랜잭션 결과: 현재 호출의 변경 ROLLBACK보존·반환하는 것: 오류 재전달; 시작 전 배치 상태 유지
SQL 예외로 롤백한 호출이 REJECTED 오류 기록까지 새로 남기는 것은 아닙니다. 루트 구조·원문 타입·길이 검증은 수신 경계의 전제입니다.
같은 입력 2항목 · 정상·거절·새 UUID 수정 제출
정상 COMPLETED
매핑 2 · 거절 0
같은 UUID 재호출: 새 게시글 0
혼합 REJECTED
매핑 0 · 거절 코드 2
정상인 항목도 새로 만들지 않음
수정본 COMPLETED
새 UUID · 매핑 2 · 거절 0
기존 거절 원본은 보존
루트 posts 배열·원문 타입·길이는 수신 경계가 먼저 검증합니다. item_key 중복 등 미수집 오류는 최종 쓰기에서 롤백될 수 있습니다. 같은 UUID의 다른 해시는 수신 답안이 SIGNAL로 거절하며 해시 재계산·작성자 접근 검사는 호출자의 책임입니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 혼합 입력 검증부터 완료 요청 재시도까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 새 배치를 처리하는 여섯 노드, 여섯 간선. 임시 파싱 검증 뒤 수집 오류가 있으면 REJECTED/오류 행을 COMMIT하며 최종 게시글은 0이다. 없으면 게시글/매핑을 쓰고 COMPLETED를 COMMIT한다. 점선은 COMMIT 전 파싱/INSERT SQL 예외가 ROLLBACK으로 가는 별도 경로다. 이미 COMPLETED인 배치의 재호출은 원 표의 기존 매핑 반환으로 읽는다. COMMIT 뒤 임시 테이블 정리 오류까지 전부 되돌린다는 의미가 아니다. 모바일의 아니요 경로는 거절 노드를 통과하지 않는다. |
| 표의 값 | 이미 COMPLETED. 트랜잭션 결과: 기존 배치 확인 후 COMMIT. 보존·반환하는 것: 기존 item_key ↔ post_id 반환. 업무 검증 통과. 트랜잭션 결과: 게시글·매핑·COMPLETED를 함께 COMMIT. 보존·반환하는 것: 새 게시글 ID 매핑 반환. 수집한 검증 오류 있음. 트랜잭션 결과: 오류 행·REJECTED를 COMMIT. 보존·반환하는 것: 오류 코드 반환; 새 게시글·매핑 없음. SQL 예외. 트랜잭션 결과: 현재 호출의 변경 ROLLBACK. 보존·반환하는 것: 오류 재전달; 시작 전 배치 상태 유지. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 혼합 입력 검증 두 항목의 오류를 모은다. |
|
|
| 거절 확정 오류 기록과 배치 상태를 저장한다. |
|
|
| 새 요청 수정본 원 거절 이력을 유지하고 새 UUID로 처리한다. |
|
|
| 완료 요청 재시도 같은 UUID·해시의 기존 결과를 반환한다. |
|
|
정상과 혼합, 재시도의 검증
완료된 배치의 항목 수와 고아 매핑, 거부된 배치의 부분 삽입, 항목과 요청 키의 중복을 확인합니다. 완료된 배치의 재호출은 저장된 항목과 게시글 ID의 매핑을 돌려줄 뿐, 아래 SQL이 매핑의 체크섬을 계산하지는 않습니다.
SELECT b.batch_id FROM post_import_batch AS b
WHERE b.batch_status = 'COMPLETED'
AND (SELECT COUNT(*) FROM post_import_item AS i WHERE i.batch_id = b.batch_id)
<> JSON_LENGTH(b.payload, '$.posts');
SELECT i.batch_id, i.item_key FROM post_import_item AS i
LEFT JOIN posts AS p ON p.id = i.post_id
WHERE p.id IS NULL;
SELECT b.batch_id FROM post_import_batch AS b
WHERE b.batch_status = 'REJECTED'
AND EXISTS (SELECT 1 FROM post_import_item AS i WHERE i.batch_id = b.batch_id);
SELECT batch_id, item_key FROM post_import_item
GROUP BY batch_id, item_key HAVING COUNT(*) <> 1;
SELECT p.author_id, p.request_key FROM posts AS p
GROUP BY p.author_id, p.request_key HAVING COUNT(*) > 1;다섯 쿼리는 모두 빈 결과입니다. 세 배치의 행에는 원문 JSON의 해시와 처리 시각이 남으며, 해시와 시각의 값은 실행할 때마다 달라지므로 싣지 않았습니다.
JSON_LENGTH가 NULL이 되는 루트 구조는 수신 스키마에서 먼저 거부합니다. 이 프로시저에 루트 posts 배열이 있는지 검사하는 단계를 더하면, 잘못된 배치도 REJECTED 상태와 사유를 가질 수 있습니다. 또 item_key처럼 따로 수집하지 않은 오류는 최종 INSERT에서 예외가 되어 전체가 롤백될 수 있습니다(연습 문제 2).
item_key는 배치 안에서만 유일합니다. 외부 앱이 전역 이벤트 ID를 준다면, UNIQUE (source_system, external_item_id)를 별도의 원장에 두어 서로 다른 배치 사이의 중복도 막습니다.
한 요청 본문에 수십만 항목을 넣으면 행의 잠금과 임시 테이블, 롤백의 크기가 커지므로, API에서 항목의 수와 바이트 크기를 제한하고 배치를 나눕니다. 전체 성공 아니면 전체 취소의 범위는, 사용자가 고칠 수 있는 단위와 맞추어야 합니다.
요청 본문에 자유 메모나 이메일이 들어올 수 있다면, 거부된 raw_item도 개인정보입니다. 접근 권한과 암호화, 보존 기간과 파기를 설계하고, 검증이 편하다는 이유로 무기한 보관하지 않습니다.
| 따져 볼 것 | 질문 |
|---|---|
| 원자성 | 한 항목의 오류 때 배치 전체를 취소해야 하는가 |
| 식별 | 클라이언트가 안정된 배치와 항목의 키를 주는가 |
| 크기 | 임시 파싱과 롤백이 가능한 최대 항목 수를 정했는가 |
| 오류 | 호출하는 쪽이 코드와 원본의 위치로 고칠 수 있는가 |
| 정규화 | 성공한 뒤 핵심 쿼리가 JSON이 아니라 외래 키가 있는 테이블을 쓰는가 |
일괄 업로드와 자세한 오류가 필요하다면 임시 적재 배치가 유용하고, 실시간 단건 API라면 같은 구조를 더 작게 적용할 수 있습니다. 성공한 요청의 원문을 JSON에만 남겨 두고 OLTP 쿼리가 매번 JSON_TABLE을 부르는 방식은 피합니다.
정리
JSON_TABLE은 문서 배열을 검증할 수 있는 관계형의 행 집합으로 바꿉니다.- 수신과 파싱, 검증, 최종 쓰기를 나누어야 항목마다 오류를 코드로 남길 수 있습니다.
- 원본 배치와 항목의 거부, 최종 매핑은 서로 다른 수명과 역할을 가집니다.
- 오류가 0건인 배치만 최종 게시글과 매핑을 한 트랜잭션에서 만듭니다.
- 배치의 해시와 항목의 키가 응답 손실과 재전송에서 중복 생성을 막습니다.
연습 문제
1. 같은 배치 ID의 다른 본문
같은 batch_id에 다른 요청 본문의 해시가 들어오면 충돌로 거부하는 수신 프로시저를 작성해 보세요. 기존의 원본 JSON과 상태는 절대 덮지 않아야 합니다.
해설 보기
INSERT하기 전에 배치의 행을 잠그고 저장된 해시와 비교합니다. 같은 해시면 지금의 상태를 돌려주고, 다른 해시면 SIGNAL하며, 없는 ID만 STAGED로 만듭니다. 충돌의 기록을 영구히 저장하는 코드는 아니며, 호출하는 쪽은 검증한 JSON을 같은 정규화 규칙으로 해시해 넘기고, 그 작성자가 기존 배치에 접근할 수 있는지도 따로 확인합니다.
DROP PROCEDURE IF EXISTS stage_post_import;
DELIMITER //
CREATE PROCEDURE stage_post_import(
IN p_batch BINARY(16),
IN p_author BIGINT UNSIGNED,
IN p_payload JSON,
IN p_hash BINARY(32)
)
proc: BEGIN
DECLARE v_hash BINARY(32);
DECLARE v_status VARCHAR(16);
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
IF p_hash IS NULL OR p_batch IS NULL OR p_author IS NULL OR p_payload IS NULL THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'batch identity, payload and hash required';
END IF;
SELECT payload_sha256, batch_status INTO v_hash, v_status
FROM post_import_batch
WHERE batch_id = p_batch
FOR UPDATE;
IF v_hash IS NOT NULL THEN
IF v_hash <> p_hash THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'batch payload conflict';
END IF;
COMMIT;
SELECT v_status AS batch_status;
LEAVE proc;
END IF;
INSERT INTO post_import_batch
(batch_id, author_id, payload, payload_sha256, batch_status, created_at)
VALUES (p_batch, p_author, p_payload, p_hash, 'STAGED', CURRENT_TIMESTAMP(6));
COMMIT;
SELECT 'STAGED' AS batch_status;
END//
DELIMITER ;
CALL stage_post_import(@completed_batch_id, @import_author_id, @completed_payload,
UNHEX(SHA2(CAST(@completed_payload AS CHAR), 256)));
CALL stage_post_import(@completed_batch_id, @import_author_id, @retry_payload,
UNHEX(SHA2(CAST(@retry_payload AS CHAR), 256)));batch_status
COMPLETED
ERROR 1644 (45000): batch payload conflict같은 본문의 재호출은 행을 늘리지 않고 지금의 상태 COMPLETED를 돌려줍니다. 다른 본문을 같은 ID로 보내면 원래의 JSON과 해시를 보존한 채 오류가 납니다. 이 프로시저는 p_hash를 다시 계산하지 않으며, 아직 없는 같은 ID가 동시에 들어올 때의 중복 키나 교착 상태의 오류는 호출하는 쪽이 재시도로 처리합니다.
2. 수집하지 않은 오류
새 STAGED 배치에 두 항목이 있고, 필수 값과 상태, 게시 시각은 유효하며 requestKey도 서로 다른 새 값입니다. 다만 두 itemKey가 모두 same-item입니다. 오류 수집과 커서의 쓰기 순서를 따라가면, 게시글과 매핑, 배치의 상태는 어떻게 남나요?
해설 보기
지금의 수집 단계에는 item_key의 중복 검사가 없으므로 거부 행은 생기지 않습니다. 커서는 첫 항목의 게시글과 매핑을 쓰지만, 둘째 매핑이 같은 (batch_id, item_key) 기본 키를 다시 쓰면서 1062 예외가 납니다. EXIT 처리기가 이 호출의 게시글과 매핑을 모두 롤백하므로, 새 게시글과 매핑은 0건이고 배치는 시작하기 전의 STAGED로 남습니다.
이 결과는 REJECTED와 오류 코드를 커밋하는 경로와 다르며, 호출하는 쪽은 어느 항목이 왜 문제인지 코드로 받지 못합니다. 같은 원문을 다시 처리해도 원인이 없어지지 않으므로, itemKey를 고친 새 UUID로 제출하거나 수집 단계에 DUPLICATE_ITEM_KEY 같은 검사를 더합니다.
다음 글 JSON 검색 인덱스에서는 자주 검색하는 JSON의 스칼라와 배열을 생성 열과 다중 값 인덱스로 끌어올립니다.