MSSQL 3.4 · 3부. 설계

제약 조건

CHECK · UNIQUE · DEFAULT, 그리고 필터 인덱스로 만드는 조건부 고유입니다. 제약마다 NULL 을 다르게 다룹니다.

예상 학습 시간 18분 난이도 중급
개념 설명

규칙을 데이터베이스에 적어 둡니다

"첨부 크기는 0보다 커야 한다", "아이디는 겹치면 안 된다", "적지 않으면 0으로 둔다" 같은 규칙은 응용 프로그램에도 적을 수 있습니다. 그런데 자료를 넣는 길은 하나가 아닙니다. 관리자 화면, 배치 작업, 사람이 직접 실행하는 SQL 이 모두 같은 표를 건드립니다.

제약 조건은 그 규칙을 표 자체에 붙이는 것입니다. 어느 길로 들어와도 검사를 받습니다. 실습 데이터베이스에도 몇 개가 걸려 있습니다.

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;
결과 — CHECK
제약정의
FILESCK_FILES_size([size_bytes]>(0))
POSTSCK_POSTS_depth([depth]>=(0))
결과 — DEFAULT
제약정의
BOARDSuse_categoryDF_BOARDS_use_category((0))
BOARDSuse_replyDF_BOARDS_use_reply((1))
POSTSdepthDF_POSTS_depth((0))
POSTShit_countDF_POSTS_hit_count((0))
POSTSsort_noDF_POSTS_sort_no((0))

제약을 어기면 오류가 납니다. 번호가 제약마다 다릅니다. 이 번호를 외워 두면 로그만 보고도 무엇이 막혔는지 알 수 있습니다.

번호무엇메시지의 첫머리
515NOT NULLCannot insert the value NULL into column …
547CHECK · FOREIGN KEY… conflicted with the CHECK constraint …
2627PRIMARY KEY · UNIQUE 제약Violation of UNIQUE KEY constraint …
2601UNIQUE 인덱스Cannot insert duplicate key row … with unique index …

2627 과 2601 이 나뉘는 것은 제약으로 만들었는지 인덱스로 만들었는지의 차이입니다. 뒤에서 그 차이가 왜 생기는지 봅니다.

CHECK

값의 범위를 정합니다

CK_FILES_size 는 첨부 크기가 0보다 커야 한다고 적혀 있습니다. 0을 넣어 봅니다.

SQL
INSERT INTO Board.FILES (post_num, origin_name, size_bytes, reg_date)
VALUES (1, N'빈파일.txt', 0, '2026-01-01');
오류
메시지 547 The INSERT statement conflicted with the CHECK constraint "CK_FILES_size". The conflict occurred in database "MssqlLab", table "Board.FILES", column 'size_bytes'.

NULL 은 통과합니다

여기가 가장 자주 틀리는 자리입니다. CHECK 는 조건이 거짓일 때만 막습니다. 1.9 에서 본 대로 NULL 이 섞인 비교는 거짓이 아니라 UNKNOWN 이고, CHECK 는 그것을 통과시킵니다.

SQL
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);      -- 막힙니다
결과 — 두 번째까지 넣은 뒤
numscore
110
2NULL
오류 — 세 번째
메시지 547 The INSERT statement conflicted with the CHECK constraint "CK_TCK_score".

score > 0 이라고 적었지만 NULL 은 들어갔습니다. 값을 반드시 받아야 한다면 NOT NULL 을 함께 적으십시오. 조건 안에서 막고 싶다면 CHECK (score IS NOT NULL AND score > 0) 처럼 명시해야 합니다.

두 열을 함께 보는 CHECK

열 하나가 아니라 행 전체를 보는 조건도 적을 수 있습니다. 기간을 담는 표라면 끝이 시작보다 앞설 수 없습니다.

SQL
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');  -- 막힙니다
오류 — 세 번째
메시지 547 The INSERT statement conflicted with the CHECK constraint "CK_TRANGE_period". The conflict occurred in database "MssqlLab", table "Board.T_RANGE".
앞의 오류와 달리 column 이 적혀 있지 않습니다. 열 하나가 아니라 행을 보는 조건이기 때문입니다.

end_date IS NULL OR 를 앞에 둔 것은 앞에서 본 성질을 일부러 적어 둔 것입니다. 없어도 NULL 은 통과하지만, 적어 두면 읽는 사람이 "끝나지 않은 기간을 허용한다" 는 뜻을 알 수 있습니다.

