데이터 타입과 CHECK 제약
타입만으로는 막지 못하는 빈 본문과 모르는 상태, 게시 시각이 없는 공개 글을 재현하고, 숫자와 문자, 시각 타입의 범위를 정한 뒤 CHECK 제약으로 회원과 게시글의 상태와 시각 조합을 데이터베이스에 선언합니다. NULL과 UNKNOWN이 CHECK를 통과하는 규칙, 경계값으로 제약을 확인하는 방법, 제약을 배포할 때의 순서를 다룹니다.
타입을 고르는 것은 저장 공간을 정하는 일에 그치지 않고, 그 열에 어떤 값과 연산을 허용할지를 정하는 일입니다. 조회수를 문자열로 저장하면 '30회'와 '30'이 섞이고, 날짜를 문자열로 저장하면 정렬과 시간대 판단이 어려워집니다. 하지만 알맞은 타입을 골라도 빈 본문, 알 수 없는 상태, 공개된 글인데 게시 시각이 없는 행 같은 업무상의 오류는 따로 막아야 합니다.
앞 글의 posts는 열의 타입과 외래 키의 뼈대를 갖추었지만, 상태 문자열과 시각의 조합은 아직 아무 값이나 받습니다. 이 글에서는 회원의 상태와 게시글의 제목, 본문, 상태, 시각에 대한 규칙을 MySQL 8.4의 CHECK 제약으로 더합니다.
타입만으로는 막지 못하는 행
이 교재의 엄격 모드에서는 UNSIGNED 열에 음수 조회수를 넣으면 오류가 나지만, 빈 본문이나 철자가 틀린 상태는 타입만으로는 막지 못합니다. 다음 행은 열 하나하나만 보면 모두 타입에 맞습니다.
INSERT INTO posts (
author_id, title, content, status, view_count,
created_at, updated_at, published_at
) VALUES (
1, '잘못된 게시 상태', '', 'PUBLISH', 0,
'2026-07-03 09:00:00', '2026-07-03 08:00:00', NULL
);
SELECT title, CHAR_LENGTH(content) AS content_length, status,
created_at, updated_at,
(updated_at < created_at) AS updated_before_created,
published_at
FROM posts
WHERE title = '잘못된 게시 상태';title | content_length | status | created_at | updated_at | updated_before_created | published_at
잘못된 게시 상태 | 0 | PUBLISH | 2026-07-03 09:00:00.000000 | 2026-07-03 08:00:00.000000 | 1 | NULL본문은 비어 있고, 상태는 PUBLISHED의 오타이며, 수정 시각이 생성 시각보다 앞섭니다. 그런데도 VARCHAR와 DATETIME의 범위 안에 있으므로 INSERT는 성공합니다. 데이터베이스는 문서에만 적혀 있는 규칙을 짐작해 주지 않습니다. 애플리케이션에 열거형과 검증이 있더라도, 데이터 가져오기나 관리자의 SQL, 다른 서비스도 이 테이블에 쓸 수 있으므로, 한 행 안에서 판단할 수 있는 핵심 규칙은 데이터베이스의 제약으로 함께 지켜야 합니다.
타입은 범위를, 제약은 업무의 규칙을 정한다
정수 열에는 예상되는 최댓값과 음수의 허용 여부를, DECIMAL에는 전체 자릿수와 소수 자릿수를, VARCHAR에는 최대 길이와 정렬 규칙을, DATETIME에는 기준이 되는 시간대를 정합니다. NULL은 값이 없다는 뜻이므로 0이나 빈 문자열을 대신해 쓰지 않습니다. BOOLEAN은 MySQL에서 TINYINT(1)의 별칭이라는 점처럼, 타입의 세부는 제품마다 다르다는 것도 알아 둡니다.
CHECK 제약은 한 행 안의 열들이 만족해야 할 조건을 참과 거짓의 식으로 선언하고, 식이 거짓(FALSE)이 되는 행을 거부합니다. MySQL은 8.0.16부터 CHECK를 실제로 검사합니다. 여기서 중요한 규칙이 있습니다. MySQL 8.4의 CHECK 설명에 따르면 식의 결과가 참(TRUE)이거나 알 수 없음(UNKNOWN)이면 통과합니다.
예를 들어 NULL을 허용하는 수량 열에 CHECK (quantity > 0)만 걸었다면, 0은 비교 결과가 거짓이라 거부되지만 NULL은 양수인지 판단할 수 없어 UNKNOWN이 되므로 통과합니다. "수량이 반드시 있어야 한다"는 규칙이라면 NOT NULL도 함께 걸어야 합니다. NOT NULL과 UNIQUE, CHECK는 제약이고, DEFAULT는 값을 생략했을 때 쓸 기본값을 정하는 별도의 규칙입니다.
이 교재는 사건이 일어난 시각을 UTC 기준의 DATETIME(6)으로 저장합니다. DATETIME은 넣은 날짜와 시각을 그대로 저장하고, TIMESTAMP는 세션의 시간대에 따라 UTC로 바꿔 저장했다가 읽을 때 되돌리며 범위도 1970년부터 2038년까지로 좁습니다. 어느 쪽이 언제나 낫지는 않습니다. 예약한 회의처럼 "그 지역의 몇 시"가 중요한 값과, 결제처럼 "언제 일어났는가"가 중요한 값은 요구가 다릅니다.
제약을 더하기 전에 기존 데이터를 정리한다
제약을 더할 때 기존 행 가운데 하나라도 규칙을 어기면 ALTER TABLE이 실패합니다. 그래서 앞의 잘못된 실습 행을 먼저 지운 뒤 제약을 더합니다. 제약의 이름에는 업무상 뜻을 담아, 오류가 났을 때 어떤 규칙이 깨졌는지 이름만 보고 알 수 있게 합니다.
DELETE FROM posts
WHERE title = '잘못된 게시 상태';
ALTER TABLE members
ADD CONSTRAINT ck_members_status
CHECK (status IN ('ACTIVE', 'SUSPENDED', 'DELETED'));
ALTER TABLE posts
ADD CONSTRAINT ck_posts_title
CHECK (CHAR_LENGTH(TRIM(title)) BETWEEN 1 AND 80),
ADD CONSTRAINT ck_posts_content
CHECK (CHAR_LENGTH(TRIM(content)) BETWEEN 1 AND 5000),
ADD CONSTRAINT ck_posts_status
CHECK (status IN ('DRAFT', 'PUBLISHED', 'HIDDEN', 'DELETED')),
ADD CONSTRAINT ck_posts_time_order
CHECK (
updated_at >= created_at
AND (published_at IS NULL OR published_at >= created_at)
),
ADD 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')
);| 제약 | 지키는 규칙 |
|---|---|
ck_members_status | 회원의 상태는 정해 둔 세 값 가운데 하나다 |
ck_posts_title | 앞뒤 공백을 뺀 제목은 1자 이상 80자 이하다 |
ck_posts_content | 앞뒤 공백을 뺀 본문은 1자 이상 5000자 이하다 |
ck_posts_status | 게시글의 상태는 정해 둔 네 값 가운데 하나다 |
ck_posts_time_order | 수정 시각과 게시 시각은 생성 시각보다 앞서지 않는다 |
ck_posts_publication_pair | 공개 글에는 게시 시각이 있고, 초안에는 없다 |
ck_posts_publication_pair가 허용하는 상태와 게시 시각의 조합은 다음과 같습니다. 숨김과 삭제 상태는 예전에 공개되었던 시각을 남겨 둘 수도, 처음부터 NULL일 수도 있습니다.
CHECK · STATE / TIME
공개에는 게시 시각이, 초안에는 NULL이 필요
publication_pair가 허용하는 조합입니다. 숨김·삭제 상태는 과거 공개 시각을 남길 수도, NULL일 수도 있습니다.
| 게시 상태 | 게시 시각이 NULL | 게시 시각이 있음 |
|---|---|---|
| 초안 · DRAFT | 허용 | 거절 |
| 공개 · PUBLISHED | 거절 | 허용 |
| 숨김 · HIDDEN | 허용 | 허용 |
| 삭제 · DELETED | 허용 | 허용 |
- 초안 · DRAFT
- 게시 시각이 NULL: 허용게시 시각이 있음: 거절
- 공개 · PUBLISHED
- 게시 시각이 NULL: 거절게시 시각이 있음: 허용
- 숨김 · HIDDEN
- 게시 시각이 NULL: 허용게시 시각이 있음: 허용
- 삭제 · DELETED
- 게시 시각이 NULL: 허용게시 시각이 있음: 허용
publication_pair 식의 조합만 비교합니다. 시각이 있으면 created_at 이후 또는 같은 시각이어야 하며, 다른 CHECK도 모두 만족해야 저장됩니다.
몇 가지 한계도 알아 둡니다. TRIM은 기본으로 양 끝의 공백 문자만 지우므로, 탭이나 줄바꿈만 든 본문은 빈 입력으로 걸러지지 않습니다. 상태의 비교는 대소문자를 구별하지 않는 utf8mb4_0900_ai_ci를 따르므로 'published'도 통과하며, CHECK가 대문자 표기까지 강제하지는 않습니다. 또 "초안에서 공개로는 갈 수 있지만 삭제에서 공개로는 돌아갈 수 없다" 같은 상태의 변화 순서는 한 행만 보는 CHECK로는 검사할 수 없으므로, 변경을 처리하는 코드와 이력이 함께 맡습니다.
더 깊이: CHECK 식에 쓸 수 없는 것
CHECK 식에는 같은 행의 열과 리터럴, 연산자, 결정적(deterministic) 내장 함수만 쓸 수 있습니다. 결정적이라는 것은 테이블의 데이터가 같으면 누가 언제 호출해도 결과가 같다는 뜻이어서, NOW(), CURRENT_USER(), CONNECTION_ID()는 쓸 수 없습니다. 그래서 "게시 시각은 지금보다 미래일 수 없다"처럼 현재 시각과 비교하는 규칙은 CHECK로 선언할 수 없고, ck_posts_time_order처럼 같은 행의 다른 열과 비교하는 형태로만 나타낼 수 있습니다. 서브쿼리와 다른 테이블의 열, 저장 함수, 시스템 변수와 사용자 변수, AUTO_INCREMENT 열도 식에 넣을 수 없습니다.
외래 키와 함께 쓸 때도 제한이 있습니다. ON UPDATE나 ON DELETE의 참조 동작이 걸린 열에는 CHECK를 걸 수 없고, 반대로 CHECK에 쓰인 열에는 참조 동작을 정할 수 없습니다. 또 CHECK 제약의 이름은 테이블마다가 아니라 스키마(데이터베이스) 안에서 유일해야 하므로, 다른 테이블에 같은 이름의 CHECK를 만들 수 없습니다. 이름을 생략하면 MySQL은 posts_chk_1처럼 테이블 이름 뒤에 _chk_와 순번을 붙인 이름을 만듭니다.
위반했을 때의 처리는 문장에 따라 다릅니다. MySQL 8.4의 CHECK 설명에 따르면 INSERT, UPDATE, REPLACE, LOAD DATA는 식이 거짓이면 오류를 내지만, INSERT IGNORE나 UPDATE IGNORE, LOAD DATA ... IGNORE는 경고만 남기고 그 행을 건너뜁니다. 대량으로 가져오는 작업에 IGNORE를 붙이면 규칙을 어긴 행이 조용히 빠질 수 있으므로, 경고의 수를 반드시 확인해야 합니다. 식은 평가하는 순간의 sql_mode를 따른다는 점도 함께 알아 둡니다.
메타데이터와 경계값으로 확인한다
더한 제약이 실제로 등록되었는지는 메타데이터에서 확인하고, 지금의 데이터가 규칙을 지키는지는 위반한 행을 세어 확인합니다. 첫 조회는 제약의 목록인 table_constraints에 CHECK의 식을 담은 check_constraints를 LEFT JOIN합니다. 기본 키나 외래 키는 check_constraints에 짝이 없으므로, LEFT JOIN이어야 목록에서 빠지지 않고 check_clause만 NULL로 나옵니다. 둘째 조회의 SUM은 MySQL에서 조건식이 참이면 1, 거짓이면 0이 되는 성질을 이용해 규칙을 어긴 행을 셉니다.
SELECT tc.table_name, tc.constraint_name, tc.constraint_type, tc.enforced, cc.check_clause
FROM information_schema.table_constraints AS tc
LEFT JOIN information_schema.check_constraints AS cc
ON cc.constraint_schema = tc.constraint_schema
AND cc.constraint_name = tc.constraint_name
WHERE tc.constraint_schema = 'board_lab'
AND tc.table_name IN ('members', 'posts')
ORDER BY tc.table_name, tc.constraint_name;
SELECT
MIN(CHAR_LENGTH(content)) AS min_content_length,
MAX(CHAR_LENGTH(content)) AS max_content_length,
SUM((status = 'PUBLISHED' AND published_at IS NULL)
OR (status = 'DRAFT' AND published_at IS NOT NULL)) AS broken_publication_state
FROM posts;첫 조회에는 ck_로 시작하는 여섯 제약이 CHECK 종류로, enforced가 YES로 보여야 하고, 기본 키와 UNIQUE, 외래 키도 함께 나옵니다. check_clause에는 MySQL이 정리해 둔 식이 보이므로 적은 그대로와 모양이 조금 다를 수 있습니다. 둘째 조회에서는 본문 길이가 1부터 5000 사이이고 broken_publication_state가 0이어야 합니다. 이 위반 건수 조회는 제약을 더하기 전에 먼저 실행해, 실패할 마이그레이션을 미리 알아채는 데 씁니다.
제약은 경계값으로 확인합니다. 제목이 1자와 80자일 때는 들어가고 0자와 81자일 때는 거부되는지, 공개 상태에 게시 시각이 NULL인 행과 초안에 게시 시각이 있는 행이 각각 거부되는지 시험해 봅니다. 다음은 이 원리를 따로 떼어 확인하는 작은 테이블로, 게시판 스키마와 섞이지 않도록 별도의 데이터베이스에 만듭니다.
CREATE DATABASE IF NOT EXISTS cs_db_lab;
DROP TABLE IF EXISTS cs_db_lab.cs_check_stock;
CREATE TABLE cs_db_lab.cs_check_stock (
id INT PRIMARY KEY,
points INT NOT NULL DEFAULT 0,
stock INT NOT NULL,
CONSTRAINT cs_check_stock_range CHECK (stock >= 0 AND stock <= 10000)
) ENGINE = InnoDB;
INSERT INTO cs_db_lab.cs_check_stock (id, stock) VALUES (1, 0), (2, 10000);
SELECT id, points, stock FROM cs_db_lab.cs_check_stock ORDER BY id;id | points | stock
1 | 0 | 0
2 | 0 | 10000points를 생략했으므로 기본값 0이 들어갔고, stock의 하한 0과 상한 10000은 모두 허용되었습니다. 이제 바로 밖의 값을 문장 하나씩 따로 실행해 봅니다.
INSERT INTO cs_db_lab.cs_check_stock (id, stock) VALUES (3, -1);INSERT INTO cs_db_lab.cs_check_stock (id, stock) VALUES (4, 10001);두 문장은 모두 CHECK 위반 오류 3819로 거부되고, 앞의 두 행은 그대로 남습니다. 허용 범위의 양 끝 값 두 개와 바로 밖의 값 두 개를 함께 시험해야 범위를 제대로 확인한 것입니다. 상한식 stock <= 10000을 실수로 stock < 10000으로 적었다면, 10000을 넣는 정상 시험이 실패해 바로 드러납니다.
제약을 배포할 때의 순서
운영 중인 큰 테이블에 CHECK를 더하면 모든 행을 검사하는 시간과 테이블의 메타데이터 잠금이 생깁니다. 그래서 먼저 읽기 쿼리로 위반 건수를 세고, 잘못된 값을 쓰는 코드를 고친 뒤, 위반이 0이 된 것을 확인하고 제약을 배포합니다. 마이그레이션과 애플리케이션 배포 사이에는 예전 버전의 코드도 새 제약을 지키는 기간이 있어야 합니다. 구조를 안전하게 바꾸는 방법은 다음 장에서 자세히 봅니다.
제약 위반은 데이터베이스의 오류 메시지를 사용자에게 그대로 보여 주지 말고, 애플리케이션이 "제목은 1자 이상이어야 합니다" 같은 업무의 오류로 바꾸어 돌려줍니다. 운영 기록에는 어떤 제약이 깨졌는지 알 수 있도록 제약의 이름을 남깁니다. 오류 3819가 어떤 제약의 이름과 함께 오는지를 기준으로 오류 응답을 정하면, 제약을 더할 때마다 응답도 일관되게 늘려 갈 수 있습니다.
몇 가지 함정도 알아 둡니다. CHECK는 UNKNOWN을 통과시키므로 CHECK (published_at >= created_at)만으로는 NULL을 막지 못하며, 값이 선택 사항이라면 의도한 대로이고 필수라면 NOT NULL을 더합니다. 문자열의 길이는 글자 수와 바이트 수가 다르고, 인덱스 키의 길이 한도는 바이트로 계산됩니다. DECIMAL을 FLOAT로 바꾸면 정확한 비교와 합계가 달라질 수 있습니다. DEFAULT는 열을 생략했을 때만 적용되므로, NOT NULL 열에 NULL을 명시해 넣으면 기본값이 들어가는 대신 오류가 납니다. 다만 AUTO_INCREMENT 열에 NULL을 넣으면 다음 번호를 만들어 주는 별도의 규칙이 적용됩니다.
| 따져 볼 것 | 질문 |
|---|---|
| 범위 | 예상되는 최솟값과 최댓값, 넘칠 가능성을 계산했는가 |
| NULL | 값이 없는 것과 0, 빈 문자열을 구별했는가 |
| 단위 | 조회수, 글자 수, 시각의 기준이 이름과 문서에 있는가 |
| 조합 | 상태와 관련된 열들이 한 행 안에서 모순되지 않는가 |
| 배포 | 기존의 위반과 마이그레이션의 잠금을 먼저 확인했는가 |
여기서 완성한 members와 posts는 2장의 DML이 그대로 이어 씁니다. 3장부터 5장까지는 누구의 환경에서나 결과가 같도록 query_post_demo나 ch5_로 시작하는 테이블처럼 전용 실습 테이블을 따로 만들어 쓰고, 8장의 트랜잭션에서는 이 두 테이블로 CHECK 오류가 무엇을 되돌리는지 확인합니다. board_lab의 댓글과 태그 테이블은 10장에서 만듭니다.
정리
- 타입은 저장할 수 있는 값의 범위를 정하고, CHECK 제약은 그 안에서 업무가 허용하는 값과 조합을 정합니다.
- NULL은 값이 없다는 뜻이며 0이나 빈 문자열과 같지 않고, CHECK는 UNKNOWN을 통과시키므로 필수 값에는
NOT NULL을 함께 겁니다. - 상태와 게시 시각처럼 함께 움직여야 하는 열은 이름 붙은 CHECK로 조합을 강제합니다.
- 제약은 허용 범위의 양 끝 값과 바로 밖의 값으로 확인하고, 더하기 전에 기존의 위반을 먼저 셉니다.
- 상태의 변화 순서처럼 한 행으로 판단할 수 없는 규칙은 애플리케이션과 이력이 함께 맡습니다.
연습 문제
1. 정상 행과 두 가지 오류
정상 게시글, 공백만 든 본문의 게시글, 게시 시각이 없는 공개 게시글을 차례로 넣어 보세요. 어느 행이 남고, 두 오류는 각각 어느 제약을 가리키나요?
INSERT INTO posts (author_id, title, content, status, view_count, created_at, updated_at, published_at)
VALUES (1, '정상 경계 실습', '본문 길이와 상태 조합을 확인합니다.', 'PUBLISHED', 0,
'2026-07-03 09:00:00', '2026-07-03 10:00:00', '2026-07-03 10:00:00');
INSERT INTO posts (author_id, title, content, status, created_at, updated_at)
VALUES (1, '본문 오류', ' ', 'DRAFT', '2026-07-03 11:00:00', '2026-07-03 11:00:00');
INSERT INTO posts (author_id, title, content, status, created_at, updated_at, published_at)
VALUES (1, '게시 시각 누락', '게시 상태인데 게시 시각이 없습니다.', 'PUBLISHED',
'2026-07-03 12:00:00', '2026-07-03 12:00:00', NULL);해설 보기
첫 행만 남습니다. 둘째 행은 본문의 공백을 TRIM으로 지우면 길이가 0이 되어 ck_posts_content를, 셋째 행은 공개 상태인데 게시 시각이 없어 ck_posts_publication_pair를 어겨 각각 오류 3819로 거부됩니다. 오류 메시지에 제약의 이름이 함께 나오므로, 이름을 업무의 뜻으로 지어 두면 어떤 규칙이 깨졌는지 바로 알 수 있습니다.
2. 두 제약을 따로 따져 보기
다른 필수 값은 모두 정상이고 created_at이 09:00, updated_at이 10:00인 같은 날의 행을 생각합니다. (가) 숨김 상태에 게시 시각 NULL, (나) 숨김 상태에 게시 시각 08:00, (다) 공개 상태에 게시 시각 NULL, (라) 초안에 게시 시각 10:00 가운데 저장할 수 있는 것은 무엇인가요?
해설 보기
(가)만 저장할 수 있습니다. 숨김 상태는 ck_posts_publication_pair가 게시 시각의 유무를 모두 허용하지만, (나)는 게시 시각 08:00이 생성 시각 09:00보다 앞서 ck_posts_time_order에 걸립니다. (다)와 (라)는 ck_posts_publication_pair에 걸립니다. 한 제약이 허용한다고 그 행이 저장되는 것은 아니며, 모든 제약을 동시에 만족해야 합니다.
3. NULL을 허용한 재고
본문의 cs_check_stock에서 stock을 NULL을 허용하도록 바꾸고 CHECK는 그대로 두었다면, stock이 NULL인 행은 거부되나요?
해설 보기
거부되지 않습니다. NULL >= 0의 결과는 UNKNOWN이고, CHECK는 거짓일 때만 거부하므로 NULL인 행은 통과합니다. CHECK가 정한 것은 값이 있을 때의 범위일 뿐 값이 반드시 있어야 한다는 규칙이 아니므로, 재고가 필수라면 NOT NULL을 함께 유지해야 합니다. 숫자의 범위와 값의 존재는 서로 다른 규칙입니다.
다음 글 운영 심화: 안전한 스키마 변경에서는 데이터가 쌓인 테이블의 구조를 바꿀 때 잠금과 호환을 고려해 단계적으로 바꾸는 방법을 다룹니다.