안동민 개발노트

본문 시작

코드별 확장 속성

attr1·attr2의 의미 충돌을 재현하고 게시글 공개 범위 코드에만 필요한 정책을 명시적인 1:1 확장 테이블로 모델링합니다.

공통 코드가 자리를 잡으면 곧 attr1, attr2를 붙여 모든 그룹의 추가 정보를 담고 싶은 유혹이 생깁니다.

컬럼은 적게 늘지만 같은 칸이 그룹마다 할인율, 캐시 초, 색상처럼 다른 뜻을 갖습니다.

유연성은 이름을 지우는 데서 생기지 않습니다.

어떤 코드에 어떤 정책이 붙는지 관계로 표현하고 타입·필수값·단위를 DB가 확인할 수 있어야 운영자가 값을 안전하게 바꿀 수 있습니다.

게시글 공개 범위 중 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;

PRIVATE의 Y는 숫자 변환에서 0이 되고 VIP 할인율 5는 캐시 초처럼 보입니다.

이 SELECT의 숫자 변환은 잘못된 문자열을 0으로 만들 수 있고 변환 경고도 낼 수 있습니다. 아래의 의미 검증 오류 0은 서버 경고가 없다는 뜻이 아닙니다.

원문과 기준 데이터로 예상한 오류 예시

이 문서의 결과 블록은 SQL과 MySQL 8.4 규칙에 따른 예상 형태이며 이번 작업에서 새로 실행한 관측 로그가 아닙니다.

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

semantic validation errors raised: 0

범용 슬롯은 컬럼 수를 줄였을 뿐 스키마를 문서 밖으로 밀어냈습니다.

그룹 이름을 알아야 속성 위치와 단위를 해석할 수 있어 임시 쿼리와 데이터 품질 검사가 모두 취약합니다.

모든 코드가 같은 속성을 공유하지 않는다면 상세 코드 행을 넓히지 말고 해당 그룹을 위한 1:1 확장을 둡니다.

명시적 이름과 타입으로 제약을 걸고 없는 정책은 행 없음으로 표현합니다.

attr1의 숫자가 세 단위로 갈라지는 순간

  1. 단위 — 각 속성 값이 초·글자 수·불리언 중 무엇인지 표기합니다.
  2. 범위 — 속성이 모든 그룹에 공통인지 한 그룹에만 필요한지 나눕니다.
  3. 필수 — 코드별로 반드시 필요한 값과 선택값을 구분합니다.
  4. 조회 — CASE 없이 컬럼 이름만 보고 조건을 작성할 수 있는지 확인합니다.

공통 코드와 확장 정책

이름·정렬·활성처럼 모든 코드가 공유하는 메타데이터는 final_code에 남깁니다.

공개 범위의 로그인·캐시·검색 미리보기 정책처럼 특정 그룹만 갖는 속성은 그 그룹 전용 테이블에 둡니다.

확장 테이블의 복합 PK를 그대로 공통 코드 FK로 연결하면 존재하지 않는 코드 정책을 만들 수 없습니다.

그룹 검사를 더하면 다른 그룹 코드가 잘못 확장되는 것도 차단합니다.

공통 메타데이터와 공개 범위 정책의 경계를 긋기

  1. 공통부 — 모든 그룹이 사용하는 이름·순서·활성만 기본에 둡니다.
  2. 전용부 — 로그인 필요 여부·캐시 TTL·검색 미리보기 길이를 이름 있는 타입으로 선언합니다.
  3. 수명 — 코드 삭제·중단과 정책 행 삭제가 함께 움직일지 정합니다.
  4. 기본값 — 정책 행 없음과 값 0이 같은지 다른지 공개 범위 규칙에 적습니다.

복합 키 기반 정책 확장

ch13-1의 final_code를 부모로 사용합니다.

세 공개 범위 코드마다 정책 행을 하나씩 두고 불리언·초·글자 수 단위를 컬럼 이름과 검사로 고정합니다.

