본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
선택 심화 · 17장 : 상속 모델과 동적 속성

타입형 EAV 모델

문자열 값 하나의 정렬·검증 오류를 재현한 뒤 속성 정의와 타입 명시 값 열, 쓰기 주체 프로시저로 동적 속성을 통제합니다.

게시판 운영자가 새 메타데이터를 자주 추가하면 매번 ALTER TABLE을 수행하는 비용이 커질 수 있습니다. 이때 엔티티-속성-값 모델은 속성을 행으로 등록해 스키마 배포 없이 선택 필드를 늘립니다. 하지만 모든 값을 VARCHAR 하나에 넣으면 숫자 비교, 날짜 범위, 필수 여부와 단위가 사라집니다.

회원 게시판에서는 핵심 제목, URL, 게시글 유형을 EAV로 옮기지 않습니다. 이 값들은 FK와 주요 쿼리가 의존하는 안정된 스키마에 남깁니다. “예상 읽기 시간”, “편집 점수”, “검수일”처럼 드물고 선택적인 메타데이터만 타입 명시 EAV 영역에 둡니다.

ch17-3의 posts_joined가 엔티티 소유자입니다. post_attribute_definition은 코드, value_type, 단위, 허용 게시글 유형을 정의합니다. post_attribute_value는 텍스트, 번호, 불리언, 날짜 중 정확히 한 열만 채웁니다.

검사는 한 값 행에서 NULL이 아닌 개수를 검증할 수 있지만 정의 행의 value_type과 일치하는지는 볼 수 없습니다. 모든 애플리케이션 쓰기는 set_post_attribute 프로시저를 통과하고, 사후 일치 검사는 정의와 값을 조인해 불일치를 찾습니다.


문자열 EAV의 타입 오류

value_text 하나는 형식을 자유롭게 하지만 의미도 자유롭게 만듭니다. CAST를 쿼리마다 반복하면 잘못된 문자열이 0 또는 NULL로 변환되고 인덱스를 활용하기 어렵습니다. attr_name 오타는 새 속성처럼 들어갑니다.

모든 속성을 두 VARCHAR로 저장한 반례
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';

사전식 정렬과 오타 속성, 유효하지 않은 날짜·불리언이 모두 저장됩니다.

오류 실행 결과
editorial_score lexical order=100,20
invalid reviewed_at accepted=2026-99-41
invalid boolean accepted=maybe
misspelled attribute accepted=editroial_score
constraint errors=0

EAV의 유연성은 스키마 책임이 사라진다는 뜻이 아닙니다. 속성 코드의 목록, 데이터 유형, 단위, 적용 대상, 수명주기를 별도 정의가 소유해야 합니다. 값은 정의 FK를 통해서만 만들어집니다.

텍스트와 번호를 같은 인덱스에서 효율적으로 비교할 수 없습니다. 타입 명시 열을 나누고 attribute_id 선두 인덱스를 만들면 editorial_score 범위는 value_number를 사용합니다. NULL 열이 늘지만 핵심 엔티티의 희소 하위 유형 열과 달리 동적 메타데이터라는 제한된 영역입니다.

EAV로 보내면 안 되는 질문

author_id, status, view_count처럼 제약 조건과 조인, 집계의 중심이 되는 값은 정규 열이어야 합니다. 자주 필터링하거나 모든 행에 필수인 속성도 승격 후보입니다.


속성 정의와 값 검증

값 행은 네 타입 명시 열 중 정확히 하나만 가집니다. 불리언은 0/1 검사를 받고 날짜는 DATE 파서를 통과합니다. 숫자는 DECIMAL로 저장해 문자열 CAST를 제거합니다.

속성 정의의 allowed_post_type이 NULL이면 모든 게시글에 적용되고, 값이 있으면 부모 판별자와 같아야 합니다. 프로시저는 게시글 행을 잠가 유형과 존재를 확인한 뒤 정의를 읽습니다.

속성 이름 변경은 코드 UPDATE가 아니라 별칭 마이그레이션으로 다루기

attr_code가 외부 API 규칙이면 이름을 덮지 않습니다. 새 정의를 만들고 값을 백필한 뒤 이전 코드를 사용 중단 예정 상태로 전환합니다. 이 실습은 is_active 플래그로 신규 쓰기를 막을 수 있게 합니다.

MySQL 8.4 메모: 불리언 합계 검사에서 NULL이 아닌 판단 네 개의 합이 1인지 확인합니다. TRUE/FALSE가 1/0으로 평가되는 MySQL 동작을 명시적으로 검증 예제 데이터에 포함합니다.


타입별 값 저장 프로시저

DDL은 속성 목록과 값 테이블을 만들고 NUMBER/TEXT/BOOLEAN/DATE 인덱스를 분리합니다. 쓰기 주체는 네 타입 명시 매개변수를 받으며 정의와 맞지 않는 조합을 SIGNAL로 거절합니다.

제한된 typed EAV schema와 set procedure
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');

숫자와 날짜가 각자 타입 명시 열에 저장되고 같은 게시글/코드는 UPDATE로 교체됩니다.

개선 실행 결과
definitions=3
text attribute values=2
non-null slots per value=1
type-definition mismatches=0
misspelled code=SIGNAL 45000

UPDATE 뒤 ROW_COUNT=0은 값이 실제로 같아 변경이 없을 때도 발생할 수 있어 INSERT에서 중복 키 오류가 발생할 수 있습니다. 실무 프로시저에서는 존재 여부를 먼저 SELECT하거나 INSERT 별칭을 이용한 UPSERT 문법을 사용할 수 있습니다. 이 교재에서는 동일값 재설정을 멱등하게 처리하기 위해 아래 증거 단계에서 존재 점검 개선안을 적용합니다.

