dev notes

JPA 쿠폰 정책의 nullable 필드 조합을 MySQL CHECK로 제한한 이유

2025-03-0716 min read
공유

운영 중 변경할 수 있는 쿠폰 정책#

신규 가입을 유도하는 웰컴 쿠폰과 특정 기간에 진행하는 이벤트 쿠폰을 상황에 맞춰 발행하고 싶어 하는 사장님의 요청이 들어오게 되었습니다. 프로모션이 바뀔 때마다 할인율과 유효 기간을 코드에서 수정하고 다시 배포하는 방식으로는 대응이 늦어질 수 있어, 쿠폰 정책을 운영 중에 변경할 수 있는 기능이 필요했습니다.

이 요구를 반영해 쿠폰의 유형과 할인율, 유효 기간을 CouponPolicy 엔티티로 분리했습니다. 가입 쿠폰인 WELCOME과 특정 기간에만 발급하는 EVENT는 공통 속성이 많아 같은 테이블에서 관리했지만, EVENT에는 발급 시작과 종료 시각이 모두 필요하고 WELCOME은 이벤트 기간을 사용하지 않는 차이가 있었습니다.

java
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PolicyType policyType;
 
private LocalDateTime eventStartAt;
private LocalDateTime eventEndAt;

eventStartAteventEndAt을 nullable로 두면 두 유형을 한 엔티티로 표현할 수 있습니다. 다만 DB에는 두 컬럼이 비어 있을 수 있다는 정의만 남기 때문에, EVENT에는 기간이 반드시 있어야 하고 WELCOME에는 없어야 한다는 정책까지 저장 단계에서 확인되지는 않습니다.

CHECK만으로는 NULL을 막을 수 없다#

제약이 없는 테이블에는 종료일이 없는 EVENT, 이벤트 기간이 들어간 WELCOME, 정의되지 않은 UNKNOWN 유형을 모두 저장할 수 있습니다. policy_type 자체가 NULL인 행도 컬럼이 nullable이면 그대로 들어갑니다.

여기서 CHECK (policy_type IN ('WELCOME', 'EVENT'))만 추가해도 NULL은 막히지 않습니다. MySQL의 CHECK 제약은 조건이 FALSE일 때만 위반으로 처리하고, NULL IN (...)처럼 결과가 UNKNOWN이면 통과시키기 때문입니다.

text
policy_type = 'UNKNOWN' → IN 조건이 FALSE → 거절
policy_type = NULL      → IN 조건이 UNKNOWN → 통과

MySqlCouponPolicyConstraintProbe.java에서도 policy_type을 nullable로 둔 뒤 두 CHECK만 적용하자, 유형이 없고 기간도 없는 행과 유형 없이 기간만 있는 행이 모두 저장됐습니다.

text
check-only null_type_empty=accepted null_type_period=accepted

따라서 허용할 문자열은 CHECK로 제한하고, 유형이 반드시 존재해야 한다는 조건은 NOT NULL로 따로 표현해야 합니다. 둘은 대체 관계가 아니라 서로 다른 상태를 막는 제약입니다.

생성과 변경을 유형별 메서드로 제한하기#

애플리케이션에서는 정책을 처음 만드는 경로부터 유형별로 나눴습니다. event()는 시작과 종료 시각을 모두 받고 순서를 검증하며, welcome()은 이벤트 기간을 입력받지 않습니다.

java
public static CouponPolicy event(
    String name,
    LocalDateTime startAt,
    LocalDateTime endAt
) {
    validateEventPeriod(startAt, endAt);
    return new CouponPolicy(name, PolicyType.EVENT, startAt, endAt);
}
 
public static CouponPolicy welcome(String name) {
    return new CouponPolicy(name, PolicyType.WELCOME, null, null);
}

이 기능은 정책을 생성하는 것보다 운영 중에 수정하는 일이 더 많으므로, 정적 팩토리만으로는 충분하지 않습니다. 필드 setter를 열어두면 managed 엔티티의 종료 시각을 null로 바꾼 뒤 dirty checking으로 잘못된 UPDATE가 실행될 수 있습니다. 수정도 changeEventPeriod()처럼 의도가 드러나는 메서드로 제한하고 같은 검증을 거치게 했습니다.

java
public void changeEventPeriod(LocalDateTime startAt, LocalDateTime endAt) {
    if (policyType != PolicyType.EVENT) {
        throw new IllegalStateException("이벤트 쿠폰만 기간을 변경할 수 있습니다.");
    }
 
    validateEventPeriod(startAt, endAt);
    this.eventStartAt = startAt;
    this.eventEndAt = endAt;
}