CHECK같은 행 안의 값만 볼 수 있습니다. 다른 표를 조회하거나 GETDATE() 처럼 실행할 때마다 달라지는 것을 넣지 마십시오. 넣으면 이미 들어 있던 행이 나중에 규칙을 어기게 되고, 그때부터 그 행은 고칠 수 없게 됩니다.

UNIQUE

겹치지 않게 합니다

3.2 에서 본 대로 실습 데이터베이스는 기본 키를 대리 키로 두고 자연 키는 유일 제약으로 지킵니다. UX_USERS_user_id 가 그것입니다.

SQL
INSERT INTO Member.USERS (user_id, nickname, email, reg_date)
VALUES ('hong', N'또다른홍길동', NULL, '2026-01-01');
오류
메시지 2627 Violation of UNIQUE KEY constraint 'UX_USERS_user_id'. Cannot insert duplicate key in object 'Member.USERS'. The duplicate key value is (hong).
어떤 값이 겹쳤는지까지 알려 줍니다.

NULL 은 하나만 허용합니다

CHECK 와 반대입니다. SQL Server 의 UNIQUENULL 을 값 하나로 여깁니다. 그래서 두 번째 NULL 은 중복으로 막힙니다.

SQL
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);       -- 막힙니다 (!)
오류 — 세 번째
메시지 2627 Violation of UNIQUE KEY constraint 'UQ_TUQ_email'. Cannot insert duplicate key in object 'Board.T_UQ'. The duplicate key value is (<NULL>).

이것은 SQL 표준과 다릅니다. 표준은 NULL 을 서로 다른 것으로 보아 몇 개든 허용합니다. 다른 데이터베이스에서 옮겨 오면 여기서 걸립니다. 실습 데이터베이스의 USERS.email 은 넷이 비어 있는데, 여기에 유일 제약을 걸면 넷 가운데 하나만 남게 됩니다.

필터 인덱스

조건을 붙인 고유

앞의 문제를 푸는 방법이 필터 인덱스입니다. 유일 제약 대신 조건이 붙은 유일 인덱스를 만듭니다. WHERE 를 만족하는 행만 인덱스에 들어가므로 NULL 은 애초에 검사 대상이 아닙니다.

SQL
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행
43
오류 — 값이 겹칠 때
메시지 2601 Cannot insert duplicate key row in object 'Board.T_FI' with unique index 'UX_TFI_email'. The duplicate key value is (a@x.com).
2627 이 아니라 2601 입니다. 제약이 아니라 인덱스가 막았기 때문입니다.

더 쓸모 있는 쪽 — 게시판마다 대표 글 하나

필터 인덱스가 진짜 필요한 자리는 따로 있습니다. "게시판마다 대표 글은 하나" 같은 규칙은 UNIQUE 제약으로는 만들 수 없습니다. 대표가 아닌 글은 게시판마다 여러 개여야 하기 때문입니다.

SQL
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);
오류
메시지 2601 Cannot insert duplicate key row in object 'Board.T_MAIN' with unique index 'UX_TMAIN_main'. The duplicate key value is (1).
대표가 아닌 글은 얼마든지 들어갑니다
board_num글수대표수
151
221

인덱스가 규칙을 지키고 있습니다. 응용 프로그램이 "기존 대표를 내리고 새 대표를 올린다" 를 잘못 짜도 두 개가 될 수 없습니다. 그리고 인덱스가 작습니다. 대표인 행만 담기 때문입니다.

DEFAULT

적지 않았을 때만 채웁니다

DEFAULT열을 아예 적지 않았을 때 값을 채웁니다. NULL 을 명시하면 채우지 않습니다. 자주 혼동하는 자리입니다.

SQL
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;                -- 전부 기본값
결과
nummemocnt
1기본값5
2NULL6
3기본값7
4기본값0
두 번째 행만 NULL 입니다. NULL 을 적는 것과 적지 않는 것은 다릅니다.

DEFAULT이미 들어 있는 행을 고치지 않습니다. 나중에 열을 추가하면서 기본값을 주면 그때 기존 행에도 값이 채워지지만, 기존 열에 기본값을 붙이는 것은 앞으로 들어올 행에만 적용됩니다.

