본문 바로가기

회원 게시판 최종 DDL

회원 가입과 게시판의 정규화된 원본을 의존성 순서로 생성하고, 시드 데이터와 예상한 제약 위반, 빈 결과를 돌려주어야 하는 무결성 진단, 키셋 페이지 조회로 최종 모델을 인수합니다.

앞에서 후보 키와 관계, 정규화, 물리 타입, 파생 데이터를 하나씩 설계했습니다. 이 글은 그 결정을 빈 데이터베이스에서 다시 실행할 수 있는 하나의 회원 게시판 스키마로 묶습니다. 최종 원본은 회원과 프로필, 카테고리, 게시글, 댓글, 태그, 첨부파일을 중심으로 이루어집니다.

daily_post_stats는 게시글과 같은 트랜잭션에서 갱신하는 운영 통계이며, 언제든 posts에서 다시 계산할 수 있어야 합니다. 핵심 조회는 회원별 게시글 목록과 전체 공개 게시글 목록이고, 둘 다 created_at DESC, id DESC로 전체 순서를 만듭니다. 같은 시각의 행은 id로 구별하고, 직전 행의 두 값을 함께 커서로 넘깁니다.

의존성 순서와 반복 실행

외래 키가 참조하는 부모 테이블은 자식보다 먼저 만들어야 하고, 지울 때는 거꾸로 자식부터 지워야 합니다. 이 스키마의 생성 순서는 다음과 같습니다.

순서테이블먼저 있어야 하는 부모
1members, categories, tags없음
2posts회원, 카테고리
3member_profiles, comments회원, 게시글, 부모 댓글(자기 참조)
4post_tags, attachments, post_attachments게시글, 태그, 첨부파일
5daily_post_stats, v_published_posts회원, 게시글, 카테고리

DDL은 배포 도구가 버전을 기록해 한 번만 적용하는 것이 원칙입니다. 이 글의 DROP DATABASE는 앞 장들에서 만든 실습용 테이블까지 모두 지우고 연습용 빈 스키마를 다시 만들기 위한 장치이므로, 운영 데이터베이스에서는 절대 실행하지 않습니다.

00_reset_board_lab.sql
DROP DATABASE IF EXISTS board_lab;

CREATE DATABASE board_lab
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

USE board_lab;
SET SESSION time_zone = '+00:00';

앞 장들의 테이블이 모두 사라지고, 이후의 AUTO_INCREMENT 값은 다시 1부터 시작합니다. 열과 제약의 이름도 이 글의 DDL을 기준으로 다시 정했습니다. 예를 들어 10장 3편의 categories.category_code와 uk_category_code는 여기서 code와 uq_categories_code가 됩니다. DATETIME에 UTC 값을 기록하는 규칙에 맞추어, 이 연결의 time_zone도 '+00:00'으로 맞춥니다.

정규화한 원본 DDL

회원의 이메일은 로그인의 업무 키이므로 UNIQUE NOT NULL로 지키고, 기본 키 id는 이메일이 바뀌어도 게시글과 댓글의 참조를 안정적으로 유지합니다. 이 스키마는 InnoDB의 16KB 페이지를 전제로 합니다. URL은 최대 768자의 utf8mb4 문자열에 이진 정렬 규칙을 쓰고 정규화는 저장하기 전에 하며, 이름과 이메일에는 데이터베이스의 대소문자와 악센트를 구별하지 않는 규칙이 적용됩니다.

01_create_board_schema.sql
CREATE TABLE members (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  email VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  name VARCHAR(80) NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (id),
  CONSTRAINT uq_members_email UNIQUE (email),
  CONSTRAINT ck_members_status
    CHECK (status IN ('ACTIVE', 'SUSPENDED', 'DELETED'))
) ENGINE = InnoDB;

CREATE TABLE categories (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  code VARCHAR(40) NOT NULL,
  name VARCHAR(100) NOT NULL,
  description VARCHAR(300) NULL,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (id),
  CONSTRAINT uq_categories_code UNIQUE (code),
  CONSTRAINT uq_categories_name UNIQUE (name)
) ENGINE = InnoDB;

CREATE TABLE tags (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  code VARCHAR(40) NOT NULL,
  name VARCHAR(100) NOT NULL,
  is_active BOOLEAN NOT NULL DEFAULT TRUE,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (id),
  CONSTRAINT uq_tags_code UNIQUE (code),
  CONSTRAINT uq_tags_name UNIQUE (name),
  CONSTRAINT ck_tags_active CHECK (is_active IN (0, 1))
) ENGINE = InnoDB;

