본문 바로가기

DB 코드와 애플리케이션 열거형

애플리케이션의 ENUM과 데이터베이스의 코드가 서로 다른 값으로 배포되는 불일치를 재현하고, 릴리스 목록과 양방향 차집합, 상태 전이 표로 값의 존재와 허용 전이를 나누어 검증합니다. 코드를 더하고 이름을 바꾸는 배포 순서도 다룹니다.

애플리케이션의 ENUM은 타입 안전성과 자동 완성을 주고, 데이터베이스의 코드는 표시 이름과 운영 메타데이터를 바로 바꿀 수 있게 합니다. 둘을 함께 쓰면 장점이 겹치지만, 같은 값 목록을 두 곳에 복제해 맞추어야 하는 일이 생깁니다.

그래서 둘을 함께 쓰는 하이브리드 전략은 편의 기능이 아니라 릴리스의 규칙입니다. 배포할 ENUM 집합과 데이터베이스의 활성 코드 집합을 기계적으로 비교하고, 어긋나면 애플리케이션을 시작하지 않거나 호환 모드로 제한합니다. 예제는 데이터베이스에는 DELETED가 있지만 한 서버의 ENUM에는 없고, 개발 중인 분기에는 데이터베이스에 아직 없는 PAUSED가 들어간 상황을 가정합니다.

코드 목록 불일치의 발견 시점

저장하거나 전송한 표현을 다시 프로그램의 값으로 되돌리는 일을 역직렬화(deserialization)라고 하며, 여기서는 데이터베이스에서 읽은 코드 문자열을 앱의 ENUM 상수로 바꾸는 단계입니다. 데이터베이스의 값이 ENUM에 없으면 이 단계에서 오류가 납니다. Java의 valueOf가 선언에 없는 이름을 받으면 IllegalArgumentException을 던지는 것이 그 예이며, 이 동작은 enum 메서드와 문자열 입력 변환에서 볼 수 있습니다. 반대로 ENUM만 앞서가면 INSERT가 외래 키나 CHECK에서 거부됩니다. 문서를 사람이 확인하는 방식은 대소문자와 비활성 코드, 구버전 서버의 호환 구간을 놓칩니다.

서로 다른 DB와 배포 코드 집합
DROP TEMPORARY TABLE IF EXISTS release_enum_bad;

CREATE TEMPORARY TABLE release_enum_bad (
  code VARCHAR(40) NOT NULL PRIMARY KEY
);

INSERT INTO release_enum_bad (code)
VALUES ('DRAFT'), ('PUBLISHED'), ('HIDDEN'), ('PAUSED');

SELECT db.code AS db_only
FROM final_code AS db
LEFT JOIN release_enum_bad AS app ON app.code = db.code
WHERE db.group_code = 'POST_STATUS'
  AND db.is_active = TRUE
  AND app.code IS NULL;

SELECT app.code AS app_only
FROM release_enum_bad AS app
LEFT JOIN final_code AS db
  ON db.group_code = 'POST_STATUS'
 AND db.code = app.code
 AND db.is_active = TRUE
WHERE db.code IS NULL;
db_only
DELETED

app_only
PAUSED

데이터베이스에만 있는 DELETED와 앱에만 있는 PAUSED가 한 행씩 나오므로, 어느 방향의 요청도 안전하지 않습니다. 이 서버는 DELETED인 게시글을 읽으면 역직렬화에 실패하고, PAUSED를 쓰면 외래 키에 거부됩니다.

여기서 기준으로 삼는 규칙은, 한 릴리스가 새로 쓰는 값의 집합과 데이터베이스의 활성 코드 집합이 정확히 같아야 한다는 것입니다. ENUM과 코드 테이블은 역할이 다르지만 이 비교 대상의 식별자 집합은 같아야 하며, 교집합이 아니라 정확한 집합의 일치를 릴리스마다 확인합니다. 표시 이름과 정렬, 활성 여부는 데이터베이스가, 분기 로직과 타입은 앱이 맡습니다.

새 코드는 데이터베이스에 먼저 비활성이나 미사용 상태로 등록하고, 모든 앱이 그 값을 이해한 뒤에 활성화할 수 있습니다. 코드의 제거는 더 어려우므로 읽기 호환 기간과 과거 행의 해석을 남깁니다.