이 경계는 API 요청에 구체적인 검증 오류를 반환하고, 엔티티의 변경 지점을 한곳으로 모으기 위한 것입니다. JPQL 벌크 업데이트나 native query, migration처럼 엔티티 메서드를 거치지 않는 경로는 여전히 남으므로, 한 행이 반드시 지켜야 하는 조합은 DB에도 적용했습니다.

NOT NULL과 CHECK를 포함한 정책 테이블#

재현에 사용한 테이블 전체는 다음과 같습니다.

sql
CREATE TABLE coupon_policy (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    policy_type VARCHAR(30) NOT NULL,
    event_start_at DATETIME NULL,
    event_end_at DATETIME NULL,
 
    CONSTRAINT chk_coupon_policy_type
        CHECK (policy_type IN ('WELCOME', 'EVENT')),
 
    CONSTRAINT chk_coupon_policy_event_period
        CHECK (
            (
                policy_type = 'EVENT'
                AND event_start_at IS NOT NULL
                AND event_end_at IS NOT NULL
                AND event_start_at < event_end_at
            )
            OR
            (
                policy_type = 'WELCOME'
                AND event_start_at IS NULL
                AND event_end_at IS NULL
            )
        )
) ENGINE=InnoDB;

policy_type <> 'EVENT'로 나머지 유형을 한꺼번에 처리하지 않고 WELCOME을 명시한 이유는 UNKNOWN 같은 문자열이 정상적인 비이벤트 정책으로 분류되는 것을 막기 위해서입니다. policy_type NOT NULL은 앞서 확인한 3치 논리의 빈틈을 닫습니다.

MySQL은 8.0.16부터 CHECK를 실제로 강제합니다. MySQL 8.0.44에서 정상 조합과 잘못된 조합을 저장한 결과, 정상 EVENTWELCOME만 허용됐고 NULL 유형 두 건도 NOT NULL에서 리젝됐습니다.

text
valid_event: accepted
missing_end: rejected
reversed_range: rejected
welcome_with_event_range: rejected
valid_welcome: accepted
unknown_type: rejected
null_type_empty: rejected
null_type_period: rejected

UPDATE에도 같은 제약이 적용됩니다. 정상 EVENT의 종료 시각을 null로 변경한 쿼리는 거절됐고, 유효한 종료 시각으로 변경한 쿼리만 반영됐습니다.

text
update invalidPeriod=rejected validPeriod=accepted

기존 테이블에 CHECK를 적용하는 순서#

운영 중인 테이블에 제약을 추가하기 전에는 기존 데이터와 DDL 실행 방식을 함께 확인해야 합니다. 먼저 NULL 유형, 허용되지 않은 유형과 유형별 기간 조건을 나눠 위반 행을 찾았습니다. 단순히 WHERE NOT (CHECK 조건)만 사용하면 조건 결과가 UNKNOWNNULL 행이 조회에서도 빠질 수 있습니다.

sql
SELECT id, policy_type, event_start_at, event_end_at
FROM coupon_policy
WHERE policy_type IS NULL
   OR policy_type NOT IN ('WELCOME', 'EVENT')
   OR (
        policy_type = 'EVENT'
        AND (
            event_start_at IS NULL
            OR event_end_at IS NULL
            OR event_start_at >= event_end_at
        )
   )
   OR (
        policy_type = 'WELCOME'
        AND (event_start_at IS NOT NULL OR event_end_at IS NOT NULL)
   );

MySQL 8.0.44에서 위반 행이 남은 테이블에 enforced CHECK를 추가하면 DDL이 실패했습니다. 위반 데이터를 정리한 뒤에는 기본 알고리즘으로 제약을 추가할 수 있었습니다.

text
migration dirtyAdd=rejected cleanedAdd=accepted

큰 테이블에서는 여기서 한 단계 더 확인해야 합니다. enforced CHECKALGORITHM=INPLACE, LOCK=NONE으로 추가하거나 ENFORCED로 전환하자 MySQL 8.0.44는 1845 오류와 함께 COPY를 사용하라고 반환했습니다. 반면 NOT ENFORCED 상태의 제약은 같은 온라인 옵션으로 추가할 수 있었습니다.

text
directEnforcedOnline=rejected(1845)
addNotEnforcedOnline=accepted
cleanEnforceOnline=rejected(1845)
cleanEnforceDefault=accepted

LOCK=NONE을 명시한 사전 실행은 지원되지 않는 작업이 조용히 더 강한 잠금이나 COPY로 바뀌지 않게 즉시 실패시키는 용도로 사용했습니다. NOT ENFORCED 제약은 기존 데이터 정리와 최종 DDL을 나누는 데는 도움이 되지만, 그 상태에서는 새 INSERTUPDATE도 막지 못합니다.

