본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
선택 심화 · 18장 : JSON과 운영 설계

저장 구조와 AI 콘텐츠 검수

관계형·JSON·외부 보관의 책임을 확정하고 AI가 제안한 게시글을 자동 게시하지 않는 후보·검사·승인·릴리스 증거 모델을 완성합니다.

회원 게시판의 마지막 설계 결정은 모든 데이터를 한 저장 형식에 넣는 일이 아닙니다. 회원, 게시글, 작성자 관계, 상태처럼 트랜잭션과 FK가 묶는 핵심 사실은 관계형 테이블에 둡니다. 게시글마다 선택적으로 달라지는 작은 메타데이터는 검증된 JSON에 둘 수 있습니다. 대형 원본 파일과 모델 응답 전문은 접근 통제와 보존 정책을 갖춘 객체 저장소가 맡습니다.

또 하나의 경계는 AI가 만든 제목과 본문을 게시판에 반영하는 과정입니다. 모델 출력은 문법이 자연스러워도 사실 오류, 개인정보, 저작권 침해, 유해 링크, 중복 문장을 포함할 수 있습니다. 후보를 곧바로 posts에 INSERT하거나 기존 게시글에 UPDATE하면 검토 전 내용이 사용자에게 노출됩니다.

이 문서는 AI 출력을 신뢰 경계 밖 입력으로 다룹니다. ai_content_candidate는 제안 원문과 출처, 모델 식별 정보, 해시를 고정합니다. ai_review_ruleai_review_result는 필수 검사의 결과와 근거를 보존합니다. 모든 필수 검사가 통과하고 제출자와 다른 검토자가 승인한 후보만 content_release에 포함할 수 있습니다. 릴리스에 포함되었다는 사실도 자동 게시 명령은 아닙니다.


검수 없는 자동 게시의 위험

단순한 자동화는 모델 응답에 제목과 본문이 있으면 곧바로 PUBLISHED 게시글을 만들 수 있습니다. 이 경로에는 사실 근거, 개인정보, 링크 안전성, 게시판 범위, 중복 여부를 확인하는 지점이 없습니다.

AI 초안을 즉시 게시하는 잘못된 경로
DROP TABLE IF EXISTS ai_content_draft_bad;
CREATE TABLE ai_content_draft_bad(
 draft_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
 author_id BIGINT UNSIGNED NOT NULL,
 title VARCHAR(200) NOT NULL,
 content LONGTEXT NOT NULL,
 model_name VARCHAR(80) NOT NULL
) ENGINE=InnoDB;

INSERT INTO members(email,password_hash,name,status,created_at)
VALUES(
 'ch18-ai-bad-author@example.com','not-a-real-hash',
 '자동 게시 위험 예제 작성자','ACTIVE',CURRENT_TIMESTAMP(6)
)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id);
SET @member_id=LAST_INSERT_ID();

DELETE FROM posts
WHERE author_id=@member_id
 AND request_key LIKE 'ai-draft-%';

INSERT INTO ai_content_draft_bad(author_id,title,content,model_name)
VALUES(@member_id,'검증되지 않은 성능 수치',
 '홍길동의 이메일은 user@example.com이며 이 설정은 항상 10배 빠르다.',
 'example-model');

INSERT INTO posts
 (author_id,title,content,status,view_count,created_at,updated_at,published_at,version,request_key)
SELECT author_id,title,content,'PUBLISHED',0,CURRENT_TIMESTAMP(6),CURRENT_TIMESTAMP(6),
 CURRENT_TIMESTAMP(6),0,CONCAT('ai-draft-',draft_id)
FROM ai_content_draft_bad;

코드는 성공하지만 개인정보와 근거 없는 수치가 이미 공개 상태로 저장됩니다.

오류 실행 결과
candidate review records=0
personal-data check=missing
fact-source check=missing
automatic published posts=1
human approval=0

문제는 모델의 품질 점수보다 쓰기 경계에 있습니다. AI가 언제나 정확하다는 가정도, 사람이 나중에 발견할 것이라는 가정도 게시 전 무결성을 만들지 못합니다.

후보 저장과 게시글 반영을 분리하면 검토 실패가 공개 데이터로 번지지 않습니다. 후보는 거절되어도 감사와 개선 근거를 위해 일정 기간 보존할 수 있습니다. 게시글 반영은 승인된 해시와 릴리스 항목을 다시 대조한 별도 편집 절차가 수행합니다.


후보·검사·승인의 경계