이때 엄격한 비교는 순차 배포와 부딪힐 수 있습니다. 데이터베이스의 활성 값은 기존 네 개이고 PAUSED를 비활성으로 먼저 등록했다고 합시다. 새 앱이 PAUSED까지 읽을 수 있어 다섯 값을 목록에 넣으면, 활성 집합과의 엄격한 비교는 PAUSED를 앱 전용 한 행으로 보고합니다. 순차 배포를 허용하려면 "이 릴리스가 읽을 수 있는 값"과 "지금 새로 쓰는 값"을 서로 다른 규칙으로 정해야 합니다. 새로 쓰는 활성 값은 현재의 모든 읽기 주체가 이해해야 하고, 남은 과거 행을 해석하려면 비활성인 과거 값도 읽기 집합에 들어갈 수 있습니다.

열거형과 코드 테이블의 역할

앱은 ENUM으로 분기할 수 있는 상태를 제한하고, 데이터베이스는 안정적인 코드 ID와 표시, 정렬, 활성 정보를 관리합니다. 같은 식별자 집합을 공유하지만, 한쪽이 다른 쪽의 역할을 대신하지는 않습니다.

DRAFT → HIDDEN처럼 값이 둘 다 있다고 모든 전이가 허용되는 것도 아닙니다. 허용하는 전이는 별도의 관계로 나타내거나 서비스의 상태 머신이 맡고, 데이터베이스의 릴리스 목록과 함께 검증합니다.

대상맡는 곳확인할 것
식별자데이터베이스 코드와 ENUM 이름같은 철자 규칙으로 고정했는가
메타데이터데이터베이스의 코드 행문구와 정렬, 운영 속성을 행에서 읽는가
로직타입이 안전한 앱 코드전이와 행동의 분기를 앱이 맡는가
릴리스 검증배포 파이프라인배포 산출물의 코드 목록을 데이터베이스 집합과 비교하는가

기본 정렬 규칙 utf8mb4_0900_ai_ci는 대소문자만 다른 철자를 같은 값으로 보므로, 아래의 비교만으로 철자가 바이트 단위까지 같은지는 검증되지 않습니다. 철자까지 검증하려면 대소문자를 구별하는 비교나 이진 비교를 따로 적용합니다.

릴리스 목록과 상태 전이

릴리스별 ENUM 목록을 적재할 테이블과 허용하는 전이의 표를 만듭니다. 기준 릴리스는 데이터베이스의 활성 상태 네 개와 일치하고, 전이 표의 양쪽 값도 공통 코드의 외래 키를 통과합니다.

ENUM 릴리스 목록과 상태 전이 규칙
DROP TABLE IF EXISTS final_post_status_transition;
DROP TABLE IF EXISTS final_release_enum_code;

CREATE TABLE final_release_enum_code (
  release_id VARCHAR(40) NOT NULL,
  group_code VARCHAR(40) NOT NULL,
  code VARCHAR(40) NOT NULL,
  loaded_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (release_id, group_code, code),
  CONSTRAINT fk_release_enum_code FOREIGN KEY (group_code, code)
    REFERENCES final_code (group_code, code) ON DELETE RESTRICT,
  CONSTRAINT chk_release_enum_group
    CHECK (group_code = 'POST_STATUS')
) ENGINE=InnoDB;

CREATE TABLE final_post_status_transition (
  group_code VARCHAR(40) NOT NULL DEFAULT 'POST_STATUS',
  from_code VARCHAR(40) NOT NULL,
  to_code VARCHAR(40) NOT NULL,
  transition_name VARCHAR(100) NOT NULL,
  PRIMARY KEY (group_code, from_code, to_code),
  CONSTRAINT chk_transition_group
    CHECK (group_code = 'POST_STATUS'),
  CONSTRAINT chk_transition_self CHECK (from_code <> to_code),
  CONSTRAINT fk_transition_from FOREIGN KEY (group_code, from_code)
    REFERENCES final_code (group_code, code) ON DELETE RESTRICT,
  CONSTRAINT fk_transition_to FOREIGN KEY (group_code, to_code)
    REFERENCES final_code (group_code, code) ON DELETE RESTRICT
) ENGINE=InnoDB;

INSERT INTO final_release_enum_code
  (release_id, group_code, code)
VALUES
  ('2026.07', 'POST_STATUS', 'DRAFT'),
  ('2026.07', 'POST_STATUS', 'PUBLISHED'),
  ('2026.07', 'POST_STATUS', 'HIDDEN'),
  ('2026.07', 'POST_STATUS', 'DELETED');

