Menu

SQLite CHECK 제약조건으로 데이터 무결성 지키기

SQLite의 CHECK 제약조건으로 컬럼 값에 규칙을 거는 방법을 정리했습니다. 단일 컬럼 검사부터 다중 컬럼 검사, 이름 붙이기, 자주 만나는 함정까지 한 번에 살펴봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 — 편집하고 실행하면 결과를 바로 볼 수 있습니다.

CHECK 제약조건은 모든 행이 지켜야 할 규칙

CHECK 제약조건은 테이블에 걸어두는 불리언 표현식입니다. SQLite는 INSERTUPDATE가 일어날 때마다 이 표현식을 평가하고, 결과가 거짓이면 해당 작업을 실패 처리합니다. "가격은 음수가 될 수 없다", "상태는 정해진 세 가지 값 중 하나여야 한다" 같은 비즈니스 규칙을 스키마 자체에 새겨 넣는 방법인 셈이죠.

첫 두 행은 정상적으로 들어갑니다. 세 번째 행에서는 CHECK constraint failed 에러가 발생하며 거부되죠. 테이블에는 아예 기록되지 않습니다. 이 제약조건은 모든 쓰기 경로에 동일하게 적용됩니다. 애플리케이션이든, 마이그레이션 스크립트든, CLI에서 직접 만지는 경우든 예외가 없습니다.

컬럼 단위 vs 테이블 단위 CHECK 제약조건

CHECK는 두 가지 위치에 작성할 수 있습니다. 컬럼 정의 바로 뒤에 붙이거나(컬럼 단위), 모든 컬럼을 정의한 뒤에 따로 적는(테이블 단위) 방식입니다. 동작은 똑같고, 어느 쪽이 더 자연스럽게 읽히느냐의 차이일 뿐입니다.

첫 번째 예약은 정상적으로 들어가지만, 두 번째는 실패합니다. 종료 시각이 시작 시각보다 빠르기 때문이죠. 단일 컬럼에 대한 규칙은 컬럼 레벨로 작성하는 게 읽기 좋고, 두 개 이상의 컬럼을 비교하는 규칙은 테이블 레벨로 두는 편이 자연스럽습니다.

값을 특정 목록으로 제한하기

CHECK 제약조건이 가장 자주 쓰이는 곳은, 컬럼 값을 미리 정해 둔 몇 가지 중 하나로만 강제하고 싶을 때입니다. SQLite에는 enum 타입이 따로 없기 때문에 CHECK ... IN (...) 패턴을 관용적으로 사용합니다:

세 번째 행은 실패합니다. 'pending'이 허용 목록에 없거든요. 만약 나중에 새로운 상태값을 추가해야 한다면 테이블을 다시 만들어야 하니(자세한 내용은 아래에서 다룹니다), 목록을 못 박아두기 전에 한 번쯤 고민해볼 필요가 있습니다. 다만 역할 이름이나 주문 상태처럼 어휘가 진짜로 고정되어 있는 경우라면, CHECK 제약조건이야말로 딱 맞는 선택입니다.

CHECK 제약조건에 이름 붙이기

기본적으로 제약조건은 익명입니다. 에러 메시지에는 "CHECK constraint failed"와 함께 표현식만 표시되는데, 테이블에 CHECK가 하나뿐일 때는 괜찮지만 다섯 개쯤 되면 어떤 게 걸렸는지 헷갈리기 시작합니다. 이럴 때 CONSTRAINT 키워드로 이름을 지정해주면 됩니다.

이제 오류 메시지에 제약조건 이름이 같이 찍히기 때문에, 어떤 규칙이 깨졌는지 바로 알 수 있습니다. 이름 짓는 데 글자 몇 개 더 쓰는 게 귀찮을 수 있지만, 운영 환경에서 문제가 한 번이라도 터지면 그 값어치를 합니다.

CHECK와 NULL: 흔히 빠지는 함정

CHECK 제약조건은 표현식이 true 이거나 NULL일 때 통과합니다. 명시적으로 false일 때만 실패하죠. 처음 들으면 좀 이상하게 느껴지지만, NULL과의 비교는 거의 모두 true나 false가 아니라 NULL로 평가된다는 점을 떠올리면 자연스럽게 이해됩니다.

NULL이 들어간 행은 문제없이 들어갑니다. NULL >= 0의 결과는 false가 아니라 NULL이라서 CHECK 제약조건이 실패하지 않거든요. 음수 NULL 값을 모두 막고 싶다면 NOT NULL과 CHECK를 함께 걸어주면 됩니다.

이제 insert 문은 CHECK까지 가기도 전에 NOT NULL 제약조건에서 막혀버립니다. 두 제약조건은 서로 역할을 나눠서 동작합니다. NOT NULL은 값이 아예 없는 경우를 막아주고, CHECK는 값의 형태(모양)를 검사합니다.

CHECK 안에서 자주 쓰는 SQLite 내장 함수

CHECK 표현식 안에서는 SQLite의 내장 함수 대부분을 그대로 사용할 수 있습니다. 그중에서도 실무에서 자주 등장하는 함수 몇 가지를 살펴보겠습니다.