후보의 변경 불가 핵심은 원본 요청 식별자, 모델 식별 정보, 제안 제목과 본문, SHA-256 해시입니다. 검토자가 읽은 내용과 릴리스가 가리키는 내용이 같아야 하므로 승인 뒤 이 값들을 수정하지 않습니다.

필수 규칙은 사실 근거, 개인정보, 저작권, 링크 안전성, 유해 표현, 게시판 범위, 중복, 문체입니다. 검사 결과는 PASS, FAIL, MISSING 가운데 하나이며 PASS에는 사람이 확인할 수 있는 근거가 필요합니다. 자동 분류기 결과는 근거 하나일 뿐 최종 승인을 대신하지 않습니다.

제출자와 검토자를 분리하면 자기 승인을 막을 수 있습니다. 검토자는 후보 내용을 수정하지 않고 거절 이유를 남기거나 새 후보 제출을 요구합니다. 수정된 내용은 해시가 다른 새 후보로 등록해 모든 필수 검사를 다시 수행합니다.

승인과 게시를 분리하는 이유

승인은 검토가 끝났다는 상태 전환입니다. 게시글 생성·수정은 충돌 확인, 대상 게시글 버전, 예약 시각, 롤백 계획을 확인하는 별도 편집 작업입니다. 이 예제에는 후보를 읽어 posts에 자동 반영하는 프로시저를 의도적으로 만들지 않습니다.

운영 안전 규칙: AI 후보 테이블에 접근할 수 있는 계정에 posts 게시 권한을 자동으로 함께 주지 않습니다. 승인된 해시와 사람이 검토한 릴리스 항목만 제한된 편집 절차로 전달합니다.


AI 콘텐츠 검수 스키마

스키마는 후보, 규칙, 결과, 릴리스, 릴리스 항목을 분리합니다. 승인 프로시저는 검토자 분리와 모든 필수 규칙의 PASS를 확인합니다. 승인된 후보의 핵심 내용을 동결하는 트리거를 함께 둡니다.

AI 콘텐츠 후보와 해시 고정 릴리스 매니페스트
DROP PROCEDURE IF EXISTS approve_ai_content_candidate;
DROP TRIGGER IF EXISTS freeze_approved_ai_candidate;
DROP TABLE IF EXISTS content_release_item;
DROP TABLE IF EXISTS content_release;
DROP TABLE IF EXISTS ai_review_result;
DROP TABLE IF EXISTS ai_review_rule;
DROP TABLE IF EXISTS ai_content_candidate;

CREATE TABLE ai_content_candidate(
 candidate_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
 target_post_id BIGINT UNSIGNED NULL,
 source_request_id VARCHAR(120) NOT NULL,
 model_reference VARCHAR(120) NOT NULL,
 prompt_summary VARCHAR(500) NOT NULL,
 proposed_title VARCHAR(200) NOT NULL,
 proposed_content LONGTEXT NOT NULL,
 content_sha256 BINARY(32) GENERATED ALWAYS AS(
  UNHEX(SHA2(CONCAT(proposed_title,CHAR(0),proposed_content),256))
 ) STORED,
 risk_level VARCHAR(16) NOT NULL,
 review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
 submitter_id BIGINT UNSIGNED NOT NULL,
 reviewer_id BIGINT UNSIGNED NULL,
 review_note VARCHAR(500) NULL,
 created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
 reviewed_at DATETIME(6) NULL,
 UNIQUE KEY uq_ai_candidate_source(source_request_id),
 FOREIGN KEY(target_post_id) REFERENCES posts(id) ON DELETE SET NULL,
 FOREIGN KEY(submitter_id) REFERENCES members(id) ON DELETE RESTRICT,
 FOREIGN KEY(reviewer_id) REFERENCES members(id) ON DELETE RESTRICT,
 CHECK(risk_level IN('LOW','MEDIUM','HIGH')),
 CHECK(review_status IN('PENDING','APPROVED','REJECTED'))
) ENGINE=InnoDB;

CREATE TABLE ai_review_rule(
 rule_code VARCHAR(40) PRIMARY KEY,
 description VARCHAR(240) NOT NULL,
 is_required BOOLEAN NOT NULL DEFAULT TRUE,
 CHECK(is_required IN(0,1))
) ENGINE=InnoDB;

