03 · 기본키 전략
이 문서가 답하는 질문: TypeORM의 PK는
@PrimaryColumn과@PrimaryGeneratedColumn두 종이고, 후자는 4가지 생성 전략('increment'·'uuid'·'rowid'·'identity')을 가진다. 어느 것을 골라야 하나? 한 줄 답 (Pyramid Top): “@PrimaryColumn은 수동 PK,@PrimaryGeneratedColumn은 자동 PK다 — 자동의 4 전략 중'uuid'가 분산에서 안전하고,'increment'가 단일 DB에서 단순하며, 나머지 둘은 특정 DB 전용이다.”
Why — PK는 데이터 정체성의 결정
PK는 단순한 “고유 컬럼”이 아니다. PK는 그 row가 우주에서 누구인가를 정의한다 — 이 결정이 분산 시스템 가능성·외래키 디자인·인덱스 페이지 크기에 직결된다.
세 축이 충돌한다:
- 단순성 —
id INTEGER AUTO_INCREMENT는 가장 적게 적는다. - 분산성 — 여러 노드에서 동시에 발급해도 충돌이 없어야 한다.
- 노출 가능성 — URL에 박힐 때 예측 불가능해야 한다 (
/users/1vs/users/3f2a...).
PK 전략은 이 셋 사이의 절충점을 어디에 두느냐다.
How — 두 데코레이터, 4 전략
@PrimaryColumn — 수동 PK
import { Entity, PrimaryColumn, Column } from 'typeorm'
@Entity()
class Country {
@PrimaryColumn({ length: 2 })
code!: string // 'KR', 'US', 'JP'
@Column() name!: string
}PK 값을 코드가 직접 넣어야 한다. repo.save({ code: 'KR', name: '대한민국' }). 자연키(natural key)나 외부 시스템에서 받은 ID에 쓴다.
@PrimaryGeneratedColumn() — 자동 PK 4 전략
@PrimaryGeneratedColumn() // 기본 = 'increment'
@PrimaryGeneratedColumn('increment') // 명시
@PrimaryGeneratedColumn('uuid') // UUID v4
@PrimaryGeneratedColumn('rowid') // CockroachDB / SQLite rowid
@PrimaryGeneratedColumn('identity') // PG 10+ / SQL Server IDENTITY1) 'increment' — 정수 자동 증가
@PrimaryGeneratedColumn()
id!: number| DB | DDL |
|---|---|
| PostgreSQL | id SERIAL (PG 10 미만) / 권장은 identity |
| MySQL | id INT AUTO_INCREMENT PRIMARY KEY |
| SQLite | id INTEGER PRIMARY KEY AUTOINCREMENT |
| SQL Server | id INT IDENTITY(1,1) |
장점: 인덱스 페이지가 순차 삽입되어 클러스터드 인덱스 효율 최고. 디버깅 쉬움 (/users/123).
단점: 분산 발급 불가능. URL 노출 시 총 사용자 수 추정이 가능 (/users/41723).
2) 'uuid' — UUID v4
@PrimaryGeneratedColumn('uuid')
id!: string // 'a1b2c3d4-...-...'| DB | DDL |
|---|---|
| PostgreSQL | id uuid DEFAULT gen_random_uuid() (pgcrypto 또는 PG 13+) |
| MySQL | id char(36) — 함수 default는 5.7+의 UUID() 또는 애플리케이션 생성 |
| SQLite | id varchar — 애플리케이션이 생성 |
| SQL Server | id uniqueidentifier DEFAULT NEWID() |
장점: 분산 발급 안전. URL에 박혀도 추정 불가. 다른 시스템에서 미리 생성해서 보낼 수 있다. 단점: 인덱스 무작위 삽입으로 페이지 분할 잦음. 16바이트로 공간 2배. 사람이 읽기 어려움.
완화책: UUID v7 (시간 정렬 가능). 2024년 RFC 9562로 표준화. TypeORM은 아직 기본 미지원이라 transformer나 애플리케이션 측 생성으로.
3) 'rowid' — SQLite/CockroachDB
@PrimaryGeneratedColumn('rowid')
id!: string // CockroachDB의 unique_rowid()- SQLite: 모든 테이블이 암묵적 rowid를 가진다. PK 미지정 시 그 rowid가 PK.
- CockroachDB:
unique_rowid()함수가 분산 안전한 정수를 생성 (시간 + 노드 ID 조합).
다른 DB에서는 권장 안 함 — 정수 시퀀스로 떨어지지만 의미가 흐릿하다.
4) 'identity' — PG 10+ / SQL Server
@PrimaryGeneratedColumn('identity')
id!: number| DB | DDL |
|---|---|
| PostgreSQL 10+ | id INTEGER GENERATED ALWAYS AS IDENTITY (SQL 표준) |
| SQL Server | id INT IDENTITY(1,1) |
| MySQL | ❌ 미지원 |
| SQLite | ❌ 미지원 |
SERIAL vs IDENTITY (PG):
| 측면 | SERIAL | IDENTITY (SQL 표준) |
|---|---|---|
| 권한 | sequence 권한 따로 필요 | 컬럼과 함께 묶임 |
INSERT INTO t DEFAULT VALUES | 동작 | 동작 |
INSERT INTO t (id) VALUES (999) | 동작 (sequence 안 건드림) | OVERRIDING USER VALUE/OVERRIDING SYSTEM VALUE로 명시 필요 |
| SQL 표준 준수 | ❌ | ✅ |
| PG 권장 (PG 10+) | — | ✅ |
PG 10+를 쓴다면 'identity'가 더 안전하다 — 실수로 id 값을 박는 사고를 방지한다.
복합키 — @PrimaryColumn × N
@Entity()
class OrderItem {
@PrimaryColumn() orderId!: string
@PrimaryColumn() productId!: string
@Column() quantity!: number
}DDL: PRIMARY KEY (order_id, product_id).
복합키는 조인 테이블에 자연스럽다. 그러나 외래키 관계가 5+개가 되면 대리키(@PrimaryGeneratedColumn('uuid') id) 추가가 운영적으로 편하다 — 복합키를 모든 child가 들고 다녀야 하는 부담 때문에.
What — 결정 매트릭스
어떤 전략을 언제 쓰나
| 상황 | 추천 | 이유 |
|---|---|---|
| 단일 DB, 내부 시스템 | 'increment' | 가장 단순, 인덱스 효율 |
| 다중 서비스, 분산 발급 | 'uuid' | 충돌 없음 |
| URL에 노출 | 'uuid' | 추정 불가 |
| PG 10+, SQL 표준 | 'identity' | SERIAL의 후계 |
| CockroachDB | 'rowid' 또는 'uuid' | 분산 |
| SQLite 임베디드 | 'rowid' 또는 'increment' | 자연 |
| 자연키(국가코드·ISIN) | @PrimaryColumn | 도메인 의미 보존 |
| 조인 테이블 | 복합 @PrimaryColumn 또는 대리 UUID | 관계 본질에 따라 |
결정 트리
전체 예제 — 4 패턴
import { Entity, PrimaryColumn, PrimaryGeneratedColumn, Column } from 'typeorm'
// 패턴 A: 자동 정수 (단일 DB)
@Entity()
class Article {
@PrimaryGeneratedColumn('identity')
id!: number
@Column() title!: string
}
// 패턴 B: UUID (분산/URL 노출)
@Entity()
class User {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column() email!: string
}
// 패턴 C: 자연키
@Entity()
class Country {
@PrimaryColumn({ length: 2 })
isoCode!: string // 'KR'
@Column() name!: string
}
// 패턴 D: 복합키 (조인 테이블)
@Entity()
class UserRole {
@PrimaryColumn('uuid') userId!: string
@PrimaryColumn() roleId!: number
@Column() assignedAt!: Date
}UUID 생성 어디서 일어나나
@PrimaryGeneratedColumn('uuid')
id!: string세 가지 가능성:
- DB 함수: PG는
gen_random_uuid()가 default로 박힌다 — INSERT 시 DB가 생성. - TypeORM 클라이언트: 일부 드라이버에선 INSERT 전에 TypeORM이
uuid라이브러리로 생성해 박는다. - 명시 발급:
entity.id = randomUUID()로 애플리케이션이 미리 박을 수도 있다.
왜 중요한가: RETURNING이 없는 DB(예: 일부 MySQL 버전)에서 INSERT 후 PK를 받는 비용이 다르다. 애플리케이션 측 생성이 왕복 비용을 절약한다.
@PrimaryGeneratedColumn + @Column 혼합 PK
@Entity()
class Audit {
@PrimaryGeneratedColumn('increment') id!: number // 자동 정수
@PrimaryColumn() tenantId!: string // 추가 PK 컬럼
}복합키의 한 컬럼이 자동 발급이고 다른 컬럼이 수동인 패턴. 멀티 테넌시에서 자주 본다 — (tenantId, id) PK로 테넌트별 ID space를 가진다.
What-if — 자주 틀리는 패턴
함정 1) 'increment'로 시작했다 분산 시점에 갈아엎기
// 처음
@PrimaryGeneratedColumn() id!: number
// 6개월 뒤, 서비스 분리
// → id 충돌 위험, 마이그레이션 지옥대안: 처음부터 UUID. 추적 시 정수가 편하다는 이유는 분산 발급 비용보다 거의 항상 작다.
함정 2) UUID를 varchar(36)로 박기
@PrimaryGeneratedColumn('uuid')
id!: string // PG: uuid, MySQL: varchar(36)MySQL은 uuid 타입이 없어 36문자열로 떨어진다 — 공간 2.25배. 해결책:
@PrimaryColumn({ type: 'binary', length: 16 })
id!: Buffer // 16바이트 raw수동 발급하고 바이너리로 박는다. 단, 디버깅 시 hex 변환이 피곤하다 — 트레이드오프.
함정 3) 복합키에 외래키가 줄줄이
@Entity()
class OrderItem {
@PrimaryColumn() orderId!: string
@PrimaryColumn() productId!: string
}
@Entity()
class OrderItemTag {
@PrimaryColumn() orderId!: string // 부모 PK 두 개를 다 들고
@PrimaryColumn() productId!: string
@PrimaryColumn() tagId!: string
}부모 PK가 자식에 전부 박힌다. 3단 깊이면 6컬럼 PK. 대안: 대리키 UUID 추가.
함정 4) 'uuid'인데 index 페이지 분할로 INSERT 느림
@PrimaryGeneratedColumn('uuid') id!: string // 무작위 → 클러스터드 인덱스 페이지 분할수억 건 INSERT에서 성능 차이 큼. 완화:
- UUID v7: 시간 정렬 (직접 생성).
- ULID: Lexicographic + 시간 (직접 생성).
- Sequential UUID: 일부 DB는 시간 prefix UUID 함수 제공 (MySQL
UUID_TO_BIN(uuid, 1)).
함정 5) 'increment'로 INSERT-then-RETURNING 가정
const saved = await repo.save({ name: 'Alice' })
console.log(saved.id) // ✅ 일반적으로 들어옴대부분 동작하지만, bulk insert나 raw query 후에는 왕복 비용이 든다. UUID 클라이언트 발급은 왕복을 없앤다.
함정 6) 복합 PK 엔티티의 findOneBy({ orderId })
await repo.findOneBy({ orderId: 'X' }) // ❌ 결과 모호 (여러 productId가 있을 수 있음)복합 PK에서는 모든 PK 필드를 채워야 findOne이 의미를 가진다. 하나만 채워 검색하면 find를 써야 한다.
Insight — 흥미로운 이야기
“
SERIAL은 표준이 아니었다 — PG 10이 그제서야IDENTITY를 도입했다”PostgreSQL의
SERIAL은 암묵적 시퀀스 + DEFAULT 조합이다 — 1997년부터 있었지만 SQL 표준 아님. SQL:2003이GENERATED AS IDENTITY를 표준화했고, PG는 *15년 뒤인 2017년(PG 10)*에야 따라잡았다. 차이의 핵심:SERIAL은 시퀀스가 별도 객체라 권한이 따로 필요하고,INSERT (id) VALUES (999)로 시퀀스를 건드리지 않고 강제 삽입 가능.IDENTITY는 이런 우회를OVERRIDING SYSTEM VALUE로 명시 강제 — 즉 실수가 줄어든다. TypeORM이'identity'를 별도 옵션으로 둔 이유 — 두 동작이 미묘하게 다르기 때문이다.
“UUID v7이 모든 인덱스 문제를 해결한다는 환상”
2024년 RFC 9562로 표준화된 UUID v7은 *상위 48비트가 Unix 시간(ms)*이라 시간 정렬된다. INSERT 시 인덱스 페이지가 순차로 채워져 v4의 페이지 분할 문제가 사라진다. 그러나 주의점:
- 예측 가능성이 다시 생긴다 —
/users/<uuidv7>에서 생성 시간을 추정할 수 있다.- 표준 라이브러리 지원은 아직 분산되어 있다 — Node.js 24+의
crypto.randomUUID()는 v4 고정.“v7로 갈아탈지”는 예측 가능성 vs 인덱스 효율의 새 트레이드오프다.
요약 + Mermaid
요약: PK는 두 데코레이터 —
@PrimaryColumn(수동),@PrimaryGeneratedColumn(자동) — 후자는 4 전략. 분산 발급·URL 노출이 필요하면'uuid', 단일 DB면'identity'(PG 10+) 또는'increment'. 복합키는 조인 테이블에 자연스럽지만 3단 깊이가 되면 대리키 추가가 운영적이다. 다음 문서(04-special-columns)는 PK 외의 시스템 컬럼(시간·삭제·버전)을 다룬다.