세 가지 실패 케이스를 보면 이메일 형식이 어긋나거나, 사용자명이 너무 짧거나, 국가 코드가 소문자인 경우다. 단순한 패턴은 LIKE로 처리할 수 있고, length(), upper(), lower() 같은 함수나 산술 연산도 자유롭게 쓸 수 있다. 단, 표현식은 반드시 결정적(deterministic)이어야 한다는 점을 잊지 말자. CHECK 안에 random()이나 current_timestamp 같은 걸 넣으면 같은 규칙이 행마다 다르게 적용될 수 있는데, 이런 동작을 원하는 경우는 거의 없다.

CHECK vs 트리거, 언제 뭘 써야 할까

CHECK 제약조건과 트리거 모두 잘못된 데이터를 막을 수 있다 보니, 처음 배우는 분들은 둘 중에 뭘 골라야 할지 헷갈려한다. 기준은 간단하다.

  • CHECK: 규칙이 지금 쓰는 행 자체에만 의존할 때. "이 컬럼과 저 컬럼의 비교", "값이 특정 범위 안인지", "문자열이 특정 패턴과 맞는지" 같은 경우다.
  • 트리거: 규칙이 다른 행이나 다른 테이블을 참조해야 하거나, 단일 불리언 표현식으로 표현하기 어려운 복잡한 로직이 필요할 때. 보통 RAISE를 호출하는 BEFORE INSERT/UPDATE 트리거를 쓴다.

CHECK가 더 빠르고, 더 간단하고, 무엇보다 스키마에 그대로 드러난다. CREATE TABLE 문만 봐도 어떤 규칙이 걸려 있는지 누구나 한눈에 파악할 수 있다는 뜻이다. CHECK로 표현이 안 되는 경우에만 트리거로 넘어가자.

ALTER로는 CHECK 제약조건을 못 떼낸다

여기가 좀 까칠한 부분이다. SQLite에는 ALTER TABLE ... DROP CONSTRAINT 같은 문법이 아예 없다. CHECK를 제거하거나 수정하려면 테이블을 새로 만들어야 한다.

BEGIN;

CREATE TABLE products_new (
    id    INTEGER PRIMARY KEY,
    name  TEXT NOT NULL,
    price REAL NOT NULL CHECK (price >= 0 AND price <= 1000000)
);

INSERT INTO products_new SELECT * FROM products;
DROP TABLE products;
ALTER TABLE products_new RENAME TO products;

COMMIT;

전체 작업은 트랜잭션으로 감싸 두는 게 좋습니다. 중간에 실패하더라도 데이터베이스가 원래 상태로 남아 있으니까요. 만약 다른 테이블이 지금 재구축하려는 테이블을 외래 키로 참조하고 있다면 절차가 좀 더 길어집니다 — foreign_keys를 끄고, 재구축한 뒤, 다시 켜고, 무결성 검사까지 돌려야 하죠. 이 부분은 커리큘럼 후반의 마이그레이션 문서에서 자세히 다루겠습니다.

다음 주제: UNIQUE 제약조건

CHECK 제약조건은 한 행 안에서 값의 형태 를 검증합니다. 이어서 살펴볼 UNIQUE 제약조건은 행과 행 사이 의 관계를 검증해요. 즉, 특정 컬럼이나 컬럼 묶음에 대해 두 행이 같은 값을 갖지 않도록 보장해 주죠. 바로 다음 글에서 만나보겠습니다.

자주 묻는 질문

SQLite CHECK 제약조건이 뭔가요?

테이블에 붙이는 불리언 표현식이라고 보면 됩니다. 모든 행이 이 조건을 만족해야 하고, SQLite는 INSERTUPDATE가 일어날 때마다 식을 평가해서 결과가 false면 변경을 막아 버립니다. '가격은 0보다 커야 한다' 같은 규칙을 애플리케이션 코드 없이 강제할 수 있는 가장 간단한 방법이에요.

CHECK 제약조건에서 여러 컬럼을 같이 참조할 수 있나요?

가능합니다. 특정 컬럼에 붙이지 말고 컬럼 정의가 끝난 뒤 테이블 단위 제약조건으로 적어 주세요. 예를 들어 CHECK (start_date <= end_date) 처럼 쓰면 두 컬럼을 동시에 참조할 수 있습니다. 컬럼 단위에서도 다른 컬럼 참조가 기술적으로는 되지만, 두 개 이상 엮일 땐 테이블 단위로 적는 게 훨씬 읽기 좋습니다.

왜 CHECK 제약조건이 NULL 값에는 동작하지 않죠?

CHECK는 식이 true이거나 NULL이면 통과시키고, 명시적으로 false일 때만 실패시킵니다. 그래서 CHECK (age >= 0)은 NULL인 age를 허용해 버려요. NULL >= 0의 결과가 false가 아니라 NULL이기 때문이죠. NULL까지 막고 싶다면 NOT NULL을 함께 걸어 주면 됩니다.

SQLite에서 CHECK 제약조건을 삭제하거나 수정할 수 있나요?

직접은 안 됩니다. SQLite에는 ALTER TABLE ... DROP CONSTRAINT가 없어요. 방법은 두 가지인데, 하나는 PRAGMA writable_schemasqlite_schema를 직접 고치는 것(고급이고 위험합니다), 다른 하나는 테이블을 새로 만드는 겁니다. 원하는 제약조건으로 새 테이블을 만들고 → 데이터 복사 → 기존 테이블 삭제 → 이름 변경 순서로요. 이때 제약조건에 이름을 붙여 두면 재구성 스크립트가 훨씬 깔끔해집니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기