02. 스키마 정의 (Schema Basics)

2026년 2월 16일 수정됨

📋 개요

데이터베이스의 뼈대를 만드는 작업입니다. 테이블, 컬럼, 인덱스 등 스키마의 기초를 다룹니다.

📋 목차

데이터베이스의 뼈대를 만드는 작업입니다. 실무에서 가장 자주 열어보게 될 "설계도" 입니다.


🏛️ 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 성능의 핵심입니다. WHEREJOIN에 자주 쓰이는 컬럼에 걸어주세요.

(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)