1. 목표: 가변 속성 지원

소프트웨어는 확장성이 중요하다. 소프트웨어를 운영하는 동안에 새로운 요구사항을 유동적으로 수용할 수 있도록 소프트웨어를 설계하고 싶어한다.

1.1 관계형 데이터베이스와 메터데이터의 비유연성에 대한 논란

E.F.Codd가 처음으로 관계형 데이터베이스를 제안한 이후로 계속적으로 발생하는 논란으로 이 문제를 해결하기 위해 많은 고민을 해야 한다.
현대적인 객체 지향 프로그래밍 모델에서는 동일한 데이터 타입을 확장 및 상속하는 방법을 통해 객체의 타입도 관계를 가질 수 있기 때문에 이와 같은 방법을 사용하여 테이블 구조의 확장성을 생각해 볼 수 있다.

1.2 예제

ISSUE
모든 이슈는 이슈를 보고한 사람과 관계가 있음.
이슈는 제품과도 관계가 있을 뿐 아니라 우선순위도 갖는다.

BUG
버그가 발생한 제품의 버전과 버그의 중요도 또는 영향도와 같은 다른 속성을 가진다.

FEATUREREQUEST
자신만이 가지고 있는 스폰서 속성을 가진다.

2. 안티패턴:범용 속성 테이블 사용

가변 속성을 지원해야 할 때 일부 개발자가 흥미를 갖는 방법은 별도 테이블을 생성해 속성을 행으로 저장하는 것이다.

2.1 예제

위 그림은 두 테이블을 표시한 다이어그램이다. 이 속성 테이블의 각 행은 세개의 칼럼을 갖는다.
즉,

  • 엔터티 : 보통 이 칼럼은 하나의 엔터티에 대해 하나의 행을 가지는 부모 테이블에 대한 FK이다.
  • 속성 : 일반적인 테이블에서의 칼럼 이름을 나타내지만, 이 새로운 설계에서는 각 행마다 속성이 하나씩 들어간다.
  • 값 : 모든 엔터티는 각 속성에 대한 값을 가진다. 예를 들어, PK값이 1234인 버그가 주어졌을 때, status란 속성을 가지고, 그 속성 값은 NEW다.

이 설계는 엔터티-속성-값(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');

2.1.1 별도 테이블을 추가해 얻은것처럼 보이는 이득

별도 테이블을 추가해 다음과 같은 이득을 얻은것처럼 보인다.

  • 두 테이블 모두 적은 칼럼을 갖고 있다.
  • 새로운 속성을 지원하기 위해 컬럼 수를 늘릴 필요가 없다.
  • 특정 속성이 해당 행에 적용되지 않을 때 NULL을 채워야 하는 칼럼이 지저분하게 생기는 것을 피할 수 있다.

개선된 것처럼 보이지만, 데이터베이스 설계가 단순하다고 해서 사용하기 어려운 것을 보상해 주지는 않는다.

2.1.2 속성 조회

위와 같은 테이블을 만든뒤 조회를 해보도록 하자.
모든 버그와 보고일자 조회
일반적인 테이블에서는 다음과 같이 조회한다.


SELECT issue_id, date_reported FROM Issues;

EAV설계를 통해 테이블을 생성한 경우에는 다음과 같이 조회해야 한다.


SELECT issue_id, attr_value AS "date_reported"
FROM IssueAttributes
WHERE attr_name = 'date_reported';

2.1.3 데이터 정합성 지원

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

속성이름강제불가
속성 이름이 값이기 때문에 속성이름을 정확히 같은 값으로 저장시킬 강제 방법이 없다.

2.1.3 행을 재구성하기

각 속성들을 행으로 저장하였기 때문에 일반적인 테이블에 저장된 것처럼 이슈의 각 특징들을 하나의 행처럼 꺼내고 싶을 경우가 있을 것이다.
이런 경우 각 속성을 조인을 통해 꺼내야 하는데, 속성 개수가 늘어나면 조인 회수도 늘어나고, 쿼리의 비용은 지수적으로 증기한다.


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;

3. 안티패턴 인식 방법

다음과 같은 말을 한다면 EAV 안티패턴을 사용하고 있다는 뜻이다.

  • 이 데이터베이스는 메터데이터 변경없이 확장이 가능하지. 런타임에 새로운 속석을 정의할 수 있다.
  • 하나의 쿼리에서 조인을 최대 몇번이나 할 수 있지?
  • 우리 전자상거래 플랫폼에서는 리포트를 어떻게 생성해야 할지 이해할 수 없어. 아무래도 컨설턴트를 고용해야 할 것 같아.

4. 안티패턴 사용이 합당한 경우

일반적인 경우 EAV 안티패턴은 관계형 패러다임의 강점을 나타내는 기능 중 많은 부분을 양보해야 하기 때문에 합리화 하기는 어렵다.
그러나 다음과 같은 경우에는 사용이 합당할 수 있다.
비관계형 데이터 관리가 필요한 경우

비관계형 데이터를 관리하는 기술들

  • Berkeley DB
    Key-value 저장소
  • Cassandra
    Facebook에서 개발한 분산 칼럼지향 데이터베이스
  • CouchDB
    문서지향 데이터베이스
  • Hadoop & HBase
    대규모 반구조적 데이터 저장소
  • MongoDB
    문서지향 데이터베이스
  • Redis
    문서지향 메모리 데이터베이스
  • Tokyo Cabinet
    Key-Value 저장소

5. 해법: 제약 조건 선언하기

EVA사용하지 않고 저장하는 몇 가지 방법에 대해 알아보자.

5.1 단일 테이블 상속

관련된 모든 타입을 하나의 테이블에 저장하고, 각 타입에 있는 모든 속성을 별도의 컬럼으로 가지도록 한다.
이와 같은 설계는 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)
);