INSERT INTO final_post_status_transition
  (from_code, to_code, transition_name)
VALUES
  ('DRAFT', 'PUBLISHED', '게시 시작'),
  ('DRAFT', 'DELETED', '초안 삭제'),
  ('PUBLISHED', 'HIDDEN', '게시 숨김'),
  ('PUBLISHED', 'DELETED', '게시글 삭제');

SELECT
  (SELECT COUNT(*) FROM final_release_enum_code
   WHERE release_id = '2026.07') AS manifest_codes,
  (SELECT COUNT(*) FROM final_code
   WHERE group_code = 'POST_STATUS' AND is_active = TRUE) AS active_db_codes,
  (SELECT COUNT(*) FROM final_post_status_transition) AS allowed_transitions;
manifest_codes | active_db_codes | allowed_transitions
4              | 4               | 4

전이 표는 존재하는 게시글 상태끼리의 허용 간선만 저장합니다. 목록은 앱의 바이너리가 실제로 이해하는 값을 빌드 과정에서 만들어야 하며, 사람이 SQL을 손으로 맞추면 다시 어긋날 수 있습니다.

이 final_release_enum_code에는 외래 키가 있어, 데이터베이스에 등록되지 않은 앱 코드는 적재하는 단계에서 1452로 거부됩니다. 그러므로 임의의 원본 목록을 모두 받아 차집합을 진단하려면, CI 산출물을 먼저 외래 키가 없는 임시 적재 테이블에 넣고 비교합니다. 앞의 release_enum_bad가 그런 역할입니다.

상태 전이 표를 운영자가 바로 고칠 수 있게 하면 앱의 로직과 다시 충돌합니다. 전이를 데이터베이스가 맡을지 앱이 맡을지 정하고, 함께 맡는다면 같은 릴리스 검증을 적용합니다.

코드 집합과 금지 전이의 검증

두 차집합을 UNION ALL해 한 행이라도 나오면 배포를 멈춥니다. 코드 집합이 맞아도 전이의 허용 여부는 따로 확인합니다. 아래의 EXISTS는 0과 1을 조회할 뿐 실제 게시글의 UPDATE를 거부하지 않으므로, 쓰기 경로가 이 판정과 상태 변경을 함께 적용해야 합니다.

배포 코드 동등성·전이 검증
SELECT 'DB_ONLY' AS mismatch_type, db.code
FROM final_code AS db
LEFT JOIN final_release_enum_code AS app
  ON app.release_id = '2026.07'
 AND app.group_code = db.group_code
 AND app.code = db.code
WHERE db.group_code = 'POST_STATUS'
  AND db.is_active = TRUE
  AND app.code IS NULL

UNION ALL

SELECT 'APP_ONLY' AS mismatch_type, app.code
FROM final_release_enum_code AS app
LEFT JOIN final_code AS db
  ON db.group_code = app.group_code
 AND db.code = app.code
 AND db.is_active = TRUE
WHERE app.release_id = '2026.07'
  AND app.group_code = 'POST_STATUS'
  AND db.code IS NULL;

SELECT EXISTS (
  SELECT 1
  FROM final_post_status_transition
  WHERE from_code = 'DRAFT'
    AND to_code = 'HIDDEN'
) AS draft_hide_allowed;

SELECT EXISTS (
  SELECT 1
  FROM final_post_status_transition
  WHERE from_code = 'PUBLISHED'
    AND to_code = 'HIDDEN'
) AS published_hide_allowed;
draft_hide_allowed
0

published_hide_allowed
1

불일치 쿼리는 빈 결과이므로 릴리스 2026.07은 데이터베이스와 집합이 같습니다. 그래도 DRAFT → HIDDEN은 0, PUBLISHED → HIDDEN은 1이므로, 있을 수 있는 값과 허용되는 변화가 서로 다른 규칙임이 드러납니다.

코드 집합 일치와 상태 전이 허용을 따로 검증한다

PAUSED와 DELETED의 불일치를 발견한다. 현재→다음 쌍을 따로 검사한다. 각 단계의 값과 전환 이유를 비교합니다.

RELEASE CONTRACT · SET DIFFERENCE / TRANSITION

코드 집합 일치와 상태 전이 허용을 따로 검증한다