정의의 숫자 최소/최대 같은 제약도 메타데이터로 둘 수 있지만 DB 검사가 다른 행을 참조하지 못합니다. 쓰기 주체 프로시저와 감사가 범위를 검증하고, 핵심 점수로 승격되면 정규 열 검사로 옮깁니다.

쿼리는 attr_code를 먼저 ID로 해석하기

매 행마다 정의를 조인해 코드를 비교할 수 있지만 경합 집중 범위 쿼리는 attribute_id를 상수로 해결하고 번호 인덱스를 사용합니다. 캐시된 ID는 마이그레이션에서 코드 식별자가 유지된다는 규칙이 필요합니다.


타입·적용 범위 검증

검사가 단일 값 제약을 막고 프로시저가 value_type을 대조합니다. 운영 일치 검사는 우회 가져오기로 생긴 불일치를 찾고 editorial_score가 0~100인지 확인합니다.

EAV 무결성과 numeric range query
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 v
JOIN post_attribute_definition 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 v
JOIN post_attribute_definition d ON d.attribute_id=v.attribute_id
JOIN posts_joined 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 v
JOIN post_attribute_definition 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 v
JOIN post_attribute_definition d ON d.attribute_id=v.attribute_id
JOIN posts_joined 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;

네 감사 쿼리는 0행이고 마지막 검색은 72.5인 텍스트 게시글을 반환합니다. 텍스트로 편집 점수를 넣으려는 프로시저 호출은 타입 명시 속성 값 불일치에서 멈춥니다.

같은 값 재호출을 안전하게 하려면 값 존재를 SELECT FOR UPDATE로 확인해 INSERT와 UPDATE를 분기합니다. 이 개선은 동시성도 게시글 행 잠금으로 직렬화됩니다.

EAV 속성을 정규 열로 승격하는 신호

대부분의 게시글이 값을 가지고 필수 검증이 늘며 여러 쿼리가 범위 필터를 사용하면 ALTER로 열을 추가합니다. 이중 쓰기와 백필, 차집합 0 확인 뒤 EAV 이전 정의를 비활성화합니다.

다중값 속성

현재 PK는 게시글당 속성 하나만 허용합니다. 여러 언어나 키워드는 value_order 또는 정규화된 자식 엔티티가 필요합니다. 쉼표 문자열 하나에 넣지 않습니다. 키워드가 관계 검색의 중심이면 post_tag 연결이 더 적절합니다.

삭제와 정의 수명

사용 중 정의는 RESTRICT로 삭제되지 않습니다. is_active=FALSE는 신규 쓰기 주체만 막고 기존 값과 보고서를 보존합니다. 코드 이름 변경은 별칭과 마이그레이션을 거칩니다.


EAV 적용 기준

판단 축확인할 질문
안정성필드가 자주 생기고 사라져 릴리스 DDL이 과도한가?
선택성대부분 엔티티에 값이 없는 선택 메타데이터인가?
핵심성FK·정산·주요 집계가 이 값에 의존하지 않는가?
타입정의와 타입 명시 열로 검증 가능한가?
승격사용량이 커질 때 정규 열로 옮길 절차가 있는가?

다섯 조건을 만족하는 선택 메타데이터만 EAV에 둡니다. “스키마를 설계하기 싫다”는 이유로 핵심 사실을 EAV로 보내면 쿼리와 무결성 비용이 더 커집니다. 속성 소유자와 사용 중단 정책을 목록에 기록합니다.


연습 문제

is_featured_image를 텍스트 게시글에 설정하면 거절되고 이미지 게시글에는 성공하는 예제 데이터를 완성하세요. 동일 TRUE를 두 번 설정해도 중복 오류 없이 한 행만 남도록 프로시저의 존재 분기를 고치세요.

해설과 예시 답안

게시글 행 잠금 뒤 값 PK를 SELECT해 존재 여부를 판단합니다. 있으면 UPDATE, 없으면 INSERT합니다. 적용 범위 검사는 기존 정의 로직이 이미지 게시글에만 이 속성을 허용합니다.

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_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 INTO v_attribute FROM post_attribute_definition
 WHERE attr_code='is_featured_image' AND value_type='BOOLEAN' AND is_active=TRUE;
 IF v_type<>'IMAGE' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='featured flag applies only to image'; 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(*) FROM post_attribute_value v
JOIN post_attribute_definition d ON d.attribute_id=v.attribute_id
WHERE v.post_id=@joined_image AND d.attr_code='is_featured_image';

건수는 1입니다. 텍스트 게시글 ID로 호출하면 대표 이미지 속성은 이미지 게시글에만 적용 가능하다는 오류가 발생하고 기존 텍스트 게시글 메타데이터는 변하지 않습니다.


핵심 정리

  • EAV는 스키마가 자주 바뀌는 선택 메타데이터에만 제한합니다.
  • 속성 정의가 코드·유형·단위·허용 게시글 범위를 소유합니다.
  • 값 테이블은 타입 명시 구간 하나만 채우고 쓰기 주체가 정의와 실제 구간을 대조합니다.
  • 핵심 쿼리와 필수값이 된 속성은 정규 열로 승격합니다.

다음 장에서는 구조가 유연한 JSON 문서를 검증·분해·색인하고 관계형 열과의 경계를 최종 결정합니다.