타입형 EAV 모델
모든 값을 문자열 하나에 담은 EAV가 정렬과 검증을 잃는 것을 재현하고, 속성 정의와 타입별 값 열, 쓰기 프로시저로 동적인 선택 속성을 통제합니다. 타입 검사와 업무 범위의 검사를 구분하고, 정규 열로 승격할 신호를 정합니다.
게시판의 운영자가 새 메타데이터를 자주 더하면, 그때마다 ALTER TABLE을 하는 비용이 커질 수 있습니다. 이때 엔티티-속성-값(EAV, entity-attribute-value) 모델은 속성을 행으로 등록해, 스키마의 배포 없이 선택적인 필드를 늘립니다. 하지만 모든 값을 VARCHAR 하나에 넣으면 숫자의 비교와 날짜의 범위, 필수 여부와 단위가 사라집니다.
회원 게시판은 제목과 URL, 게시글의 유형 같은 핵심 값을 EAV로 옮기지 않습니다. 이런 값은 외래 키와 주요 쿼리가 기대는 안정된 스키마에 남기고, "예상 읽기 시간", "편집 점수", "검수일"처럼 드물고 선택적인 메타데이터만 타입을 명시한 EAV 영역에 둡니다.
엔티티의 소유자는 17장 3편의 posts_joined입니다. post_attribute_definition은 속성의 코드와 value_type, 단위, 허용하는 게시글 유형을 정의하고, post_attribute_value는 텍스트와 숫자, 불리언, 날짜 가운데 정확히 한 열만 채웁니다. CHECK는 한 값 행에서 NULL이 아닌 열의 수는 검증하지만, 그 값이 정의 행의 value_type과 맞는지는 다른 행이라 볼 수 없습니다. 그래서 애플리케이션의 쓰기를 set_post_attribute로 제한하는 권한 정책을 전제로 하고, 사후의 검사는 정의와 값을 조인해 불일치를 찾습니다.
문자열 EAV의 타입 오류
value_text 하나는 형식을 자유롭게 하지만 뜻도 자유롭게 만듭니다. 쿼리마다 CAST를 반복하면 잘못된 문자열이 0이나 NULL로 바뀌고 인덱스도 쓰기 어려우며, attr_name의 오타는 새 속성처럼 들어갑니다.
DROP TABLE IF EXISTS post_eav_bad;
CREATE TABLE post_eav_bad (
post_id BIGINT UNSIGNED NOT NULL,
attr_name VARCHAR(80) NOT NULL,
value_text VARCHAR(500) NOT NULL,
PRIMARY KEY (post_id, attr_name)
) ENGINE=InnoDB;
INSERT INTO post_eav_bad VALUES
(1, 'editorial_score', '100'), (2, 'editorial_score', '20'),
(3, 'reviewed_at', '2026-99-41'), (4, 'is_verified', 'maybe'),
(5, 'editroial_score', '70');
SELECT post_id, value_text FROM post_eav_bad
WHERE attr_name = 'editorial_score' ORDER BY value_text;
SELECT * FROM post_eav_bad WHERE attr_name = 'reviewed_at';post_id | value_text
1 | 100
2 | 20
post_id | attr_name | value_text
3 | reviewed_at | 2026-99-41점수는 문자열의 사전 순서로 정렬되어 100이 20보다 앞섰고, 있을 수 없는 날짜 '2026-99-41'과 불리언이 아닌 'maybe', 오타 난 속성 이름 editroial_score가 모두 오류 없이 저장되었습니다. 오타 난 행은 editorial_score를 찾는 첫 조회에서 아예 빠집니다.
EAV가 유연하다고 해서 스키마가 할 일이 사라지는 것은 아닙니다. 속성 코드의 목록과 데이터 타입, 단위, 적용 대상, 수명은 별도의 정의가 갖고, 값은 정의의 외래 키를 거쳐서만 만들어집니다. 텍스트와 숫자를 같은 인덱스에서 효율적으로 비교할 수는 없으므로, 타입별 열을 나누고 attribute_id로 시작하는 인덱스를 만들면 editorial_score의 범위 검색은 value_number를 씁니다. NULL 열이 늘지만, 핵심 엔티티의 듬성듬성한 하위 유형 열과 달리 동적인 메타데이터라는 제한된 영역입니다.
반대로 EAV로 보내면 안 되는 값도 있습니다. author_id와 status, view_count처럼 제약과 조인, 집계의 중심이 되는 값은 정규 열이어야 하고, 자주 거르거나 모든 행에 필수인 속성도 정규 열로 올릴 후보입니다.
속성 정의와 값의 검증
값 행은 타입별 네 열 가운데 정확히 하나만 가집니다. 불리언은 0과 1의 CHECK를 받고, 날짜는 DATE로 변환되며, 숫자는 DECIMAL로 저장해 문자열의 CAST를 없앱니다. 다만 0 날짜나 불완전한 날짜의 허용은 SQL 모드에도 영향을 받으므로, DATE 타입만으로 모든 달력의 규칙이 보장된다고 보지는 않습니다.
속성 정의의 allowed_post_type이 NULL이면 모든 게시글에 적용되고, 값이 있으면 부모의 판별자와 같아야 합니다. 프로시저는 게시글의 행을 잠가 유형과 존재를 확인한 뒤 정의를 읽습니다.
attr_code가 외부 API의 약속이라면 이름을 덮어쓰지 않습니다. 새 정의를 만들고 값을 채운 뒤 이전 코드를 폐기 예정으로 바꾸며, 이 실습은 is_active 플래그로 새 쓰기를 막을 수 있게 합니다.
타입형 EAV의 검사 책임
완료된 IMAGE 게시글과 활성 BOOLEAN 정의를 확인한다. PK 존재를 확인하고 UPDATE한다. 각 단계의 값과 전환 이유를 비교합니다.
TYPED EAV · ROW CHECK / DEFINITION / WRITE POLICY
값 하나의 타입과 정의의 타입을 서로 다른 경계에서 검사한다
FK는 게시글·정의의 존재를, 행 CHECK는 네 값 중 정확히 하나와 불리언 0/1을 검사합니다. 활성 정의·허용 게시글 유형·타입 일치는 정의를 읽는 프로시저가 확인합니다.
| 검사 위치 | 실제로 확인하는 것 | 남는 범위 |
|---|---|---|
| 값 테이블 FK | 게시글과 속성 정의의 존재 | 정의의 value_type·활성 여부는 비교하지 않음 |
| 값 행 CHECK | 네 값 중 하나만 채움, 불리언은 0 또는 1 | 정의와 값 열의 타입 일치는 별도 |
| 범용 프로시저 | 완료 부모·활성 정의·허용 게시글 유형·타입 열 | editorial_score의 0~100 범위는 강제하지 않음 |
| 사후 감사 | 타입·적용 유형 불일치와 점수 범위 위반 | 위반 쓰기를 미리 거절하는 제약이 아님 |
- 값 테이블 FK
- 실제로 확인하는 것: 게시글과 속성 정의의 존재남는 범위: 정의의 value_type·활성 여부는 비교하지 않음
- 값 행 CHECK
- 실제로 확인하는 것: 네 값 중 하나만 채움, 불리언은 0 또는 1남는 범위: 정의와 값 열의 타입 일치는 별도
- 범용 프로시저
- 실제로 확인하는 것: 완료 부모·활성 정의·허용 게시글 유형·타입 열남는 범위: editorial_score의 0~100 범위는 강제하지 않음
- 사후 감사
- 실제로 확인하는 것: 타입·적용 유형 불일치와 점수 범위 위반남는 범위: 위반 쓰기를 미리 거절하는 제약이 아님
아래 범용 프로시저의 같은 값 재설정 결함은 비교 대상으로 남깁니다. 마지막 답안은 이미지 불리언 하나를 위한 별도 프로시저입니다.
이미지 플래그의 같은 TRUE 재설정 · 존재 여부로 분기
완료된 IMAGE + 활성 BOOLEAN 정의 + 0/1 값 확인
값 PK 없음 → INSERT · 이미 있음 → UPDATE
TRUE 두 번 호출 → 값 1행 · TEXT 호출 → SIGNAL 45000
답안은 이미지 전용 별도 프로시저입니다. 범용 프로시저의 ROW_COUNT=0 뒤 INSERT 결함과 점수 0~100 사전 검사 부재는 남습니다. 사후 감사는 위반 쓰기를 미리 막지 않으며, 게시글 행 잠금이 속성 정의의 동시 변경까지 잠그지는 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 첫 TRUE 검증부터 TRUE 재실행까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 세 테이블 노드, 두 FK 화살표. post_attribute_value에서 posts_joined와 post_attribute_definition을 참조한다. 행 CHECK는 정확히 한 값 열만 보장한다. 정의 value_type과 실제 값 열의 대조는 프로시저의 책임이며, editorial_score 0~100 업무 범위는 현재 감사 책임이다. 정의 FK 존재만으로 타입 일치가 보장되지 않는다. 모바일 두 화살표도 값 행에서 참조 대상으로 향한다. |
| 표의 값 | 값 테이블 FK. 실제로 확인하는 것: 게시글과 속성 정의의 존재. 남는 범위: 정의의 value_type·활성 여부는 비교하지 않음. 값 행 CHECK. 실제로 확인하는 것: 네 값 중 하나만 채움, 불리언은 0 또는 1. 남는 범위: 정의와 값 열의 타입 일치는 별도. 범용 프로시저. 실제로 확인하는 것: 완료 부모·활성 정의·허용 게시글 유형·타입 열. 남는 범위: editorial_score의 0~100 범위는 강제하지 않음. 사후 감사. 실제로 확인하는 것: 타입·적용 유형 불일치와 점수 범위 위반. 남는 범위: 위반 쓰기를 미리 거절하는 제약이 아님. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 TRUE 검증 완료된 IMAGE 게시글과 활성 BOOLEAN 정의를 확인한다. |
|
|
| INSERT 정의와 게시글 PK로 한 값을 저장한다. |
|
|
| TRUE 재실행 PK 존재를 확인하고 UPDATE한다. |
|
|
타입형 EAV의 검사 책임
완료된 IMAGE 게시글과 활성 BOOLEAN 정의를 확인한다. PK 존재를 확인하고 UPDATE한다. 각 단계의 값과 전환 이유를 비교합니다.
TYPED EAV · ROW CHECK / DEFINITION / WRITE POLICY
값 하나의 타입과 정의의 타입을 서로 다른 경계에서 검사한다
FK는 게시글·정의의 존재를, 행 CHECK는 네 값 중 정확히 하나와 불리언 0/1을 검사합니다. 활성 정의·허용 게시글 유형·타입 일치는 정의를 읽는 프로시저가 확인합니다.
| 검사 위치 | 실제로 확인하는 것 | 남는 범위 |
|---|---|---|
| 값 테이블 FK | 게시글과 속성 정의의 존재 | 정의의 value_type·활성 여부는 비교하지 않음 |
| 값 행 CHECK | 네 값 중 하나만 채움, 불리언은 0 또는 1 | 정의와 값 열의 타입 일치는 별도 |
| 범용 프로시저 | 완료 부모·활성 정의·허용 게시글 유형·타입 열 | editorial_score의 0~100 범위는 강제하지 않음 |
| 사후 감사 | 타입·적용 유형 불일치와 점수 범위 위반 | 위반 쓰기를 미리 거절하는 제약이 아님 |
- 값 테이블 FK
- 실제로 확인하는 것: 게시글과 속성 정의의 존재남는 범위: 정의의 value_type·활성 여부는 비교하지 않음
- 값 행 CHECK
- 실제로 확인하는 것: 네 값 중 하나만 채움, 불리언은 0 또는 1남는 범위: 정의와 값 열의 타입 일치는 별도
- 범용 프로시저
- 실제로 확인하는 것: 완료 부모·활성 정의·허용 게시글 유형·타입 열남는 범위: editorial_score의 0~100 범위는 강제하지 않음
- 사후 감사
- 실제로 확인하는 것: 타입·적용 유형 불일치와 점수 범위 위반남는 범위: 위반 쓰기를 미리 거절하는 제약이 아님
아래 범용 프로시저의 같은 값 재설정 결함은 비교 대상으로 남깁니다. 마지막 답안은 이미지 불리언 하나를 위한 별도 프로시저입니다.
이미지 플래그의 같은 TRUE 재설정 · 존재 여부로 분기
완료된 IMAGE + 활성 BOOLEAN 정의 + 0/1 값 확인
값 PK 없음 → INSERT · 이미 있음 → UPDATE
TRUE 두 번 호출 → 값 1행 · TEXT 호출 → SIGNAL 45000
답안은 이미지 전용 별도 프로시저입니다. 범용 프로시저의 ROW_COUNT=0 뒤 INSERT 결함과 점수 0~100 사전 검사 부재는 남습니다. 사후 감사는 위반 쓰기를 미리 막지 않으며, 게시글 행 잠금이 속성 정의의 동시 변경까지 잠그지는 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 첫 TRUE 검증부터 TRUE 재실행까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 세 테이블 노드, 두 FK 화살표. post_attribute_value에서 posts_joined와 post_attribute_definition을 참조한다. 행 CHECK는 정확히 한 값 열만 보장한다. 정의 value_type과 실제 값 열의 대조는 프로시저의 책임이며, editorial_score 0~100 업무 범위는 현재 감사 책임이다. 정의 FK 존재만으로 타입 일치가 보장되지 않는다. 모바일 두 화살표도 값 행에서 참조 대상으로 향한다. |
| 표의 값 | 값 테이블 FK. 실제로 확인하는 것: 게시글과 속성 정의의 존재. 남는 범위: 정의의 value_type·활성 여부는 비교하지 않음. 값 행 CHECK. 실제로 확인하는 것: 네 값 중 하나만 채움, 불리언은 0 또는 1. 남는 범위: 정의와 값 열의 타입 일치는 별도. 범용 프로시저. 실제로 확인하는 것: 완료 부모·활성 정의·허용 게시글 유형·타입 열. 남는 범위: editorial_score의 0~100 범위는 강제하지 않음. 사후 감사. 실제로 확인하는 것: 타입·적용 유형 불일치와 점수 범위 위반. 남는 범위: 위반 쓰기를 미리 거절하는 제약이 아님. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 TRUE 검증 완료된 IMAGE 게시글과 활성 BOOLEAN 정의를 확인한다. |
|
|
| INSERT 정의와 게시글 PK로 한 값을 저장한다. |
|
|
| TRUE 재실행 PK 존재를 확인하고 UPDATE한다. |
|
|
타입별 값의 저장 프로시저
DDL은 속성의 목록과 값 테이블을 만들고, NUMBER와 TEXT, DATE의 검색 인덱스를 둡니다. BOOLEAN 전용의 인덱스는 두지 않았습니다. 쓰기 프로시저는 타입별 매개변수 네 개를 받아 정의와 맞지 않는 조합을 SIGNAL로 거부하며, 본문과 연습 문제의 프로시저는 각자 트랜잭션을 시작하고 끝내므로 바깥 트랜잭션 없이 호출합니다.
DROP PROCEDURE IF EXISTS set_post_attribute;
DROP TABLE IF EXISTS post_attribute_value;
DROP TABLE IF EXISTS post_attribute_definition;
CREATE TABLE post_attribute_definition (
attribute_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
attr_code VARCHAR(80) NOT NULL,
value_type VARCHAR(16) NOT NULL,
allowed_post_type VARCHAR(16) NULL,
unit_code VARCHAR(20) NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
description VARCHAR(240) NOT NULL,
UNIQUE KEY uq_post_attr_code (attr_code),
CHECK (value_type IN ('TEXT', 'NUMBER', 'BOOLEAN', 'DATE')),
CHECK (allowed_post_type IS NULL
OR allowed_post_type IN ('TEXT', 'IMAGE', 'LINK', 'POLL')),
CHECK (is_active IN (0, 1))
) ENGINE=InnoDB;
CREATE TABLE post_attribute_value (
post_id BIGINT UNSIGNED NOT NULL,
attribute_id BIGINT UNSIGNED NOT NULL,
value_text VARCHAR(500) NULL,
value_number DECIMAL(18,4) NULL,
value_boolean BOOLEAN NULL,
value_date DATE NULL,
recorded_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (post_id, attribute_id),
FOREIGN KEY (post_id) REFERENCES posts_joined (post_id) ON DELETE CASCADE,
FOREIGN KEY (attribute_id)
REFERENCES post_attribute_definition (attribute_id) ON DELETE RESTRICT,
CONSTRAINT chk_post_attr_one_value CHECK (
(value_text IS NOT NULL) + (value_number IS NOT NULL)
+ (value_boolean IS NOT NULL) + (value_date IS NOT NULL) = 1
),
CONSTRAINT chk_post_attr_boolean
CHECK (value_boolean IS NULL OR value_boolean IN (0, 1)),
INDEX ix_post_attr_number (attribute_id, value_number, post_id),
INDEX ix_post_attr_text (attribute_id, value_text(120), post_id),
INDEX ix_post_attr_date (attribute_id, value_date, post_id)
) ENGINE=InnoDB;
DELIMITER //
CREATE PROCEDURE set_post_attribute(
IN p_post BIGINT UNSIGNED,
IN p_code VARCHAR(80),
IN p_text VARCHAR(500),
IN p_number DECIMAL(18,4),
IN p_boolean BOOLEAN,
IN p_date DATE
)
BEGIN
DECLARE v_attribute BIGINT UNSIGNED;
DECLARE v_value_type VARCHAR(16);
DECLARE v_allowed VARCHAR(16);
DECLARE v_post_type VARCHAR(16);
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
SELECT post_type INTO v_post_type
FROM posts_joined
WHERE post_id = p_post AND is_complete = TRUE
FOR UPDATE;
SELECT attribute_id, value_type, allowed_post_type
INTO v_attribute, v_value_type, v_allowed
FROM post_attribute_definition
WHERE attr_code = p_code AND is_active = TRUE;
IF v_post_type IS NULL OR v_attribute IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'post or attribute unavailable';
END IF;
IF v_allowed IS NOT NULL AND v_allowed <> v_post_type THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'attribute not allowed for post type';
END IF;
IF (v_value_type = 'TEXT') <> (p_text IS NOT NULL)
OR (v_value_type = 'NUMBER') <> (p_number IS NOT NULL)
OR (v_value_type = 'BOOLEAN') <> (p_boolean IS NOT NULL)
OR (v_value_type = 'DATE') <> (p_date IS NOT NULL)
OR (p_text IS NOT NULL) + (p_number IS NOT NULL)
+ (p_boolean IS NOT NULL) + (p_date IS NOT NULL) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'typed attribute value mismatch';
END IF;
UPDATE post_attribute_value
SET value_text = p_text, value_number = p_number,
value_boolean = p_boolean, value_date = p_date,
recorded_at = CURRENT_TIMESTAMP(6)
WHERE post_id = p_post AND attribute_id = v_attribute;
IF ROW_COUNT() = 0 THEN
INSERT INTO post_attribute_value
(post_id, attribute_id, value_text, value_number, value_boolean, value_date)
VALUES (p_post, v_attribute, p_text, p_number, p_boolean, p_date);
END IF;
COMMIT;
END//
DELIMITER ;
INSERT INTO post_attribute_definition
(attr_code, value_type, allowed_post_type, unit_code, description)
VALUES
('editorial_score', 'NUMBER', NULL, 'POINT', '편집 검수 기준의 0~100 점수'),
('reviewed_on', 'DATE', NULL, NULL, '편집자가 마지막으로 검수한 날짜'),
('is_featured_image', 'BOOLEAN', 'IMAGE', NULL, '대표 이미지 사용 여부');
SET @joined_text = (
SELECT post_id FROM posts_joined
WHERE post_type = 'TEXT' AND is_complete = TRUE
ORDER BY post_id LIMIT 1
);
SET @joined_image = (
SELECT post_id FROM posts_joined
WHERE post_type = 'IMAGE' AND is_complete = TRUE
ORDER BY post_id LIMIT 1
);
CALL set_post_attribute(@joined_text, 'editorial_score', NULL, 72.5, NULL, NULL);
CALL set_post_attribute(@joined_text, 'reviewed_on', NULL, NULL, NULL, '2026-07-16');
CALL set_post_attribute(@joined_text, 'editroial_score', NULL, 70, NULL, NULL);
SELECT d.attr_code, v.value_number, v.value_date
FROM post_attribute_value AS v
JOIN post_attribute_definition AS d ON d.attribute_id = v.attribute_id
WHERE v.post_id = @joined_text
ORDER BY d.attribute_id;ERROR 1644 (45000): post or attribute unavailable
attr_code | value_number | value_date
editorial_score | 72.5000 | NULL
reviewed_on | NULL | 2026-07-1617장 3편의 텍스트 게시글 1에 숫자와 날짜가 각자의 타입 열에 저장되었고, DECIMAL(18,4)이므로 72.5는 72.5000으로 나옵니다. 오타 난 코드는 정의가 없어 SIGNAL로 거부되었으므로, 문자열 EAV처럼 새 속성이 되지 않습니다.
이 프로시저의 존재 분기에는 약점이 있습니다. UPDATE가 바꾼 행이 0이면 없는 값으로 보고 INSERT하는데, 바뀐 행의 수는 값이 같아 변경이 없을 때도 0이 됩니다. 여기서는 recorded_at을 매번 현재 시각으로 바꾸므로 같은 값으로 다시 불러도 보통은 바뀐 행이 1이지만, 같은 마이크로초 안의 두 호출이나 recorded_at의 갱신을 없앤 변형에서는 0이 되어 INSERT가 1062로 실패합니다. 실무에서는 존재 여부를 먼저 SELECT하거나, 행 별칭을 쓰는 INSERT ... ON DUPLICATE KEY UPDATE를 씁니다(연습 문제 1).
정의에 숫자의 최솟값과 최댓값 같은 제약을 메타데이터로 둘 수도 있지만, CHECK는 다른 행을 참조하지 못합니다. 그래서 editorial_score의 0부터 100까지의 범위는 아래의 감사만 확인하고, 범용 프로시저는 거부하지 않습니다(연습 문제 2). 쓰는 시점에 강제해야 하거나 핵심 점수로 올라간다면 정규 열의 CHECK로 옮깁니다.
자주 실행하는 범위 쿼리는 행마다 정의를 조인해 코드를 비교하지 말고, attr_code를 먼저 attribute_id로 바꾸어 상수로 두고 숫자 인덱스를 씁니다. 이렇게 캐시한 ID는, 마이그레이션에서도 코드와 ID의 짝을 유지한다는 규칙이 있어야 안전합니다.
타입과 적용 범위의 검증
CHECK는 값이 하나인지의 위반을 막고, 프로시저는 value_type을 대조합니다. 운영의 대조는 우회한 가져오기로 생긴 불일치를 찾고, editorial_score가 0부터 100 사이인지 확인합니다.
SELECT post_id, attribute_id FROM post_attribute_value
WHERE (value_text IS NOT NULL) + (value_number IS NOT NULL)
+ (value_boolean IS NOT NULL) + (value_date IS NOT NULL) <> 1;
SELECT v.post_id, d.attr_code, d.value_type
FROM post_attribute_value AS v
JOIN post_attribute_definition AS d ON d.attribute_id = v.attribute_id
WHERE (d.value_type = 'TEXT') <> (v.value_text IS NOT NULL)
OR (d.value_type = 'NUMBER') <> (v.value_number IS NOT NULL)
OR (d.value_type = 'BOOLEAN') <> (v.value_boolean IS NOT NULL)
OR (d.value_type = 'DATE') <> (v.value_date IS NOT NULL);
SELECT v.post_id, d.attr_code
FROM post_attribute_value AS v
JOIN post_attribute_definition AS d ON d.attribute_id = v.attribute_id
JOIN posts_joined AS r ON r.post_id = v.post_id
WHERE d.allowed_post_type IS NOT NULL AND d.allowed_post_type <> r.post_type;
SELECT v.post_id, v.value_number
FROM post_attribute_value AS v
JOIN post_attribute_definition AS d ON d.attribute_id = v.attribute_id
WHERE d.attr_code = 'editorial_score'
AND (v.value_number < 0 OR v.value_number > 100);
SELECT r.post_id, r.title, v.value_number AS editorial_score
FROM post_attribute_value AS v
JOIN post_attribute_definition AS d ON d.attribute_id = v.attribute_id
JOIN posts_joined AS r ON r.post_id = v.post_id
WHERE d.attr_code = 'editorial_score' AND v.value_number >= 70
ORDER BY v.value_number DESC;post_id | title | editorial_score
1 | joined 상속 읽기 | 72.5000네 감사 쿼리는 빈 결과이고, 마지막 검색은 72.5점인 텍스트 게시글을 돌려줍니다. 편집 점수를 텍스트로 넣으려는 호출은 프로시저의 typed attribute value mismatch에서 멈춥니다.
모든 쓰기 주체가 같은 게시글의 행을 먼저 잠근다는 전제에서, 그 게시글의 값 갱신은 차례로 처리됩니다. 다만 이 잠금이 속성 정의의 동시 변경까지 막지는 않으므로, 정의의 변경도 따로 관리합니다.
대부분의 게시글이 값을 갖게 되고, 필수의 검증이 늘며, 여러 쿼리가 범위로 거른다면 ALTER로 정규 열을 더할 때입니다. 두 곳에 함께 쓰는 기간과 백필을 거쳐, 차집합이 빈 결과인지 확인한 뒤 EAV의 이전 정의를 비활성화합니다.
지금의 기본 키는 게시글마다 속성 하나에 값을 하나만 허용합니다. 여러 언어나 여러 키워드가 필요하다면 value_order나 정규화한 자식 엔티티가 필요하며, 쉼표로 이은 문자열 하나에 넣지 않습니다. 키워드가 관계 검색의 중심이라면 post_tags 같은 연결 테이블이 더 알맞습니다. 쓰고 있는 정의는 RESTRICT로 지워지지 않고, is_active = FALSE는 새 쓰기만 막아 기존 값과 보고서를 보존하며, 코드 이름의 변경은 별칭과 마이그레이션을 거칩니다.
| 따져 볼 것 | 질문 |
|---|---|
| 안정성 | 필드가 자주 생기고 사라져 릴리스마다의 DDL이 지나친가 |
| 선택성 | 대부분의 엔티티에 값이 없는 선택적인 메타데이터인가 |
| 핵심성 | 외래 키와 정산, 주요 집계가 이 값에 기대지 않는가 |
| 타입 | 정의와 타입별 열로 검증할 수 있는가 |
| 승격 | 쓰임이 커질 때 정규 열로 옮길 절차가 있는가 |
다섯 조건을 모두 만족하는 선택적인 메타데이터만 EAV에 둡니다. 스키마를 설계하기 귀찮다는 이유로 핵심 사실을 EAV로 보내면 쿼리와 무결성의 비용이 더 커지므로, 속성마다 담당자와 폐기 정책을 목록에 기록합니다.
정리
- 모든 값을 문자열 하나에 담은 EAV는 정렬과 검증, 오타의 거부를 잃습니다.
- EAV는 스키마가 자주 바뀌는 선택적인 메타데이터에만 제한합니다.
- 속성 정의가 코드와 타입, 단위, 허용하는 게시글의 범위를 가집니다.
- 값 테이블은 타입별 열 하나만 채우고, 쓰기 프로시저가 정의와 실제로 채운 열을 대조합니다.
- 타입이 맞는 것과 업무의 범위를 지키는 것은 다른 검사이며, 핵심 쿼리와 필수 값이 된 속성은 정규 열로 승격합니다.
연습 문제
1. 이미지 전용 플래그와 존재 분기
is_featured_image를 텍스트 게시글에 설정하면 거부되고 이미지 게시글에는 성공하는 예제를 완성해 보세요. 같은 TRUE를 두 번 설정해도 중복 오류 없이 한 행만 남도록, 바뀐 행의 수가 아니라 존재 여부로 분기해야 합니다.
해설 보기
게시글의 행을 잠근 뒤 값의 기본 키를 SELECT해 존재 여부를 판단하고, 있으면 UPDATE, 없으면 INSERT합니다. 이 이미지 전용 답안은 완료된 이미지 부모와 활성 BOOLEAN 정의, 허용 유형, NULL이 아닌 0이나 1의 값을 먼저 검사하며, 범용 프로시저 자체를 바꾸지는 않습니다.
DROP PROCEDURE IF EXISTS set_image_feature_attribute;
DELIMITER //
CREATE PROCEDURE set_image_feature_attribute(
IN p_post BIGINT UNSIGNED,
IN p_value BOOLEAN
)
BEGIN
DECLARE v_attribute BIGINT UNSIGNED;
DECLARE v_type VARCHAR(16);
DECLARE v_allowed VARCHAR(16);
DECLARE v_exists INT DEFAULT 0;
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
SELECT post_type INTO v_type
FROM posts_joined
WHERE post_id = p_post AND is_complete = TRUE
FOR UPDATE;
SELECT attribute_id, allowed_post_type INTO v_attribute, v_allowed
FROM post_attribute_definition
WHERE attr_code = 'is_featured_image' AND value_type = 'BOOLEAN'
AND is_active = TRUE;
IF v_type IS NULL OR v_type <> 'IMAGE' OR v_attribute IS NULL
OR v_allowed IS NULL OR v_allowed <> 'IMAGE'
OR p_value IS NULL OR p_value NOT IN (0, 1) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'active image flag and boolean value required';
END IF;
SELECT COUNT(*) INTO v_exists
FROM post_attribute_value
WHERE post_id = p_post AND attribute_id = v_attribute
FOR UPDATE;
IF v_exists = 0 THEN
INSERT INTO post_attribute_value (post_id, attribute_id, value_boolean)
VALUES (p_post, v_attribute, p_value);
ELSE
UPDATE post_attribute_value
SET value_boolean = p_value, recorded_at = CURRENT_TIMESTAMP(6)
WHERE post_id = p_post AND attribute_id = v_attribute;
END IF;
COMMIT;
END//
DELIMITER ;
CALL set_image_feature_attribute(@joined_image, TRUE);
CALL set_image_feature_attribute(@joined_image, TRUE);
SELECT COUNT(*) AS flag_rows
FROM post_attribute_value AS v
JOIN post_attribute_definition AS d ON d.attribute_id = v.attribute_id
WHERE v.post_id = @joined_image AND d.attr_code = 'is_featured_image';
CALL set_image_feature_attribute(@joined_text, TRUE);flag_rows
1
ERROR 1644 (45000): active image flag and boolean value required같은 TRUE를 두 번 설정해도 두 번째 호출은 기존 행을 찾아 UPDATE하므로 행은 하나입니다. 텍스트 게시글로 부르면 유형이 IMAGE가 아니어서 거부되고, 텍스트 게시글의 기존 메타데이터는 그대로입니다.
2. 타입의 불일치와 범위의 위반
완료된 게시글과 활성 NUMBER 정의가 있고 적용 유형도 맞습니다. A는 p_text에 문자열 '72.5'만, B는 p_number에 숫자 120만 주고 다른 값 인자는 NULL로 둡니다. 두 호출 모두 새 속성값을 만드는 경우이고 다른 오류는 없을 때, 프로시저가 먼저 거부하는 호출과 저장된 뒤 점수 감사가 찾는 호출은 각각 무엇인가요?
해설 보기
A는 값 인자가 하나뿐이지만, 정의는 NUMBER인데 TEXT 인자를 주었으므로 typed attribute value mismatch로 거부됩니다. 값 열 하나만 채운다는 행의 CHECK만으로는 정의의 타입과 맞는지 판단할 수 없으므로, 이 대조는 프로시저가 맡습니다.
B는 NUMBER 인자 하나이므로 범용 프로시저의 타입 검사를 통과해 저장됩니다. 120은 DECIMAL(18,4)로 나타낼 수 있지만 editorial_score의 0부터 100까지라는 업무 범위를 벗어나므로, 본문의 넷째 감사 쿼리가 찾아냅니다. 타입이 맞는지와 그 속성의 허용 범위 안인지는 서로 다른 검사입니다.
다음 글 JSON 구조와 검증부터는 18장으로 넘어가, 구조가 유연한 JSON 문서를 검증하고 분해하고 색인하며, 어떤 값을 관계형 열에 둘지 최종적으로 정합니다.