CHECK Constraint와 애플리케이션 검증의 역할

CHECK Constraint와 애플리케이션 검증의 역할

한눈에 보기

애플리케이션 검증은 사용자에게 빠르고 구체적인 피드백을 제공하고, CHECK Constraint는 어떤 쓰기 경로에서도 행이 허용되지 않은 상태로 저장되는 것을 막는다. 같은 조건을 두 계층에 적는 것은 불필요한 중복이 아니라 역할이 다른 이중 방어다. 다만 CHECK는 주로 현재 행만으로 판정할 수 있는 불변식에 적합하며, NULL의 3값 논리와 DB 버전별 지원 차이를 반드시 확인해야 한다.

목차

문제가 되는 상황

쿠폰의 할인율은 0% 이상 100% 이하여야 한다고 하자. API 요청을 받는 애플리케이션에는 이미 검증 코드가 있다.

type CreateCouponInput = {
  code: string;
  discountRate: number;
};

function validateCoupon(input: CreateCouponInput) {
  if (input.discountRate < 0 || input.discountRate > 100) {
    throw new ValidationError(
      "할인율은 0 이상 100 이하로 입력해 주세요.",
    );
  }
}

이 API만 존재한다면 충분해 보인다. 그러나 시간이 지나면 같은 테이블을 수정하는 경로가 늘어난다.

그중 하나라도 검증을 빠뜨리면 discount_rate = 150인 쿠폰이 저장될 수 있다. 읽기 코드는 “DB에는 올바른 데이터만 있다”는 가정을 더 이상 할 수 없고, 잘못된 할인가가 결제 계산까지 전파될 수 있다.

애플리케이션 검증만으로 부족한 이유

애플리케이션 검증은 특정 코드 경로에 묶여 있다. 모든 쓰기가 언제나 같은 함수와 같은 버전의 코드를 통과한다는 보장은 없다.

flowchart TB
    A[Public API 검증] --> DB[(coupons)]
    B[Admin API] --> DB
    C[Batch Job] --> DB
    D[Migration Script] --> DB
    E[Manual SQL] --> DB

Public API에만 검증이 있다면 나머지 화살표는 규칙을 우회한다. 반면 DB 제약은 테이블에 도달하는 모든 쓰기에 동일하게 적용된다.

서비스가 하나여도 쓰기 경로는 하나가 아니다

초기에는 단일 API뿐이더라도 데이터 복구, 테스트 도구, 백필 작업이 생긴다. “현재 코드에서는 절대 그런 값을 저장하지 않는다”는 설명보다 스키마가 거부하도록 만드는 편이 장기적으로 안전하다.

CHECK Constraint가 지키는 불변식

CHECK Constraint는 행이 저장될 때 조건식이 거짓인지를 검사한다.

CREATE TABLE coupons (
  id BIGINT PRIMARY KEY,
  code VARCHAR(50) NOT NULL,
  discount_rate DECIMAL(5, 2) NOT NULL,
  starts_at DATETIME(6) NOT NULL,
  ends_at DATETIME(6) NOT NULL,
  CONSTRAINT ck_coupons_discount_rate
    CHECK (discount_rate >= 0 AND discount_rate <= 100),
  CONSTRAINT ck_coupons_period
    CHECK (starts_at < ends_at)
);

이제 어떤 클라이언트가 다음 값을 저장하려 해도 DB가 거부한다.

INSERT INTO coupons (
  id, code, discount_rate, starts_at, ends_at
) VALUES (
  1,
  'WELCOME',
  150,
  '2026-09-01 00:00:00',
  '2026-09-30 23:59:59'
);

CHECK는 “항상 참이어야 하는 도메인 문장”을 스키마 가까이에 기록한다는 의미도 있다. 열 타입만 봐서는 알 수 없는 범위와 열 사이 관계가 제약 이름과 조건식에 남는다.

적합한 예는 다음과 같다.

CONSTRAINT ck_products_stock
  CHECK (stock >= 0)
CONSTRAINT ck_orders_amount
  CHECK (subtotal >= 0 AND discount_amount >= 0 AND total_amount >= 0)
