junyeokk
Blog
Database·2025. 11. 27

PostgreSQL 기본 데이터 타입과 제약조건

ORM이 SQL을 자동 생성해주지만, 데이터베이스에 어떤 타입으로 저장되는지 이해하는 것은 중요하다. 잘못된 타입을 선택하면 정밀도 손실, 성능 저하, 저장 공간 낭비가 발생한다.

문자열 타입

varchar(n)

최대 n자까지 저장하는 가변 길이 문자열이다. 실제 입력된 길이만큼만 공간을 사용한다.

sql
"name" varchar(255) not null
"phone" varchar(50) null
"serial_number" varchar(100) not null

MikroORM에서 @Property({ length: 255 })로 지정하면 varchar(255)에 매핑된다. 길이를 지정하지 않으면 기본값이 적용된다.

n을 초과하는 문자열을 넣으면 PostgreSQL이 에러를 발생시킨다. 매장 이름처럼 길이가 예측 가능한 데이터에 적합하다.

text

길이 제한이 없는 문자열이다. 최대 1GB까지 저장할 수 있다.

sql
"address" text not null
"description" text null
"s3_key" text null

MikroORM에서 @Property({ type: 'text' })로 지정한다. 주소, 설명, S3 키처럼 길이를 예측하기 어려운 데이터에 사용한다.

varchartext의 성능 차이는 PostgreSQL에서 거의 없다. PostgreSQL은 내부적으로 둘 다 같은 방식(varlena)으로 저장한다. varchar(n)의 장점은 데이터 무결성이다. 잘못된 데이터가 들어오는 것을 DB 레벨에서 막는다.

숫자 타입

integer (int)

32비트 정수. -2,147,483,648 ~ 2,147,483,647 범위를 저장한다.

sql
"quantity" int not null default 1
"sequence" int not null

수량, 순서 번호 등 소수점이 필요 없는 정수에 사용한다.

numeric (decimal)

정확한 소수점 계산이 필요한 경우에 쓴다. precision(전체 자릿수)과 scale(소수점 이하 자릿수)을 지정한다.

sql
"amount" numeric(10,2) not null
"unit_price" numeric(10,2) null

numeric(10,2)는 전체 10자리, 소수점 이하 2자리다. 99,999,999.99까지 표현할 수 있다.

금액에 float이나 double precision을 쓰면 안 되는 이유가 있다. 부동소수점 타입은 0.1 + 0.2가 0.30000000000000004가 되는 정밀도 문제가 있다. 1원 단위까지 정확해야 하는 금액 계산에서 이런 오류는 허용할 수 없다. numeric은 이런 문제 없이 정확한 계산을 보장한다. 다만 연산 속도는 float보다 느리다.

날짜/시간 타입

timestamptz

타임존 정보를 포함한 날짜+시간이다. PostgreSQL이 내부적으로 UTC로 변환해서 저장하고, 조회할 때 클라이언트의 타임존 설정에 맞춰 변환한다.

sql
"created_at" timestamptz not null default CURRENT_TIMESTAMP
"updated_at" timestamptz not null default CURRENT_TIMESTAMP
"sold_at" timestamptz not null default CURRENT_TIMESTAMP
"expires_at" timestamptz null

타임존 없는 timestamp도 있지만, timestamptz를 쓰는 것이 안전하다. 서버가 어느 타임존에서 실행되든 시간이 올바르게 해석된다.

CURRENT_TIMESTAMP는 SQL 실행 시점의 현재 시간을 넣는다. created_at의 기본값으로 사용하면 레코드 생성 시점이 자동으로 기록된다.

UUID 타입

uuid

128비트 고유 식별자다. 550e8400-e29b-41d4-a716-446655440000 형태로 저장된다.

sql
"store_id" uuid not null
"device_id" uuid not null
"session_id" uuid not null

자동 증가 정수(serial) 대신 UUID를 기본 키로 쓰는 이유는 두 가지다. 첫째, 분산 환경에서 중앙 서버 없이 ID를 생성할 수 있다. 키오스크 디바이스가 각각 독립적으로 세션 ID를 생성해도 충돌하지 않는다. 둘째, ID로 레코드 수를 추측할 수 없어서 보안에 유리하다. 정수 ID는 /stores/1, /stores/2 같은 패턴이 노출된다.

