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가 우주에서 누구인가를 정의한다 — 이 결정이 분산 시스템 가능성·외래키 디자인·인덱스 페이지 크기에 직결된다.

세 축이 충돌한다:

  1. 단순성id INTEGER AUTO_INCREMENT는 가장 적게 적는다.
  2. 분산성 — 여러 노드에서 동시에 발급해도 충돌이 없어야 한다.
  3. 노출 가능성 — URL에 박힐 때 예측 불가능해야 한다 (/users/1 vs /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 IDENTITY

1) 'increment' — 정수 자동 증가

@PrimaryGeneratedColumn()
id!: number
DBDDL
PostgreSQLid SERIAL (PG 10 미만) / 권장은 identity
MySQLid INT AUTO_INCREMENT PRIMARY KEY
SQLiteid INTEGER PRIMARY KEY AUTOINCREMENT
SQL Serverid INT IDENTITY(1,1)

장점: 인덱스 페이지가 순차 삽입되어 클러스터드 인덱스 효율 최고. 디버깅 쉬움 (/users/123). 단점: 분산 발급 불가능. URL 노출 시 총 사용자 수 추정이 가능 (/users/41723).

2) 'uuid' — UUID v4

@PrimaryGeneratedColumn('uuid')
id!: string                                 // 'a1b2c3d4-...-...'
DBDDL
PostgreSQLid uuid DEFAULT gen_random_uuid() (pgcrypto 또는 PG 13+)
MySQLid char(36) — 함수 default는 5.7+의 UUID() 또는 애플리케이션 생성
SQLiteid varchar애플리케이션이 생성
SQL Serverid 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
DBDDL
PostgreSQL 10+id INTEGER GENERATED ALWAYS AS IDENTITY (SQL 표준)
SQL Serverid INT IDENTITY(1,1)
MySQL❌ 미지원
SQLite❌ 미지원

SERIAL vs IDENTITY (PG):

측면SERIALIDENTITY (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

세 가지 가능성:

  1. DB 함수: PG는 gen_random_uuid()가 default로 박힌다 — INSERT 시 DB가 생성.
  2. TypeORM 클라이언트: 일부 드라이버에선 INSERT 전에 TypeORM이 uuid 라이브러리로 생성해 박는다.
  3. 명시 발급: 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 insertraw 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 외의 시스템 컬럼(시간·삭제·버전)을 다룬다.