본문 바로가기

데이터 타입과 CHECK 제약

숫자·문자·시각 타입의 도메인을 선택하고 검사로 회원 게시판의 범위와 상태 조합을 데이터베이스에 선언합니다.

타입은 저장 공간 선택을 넘어 어떤 연산과 범위를 허용할지 정합니다.

문자열에 조회수를 저장하면 ‘30회’와 ‘30’이 섞이고 날짜 문자열은 정렬과 시간대 판단이 어려워집니다.

올바른 타입을 선택한 뒤에도 빈 본문, 알 수 없는 상태, 게시 상태인데 게시 시각 없음 같은 업무상 오류는 검사가 필요합니다.

앞 문서의 posts는 열 타입과 FK 골격을 가졌지만 상태 문자열과 시각 조합은 아직 자유롭습니다.

회원 상태와 게시글 제목·본문·상태·시각 규칙을 MySQL 8.4 검사로 강화합니다.


물리 타입만 사용한 검증의 한계

교재의 엄격 모드에서는 UNSIGNED 열에 음수 조회수를 넣으면 거절되지만 빈 본문과 알 수 없는 상태는 타입만으로 막지 못합니다.

VARCHAR 상태에는 철자 오류도 들어가고 PUBLISHED인데 published_at이 NULL인 행도 저장됩니다.

열 하나씩만 보면 유효해 보여도 행 전체 상태는 모순입니다.

업무 도메인을 위반하지만 타입은 맞는 행
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 = '잘못된 게시 상태';

검사를 추가하기 전에는 숫자 타입·문자열 길이 안에 있어 INSERT가 성공할 수 있지만 모든 업무 규칙을 어깁니다.

예상 오류 결과
title            | content_length | status  | created_at          | updated_at          | updated_before_created | published_at
잘못된 게시 상태 | 0              | PUBLISH | 2026-07-03 09:00:00 | 2026-07-03 08:00:00 | 1                      | NULL

DBMS는 문서에만 적힌 규칙을 추론하지 않습니다.

VARCHAR와 DATETIME이 허용하는 범위와 회원 게시판이 허용할 범위는 다릅니다.

애플리케이션 열거형과 검증이 있어도 가져오기·관리 SQL·다른 서비스가 테이블에 쓸 수 있으므로 행 안에서 결정 가능한 핵심 규칙을 검사로 공유합니다.

기존 위반 행을 먼저 고치지 않으면 제약 추가에서 오류가 발생합니다.


데이터 타입과 업무 규칙

정수에는 예상 최댓값과 음수 허용 여부, DECIMAL에는 정밀도·소수 자릿수, VARCHAR에는 최대 길이와 정렬 규칙, DATETIME에는 기준 시간대를 정합니다.

NULL은 값 부재를 표현하며 0·빈 문자열과 같은 뜻으로 쓰지 않습니다.

검사는 한 행에 속한 열들의 범위·조합을 불리언 식으로 선언하고 FALSE를 거절합니다.

검사 식의 결과가 알 수 없음이면 통과할 수 있으므로 NOT NULL과 함께 써야 할 규칙을 구분합니다.

예를 들어 NULL을 허용하는 수량 열에 CHECK (quantity > 0)만 선언했다고 생각해 보세요. 0은 비교 결과가 FALSE라 거절되지만 NULL은 양수인지 판단할 값이 없어 UNKNOWN이 됩니다. CHECK는 UNKNOWN을 거절하지 않으므로 “수량이 반드시 있어야 한다”는 규칙에는 NOT NULL도 필요합니다. 현재 posts의 필수 열에는 이미 그 규칙이 있습니다. MySQL 8.4 CHECK 설명은 TRUE·UNKNOWN 허용과 FALSE 거절을 명시합니다.

NOT NULL·UNIQUE·CHECK는 제약이며, DEFAULT는 생략한 값의 기본값을 정하는 별도 규칙입니다.

MySQL은 8.0.16 이후 검사를 실제 강제하며 8.4에서도 제약 조건 이름을 메타데이터에 남깁니다.

BOOLEAN은 TINYINT(1) 별칭이고 시간 타입·자동 시각 세부는 제품별 차이가 있습니다.

이 교재는 사건 시각을 UTC 의미의 DATETIME(6)로 저장하고 사용자 시간대 이름은 별도 문자열로 둡니다.

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도 모두 만족해야 저장됩니다.


기존 데이터 정리와 제약 추가

