262 lines
8.5 KiB
Markdown
262 lines
8.5 KiB
Markdown
# PK / FK / UNIQUE / CHECK 예시
|
|
|
|
## 좋은 예시
|
|
|
|
### 예시 1. 대표 식별자는 PK, 비즈니스 고유성은 UNIQUE로 분리한다
|
|
|
|
```sql
|
|
CREATE TABLE auth.users (
|
|
id bigint GENERATED ALWAYS AS IDENTITY,
|
|
email text NOT NULL,
|
|
username text NOT NULL,
|
|
created_at timestamp with time zone NOT NULL,
|
|
CONSTRAINT pk_users PRIMARY KEY (id),
|
|
CONSTRAINT uq_users__email UNIQUE (email),
|
|
CONSTRAINT uq_users__username UNIQUE (username)
|
|
);
|
|
```
|
|
|
|
**좋은 이유:**
|
|
|
|
- 대표 식별자 id를 PK로 둔다
|
|
- 이메일과 username은 business unique로 분리한다
|
|
- identity에만 기대지 않고 PK가 유일성을 보장한다
|
|
|
|
PostgreSQL은 PK가 대표 식별자이며 unique B-tree 인덱스를 자동 생성한다고 설명하고, identity는 자동 생성일 뿐 uniqueness를 보장하지 않는다고 명시한다.
|
|
|
|
### 예시 2. 필수 관계는 FK + NOT NULL, 삭제 정책은 관계 의미에 맞춘다
|
|
|
|
```sql
|
|
CREATE TABLE auth.users (
|
|
id bigint GENERATED ALWAYS AS IDENTITY,
|
|
CONSTRAINT pk_users PRIMARY KEY (id)
|
|
);
|
|
|
|
CREATE TABLE auth.sessions (
|
|
id bigint GENERATED ALWAYS AS IDENTITY,
|
|
user_id bigint NOT NULL,
|
|
expires_at timestamp with time zone NOT NULL,
|
|
CONSTRAINT pk_sessions PRIMARY KEY (id),
|
|
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
|
|
REFERENCES auth.users (id)
|
|
ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX ix_sessions__user_id ON auth.sessions (user_id);
|
|
```
|
|
|
|
**좋은 이유:**
|
|
|
|
- 세션은 사용자에 종속된 구성요소이므로 ON DELETE CASCADE가 자연스럽다
|
|
- 필수 관계를 NOT NULL로 닫았다
|
|
- FK 컬럼 인덱스를 별도로 생성했다
|
|
|
|
PostgreSQL 공식 문서도 구성요소 관계에는 CASCADE가 적절할 수 있고, FK는 참조하는 쪽 인덱스를 자동 생성하지 않는다고 설명한다.
|
|
|
|
### 예시 3. soft delete 환경의 조건부 고유성은 partial unique index로 표현한다
|
|
|
|
```sql
|
|
CREATE TABLE auth.users (
|
|
id bigint GENERATED ALWAYS AS IDENTITY,
|
|
tenant_id bigint NOT NULL,
|
|
email text NOT NULL,
|
|
deleted_at timestamp with time zone,
|
|
CONSTRAINT pk_users PRIMARY KEY (id)
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_users__tenant_email_active
|
|
ON auth.users (tenant_id, email)
|
|
WHERE deleted_at IS NULL;
|
|
```
|
|
|
|
**좋은 이유:**
|
|
|
|
- “삭제되지 않은 사용자만 tenant 내 email 유일”이라는 조건부 고유성을 정확히 표현한다
|
|
- 일반 UNIQUE constraint로는 일부 행에만 적용되는 uniqueness를 표현할 수 없다
|
|
|
|
PostgreSQL 공식 문서는 일부 행에만 적용되는 uniqueness restriction은 unique constraint가 아니라 unique partial index로 표현해야 한다고 설명한다.
|
|
|
|
### 예시 4. CHECK는 같은 행 안의 불변식에만 사용한다
|
|
|
|
```sql
|
|
CREATE TABLE billing.payments (
|
|
id bigint GENERATED ALWAYS AS IDENTITY,
|
|
amount numeric(19,4) NOT NULL,
|
|
refunded_amount numeric(19,4) NOT NULL DEFAULT 0,
|
|
status text NOT NULL,
|
|
CONSTRAINT pk_payments PRIMARY KEY (id),
|
|
CONSTRAINT ck_payments__amount_positive CHECK (amount > 0),
|
|
CONSTRAINT ck_payments__refunded_amount_range
|
|
CHECK (refunded_amount >= 0 AND refunded_amount <= amount),
|
|
CONSTRAINT ck_payments__status
|
|
CHECK (status IN ('PENDING', 'PAID', 'CANCELLED', 'REFUNDED'))
|
|
);
|
|
```
|
|
|
|
**좋은 이유:**
|
|
|
|
- 모두 같은 행 안에서 평가 가능한 규칙이다
|
|
- null 금지는 NOT NULL로, 값 범위는 CHECK로 역할을 분리했다
|
|
- CHECK에 cross-table 의존이 없다
|
|
|
|
PostgreSQL은 CHECK가 row-local invariant에 적합하고, null 금지는 NOT NULL로 표현하는 것이 맞다고 설명한다.
|
|
|
|
### 예시 5. 조인 테이블은 복합 PK를 예외적으로 사용할 수 있다
|
|
|
|
```sql
|
|
CREATE TABLE auth.users (
|
|
id bigint GENERATED ALWAYS AS IDENTITY,
|
|
CONSTRAINT pk_users PRIMARY KEY (id)
|
|
);
|
|
|
|
CREATE TABLE auth.roles (
|
|
id bigint GENERATED ALWAYS AS IDENTITY,
|
|
CONSTRAINT pk_roles PRIMARY KEY (id)
|
|
);
|
|
|
|
CREATE TABLE auth.user_roles (
|
|
user_id bigint NOT NULL,
|
|
role_id bigint NOT NULL,
|
|
granted_at timestamp with time zone NOT NULL,
|
|
CONSTRAINT pk_user_roles PRIMARY KEY (user_id, role_id),
|
|
CONSTRAINT fk_user_roles__users FOREIGN KEY (user_id)
|
|
REFERENCES auth.users (id)
|
|
ON DELETE CASCADE,
|
|
CONSTRAINT fk_user_roles__roles FOREIGN KEY (role_id)
|
|
REFERENCES auth.roles (id)
|
|
ON DELETE RESTRICT
|
|
);
|
|
```
|
|
|
|
**좋은 이유:**
|
|
|
|
- 조인 테이블에서는 (user_id, role_id) 조합 자체가 자연스러운 정체성이다
|
|
- 복합 PK가 FK와 겹쳐도 의미가 분명하다
|
|
- 삭제 정책도 관계 의미에 따라 다르게 선택했다
|
|
|
|
PostgreSQL은 복합 PK와 FK를 모두 지원하며, 문서 예시에서도 many-to-many 구조에서 이런 형태를 보여준다. 다만 JPA에서는 복합 PK가 별도 키 클래스를 요구하므로 예외적으로 사용하는 편이 안전하다.
|
|
|
|
## 나쁜 예시
|
|
|
|
### 예시 1. PK 없이 UNIQUE만으로 테이블을 운영한다
|
|
|
|
```sql
|
|
CREATE TABLE auth.users (
|
|
email text NOT NULL UNIQUE,
|
|
username text NOT NULL UNIQUE
|
|
);
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- 대표 식별자가 없다
|
|
- FK 기본 참조 대상과 ORM 식별 의미가 불명확하다
|
|
- “고유한 컬럼 몇 개”와 “대표 PK”의 역할이 섞인다
|
|
|
|
PostgreSQL도 모든 테이블에 PK를 강제하지는 않지만, 일반적으로 두는 것이 가장 좋다고 설명한다.
|
|
|
|
### 예시 2. nullable UNIQUE를 두고 NULL도 하나만 허용된다고 착각한다
|
|
|
|
```sql
|
|
CREATE TABLE auth.users (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
external_subject text UNIQUE
|
|
);
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- PostgreSQL 기본 동작에서는 NULL이 서로 다른 값으로 취급된다
|
|
- 따라서 external_subject가 NULL인 행이 여러 개 들어갈 수 있다
|
|
- “값이 없으면 하나만 허용” 의미라면 현재 설계는 틀렸다
|
|
|
|
이 경우는 NOT NULL, UNIQUE NULLS NOT DISTINCT, 또는 모델 재설계가 필요하다.
|
|
|
|
### 예시 3. 필수 관계인데 nullable FK로 열어 둔다
|
|
|
|
```sql
|
|
CREATE TABLE auth.users (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
|
|
);
|
|
|
|
CREATE TABLE auth.sessions (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
user_id bigint,
|
|
CONSTRAINT fk_sessions__users FOREIGN KEY (user_id)
|
|
REFERENCES auth.users (id)
|
|
);
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- 세션이 반드시 사용자에 속해야 한다면 user_id는 NOT NULL이어야 한다
|
|
- FK만으로는 NULL을 막지 못한다
|
|
- 필수 관계를 스키마가 보장하지 못한다
|
|
|
|
PostgreSQL은 참조 컬럼에 null이 있으면 FK를 회피할 수 있다고 설명한다.
|
|
|
|
### 예시 4. 독립 객체 관계에 무분별하게 CASCADE를 건다
|
|
|
|
```sql
|
|
CREATE TABLE catalog.products (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
|
|
);
|
|
|
|
CREATE TABLE ordering.order_items (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
product_id bigint NOT NULL,
|
|
CONSTRAINT fk_order_items__products FOREIGN KEY (product_id)
|
|
REFERENCES catalog.products (id)
|
|
ON DELETE CASCADE
|
|
);
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- product와 order item 관계는 도메인에 따라 독립 객체일 수 있다
|
|
- 상품 삭제가 주문 이력 일부를 연쇄 삭제하면 운영상 매우 위험할 수 있다
|
|
- 이 경우는 RESTRICT 또는 NO ACTION이 더 자연스러운 경우가 많다
|
|
|
|
PostgreSQL 공식 문서도 독립 객체 관계라면 RESTRICT 또는 NO ACTION이 더 적절하다고 설명한다.
|
|
|
|
### 예시 5. null 금지를 CHECK로 우회한다
|
|
|
|
```sql
|
|
CREATE TABLE auth.users (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
email text,
|
|
CONSTRAINT ck_users__email_not_null CHECK (email IS NOT NULL)
|
|
);
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- null 금지는 NOT NULL이 더 직접적이고 효율적이다
|
|
- 역할이 다른 제약을 섞고 있다
|
|
- 팀 규칙 해석도 흐려진다
|
|
|
|
PostgreSQL 공식 문서도 CHECK (col IS NOT NULL)보다 explicit NOT NULL이 더 효율적이라고 설명한다.
|
|
|
|
### 예시 6. cross-row 규칙을 CHECK로 해결하려 한다
|
|
|
|
```sql
|
|
CREATE TABLE auth.user_roles (
|
|
user_id bigint NOT NULL,
|
|
role_id bigint NOT NULL,
|
|
is_primary boolean NOT NULL,
|
|
CONSTRAINT ck_user_roles__only_one_primary
|
|
CHECK (
|
|
NOT is_primary
|
|
OR role_id IS NOT NULL
|
|
)
|
|
);
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- “사용자당 primary role은 하나만” 같은 규칙은 이런 CHECK로 보장되지 않는다
|
|
- CHECK는 다른 행을 기준으로 유일성을 유지하는 수단이 아니다
|
|
- 이런 요구는 (user_id) 조건부 unique index 같은 방식으로 풀어야 한다
|
|
|
|
PostgreSQL은 cross-row / cross-table 규칙에 CHECK를 쓰지 말고 UNIQUE, FK, EXCLUDE, trigger를 검토하라고 안내한다.
|