게시글 공개 범위 정책 extension
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뿐 아니라 UNKNOWN도 허용합니다. PUBLIC 분기의 IS NOT NULL을 빼면 NULL이 BETWEEN을 UNKNOWN으로 만들어 필수 길이 검사를 통과하므로 이를 명시적으로 차단했습니다.

로그인 플래그 검사는 0·1 범위만 보장합니다. PRIVATE에 반드시 TRUE를 저장해야 한다는 코드별 정책까지 연결한 제약은 아닙니다.

공개 범위별 미리보기 CHECK 판정

공개 범위별 미리보기 CHECK 판정

공개 범위별 미리보기 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·타입 제약은 별도로 통과해야 합니다.

기준 데이터의 예상 결과
PUBLIC
  requires_login: 0
  cache_ttl_seconds: 300
  search_preview_chars: 200

MEMBERS_ONLY
  requires_login: 1
  cache_ttl_seconds: 60
  search_preview_chars: NULL

PRIVATE
  requires_login: 1
  cache_ttl_seconds: 0
  search_preview_chars: NULL

정책이 코드와 항상 한 행씩 존재해야 한다면 신규 코드 등록 트랜잭션에서 두 INSERT를 함께 처리하고 누락 일치 검사를 둡니다.

FK만으로는 부모마다 자식이 반드시 존재하는 전체 참여를 강제하지 못합니다.

그룹별 확장이 너무 많아지고 속성이 운영 중 동적으로 바뀐다면 EAV나 JSON을 검토할 수 있습니다.

그러나 고정된 핵심 정책까지 동적 모델로 옮기면 타입과 검색 비용을 잃습니다.

범위 위반과 참여 누락을 별도로 확인하기

  1. 정상 정책 — 세 공개 범위의 로그인·캐시 TTL·미리보기 값을 입력합니다.
  2. 단위 위반 — cache_ttl_seconds 4000을 넣어 검사를 확인합니다.
  3. 그룹 위반 — POST_STATUS 코드를 공개 범위 정책에 연결해 거절되는지 봅니다.
  4. 참여 일치 검사 — 활성 공개 범위 코드 중 정책 행이 없는 값을 찾습니다.

타입과 필수 정책 검증

행 내부 범위는 검사가 막고 부모·자식 참여 누락은 안티 조인으로 찾습니다.

둘을 구분해야 제약이 보장하지 않는 규칙을 운영 일치 검사가 맡을 수 있습니다.

정책 제약과 참여 대사
INSERT INTO final_post_visibility_policy
  (visibility_code, requires_login, cache_ttl_seconds, search_preview_chars)
VALUES ('MEMBERS_ONLY', TRUE, 4000, NULL);
-- ERROR 3819: chk_visibility_policy_cache 또는 PK 1062

UPDATE final_post_visibility_policy
SET search_preview_chars = 200
WHERE visibility_code = 'PRIVATE';
-- ERROR 3819: chk_visibility_policy_preview

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;

위반 구문은 각각 실행해 오류를 확인합니다. 안티 조인은 ch13-1의 기본 공개 범위 세 코드만 있으면 0행입니다. 앞 연습에서 활성 LINK_ONLY까지 추가하고 그 정책을 만들지 않았다면 LINK_ONLY가 누락 행으로 나옵니다.

조회자는 그룹별 CASE 없이 컬럼 이름과 타입으로 세 정책을 해석합니다.

기본·확장·EAV·JSON 배치 비교

  • 기본 컬럼 추가 — 조회와 제약은 단순하지만 일부 그룹만 쓰면 많은 행에 NULL이 생깁니다. 모든 코드가 같은 속성을 가질 때 적합합니다.
  • 그룹 확장 — 명시적 타입을 쓰고 불필요한 NULL을 줄이는 대신 그룹별 테이블이 늘어납니다. 정책 집합이 고정될 때 적합합니다.
  • EAV 속성 — 운영 중 정의를 추가할 수 있지만 타입·조회·검증이 복잡해집니다. 실제로 동적인 메타데이터에 한해 검토합니다.
  • JSON 정책 — 객체 단위 입출력은 편하지만 경로 제약과 인덱스 비용이 생깁니다. 드문 비핵심 속성에 적합합니다.