CREATE TABLE posts (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  author_id BIGINT UNSIGNED NOT NULL,
  category_id BIGINT UNSIGNED NULL,
  title VARCHAR(160) NOT NULL,
  content TEXT NOT NULL,
  status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
  view_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  updated_at DATETIME(6) NOT NULL
    DEFAULT CURRENT_TIMESTAMP(6)
    ON UPDATE CURRENT_TIMESTAMP(6),
  published_at DATETIME(6) NULL,
  version BIGINT UNSIGNED NOT NULL DEFAULT 0,
  request_key VARCHAR(64) NOT NULL,
  PRIMARY KEY (id),
  CONSTRAINT uq_posts_author_request
    UNIQUE (author_id, request_key),
  CONSTRAINT ck_posts_status
    CHECK (status IN ('DRAFT', 'HIDDEN', 'PUBLISHED', 'DELETED')),
  CONSTRAINT ck_posts_title
    CHECK (CHAR_LENGTH(TRIM(title)) BETWEEN 1 AND 160),
  CONSTRAINT ck_posts_content
    CHECK (CHAR_LENGTH(TRIM(content)) >= 1),
  CONSTRAINT ck_posts_request_key
    CHECK (CHAR_LENGTH(TRIM(request_key)) BETWEEN 1 AND 64),
  CONSTRAINT ck_posts_time_order
    CHECK (
      updated_at >= created_at
      AND (published_at IS NULL OR published_at >= created_at)
    ),
  CONSTRAINT ck_posts_publication_pair
    CHECK (
      (status = 'PUBLISHED' AND published_at IS NOT NULL)
      OR (status = 'DRAFT' AND published_at IS NULL)
      OR status IN ('HIDDEN', 'DELETED')
    ),
  CONSTRAINT fk_posts_author
    FOREIGN KEY (author_id)
    REFERENCES members (id)
    ON DELETE RESTRICT,
  CONSTRAINT fk_posts_category
    FOREIGN KEY (category_id)
    REFERENCES categories (id)
    ON DELETE SET NULL,
  INDEX ix_posts_author_created
    (author_id, created_at DESC, id DESC),
  INDEX ix_posts_status_created
    (status, created_at DESC, id DESC)
) ENGINE = InnoDB;

CREATE TABLE member_profiles (
  member_id BIGINT UNSIGNED NOT NULL,
  bio VARCHAR(500) NULL,
  avatar_url VARCHAR(768) NULL,
  updated_at DATETIME(6) NOT NULL
    DEFAULT CURRENT_TIMESTAMP(6)
    ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (member_id),
  CONSTRAINT fk_member_profiles_member
    FOREIGN KEY (member_id)
    REFERENCES members (id)
    ON DELETE CASCADE
) ENGINE = InnoDB;

CREATE TABLE comments (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  post_id BIGINT UNSIGNED NOT NULL,
  author_id BIGINT UNSIGNED NOT NULL,
  parent_comment_id BIGINT UNSIGNED NULL,
  content VARCHAR(1000) NOT NULL,
  status VARCHAR(16) NOT NULL DEFAULT 'VISIBLE',
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  updated_at DATETIME(6) NOT NULL
    DEFAULT CURRENT_TIMESTAMP(6)
    ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (id),
  CONSTRAINT ck_comments_status
    CHECK (status IN ('VISIBLE', 'HIDDEN', 'DELETED')),
  CONSTRAINT fk_comments_post
    FOREIGN KEY (post_id)
    REFERENCES posts (id)
    ON DELETE CASCADE,
  CONSTRAINT fk_comments_author
    FOREIGN KEY (author_id)
    REFERENCES members (id)
    ON DELETE RESTRICT,
  CONSTRAINT fk_comments_parent
    FOREIGN KEY (parent_comment_id)
    REFERENCES comments (id)
    ON DELETE SET NULL,
  INDEX ix_comments_post_created
    (post_id, created_at, id),
  INDEX ix_comments_parent
    (parent_comment_id, created_at, id)
) ENGINE = InnoDB;

CREATE TABLE post_tags (
  post_id BIGINT UNSIGNED NOT NULL,
  tag_id BIGINT UNSIGNED NOT NULL,
  source VARCHAR(16) NOT NULL DEFAULT 'MANUAL',
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (post_id, tag_id),
  CONSTRAINT ck_post_tags_source
    CHECK (source IN ('MANUAL', 'AUTO')),
  CONSTRAINT fk_post_tags_post
    FOREIGN KEY (post_id)
    REFERENCES posts (id)
    ON DELETE CASCADE,
  CONSTRAINT fk_post_tags_tag
    FOREIGN KEY (tag_id)
    REFERENCES tags (id)
    ON DELETE CASCADE,
  INDEX ix_post_tags_tag_post (tag_id, post_id)
) ENGINE = InnoDB;