잘못된 앱 목록에는 PAUSED가 있고 DELETED가 없습니다. 릴리스 집합을 맞춘 뒤에도 DRAFT → HIDDEN이 허용되는지는 별도 전이 규칙으로 판정합니다.

네상태와네허용전이전이표의허용간선네개는DRAFT에서PUBLISHED와DELETED, PUBLISHED에서HIDDEN과DELETED입니다. DRAFT에서HIDDEN으로직접가는간선은없고DELETED는여기서나가는간선이없습니다. 화살표는상태변경의허용관계이며조회실행순서나FK방향이아닙니다.게시 시작게시 숨김왼쪽: 초안 삭제오른쪽: 게시글 삭제DRAFT초안PUBLISHED게시됨HIDDEN숨김DELETED삭제됨
네상태와네허용전이전이표의허용간선네개는DRAFT에서PUBLISHED와DELETED, PUBLISHED에서HIDDEN과DELETED입니다. DRAFT에서HIDDEN으로직접가는간선은없고DELETED는여기서나가는간선이없습니다. 화살표는상태변경의허용관계이며조회실행순서나FK방향이아닙니다.게시 시작게시 숨김왼쪽: 초안 삭제오른쪽: 게시글 삭제DRAFT초안PUBLISHED게시됨HIDDEN숨김DELETED삭제됨

간선은 전이 표에 저장한 허용 관계입니다. DRAFT → HIDDEN은 직접 간선이 없고, PUBLISHED → DELETED는 HIDDEN을 거치지 않는 별도 경로입니다. EXISTS가 1이어도 실제 UPDATE의 조건·권한 검사는 쓰기 경로에서 적용해야 합니다.

활성 DB 집합

DRAFT · PUBLISHED · HIDDEN · DELETED

DB에만 있는 값: DELETED

잘못된 앱 집합

DRAFT · PUBLISHED · HIDDEN · PAUSED

앱에만 있는 값: PAUSED

릴리스 2026.07 · 양방향 차집합 0행

수정 앱 집합: DRAFT · PUBLISHED · HIDDEN · DELETED

DB_ONLY = 0 / APP_ONLY = 0

본문 ai_ci 비교는 대소문자 철자의 바이트 일치까지 검사하지 않습니다. 미등록 앱 값을 진단하려면 먼저 FK 없는 원본 목록에 적재합니다.

집합 일치 이후 · 별도 전이 판정

DRAFT → HIDDEN: EXISTS = 0 · 허용 간선 없음

PUBLISHED → HIDDEN: EXISTS = 1 · 허용 간선 있음

두 코드가 존재하는 것만으로 변화가 허용되지는 않습니다. EXISTS 조회 자체는 UPDATE를 막지 않으므로 쓰기 경로가 판정과 상태 변경을 함께 적용합니다. 권한·시간 등 조건은 추가 규칙입니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 잘못된 집합부터 전이 조회까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목네 상태와 네 허용 전이
관계 설명전이 표의 허용 간선 네 개는 DRAFT에서 PUBLISHED와 DELETED, PUBLISHED에서 HIDDEN과 DELETED입니다. DRAFT에서 HIDDEN으로 직접 가는 간선은 없고 DELETED는 여기서 나가는 간선이 없습니다. 화살표는 상태 변경의 허용 관계이며 조회 실행 순서나 FK 방향이 아닙니다.
설명·조건두 코드가 존재하는 것만으로 상태 전이가 허용되지는 않습니다. EXISTS 조회 자체는 UPDATE를 막지 않으므로 쓰기 경로가 판정과 상태 변경을 함께 적용합니다. 권한·시간 등 조건은 추가 규칙입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
잘못된 집합

PAUSED와 DELETED의 불일치를 발견한다.

  • 집합·상태 전이 1: app-only PAUSED1
  • 집합·상태 전이 2: db-only DELETED1
  • 배포 검증 실패
집합 수정

2026.07 릴리스의 네 상태가 일치한다.

  • 집합·상태 전이 1: 양방향 차집합 0행
  • 집합 검증 통과
전이 조회

현재→다음 쌍을 따로 검사한다.

  • 집합·상태 전이 1: DRAFT→HIDDEN0
  • 집합·상태 전이 2: PUBLISHED→HIDDEN1
  • EXISTS 결과; 실제 변경 제어는 별도

코드 집합 일치와 상태 전이 허용을 따로 검증한다

PAUSED와 DELETED의 불일치를 발견한다. 현재→다음 쌍을 따로 검사한다. 각 단계의 값과 전환 이유를 비교합니다.