INSERT INTO ai_review_rule VALUES
 ('FACT_SOURCE','수치와 사실의 근거를 확인',TRUE),
 ('PERSONAL_DATA','개인정보와 비밀값 노출을 확인',TRUE),
 ('COPYRIGHT','과도한 인용과 권리 침해를 확인',TRUE),
 ('LINK_SAFETY','외부 링크의 목적과 안전성을 확인',TRUE),
 ('HARMFUL_CONTENT','유해하거나 차별적인 표현을 확인',TRUE),
 ('BOARD_SCOPE','게시판 목적과 카테고리 범위를 확인',TRUE),
 ('DUPLICATION','기존 게시글과 실질 중복을 확인',TRUE),
 ('EDITORIAL_STYLE','교재형 문체와 표기 규칙을 확인',TRUE);

CREATE TABLE ai_review_result(
 candidate_id BIGINT UNSIGNED NOT NULL,
 rule_code VARCHAR(40) NOT NULL,
 review_result VARCHAR(16) NOT NULL,
 evidence VARCHAR(1000) NOT NULL,
 reviewed_by BIGINT UNSIGNED NOT NULL,
 reviewed_at DATETIME(6) NOT NULL,
 PRIMARY KEY(candidate_id,rule_code),
 FOREIGN KEY(candidate_id) REFERENCES ai_content_candidate(candidate_id) ON DELETE CASCADE,
 FOREIGN KEY(rule_code) REFERENCES ai_review_rule(rule_code) ON DELETE RESTRICT,
 FOREIGN KEY(reviewed_by) REFERENCES members(id) ON DELETE RESTRICT,
 CHECK(review_result IN('PASS','FAIL','MISSING'))
) ENGINE=InnoDB;

CREATE TABLE content_release(
 release_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
 release_name VARCHAR(120) NOT NULL,
 release_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
 approved_by BIGINT UNSIGNED NOT NULL,
 approved_at DATETIME(6) NOT NULL,
 UNIQUE KEY uq_content_release_name(release_name),
 FOREIGN KEY(approved_by) REFERENCES members(id) ON DELETE RESTRICT,
 CHECK(release_status IN('DRAFT','READY','APPLIED','WITHDRAWN'))
) ENGINE=InnoDB;

CREATE TABLE content_release_item(
 release_id BIGINT UNSIGNED NOT NULL,
 sequence_no INT UNSIGNED NOT NULL,
 candidate_id BIGINT UNSIGNED NOT NULL,
 approved_content_sha256 BINARY(32) NOT NULL,
 PRIMARY KEY(release_id,sequence_no),
 UNIQUE KEY uq_release_candidate(release_id,candidate_id),
 FOREIGN KEY(release_id) REFERENCES content_release(release_id) ON DELETE RESTRICT,
 FOREIGN KEY(candidate_id) REFERENCES ai_content_candidate(candidate_id) ON DELETE RESTRICT
) ENGINE=InnoDB;

DELIMITER //
CREATE TRIGGER freeze_approved_ai_candidate
BEFORE UPDATE ON ai_content_candidate FOR EACH ROW
BEGIN
 IF OLD.review_status='APPROVED' AND(
   NEW.review_status<>'APPROVED'
   OR NOT(NEW.proposed_title<=>OLD.proposed_title)
   OR NOT(NEW.proposed_content<=>OLD.proposed_content)
   OR NOT(NEW.source_request_id<=>OLD.source_request_id)
  OR NOT(NEW.model_reference<=>OLD.model_reference)
 ) THEN
  SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='approved AI content is immutable';
 END IF;
END//

CREATE PROCEDURE approve_ai_content_candidate(
 IN p_candidate BIGINT UNSIGNED,
 IN p_reviewer BIGINT UNSIGNED,
 IN p_note VARCHAR(500))
BEGIN
 DECLARE v_submitter BIGINT UNSIGNED;
 DECLARE v_status VARCHAR(16);
 DECLARE v_missing INT;
 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
 START TRANSACTION;
 SELECT submitter_id,review_status INTO v_submitter,v_status
 FROM ai_content_candidate WHERE candidate_id=p_candidate FOR UPDATE;
 IF v_submitter IS NULL OR v_status<>'PENDING' OR v_submitter=p_reviewer THEN
  SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='reviewer separation or state invalid';
 END IF;
 SELECT COUNT(*) INTO v_missing
 FROM ai_review_rule r
 LEFT JOIN ai_review_result x
  ON x.candidate_id=p_candidate
  AND x.rule_code=r.rule_code
  AND x.review_result='PASS'
  AND x.reviewed_by<>v_submitter
 WHERE r.is_required=TRUE AND x.rule_code IS NULL;
 IF v_missing<>0 THEN
  SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='required content checks not passed';
 END IF;
 UPDATE ai_content_candidate
 SET review_status='APPROVED',reviewer_id=p_reviewer,review_note=p_note,
     reviewed_at=CURRENT_TIMESTAMP(6)
 WHERE candidate_id=p_candidate;
 COMMIT;