CREATE TABLE attachments (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  storage_url VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
  original_name VARCHAR(255) NOT NULL,
  mime_type VARCHAR(127) NOT NULL,
  byte_size BIGINT UNSIGNED NOT NULL,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (id),
  CONSTRAINT uq_attachments_storage_url UNIQUE (storage_url),
  CONSTRAINT ck_attachments_byte_size CHECK (byte_size > 0)
) ENGINE = InnoDB ROW_FORMAT = DYNAMIC;

CREATE TABLE post_attachments (
  post_id BIGINT UNSIGNED NOT NULL,
  attachment_id BIGINT UNSIGNED NOT NULL,
  position SMALLINT UNSIGNED NOT NULL,
  caption VARCHAR(300) NULL,
  PRIMARY KEY (post_id, attachment_id),
  CONSTRAINT uq_post_attachments_position
    UNIQUE (post_id, position),
  CONSTRAINT ck_post_attachments_position
    CHECK (position >= 1),
  CONSTRAINT fk_post_attachments_post
    FOREIGN KEY (post_id)
    REFERENCES posts (id)
    ON DELETE CASCADE,
  CONSTRAINT fk_post_attachments_attachment
    FOREIGN KEY (attachment_id)
    REFERENCES attachments (id)
    ON DELETE RESTRICT
) ENGINE = InnoDB;

CREATE TABLE daily_post_stats (
  member_id BIGINT UNSIGNED NOT NULL,
  stat_date DATE NOT NULL,
  published_count INT UNSIGNED NOT NULL DEFAULT 0,
  total_views BIGINT UNSIGNED NOT NULL DEFAULT 0,
  updated_at DATETIME(6) NOT NULL
    DEFAULT CURRENT_TIMESTAMP(6)
    ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (member_id, stat_date),
  CONSTRAINT ck_daily_post_stats_count
    CHECK (published_count <= 20),
  CONSTRAINT fk_daily_post_stats_member
    FOREIGN KEY (member_id)
    REFERENCES members (id)
    ON DELETE CASCADE
) ENGINE = InnoDB;

CREATE VIEW v_published_posts AS
SELECT
  p.id,
  p.author_id,
  m.name AS author_name,
  p.category_id,
  c.name AS category_name,
  p.title,
  p.content,
  p.view_count,
  p.created_at,
  p.published_at,
  p.version
FROM posts AS p
JOIN members AS m
  ON m.id = p.author_id
LEFT JOIN categories AS c
  ON c.id = p.category_id
WHERE p.status = 'PUBLISHED';

MySQL의 CHECK는 AUTO_INCREMENT 열을 쓸 수 없습니다. 자식 행의 열 값을 바꾸는 참조 동작인 ON UPDATE CASCADE와, ON UPDATE나 ON DELETE의 SET NULL, SET DEFAULT가 있는 외래 키의 열도 CHECK에 쓸 수 없습니다. id는 AUTO_INCREMENT이고 parent_comment_id의 외래 키는 ON DELETE SET NULL이므로, 댓글이 자기 자신을 부모로 삼지 못하게 하는 parent_comment_id <> id 같은 CHECK는 두지 않았습니다. 이 DDL이 강제하는 규칙과, 쓰기 경로에서 따로 검증할 규칙을 나누어 둡니다.

FINAL SCHEMA · CONSTRAINT / WRITE PATH / AUDIT

DDL의 행 제약과 여러 행의 업무 검증을 함께 완성한다

요청 중복·부모 존재·요약 상한·URL 유일성은 선언된 경계 안에서만 보장합니다. 재시도 의미, 댓글 트리, 원본 통계 일치와 파일 저장은 별 실행 경로를 설계합니다.

최종 DDL과 쓰기 경로의 책임
업무 규칙DDL이 강제하는 범위별도 구현할 책임
작성 요청작성자 + request_key 중복 거절재시도 응답과 본문 일치 처리
부모 댓글NULL 또는 존재하는 댓글 참조자기 참조 · 순환 · 같은 게시글 검사
일일 게시 수요약 행의 published_count ≤ 20모든 게시 변경과 요약 동기화
저장 URL768자 이내 문자열의 UNIQUEURL 정규화와 파일 저장 성공 확인
작성 요청
DDL이 강제하는 범위: 작성자 + request_key 중복 거절
별도 구현할 책임: 재시도 응답과 본문 일치 처리
부모 댓글
DDL이 강제하는 범위: NULL 또는 존재하는 댓글 참조
별도 구현할 책임: 자기 참조 · 순환 · 같은 게시글 검사
일일 게시 수
DDL이 강제하는 범위: 요약 행의 published_count ≤ 20
별도 구현할 책임: 모든 게시 변경과 요약 동기화
저장 URL
DDL이 강제하는 범위: 768자 이내 문자열의 UNIQUE
별도 구현할 책임: URL 정규화와 파일 저장 성공 확인