앞의 나쁜 실습 행을 삭제하고 회원 상태와 게시글 내용·상태·시각 조합을 추가합니다.

제약 조건 이름은 오류 로그에서 어떤 규칙이 깨졌는지 알 수 있게 업무 의미를 담습니다.

회원 게시판 도메인 제약 강화
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')
    );

허용 상태와 범위를 벗어난 행은 제약 조건 이름과 함께 오류가 발생하고 게시 상태·시각은 정해진 조합으로 움직입니다.

예상 결과
constraint                         | protected rule
ck_members_status          | known member lifecycle state
ck_posts_title             | trimmed title length 1..80
ck_posts_content           | trimmed content length 1..5000
ck_posts_status            | known post lifecycle state
ck_posts_time_order        | created_at <= updated/published time
ck_posts_publication_pair  | public status ↔ published_at

DRAFT 게시글의 view_count를 0으로 허용하고 게시 뒤 증가한 조회수를 저장합니다.

TRIM의 기본 제거 대상은 양끝의 일반 공백이며 탭·줄바꿈 전체를 비어 있는 입력으로 검사하지 않습니다. 상태 비교는 utf8mb4_0900_ai_ci를 따르므로 CHECK가 대문자 표기 자체를 강제하지도 않습니다.

복잡한 상태 전이 순서는 한 행의 CHECK만으로 모두 검증하지 않고 변경 서비스와 감사 이력이 함께 책임집니다.


메타데이터와 경계값 검증

다음 조회는 CHECK 정의와 값 분포를 확인합니다. 강제 여부는 information_schema.table_constraints의 ENFORCED 열도 별도로 조회해 확인하고, 제목 0·1·80·81자와 본문 0·1·5000·5001자 경계를 트랜잭션에서 시도합니다.

CHECK 목록과 경계 진단
SELECT
  tc.table_name,
  tc.constraint_name,
  tc.constraint_type,
  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_* 정의가 보이고 본문 길이가 1..5000, broken_publication_state가 0이어야 합니다.

제약 추가 전 이 진단을 마이그레이션 선행 조건으로 실행합니다.

DATETIME은 입력한 날짜·시각을 그대로 저장하고 TIMESTAMP는 세션 시간대 변환과 더 좁은 범위를 가집니다.

어느 타입이 무조건 우월하지 않습니다.

예약된 현지 시각과 절대 사건 시각은 요구가 다릅니다.

회원 게시판은 UTC로 변환한 사건 시각을 DATETIME에 저장하고 표시 시간대는 애플리케이션 설정과 회원 환경에서 결정합니다.

DST 경계에서 현지 시각 하나가 없거나 두 번 생길 수 있으므로 변환은 시간대 라이브러리와 함께 검증합니다.

제목 1·80자는 성공하고 0·81자는 오류가 발생하는지 비교하세요.

상태 PUBLISHED와 published_at NULL, DRAFT와 NULL이 아닌 조합을 각각 시도합니다.

제약 조건을 하나씩 제거해 어떤 잘못된 행이 다시 들어가는지 관찰할 수 있지만 누적 스키마에서는 삭제하지 말고 별도 임시 테이블로 재현합니다.

오류 3819의 제약 조건 이름을 오류 응답과 연결하는 기준도 생각합니다.

대형 기존 테이블에 검사를 추가할 때 전체 행 검증과 메타데이터 잠금 비용을 평가합니다.

먼저 위반 건수를 읽기 쿼리로 확인하고 쓰기 경로를 수정한 뒤 제약을 배포합니다.

스키마 마이그레이션과 애플리케이션 배포 사이에는 구버전도 새 제약을 만족하는 호환 창이 필요합니다.

DB 오류 메시지를 클라이언트에 그대로 노출하지 않고 안정적인 도메인 오류로 변환하되 운영 로그에는 제약 조건 이름을 보존합니다.

검사 결과가 알 수 없음이어도 통과하므로 CHECK (published_at >= created_at)만으로는 NULL을 막지 않습니다.

선택값이면 의도한 통과이고 필수면 NOT NULL을 추가합니다.

문자열 길이는 문자 수와 바이트 수가 다르고 인덱스 키 길이도 문자 집합 영향을 받습니다.

DECIMAL을 FLOAT로 바꾸면 정확한 비교와 집계 경계가 달라질 수 있습니다.

일반 열의 DEFAULT는 생략한 값에 적용되며 명시적 NULL의 대체값이 아닙니다. AUTO_INCREMENT 열의 NULL 입력은 번호 생성이라는 별도 규칙을 따릅니다.