문제점
다음과 같은 문제점을 가지고 있다.

  • 테이블에 들어가는 칼럼 수의 실질적인 한계에 직면할 수 있다.
  • 어떤 속성이 어느 서브타입에 속하는지 정의하는 메터데이터가 없다.

이 방법은 다음과 같은 경우에 사용하는 것이 좋다.

  • 서브 타입의 개수가 적고 특정 서브타입에만 속하는 속성 개수가 적을 때
  • 액티브 레코드와 같은 단일 테이블 데이터베이스 접근 패턴을 사용해야 할 때 가장 좋다.

5.2 구체 테이블 상속

서브 타입별로 별도의 테이블을 만드는 것이다.


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

단일 테이블 상속보다 좋은 점

  • 특정 서브타입을 저장할 때 해당 서브타입에 적용되지 않는 속성은 저장할 수 없게 할 수 있다.
  • 단일 테이블 상속 설계에 있어야 했떤 각 행의 서브타입을 나타내는 부가적 속성이 필요하지 않다.

문제점은 다음과 같다.

  • 각 테이블별 관계를 설명할 메터 데이터가 없다.
  • 새로운 공통 속성이 추가되는 경우 모든 서브타입 테이블을 변경해야 한다.
  • 서브타입에 상관없이 모든 객체를 보는 것이 힘들다.

이 방법은 다음과 같은 경우에 사용하는 것이 좋다.

  • 모든 서브 타입을 한꺼번에 조회할 필요가 거의 없는 경우에 적합하다.

5.3 클래스 테이블 상속

테이블을 객체지향 클래스인 것처럼 생각해 상속을 흉내 내는 것이다.


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

특징은 다음과 같다.

  • 메타데이터에 의해 일대일 관계가 성립된다.
  • 검색에서 베이스 타입에 있는 속성만 참조하는 한 모든 서브타입에 대한 검색을 하는데 효율적인 방법을 제공한다.
  • 베이스테이블의 행이 어떤 서브타입을 나타내는지 알 필요가 없다.

이 방법은 다음과 같은 경우에 사용하는 것이 좋다.

  • 서브타입에 대한 조회가 많고 공통칼럼을 참조하는 경우가 많은 경우 적합하다.

5.4 반구조적 데이터


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컬럼으로 저장하는 방법을 사용한다.
장점은 다음과 같다.

  • 확장이 쉽다.

단점은 다음과 같다.

  • SQL이 특정 속성에 접근하는 것을 거의 지원하지 못한다.
  • 여러 속성을 담고 있는 TEXT 컬럼을 하나의 값으로 꺼내야 하고 코드를 복호화해 속성을 해석하는 애플리케이션 코드도 작성해야 한다.

이 방법은 다음과 같은 경우에 사용하는 것이 좋다.

  • 서브타입 개수를 제한할 수 없는 경우에 적합하다.
  • 새로운 속성을 저의할 수 있는 완전한 유연성이 필요할 때 적합하다.

5.5 사후 처리

사후 처리를 위해서 앞서 안티패턴 절에서 설명한 문제를 잘 이해해 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;
}