요약 CHECK는 posts를 세지 않습니다. 이 fixture는 게시글과 통계 INSERT만 같은 트랜잭션으로 묶습니다.

게시글 → 일일 통계 → 함께 COMMIT

posts에 공개 글 1행 INSERT

daily_post_stats의 해당 회원·날짜 count +1

두 문장 성공 때만 COMMIT · 중간 오류면 실행 중단 후 ROLLBACK

요약의 count ≤20 CHECK가 posts를 직접 세지는 않습니다. 조회수 수정·상태 전환·삭제 경로도 동기화해야 합니다. 본문의 통계 대사는 기존 요약 행에서 시작하므로 아예 빠진 회원·날짜 요약은 별도로 찾아야 합니다.

시드 데이터와 정상 경로

시드 데이터는 정상적인 INSERT의 순서를 보여 주면서, 뒤의 위반 테스트가 참조할 값을 정해 둡니다. 결과를 다시 계산할 수 있도록 게시글의 작성 시각과 공개 시각은 2026-07-15 09:00:00으로 고정했습니다. 비밀번호 해시는 형식의 예일 뿐이며, 실제 서비스에서는 검증된 비밀번호 해시 라이브러리로 만듭니다.

아래 트랜잭션의 COMMIT은 게시글과 통계의 두 쓰기가 모두 성공한 경로에서만 실행합니다. SQL 클라이언트나 애플리케이션이 중간의 오류 뒤에도 다음 문장을 계속 실행하게 두면 안 됩니다. InnoDB에는 실패한 문장만 되돌리고 앞의 변경은 트랜잭션에 남기는 오류도 있으므로, 오류가 나면 실행을 멈추고 ROLLBACK해야 합니다(InnoDB의 오류 처리).

02_seed_board_data.sql
INSERT INTO members
  (email, password_hash, name)
VALUES
  ('[email protected]', '$2a$12$example.hash', '안동민');

SET @member_id := LAST_INSERT_ID();

INSERT INTO member_profiles
  (member_id, bio, avatar_url)
VALUES
  (@member_id, '데이터베이스와 백엔드를 기록합니다.',
   'https://cdn.example.com/avatars/min.png');

INSERT INTO categories
  (code, name, description)
VALUES
  ('database', '데이터베이스', '관계형 모델과 MySQL 게시글');

SET @category_id := LAST_INSERT_ID();

INSERT INTO tags (code, name)
VALUES ('mysql', 'MySQL'), ('modeling', '모델링');

SET @mysql_tag_id := (
  SELECT id FROM tags WHERE code = 'mysql'
);
SET @modeling_tag_id := (
  SELECT id FROM tags WHERE code = 'modeling'
);

START TRANSACTION;

INSERT INTO posts
  (author_id, category_id, title, content, status,
   created_at, published_at, request_key)
VALUES
  (@member_id, @category_id,
   '회원 게시판 최종 모델',
   '정규화된 원본과 관계 제약을 하나의 DDL로 묶습니다.',
   'PUBLISHED', '2026-07-15 09:00:00', '2026-07-15 09:00:00',
   'post-final-001');

SET @post_id := LAST_INSERT_ID();

INSERT INTO daily_post_stats
  (member_id, stat_date, published_count, total_views)
VALUES
  (@member_id, '2026-07-15', 1, 0)
ON DUPLICATE KEY UPDATE
  published_count = published_count + 1;

COMMIT;

INSERT INTO comments
  (post_id, author_id, content)
VALUES
  (@post_id, @member_id, '최종 스키마를 확인했습니다.');

SET @comment_id := LAST_INSERT_ID();

INSERT INTO comments
  (post_id, author_id, parent_comment_id, content)
VALUES
  (@post_id, @member_id, @comment_id, '답글 관계도 정상입니다.');

INSERT INTO post_tags
  (post_id, tag_id, source)
VALUES
  (@post_id, @mysql_tag_id, 'MANUAL'),
  (@post_id, @modeling_tag_id, 'MANUAL');

INSERT INTO attachments
  (storage_url, original_name, mime_type, byte_size)
VALUES
  ('https://cdn.example.com/files/schema.png',
   'schema.png', 'image/png', 245760);

SET @attachment_id := LAST_INSERT_ID();

INSERT INTO post_attachments
  (post_id, attachment_id, position, caption)
VALUES
  (@post_id, @attachment_id, 1, '최종 ERD');

