복합 키와 일대다 관계
둘 이상의 컬럼으로 업무 유일성을 표현하고, 회원 게시판의 카테고리와 게시글을 올바른 일대다 관계로 연결합니다.
“한 사용자는 하루에 게시 한도를 하나만 가진다”는 규칙은 member_id만으로도, quota_date만으로도 표현할 수 없습니다.
두 값을 함께 봐야 한 행을 구별할 수 있습니다.
이런 요구가 복합 키의 출발점입니다.
같은 시점에 회원 게시판에는 또 하나의 관계가 필요합니다.
한 게시글 카테고리에는 여러 게시글이 쌓이고, 한 게시글은 하나의 카테고리에 속합니다.
복합 유일성과 일대다 관계를 함께 설계하면 “무엇이 한 행을 구별하는가”와 “외래 키를 어느 쪽에 둘 것인가”가 자연스럽게 연결됩니다.
복합 후보 키의 최소성
복합 키는 두 개 이상의 컬럼을 묶은 키입니다.
컬럼이 많다고 복합 후보 키가 되는 것은 아닙니다.
조합 전체는 유일하고, 그중 하나라도 빼면 유일성이 깨져야 최소성을 만족합니다.
daily_post_quota의 후보를 표로 검토해 봅시다.
| 컬럼 집합 | 유일한가? | 최소한인가? | 판단 |
|---|---|---|---|
{member_id} | 아니요 | - | 한 회원에게 여러 날짜별 게시 한도가 있음 |
{quota_date} | 아니요 | - | 여러 회원이 같은 날짜에 게시 한도를 만듦 |
{member_id, quota_date} | 예 | 예 | 복합 후보 키 |
{member_id, quota_date, max_posts} | 예 | 아니요 | max_posts가 불필요한 슈퍼 키 |
복합 키의 컬럼 순서는 유일성 자체에는 영향을 주지 않지만 인덱스 사용 방식에는 영향을 줍니다.
MySQL의 다중 컬럼 B-트리 인덱스는 왼쪽부터 이어지는 컬럼 조건을 중심으로 사용할 수 있습니다.
UNIQUE (member_id, quota_date)는 특정 회원의 날짜별 게시 한도 조회에는 자연스럽지만, 모든 회원의 특정 날짜별 게시 한도만 자주 찾는다면 별도의 인덱스가 필요할 수 있습니다.
키는 먼저 업무 규칙으로 결정하고, 인덱스 순서와 추가 인덱스는 실제 조회 패턴으로 결정해야 합니다.
“인덱스에 좋을 것 같다”는 이유로 업무상 유일성의 의미를 바꾸면 안 됩니다.
복합 키와 대리 키
daily_post_quota의 정체성을 (member_id, quota_date)로 직접 표현하는 복합 기본 키 모델은 충분히 유효합니다.
CREATE TABLE daily_post_quota_composite_demo (
member_id BIGINT UNSIGNED NOT NULL,
quota_date DATE NOT NULL,
max_posts SMALLINT UNSIGNED NOT NULL,
PRIMARY KEY (member_id, quota_date),
CONSTRAINT fk_quota_composite_member
FOREIGN KEY (member_id)
REFERENCES members (id)
) ENGINE = InnoDB;이 행을 다른 테이블이 거의 참조하지 않고, 회원과 날짜의 조합 자체가 수명 동안 변하지 않는다면 간결합니다.
반대로 게시 한도의 변경 이력이나 알림처럼 여러 자식이 생기면 모든 외래 키가 두 컬럼을 반복해서 가져야 합니다.
한도 날짜를 잘못 입력해 수정하는 일도 대표 식별자 변경이 됩니다.
회원 게시판의 누적 모델에서는 참조 확장을 고려해 대리 키를 기본 키로 두고, 복합 후보 키를 UNIQUE로 보존합니다.
USE board_lab;
SET @member_id := (
SELECT id
FROM members
ORDER BY id
LIMIT 1
);
DROP TABLE IF EXISTS daily_post_quota;
CREATE TABLE daily_post_quota (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
member_id BIGINT UNSIGNED NOT NULL,
quota_date DATE NOT NULL,
max_posts SMALLINT UNSIGNED NOT NULL,
note VARCHAR(300) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_daily_post_quota_member_date (member_id, quota_date),
KEY ix_daily_post_quota_date (quota_date),
CONSTRAINT ck_daily_post_quota_max_posts
CHECK (max_posts BETWEEN 1 AND 20),
CONSTRAINT fk_daily_post_quota_member
FOREIGN KEY (member_id)
REFERENCES members (id)
) ENGINE = InnoDB;
INSERT INTO daily_post_quota
(member_id, quota_date, max_posts, note)
VALUES
(@member_id, '2026-07-14', 5, '하루에 작성할 수 있는 게시글 수');같은 회원과 날짜로 게시 한도를 한 번 더 저장하면 대리 키는 새 값을 만들 수 있어도 복합 대체 키가 거절합니다.
INSERT INTO daily_post_quota
(member_id, quota_date, max_posts)
VALUES
(@member_id, '2026-07-14', 3);ERROR 1062 (23000): Duplicate entry '<member_id>-2026-07-14'
for key 'daily_post_quota.uk_daily_post_quota_member_date'대리 키를 썼다고 복합 키가 사라진 것이 아닙니다.
대표 기본 키의 역할은 id가, “회원별 하루 한 게시 한도”라는 업무 후보 키의 역할은 (member_id, quota_date)가 맡았습니다.
일대다 관계의 외래 키
이제 게시글 카테고리 categories를 추가합니다.
“데이터베이스” 카테고리 하나에는 여러 게시글이 기록될 수 있고, 각 게시글은 정확히 한 카테고리에 속합니다.
카테고리 행에 post_ids = '1,2,3' 같은 목록을 넣으면 한 컬럼에 여러 값이 들어가 원자성을 잃습니다.
post_id_1, post_id_2처럼 컬럼을 늘리면 게시글 수가 늘 때마다 스키마를 바꿔야 합니다.
카테고리를 여러 행으로 복제하면 카테고리의 기본 키 유일성이 깨집니다.
반면 각 게시글은 속한 카테고리 하나만 알면 됩니다.
따라서 외래 키 category_id는 ‘다’ 쪽인 posts에 둡니다.
CREATE TABLE categories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
category_code VARCHAR(40) NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(300) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_category_code (category_code),
UNIQUE KEY uk_category_name (name)
) ENGINE = InnoDB;
INSERT INTO categories (category_code, name, description)
VALUES
('database', '데이터베이스', '관계형 모델과 MySQL 8.4 실습'),
('java', 'Java', '언어와 JVM 학습');
SET @database_category_id := (
SELECT id
FROM categories
WHERE category_code = 'database'
);
ALTER TABLE posts
ADD COLUMN category_id BIGINT UNSIGNED NULL AFTER author_id;
UPDATE posts
SET category_id = @database_category_id
WHERE category_id IS NULL;
ALTER TABLE posts
MODIFY category_id BIGINT UNSIGNED NOT NULL,
ADD KEY ix_posts_category_created (category_id, created_at),
ADD CONSTRAINT fk_posts_category
FOREIGN KEY (category_id)
REFERENCES categories (id);기존 데이터가 있는 테이블에는 바로 NOT NULL 컬럼을 추가하기보다 허용 상태로 추가 → 값 채우기 → 누락 확인 → 필수 제약 강화 순서로 이동하는 편이 안전합니다.
위 예제는 학습용 소량 데이터라 한 번에 진행하지만, 운영 테이블에서는 DDL의 잠금·온라인 변경 지원과 배치 갱신을 별도로 검토해야 합니다.
MySQL DDL은 트랜잭션 단위를 암묵적으로 확정할 수 있으므로 일반 DML처럼 전체를 ROLLBACK할 수 있다고 가정하면 안 됩니다.
SELECT COUNT(*) AS missing_category_count
FROM posts
WHERE category_id IS NULL;결과가 0일 때만 NOT NULL로 강화합니다.
마이그레이션은 문법을 실행하는 일이 아니라 강한 제약을 적용할 수 있는 데이터 상태를 먼저 만드는 일입니다.
관계와 조회 방향
객체는 참조 필드가 있는 방향으로 이동하지만, 관계형 데이터베이스의 외래 키는 조회 방향을 제한하지 않습니다.
posts.category_id 하나로 게시글에서 카테고리를 찾을 수도 있고, 카테고리에서 게시글 목록을 찾을 수도 있습니다.
SELECT
s.id AS post_id,
s.title,
sub.name AS category_name
FROM posts AS s
JOIN categories AS sub
ON sub.id = s.category_id
WHERE s.author_id = @member_id
ORDER BY s.created_at;각 게시글은 하나의 카테고리만 가리키므로 게시글을 기준으로 한 다대일 조인은 보통 기준 행 수를 늘리지 않습니다.
SELECT
sub.id AS category_id,
sub.name,
COUNT(s.id) AS post_count,
COALESCE(SUM(s.view_count), 0) AS total_views
FROM categories AS sub
LEFT JOIN posts AS s
ON s.category_id = sub.id
GROUP BY sub.id, sub.name
ORDER BY sub.id;카테고리를 기준으로 게시글을 조인하면 한 카테고리 행이 게시글 수만큼 반복될 수 있습니다.
이것은 오류가 아니라 일대다 조인의 정상적인 모양입니다.
다만 여러 일대다 자식을 동시에 조인한 뒤 합계를 내면 행이 서로 곱해질 수 있으므로, 집계 단위를 먼저 줄이거나 각각 집계한 결과를 조인해야 합니다.
선택 참여와 NULL
posts.category_id NOT NULL과 외래 키는 모든 게시글이 존재하는 카테고리 하나에 반드시 참여하도록 강제합니다.
반대로 새 카테고리는 게시글이 하나도 없어도 저장할 수 있습니다.
categories 테이블에는 “자식이 최소 한 행 있어야 한다”를 검사할 외래 키 컬럼이 없기 때문입니다.
만약 “카테고리를 만들 때 첫 게시글도 반드시 있어야 한다”가 업무 규칙이라면 보통 애플리케이션이 한 트랜잭션으로 두 행을 만듭니다.
START TRANSACTION;
INSERT INTO categories (category_code, name)
VALUES ('network', '네트워크');
SET @network_category_id := LAST_INSERT_ID();
INSERT INTO posts
(author_id, category_id, title, content, status, request_key)
VALUES
(@member_id, @network_category_id, 'OSI 계층 정리', 'OSI 7계층의 책임을 정리합니다.', 'PUBLISHED', 'post-key-network');
COMMIT;ERD는 “카테고리는 최소 한 게시글을 가진다”는 원래 규칙을 표현할 수 있지만, 기본 외래 키만으로 부모 쪽 최소 개수까지 강제할 수는 없습니다.
모델, DDL, 애플리케이션이 각각 무엇을 보장하는지 구분해야 합니다.
연습 문제
한 게시글에는 순서가 있는 댓글이 여러 개 생깁니다.
댓글 번호는 게시글 안에서 1, 2, 3...으로 시작하며, 다른 게시글에서는 같은 번호를 다시 사용할 수 있습니다.
댓글을 다른 테이블이 독립적으로 참조할 계획은 아직 없습니다.
- 후보 키를 고르세요.
- 외래 키가 어느 테이블에 있어야 하는지 설명하세요.
- 복합 기본 키를 사용한 연습용 DDL을 작성하세요.
- 같은 게시글의 같은 순번을 두 번 넣어 오류를 확인하세요.
해설과 예시 답안
post_id만으로는 한 게시글의 여러 댓글을 구별하지 못하고, sequence_no는 다른 게시글에서 반복됩니다.
(post_id, sequence_no)가 최소 복합 후보 키입니다.
한 게시글에 여러 댓글이 있으므로 외래 키는 ‘다’ 쪽인 댓글에 둡니다.
SET @comment_post_id := (
SELECT MIN(id)
FROM posts
WHERE author_id = @member_id
);
CREATE TABLE comments (
post_id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
sequence_no SMALLINT UNSIGNED NOT NULL,
content VARCHAR(1000) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'VISIBLE',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (post_id, sequence_no),
CONSTRAINT ck_comments_sequence CHECK (sequence_no >= 1),
CONSTRAINT ck_comments_status
CHECK (status IN ('VISIBLE', 'HIDDEN', 'DELETED')),
CONSTRAINT fk_comment_post
FOREIGN KEY (post_id)
REFERENCES posts (id),
CONSTRAINT fk_comment_author
FOREIGN KEY (author_id)
REFERENCES members (id)
) ENGINE = InnoDB;
INSERT INTO comments (post_id, author_id, sequence_no, content)
VALUES (@comment_post_id, @member_id, 1, '후보 키를 이해했습니다.');
INSERT INTO comments (post_id, author_id, sequence_no, content)
VALUES (@comment_post_id, @member_id, 1, '같은 순번 중복');
-- ERROR 1062: PRIMARY 키 중복나중에 대댓글이나 신고처럼 독립 참조가 많아지면 id 대리 키를 추가하고 (post_id, sequence_no)는 UNIQUE로 옮기는 선택을 재검토할 수 있습니다.
핵심 정리
- 복합 후보 키는 전체 조합이 유일하고 각 컬럼이 모두 필요해야 합니다.
- 대리 PK를 선택해도 복합 업무 규칙은
UNIQUE로 보존해야 합니다. - 일대다 관계의 외래 키는 하나의 부모를 가리킬 수 있는 ‘다’ 쪽에 둡니다.
- 외래 키 하나로 양쪽 방향의 조인이 가능하며, 일에서 다로 조인하면 행이 늘 수 있습니다.
- 자식의 필수 참여는
NOT NULL과 FK로 강제할 수 있지만, 부모의 최소 자식 수는 별도 업무 로직이 필요합니다.
다음 문서에서는 관계의 나머지 형태인 일대일과 다대다를 회원 게시판의 프로필과 태그 모델로 완성합니다.