정책 효력 시점과 RESTRICT가 남기는 책임

정책 변경은 표시 이름 변경보다 위험합니다.

캐시 TTL을 300초에서 60초로 줄이면 기존 캐시를 즉시 만료할지 신규 응답부터 적용할지 효력 시간이 필요할 수 있습니다.

확장 테이블을 ON DELETE CASCADE로 두면 코드 정리 실수가 정책까지 지웁니다.

과거 행 해석을 중시하므로 여기서는 RESTRICT와 명시적 중단 절차를 선택했습니다.

0·NULL·테넌트 재정의에서 달라지는 의미

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

캐시 TTL 변경의 영향 행을 관찰하기

정책 변경에는 이전 값·변경자·효력 시점·영향 받은 캐시 수를 감사 기록으로 남깁니다.

활성 코드 대비 정책 누락 수, 범위 제약 오류, 캐시 TTL 만료 대기 건수를 관찰합니다.

단위와 필수 참여를 흔드는 정책 실험

  1. 범위 — 캐시 TTL을 3601초로 바꾸어 오류를 확인합니다.
  2. 타입 — requires_login에 2를 넣어 불리언 검사를 봅니다.
  3. 참여 — 새 활성 공개 범위 코드를 정책 없이 추가하고 안티 조인을 실행합니다.
  4. 수명 — PRIVATE 코드를 비활성화했을 때 기존 정책을 유지할지 기록합니다.

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


코드 속성 배치 기준

판단 축확인할 질문
공통성모든 코드 그룹에 같은 뜻으로 존재하는가?
타입DB 타입과 검사로 잘못된 값을 막아야 하는가?
변경운영 중 속성 정의 자체가 자주 추가되는가?
참여모든 코드가 반드시 정책 한 행을 가져야 하는가?
이력정책의 과거 효력 구간을 조회해야 하는가?

범용성은 속성 번호를 늘리는 것이 아니라 변하는 축을 정확히 고르는 일입니다.

고정된 정책은 이름 있는 컬럼과 전용 확장이 가장 읽기 쉽습니다.


연습 문제

코드 표시 이름을 ko-KR, en-US 두 로캘로 제공하는 표시명 확장을 설계하세요.

같은 코드·로캘 중복과 존재하지 않는 코드의 번역을 막아야 합니다.

해설과 예시 답안

번역은 모든 그룹에 공통으로 적용될 수 있으므로 코드별 다국어 표시명 테이블을 둡니다.

복합 코드 키 뒤에 로캘을 추가한 PK가 한 언어당 한 행을 보장합니다.

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');

같은 (group, code, locale) 재입력은 1062, 없는 코드 번역은 1452, 정규식 형식에 어긋난 로캘은 3819입니다. REGEXP_LIKE의 c는 기본 정렬 규칙과 별개로 대소문자 구분을 켭니다. 이 식은 소문자 두 글자와 선택적인 대문자 국가 코드 형식을 검사하며, ko-KR·en-US만 허용하는 목록은 아닙니다.


핵심 정리

  • 속성 번호는 의미와 단위를 스키마 밖으로 밀어냅니다.
  • 공통 메타데이터와 그룹별 정책을 분리합니다.
  • 전용 확장은 명시적 타입·검사·복합 FK를 제공합니다.
  • 부모마다 정책이 필요한 규칙은 안티 조인 일치 검사까지 둡니다.

다음 문서에서는 코드 변경을 버전과 아웃박스로 발행해 캐시의 오래된 상태를 측정합니다.