단점은 정수보다 크기가 크다(16바이트 vs 4바이트)는 것과, 정렬 성능이 떨어진다는 점이다.

JSON 타입

jsonb

JSON 데이터를 바이너리 형태로 저장한다. 검색, 인덱싱이 가능하다.

sql
"config" jsonb not null default '{}'
"data" jsonb null

스키마가 고정되지 않은 데이터에 사용한다. 디바이스 설정처럼 디바이스마다 다른 구조의 데이터를 저장할 때 유용하다. 모든 가능한 설정 항목을 컬럼으로 만들면 대부분이 null이 되지만, JSON으로 저장하면 필요한 것만 넣으면 된다.

jsonjsonb의 차이: json은 텍스트 그대로 저장하고, jsonb는 파싱된 바이너리로 저장한다. jsonb가 저장 시 약간 느리지만, 읽기와 검색이 빠르고 인덱싱이 가능하다. 특별한 이유가 없으면 jsonb를 사용한다.

제약조건

PRIMARY KEY

테이블의 각 행을 고유하게 식별한다. null 불가, 중복 불가.

sql
constraint "store_pkey" primary key ("store_id")

NOT NULL

null 값을 허용하지 않는다. 필수 데이터에 사용한다.

sql
"name" varchar(255) not null    -- 매장 이름은 필수
"phone" varchar(50) null        -- 전화번호는 선택

UNIQUE

중복 값을 허용하지 않는다.

sql
alter table "chiki"."device"
  add constraint "device_serial_number_unique" unique ("serial_number");

복합 유니크 제약조건은 여러 컬럼의 조합이 고유해야 한다.

sql
-- 같은 세션 내에서 sequence가 중복되면 안 됨
alter table "chiki"."shot"
  add constraint "shot_session_sequence_unique" unique ("session_id", "sequence");

session_id가 같고 sequence도 같은 행은 들어갈 수 없다. 하지만 session_id가 다르면 sequence가 같아도 허용된다.

CHECK

컬럼 값이 특정 조건을 만족하는지 검사한다.

sql
"status" text check ("status" in ('active', 'inactive', 'maintenance'))
  not null default 'active'

status 컬럼에 'active', 'inactive', 'maintenance' 외의 값이 들어오면 에러가 발생한다. MikroORM의 @Enum 데코레이터가 이 CHECK 제약조건을 자동 생성한다.

DEFAULT

값을 명시하지 않았을 때 사용할 기본값을 지정한다.

sql
"quantity" int not null default 1
"status" text not null default 'active'
"created_at" timestamptz not null default CURRENT_TIMESTAMP

CURRENT_TIMESTAMP처럼 SQL 함수를 기본값으로 쓸 수 있다.

FOREIGN KEY

다른 테이블의 기본 키를 참조한다. 참조하는 값이 실제로 존재하는지 DB가 보장한다.

sql
alter table "chiki"."device"
  add constraint "device_store_id_foreign"
  foreign key ("store_id")
  references "chiki"."store" ("store_id");

존재하지 않는 store_id로 Device를 만들려고 하면 에러가 발생한다. 데이터 무결성을 DB 레벨에서 보장한다.

CASCADE 옵션을 추가하면 부모 레코드 삭제 시 자식 레코드도 함께 삭제된다. MikroORM에서 deleteRule: 'cascade'로 설정한 것이 이에 해당한다.

정리

  • 금액은 반드시 numeric을 사용하고, float/double precision은 부동소수점 정밀도 문제 때문에 금액 계산에 쓰면 안 된다.
  • 날짜는 timestamptz를 기본으로 쓰고, JSON은 jsonb를 기본으로 쓴다. 각각 타임존 안전성과 검색/인덱싱 이점이 있다.
  • 제약조건(NOT NULL, UNIQUE, CHECK, FOREIGN KEY)은 애플리케이션 레벨이 아닌 DB 레벨에서 데이터 무결성을 보장하는 마지막 방어선이다.

관련 문서