본문 바로가기

코드별 확장 속성

attr1, attr2 같은 범용 슬롯이 그룹마다 다른 뜻을 갖는 문제를 재현하고, 게시글 공개 범위 코드에만 필요한 정책을 이름과 타입이 있는 1:1 확장 테이블로 모델링합니다. CHECK의 NULL 판정과 정책 행의 누락을 진단하는 방법도 다룹니다.

공통 코드가 자리를 잡으면 곧 attr1, attr2 같은 열을 붙여 모든 그룹의 추가 정보를 담고 싶어집니다. 열은 조금만 늘지만, 같은 칸이 그룹마다 할인율이나 캐시 시간(초), 색상처럼 다른 뜻을 갖게 됩니다.

유연성은 이름을 지우는 데서 생기지 않습니다. 어떤 코드에 어떤 정책이 붙는지 관계로 나타내고, 타입과 필수 여부, 단위를 데이터베이스가 확인할 수 있어야 운영자가 값을 안전하게 바꿀 수 있습니다. 예제는 게시글 공개 범위 가운데 PUBLIC은 검색 결과에 미리보기를 보여 줄 수 있고, MEMBERS_ONLY와 PRIVATE는 로그인이나 작성자 확인이 필요하다고 가정합니다.

범용 속성 슬롯의 모호성

열 이름에 뜻이 없으므로, SQL은 그룹별 CASE를 반복해야 합니다. 문자열 칸에 숫자와 불리언을 함께 넣으면 잘못된 단위와 타입도 그대로 저장되고, 정책 하나를 필수로 만드는 검사도 쓰기 어렵습니다.

의미와 타입을 잃은 범용 코드 속성
DROP TABLE IF EXISTS code_attribute_bad;

CREATE TABLE code_attribute_bad (
  group_code VARCHAR(40) NOT NULL,
  code VARCHAR(40) NOT NULL,
  attr1 VARCHAR(100) NULL,
  attr2 VARCHAR(100) NULL,
  PRIMARY KEY (group_code, code)
);

INSERT INTO code_attribute_bad
  (group_code, code, attr1, attr2)
VALUES
  ('POST_VISIBILITY', 'PRIVATE', 'Y', NULL),
  ('POST_VISIBILITY', 'MEMBERS_ONLY', '60', '200'),
  ('MEMBER_GRADE', 'VIP', '5', 'gold');

SELECT group_code,
       code,
       CAST(attr1 AS UNSIGNED) AS cache_ttl_seconds
FROM code_attribute_bad
ORDER BY group_code, code;

SHOW WARNINGS;
group_code      | code         | cache_ttl_seconds
MEMBER_GRADE    | VIP          | 5
POST_VISIBILITY | MEMBERS_ONLY | 60
POST_VISIBILITY | PRIVATE      | 0

Level   | Code | Message
Warning | 1292 | Truncated incorrect INTEGER value: 'Y'

PRIVATE의 Y는 숫자로 바꾸면서 0이 되었고, VIP의 할인율 5는 캐시 시간처럼 보입니다. 서버는 경고 하나만 남기고 오류 없이 결과를 돌려주며, 단위가 틀렸다는 것은 아무도 알려 주지 않습니다.

범용 슬롯은 열의 수를 줄였을 뿐 스키마를 문서 밖으로 밀어냈습니다. 그룹의 이름을 알아야 속성의 위치와 단위를 해석할 수 있으므로, 임시 쿼리와 데이터 품질 검사가 모두 취약해집니다. 모든 코드가 같은 속성을 공유하지 않는다면 상세 코드의 행을 넓히지 말고 그 그룹을 위한 1:1 확장을 두며, 이름과 타입이 있는 열로 제약을 걸고 없는 정책은 행이 없는 것으로 나타냅니다.

설계할 때는 각 속성 값의 단위(초, 글자 수, 불리언)를 적고, 속성이 모든 그룹에 공통인지 한 그룹에만 필요한지 나눕니다. 코드마다 반드시 필요한 값과 선택 값을 구분하고, CASE 없이 열 이름만 보고 조건을 쓸 수 있는지 확인합니다.

공통 코드와 확장 정책