CONSTRAINT ck_reservations_period
  CHECK (check_in_date < check_out_date)

검증이 중복되어도 필요한 이유

“DB가 검사한다면 애플리케이션 검증을 지워도 되지 않을까?”라는 질문이 생긴다. 저장만 막는 것이 목적이라면 가능하지만 사용자 경험과 유지보수 측면에서는 둘 다 두는 편이 낫다.

역할 애플리케이션 검증 CHECK Constraint
실행 시점 DB 쓰기 전 INSERT 또는 UPDATE 시점
적용 범위 해당 코드 경로 테이블에 쓰는 모든 경로
오류 메시지 필드별로 친절하게 구성 가능 DB 오류를 변환해야 함
외부 조회 API, 캐시, 다른 서비스 조회 가능 일반적으로 제한적
동시성 안전성 구현 방식에 따라 달라짐 저장되는 각 행에 강제
규칙 문서화 위치 코드와 API 스키마 데이터 스키마

애플리케이션은 요청을 받자마자 여러 오류를 한 번에 알려 줄 수 있다.

{
  "code": "INVALID_COUPON",
  "fields": {
    "discountRate": "0 이상 100 이하로 입력해 주세요.",
    "endsAt": "시작 시각보다 뒤여야 합니다."
  }
}

DB 제약은 이를 우회하거나 동시성 사이에서 규칙이 깨지는 마지막 순간을 막는다. 애플리케이션 검증은 좋은 피드백을, DB 제약은 절대 저장되면 안 되는 상태에 대한 최종 보장을 담당한다.

CHECK와 NULL의 3값 논리

CHECK를 작성할 때 가장 놓치기 쉬운 부분은 NULL이다. SQL 조건식에는 TRUE, FALSE, UNKNOWN 세 결과가 있고 CHECK는 보통 조건이 FALSE일 때 위반으로 판단한다. UNKNOWN은 통과할 수 있다.

CREATE TABLE products (
  id BIGINT PRIMARY KEY,
  price DECIMAL(12, 2) NULL,
  CONSTRAINT ck_products_price CHECK (price > 0)
);

price = NULL이면 NULL > 0의 결과는 UNKNOWN이다. 이 제약만으로는 NULL 저장을 막지 못한다. 가격이 반드시 있어야 한다면 NOT NULL을 함께 선언해야 한다.

price DECIMAL(12, 2) NOT NULL,
CONSTRAINT ck_products_price CHECK (price > 0)

값이 없을 수 있지만 값이 있을 때만 양수여야 한다면 nullable과 CHECK의 조합이 오히려 의도에 맞다.

cancelled_fee DECIMAL(12, 2) NULL,
CONSTRAINT ck_orders_cancelled_fee
  CHECK (cancelled_fee >= 0)

명시적으로 쓰면 의도가 더 잘 보일 수도 있다.

CONSTRAINT ck_orders_cancelled_fee
  CHECK (cancelled_fee IS NULL OR cancelled_fee >= 0)

NULL의 비교 의미는 NULL은 값이 아니라 알 수 없음이다와 함께 이해하는 것이 좋다.

여러 열 사이의 관계를 표현하기

CHECK의 장점은 한 열의 범위뿐 아니라 같은 행에 있는 여러 열의 관계를 표현할 수 있다는 점이다.

기간의 순서

CONSTRAINT ck_campaigns_period
  CHECK (starts_at < ends_at)

상태와 완료 시각의 일관성

CONSTRAINT ck_jobs_completion
  CHECK (
    (status = 'COMPLETED' AND completed_at IS NOT NULL)
    OR
    (status <> 'COMPLETED' AND completed_at IS NULL)
  )

최소값과 최대값

CONSTRAINT ck_filters_price_range
  CHECK (
    min_price IS NULL
    OR max_price IS NULL
    OR min_price <= max_price
  )

