기본 키 설계가 스키마의 결합성과 성능, 일관성에 어떤 영향을 주는지 설명하고, 구조화된 기본 키라는 대안을 장단점과 함께 제안합니다.
때때로 고객의 쿼리를 최적화하다가 벽에 부딪히곤 합니다. 여러 해에 걸쳐, 저는 대부분의 경우가 두 범주 중 하나에 들어간다는 점을 깨달았습니다: (1) 인프라의 성능이 아니라 클라우드 청구서를 기준으로 한 비현실적인 기대, (2) 기본 키 설계. 이 글에서는 기본 키가 어떻게 테이블을 울타리 친 정원처럼 가둬서 데이터베이스 스키마를 서로 분리된 조각으로 무너뜨리고, 결과적으로 SQL의 집합 기반 능력을 사실상 무력화할 수 있는지 설명합니다. 또한 기본 키 설계에 대한 대안적 접근을 제안하고 그 장단점도 논의합니다. 저는 이것이 자연 키 및 대리 키와 어떻게 다른지 강조하기 위해 _구조화된 기본 키_라는 용어를 사용합니다.
목차:
이 글에서는 단순한 웹 쇼핑몰을 위한 작은 스키마를 사용하겠습니다. 먼저 customers 테이블의 핵심부터 시작해 보겠습니다:
CREATE TABLE customers (
name VARCHAR NOT NULL,
email VARCHAR NOT NULL,
/* more attributes */
/* TODO: PRIMARY KEY */
)
이 글은 비즈니스 속성에 관한 것이 아니므로 짧게 유지했습니다.0 표시된 두 열은 단지 핵심 질문을 던질 수 있게 하기 위해 존재합니다. 이 테이블에 좋은 기본 키는 무엇일까요?
언뜻 보면 이것은 자연 키 대 대리 키 논쟁처럼 보입니다. 하지만 이 글에서는 그것이 부차적 측면일 뿐이므로 짧게 하겠습니다: 현재 보인 형태의 테이블에는 기본 키에 포함할 만하다고 볼 수 있는 열이 없습니다.1 이유는 두 열 모두 외부에서 정의된 의미 체계의 지배를 받기 때문입니다. 즉, 그들이 따르는 규칙을 우리가 모른다는 뜻입니다. 특히, 그것들이 고유한지, 혹은 언제 고유한지 알지 못합니다. 물론 이름은 고유하지 않다 는 것을 아니까 name 열은 배제할 수 있습니다. 덜 분명해 보이지만, 이메일 주소도 마찬가지이며 더 일반적으로는 고유성 규칙이 외부에서 정의되는 모든 것이 그렇습니다.
제가 “기본 키 값 은 불변이어야 한다”는 주장을 사용하지 않는다는 점에 주목해 주세요. 그렇다고 그것이 나쁜 주장이라고 말하는 것도 아닙니다. 기본 키 값이 바뀌면 많은 테이블의 많은 행에 영향을 줄 수 있으므로 그 변경을 데이터베이스에 적용하기 어려울 수 있습니다.2 또한 이전 기본 키 값이 데이터베이스 밖의 흔적들(API, 로그 파일, 인쇄물 등)에 남아 있을 수 있어서, 기본 키 값을 바꾸면 그런 흔적들이 무의미해질 수 있습니다. 두 주장은 모두 옳지만, 관계 이론의 관점에서는 둘 다 무관합니다. 저는 관계 모델이 작동하기 위해 엄격히 요구되는 더 강한 주장을 선호합니다.
그 주장은 고유성 규칙 의 불변성 이 필요하다는 것입니다. 강조에 주목해 주세요: 엄격히 필요한 것은 불변의 고유성 규칙 이지, 불변의 값이 아닙니다! 이메일 주소를 예로 들어 봅시다. 문제는 이메일 주소의 고유성 의미가 대상 메일 서버에 의존한다는 점입니다. 특히 “@” 앞부분은 대소문자를 구분해야 하지만, 종종 그렇지 않습니다, 서브어드레싱(“+” 주소 지정)이 지원될 수도 있고 아닐 수도 있으며, 점이 무시될 수도 있습니다. 이 점을 곱씹어 보세요. a.b@gmail.com과 ab@gmail.com은 같은 메일박스를 가리키지만, 다른 도메인에서는 서로 다른 메일박스를 가리킬 수도 있습니다.
외부에서 정의된 고유성 의미는 문제입니다. 우리가 그것을 완전히 이해하지 못할 수도 있고, 미래에 바뀔 수도 있습니다. 그런 오해와 변화는 우리 기본 키의 고유성을 깨뜨릴 것입니다. 따라서 우리는 그런 값을 기본 키에 사용해서는 안 됩니다. 여기까지의 이야기는 “항상 대리 키를 사용하라”는 주문과 일치하지만, 이야기는 여기서 끝나지 않습니다. 따라서 이 글의 나머지는 다음과 같은 customers 테이블 정의를 바탕으로 진행합니다:
CREATE TABLE customers (
name VARCHAR NOT NULL,
email VARCHAR NOT NULL,
/* more attributes */
id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (id)
)
쇼핑몰의 두 번째 테이블은 주문용입니다:
CREATE TABLE orders (
customer_id BIGINT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers (id),
placed TIMESTAMP(6) NOT NULL,
/* more attributes */
/* TODO: PRIMARY KEY */
)
다시 말하지만 관련 부분만 보여 줍니다. 고객에 대한 참조와 외래 키 정의로 시작합니다. 그 다음에는 주문 시각을 저장하기 위한 placed 열이 옵니다. 분명히 이 테이블에는 더 많은 열이 있겠지만, 중요한 질문과는 무관합니다: 이 테이블에 좋은 기본 키는 무엇일까요?
상황은 앞선 경우와 약간 다릅니다. (customer_id, placed) 조합을 고려해 볼 가치가 있다고 주장할 수 있습니다. 그리고 저는 실제로 동의합니다. 시간의 고유성 의미 역시 혼란스러운 방식으로 우리 우주에 의해 외부에서 정의되지만, 웹 쇼핑몰 맥락에서는 타임스탬프를 충분히 잘 이해되고 불변인 것으로 간주할 수 있습니다. 과거 사건의 타임스탬프는 우주 자체에 의해 뒷받침되는, 불변 값이라는 있으면 좋은 속성도 가집니다. 타임스탬프의 해상도가 충분히 높다면 그것을 고유하다고 볼 수도 있습니다. customer_id와 placed 열을 결합한 기본 키의 결과는 한 고객이 같은 마이크로초에 두 주문을 할 수 없다는 것입니다. 반대로 말하면, 이 기본 키는 쇼핑몰의 처리 능력에 대해 고객당 초당 백만 건 주문이라는 엄격한 상한을 둡니다. 이것이 받아들일 수 있는 한계인지 여부는 각자 판단할 문제이며, 뒤로 가면 중요하지 않게 됩니다. 우리가 이 한계를 받아들인다면 (customer_id, placed) 조합을 기본 키로 쓰는 데 반대할 강력한 논거는 없습니다. 불변 값이라는 있으면 좋은 속성도 충족됩니다. 반대 이유는 타임스탬프가 디스크에서 꽤 많은 공간을 차지한다는 점↓과 사람이 다루기에 불편하다는 점입니다. 전화로 “어떤 주문을 취소하시겠습니까?”라고 묻는 상황을 상상해 보세요.
대안도 함께 고려해 봅시다. 만약 placed 열을 기본 키의 일부로 받아들이지 않는다면, 이 테이블에는 후보 키가 없습니다. 그러니 앞과 마찬가지로 하나를 만들어야 합니다. 다시 한번 “항상 대리 키를 사용하라”는 주문이 적용되는 듯 보입니다. 하지만 이번에는 그것이 문제가 있는 기본 키로 이어집니다. orders 테이블에 흔히 쓰이는 기본 키를 보세요:
CREATE TABLE orders (
customer_id BIGINT NOT NULL,
FOREIGN KEY (customer_id)
REFERENCES customers (id),
placed TIMESTAMP(6) NOT NULL,
/* more attributes */
id BIGINT NOT NULL
GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (id)
)
문제가 보이시나요? 이 기본 키가 어떻게 스키마를 조각내는지 보여 드리겠습니다…
쇼핑몰의 첫 번째 설계를 완성하는 테이블은 주문 항목용입니다:
CREATE TABLE order_lines (
order_id BIGINT NOT NULL,
FOREIGN KEY (order_id)
REFERENCES orders (id),
product_id INTEGER NOT NULL,
qty INTEGER NOT NULL CHECK (qty > 0),
/* more attributes */
/* TODO: PRIMARY KEY */
)
이 테이블은 사실상 장바구니입니다. 주문에 담긴 각 상품마다 한 행이 있습니다. 이 테이블에는 orders 테이블을 향한 외래 키가 있지만 아직 기본 키는 없습니다. order_lines의 기본 키는 이 논의에서 중요하지조차 않습니다.

