본문 바로가기

저장 구조와 AI 콘텐츠 검수

관계형 테이블과 JSON, 외부 보관이 맡을 데이터를 정리하고, AI가 제안한 게시글을 곧바로 게시하는 경로의 위험을 재현합니다. 후보와 필수 검사, 제출자와 분리된 승인, 해시를 고정한 릴리스로 자동 게시하지 않는 검수 모델을 완성합니다.

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

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

그래서 이 글은 AI의 출력을 신뢰할 수 없는 외부 입력으로 다룹니다. ai_content_candidate는 제안된 원문과 출처, 모델의 식별 정보, 해시를 고정하고, ai_review_rule과 ai_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 ('[email protected]', 'not-a-real-hash',
        '자동 게시 위험 예제 작성자', 'ACTIVE', CURRENT_TIMESTAMP(6))
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id);
SET @member_id = LAST_INSERT_ID();

START TRANSACTION;

INSERT INTO ai_content_draft_bad (author_id, title, content, model_name)
VALUES (@member_id, '검증되지 않은 성능 수치',
        '홍길동의 이메일은 [email protected]이며 이 설정은 항상 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;

SELECT title, status FROM posts
WHERE author_id = @member_id AND request_key LIKE 'ai-draft-%';

ROLLBACK;
title                    | status
검증되지 않은 성능 수치  | PUBLISHED

SQL은 아무 오류 없이 성공했고, 개인정보와 출처 없는 수치를 담은 글이 공개 상태로 저장되었습니다. 검수의 기록도, 사람의 승인도 없었으며, ROLLBACK이 없었다면 그대로 사용자에게 보였을 것입니다.

문제는 모델의 품질 점수보다 쓰기의 경로에 있습니다. AI가 언제나 정확하다는 가정도, 사람이 나중에 발견할 것이라는 가정도 게시 전의 무결성을 만들지 못합니다. 후보의 저장과 게시글의 반영을 나누면 검토의 실패가 공개 데이터로 번지지 않고, 거부된 후보도 감사와 개선을 위해 일정 기간 보존할 수 있습니다. 게시글의 반영은 승인된 해시와 릴리스 항목을 다시 대조하는 별도의 편집 절차가 맡습니다.

후보와 검사, 승인의 분리

후보를 검토하는 동안에는 원본 요청 ID와 모델, 제안된 제목과 본문을 고정한다는 쓰기 정책이 필요하며, 아래의 트리거는 승인한 뒤의 그 필드 변경을 막습니다. 제목과 본문의 해시는 둘 사이에 NUL 구분자를 넣어 계산하므로 입력에서 NUL 문자를 거부한다는 전제도 두는데, 이 예제는 그 입력 검사까지 구현하지 않습니다.

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

제출자와 검토자를 나누면 자기 승인을 막을 수 있습니다. 검토자는 후보의 내용을 고치지 않고 거부의 이유를 남기거나 새 후보의 제출을 요구하며, 고친 내용은 해시가 다른 새 후보로 등록해 모든 필수 검사를 다시 합니다.

승인은 검토가 끝났다는 상태의 전환이고, 게시글의 생성이나 수정은 충돌의 확인과 대상 게시글의 버전, 예약 시각, 롤백 계획을 확인하는 별도의 편집 작업입니다. 그래서 이 예제는 후보를 읽어 posts에 자동으로 반영하는 프로시저를 일부러 만들지 않으며, AI 후보 테이블에 접근하는 계정에 posts의 게시 권한을 함께 주지 않고, 승인된 해시와 사람이 검토한 릴리스 항목만 제한된 편집 절차로 넘깁니다.

검수 기록과 내용 해시가 확인하는 범위

현재 제목·본문 해시를 보관한다. 승인 해시를 복사한다. 각 단계의 값과 전환 이유를 비교합니다.

CONTENT REVIEW · CANDIDATE / EVIDENCE / RELEASE

PASS 기록과 같은 내용 해시는 사실 검수의 증명을 대신하지 않는다

승인은 제출자와 다른 검토자·필수 8규칙 PASS를 확인하고 릴리스는 승인 해시를 복사합니다. 실제 사실·개인정보 검사의 근거와 검사 중 내용 고정은 별도 쓰기 정책이며 posts 반영 절차도 별도입니다.

승인된 해시를 전달해도 게시글 반영은 별도 작업이다일곱노드여섯간선. 후보와필수PASS근거뒤승인조건을판단하고실패는SIGNAL로승인호출만거절된다. REJECTED상태기록은본문의별도UPDATE다. 성공은APPROVED의일부열동결뒤릴리스항목으로해시를복사한다. 마지막점선은구현밖별도posts반영절차이며현재SQL의자동게시아니다. PASS의사실성/검사한해시/규칙버전결합은이그림으로새로보장되지않고운영정책전제다. 모바일예경로는호출오류노드를통과하지않는다.후보 제목 · 본문현재 내용 해시필수 8규칙의 PASS 근거검토자 분리승인 조건모두 통과?승인 호출 오류상태 자동 거절 아님APPROVED일부 내용 열 동결릴리스 항목승인 해시 복사별도 posts 반영버전 · 충돌 · 해시 대조아니요예점선: 별도 편집 절차
승인된 해시를 전달해도 게시글 반영은 별도 작업이다일곱노드여섯간선. 후보와필수PASS근거뒤승인조건을판단하고실패는SIGNAL로승인호출만거절된다. REJECTED상태기록은본문의별도UPDATE다. 성공은APPROVED의일부열동결뒤릴리스항목으로해시를복사한다. 마지막점선은구현밖별도posts반영절차이며현재SQL의자동게시아니다. PASS의사실성/검사한해시/규칙버전결합은이그림으로새로보장되지않고운영정책전제다. 모바일예경로는호출오류노드를통과하지않는다.후보 제목 · 본문현재 내용 해시필수 8규칙 PASS 근거검토자 분리승인 조건모두 통과?승인 호출 오류자동 REJECTED 아님APPROVED일부 내용 열 동결릴리스 항목승인 해시 복사별도 posts 반영버전 · 충돌해시 대조아니요예점선: 별도 편집 절차
검수 기록과 내용 해시가 확인하는 범위
저장 대상현재 기록자동 보장하지 않는 것
후보 내용제목·본문으로 생성한 SHA-256검사 전에 읽은 내용과 같다는 사실
검사 결과후보 ID·규칙 코드·결과·검토자·근거검사한 내용 해시·규칙 버전과의 결합
승인 상태PENDING에서 분리 검토자로 APPROVEDPASS 근거의 사실성·규칙의 동시 변경 방지
릴리스 항목승인 상태에서 복사한 내용 해시실제 posts 반영과 버전·충돌 검사
후보 내용
현재 기록: 제목·본문으로 생성한 SHA-256
자동 보장하지 않는 것: 검사 전에 읽은 내용과 같다는 사실
검사 결과
현재 기록: 후보 ID·규칙 코드·결과·검토자·근거
자동 보장하지 않는 것: 검사한 내용 해시·규칙 버전과의 결합
승인 상태
현재 기록: PENDING에서 분리 검토자로 APPROVED
자동 보장하지 않는 것: PASS 근거의 사실성·규칙의 동시 변경 방지
릴리스 항목
현재 기록: 승인 상태에서 복사한 내용 해시
자동 보장하지 않는 것: 실제 posts 반영과 버전·충돌 검사

후보 승인 트리거가 검사 결과·규칙·릴리스의 직접 변경까지 일괄 동결하지는 않습니다.

두 FAIL이면 승인 거절 · 수정본은 새 후보로 검토

PERSONAL_DATA FAIL + FACT_SOURCE FAIL → 필수 PASS 누락

승인 호출 오류 → REJECTED로 기록 · 원문과 해시 보존

수정본 새 요청 ID → 8규칙 다시 검수 → 승인·릴리스 대사

정상 fixture의 PASS 8행 INSERT는 실제 검수 실행이나 콘텐츠 안전성 증거가 아닙니다. 내용 해시는 제목·NUL·본문을 묶고 입력 NUL 거절은 구현 밖 전제입니다. 승인 뒤 트리거도 검사 결과·규칙·릴리스 전체를 동결하지 않으며 자동 게시 SQL은 없습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 후보 대기부터 릴리스까지 진행합니다.
검수 기록과 내용 해시가 확인하는 범위
저장 대상현재 기록자동으로 보장하지 않는 것
후보 내용제목·본문으로 생성한 SHA-256검사 전에 읽은 내용과 같다는 사실
검사 결과후보 ID·규칙 코드·결과·검토자·근거검사한 내용 해시·규칙 버전과의 결합
승인 상태PENDING에서 분리 검토자로 APPROVEDPASS 근거의 사실성·규칙의 동시 변경 방지
릴리스 항목승인 상태에서 복사한 내용 해시실제 posts 반영과 버전·충돌 검사
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명일곱 노드, 여섯 간선. 후보와 필수 PASS 근거 뒤 승인 조건을 판단하고 실패하면 SIGNAL로 승인 호출만 거절된다. REJECTED 상태 기록은 본문의 별도 UPDATE다. 성공하면 APPROVED의 일부 열을 동결한 뒤 릴리스 항목으로 해시를 복사한다. 마지막 점선은 구현 밖 별도 posts 반영 절차이며 현재 SQL의 자동 게시가 아니다. PASS의 사실성/검사한 해시/규칙 버전 결합은 이 그림으로 새로 보장되지 않고 운영 정책의 전제다. 모바일의 예 경로는 호출 오류 노드를 통과하지 않는다.
표의 열자동으로 보장하지 않는 것
표의 값후보 내용. 현재 기록: 제목·본문으로 생성한 SHA-256. 자동으로 보장하지 않는 것: 검사 전에 읽은 내용과 같다는 사실. 검사 결과. 현재 기록: 후보 ID·규칙 코드·결과·검토자·근거. 자동으로 보장하지 않는 것: 검사한 내용 해시·규칙 버전과의 결합. 승인 상태. 현재 기록: PENDING에서 분리 검토자로 APPROVED. 자동으로 보장하지 않는 것: PASS 근거의 사실성·규칙의 동시 변경 방지. 릴리스 항목. 현재 기록: 승인 상태에서 복사한 내용 해시. 자동으로 보장하지 않는 것: 실제 posts 반영과 버전·충돌 검사.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
후보 대기

현재 제목·본문 해시를 보관한다.

  • 해시·검수 기록 1: PENDING
  • 해시·검수 기록 2: 현재 SHA-256
  • 필수 8규칙 근거 수집
승인 검사

모든 PASS를 확인하고 검토자가 제출자와 다른지 확인한다.

  • 해시·검수 기록 1: 8 PASS
  • 해시·검수 기록 2: submitter≠reviewer
  • 실패면 SIGNAL; REJECTED 기록은 별도 UPDATE
승인

조건을 통과한 후보를 승인한다.

  • 해시·검수 기록 1: APPROVED
  • 해시·검수 기록 2: 일부 내용 열 동결
  • 검사한 해시·규칙 버전 결합/사실성은 별도 정책
릴리스

승인 해시를 복사한다.

  • 해시·검수 기록 1: release hash=approved hash
  • 해시·검수 기록 2: posts 미반영
  • 별도 편집 절차로 전달; PASS fixture는 실제 검사 실행 아님

검수 기록과 내용 해시가 확인하는 범위

현재 제목·본문 해시를 보관한다. 승인 해시를 복사한다. 각 단계의 값과 전환 이유를 비교합니다.

CONTENT REVIEW · CANDIDATE / EVIDENCE / RELEASE

PASS 기록과 같은 내용 해시는 사실 검수의 증명을 대신하지 않는다

승인은 제출자와 다른 검토자·필수 8규칙 PASS를 확인하고 릴리스는 승인 해시를 복사합니다. 실제 사실·개인정보 검사의 근거와 검사 중 내용 고정은 별도 쓰기 정책이며 posts 반영 절차도 별도입니다.

승인된 해시를 전달해도 게시글 반영은 별도 작업이다일곱노드여섯간선. 후보와필수PASS근거뒤승인조건을판단하고실패는SIGNAL로승인호출만거절된다. REJECTED상태기록은본문의별도UPDATE다. 성공은APPROVED의일부열동결뒤릴리스항목으로해시를복사한다. 마지막점선은구현밖별도posts반영절차이며현재SQL의자동게시아니다. PASS의사실성/검사한해시/규칙버전결합은이그림으로새로보장되지않고운영정책전제다. 모바일예경로는호출오류노드를통과하지않는다.후보 제목 · 본문현재 내용 해시필수 8규칙의 PASS 근거검토자 분리승인 조건모두 통과?승인 호출 오류상태 자동 거절 아님APPROVED일부 내용 열 동결릴리스 항목승인 해시 복사별도 posts 반영버전 · 충돌 · 해시 대조아니요예점선: 별도 편집 절차
승인된 해시를 전달해도 게시글 반영은 별도 작업이다일곱노드여섯간선. 후보와필수PASS근거뒤승인조건을판단하고실패는SIGNAL로승인호출만거절된다. REJECTED상태기록은본문의별도UPDATE다. 성공은APPROVED의일부열동결뒤릴리스항목으로해시를복사한다. 마지막점선은구현밖별도posts반영절차이며현재SQL의자동게시아니다. PASS의사실성/검사한해시/규칙버전결합은이그림으로새로보장되지않고운영정책전제다. 모바일예경로는호출오류노드를통과하지않는다.후보 제목 · 본문현재 내용 해시필수 8규칙 PASS 근거검토자 분리승인 조건모두 통과?승인 호출 오류자동 REJECTED 아님APPROVED일부 내용 열 동결릴리스 항목승인 해시 복사별도 posts 반영버전 · 충돌해시 대조아니요예점선: 별도 편집 절차
검수 기록과 내용 해시가 확인하는 범위
저장 대상현재 기록자동 보장하지 않는 것
후보 내용제목·본문으로 생성한 SHA-256검사 전에 읽은 내용과 같다는 사실
검사 결과후보 ID·규칙 코드·결과·검토자·근거검사한 내용 해시·규칙 버전과의 결합
승인 상태PENDING에서 분리 검토자로 APPROVEDPASS 근거의 사실성·규칙의 동시 변경 방지
릴리스 항목승인 상태에서 복사한 내용 해시실제 posts 반영과 버전·충돌 검사
후보 내용
현재 기록: 제목·본문으로 생성한 SHA-256
자동 보장하지 않는 것: 검사 전에 읽은 내용과 같다는 사실
검사 결과
현재 기록: 후보 ID·규칙 코드·결과·검토자·근거
자동 보장하지 않는 것: 검사한 내용 해시·규칙 버전과의 결합
승인 상태
현재 기록: PENDING에서 분리 검토자로 APPROVED
자동 보장하지 않는 것: PASS 근거의 사실성·규칙의 동시 변경 방지
릴리스 항목
현재 기록: 승인 상태에서 복사한 내용 해시
자동 보장하지 않는 것: 실제 posts 반영과 버전·충돌 검사

후보 승인 트리거가 검사 결과·규칙·릴리스의 직접 변경까지 일괄 동결하지는 않습니다.

두 FAIL이면 승인 거절 · 수정본은 새 후보로 검토

PERSONAL_DATA FAIL + FACT_SOURCE FAIL → 필수 PASS 누락

승인 호출 오류 → REJECTED로 기록 · 원문과 해시 보존

수정본 새 요청 ID → 8규칙 다시 검수 → 승인·릴리스 대사

정상 fixture의 PASS 8행 INSERT는 실제 검수 실행이나 콘텐츠 안전성 증거가 아닙니다. 내용 해시는 제목·NUL·본문을 묶고 입력 NUL 거절은 구현 밖 전제입니다. 승인 뒤 트리거도 검사 결과·규칙·릴리스 전체를 동결하지 않으며 자동 게시 SQL은 없습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 후보 대기부터 릴리스까지 진행합니다.
검수 기록과 내용 해시가 확인하는 범위
저장 대상현재 기록자동으로 보장하지 않는 것
후보 내용제목·본문으로 생성한 SHA-256검사 전에 읽은 내용과 같다는 사실
검사 결과후보 ID·규칙 코드·결과·검토자·근거검사한 내용 해시·규칙 버전과의 결합
승인 상태PENDING에서 분리 검토자로 APPROVEDPASS 근거의 사실성·규칙의 동시 변경 방지
릴리스 항목승인 상태에서 복사한 내용 해시실제 posts 반영과 버전·충돌 검사

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명일곱 노드, 여섯 간선. 후보와 필수 PASS 근거 뒤 승인 조건을 판단하고 실패하면 SIGNAL로 승인 호출만 거절된다. REJECTED 상태 기록은 본문의 별도 UPDATE다. 성공하면 APPROVED의 일부 열을 동결한 뒤 릴리스 항목으로 해시를 복사한다. 마지막 점선은 구현 밖 별도 posts 반영 절차이며 현재 SQL의 자동 게시가 아니다. PASS의 사실성/검사한 해시/규칙 버전 결합은 이 그림으로 새로 보장되지 않고 운영 정책의 전제다. 모바일의 예 경로는 호출 오류 노드를 통과하지 않는다.
표의 열자동으로 보장하지 않는 것
표의 값후보 내용. 현재 기록: 제목·본문으로 생성한 SHA-256. 자동으로 보장하지 않는 것: 검사 전에 읽은 내용과 같다는 사실. 검사 결과. 현재 기록: 후보 ID·규칙 코드·결과·검토자·근거. 자동으로 보장하지 않는 것: 검사한 내용 해시·규칙 버전과의 결합. 승인 상태. 현재 기록: PENDING에서 분리 검토자로 APPROVED. 자동으로 보장하지 않는 것: PASS 근거의 사실성·규칙의 동시 변경 방지. 릴리스 항목. 현재 기록: 승인 상태에서 복사한 내용 해시. 자동으로 보장하지 않는 것: 실제 posts 반영과 버전·충돌 검사.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
후보 대기

현재 제목·본문 해시를 보관한다.

  • 해시·검수 기록 1: PENDING
  • 해시·검수 기록 2: 현재 SHA-256
  • 필수 8규칙 근거 수집
승인 검사

모든 PASS를 확인하고 검토자가 제출자와 다른지 확인한다.

  • 해시·검수 기록 1: 8 PASS
  • 해시·검수 기록 2: submitter≠reviewer
  • 실패면 SIGNAL; REJECTED 기록은 별도 UPDATE
승인

조건을 통과한 후보를 승인한다.

  • 해시·검수 기록 1: APPROVED
  • 해시·검수 기록 2: 일부 내용 열 동결
  • 검사한 해시·규칙 버전 결합/사실성은 별도 정책
릴리스

승인 해시를 복사한다.

  • 해시·검수 기록 1: release hash=approved hash
  • 해시·검수 기록 2: posts 미반영
  • 별도 편집 절차로 전달; PASS fixture는 실제 검사 실행 아님

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 (CAST(NEW.proposed_title AS BINARY) <=> CAST(OLD.proposed_title AS BINARY))
       OR NOT (CAST(NEW.proposed_content AS BINARY) <=> CAST(OLD.proposed_content AS BINARY))
       OR NOT (CAST(NEW.source_request_id AS BINARY) <=> CAST(OLD.source_request_id AS BINARY))
       OR NOT (CAST(NEW.model_reference AS BINARY) <=> CAST(OLD.model_reference AS BINARY))
     ) 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 p_reviewer IS NULL OR 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 AS r
  LEFT JOIN ai_review_result AS 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 행에는 검사한 후보의 해시나 규칙의 버전이 없으므로, 검사를 시작할 때부터 내용과 규칙을 고정하고 직접 DML의 권한을 제한한다는 전제가 필요하며, 후보 행 하나의 잠금이 검사 결과와 규칙의 동시 변경까지 막지는 않습니다. 조건을 어긴 승인 호출은 롤백되고, 승인 SQL에는 posts의 쓰기가 없으므로 그 호출로 게시글이 바뀌지도 않습니다.