조건이 길어질수록 제약 이름과 코드 주석, 별도 설계 문서가 중요해진다. 너무 많은 상태 전이를 하나의 거대한 논리식으로 넣으면 마이그레이션과 오류 해석이 어려워질 수 있다. 서로 독립적인 규칙은 제약을 나눠 어떤 조건이 실패했는지 드러내는 편이 좋다.

CONSTRAINT ck_orders_non_negative_subtotal CHECK (subtotal >= 0),
CONSTRAINT ck_orders_non_negative_discount CHECK (discount_amount >= 0),
CONSTRAINT ck_orders_discount_limit CHECK (discount_amount <= subtotal)

다른 행과 외부 상태가 필요한 규칙은 어렵다

CHECK는 일반적으로 현재 행의 값만으로 판정할 수 있는 규칙에 적합하다. 다음과 같은 조건은 단순 CHECK로 해결하기 어렵거나 DB가 서브쿼리를 허용하지 않을 수 있다.

외래 키 존재 여부는 외래 키가 데이터 정합성을 지키는 방식처럼 FOREIGN KEY가 담당한다. 여러 행의 합계와 동시성 규칙은 조건부 UPDATE, 잠금, 트랜잭션 격리, 데이터 모델 변경을 함께 검토해야 한다.

UPDATE products
SET stock = stock - :quantity
WHERE id = :product_id
  AND stock >= :quantity;

이 예시는 CHECK로 “예약의 합이 재고 이하”를 뒤늦게 검사하는 대신, 재고 열 자체가 음수가 되지 않는 조건으로 변경을 원자화한다.

복잡한 규칙을 모두 DB 트리거로 옮기는 것도 신중해야 한다

트리거는 강한 보장을 제공할 수 있지만 실행 흐름이 애플리케이션 코드에서 보이지 않고 배포·디버깅 난도가 높아진다. 규칙의 중요도와 쓰기 경로, 팀의 운영 역량을 함께 고려한다.

제약 위반을 도메인 오류로 변환한다

CHECK 위반을 그대로 500 오류로 내보내면 사용자는 입력을 고칠 방법을 알 수 없다. 제약 이름을 안정적인 애플리케이션 오류로 매핑한다.

const constraintErrorMap = {
  ck_coupons_discount_rate: {
    code: "INVALID_DISCOUNT_RATE",
    message: "할인율은 0 이상 100 이하이어야 합니다.",
  },
  ck_coupons_period: {
    code: "INVALID_COUPON_PERIOD",
    message: "쿠폰 종료 시각은 시작 시각보다 뒤여야 합니다.",
  },
} as const;

function translateDatabaseError(error: unknown) {
  const constraint = getConstraintName(error);
  const mapped = constraintErrorMap[constraint];

  if (mapped) {
    return new DomainValidationError(mapped.code, mapped.message);
  }

  return error;
}

실제 드라이버가 제약 이름을 어떤 필드로 제공하는지 확인해야 한다. 원본 SQL과 입력값 전체를 사용자 응답에 포함해서는 안 되고, 서버 로그에도 개인정보가 노출되지 않도록 한다.

애플리케이션 사전 검증을 통과했는데 DB CHECK가 실패했다면 단순 사용자 오류가 아닐 수도 있다. 다음 가능성을 관측해야 한다.

따라서 제약 위반 횟수를 constraint 이름별로 집계하면 데이터 품질과 배포 이상을 조기에 발견할 수 있다.

기존 테이블에 CHECK를 안전하게 추가하기

운영 테이블에는 이미 규칙을 위반한 데이터가 있을 수 있다. 먼저 위반 행의 수와 분포를 조회한다.

SELECT id, discount_rate
FROM coupons
WHERE discount_rate < 0
   OR discount_rate > 100;

기간 조건도 별도로 확인한다.

SELECT id, starts_at, ends_at
FROM coupons
WHERE starts_at >= ends_at;