RELEASE CONTRACT · SET DIFFERENCE / TRANSITION

코드 집합 일치와 상태 전이 허용을 따로 검증한다

잘못된 앱 목록에는 PAUSED가 있고 DELETED가 없습니다. 릴리스 집합을 맞춘 뒤에도 DRAFT → HIDDEN이 허용되는지는 별도 전이 규칙으로 판정합니다.

네상태와네허용전이전이표의허용간선네개는DRAFT에서PUBLISHED와DELETED, PUBLISHED에서HIDDEN과DELETED입니다. DRAFT에서HIDDEN으로직접가는간선은없고DELETED는여기서나가는간선이없습니다. 화살표는상태변경의허용관계이며조회실행순서나FK방향이아닙니다.게시 시작게시 숨김왼쪽: 초안 삭제오른쪽: 게시글 삭제DRAFT초안PUBLISHED게시됨HIDDEN숨김DELETED삭제됨
네상태와네허용전이전이표의허용간선네개는DRAFT에서PUBLISHED와DELETED, PUBLISHED에서HIDDEN과DELETED입니다. DRAFT에서HIDDEN으로직접가는간선은없고DELETED는여기서나가는간선이없습니다. 화살표는상태변경의허용관계이며조회실행순서나FK방향이아닙니다.게시 시작게시 숨김왼쪽: 초안 삭제오른쪽: 게시글 삭제DRAFT초안PUBLISHED게시됨HIDDEN숨김DELETED삭제됨

간선은 전이 표에 저장한 허용 관계입니다. DRAFT → HIDDEN은 직접 간선이 없고, PUBLISHED → DELETED는 HIDDEN을 거치지 않는 별도 경로입니다. EXISTS가 1이어도 실제 UPDATE의 조건·권한 검사는 쓰기 경로에서 적용해야 합니다.

활성 DB 집합

DRAFT · PUBLISHED · HIDDEN · DELETED

DB에만 있는 값: DELETED

잘못된 앱 집합

DRAFT · PUBLISHED · HIDDEN · PAUSED

앱에만 있는 값: PAUSED

릴리스 2026.07 · 양방향 차집합 0행

수정 앱 집합: DRAFT · PUBLISHED · HIDDEN · DELETED

DB_ONLY = 0 / APP_ONLY = 0

본문 ai_ci 비교는 대소문자 철자의 바이트 일치까지 검사하지 않습니다. 미등록 앱 값을 진단하려면 먼저 FK 없는 원본 목록에 적재합니다.

집합 일치 이후 · 별도 전이 판정

DRAFT → HIDDEN: EXISTS = 0 · 허용 간선 없음

PUBLISHED → HIDDEN: EXISTS = 1 · 허용 간선 있음

두 코드가 존재하는 것만으로 변화가 허용되지는 않습니다. EXISTS 조회 자체는 UPDATE를 막지 않으므로 쓰기 경로가 판정과 상태 변경을 함께 적용합니다. 권한·시간 등 조건은 추가 규칙입니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 잘못된 집합부터 전이 조회까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목네 상태와 네 허용 전이
관계 설명전이 표의 허용 간선 네 개는 DRAFT에서 PUBLISHED와 DELETED, PUBLISHED에서 HIDDEN과 DELETED입니다. DRAFT에서 HIDDEN으로 직접 가는 간선은 없고 DELETED는 여기서 나가는 간선이 없습니다. 화살표는 상태 변경의 허용 관계이며 조회 실행 순서나 FK 방향이 아닙니다.
설명·조건두 코드가 존재하는 것만으로 상태 전이가 허용되지는 않습니다. EXISTS 조회 자체는 UPDATE를 막지 않으므로 쓰기 경로가 판정과 상태 변경을 함께 적용합니다. 권한·시간 등 조건은 추가 규칙입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
잘못된 집합

PAUSED와 DELETED의 불일치를 발견한다.

  • 집합·상태 전이 1: app-only PAUSED1
  • 집합·상태 전이 2: db-only DELETED1
  • 배포 검증 실패
집합 수정

2026.07 릴리스의 네 상태가 일치한다.

  • 집합·상태 전이 1: 양방향 차집합 0행
  • 집합 검증 통과
전이 조회