여기서 완성한 회원·posts 열은 ch2 DML, ch3 필터, ch4 계산·집계가 그대로 사용합니다.

ch5는 comments, tags, post_tags 관계를 추가하고 ch8 트랜잭션은 이 검사 오류의 롤백 범위를 확인합니다.

데이터 사전에는 각 열의 타입뿐 아니라 NULL·단위·시간대·제약 조건 이유를 함께 적습니다.


타입과 제약 선택 기준

판단 축확인할 질문
범위예상 최소·최대와 오버플로를 계산했는가?
NULL부재와 0·빈 문자열을 구분했는가?
단위조회수·문자 수·시각 의미가 이름과 문서에 있는가?
조합상태와 관련 열이 한 행에서 모순되지 않는가?
배포기존 위반과 마이그레이션 잠금을 먼저 확인했는가?

타입 이름을 관습으로 고르지 않고 허용 도메인 표를 만든 뒤 가장 좁고 충분한 타입과 제약을 선택합니다.

DB만으로 검증하기 어려운 외부 시간대·복잡한 전이는 책임 범위를 명시합니다.


작은 경계값 fixture로 CHECK 문법 검증하기

복잡한 게시판 예제와 별도로 숫자 기본값과 하한·상한을 한 표에서 확인합니다. 0은 숫자이며 알파벳 O와 다릅니다. 두 상한을 연결하는 식에는 각각 비교 연산자가 있어야 합니다.

독립 CHECK 경계 데이터
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;

두 행의 points는 0이고 stock은 각각 0,10000입니다. 다음 두 실패 문장은 각각 따로 실행합니다. 각각 MySQL 8.4의 CHECK 위반 오류 3819 대상이며 정상 데이터 두 행은 그대로 남아야 합니다.

하한 바로 밖의 값 — 별도 실행
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);

정상 경계 두 개와 바로 밖의 값 두 개를 함께 검사해야 범위를 확인할 수 있습니다.

직접 문제: stock을 NULL 허용으로 바꾸고 같은 CHECK만 남기면 NULL은 거절되나요?

힌트와 풀이

NULL 비교의 UNKNOWN을 떠올리세요. CHECK는 UNKNOWN도 통과하므로 필수 재고 값에는 NOT NULL을 함께 유지해야 합니다. 숫자 범위와 값 존재는 다른 규칙입니다.


연습 문제

정상 게시글, 빈 본문 게시글, 게시 시각 없는 PUBLISHED 게시글을 차례로 삽입하세요.

정상 행만 남고 두 오류의 제약 조건 이름이 예상과 같은지 기록하세요.

두 검사를 따로 적용해 보기

다른 필수 값은 모두 정상이고 created_at=09:00, updated_at=10:00인 같은 날의 행을 가정합니다. ① HIDDEN·게시 시각 NULL, ② HIDDEN·게시 시각 08:00, ③ PUBLISHED·게시 시각 NULL, ④ DRAFT·게시 시각 10:00을 종이에 판정하세요. publication_pair와 time_order의 결과를 따로 쓰고 둘 다 통과한 경우만 저장 가능하다고 결론 냅니다.

해설과 예시 답안

첫 행은 모든 범위와 상태 조합을 만족합니다.

조건 변경 판정은 ① 허용, ② time_order 거절, ③·④ publication_pair 거절입니다. HIDDEN은 게시 시각의 유무를 모두 허용하지만, 시각이 있다면 생성 시각보다 앞설 수 없습니다. 하나의 CHECK가 허용한다고 행 전체가 허용되는 것은 아닙니다.

둘째는 본문 길이, 셋째는 publication_pair가 거절합니다.

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
);

첫 INSERT만 성공하고 오류가 각각 ck_posts_content, ck_posts_publication_pair를 가리키는지 확인합니다.


핵심 정리

  • DB 타입 범위와 업무 도메인 범위는 다르므로 검사로 간격을 메웁니다.
  • NULL은 부재 의미이며 0·빈 문자열과 자동으로 같지 않습니다.
  • 겹치는 열의 상태 조합을 이름 있는 제약 조건으로 강제합니다.
  • 기존 데이터 진단과 호환 배포를 제약 추가의 일부로 봅니다.

다음 장에서는 이 스키마를 안전하게 변경하고 실제 회원·게시글 데이터를 INSERT·UPDATE·DELETE합니다.