빈 데이터베이스에서 실패 없이 실행했으므로, 회원과 카테고리, 게시글, 첨부파일의 id는 모두 1이고 태그는 mysql이 1, modeling이 2, 댓글은 1과 2입니다. updated_at은 기본값인 실행 시각으로 채워지며, 작성 시각보다 늦으므로 ck_posts_time_order를 지킵니다. 행의 수와 공개 게시글 뷰로 시드를 확인합니다.

시드 데이터 확인
SELECT
  (SELECT COUNT(*) FROM members) AS members,
  (SELECT COUNT(*) FROM posts) AS posts,
  (SELECT COUNT(*) FROM comments) AS comments,
  (SELECT COUNT(*) FROM post_tags) AS post_tags,
  (SELECT COUNT(*) FROM post_attachments) AS post_attachments,
  (SELECT published_count FROM daily_post_stats) AS stat_count;

SELECT id, author_name, category_name, title
FROM v_published_posts;
members | posts | comments | post_tags | post_attachments | stat_count
1       | 1     | 2        | 2         | 1                | 1

id | author_name | category_name | title
1  | 안동민      | 데이터베이스  | 회원 게시판 최종 모델

트랜잭션으로 묶은 것은 게시글과 통계, 두 쓰기뿐입니다. 예를 들어 그날의 통계가 이미 20건인데 새 공개 글을 넣고 통계를 21건으로 바꾸는 변경이 CHECK로 거부되었다면, 그대로 COMMIT하면 글만 남습니다. BEGIN과 COMMIT을 적었다는 것만으로 모든 실패 경로의 원자성이 완성되지는 않으며, 이 경우는 연습 문제 2에서 확인합니다.

제약 위반으로 설계 의도 확인

정상 시드가 들어간 뒤에는, 각 제약이 막아야 할 반례를 하나씩 따로 실행합니다. 한 오류가 뒤의 SQL까지 멈추지 않게 하고, 고아 id 999999는 이 시드에 없다는 것을 전제로 합니다. 오류의 번호보다, 어떤 이름의 제약이 어떤 업무 문장을 지켰는지를 확인합니다.

03_expected_failures.sql
-- 같은 이메일은 두 회원에게 사용할 수 없다.
INSERT INTO members
  (email, password_hash, name)
VALUES
  ('[email protected]', '$2a$12$duplicate.hash', '중복 회원');

-- 존재하지 않는 회원은 게시글 작성자가 될 수 없다.
INSERT INTO posts
  (author_id, title, content, status, request_key)
VALUES
  (999999, '고아 게시글', '저장되면 안 됩니다.', 'DRAFT', 'orphan-001');

-- 같은 작성 요청을 재시도해도 게시글이 중복되지 않는다.
INSERT INTO posts
  (author_id, title, content, status, request_key)
VALUES
  (@member_id, '중복 요청', '저장되면 안 됩니다.', 'DRAFT', 'post-final-001');

-- 공개 시각 없는 공개 게시글은 상태 규칙을 위반한다.
INSERT INTO posts
  (author_id, title, content, status, published_at, request_key)
VALUES
  (@member_id, '잘못된 공개 상태', '저장되면 안 됩니다.',
   'PUBLISHED', NULL, 'invalid-status-001');

-- 같은 태그 연결과 같은 첨부 순서는 중복될 수 없다.
INSERT INTO post_tags (post_id, tag_id)
VALUES (@post_id, @mysql_tag_id);

INSERT INTO post_attachments
  (post_id, attachment_id, position)
VALUES
  (@post_id, @attachment_id, 1);
ERROR 1062 (23000): Duplicate entry '[email protected]' for key 'members.uq_members_email'
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`board_lab`.`posts`, CONSTRAINT `fk_posts_author` ...)
ERROR 1062 (23000): Duplicate entry '1-post-final-001' for key 'posts.uq_posts_author_request'
ERROR 3819 (HY000): Check constraint 'ck_posts_publication_pair' is violated.
ERROR 1062 (23000): Duplicate entry '1-1' for key 'post_tags.PRIMARY'
ERROR 1062 (23000): Duplicate entry '1-1' for key 'post_attachments.PRIMARY'

여섯 문장이 차례로 여섯 오류를 냅니다. 마지막 문장은 (post_id, attachment_id)의 기본 키와 (post_id, position)의 UNIQUE가 모두 겹치지만, InnoDB는 기본 키인 클러스터형 인덱스에 먼저 넣으므로 PRIMARY의 중복으로 보고합니다. 순서 규칙만 따로 확인하려면 다른 첨부파일을 같은 순서 1에 잇는 반례를 만듭니다.

실패한 INSERT도 AUTO_INCREMENT 값을 쓸 수 있으므로, 이 스크립트 뒤에 넣는 게시글의 id는 2가 아닐 수 있습니다. 실패 예제는 운영 마이그레이션에 그대로 넣지 않고, 별도의 검증 스크립트나 수동 인수 절차로 실행하며 예상한 제약 이름을 기록합니다.