END//
DELIMITER ;

모든 필수 규칙이 PASS이고 각 PASS의 reviewed_by와 최종 검토자가 모두 제출자와 다를 때만 후보가 APPROVED가 됩니다. 조건을 하나라도 충족하지 못하면 후보는 대기 상태에 머물며 게시글에는 아무 변화도 없습니다.

개선 실행 결과
required rules=8
passed rules=8
reviewer separation=yes
PASS reviewers separated=yes
approved hash pinned=yes
automatic post publishing=not implemented

승인 트리거는 제목, 본문, 출처, 모델 식별 정보의 사후 변경과 APPROVED 상태의 되돌리기를 막습니다. 따라서 APPROVED를 REJECTED로 바꾼 뒤 본문을 수정하는 우회도 허용되지 않습니다. 검사 근거 자체도 승인 뒤 수정할 수 없도록 별도 동결 트리거를 추가할 수 있습니다.

릴리스 항목의 해시 대사

content_release_item.approved_content_sha256는 검토자가 승인한 시점의 해시를 복사합니다. 편집 절차는 반영 직전에 후보의 현재 해시와 다시 비교합니다. 다르면 반영을 중단하고 새 검토를 요구합니다.


누락·자가 승인·해시 검증

후보 하나를 등록하고 필수 검사 결과를 모두 만든 뒤 분리된 검토자로 승인합니다. 릴리스 감사는 승인 상태, 해시, 순서 중복을 검사합니다.

AI 콘텐츠 검수 인수와 릴리스 무결성 대사
INSERT INTO members(email,password_hash,name,status,created_at)
VALUES('ai-submitter@example.com','not-a-real-hash','AI 제출자','ACTIVE',CURRENT_TIMESTAMP(6))
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id);
SET @submitter_id=LAST_INSERT_ID();

INSERT INTO members(email,password_hash,name,status,created_at)
VALUES('ai-reviewer@example.com','not-a-real-hash','AI 검토자','ACTIVE',CURRENT_TIMESTAMP(6))
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id);
SET @reviewer_id=LAST_INSERT_ID();

INSERT INTO ai_content_candidate
 (source_request_id,model_reference,prompt_summary,proposed_title,proposed_content,
  risk_level,submitter_id)
VALUES
 ('request-20260718-001','reviewed-model-v1','B+Tree 입문 게시글 초안',
  'B+Tree가 범위 검색에 적합한 이유',
  'B+Tree는 정렬된 키와 균형 높이를 이용해 범위 검색 경로를 제한합니다.',
  'LOW',@submitter_id);
SET @candidate=LAST_INSERT_ID();

INSERT INTO ai_review_result
SELECT @candidate,rule_code,'PASS',
 CONCAT('검토 근거: ',description),@reviewer_id,CURRENT_TIMESTAMP(6)
FROM ai_review_rule;

CALL approve_ai_content_candidate(
 @candidate,@reviewer_id,'출처·개인정보·문체 검수 완료');

INSERT INTO content_release(release_name,release_status,approved_by,approved_at)
VALUES('database-posts-2026-07-18','READY',@reviewer_id,CURRENT_TIMESTAMP(6));
SET @release=LAST_INSERT_ID();
INSERT INTO content_release_item
SELECT @release,1,candidate_id,content_sha256
FROM ai_content_candidate WHERE candidate_id=@candidate AND review_status='APPROVED';

SELECT c.candidate_id,r.rule_code
FROM ai_content_candidate c CROSS JOIN ai_review_rule r
LEFT JOIN ai_review_result x
 ON x.candidate_id=c.candidate_id
 AND x.rule_code=r.rule_code
 AND x.review_result='PASS'
WHERE c.candidate_id=@candidate AND r.is_required=TRUE AND x.rule_code IS NULL;

SELECT x.candidate_id,x.rule_code
FROM ai_review_result x
JOIN ai_content_candidate c ON c.candidate_id=x.candidate_id
WHERE x.candidate_id=@candidate
 AND x.reviewed_by=c.submitter_id;

SELECT i.release_id,i.sequence_no
FROM content_release_item i
JOIN ai_content_candidate c ON c.candidate_id=i.candidate_id
WHERE c.review_status<>'APPROVED'
 OR i.approved_content_sha256<>c.content_sha256;