최종 enforcement에 COPY가 필요한 테이블이라면 행 수와 DDL 소요 시간을 같은 MySQL 버전의 복제 환경에서 먼저 측정하고, 허용할 수 있는 점검 시간에 적용합니다. 중단 시간을 허용할 수 없다면 pt-online-schema-change 같은 도구도 선택지가 되지만, foreign key와 trigger, replication 구성에 따라 제약이 있으므로 해당 토폴로지에서 별도로 검증해야 합니다.

정책 유형을 제한하는 세 가지 방식#

허용할 policy_type을 제한하는 방법은 CHECK만 있는 것이 아닙니다.

방식DB에서 필요한 변경적용 조건
CHECK IN (...)제약 교체와 기존 행 재검증유형이 적고 자주 늘지 않을 때
MySQL ENUM컬럼 정의 변경DB 타입으로 값 집합을 강하게 고정할 때
코드 테이블과 FK참조 데이터 INSERT유형이 운영 중 자주 추가되거나 별도 속성을 가질 때

현재 범위에서는 정책 유형이 WELCOMEEVENT로 작고, 새 유형이 추가되면 기간 필드 조합과 애플리케이션 로직도 함께 배포해야 했습니다. 유형 값만 독립적으로 늘어나는 구조가 아니어서 별도 코드 테이블보다 migration에 CHECK를 명시하는 방식을 선택했습니다.

반대로 유형을 운영 화면에서 자주 추가하거나 유형 자체에 이름과 활성 상태 같은 속성이 생긴다면 코드 테이블과 FK가 더 적합합니다. 이 경우에도 유형별 nullable 조합을 FK 하나로 표현할 수 있는 것은 아니므로 테이블 구조나 추가 제약은 별도로 설계해야 합니다.

CHECK가 보장하는 범위는 한 행만#

현재 CHECK가 확인하는 것은 하나의 정책 행 안에서 유형과 이벤트 기간이 맞는지입니다. 동시에 활성화된 WELCOME 정책이 두 개인지, 서로 다른 EVENT 정책의 기간이 겹치는지는 다른 행을 조회해야 하므로 이 제약으로 막을 수 없습니다.

하나의 활성 정책만 허용하는 조건은 상태를 표현한 생성 컬럼과 유일 인덱스로 모델링할 수 있는지 검토하고, 기간 중복처럼 두 범위를 비교해야 하는 조건은 정책 변경 트랜잭션에서 대상 범위를 잠근 뒤 확인해야 합니다. 이 규칙이 중요해지면 유형별 정책 테이블을 분리하는 편이 오히려 제약을 단순하게 만들 수 있습니다.

JPA 모델과 스키마 제약의 역할#

이 구조에서는 Hibernate의 자동 DDL 생성보다 버전이 관리되는 migration SQL을 스키마의 기준으로 두고, JPA 설정은 스키마가 매핑과 일치하는지 검증하는 용도로 사용했습니다. 엔티티의 @Column(nullable = false)policy_type의 의도를 코드에도 남기지만, 실제 NOT NULLCHECK의 기준은 배포된 DDL입니다.

도메인 메서드에서 잘못된 생성과 수정을 먼저 거절하므로, DB의 CHECK 위반은 사용자 입력을 정상적으로 분류하기 위한 경로로 사용하지 않았습니다. 이 예외가 Spring의 DataIntegrityViolationException으로 올라오면 bulk query나 migration, 엔티티 매핑이 도메인 규칙을 우회했다는 신호로 보고 기록해야 합니다.

시간 컬럼도 적용 범위를 정해야 합니다. MySQL의 DATETIME은 시간대 변환을 하지 않으므로 하나의 영업 시간대를 기준으로 프로모션을 운영할 때는 기준 시간대를 설정과 API 계약에 명시합니다. 여러 시간대에서 같은 쿠폰을 운영해야 한다면 UTC 기준 시각이나 시간대 식별자를 함께 저장하는 모델이 필요합니다.

마무리하며#

WELCOMEEVENT를 한 테이블에서 관리하면서 nullable 컬럼은 단순한 선택 값이 아니라, 쿠폰 유형에 따라 값의 유무가 달라지는 정책 데이터가 됐습니다. Dayner에서는 생성과 변경 경로를 유형별 메서드로 제한하고, policy_type NOT NULLCHECK를 migration에 함께 적용해 엔티티를 거치지 않는 저장에도 같은 규칙을 적용했습니다.

이후에는 nullable 필드가 늘어날 때 컬럼별 설정만 확인하지 않고, 유형과 필드의 조합을 하나의 행 제약으로 표현할 수 있는지도 함께 보게 됐습니다. 다음 글에서는 existsBy 조회와 INSERT 사이에서 발생한 중복 발급을 다룹니다.

Connected Notes