02. 스키마 정의 (Schema Basics)
📋 개요
데이터베이스의 뼈대를 만드는 작업입니다. 테이블, 컬럼, 인덱스 등 스키마의 기초를 다룹니다.
📋 목차
- 🏛️ 1. 기본 구조: 테이블 정의
- 💎 2. 필수 컬럼 타입 총정리 (Cheat Sheet)
- �️ 3. 컬럼 옵션 & 제약조건 (Chain Methods)
- 🏗️ 4. 테이블 레벨 설정 (Advanced Configuration)
- 📂 5. 실전 폴더 구조 (Reference)
- 🔗 레퍼런스
데이터베이스의 뼈대를 만드는 작업입니다. 실무에서 가장 자주 열어보게 될 "설계도" 입니다.
🏛️ 1. 기본 구조: 테이블 정의
Drizzle에서 테이블은 pgTable 함수로 정의합니다. 첫 번째 인자는 DB 테이블명, 두 번째 인자는 컬럼 정의 객체 입니다.
// src/db/schema/users.ts
import { pgTable, serial, text, timestamp, boolean } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
// 1. 컬럼 정의
id: serial('id').primaryKey(),
email: text('email').notNull().unique(), // 체이닝으로 제약조건 추가
role: text('role', { enum: ['admin', 'user'] }).default('user'),
isActive: boolean('is_active').default(true),
// 2. 날짜 자동 관리
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow().$onUpdate(() => new Date()),
}, (table) => {
// 3. 테이블 레벨 설정 (인덱스, 복합키 등)
return {
emailIdx: index('email_idx').on(table.email),
};
});� Tip (CamelCase vs SnakeCase)
- JS 변수명:
isActive(코드에서 쓸 때)- DB 컬럼명:
is_active(DB에 저장될 때)
Drizzle이 내부적으로 자동 매핑해줍니다. DB는 snake_case가 국룰!
💎 2. 필수 컬럼 타입 총정리 (Cheat Sheet)
실무에서 자주 쓰는 PostgreSQL 타입들입니다.
🔤 문자열 (String)
| 함수 | 설명 | 옵션 예시 | 비고 |
|---|---|---|---|
text('name') | 제한 없는 텍스트 | - | Postgres에선 varchar보다 text 권장 (성능 차이 없음) |
varchar('name') | 길이 제한 문자열 | { length: 256 } | MySQL 마이그레이션 고려 시 사용 |
char('code') | 고정 길이 문자열 | { length: 2 } | 국가 코드(KR, US) 등에 적합 |
🔢 숫자 (Number)
| 함수 | 설명 | 비고 |
|---|---|---|
serial('id') | 자동 증가 정수 (1, 2, 3...) | 소규모/중규모 프로젝트 Primary Key |
integer('age') | 일반 정수 (4바이트) | 나이, 개수, 순서 |
bigint('price', { mode: 'number' }) | 큰 정수 (8바이트) | 금융, 조회수. mode: 'number' 안 하면 JS에서 BigInt 타입 됨(주의) |
numeric('rate', { precision: 5, scale: 2 }) | 소수점 (999.99) | 환율, 도량형 등 정밀 연산 필요 시 |
doublePrecision('score') | 부동소수점 | 과학 연산, 대략적인 실수 값 |
UUID vs Serial
serial: 읽기 쉽고 용량 작음. 분산 DB(샤딩) 환경에선 충돌 위험.uuid: 전 세계 유일. 분산 환경 필수. URL에 노출돼도 유추 불가능.- 실무 추천: 초기 스타트업은
serial로 충분, 보안이 중요하거나(ID 유추 방지) 대규모 확장이 예정되면uuid사용.
📅 날짜/시간 (Time)
| 함수 | 설명 | 추천 설정 |
|---|---|---|
timestamp('at') | 날짜 + 시간 | .withTimezone() (UTC 저장 권장) |
date('dob') | 날짜만 (생일 등) | - |
time('start') | 시간만 (근무시간 등) | - |
interval('duration') | 시간 간격 | - |
Best Practice:
timestamp는 가능하면{ withTimezone: true }를 쓰세요. 글로벌 서비스의 악몽(시차 계산)을 막아줍니다.
📦 기타 유용한 타입
| 함수 | 설명 | 활용 예시 |
|---|---|---|
boolean('is_ok') | true / false | 활성/비활성, 삭제 여부 |
json('data') / jsonb('data') | JSON 객체 저장 | jsonb 추천 (인덱싱/검색 가능) |
pgEnum('role', [...]) | 열거형 (Enum) | 고정된 값 목록 (User Role, Status) |
�️ 3. 컬럼 옵션 & 제약조건 (Chain Methods)
함수 뒤에 점(.)을 찍어 제약조건을 겁니다.
.notNull():NULL금지. (필수 값).default(값): 값 안 넣으면 기본값 사용. (.default(0),.default(false)).defaultNow(): (날짜 전용) 현재 시간으로 설정.created_at국룰..primaryKey(): 기본키 설정..unique(): 중복 금지. (이메일, 주민번호 등).$onUpdate(() => new Date()): (ORM 레벨) 데이터 수정될 때마다 자동 실행.updated_at에 필수.
🏗️ 4. 테이블 레벨 설정 (Advanced Configuration)
컬럼 정의 객체 뒤에 오는 세 번째 인자(콜백 함수) 입니다.
이곳에서는 여러 컬럼을 조합 하거나 복잡한 제약조건 을 설정합니다.
❓ 컬럼 vs 테이블 레벨: 언제 무엇을 쓸까?
| 구분 | 컬럼 레벨 (.unique()) | 테이블 레벨 (unique().on(...)) |
|---|---|---|
| 대상 | 해당 컬럼 1개 | 1개 이상의 컬럼 조합 |
| 용도 | 단순 제약 (이메일 중복 금지) | 복합 제약 (같은 그룹 내 이름 중복 금지) |
| 가독성 | 직관적 (컬럼 옆에 바로 보임) | 구조적 (제약조건만 따로 모아서 관리) |
🔍 1. 인덱스 (Indexes)
DB 성능의 핵심입니다. WHERE나 JOIN에 자주 쓰이는 컬럼에 걸어주세요.
(table) => ({
// 1. 단일 인덱스 (Single)
// "이메일로 검색할 일이 많다"
emailIdx: index('email_idx').on(table.email),
// 2. 복합 인덱스 (Composite) - ⭐️ 테이블 레벨의 존재 이유
// "성과 이름을 동시에 검색한다" (WHERE last_name = 'Kim' AND first_name = 'David')
nameIdx: index('name_idx').on(table.lastName, table.firstName),
// 3. 유니크 인덱스 (Unique)
// "폰 번호는 중복되면 안 된다" (제약조건효과 + 검색속도)
phoneUnique: uniqueIndex('phone_idx').on(table.phone),
})💡 Tip (Over-indexing):
인덱스는 '쓰기 속도'를 희생 하고 '읽기 속도'를 얻는 것입니다. 무지성으로 다 걸면INSERT/UPDATE가 느려지고 디스크를 많이 차지합니다.
- 필수: Foreign Key, Unique Column
- 권장: 자주 조회하는 조건 (
status,type), 정렬 기준 (created_at)
🛡️ 2. 체크 제약조건 (Check Constraints)
DB 수준에서 데이터의 유효성(Validation) 을 강제합니다. 애플리케이션 버그로 이상한 데이터가 들어가는 것을 원천 차단합니다.
import { sql } from 'drizzle-orm';
(table) => ({
// "나이는 19세 이상이어야 한다" (단순 값 체크)
ageCheck: check('age_check', sql`${table.age} >= 19`),
// "할인가격은 정가보다 작아야 한다" (컬럼 간 비교 ⭐️)
priceCheck: check('price_check', sql`${table.discountPrice} < ${table.normalPrice}`),
})⚠️ 주의: SQLite, MySQL 일부 버전에서는
CHECK제약조건이 무시될 수 있습니다. (Postgres는 아주 잘 동작함)
🔑 3. 복합 기본키 (Composite Primary Key)
단일 컬럼(id)이 아니라, 두 개 이상의 컬럼을 합쳐서 식별자로 쓸 때 사용합니다.
주로 N:M 관계의 연결 테이블(Junction Table) 에서 씁니다.
// users_to_groups 테이블
(table) => ({
// "유저ID와 그룹ID의 쌍은 유일해야 하며, 이것이 식별자다"
pk: primaryKey({ columns: [table.userId, table.groupId] }),
})Why?:
userId=1인 행이 여러 개일 수 있고,groupId=5인 행도 여러 개일 수 있지만, (1,5) 조합은 딱 하나만 있어야 하기 때문입니다.
🔗 4. 복합 외래키 (Composite Foreign Key)
흔하진 않지만, 두 개 이상의 컬럼이 합쳐져서 다른 테이블의 복합 키를 참조할 때 씁니다.
(table) => ({
// "내 테이블의 (a, b) 컬럼은 다른 테이블의 (x, y)를 참조한다"
compositeFk: foreignKey({
columns: [table.a, table.b],
foreignColumns: [otherTable.x, otherTable.y],
name: 'custom_fk_name' // 이름 직접 지정
})
.onDelete('cascade') // 부모 삭제 시 자식도 삭제
.onUpdate('cascade'),
})📂 5. 실전 폴더 구조 (Reference)
src/db/
├── schema/
│ ├── index.ts // 모든 스키마 export (진입점)
│ ├── users.ts // 도메인별 분리
│ ├── posts.ts
│ ├── comments.ts
│ └── enums.ts // Enum은 따로 모아두면 재사용하기 편함
├── index.ts // DB 연결 설정 (drizzle(...))
└── migrate.ts // 마이그레이션 스크립트
enums.ts 팁
// enums.ts
import { pgEnum } from 'drizzle-orm/pg-core';
export const roleEnum = pgEnum('role', ['admin', 'manager', 'user']);
export const statusEnum = pgEnum('status', ['pending', 'active', 'blocked']);다른 파일에서 import { roleEnum } from './enums' 해서 쓰면 깔끔합니다.
🔗 레퍼런스
다음 장: 03. 관계 정의 (Relations)