현재→다음 쌍을 따로 검사한다.

  • 집합·상태 전이 1: DRAFT→HIDDEN0
  • 집합·상태 전이 2: PUBLISHED→HIDDEN1
  • EXISTS 결과; 실제 변경 제어는 별도

순차 배포와 이름 변경

순차 배포 중에는 구버전 서버가 새 코드를 읽을 수 있습니다. 그래서 새 코드를 데이터베이스에 먼저 비활성으로 등록하고, 모든 버전이 그 값을 역직렬화할 수 있게 배포한 뒤, 쓰기 주체를 활성화하는 확장 후 축소(expand and contract)의 순서를 씁니다. PAUSED를 비활성으로 먼저 등록하면 활성 집합에는 들어가지 않으므로, 기존 릴리스 목록 그대로도 위의 차집합은 빈 결과로 남습니다.

ENUM의 이름을 바꾸는 것은 데이터베이스의 기본 키를 바꾸는 것과 같습니다. 새 이름을 더하고, 앱이 두 이름을 모두 읽되 새 이름만 쓰는 기간을 거친 뒤, 과거의 데이터를 채워 바꾸고 이전 코드를 중단합니다.

예외도 있습니다. 비활성 코드는 새 선택에서 빠져도 과거 행을 읽는 ENUM에는 남아야 할 수 있고, 모바일 클라이언트처럼 강제 업그레이드가 어려운 소비자에게는 더 긴 읽기 호환 기간이 필요합니다. 외부 API에 공개한 코드의 이름 변경은 내부의 데이터베이스 변경보다 버전이 있는 API 규칙이 우선하며, 전이 조건이 사용자의 권한이나 시간, 잔액에 달려 있다면 단순한 간선 표로는 부족합니다.

방식장점비용알맞은 경우
데이터베이스 코드만운영 변경이 빠르다앱의 타입과 분기 안전성이 약하다단순한 선택 목록
앱 ENUM만컴파일 검증이 단순하다표시나 속성을 바꿔도 재배포해야 한다로직 중심의 고정된 상태
하이브리드타입과 운영 메타데이터를 함께 쓴다동기화 검증 단계가 필요하다상태 로직과 표시가 모두 중요할 때
전이 표를 데이터베이스가 소유운영 규칙을 볼 수 있다앱의 상태 머신과 중복될 수 있다전이를 중앙에서 설정할 분명한 이유가 있을 때

배포마다 릴리스별 데이터베이스 전용과 앱 전용의 수, 구버전 서버의 비율, 알 수 없는 코드의 역직렬화 오류를 기록하고, 코드의 추가와 중단, 별칭의 기간, 쓰기 주체의 활성 시각을 변경 기록에 남깁니다.

따져 볼 것질문
로직코드별 분기와 허용 행동이 애플리케이션에 있는가
운영이름과 정렬, 속성을 재배포 없이 바꾸어야 하는가
검증릴리스 산출물과 데이터베이스 집합을 자동으로 비교하는가
호환순차 배포 중의 이전과 신규, 읽기와 쓰기 주체의 조합을 설명하는가
전이값의 존재와 상태 변화의 규칙을 누가 맡는지 구분하는가

하이브리드는 두 저장소를 쓰는 만큼 검증 장치가 있어야 하며, 릴리스 검증이 없다면 ENUM과 코드 테이블의 장점보다 불일치의 위험이 더 큽니다.

정리

  • ENUM과 데이터베이스 코드는 역할이 달라도, 새로 쓰는 식별자의 집합은 릴리스마다 같아야 합니다.
  • 양방향 차집합이 릴리스의 불일치를 찾아내고, 한 행이라도 나오면 배포를 멈춥니다.
  • 코드의 존재와 허용하는 상태 전이는 서로 다른 규칙입니다.
  • 새 코드는 비활성으로 먼저 등록하고, 이름 변경은 두 이름을 함께 읽는 기간을 거치는 확장 후 축소로 배포합니다.

연습 문제

1. 과거 코드의 호환 별칭

과거 시스템의 REMOVED를 표준 상태 DELETED로 모으는 호환 별칭을 설계해 보세요. 이전 행을 계속 읽을 수 있어야 하고, 새 쓰기 주체는 DELETED만 쓰며, 최종적으로 REMOVED의 참조 수를 0으로 만들 수 있어야 합니다.

해설 보기