이름과 정렬, 활성 여부처럼 모든 코드가 공유하는 메타데이터는 final_code에 남깁니다. 공개 범위의 로그인과 캐시, 검색 미리보기 정책처럼 특정 그룹만 갖는 속성은 그 그룹 전용의 테이블에 둡니다.

확장 테이블의 복합 기본 키를 그대로 공통 코드의 외래 키로 이으면, 없는 코드의 정책을 만들 수 없습니다. 여기에 그룹을 고정하는 CHECK를 더하면, 다른 그룹의 코드를 잘못 확장하는 것도 막습니다. 코드를 중단하거나 지울 때 정책 행도 함께 움직일지, 정책 행이 없는 것과 값 0이 같은 뜻인지도 공개 범위의 규칙으로 적어 둡니다.

복합 키 기반 정책 확장

13장 1편의 final_code를 부모로 씁니다. 공개 범위 코드 세 개마다 정책 행을 하나씩 두고, 불리언과 초, 글자 수의 단위를 열 이름과 CHECK로 고정합니다.

게시글 공개 범위 정책 확장
DROP TABLE IF EXISTS final_post_visibility_policy;

CREATE TABLE final_post_visibility_policy (
  group_code VARCHAR(40) NOT NULL DEFAULT 'POST_VISIBILITY',
  visibility_code VARCHAR(40) NOT NULL,
  requires_login BOOLEAN NOT NULL,
  cache_ttl_seconds SMALLINT UNSIGNED NOT NULL,
  search_preview_chars SMALLINT UNSIGNED NULL,
  PRIMARY KEY (group_code, visibility_code),
  CONSTRAINT chk_visibility_policy_group
    CHECK (group_code = 'POST_VISIBILITY'),
  CONSTRAINT chk_visibility_policy_login
    CHECK (requires_login IN (0, 1)),
  CONSTRAINT chk_visibility_policy_cache
    CHECK (cache_ttl_seconds BETWEEN 0 AND 3600),
  CONSTRAINT chk_visibility_policy_preview CHECK (
    (visibility_code = 'PUBLIC'
      AND search_preview_chars IS NOT NULL
      AND search_preview_chars BETWEEN 80 AND 1000)
    OR
    (visibility_code <> 'PUBLIC' AND search_preview_chars IS NULL)
  ),
  CONSTRAINT fk_visibility_policy_code FOREIGN KEY
    (group_code, visibility_code)
    REFERENCES final_code (group_code, code) ON DELETE RESTRICT
) ENGINE=InnoDB;

INSERT INTO final_post_visibility_policy
  (visibility_code, requires_login, cache_ttl_seconds, search_preview_chars)
VALUES
  ('PUBLIC', FALSE, 300, 200),
  ('MEMBERS_ONLY', TRUE, 60, NULL),
  ('PRIVATE', TRUE, 0, NULL);

MySQL의 CHECK는 조건이 TRUE일 때뿐 아니라 NULL 때문에 UNKNOWN이 될 때도 행을 허용하고, FALSE일 때만 거부합니다(CHECK 제약). 그래서 PUBLIC 분기에 IS NOT NULL을 따로 적었습니다.

PUBLIC에 NULL을 넣으면 첫 분기는 TRUE AND FALSE AND UNKNOWN이므로 FALSE이고, 둘째 분기는 FALSE AND TRUE이므로 FALSE입니다. 전체 OR도 FALSE이므로 거부됩니다. 첫 분기에서 IS NOT NULL을 지우면 첫 분기가 TRUE AND UNKNOWN, 곧 UNKNOWN이 되고 전체도 UNKNOWN이 되어, 필수인 미리보기 길이 없이도 CHECK를 통과합니다.

POLICY ATTRIBUTE · NULL / CHECK

PUBLIC은 80~1,000, 나머지는 NULL만 허용한다

공개 정책의 search_preview_chars는 누락할 수 없습니다. PUBLIC 분기의 IS NOT NULL과 범위를 함께 확인하고, 다른 공개 범위는 미리보기를 사용하지 않는 NULL로 저장합니다.