승인 트리거는 제목과 본문, 요청 ID, 모델의 식별 정보를 바이트 단위로 비교하고, APPROVED 상태를 되돌리는 것도 막습니다. 그래서 APPROVED를 REJECTED로 바꾼 뒤 본문을 고치는 우회도 허용되지 않습니다. 다만 prompt_summary나 위험도, 대상 게시글 같은 모든 열을 고정하는 트리거는 아니고, 검사 결과와 규칙도 이 트리거의 대상이 아니므로, 검토한 뒤에 그것을 바꾸지 못하게 하는 권한과 쓰기 정책은 따로 운영합니다.

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

누락과 자기 승인, 해시의 검증

아래의 예제는 필수 규칙마다 PASS 행을 INSERT해 승인의 경로를 보여 줍니다. 실제로 사실과 개인정보, 저작권을 검사했거나 콘텐츠가 안전하다고 증명한 결과가 아니므로, 운영에서는 사람이 확인한 구체적인 기록을 남겨야 합니다. 릴리스 감사는 승인 상태와 내용 해시의 일치를 검사하며, 릴리스 안에서 순서의 중복은 기본 키가 막고, 해시는 제목과 본문을 묶을 뿐 PASS 기록의 정확성을 증명하지는 않습니다.

AI 콘텐츠 검수 인수와 릴리스 무결성 대사
INSERT INTO members (email, password_hash, name, status, created_at)
VALUES ('[email protected]', '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 ('[email protected]', '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 AS c
CROSS JOIN ai_review_rule AS r
LEFT JOIN ai_review_result AS 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 AS x
JOIN ai_content_candidate AS 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 AS i
JOIN ai_content_candidate AS c ON c.candidate_id = i.candidate_id
WHERE c.review_status <> 'APPROVED'
   OR i.approved_content_sha256 <> c.content_sha256;

SELECT c.review_status,
       (SELECT COUNT(*) FROM ai_review_result AS x
        WHERE x.candidate_id = c.candidate_id AND x.review_result = 'PASS') AS passed_rules,
       (SELECT COUNT(*) FROM content_release_item AS i
        WHERE i.candidate_id = c.candidate_id) AS release_items
FROM ai_content_candidate AS c
WHERE c.candidate_id = @candidate;

UPDATE ai_content_candidate
SET proposed_content = '승인 뒤에 몰래 바꾼 본문'
WHERE candidate_id = @candidate;
review_status | passed_rules | release_items
APPROVED      | 8            | 1

ERROR 1644 (45000): approved AI content is immutable

세 감사 쿼리는 필수 PASS의 누락과 제출자의 자기 검수, 승인 상태와 해시의 불일치를 확인하며 모두 빈 결과입니다. 여덟 규칙을 모두 통과한 후보가 승인되어, READY 릴리스에 같은 해시로 한 번만 들어갔습니다. 승인된 본문을 고치려는 UPDATE는 트리거에 거부되고, posts에 반영하는 SQL은 처음부터 넣지 않았으므로 승인과 게시는 나뉘어 있습니다.

따져 볼 것질문
관계형외래 키와 UNIQUE, 상태 전이, 트랜잭션이 핵심인가
JSON한 게시글과 함께 읽는 작은 선택 문서이고 스키마로 검증할 수 있는가
외부 보관큰 원본이나 장기 보존 기록이라 OLTP 조회와 나누어야 하는가
AI 후보모델의 출력이 공개 데이터에 닿기 전에 독립된 검수를 거치는가
승인필수 규칙과 확인 기록, 검토자의 분리, 해시의 고정이 있는가
반영승인과 실제 게시 사이에 버전과 충돌, 롤백의 확인이 있는가

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

정리

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

연습 문제

1. 안전하지 않은 후보의 거부

개인정보와 출처 없는 성능 수치를 담은 AI 후보를 등록해 보세요. PERSONAL_DATA와 FACT_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배 빠른 설정',
   '[email protected] 사용자의 환경에서 이 설정은 항상 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, '승인되면 안 되는 호출');

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, review_note
FROM ai_content_candidate
WHERE candidate_id = @unsafe_candidate;
ERROR 1644 (45000): required content checks not passed

candidate_id | review_status | review_note
2            | REJECTED      | 개인정보 제거와 출처 보강 뒤 새 후보 제출

필수 여덟 규칙 가운데 PASS가 하나도 없으므로 승인은 거부됩니다. PENDING에서 REJECTED로 바꾸는 UPDATE는 승인 트리거의 대상이 아니므로 허용되고, 본문과 content_sha256은 그대로 남습니다. 고친 문장은 새 source_request_id를 가진 후보로 등록하고 여덟 규칙을 처음부터 다시 검수합니다.

2. 제출자의 자기 승인

본문의 제출자가 새 후보를 내고, 여덟 규칙의 PASS 행도 자신이 직접 넣은 뒤, 자기 ID로 승인 프로시저를 불렀습니다. 프로시저는 어느 검사에서 멈추나요? 승인자만 다른 사람으로 바꾸면 통과하나요?

해설 보기

자기 ID로 부르면 v_submitter = p_reviewer이므로 첫 검사에서 reviewer separation or state invalid로 멈춥니다. 승인자만 다른 검토자로 바꾸어도, PASS 행의 reviewed_by가 모두 제출자이므로 x.reviewed_by <> v_submitter 조건에 걸려 여덟 규칙이 모두 빠진 것으로 세어집니다. 그래서 required content checks not passed로 멈춥니다.

승인자와 검사자가 모두 제출자와 달라야 통과하며, 본문의 둘째 감사 쿼리도 @candidate에 새 후보의 ID를 담아 실행하면 제출자가 직접 남긴 검사 결과를 찾아냅니다.

다음 글 선택 심화: 분산·객체·멀티미디어 데이터에서는 한 데이터베이스의 범위를 넘는 분산 데이터와 객체, 멀티미디어 데이터를 저장할 때 따져 볼 것을 정리합니다.