적용 순서는 다음처럼 잡을 수 있다.

  1. 새 규칙을 애플리케이션 검증에 먼저 반영한다.
  2. 위반 데이터를 탐색하고 원인을 분류한다.
  3. 자동 수정, 수동 검토, 격리 중 처리 정책을 정한다.
  4. 데이터를 정리한 뒤 같은 탐색 쿼리로 0건인지 확인한다.
  5. DB 버전과 DDL 영향을 검토해 CHECK를 추가한다.
  6. 제약 위반 오류 매핑과 관측 지표를 활성화한다.
ALTER TABLE coupons
ADD CONSTRAINT ck_coupons_discount_rate
CHECK (discount_rate >= 0 AND discount_rate <= 100);
“문법이 허용된다”와 “실제로 강제된다”는 다를 수 있다

오래된 DB 버전은 CHECK 구문을 파싱하지만 강제하지 않았던 사례가 있다. 사용 중인 정확한 제품과 버전의 공식 문서를 확인하고, 위반 INSERT를 직접 실행하는 통합 테스트로 검증한다.

CHECK 대신 다른 제약이 더 적합한 경우

모든 데이터 규칙을 CHECK로 표현할 필요는 없다.

규칙 우선 검토할 도구
값이 반드시 존재해야 함 NOT NULL
다른 행과 열 조합이 중복되면 안 됨 UNIQUE
참조 대상이 반드시 존재해야 함 FOREIGN KEY
정해진 타입과 범위의 단일 행 조건 CHECK, 적절한 데이터 타입
동시 요청을 포함한 수량 감소 조건부 UPDATE, 잠금, 트랜잭션
다른 시스템의 현재 상태 확인 애플리케이션 서비스 로직

예를 들어 상태 문자열 목록을 CHECK로 제한할 수는 있다.

CONSTRAINT ck_orders_status
  CHECK (status IN ('PENDING', 'PAID', 'CANCELLED'))

하지만 상태가 자주 추가되고 여러 테이블에서 공유된다면 enum 타입, 참조 테이블, 배포 호환성까지 비교해야 한다. CHECK 하나로 데이터 모델의 모든 선택을 대신할 수는 없다.

테스트에서 확인할 내용

애플리케이션 단위 테스트만 통과해서는 DB가 제약을 실제로 강제하는지 알 수 없다. 실제 DB를 사용하는 통합 테스트를 추가한다.

it("100을 초과하는 할인율은 DB가 거부한다", async () => {
  await expect(
    rawDatabase.execute(
      `INSERT INTO coupons
       (id, code, discount_rate, starts_at, ends_at)
       VALUES (?, ?, ?, ?, ?)`,
      [1, "INVALID", 150, "2026-09-01", "2026-09-30"],
    ),
  ).rejects.toMatchObject({ constraint: "ck_coupons_discount_rate" });
});

검증할 경계값은 -1, 0, 100, 101, NULL처럼 조건의 양쪽과 NULL을 포함한다. 시간 조건은 동일 시각, 역전된 시각, timezone 변환 후 값도 확인한다. 마이그레이션 테스트에서는 제약 생성 전 위반 데이터가 있을 때 배포가 어떻게 실패하는지도 재현하면 운영 절차를 점검할 수 있다.

결론

CHECK Constraint와 애플리케이션 검증은 같은 규칙을 다루더라도 실패를 막는 위치와 책임이 다르다. 애플리케이션은 사용자가 이해할 수 있는 빠른 피드백과 외부 상태를 포함한 복잡한 검증을 담당한다. DB는 배치와 수동 SQL을 포함한 모든 쓰기 경로에서 절대 저장되면 안 되는 행 상태를 거부한다.

CHECK에는 현재 행의 범위와 열 사이 관계처럼 변하지 않는 불변식을 두는 것이 좋다. NOT NULL, UNIQUE, FOREIGN KEY가 더 직접적인 규칙이라면 해당 제약을 우선 사용한다. NULL의 UNKNOWN 처리와 DB 버전별 강제 여부를 확인하고, 운영 테이블에는 기존 위반 데이터를 정리한 뒤 단계적으로 적용해야 한다. 두 계층의 검증을 경쟁 관계가 아니라 사용자 경험과 데이터 무결성을 나눠 맡는 구조로 보는 것이 핵심이다.

관련 노트