공개 범위별 미리보기 CHECK 판정
입력 조건미리보기 길이CHECK 결과
공개 · 누락PUBLIC · NULLFALSE · 거절
공개 · 범위 안PUBLIC · 80~1,000TRUE · 허용
공개 · 범위 밖PUBLIC · 0~79 또는 1,001 이상FALSE · 거절
비공개 · 미사용PUBLIC 이외 · NULLTRUE · 허용
비공개 · 길이 지정PUBLIC 이외 · 숫자FALSE · 거절
공개 · 누락
미리보기 길이: PUBLIC · NULL
CHECK 결과: FALSE · 거절
공개 · 범위 안
미리보기 길이: PUBLIC · 80~1,000
CHECK 결과: TRUE · 허용
공개 · 범위 밖
미리보기 길이: PUBLIC · 0~79 또는 1,001 이상
CHECK 결과: FALSE · 거절
비공개 · 미사용
미리보기 길이: PUBLIC 이외 · NULL
CHECK 결과: TRUE · 허용
비공개 · 길이 지정
미리보기 길이: PUBLIC 이외 · 숫자
CHECK 결과: FALSE · 거절

위 표는 chk_visibility_policy_preview만 판정합니다. FK와 다른 CHECK·타입 제약은 별도로 통과해야 합니다.

NULL을 명시적으로 막는 이유

PUBLIC + NULL → IS NOT NULL이 FALSE → 거절

PUBLIC + 200 → NULL 아님 + 범위 안 → 허용

PRIVATE + NULL → 비PUBLIC + IS NULL → 허용

MySQL CHECK는 UNKNOWN도 통과하므로 범위 비교만으로 PUBLIC의 NULL을 막지 못합니다. 이 패널은 preview CHECK만 판단하며 FK·로그인·TTL 제약은 별개입니다.

로그인 플래그의 CHECK는 0과 1의 범위만 보장할 뿐, PRIVATE에는 반드시 TRUE를 저장해야 한다는 코드별 정책까지 강제하지는 않습니다. 각 코드에 정책 행이 반드시 하나씩 있어야 한다면, 새 코드를 등록하는 트랜잭션에서 두 INSERT를 함께 처리하고 정책 행의 누락을 따로 검사합니다. 외래 키는 부모마다 자식이 반드시 있어야 하는 전체 참여를 강제하지 못하기 때문입니다.

그룹별 확장이 너무 많아지고 속성의 정의 자체가 운영 중에 자주 바뀐다면 EAV나 JSON을 검토할 수 있습니다. 그러나 고정된 핵심 정책까지 동적인 모델로 옮기면 타입과 검색의 이점을 잃습니다.

타입과 필수 정책의 검증

행 안의 범위는 CHECK가 막고, 부모와 자식의 참여 누락은 안티 조인으로 찾습니다. 둘을 구분해야 제약이 보장하지 않는 규칙을 운영의 일치 확인이 맡을 수 있습니다. 두 위반 문장은 각각 따로 실행합니다.

정책 제약과 참여 대사
INSERT INTO final_post_visibility_policy
  (visibility_code, requires_login, cache_ttl_seconds, search_preview_chars)
VALUES ('MEMBERS_ONLY', TRUE, 4000, NULL);

UPDATE final_post_visibility_policy
SET search_preview_chars = 200
WHERE visibility_code = 'PRIVATE';
ERROR 3819 (HY000): Check constraint 'chk_visibility_policy_cache' is violated.
ERROR 3819 (HY000): Check constraint 'chk_visibility_policy_preview' is violated.

첫 행은 MEMBERS_ONLY의 기본 키도 겹치지만, CHECK는 행을 저장소에 쓰기 전에 평가되므로 캐시 시간의 범위 위반이 먼저 보고됩니다. 둘째 문장은 PUBLIC이 아닌 코드에 미리보기 길이를 넣으려 해 거부됩니다.

정책 누락과 정책 조회
SELECT c.code AS missing_policy_code
FROM final_code AS c
LEFT JOIN final_post_visibility_policy AS p
  ON p.group_code = c.group_code
 AND p.visibility_code = c.code
WHERE c.group_code = 'POST_VISIBILITY'
  AND c.is_active = TRUE
  AND p.visibility_code IS NULL;

SELECT c.code,
       c.display_name_ko,
       p.requires_login,
       p.cache_ttl_seconds,
       p.search_preview_chars