이 기본 키가 야기하는 문제를 이해하려면 이런 쿼리를 생각해 봐야 합니다: 특정 고객이 특정 상품을 마지막으로 주문한 시점은 언제인가? 이에 해당하는 쿼리는 다음과 같습니다:
SELECT placed
FROM order_lines ol
JOIN orders o
ON o.id = ol.order_id
WHERE customer_id = ?
AND product_id = ?
ORDER BY placed DESC
FETCH FIRST 1 ROW ONLY
이것은 표준 SQL 문법의 top-n 쿼리입니다. fetch first 절이 익숙하지 않다면, 이 경우 limit 1이나 select top 1과 똑같이 동작합니다. 쿼리가 두 개의 다른 테이블에서 열을 필요로 하므로 조인이 필요하다는 것은 놀랍지 않습니다. 물론 (customer_id, placed)를 orders 테이블의 기본 키로 사용할 수 있다는 아이디어를 기억하고 있지 않다면 말이죠.
orders의 기본 키가 (customer_id, placed)라면 order_lines 테이블의 정의도 바뀝니다:
CREATE TABLE order_lines (
customer_id BIGINT NOT NULL,
order_placed TIMESTAMP(6) NOT NULL,
FOREIGN KEY (customer_id, order_placed)
REFERENCES orders (customer_id, placed),
product_id INTEGER NOT NULL,
qty INTEGER NOT NULL CHECK (qty > 0),
/* more attributes */
/* TODO: PRIMARY KEY */
)
이 테이블은 orders를 향한 외래 키를 가지므로, orders의 기본 키 열이 order_lines 테이블에도 있어야 합니다. 그 결과 앞선 쿼리에는 더 이상 조인이 필요하지 않습니다:
SELECT order_placed
FROM order_lines ol
WHERE customer_id = ?
AND product_id = ?
ORDER BY order_placed DESC
FETCH FIRST 1 ROW ONLY
이제 customer_id와 주문 시각이 product_id 옆에 바로 उपलब्ध됩니다. 다음 차트는 첫 번째 쿼리의 응답 시간을 기준(100%)으로 보여 줍니다. 아래 두 막대는 조인 없는 쿼리를 사용할 때의 상대 응답 시간을 보여 줍니다. 한 엔진(E1)에서는 10배 빨라지고, 다른 엔진에서도 여전히 4배 빨라집니다. 하지만 이것은 조인을 제거했기 때문이 아닙니다!
다음 쿼리는 제 요점을 증명하기 위해 여전히 조인을 수행합니다. order_lines에서 사용할 수 없다고 가정하듯 orders 테이블의 customer_id와 placed 열을 사용합니다. order_lines 테이블에 대한 참조는 조인 조건과 product_id 검색에만 있습니다. 특히 select 절과 order by 절은 모두 orders 테이블의 열을 참조합니다.
SELECT o.placed
FROM order_lines ol
JOIN orders o
ON o.customer_id = ol.customer_id
AND o.placed = ol.order_placed
WHERE o.customer_id = ?
AND ol.product_id = ?
ORDER BY o.placed DESC
FETCH FIRST 1 ROW ONLY
이 조인은 응답 시간을 몇 퍼센트포인트 정도만 증가시킵니다. 조인이 있어도, 이 쿼리는 orders.id에 단일 열 기본 키를 사용하는 스키마와 비교하면 몇 배나 더 빠릅니다.
조인은 큰 문제가 아닙니다. 문제가 되는 것은 기본 키가 세운 벽입니다. 다음 도식들이 그것을 눈에 보이게 해 줍니다. 먼저 orders 테이블이 단일 열 기본 키를 사용하는 변형입니다. 화살표는 외래 키를 나타냅니다. 화살표 끝은 제약의 해당 열에 위치합니다.
orders 테이블은 “들어오는” 외래 키와 “나가는” 외래 키가 서로 다른 열에 닿기 때문에 customers와 order_lines를 분리합니다. 쿼리가 customers와 order_lines를 함께 필요로 할 때마다, orders 테이블을 통해 customer_id와 order_id 사이를 행 단위로 매핑해야 합니다.
이 그림은 orders 테이블이 구조화된 기본 키 (customer_id, placed)를 사용할 때 바뀝니다:
이 스키마는 같은 고객에 속한 모든 행들 사이의 응집력을 유지합니다. 이것은 성능뿐 아니라 일관성 측면에서도 엄청난 가치가 있습니다.
비정규화가 잠재적으로3 같은 성능 이점을 가져올 수 있다는 것은 사실이지만, 구조화된 기본 키가 정규화를 깨뜨리는 것은 아니라는 점을 이해하는 것이 중요합니다. 다음 그림은 성능을 위해 제2정규형을 깨뜨리는 스키마를 보여 줍니다.
분석 중인 쿼리에 대해서는 이 스키마가 구조화된 기본 키와 같은 성능 이점을 제공합니다. 하지만 이 스키마는 order_lines 테이블의 customer_id 열이 해당 orders 행과 같은 고객을 가리킨다는 것을 보장하지 않습니다. 조만간 불일치가 생길 것입니다. 많은 사람들은 애플리케이션이 그것을 막을 수 있다고 믿지만, 머피의 법칙은 그 반대를 말합니다.
orders| id | customer_id |
| --- | --- |
| 1 | 10 |
| 2 | 20 |
order_lines| order_id | customer_id |
| --- | --- |
| 1 | 20 |
| 2 | 20 |
| 2 | 10 |
이 예시 행들이 주어졌을 때 “누가 주문 1을 했는가?”라는 질문은 각 테이블마다 다른 답을 냅니다. 더 나쁘게는, order_lines 테이블은 주문 2에 대해 자기 자신과도 모순됩니다.
물론 저는 비정규화된 스키마의 성능과 정규화된 스키마의 정확성을 둘 다 갖고 싶습니다. 바로 이것을 구조화된 기본 키가 제공합니다.
이제 구조화된 기본 키의 공간 효율성을 논의하기에 좋은 시점인 것 같습니다. 더 많은 테이블이 더 많은 열을 가진 기본 키로 이어질 수 있다는 것은 충분히 예상 가능합니다. 이것은 타이핑을 더 많이 요구하고, 더 중요하게는 on 절에서 실수할 가능성을 높입니다. 어쩔 수 없습니다. 당분간은 이 단점을 받아들여야 합니다. 물론 일부 SQL 방언은 명시적인 조인 조건 없이도 외래 키를 따라 조인할 수 있다는 점을 알고 있습니다.4 올바른 쿼리를 생성하는 스키마 인지 도구는 이 단점을 어느 정도 완화할 수 있습니다.
하지만 구조화된 기본 키의 메모리 사용량을 살펴봅시다. 일반적으로 어떤 기본 키 설계가 더 많은 메모리를 필요로 하는지에 대해서는 명확한 답이 없습니다. 구조화된 기본 키를 쓰면 orders 테이블은 id 열이 전혀 필요하지 않습니다. 이것은 종종 인덱스 하나도 절약합니다.5 반면 order_lines 테이블에는 더 많은 열이 생깁니다. 힙 테이블을 사용하는 시스템에서는 이것이 두 번 계산됩니다: 힙 테이블에서 한 번, 기본 키를 지원하는 인덱스에서 한 번입니다. 어떤 요소가 더 지배적인지—어떤 테이블에서의 절약인지 다른 테이블에서의 손실인지—는 데이터 분포에도 달려 있습니다. 주문마다 order_lines 항목이 하나뿐이라면 절약이 손실을 상회할 수도 있습니다. 말했듯이, 일반적인 답은 없습니다. 그러나 일반적으로 적용 가능한, 구조화된 기본 키 설계에 유리하게 확률을 밀어 주는 방법들은 있습니다.
쇼핑몰 스키마에서는 orders 테이블의 기본 키가 차지하는 공간이 핵심 요소입니다. 그 값들이 그것을 참조하는 외래 키를 가진 모든 테이블로 복사되기 때문입니다. 따라서 이 기본 키는 구조화된 성격을 잃지 않으면서 공간 최적화가 필요합니다. 이것은 placed 열을 기본 키에 넣는 것에 대한 강한 반대 논거입니다. 결국 단일 timestamp에 필요한 공간은 엔진과 소수 초 해상도에 따라 5바이트에서 11바이트 범위에 들어갑니다.
그래서 저는 이런 경우 작은 프록시 열을 도입할 것을 제안합니다. 그러나 기본 키의 두 열 구조, 즉 customer_id와 placed 자체 또는 그것의 프록시는 반드시 유지하는 것이 중요합니다. 아래에서는 고객별 주문 번호(order_no)를 프록시로 사용하겠습니다.
고유성만이 유일한 관심사라면 프록시 값은 무작위로 선택해도 됩니다. 어떤 경우에는 프록시 값이 원래 열 값의 정렬 순서를 보존하도록(정렬 보존) 선택하는 것이 유리합니다. 이것은 고객이 어떤 상품을 마지막으로 주문한 시점을 찾을 때 유용하지만, 성능 향상이 놀라울 정도로 작을 수 있다는 것도 보게 될 것입니다.
이런 프록시 열의 좋은 점은 값의 범위를 꽤 작게 유지할 수 있다는 것입니다. 단일 열 기본 키 order_id에는 bigint가 필요할 수 있지만, 구조화된 기본 키 (customer_id, order_no)는 두 개의 integer 값이면 충분할 수 있습니다. bigint가 integer보다 두 배 많은 바이트를 필요로한다면, 테이블 이 요구하는 공간은 두 변형에서 동일합니다.6 궁극적으로 구조화된 키 설계가 더 작습니다. 필요한 인덱스 수가 더 적기 때문입니다.7
이 맥락에서 UUID에 대해 생각거리를 하나 드리고 싶습니다: (1) UUID 하나는 integer 네 개만큼의 공간을 차지합니다;8 (2) UUIDv7를 칭찬하는 사람들은 어쩌면 항상 자기 키 안에 timestamp를 원했던 것일지도 모릅니다.
일부 독자들은 방금 도입한 order_no 값을 어디서 얻어야 하는지 궁금해할지도 모릅니다. 짧은 곁가지로, 다음 insert 문이 얼마나 놀라울 정도로 효율적인지 보여 드리고 싶습니다. 이 문은 해당 고객에 대해 사용 가능한 다음 order_no를 선택하고, 그 값으로 새 주문 행을 삽입한 다음, 방금 생성된 행의 기본 키를 애플리케이션으로 반환합니다.9
INSERT INTO orders (customer_id, order_no, placed)
SELECT customer_id
, (SELECT COALESCE(MAX(order_no),0) + 1
FROM orders WHERE customer_id = t.customer_id)
, CURRENT_TIMESTAMP
FROM (VALUES (?)) t(customer_id)
RETURNING customer_id, order_no
반환된 기본 키 값은 order_lines 테이블에 insert할 때 사용할 수 있습니다.
다음 차트의 기준(100%)은 orders 테이블에 단일 열 기본 키를 사용하는 모델에서의 같은 트랜잭션입니다. 다음 고객별 order_no를 찾는 작업까지 포함하면, 구조화된 기본 키를 사용할 때 트랜잭션은 약 3분의 1 더 오래 걸립니다. 공간 효율성과 마찬가지로, 긍정적 효과와 부정적 효과 중 어느 쪽이 더 큰지에 대한 일반적인 답은 없습니다.
분명히 동시성은 기본 키 위반으로 이어질 수 있습니다. 기본 키가 (customer_id, placed)일 때도 한 고객이 같은 타임스탬프로 두 주문을 만들면 마찬가지입니다. 자동적이고, 부드럽고(잠시 대기), 제한된 재시도 루프가 필요하며, 이것은 데드락처럼 동시성이 야기한 문제에 대처하는 일반적인 접근이기도 합니다.10
필요한 멱등성은 전용 고유 열로 확립할 수 있습니다. 이 열은 어떤 외래 키의 일부도 되지 않으므로 그 크기의 중요성은 덜합니다. 하지만 여전히 필요한 인덱스가 이 열이 소비하는 공간을 두 배로 만들기 때문에, 많은 행을 가진 (폭이 좁은) 테이블에서는 더 짧은 타입이 바람직할 수 있습니다.
returning 절은 표준 SQL이 아니며 널리 지원되지 않는다는 점에 주목하세요. 표준 SQL은 이보다 더 강력한 기능인 T495,“Combined data change and retrieval”도 제공하지만, 이것 역시 널리 지원되지는 않습니다✓✗. 엔진이 이것이나 동등한 기능을 제공하지 않는다면, 다음 고객별 order_no를 얻으려면 추가 쿼리가 필요합니다. 시간 순서를 보존할 필요가 없다면, 클라이언트 측 할당(풀에서, 무작위로 등)도 선택지가 될 수 있습니다. 고객별 매우 높은 동시성이 예상된다면 이것이 유리합니다.
비교를 위해, 다음 차트는 고객이 어떤 상품을 마지막으로 주문한 시점을 찾는 검색의 응답 시간을 비교합니다. 기준(100%)은 여전히 단일 열 기본 키를 사용하는 스키마입니다. 상대 시간은 시간 순서를 보존하지 않는 구조화된 기본 키를 사용하는 스키마에서 측정된 것입니다. 두 경우 모두 쿼리는 본질적으로 같습니다. 차이는 on 절(한 열 대 두 열)뿐입니다. 그런데도 구조화된 기본 키를 사용할 때 쿼리는 훨씬 더 빠릅니다. 기적 같지 않나요?
물론 기적은 없습니다. customer_id가 order_lines 테이블에 있으면, 엔진은 관련 고객과 상품에 대한 order_lines를 집어 올릴 수 있습니다. 그 다음 해당 orders 행들을 가져와 가장 큰 타임스탬프를 취합니다.
order_lines 테이블에 order_id만 있고 customer_id는 없다면, 엔진은 각 행이 관련 고객에 속하는지 여부를 알 수 없기 때문에 해당 상품의 모든 order_lines를 집어 와야 합니다. 또한 이 order_lines 각각에 대응하는 orders 행도 가져와야 비로소 그중 많은 행이 다른 고객의 것임을 알 수 있습니다. 구조화된 기본 키는 두 번 절약합니다: 먼저 다른 고객의 order_lines를 집어오지 않음으로써, 다음으로 그 주문 상세에도 접근하지 않음으로써입니다.11
방금 보여 준 속도 향상은 조인을 없앴기 때문도 아니고 프록시 열의 정렬 보존 특성을 활용했기 때문도 아니라는 점에 주목하세요. 쿼리가 그렇게 훨씬 빨라지는 이유는 orders 테이블의 구조화된 기본 키가 고객과 상품을 갈라놓지 않기 때문입니다.
서로 다른 두 기본 키 설계의 응답 시간 비율은 교차하는 각 매핑 테이블마다 곱해집니다.12 여기서 매핑 테이블이란 왼쪽과 오른쪽 조인 조건이 서로 다른 열을 사용하는 테이블을 뜻합니다. 쿼리에서 그런 테이블 두 개를 건너면 위에서처럼 4배에서 10배가 아니라 16배에서 100배의 차이가 날 것입니다. 그다음도 마찬가지입니다.
구조화된 기본 키의 또 다른 이점은 두 테이블 사이에 여러 조인 경로가 있을 때 불일치를 방지할 수 있다는 점입니다. 시연을 위해 쇼핑몰에 addresses라는 테이블을 하나 더 추가해 봅시다. 그리고 orders 테이블에는 ship_to와 bill_to라는 두 열을 추가했습니다.
addresses 테이블의 기본 키는 다른 테이블들과 매우 비슷하게 구조화되어 있습니다. address_no 열은 고유성에 도달하기 위해 실제로 필요했을 열들에 대한 조밀한 프록시입니다. addresses와 orders 테이블 사이의 강조된 외래 키는 특별한 주의를 기울일 가치가 있습니다: 이 외래 키는 원래 그 테이블에 있던 customer_id 열과 새로 도입된 ship_to 열을 결합합니다. 둘은 함께 addresses 테이블을 참조합니다. 이것은 주문의 ship_to 주소가 항상 주문 자체와 같은 고객에 속하도록 보장합니다. 웹 쇼핑몰에서는 매우 타당한 비즈니스 요구 사항입니다. bill_to 주소를 위한 외래 키도 비슷하게 동작하지만 그림에는 나타내지 않았습니다.
addresses 테이블이 단일 열 기본 키(addresses.id)를 가졌다면, 모든 주문은 어떤 주소든 가리킬 수 있게 됩니다—그것이 같은 고객의 것인지 여부와 상관없이 말입니다. orders 테이블의 한 행에서 출발해 customers 테이블에 두 가지 다른 경로로 도달할 수 있게 됩니다. 직접 가거나 addresses 테이블을 거쳐 가는 것입니다. 데이터에 오류가 있다면 두 서로 다른 고객에 도달할 수도 있습니다. 다시 말하지만, “잘못될 수 있는 것은 결국 잘못된다.”
이런 유형의 일관성은 완전히 선택 사항입니다. 배송지와/또는 청구지 주소에 대해 addresses 테이블의 전체 기본 키(customer_id 포함)를 저장할 수 있습니다. 그러면 orders 테이블 안에 customer.id 값이 세 개 있게 됩니다—예를 들어 customer_id, ship_to_customer, bill_to_customer 같은 식입니다. 이렇게 하면 해당 주소들이 서로 다른 고객에 속할 수 있음을 명시적으로 드러냅니다.
그런 고객 간 참조가 허용되는지 여부를 모른다면, 그 열들을 추가하되 모두 같은 값을 가져야 한다는 것을 검증하는 check 제약을 도입할 수 있습니다. 만약 같은 고객을 가리켜야 한다는 가정이 틀린 것으로 드러나면, check 제약만 제거하면 스키마가 열리면서 사실상 addresses 테이블에 단일 열 기본 키를 쓴 것과 같은 동작을 하게 됩니다.
위의 고려 사항은 쇼핑몰 예시에서는 큰 의미가 없습니다. 하지만 멀티테넌트 시스템에서는 필요에 따라 테넌트 간 참조를 허용하거나 금지할 수 있는 능력이 매우 중요할 수 있습니다.
마지막으로 덧붙일 것은, 위에 그려진 스키마가 매우 공간 효율적일 수 있다는 점입니다. 고객당 32k개의 주소면 충분하다면, 2바이트만 필요한 작은 타입을 사용할 수 있습니다.13 ship_to와 bill_to 열은 합쳐서 단 4바이트만 필요합니다. 반대로 addresses에 단일 열 기본 키를 쓰려면 적어도 integer 값이 필요하고, 따라서 orders 테이블에서 8바이트를 차지하게 됩니다. 그 위에 구조화된 기본 키는 addresses 테이블의 인덱스 하나도 절약합니다.
결국 구조화된 기본 키는 사실상 합리적인 외래 키를 바탕으로 하고, 고유성에 도달하기 위해 추가 열이 더 필요한 곳에 그것들을 더하는 아이디어에 관한 것입니다. 이 추가 열들을 플러스 열 이라고 부르겠습니다. 레시피는 “외래 키 열 + 플러스 열”입니다. 이런 플러스 열은 더 짧은 프록시 열의 후보인 경우가 많습니다. 마지막 도식에서는 부모의 기본 키 열을 “⇧”로, 플러스 열(또는 그 프록시)을 “+”로 표시했습니다.
아주 자세히 보셨다면 마지막 도식에서 제가 order_lines 테이블의 다른 플러스 열을 사용했다는 점을 눈치채셨을지도 모릅니다. 처음에는 product_id 열이었지만, 나중에는 프록시 열 position으로 대체되었습니다. 이유는 products 테이블(그림에는 없음)의 설계가 바뀌는 경우를 대비해 더 많은 가능성을 열어 두기 때문입니다. 예를 들어 변형이 도입되는 경우처럼 말이죠.14 결국 “항상 대리 키를 사용하라”는 주문에 이유가 없는 것은 아니지만, 저는 그것이 플러스 열에만 적용되어야 한다고 생각합니다.
어떤 외래 키가 합리적인지 식별하는 체계적인 접근에 대해서는 확신이 없습니다. 분명히 모든 외래 키가 기본 키의 기반 후보가 되는 것은 아닙니다. orders 테이블의 기본 키를 addresses 테이블에 대한 외래 키 위에 세우는 것은 매우 이상할 것입니다. 또한 addresses는 ISO 3166 국가 코드용 테이블을 향한 외래 키를 가질 수도 있는데, 이것 역시 addresses 테이블의 기본 키 후보로는 매우 이상합니다. 제가 드릴 수 있는 최선의 조언은 이것입니다: 상식을 사용하세요. 구조화된 기본 키의 아이디어와 이점을 이해하고 나면, 잘 해내실 겁니다.15