제약 조건
CHECK · UNIQUE · DEFAULT, 그리고 필터 인덱스로 만드는 조건부 고유입니다. 제약마다 NULL 을 다르게 다룹니다.
규칙을 데이터베이스에 적어 둡니다
"첨부 크기는 0보다 커야 한다", "아이디는 겹치면 안 된다", "적지 않으면 0으로 둔다" 같은 규칙은 응용 프로그램에도 적을 수 있습니다. 그런데 자료를 넣는 길은 하나가 아닙니다. 관리자 화면, 배치 작업, 사람이 직접 실행하는 SQL 이 모두 같은 표를 건드립니다.
제약 조건은 그 규칙을 표 자체에 붙이는 것입니다. 어느 길로 들어와도 검사를 받습니다. 실습 데이터베이스에도 몇 개가 걸려 있습니다.
SELECT OBJECT_NAME(parent_object_id) AS 표, name AS 제약, definition AS 정의 FROM sys.check_constraints; SELECT OBJECT_NAME(dc.parent_object_id) AS 표, c.name AS 열, dc.name AS 제약, dc.definition AS 정의 FROM sys.default_constraints dc JOIN sys.columns c ON c.object_id = dc.parent_object_id AND c.column_id = dc.parent_column_id;
| 표 | 제약 | 정의 |
|---|---|---|
| FILES | CK_FILES_size | ([size_bytes]>(0)) |
| POSTS | CK_POSTS_depth | ([depth]>=(0)) |
| 표 | 열 | 제약 | 정의 |
|---|---|---|---|
| BOARDS | use_category | DF_BOARDS_use_category | ((0)) |
| BOARDS | use_reply | DF_BOARDS_use_reply | ((1)) |
| POSTS | depth | DF_POSTS_depth | ((0)) |
| POSTS | hit_count | DF_POSTS_hit_count | ((0)) |
| POSTS | sort_no | DF_POSTS_sort_no | ((0)) |
제약을 어기면 오류가 납니다. 번호가 제약마다 다릅니다. 이 번호를 외워 두면 로그만 보고도 무엇이 막혔는지 알 수 있습니다.
| 번호 | 무엇 | 메시지의 첫머리 |
|---|---|---|
| 515 | NOT NULL | Cannot insert the value NULL into column … |
| 547 | CHECK · FOREIGN KEY | … conflicted with the CHECK constraint … |
| 2627 | PRIMARY KEY · UNIQUE 제약 | Violation of UNIQUE KEY constraint … |
| 2601 | UNIQUE 인덱스 | Cannot insert duplicate key row … with unique index … |
2627 과 2601 이 나뉘는 것은 제약으로 만들었는지 인덱스로 만들었는지의 차이입니다. 뒤에서 그 차이가 왜 생기는지 봅니다.
값의 범위를 정합니다
CK_FILES_size 는 첨부 크기가 0보다 커야 한다고 적혀
있습니다. 0을 넣어 봅니다.
INSERT INTO Board.FILES (post_num, origin_name, size_bytes, reg_date) VALUES (1, N'빈파일.txt', 0, '2026-01-01');
NULL 은 통과합니다
여기가 가장 자주 틀리는 자리입니다. CHECK 는
조건이 거짓일 때만 막습니다.
1.9 에서 본 대로 NULL 이 섞인 비교는
거짓이 아니라 UNKNOWN 이고,
CHECK 는 그것을 통과시킵니다.
CREATE TABLE Board.T_CK ( num int IDENTITY(1,1) PRIMARY KEY, score int NULL, CONSTRAINT CK_TCK_score CHECK (score > 0) ); INSERT INTO Board.T_CK (score) VALUES (10); -- 들어갑니다 INSERT INTO Board.T_CK (score) VALUES (NULL); -- 들어갑니다 (!) INSERT INTO Board.T_CK (score) VALUES (0); -- 막힙니다
| num | score |
|---|---|
| 1 | 10 |
| 2 | NULL |
score > 0 이라고 적었지만
NULL 은 들어갔습니다. 값을 반드시 받아야 한다면
NOT NULL 을 함께 적으십시오. 조건 안에서
막고 싶다면 CHECK (score IS NOT NULL AND score > 0)
처럼 명시해야 합니다.
두 열을 함께 보는 CHECK
열 하나가 아니라 행 전체를 보는 조건도 적을 수 있습니다. 기간을 담는 표라면 끝이 시작보다 앞설 수 없습니다.
CREATE TABLE Board.T_RANGE ( num int IDENTITY(1,1) PRIMARY KEY, start_date date NOT NULL, end_date date NULL, CONSTRAINT CK_TRANGE_period CHECK (end_date IS NULL OR end_date >= start_date) ); INSERT INTO Board.T_RANGE VALUES ('2026-01-01', '2026-01-31'); -- 들어갑니다 INSERT INTO Board.T_RANGE VALUES ('2026-02-01', NULL); -- 아직 안 끝난 기간 INSERT INTO Board.T_RANGE VALUES ('2026-03-01', '2026-02-01'); -- 막힙니다
end_date IS NULL OR 를 앞에 둔 것은 앞에서 본 성질을
일부러 적어 둔 것입니다. 없어도 NULL 은
통과하지만, 적어 두면 읽는 사람이 "끝나지 않은 기간을 허용한다" 는 뜻을
알 수 있습니다.
CHECK 는 같은 행 안의 값만 볼 수 있습니다.
다른 표를 조회하거나 GETDATE() 처럼 실행할 때마다
달라지는 것을 넣지 마십시오. 넣으면 이미 들어 있던 행이 나중에 규칙을 어기게
되고, 그때부터 그 행은 고칠 수 없게 됩니다.
겹치지 않게 합니다
3.2 에서 본 대로 실습 데이터베이스는 기본 키를 대리 키로 두고
자연 키는 유일 제약으로 지킵니다.
UX_USERS_user_id 가 그것입니다.
INSERT INTO Member.USERS (user_id, nickname, email, reg_date) VALUES ('hong', N'또다른홍길동', NULL, '2026-01-01');
NULL 은 하나만 허용합니다
CHECK 와 반대입니다. SQL Server 의
UNIQUE 는 NULL 을 값 하나로
여깁니다. 그래서 두 번째 NULL 은 중복으로 막힙니다.
CREATE TABLE Board.T_UQ ( num int IDENTITY(1,1) PRIMARY KEY, email varchar(100) NULL, CONSTRAINT UQ_TUQ_email UNIQUE (email) ); INSERT INTO Board.T_UQ (email) VALUES ('a@x.com'); -- 들어갑니다 INSERT INTO Board.T_UQ (email) VALUES (NULL); -- 들어갑니다 INSERT INTO Board.T_UQ (email) VALUES (NULL); -- 막힙니다 (!)
이것은 SQL 표준과 다릅니다. 표준은
NULL 을 서로 다른 것으로 보아 몇 개든 허용합니다.
다른 데이터베이스에서 옮겨 오면 여기서 걸립니다. 실습 데이터베이스의
USERS.email 은 넷이 비어 있는데, 여기에 유일 제약을
걸면 넷 가운데 하나만 남게 됩니다.
조건을 붙인 고유
앞의 문제를 푸는 방법이 필터 인덱스입니다. 유일 제약 대신
조건이 붙은 유일 인덱스를 만듭니다.
WHERE 를 만족하는 행만 인덱스에 들어가므로
NULL 은 애초에 검사 대상이 아닙니다.
CREATE TABLE Board.T_FI ( num int IDENTITY(1,1) PRIMARY KEY, email varchar(100) NULL ); CREATE UNIQUE NONCLUSTERED INDEX UX_TFI_email ON Board.T_FI(email) WHERE email IS NOT NULL; INSERT INTO Board.T_FI (email) VALUES ('a@x.com'), (NULL), (NULL), (NULL); INSERT INTO Board.T_FI (email) VALUES ('a@x.com'); -- 막힙니다
| 넣은행 | NULL행 |
|---|---|
| 4 | 3 |
더 쓸모 있는 쪽 — 게시판마다 대표 글 하나
필터 인덱스가 진짜 필요한 자리는 따로 있습니다. "게시판마다 대표 글은 하나"
같은 규칙은 UNIQUE 제약으로는 만들 수
없습니다. 대표가 아닌 글은 게시판마다 여러 개여야 하기 때문입니다.
CREATE TABLE Board.T_MAIN ( num int IDENTITY(1,1) PRIMARY KEY, board_num int NOT NULL, title nvarchar(50) NOT NULL, is_main bit NOT NULL DEFAULT 0 ); -- 대표인 행만 인덱스에 담습니다. CREATE UNIQUE NONCLUSTERED INDEX UX_TMAIN_main ON Board.T_MAIN(board_num) WHERE is_main = 1; INSERT INTO Board.T_MAIN (board_num, title, is_main) VALUES (1, N'공지 대표', 1), (1, N'일반1', 0), (1, N'일반2', 0), (2, N'자유 대표', 1), (2, N'일반3', 0); -- 1번 게시판에 대표를 하나 더 넣어 봅니다. INSERT INTO Board.T_MAIN (board_num, title, is_main) VALUES (1, N'공지 대표2', 1);
| board_num | 글수 | 대표수 |
|---|---|---|
| 1 | 5 | 1 |
| 2 | 2 | 1 |
인덱스가 규칙을 지키고 있습니다. 응용 프로그램이 "기존 대표를 내리고 새 대표를 올린다" 를 잘못 짜도 두 개가 될 수 없습니다. 그리고 인덱스가 작습니다. 대표인 행만 담기 때문입니다.
적지 않았을 때만 채웁니다
DEFAULT 는 열을 아예 적지 않았을 때
값을 채웁니다. NULL 을 명시하면 채우지 않습니다.
자주 혼동하는 자리입니다.
CREATE TABLE Board.T_DF ( num int IDENTITY(1,1) PRIMARY KEY, memo nvarchar(20) NULL CONSTRAINT DF_TDF_memo DEFAULT N'기본값', cnt int NOT NULL CONSTRAINT DF_TDF_cnt DEFAULT 0 ); INSERT INTO Board.T_DF (cnt) VALUES (5); -- memo 를 적지 않음 INSERT INTO Board.T_DF (memo, cnt) VALUES (NULL, 6); -- NULL 을 명시 INSERT INTO Board.T_DF (memo, cnt) VALUES (DEFAULT, 7); -- DEFAULT 를 명시 INSERT INTO Board.T_DF DEFAULT VALUES; -- 전부 기본값
| num | memo | cnt |
|---|---|---|
| 1 | 기본값 | 5 |
| 2 | NULL | 6 |
| 3 | 기본값 | 7 |
| 4 | 기본값 | 0 |
DEFAULT 는 이미 들어 있는 행을 고치지
않습니다. 나중에 열을 추가하면서 기본값을 주면 그때 기존 행에도 값이
채워지지만, 기존 열에 기본값을 붙이는 것은 앞으로 들어올 행에만 적용됩니다.
이름을 짓고, 번호를 믿지 않습니다
이름을 짓지 않으면
제약에 이름을 주지 않으면 SQL Server 가 지어 줍니다. 그런데 뒤에 붙는 것이 임의의 값입니다.
CREATE TABLE Board.T_NM ( num int NOT NULL PRIMARY KEY, cnt int NOT NULL DEFAULT 0, CHECK (cnt >= 0) ); SELECT name AS 자동이름, type_desc AS 종류 FROM sys.objects WHERE parent_object_id = OBJECT_ID('Board.T_NM') AND type IN ('D', 'C', 'PK');
| 자동이름 | 종류 |
|---|---|
| CK__T_NM__cnt__7FB5F314 | CHECK_CONSTRAINT |
| DF__T_NM__cnt__7EC1CEDB | DEFAULT_CONSTRAINT |
| PK__T_NM__DF908D65EBF31AA7 | PRIMARY_KEY_CONSTRAINT |
이름이 다르면 DROP CONSTRAINT 를 적을 수
없습니다. 배포 스크립트에서 제약을 잠시 떼었다 붙이는 일이 흔한데
그때 막힙니다. 실습 데이터베이스처럼
CK_FILES_size ·
DF_POSTS_hit_count 같은 이름을 직접 지으십시오.
막혀도 번호는 소비됩니다
3.2 에서 되돌린 트랜잭션이 IDENTITY 번호를 소비한다고
했습니다. 제약에 막혀 실패한 INSERT 도
마찬가지입니다. 위의 예제들을 실행하면 실습 데이터베이스의 시드가
올라갑니다. 행은 늘지 않았는데 다음 번호는 건너뜁니다.
되돌리려면 DBCC CHECKIDENT ('표', RESEED, 값) 을
사용합니다. 다만 운영 중인 표에서는 하지 마십시오. 이미 나간
번호와 부딪힙니다. 실습 데이터베이스를 처음 상태로 되돌리고 싶다면
lab-setup.sql 을 다시 실행하는 편이 안전합니다.
직접 해보기
Board.POSTS 의
hit_count 가 0보다 작아지지 않도록 하는
CHECK 제약을 적어 보세요. 이름 규칙은 실습
데이터베이스의 다른 제약을 따릅니다.
Member.USERS 의
email 은 넷이 비어 있습니다. 값이 있는 회원끼리는
겹치지 않게 하되 빈 값은 그대로 두는 방법을 적어 보세요.
이 단원의 T_ 로 시작하는 표들은 설명을 위한 것이므로
만들었다면 지우십시오.
DROP TABLE Board.T_CK, Board.T_UQ, Board.T_FI, Board.T_MAIN, Board.T_RANGE, Board.T_DF, Board.T_NM;
- 번호로 무엇이 막혔는지 압니다. 515(NOT NULL) · 547(CHECK · FOREIGN KEY) · 2627(제약) · 2601(인덱스).
CHECK는 NULL 을 통과시킵니다. 거짓일 때만 막습니다. 값을 받아야 하면NOT NULL을 함께 적습니다.CHECK는 같은 행 안의 값만 봅니다. 다른 표나 현재 시각을 넣지 않습니다.UNIQUE는 NULL 을 하나만 허용합니다. SQL 표준과 다릅니다.- 조건부 고유는 필터 인덱스로 만듭니다. 게시판마다 대표 글 하나 같은 규칙이 여기에 해당합니다.
DEFAULT는 열을 적지 않았을 때만 채웁니다. NULL 을 명시하면 NULL 이 들어갑니다.- 제약에는 이름을 직접 지으십시오. 자동 이름은 서버마다 달라 배포 스크립트에서 막힙니다.
- 제약에 막혀 실패한
INSERT도 번호는 소비합니다.