세 감사 쿼리는 필수 PASS 누락, 제출자의 자기 검수, 승인 상태·해시 불일치를 각각 확인하며 모두 0행입니다. READY 릴리스에는 승인된 후보와 같은 해시가 한 번만 들어갑니다. posts 반영은 이 SQL에 포함하지 않았으므로 승인과 게시의 경계가 유지됩니다.


저장·검수 선택 기준

판단 축확인할 질문
관계형FK, UNIQUE, 상태 전이, 트랜잭션이 핵심인가?
JSON한 게시글과 함께 읽는 작은 선택 문서이며 스키마 검증이 가능한가?
외부 보관대형 원본이나 장기 증거로 OLTP 조회와 분리해야 하는가?
AI 후보모델 출력이 공개 데이터에 닿기 전에 독립 검수 경계를 통과하는가?
승인필수 규칙, 근거, 검토자 분리, 해시 고정이 있는가?
반영승인과 실제 게시 사이에 버전·충돌·롤백 확인이 있는가?

핵심 회원 게시판에는 관계형 모델이 기준입니다. JSON과 외부 보관은 구체적인 선택성과 크기 요구가 있을 때만 추가합니다. AI 후보도 같은 원칙으로 최소 권한과 명시적 증거를 갖춥니다.


연습 문제

개인정보와 출처 없는 성능 수치를 포함한 AI 후보를 등록하세요. PERSONAL_DATAFACT_SOURCE를 FAIL로 남기고 승인 프로시저가 거절하는지 확인해야 합니다. 수정된 안전한 문장은 기존 후보를 덮지 말고 새 후보로 등록합니다.

해설과 예시 답안

실패 근거를 후보별 검사 결과로 남깁니다. 필수 PASS가 누락되어 승인 호출은 오류를 발생시킵니다. 후보 상태를 REJECTED로 바꾸되 제안 본문과 해시는 감사 증거로 보존합니다.

INSERT INTO ai_content_candidate
 (source_request_id,model_reference,prompt_summary,proposed_title,proposed_content,
  risk_level,submitter_id)
VALUES
 ('request-20260718-unsafe','reviewed-model-v1','성능 홍보 문구',
  '항상 10배 빠른 설정',
  'user@example.com 사용자의 환경에서 이 설정은 항상 10배 빠르다.',
  'HIGH',@submitter_id);
SET @unsafe_candidate=LAST_INSERT_ID();

INSERT INTO ai_review_result VALUES
 (@unsafe_candidate,'PERSONAL_DATA','FAIL','이메일 주소가 본문에 포함됨',
  @reviewer_id,CURRENT_TIMESTAMP(6)),
 (@unsafe_candidate,'FACT_SOURCE','FAIL','10배 수치의 측정 조건과 출처가 없음',
  @reviewer_id,CURRENT_TIMESTAMP(6));

CALL approve_ai_content_candidate(
 @unsafe_candidate,@reviewer_id,'승인되면 안 되는 호출');
-- ERROR: required content checks not passed

UPDATE ai_content_candidate
SET review_status='REJECTED',reviewer_id=@reviewer_id,
    review_note='개인정보 제거와 근거 보강 뒤 새 후보 제출',
    reviewed_at=CURRENT_TIMESTAMP(6)
WHERE candidate_id=@unsafe_candidate;

SELECT candidate_id,review_status,HEX(content_sha256),review_note
FROM ai_content_candidate WHERE candidate_id=@unsafe_candidate;

안전하지 않은 후보는 REJECTED 상태이며 본문과 해시가 증거로 남습니다. 수정본은 새 source_request_id를 가진 후보로 등록하고 여덟 규칙을 처음부터 검수합니다.


핵심 정리

  • 회원·게시글·작성자 관계와 상태는 관계형 스키마가 맡습니다.
  • 게시글별 작은 선택 메타데이터는 스키마로 검증한 JSON에 제한합니다.
  • AI 콘텐츠는 출처와 해시, 필수 규칙, 분리된 검토자를 통과해도 자동 게시하지 않습니다.
  • 릴리스 항목은 승인된 해시를 고정하고 실제 반영은 충돌·버전·롤백을 확인하는 별도 절차가 수행합니다.

이로써 회원 게시판의 핵심 관계, 변경 이력, 집계, 유연한 문서 데이터와 안전한 AI 콘텐츠 검수까지 연결했습니다.