빈 결과를 확인하는 무결성 진단

제약이 켜져 있어도, 과거의 데이터 이관이나 제약을 잠시 끈 이력을 확인할 진단 쿼리가 필요합니다. 다음 다섯 쿼리는 정상 상태에서 모두 빈 결과를 돌려주어야 합니다.

04_integrity_audit.sql
SELECT p.id AS orphan_post_id
FROM posts AS p
LEFT JOIN members AS m
  ON m.id = p.author_id
WHERE m.id IS NULL;

SELECT c.id AS orphan_comment_id
FROM comments AS c
LEFT JOIN posts AS p
  ON p.id = c.post_id
LEFT JOIN members AS m
  ON m.id = c.author_id
WHERE p.id IS NULL OR m.id IS NULL;

SELECT c.id AS cross_post_reply_id
FROM comments AS c
JOIN comments AS parent
  ON parent.id = c.parent_comment_id
WHERE c.post_id <> parent.post_id;

SELECT pt.post_id, pt.tag_id, COUNT(*) AS duplicate_count
FROM post_tags AS pt
GROUP BY pt.post_id, pt.tag_id
HAVING COUNT(*) > 1;

SELECT
  s.member_id,
  s.stat_date,
  s.published_count,
  COUNT(p.id) AS source_count,
  s.total_views,
  COALESCE(SUM(p.view_count), 0) AS source_total_views
FROM daily_post_stats AS s
LEFT JOIN posts AS p
  ON p.author_id = s.member_id
 AND p.status = 'PUBLISHED'
 AND p.published_at >= s.stat_date
 AND p.published_at < s.stat_date + INTERVAL 1 DAY
GROUP BY
  s.member_id,
  s.stat_date,
  s.published_count,
  s.total_views
HAVING s.published_count <> COUNT(p.id)
    OR s.total_views <> COALESCE(SUM(p.view_count), 0);

시드에서는 다섯 쿼리 모두 빈 결과입니다. 마지막 쿼리는 회원 1의 2026-07-15 통계(1건, 0회)를 그날 공개한 게시글 1의 집계(1건, 0회)와 비교해 일치를 확인합니다.

세 번째 쿼리는 단순한 자기 참조 외래 키가 강제하지 못하는 "부모 댓글과 같은 게시글" 규칙을 검사합니다. 마지막 쿼리는 이미 있는 요약 행과 원래 데이터가 어긋난 회원과 날짜를 찾으며, 요약 행 자체가 없는 날짜는 출발 집합에 없으므로 찾지 못합니다. 그러니 이 다섯 쿼리가 모든 자기 참조와 순환, 누락을 다 검사하는 것은 아닙니다.

키셋 페이지 조회

페이지 번호가 커질수록 OFFSET은 앞의 행을 읽고 버리는 비용이 커집니다. 마지막으로 본 created_at과 id를 커서로 넘기면, 다음 페이지의 시작점을 범위 조건으로 나타낼 수 있습니다. 아래의 첫 커서는 2026-07-15의 끝과 BIGINT UNSIGNED의 최댓값이며, 첫 페이지를 읽는 역할을 합니다.

05_keyset_pagination.sql
SET @cursor_created_at := '2026-07-15 23:59:59.999999';
SET @cursor_id := 18446744073709551615;

SELECT
  p.id,
  p.title,
  p.status,
  p.view_count,
  p.created_at
FROM posts AS p
WHERE p.author_id = @member_id
  AND (
    p.created_at < @cursor_created_at
    OR (p.created_at = @cursor_created_at AND p.id < @cursor_id)
  )
ORDER BY p.created_at DESC, p.id DESC
LIMIT 20;
id | title                 | status    | view_count | created_at
1  | 회원 게시판 최종 모델 | PUBLISHED | 0          | 2026-07-15 09:00:00.000000

ix_posts_author_created (author_id, created_at DESC, id DESC)는 작성자 조건과 커서의 정렬을 한 경로로 지원합니다. 다만 게시글이 한 행뿐인 지금은 옵티마이저가 어떤 경로를 고르든 의미가 없으므로, 실제로 고른 인덱스와 읽은 행 수는 충분한 데이터를 넣은 뒤 같은 SELECT 앞에 EXPLAIN ANALYZE를 붙여 확인합니다. 전체 공개 목록에서는 ix_posts_status_created가 상태 조건과 정렬을 지원하는 후보입니다.

같은 시각의 행이 페이지를 나누는 위치에 걸리면 id의 비교가 필요해집니다. 같은 작성자의 글 100, 99, 98이 모두 같은 created_at을 갖고 한 페이지가 두 행이라고 합시다.