FROM final_code AS c
JOIN final_post_visibility_policy AS p
  ON p.group_code = c.group_code
 AND p.visibility_code = c.code
WHERE c.group_code = 'POST_VISIBILITY'
ORDER BY c.sort_order;
code         | display_name_ko | requires_login | cache_ttl_seconds | search_preview_chars
PUBLIC       | 전체 공개       | 0              | 300               | 200
MEMBERS_ONLY | 회원 공개       | 1              | 60                | NULL
PRIVATE      | 작성자 전용     | 1              | 0                 | NULL

13장 1편의 기본 공개 범위 세 코드만 있다면 안티 조인은 빈 결과입니다. 앞 글의 연습 문제에서 활성 코드 LINK_ONLY를 더하고 그 정책을 만들지 않았다면, 안티 조인이 LINK_ONLY 한 행을 돌려주고, 정책 조회는 INNER JOIN이므로 여전히 세 행입니다. 조회하는 쪽은 그룹별 CASE 없이 열 이름과 타입으로 세 정책을 해석합니다.

배치장점비용알맞은 경우
기본 열 추가조회와 제약이 단순하다일부 그룹만 쓰면 많은 행이 NULL이다모든 코드가 같은 속성을 가질 때
그룹 확장명시적인 타입과 적은 NULL그룹별 테이블이 늘어난다정책의 집합이 고정될 때
EAV 속성운영 중에 정의를 더할 수 있다타입과 조회, 검증이 복잡하다실제로 동적인 메타데이터일 때
JSON 정책객체 단위의 입출력이 편하다경로 제약과 인덱스의 비용드문 비핵심 속성일 때

정책의 효력 시점과 삭제 규칙

정책의 변경은 표시 이름의 변경보다 위험합니다. 캐시 시간을 300초에서 60초로 줄이면, 기존 캐시를 바로 만료할지 새 응답부터 적용할지 정할 효력 시점이 필요할 수 있습니다. 확장 테이블을 ON DELETE CASCADE로 두면 코드를 정리하다 실수했을 때 정책까지 지워지므로, 과거 행의 해석을 중시하는 이 예제는 RESTRICT와 명시적인 중단 절차를 골랐습니다.

값 0과 NULL의 뜻도 정해 둡니다. 캐시 시간 0은 캐시하지 않는다는 뜻이며 영구 캐시와 다르고, search_preview_chars의 NULL은 제한이 없다는 뜻이 아니라 검색 미리보기를 만들지 않는다는 뜻입니다. 테넌트별 정책은 전역 코드의 확장과 별도의 재정의 테이블로 나눌 수 있고, 정책의 효력 기간이 필요해지면 현재 행을 UPDATE하는 대신 버전 테이블을 씁니다.

정책을 바꿀 때는 이전 값과 변경자, 효력 시점, 영향받은 캐시의 수를 감사 기록으로 남깁니다. 운영에서는 활성 코드에 비해 빠진 정책의 수와 범위 제약의 오류, 만료를 기다리는 캐시의 수를 지켜봅니다.

따져 볼 것질문
공통성모든 코드 그룹에 같은 뜻으로 있는 속성인가
타입데이터베이스의 타입과 CHECK로 잘못된 값을 막아야 하는가
변경운영 중에 속성의 정의 자체가 자주 더해지는가
참여모든 코드가 반드시 정책 한 행을 가져야 하는가
이력정책의 과거 효력 구간을 조회해야 하는가

범용성은 속성의 번호를 늘리는 것이 아니라, 변하는 축을 정확히 고르는 일입니다. 고정된 정책은 이름 있는 열과 전용 확장이 가장 읽기 쉽습니다.

정리

  • 속성 번호로 된 범용 슬롯은 뜻과 단위를 스키마 밖으로 밀어냅니다.
  • 모든 코드가 공유하는 메타데이터와 그룹별 정책을 나눕니다.
  • 전용 확장은 명시적인 타입과 CHECK, 복합 외래 키를 제공합니다.
  • CHECK는 UNKNOWN도 허용하므로, 필수 값에는 IS NOT NULL을 직접 적습니다.
  • 부모마다 정책이 필요한 규칙은 안티 조인의 일치 확인으로 지킵니다.

