컨텐츠 관리 데이터베이스에 웹 사이트에 공개할 기사를 저장했다. 기사 테이블과 태그 테이블 사이의 다대다 관계를 위해 교차 테이블을 사용했다.
CREATE TABLE ArticleTags
(
id BIGINT PRIMARY KEY,
article_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
FOREIGN KEY (article_id) REFERENCES Articles (id),
FOREIGN KEY (tag_id) REFERENCES Tags (id)
)
그러나, 특정 태그가 다린 기사 수를 세는 쿼리에서 잘못된 결과가 나오고 있었다. "경제" 태그가 달린 가사가 다섯 개라는 것을 알고 있었지만, 쿼리를 실행하면 일곱 개로 나왔다.
SELECT tag_id, COUNT(*) AS articles_per_tag
FROM ArticleTags
WHERE tag_id = 327;
그 tag_id와 같은 모든 행을 조회해봤더니, 태그가 하나의 기사와 중복해 연관되어 있었다. 세 개의 행은 id 값만 달랐지 동일한 연관을 나타내는 것이었다.
id | tag_id | article_id |
22 | 327 | 1234 |
23 | 327 | 1234 |
24 | 327 | 1234 |
이 테이블은 PK를 가지고 있었지만, PK가 중요 칼럼의 중복을 막지 못했다. 나머지 두 칼럼에 대해 UNIQUE 제약 조건을 생성하면 해결되겠지만, 그렇다면 id 칼럼은 왜 필요한 것일까?
목표: PK 관례 확립
데이터베이스 설계를 접했던 사람이라면, 모두 PK가 중요하고 꼭 필요한 테이블의 일부라는 사실을 알 것이다. PK는 좋은 데이터베이스 설계에 정말 중요하다. PK는 테이블 내의 모든 행이 유일함을 보장하기 때문에, 각 행에 접근하는 논리적 메커니즘이 되고 중복 행이 저장되는 것을 방지한다. 또한 PK는 관계를 생성할 때 FK로부터 참조되기도 한다. 까다로운 부분은 PK로 사용할 칼럼을 선정하는 일이다. 대부분의 테이블에서 어느 속성의 값이든 하나 이상의 행에서 나타날 잠재적 가능성이 있다. 교과서적인 예제로 자주 나오는 이름도 분명 중복될 수 있다. 심지어 이메일 주소도 마찬가지다.
이런 테이블에는 테이블로 모델링한 영역에서는 아무런 의미도 가지지 않는 인위적인 값을 저장할 새로운 칼럼이 필요하다. 이 칼럼을 PK로 사용하면 다른 속성 칼럼에는 중복 값이 들어가는 것을 허용하는 반면, 특정 행에 유일하게 접근할 수 있게 된다. 이런 형태의 PK를 가상키 또는 대체키라고 한다.
안티패턴: 만능키
책이나 기사, 프로그래밍 프레임워크는 데이터베이스 내 모든 테이블이 다음과 같은 특성을 가지는 PK 칼럼을 가지도록 하는 문화적 관례를 만들었다.
- PK 칼럼의 이름은 id다.
- PK 칼럼의 데이터 타입은 32비트 또는 64비트 정수다.
- 유일한 값은 자동 생성된다.
모든 테이블에 id란 이름의 칼럼이 있는 것은 너무도 흔해져 이게 PK와 동의어가 되어 버렸다. SQL을 배우는 프로그래머들은 PK가 항상 다음과 같은 식으로 정의되는 칼럼이라는 잘못된 생각을 갖게 된다.
CREATE TABLE Bugs (
id BIGINT PRIMARY KEY,
description VARCHAR(1000),
...
)
모든 테이블에 id 칼럼을 추가하는 것은, 그 사용을 이상하게 만드는 몇가지 효과를 초래한다.
문제1 - 중복 키 생성
테이블 안의 다른 칼럼이 자연키로 사용될 수 있는 상황에서조차 단지 통념에 따라 id 칼럼을 PK로 정의한 것을 봤을 것이다. 그 다른 칼럼에 UNIQUE 제약조건이 설정되어 있는 경우도 있다. 예를 들어, Bugs 테이블에서는 프로젝트 코드를 앞에 붙여 bug_id를 만들 수 있을 것이다.
CREATE TABLE Bugs (
id BIGINT PRIMARY KEY,
bug_id VARCHAR(10) UNIQUE,
description VARCHAR(1000),
...
)
INSERT INTO Bugs (bug_id, description, ...)
VALUES ('VIS-018', 'crashes on save', ...)
이 예에서 bug_id 칼럼은 각 행을 유일하게 식별할 수 있도록 해준다는 면에서 id와 사용 목적이 동일하다. 그럼 굳이 id가 왜 필요할까?
문제2 - 중복 행 허용
복합키는 여러 칼럼을 포함한다. 복합키가 사용되는 전형적인 예는 BugsProducts와 같은 교차 테이블 안에서다. PK는 특정한 bug_id와 product_id 값의 조합이 테이블 안에서 한 번만 나타난다는 것을 보장해야 한다. 각 값이 다른 쌍으로 여러 번 나타날 수 있을지라도 말이다. 그러나, id 칼럼을 PK로 사용하는 경우에는 유일해야 하는 두 칼럼에 제약조건이 적용되지 않는다.
CREATE TABLE BugsProducts
(
id BIGINT PRIMARY KEY,
bug_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
FOREIGN KEY (bug_id) REFERENCES Bugs (bug_id),
FOREIGN KEY (product_id) REFERENCES Products (product_id)
)
INSERT INTO BugsProducts (bug_id, product_id)
VALUES (1234, 1), (1234, 1), (1234, 1); -- 중복이 허용됨
Bugs와 Products를 연결하기 위해 이 교차 테이블을 사용할 때, 중복 때문에 의도하지 않은 결과가 발생한다. 중복을 방지하기 위해서는 id뿐 아니라 다른 두 칼럼에 UNIQUE 제약 조건을 걸어줘야 한다.
CREATE TABLE BugsProducts
(
id BIGINT PRIMARY KEY,
bug_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
UNIQUE KEY (bug_id, product_id),
FOREIGN KEY (bug_id) REFERENCES Bugs (bug_id),
FOREIGN KEY (product_id) REFERENCES Products (product_id)
)
그러나 이 두칼럼에 UNIQUE 제약조건을 걸어야 한다면, id 칼럼은 불필요한 것이다.
안티패턴 인식 방법
이 안티패턴의 징후는 쉽게 인식할 수 있다. 테이블에서 PK 칼럼 이름으로 id가 사용되고 있으면 이 안티패턴의 징후로 볼 수 있다. 좀 더 의미 있는 이름 대신 id를 선호해야 할 이유는 없다. 다음과 같은 말 또한 이 안티패턴의 증거가 될 수 있다.
- "이 테이블에는 PK가 없어도 될 것 같은데." → 이런 말을 하는 개발자는 PK와 가상키 용어의 의미를 혼동하는 것이다. 모든 테이블은 중복 행을 방지하고 각 행을 유일하게 식별하기 위해 PK 제약조건을 가져야 한다. 아마 자연키나 복합키 사용이 필요할 것이다.
- "다대다 연결에서 왜 중복이 발생했지?" → 다대다 관계를 위한 교차 테이블에는 FK 칼럼을 묶어 PK 제약조건을 걸거나 최소한 UNIQUE 제약조건이라도 걸어줘야 한다.
- "나는 데이터베이스 이론에서 값은 색인 테이블로 옮기고 ID로 참조해야 한다고 하는 걸 읽었어. 그러나 그렇게 하고 싶지 않아. 내가 원하는 실제 값을 얻기 위해 매번 조인을 해야 하기 때문이지." → 이는 데이터베이스 설계 이론에서 말하는 정규화에 대한 흔한 오해다. 정규화는 가상키와 아무런 상관이 없다.
안티패턴 사용이 합당한 경우
일부 객체-관계 프레임워크에서는 CoC(Convention over Configuration)를 통해 개발을 단순화한다. 이런 프레임워크에서는 모든 테이블이 동일한 방식(칼럼 이름은 id고 데이터 타입은 정수인 가상키)으로 PK를 정의한다고 가정한다. 이런 프레임워크를 사용한다면 그 관례를 따르고 싶을 것이다. 그렇게 해야 프레임워크의 다른 원하는 기능을 사용할 수 있기 때문이다. 물론 가상키를 사용하고 자동 증가하는 정수를 사용해 키값을 할당하는 것이 잘못은 아니다. 그러나 모든 테이블에 가상키가 필요한 것도 아니고, 모든 가상키 컬럼 이름을 id로 해야하는 것도 아니다. 가상키는 지나치게 긴 자연키를 대체하기 위해 사용한다면 적절한 선택이다. 예를 들어, 파일 시스템의 파일 속성을 저장하는 테이블에서, 파일 경로는 좋은 자연키가 될 수 있겠지만, 이렇게 긴 문자열을 키로 하면 인덱스를 만들고 유지하는 데 많은 비용이 들 것이다.
해법: 상황에 맞추기
PK는 제약조건이지 데이터 타입이 아니다. 데이터 타입이 인덱스를 지원하기만 하면, 어느 칼럼 또는 칼럼의 묶음에 대해서도 PK를 선언할 수 있다. 또한 테이블의 특정 칼럼을 PK로 잡지 않고도 자동 증가하는 정수값을 가지도록 정의할 수 있다. 이 두 개념은 서로 독립적인 것이다. 좋은 설계 방법에 경직된 관례가 끼어드는 것을 허용하지 말기 바란다.
해법: 있는 그대로 말하기
PK에 의미 있는 이름을 선택해야 한다. 이 이름은 PK가 식별하는 엔티티의 타입을 나타내야 한다. 예를 들어, Bugs 테이블의 PK는 bug_id가 되어야 한다. FK에서도 가능하다면 같은 칼럼 이름을 사용해야 한다. 이는 종종 PK 이름이 스키마 내에서 유일해야 함을 뜻한다. 하나가 다른 쪽의 FK가 아닌 한, 동일한 PK 이름이 다른 테이블에 나오면 안된다. 그러나 예외가 있다. 연결의 본질을 더 잘 표현하는 경우라면, FK를 자신이 참조하는 PK 이름과 다르게 하는 것도 괜찮다.
CREATE TABLE Bugs (
....
reported_by BIGINT NOT NULL,
FOREIGN KEY (reported_by) REFERENCES Accounts(account_id)
);
해법: 자연키와 복합키 포용
유일함이 보장되고, NULL 값을 가지는 경우가 없고, 행을 식별하는 용도로 사용할 수 있는 속성이 테이블에 있다면, 단지 통념을 따르기 위해 가상키를 추가해야 한다는 의무감을 느낄 필요는 없다. 실제로 테이블에 있는 각 속성은 변하기 마련이고, 유일하지 않게 될 수도 있다. 데이터베이스는 프로젝트 기간 동안 변화하며, 결정권자들이 자연키의 신성함을 존중하지 않을 수도 있다. 처음에는 자연키로 손색이 없어 보이던 칼럼이 나중에 알고 보니 적법하게 중복을 허용하는 것으로 밝혀질 수도 있다. 이런 경우에는 가상키를 사용할 수 있다.
복합키가 적절한 경우에는 이를 사용하기를 바란다. BugsProducts 테이블에서와 같이 여러 칼럼의 조합으로 행을 가장 잘 식별할 수 있다면, 이 칼럼 조합을 복합키로 사용해야 한다.
CREATE TABLE BugsProducts
(
bug_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
PRIMARY KEY (bug_id, product_id),
FOREIGN KEY (bug_id) REFERENCES Bugs (bug_id),
FOREIGN KEY (product_id) REFERENCES Products (product_id)
);
INSERT INTO BugsProducts (bug_id, product_id)
VALUES (1234, 1), (1234, 2), (1234, 3);
INSERT INTO BugsProducts (bug_id, product_id)
VALUES (1234, 1); -- error: duplicate entry
이런식으로 설계했다면, 제일 처음에 봤던 문제인 ArticleTags에서 발생한 중복 문제도 해결할 수 있었을 것이다. 복합 PK를 참조하는 FK또한 복합키가 되어야 함에 유의하기 바란다. 종속되는 테이블에 이렇게 칼럼 조합을 중복해야 하는 것은 안 좋아 보이지만, 장점도 있다. 중복된 칼럼 값을 얻을 때 조인을 안 해도 되기 때문에 쿼리가 단순해진다.
참고로, 복합 PK를 참조하는 FK또한 복합키가 되어야 한다는 말은 이런 것이다.
CREATE TABLE OrderDetail (
OrderID INT,
ProductID INT,
Quantity INT,
PRIMARY KEY (OrderID, ProductID)
);
이런 OrderDetail 이라는 테이블이 있고 이 테이블의 PK는 OrderID, ProductID 복합키를 가지고 있을 때, 이를 참조하는 테이블에서 FK도 반드시 OrderID, ProductID를 모두 포함해야 한다는 뜻이다.
CREATE TABLE Shipment (
ShipmentID INT,
OrderID INT,
ProductID INT,
ShipDate DATE,
PRIMARY KEY (ShipmentID),
FOREIGN KEY (OrderID, ProductID) REFERENCES OrderDetail(OrderID, ProductID)
);
SQL AntiPatterns Tip
관례는 도움이 될 때만 좋은 것이다.
'SQL AntiPatterns' 카테고리의 다른 글
1. 다중값 속성 저장 : 쉼표로 구분된 목록에 저장 (0) | 2024.12.30 |
---|