두 가지 습관

이름을 짓고, 번호를 믿지 않습니다

이름을 짓지 않으면

제약에 이름을 주지 않으면 SQL Server 가 지어 줍니다. 그런데 뒤에 붙는 것이 임의의 값입니다.

SQL
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__7FB5F314CHECK_CONSTRAINT
DF__T_NM__cnt__7EC1CEDBDEFAULT_CONSTRAINT
PK__T_NM__DF908D65EBF31AA7PRIMARY_KEY_CONSTRAINT
이 이름은 만들 때마다 달라집니다. 개발 서버와 운영 서버가 서로 다른 이름을 갖게 됩니다.

이름이 다르면 DROP CONSTRAINT 를 적을 수 없습니다. 배포 스크립트에서 제약을 잠시 떼었다 붙이는 일이 흔한데 그때 막힙니다. 실습 데이터베이스처럼 CK_FILES_size · DF_POSTS_hit_count 같은 이름을 직접 지으십시오.

막혀도 번호는 소비됩니다

3.2 에서 되돌린 트랜잭션이 IDENTITY 번호를 소비한다고 했습니다. 제약에 막혀 실패한 INSERT 도 마찬가지입니다. 위의 예제들을 실행하면 실습 데이터베이스의 시드가 올라갑니다. 행은 늘지 않았는데 다음 번호는 건너뜁니다.

되돌리려면 DBCC CHECKIDENT ('표', RESEED, 값) 을 사용합니다. 다만 운영 중인 표에서는 하지 마십시오. 이미 나간 번호와 부딪힙니다. 실습 데이터베이스를 처음 상태로 되돌리고 싶다면 lab-setup.sql 을 다시 실행하는 편이 안전합니다.

연습

직접 해보기

1. 조회수는 음수가 될 수 없습니다 난이도 하

Board.POSTShit_count 가 0보다 작아지지 않도록 하는 CHECK 제약을 적어 보세요. 이름 규칙은 실습 데이터베이스의 다른 제약을 따릅니다.

ALTER TABLE Board.POSTS ADD CONSTRAINT CK_POSTS_hit_count CHECK (hit_count >= 0); -- 이미 507건이 들어 있으므로 붙일 때 전부 검사합니다. -- 어기는 행이 있으면 547 로 거부됩니다(3.3 의 WITH NOCHECK 를 떠올리십시오). -- hit_count 는 NOT NULL 이라 NULL 이 통과하는 문제는 없습니다. -- 확인한 뒤 되돌리려면 ALTER TABLE Board.POSTS DROP CONSTRAINT CK_POSTS_hit_count;
2. 전자 메일을 조건부로 고유하게 난이도 중

Member.USERSemail 은 넷이 비어 있습니다. 값이 있는 회원끼리는 겹치지 않게 하되 빈 값은 그대로 두는 방법을 적어 보세요.

UNIQUE 제약으로는 되지 않습니다. NULL 을 하나만 허용하기 때문입니다.
CREATE UNIQUE NONCLUSTERED INDEX UX_USERS_email ON Member.USERS(email) WHERE email IS NOT NULL; -- 값이 있는 16명은 서로 겹칠 수 없고, 비어 있는 4명은 그대로 남습니다. -- UNIQUE 제약으로 만들었다면 두 번째 NULL 에서 2627 이 났을 것입니다. -- 확인한 뒤 되돌리려면 DROP INDEX UX_USERS_email ON Member.USERS;

이 단원의 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(인덱스).
  • CHECKNULL 을 통과시킵니다. 거짓일 때만 막습니다. 값을 받아야 하면 NOT NULL 을 함께 적습니다.
  • CHECK같은 행 안의 값만 봅니다. 다른 표나 현재 시각을 넣지 않습니다.
  • UNIQUENULL 을 하나만 허용합니다. SQL 표준과 다릅니다.
  • 조건부 고유는 필터 인덱스로 만듭니다. 게시판마다 대표 글 하나 같은 규칙이 여기에 해당합니다.
  • DEFAULT열을 적지 않았을 때만 채웁니다. NULL 을 명시하면 NULL 이 들어갑니다.
  • 제약에는 이름을 직접 지으십시오. 자동 이름은 서버마다 달라 배포 스크립트에서 막힙니다.
  • 제약에 막혀 실패한 INSERT번호는 소비합니다.