커서 조건다음 페이지판단
created_at < 그 시각 OR (created_at = 그 시각 AND id < 99)98같은 시각의 남은 행을 이어 읽는다
created_at < 그 시각98을 빠뜨린다같은 시각의 행이 사라진다
created_at < 그 시각 OR (created_at = 그 시각 AND id <= 99)99, 98이미 본 99가 반복된다

첫 페이지는 100과 99이므로 커서는 (그 시각, 99)입니다. 서로 다른 시각의 글까지 id만으로 비교하는 것도 시간이 우선인 정렬과 같지 않습니다. 데이터와 정렬 값이 고정되어 있으면 id까지 포함한 엄격한 비교가 같은 시각의 행을 이어 읽지만, 페이지 사이의 동시 수정까지 스냅샷으로 고정하는 기능은 아닙니다.

더 깊이: 행 생성자로 쓴 커서와 인덱스의 사용

(p.created_at, p.id) < (@cursor_created_at, @cursor_id)처럼 여러 값을 한꺼번에 비교하는 행 생성자(row constructor)는 위의 OR 조건과 뜻이 같습니다. 그런데 MySQL의 옵티마이저는 행 생성자의 열이 인덱스의 앞부분을 덮지 않으면 인덱스를 덜 활용합니다. MySQL 8.4 매뉴얼의 예에서 기본 키가 (c1, c2, c3)인 테이블을 c1 = 1 AND (c2, c3) > (1, 1)로 조회하면 인덱스 조회에 c1만 쓰이고(key_len 4), 같은 뜻의 c1 = 1 AND (c2 > 1 OR (c2 = 1 AND c3 > 1))로 바꾸면 세 열을 모두 쓰는 범위 접근이 됩니다(key_len 12).

이 글의 커서도 같은 모양입니다. 인덱스의 첫 열인 author_id는 =로 따로 비교하고 행 생성자는 그 뒤의 (created_at, id)만 덮으므로, 커서를 행 생성자로 쓰면 작성자 조건만 인덱스 조회에 쓰일 수 있습니다. 매뉴얼은 행 생성자와 AND, OR 식을 섞지 말고 한 가지 형태로 쓰라고 권하므로, 위처럼 OR로 풀어 쓴 커서가 인덱스를 더 많이 쓸 수 있습니다. 실제로 몇 개의 키 부분을 썼는지는 EXPLAIN의 key_len이나 JSON 형식의 used_key_parts로 확인합니다.

배포와 인수 기준

빈 스키마에서 성공했다고 운영의 변경까지 안전한 것은 아닙니다. 큰 테이블에 인덱스를 더하거나 NOT NULL을 강화하거나 타입을 바꾸면 잠금과 복제의 지연이 생길 수 있습니다. 배포하기 전에는 다음을 확인합니다.

확인할 것내용
적용 순서적용 순서와 롤포워드 스크립트가 버전 관리에 들어 있는가
새 필수 열NULL을 허용해 추가하고, 값을 채우고, 누락이 0행인지 확인한 뒤 제약을 강화하는가
타입외래 키 부모와 자식의 타입과 정렬 규칙이 같은가
인덱스새 인덱스의 대표 쿼리 계획과 쓰기 비용을 함께 비교했는가
파생 통계재생성 쿼리와 불일치 진단을 먼저 준비했는가
삭제 정책개인정보 삭제와 게시글 보존 정책이 CASCADE, RESTRICT의 선택과 맞는가

정리

  • 최종 DDL은 키와 관계, 정규화, 타입, 인덱스, 파생 데이터의 결정을 의존성 순서의 한 실행으로 묶습니다.
  • 회원 이메일과 게시글 요청 키는 업무의 유일성으로, id는 안정적인 참조 식별자로 나눕니다.
  • 댓글과 태그, 첨부파일은 외래 키와 복합 유일성으로 회원 게시판의 실제 관계를 나타냅니다.
  • 키셋 정렬은 created_at DESC, id DESC 두 값을 함께 씁니다.
  • 파생 통계는 원래 데이터와 기준 날짜, 불일치 진단, 재생성 절차를 가져야 합니다.
  • 성공 경로뿐 아니라 예상한 실패와 빈 결과의 진단까지 통과해야 스키마의 인수가 끝납니다.

연습 문제

1. 소프트 삭제한 댓글의 표시

댓글을 소프트 삭제할 때, 답글의 문맥은 남기되 작성자의 개인정보는 화면에 드러내지 않으려 합니다. comments.status와 content, 회원의 탈퇴 상태, 조회 쿼리의 역할을 나누어 설계해 보세요.

해설 보기