이전 코드와 새 코드를 모두 상세 코드에 두고, 별칭 관계를 명시합니다. REMOVED는 과거 행을 읽기 위한 코드이므로 처음부터 비활성으로 등록하며, 그래야 새로 쓰는 값의 집합과 위의 릴리스 검증에 들어가지 않습니다. 외래 키는 부모의 존재만 보므로, 과거 데이터를 옮겨 오는 INSERT는 비활성 코드로도 성공합니다.

DROP TABLE IF EXISTS final_code_alias;

CREATE TABLE final_code_alias (
  group_code VARCHAR(40) NOT NULL,
  alias_code VARCHAR(40) NOT NULL,
  canonical_code VARCHAR(40) NOT NULL,
  valid_until DATETIME(6) NOT NULL,
  PRIMARY KEY (group_code, alias_code),
  CONSTRAINT chk_code_alias_distinct
    CHECK (alias_code <> canonical_code),
  CONSTRAINT fk_code_alias_old FOREIGN KEY (group_code, alias_code)
    REFERENCES final_code (group_code, code) ON DELETE RESTRICT,
  CONSTRAINT fk_code_alias_new FOREIGN KEY (group_code, canonical_code)
    REFERENCES final_code (group_code, code) ON DELETE RESTRICT
) ENGINE=InnoDB;

INSERT INTO final_code
  (group_code, code, display_name_ko, sort_order, is_active)
VALUES ('POST_STATUS', 'REMOVED', '과거 삭제', 50, FALSE);

INSERT INTO final_code_alias
  (group_code, alias_code, canonical_code, valid_until)
VALUES
  ('POST_STATUS', 'REMOVED', 'DELETED', '2026-10-01 00:00:00');

INSERT INTO final_post_code_demo
  (post_status, visibility_code, view_count)
VALUES ('REMOVED', 'PRIVATE', 0);

SELECT d.post_status,
       COALESCE(a.canonical_code, d.post_status) AS canonical_status
FROM final_post_code_demo AS d
LEFT JOIN final_code_alias AS a
  ON a.group_code = d.status_group_code
 AND a.alias_code = d.post_status
ORDER BY d.post_id;
post_status | canonical_status
PUBLISHED   | PUBLISHED
REMOVED     | DELETED

읽기 계층은 이 조회처럼 REMOVED를 DELETED로 한 단계 바꿉니다. valid_until을 조건에 쓰지 않았으므로 만료된 별칭도 걸러지지 않으며, 여러 단계의 별칭과 순환의 방지, 효력 시점은 따로 구현할 대상입니다. 앞 글들의 실패와 ROLLBACK이 AUTO_INCREMENT 값을 썼으므로, 새 행의 post_id는 2가 아닐 수 있어 결과에서 뺐습니다.

마지막으로 UPDATE final_post_code_demo SET post_status = 'DELETED' WHERE post_status = 'REMOVED'로 과거 행을 채워 바꾸면 한 행이 바뀌고 REMOVED의 참조 수가 0이 됩니다. 그 뒤에야 별칭 행을 지우고 코드를 정리할 수 있으며, 별칭이 남아 있는 동안에는 ON DELETE RESTRICT가 REMOVED 코드의 삭제를 막습니다.

2. 읽기 변환과 백필의 차이

별도의 연습 데이터에 REMOVED인 글이 2개, DELETED인 글이 3개 있고 읽기 계층이 REMOVED를 DELETED로 바꾼다고 합시다. 읽기 결과의 DELETED 건수와 데이터베이스 원본의 REMOVED 건수는 각각 얼마인가요? 두 과거 행을 채워 바꾼 뒤에는 각각 얼마가 되어야 하나요?

해설 보기

읽기 결과는 처음부터 DELETED 5건이지만, 원본에는 REMOVED 2건이 그대로 남아 있습니다. 읽기 변환만으로 백필이 끝난 것은 아닙니다. 백필한 뒤에는 원본이 REMOVED 0건과 DELETED 5건이어야 하고, 읽기 결과는 여전히 DELETED 5건입니다.

그 뒤에 REMOVED를 비활성으로 두어도 외래 키는 부모 키의 존재만 보므로, 새 쓰기에서 DELETED만 허용하는 규칙은 쓰기 경로가 계속 적용해야 합니다.

다음 글 인접 리스트와 순환 방지부터는 14장으로 넘어가, 부모 ID 한 칸만 저장한 인접 리스트로 깊이가 정해지지 않은 게시판의 주제 분류를 조회하고 옮기는 방법을 다룹니다.