연습 문제

1. 미리보기 길이의 판정

부모 코드는 있고 로그인과 캐시 시간 같은 다른 조건은 모두 유효하다고 합시다. PUBLIC의 미리보기 길이 79, 80, 1000, 1001, NULL과, PRIVATE의 길이 0, NULL은 chk_visibility_policy_preview에서 각각 허용될까요?

해설 보기
공개 범위길이첫 분기둘째 분기결과
PUBLIC79FALSEFALSE거부
PUBLIC80TRUEFALSE허용
PUBLIC1000TRUEFALSE허용
PUBLIC1001FALSEFALSE거부
PUBLICNULLFALSEFALSE거부
PRIVATE0FALSEFALSE거부
PRIVATENULLFALSETRUE허용

BETWEEN은 양 끝의 80과 1000을 포함합니다. PUBLIC의 NULL은 첫 분기의 IS NOT NULL이 FALSE이므로 UNKNOWN이 아니라 FALSE가 됩니다. PRIVATE는 NULL만 허용하고 숫자 0도 거부하는데, 여기서 NULL은 "미리보기를 만들지 않음"이라는 뜻이고 길이 0이라는 저장값과 다르기 때문입니다. 이 판정은 미리보기 CHECK 하나의 결과이며 다른 제약을 대신하지 않습니다.

2. 코드 표시 이름의 다국어 확장

코드의 표시 이름을 ko-KR과 en-US 두 로캘로 제공하는 확장을 설계해 보세요. 같은 코드와 로캘의 중복, 없는 코드의 번역을 막아야 합니다.

해설 보기

번역은 모든 그룹에 공통으로 적용될 수 있으므로 코드별 다국어 표시 이름 테이블을 둡니다. 복합 코드 키 뒤에 로캘을 더한 기본 키가 언어 하나에 한 행을 보장합니다.

DROP TABLE IF EXISTS final_code_label;
CREATE TABLE final_code_label (
  group_code VARCHAR(40) NOT NULL,
  code VARCHAR(40) NOT NULL,
  locale VARCHAR(20) NOT NULL,
  display_name VARCHAR(100) NOT NULL,
  PRIMARY KEY (group_code, code, locale),
  CONSTRAINT fk_code_label_code FOREIGN KEY (group_code, code)
    REFERENCES final_code (group_code, code) ON DELETE RESTRICT,
  CONSTRAINT chk_code_label_locale
    CHECK (REGEXP_LIKE(locale, '^[a-z]{2}(-[A-Z]{2})?$', 'c'))
) ENGINE=InnoDB;

INSERT INTO final_code_label
  (group_code, code, locale, display_name)
VALUES
  ('POST_STATUS', 'PUBLISHED', 'ko-KR', '게시됨'),
  ('POST_STATUS', 'PUBLISHED', 'en-US', 'Published');

로캘은 전역에서 유일한 값이 아닙니다. 위 두 행 뒤에 ('POST_STATUS', 'HIDDEN', 'ko-KR', '숨김')을 넣으면, 부모 HIDDEN이 있고 복합 기본 키도 다르므로 허용됩니다. 반면 PUBLISHED의 ko-KR 이름만 바꾸어 같은 키로 INSERT하면 이름이 달라도 중복입니다.

ERROR 1062 (23000): Duplicate entry 'POST_STATUS-PUBLISHED-ko-KR' for key 'final_code_label.PRIMARY'

없는 코드의 번역은 fk_code_label_code의 1452로, 형식에 어긋난 로캘은 chk_code_label_locale의 3819로 거부됩니다. REGEXP_LIKE의 세 번째 인자 'c'는 열의 정렬 규칙과 상관없이 대소문자를 구별해 비교하게 하므로, ko-kr도 형식 위반입니다. 이 식은 소문자 두 글자와 선택적인 대문자 국가 코드의 형식만 검사하며, ko-KR과 en-US만 허용하는 목록은 아닙니다.

다음 글 코드 캐시 동기화에서는 코드의 이름과 정책이 바뀌었을 때, 여러 서버의 로컬 캐시가 언제 새 버전을 읽었는지 추적하는 규칙을 만듭니다.