소프트웨어는 확장성이 중요하다. 소프트웨어를 운영하는 동안에 새로운 요구사항을 유동적으로 수용할 수 있도록 소프트웨어를 설계하고 싶어한다.
E.F.Codd가 처음으로 관계형 데이터베이스를 제안한 이후로 계속적으로 발생하는 논란으로 이 문제를 해결하기 위해 많은 고민을 해야 한다.
현대적인 객체 지향 프로그래밍 모델에서는 동일한 데이터 타입을 확장 및 상속하는 방법을 통해 객체의 타입도 관계를 가질 수 있기 때문에 이와 같은 방법을 사용하여 테이블 구조의 확장성을 생각해 볼 수 있다.
ISSUE
모든 이슈는 이슈를 보고한 사람과 관계가 있음.
이슈는 제품과도 관계가 있을 뿐 아니라 우선순위도 갖는다.
BUG
버그가 발생한 제품의 버전과 버그의 중요도 또는 영향도와 같은 다른 속성을 가진다.
FEATUREREQUEST
자신만이 가지고 있는 스폰서 속성을 가진다.
가변 속성을 지원해야 할 때 일부 개발자가 흥미를 갖는 방법은 별도 테이블을 생성해 속성을 행으로 저장하는 것이다.
위 그림은 두 테이블을 표시한 다이어그램이다. 이 속성 테이블의 각 행은 세개의 칼럼을 갖는다.
즉,
이 설계는 엔터티-속성-값(Entity-Attribute-Value)또는 줄여서 EAV라 불린다.
? Open Schema, schemaless, name-value pairs
EAV설계에서는 다음과 같이 설계가 가능하다.
CREATE TABLE Issue (
issue_id SERIAL PRIMARY KEY
);
INSERT INTO Issue(issue_id) VALUES (1234);
CREATE TABLE IssueAttributes(
issue_id BIGINT UNSIGNED NOT NULL,
attr_name VARCHAR(100) NOT NULL,
attr_value VARCHAR(100),
PRIMARY KEY (issue_id, attr_name),
FOREIGN KEY (issue_id) REFERENCES Issues(issue_id)
);
INSERT INTO IssueAttributes (issue_id, attr_name, attr_value)
VALUES
(1234, 'product', '1'),
(1234, 'date_reported', '2009-06-01'),
(1234, 'status', 'NEW');
별도 테이블을 추가해 다음과 같은 이득을 얻은것처럼 보인다.
개선된 것처럼 보이지만, 데이터베이스 설계가 단순하다고 해서 사용하기 어려운 것을 보상해 주지는 않는다.
위와 같은 테이블을 만든뒤 조회를 해보도록 하자.
모든 버그와 보고일자 조회
일반적인 테이블에서는 다음과 같이 조회한다.
SELECT issue_id, date_reported FROM Issues;
EAV설계를 통해 테이블을 생성한 경우에는 다음과 같이 조회해야 한다.
SELECT issue_id, attr_value AS "date_reported"
FROM IssueAttributes
WHERE attr_name = 'date_reported';
EAV설계를 사용하면 다음과 같은 문제점이 있다.
필수속성 사용 불가
EAV설계에서는 각 속성이 컬럼이 아니라 행으로 대응되기 때문에 제약 조건으로 date_reported라는 설정값이 항상 입력되도록 하기 힘들다.
SQL데이터타입 사용 불가
각 속성마다 받아야 하는 속성값이 다를 진데, 이것을 데이터 타입을 사용하여 제약하기가 힘들어 진다. 즉, 각 속성마다 유효하지 않은 값을 받지 않을 방법이 없다.
일반적인 DB의 경우 예방이 가능하다.
INSERT INTO Issues (date_reported) VALUES ('banana'); -- ERROR!
EAV의 경우 에러 발생이 나지 않아서 검출이 안된다.
INSERT INTO IssueAttributes (issue_id, attr_name, attr_value)
VALUES (1234, 'date_reported', 'banana'); -- Not an error!
각 type별 컬럼을 만들어서 사용하는 경우도 있으나 이와 같은 경우 쿼리가 너무 복잡해져서 문제가 될 수 있다.
SELECT issue_id, COALESCE(attr_value_date, attr_value_datetime,
attr_value_integer, attr_value_numeric, attr_value_float,
attr_value_string, attr_value_text) AS "date_reported"
FROM IssueAttributes
WHERE attr_name = 'date_reported';
참조일관성강제불가
EAV설계를 사용하면 FK를 사용할 수 없다.
일반적인 DB라면 다음과 같이 처리가 가능하다.
CREATE TABLE Issues (
issue_id SERIAL PRIMARY KEY,
-- other columns
status VARCHAR(20) NOT NULL DEFAULT 'NEW',
FOREIGN KEY (status) REFERENCES BugStatus(status)
);
EAV설계에서는 다음과 같은 처리밖에 할 수 없다.
CREATE TABLE IssueAttributes (
issue_id BIGINT UNSIGNED NOT NULL,
attr_name VARCHAR(100) NOT NULL,
attr_value VARCHAR(100),
FOREIGN KEY (attr_value) REFERENCES BugStatus(status)
);
속성이름강제불가
속성 이름이 값이기 때문에 속성이름을 정확히 같은 값으로 저장시킬 강제 방법이 없다.
각 속성들을 행으로 저장하였기 때문에 일반적인 테이블에 저장된 것처럼 이슈의 각 특징들을 하나의 행처럼 꺼내고 싶을 경우가 있을 것이다.
이런 경우 각 속성을 조인을 통해 꺼내야 하는데, 속성 개수가 늘어나면 조인 회수도 늘어나고, 쿼리의 비용은 지수적으로 증기한다.
SELECT i.issue_id, i1.attr_value as "date_reported",
i2.attr_value as "status", i3.attr_value as "priority",
i4.attr_value as "description"
FROM Issues AS i
LEFT OUTER JOIN IssueAttributes AS i1
ON i.issue_id = i1.issue_id AND i1.attr_name = 'date_reported'
LEFT OUTER JOIN IssueAttributes AS i2
ON i.issue_id = i2.issue_id AND i2.attr_name = 'status'
LEFT OUTER JOIN IssueAttributes AS i3
ON i.issue_id = i3.issue_id AND i3.attr_name = 'priority'
LEFT OUTER JOIN IssueAttributes AS i4
ON i.issue_id = i4.issue_id AND i4.attr_name = 'description'
WHERE i.issue_id = 1234;
다음과 같은 말을 한다면 EAV 안티패턴을 사용하고 있다는 뜻이다.
일반적인 경우 EAV 안티패턴은 관계형 패러다임의 강점을 나타내는 기능 중 많은 부분을 양보해야 하기 때문에 합리화 하기는 어렵다.
그러나 다음과 같은 경우에는 사용이 합당할 수 있다.
비관계형 데이터 관리가 필요한 경우
비관계형 데이터를 관리하는 기술들
EVA사용하지 않고 저장하는 몇 가지 방법에 대해 알아보자.
관련된 모든 타입을 하나의 테이블에 저장하고, 각 타입에 있는 모든 속성을 별도의 컬럼으로 가지도록 한다.
이와 같은 설계는 Martin Fowler의 "Patterns of Enterprise Application Architecture"에 나와있다.
CREATE TABLE Issues (
issue_id SERIAL PRIMARY KEY,
reported_by BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED,
priority VARCHAR(20),
version_resolved VARCHAR(20),
status VARCHAR(20),
issue_type VARCHAR(10), -- BUG or FEATURE
severity VARCHAR(20), -- only for bugs
version_affected VARCHAR(20), -- only for bugs
sponsor VARCHAR(50), -- only for feature requests
FOREIGN KEY (reported_by) REFERENCES Accounts(account_id)
FOREIGN KEY (product_id) REFERENCES Products(product_id)
);
문제점
다음과 같은 문제점을 가지고 있다.
이 방법은 다음과 같은 경우에 사용하는 것이 좋다.
서브 타입별로 별도의 테이블을 만드는 것이다.
CREATE TABLE Bugs (
issue_id SERIAL PRIMARY KEY,
reported_by BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED,
priority VARCHAR(20),
version_resolved VARCHAR(20),
status VARCHAR(20),
severity VARCHAR(20), -- only for bugs
version_affected VARCHAR(20), -- only for bugs
FOREIGN KEY (reported_by) REFERENCES Accounts(account_id),
FOREIGN KEY (product_id) REFERENCES Products(product_id)
);
CREATE TABLE FeatureRequests (
issue_id SERIAL PRIMARY KEY,
reported_by BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED,
priority VARCHAR(20),
version_resolved VARCHAR(20),
status VARCHAR(20),
sponsor VARCHAR(50), -- only for feature requests
FOREIGN KEY (reported_by) REFERENCES Accounts(account_id),
FOREIGN KEY (product_id) REFERENCES Products(product_id)
);
단일 테이블 상속보다 좋은 점
문제점은 다음과 같다.
이 방법은 다음과 같은 경우에 사용하는 것이 좋다.
테이블을 객체지향 클래스인 것처럼 생각해 상속을 흉내 내는 것이다.
CREATE TABLE Issues (
issue_id SERIAL PRIMARY KEY,
reported_by BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED,
priority VARCHAR(20),
version_resolved VARCHAR(20),
status VARCHAR(20),
FOREIGN KEY (reported_by) REFERENCES Accounts(account_id),
FOREIGN KEY (product_id) REFERENCES Products(product_id)
);
CREATE TABLE Bugs (
issue_id BIGINT UNSIGNED PRIMARY KEY,
severity VARCHAR(20),
version_affected VARCHAR(20),
FOREIGN KEY (issue_id) REFERENCES Issues(issue_id)
);
CREATE TABLE FeatureRequests (
issue_id BIGINT UNSIGNED PRIMARY KEY,
sponsor VARCHAR(50),
FOREIGN KEY (issue_id) REFERENCES Issues(issue_id)
);
특징은 다음과 같다.
이 방법은 다음과 같은 경우에 사용하는 것이 좋다.
CREATE TABLE Issues (
issue_id SERIAL PRIMARY KEY,
reported_by BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED,
priority VARCHAR(20),
version_resolved VARCHAR(20),
status VARCHAR(20),
issue_type VARCHAR(10), -- BUG or FEATURE
attributes TEXT NOT NULL, -- all dynamic attributes for the row
FOREIGN KEY (reported_by) REFERENCES Accounts(account_id),
FOREIGN KEY (product_id) REFERENCES Products(product_id)
);
다음과 같은 경우 사용한다.
Martin Fowler가 직렬화된 LOB(Serialized LOB)라고 하는 데이터 속성 이름과 값을 XML 또는 JSON 형식으로 부호화해 TEXT컬럼으로 저장하는 방법을 사용한다.
장점은 다음과 같다.
단점은 다음과 같다.
이 방법은 다음과 같은 경우에 사용하는 것이 좋다.
사후 처리를 위해서 앞서 안티패턴 절에서 설명한 문제를 잘 이해해 EAV를 사용할 때 수반되는 부가 작업을 예상하고 계획해야 한다.
또한 쿼리 작성 시 일반적인 테이블에 데이터가 저장되어 있을 때처럼 엔터티를 단일 행으로 조회하는 쿼리를 작성하면 안된다. 대신 엔터티에 관련된 속성을 조회해 저장되어 있는 그대로 한 행씩 꺼내 처리해야 한다.
SELECT issue_id, attr_name, attr_value
FROM IssueAttributes
WHERE issue_id = 1234;
이 결과 집합에 대해 루프를 돌면서 객체의 속성을 설정하는 애플리케이션 코드를 작성해야 한다.
<?php
$objects = array();
$stmt = $pdo->query(
"SELECT issue_id, attr_name, attr_value
FROM IssueAttributes
WHERE issue_id = 1234");
while ($row = $stmt->fetch()) {
$id = $row['issue_id'];
$field = $row['attr_name'];
$value = $row['attr_value'];
if (!array_key_exists($id, $objects)) {
$objects[$id] = new stdClass();
}
$objects[$id]->$field = $value;
}