댓글 행과 parent_comment_id는 그대로 두어 답글 트리를 보존합니다. 삭제하면 status를 'DELETED'로 바꾸고, 화면에는 고정 문구를 보여 줍니다. 작성자의 외래 키를 지우지 않는다면, 탈퇴한 회원의 이름이 드러나지 않도록 회원의 상태와 댓글의 상태를 함께 검사합니다. 법적인 삭제 요구가 있다면, 별도의 익명 회원으로 참조를 옮기는 마이그레이션과 감사 기록이 필요합니다.

아래 예제는 댓글 1을 트랜잭션 안에서 소프트 삭제한 뒤 공개 화면의 결과를 확인하고, ROLLBACK으로 시드를 되돌립니다. HIDDEN 댓글은 공개 화면에서 빼는 정책으로 가정했습니다.

START TRANSACTION;

UPDATE comments
SET status = 'DELETED'
WHERE id = @comment_id;

SELECT
  c.id,
  c.parent_comment_id,
  CASE
    WHEN c.status = 'DELETED' THEN '삭제된 댓글입니다.'
    ELSE c.content
  END AS visible_content,
  CASE
    WHEN c.status = 'DELETED' THEN NULL
    WHEN m.status = 'DELETED' THEN '탈퇴한 회원'
    ELSE m.name
  END AS visible_author
FROM comments AS c
JOIN members AS m
  ON m.id = c.author_id
WHERE c.post_id = @post_id
  AND c.status <> 'HIDDEN'
ORDER BY c.created_at, c.id;

ROLLBACK;
id | parent_comment_id | visible_content         | visible_author
1  | NULL              | 삭제된 댓글입니다.      | NULL
2  | 1                 | 답글 관계도 정상입니다. | 안동민

댓글 1의 행이 남아 있으므로 댓글 2는 여전히 1을 부모로 가리키고, 삭제된 댓글은 본문과 작성자를 모두 숨깁니다. 다만 회원이 탈퇴해도 VISIBLE인 댓글의 본문은 그대로 나가므로, 본문에 이름이나 이메일 같은 개인정보가 들어 있다면 이 CASE만으로는 지워지지 않습니다. API 응답과 검색, 첨부파일, 캐시를 포함한 실제 삭제 정책은 따로 점검합니다.

2. 통계의 상한에 걸린 공개

시드 뒤에 그날의 통계가 이미 20건이라고 가정하고, 새 공개 글과 통계의 증가를 한 트랜잭션으로 실행합니다. 마지막 INSERT가 실패한 뒤 ROLLBACK하면 게시글 수와 통계는 각각 얼마가 되나요? 실수로 COMMIT했다면 무엇이 남나요?

START TRANSACTION;

UPDATE daily_post_stats
SET published_count = 20
WHERE member_id = @member_id AND stat_date = '2026-07-15';

INSERT INTO posts
  (author_id, category_id, title, content, status,
   created_at, published_at, request_key)
VALUES
  (@member_id, @category_id, '스물한 번째 글', '상한을 확인하는 글입니다.',
   'PUBLISHED', '2026-07-15 10:00:00', '2026-07-15 10:00:00',
   'post-final-021');

INSERT INTO daily_post_stats
  (member_id, stat_date, published_count, total_views)
VALUES
  (@member_id, '2026-07-15', 1, 0)
ON DUPLICATE KEY UPDATE
  published_count = published_count + 1;
해설 보기

마지막 INSERT는 기존 행을 21로 바꾸려다 CHECK에 걸립니다.

ERROR 3819 (HY000): Check constraint 'ck_daily_post_stats_count' is violated.

이 오류는 실패한 문장 하나만 되돌리고 트랜잭션은 열어 둡니다. 여기서 ROLLBACK하면 20으로 바꾼 UPDATE와 새 게시글이 모두 취소되어, 게시글은 시드의 1행, 그날의 통계는 1건으로 돌아갑니다. 실수로 COMMIT했다면 앞의 두 문장이 확정되어, 새 공개 글은 남았는데 통계는 20에 머무릅니다. 이것이 오류가 난 뒤 실행을 멈추고 ROLLBACK해야 하는 이유입니다.

새 게시글의 created_at을 공개 시각과 같게 직접 넣은 것은, 기본값인 실행 시각이 공개 시각보다 늦어 ck_posts_time_order에 먼저 걸리지 않게 하려는 것입니다. ROLLBACK한 INSERT도 AUTO_INCREMENT 값을 쓰므로, 다음에 넣는 게시글의 id는 건너뛴 값이 됩니다.

다음 글 선택 심화: 다치 종속과 무손실 분해 반례에서는 함수 종속만으로 설명되지 않는 독립된 다중값과 4NF, 5NF의 반례를 다룹니다.