PostgreSQL 기초 강좌 #2 자료형과 테이블 설계: 무엇으로 저장할 것인가

5 분 소요

1편의 첫 테이블에 심어 둔 습관들의 이유를 밝힐 차례입니다. 테이블 설계의 절반은 자료형 선택이고, 여기서의 잘못은 서비스가 커진 뒤 가장 고치기 비쌉니다. 이번 편은 실무에서 매일 마주치는 선택들, 문자열·숫자·시각·기본 키·제약을 판단 기준과 함께 정리하겠습니다.

문자열: text 하나면 됩니다 #

다른 DB에서 온 분들이 가장 먼저 묻는 것이 “varchar(255)로 해야 하나요?“입니다. PostgreSQL의 답은 단순합니다. text를 쓰세요. PostgreSQL에서 text와 varchar는 내부적으로 같고, 길이 제한이 있는 varchar(n)이라고 더 빠르지도 않습니다. 길이 제한이 비즈니스 규칙이라면(닉네임 20자 등) 자료형이 아니라 CHECK 제약으로 표현하는 것이 의도가 드러나고 변경도 쉽습니다.

CHECK로 길이 제한
name text NOT NULL CHECK (char_length(name) <= 20)

숫자: 돈은 무조건 numeric #

정수는 대부분 bigint면 됩니다(int로 시작했다가 21억을 넘겨 마이그레이션하는 고전적 사고를 피하는 값싼 보험입니다). 함정은 소수입니다. float·double precision은 이진 부동소수점이라 0.1 + 0.2가 정확히 0.3이 아닙니다. 돈, 수량, 요율처럼 정확해야 하는 값은 반드시 numeric을 씁니다. numeric은 십진 정확도를 보장하는 대신 연산이 느리지만, 그 차이가 문제 되는 워크로드는 드뭅니다. float의 자리는 과학 계산과 좌표처럼 근사가 허용되는 곳뿐입니다.

시각: timestamptz가 표준입니다 #

시각 저장은 이 편에서 가장 중요한 절입니다. PostgreSQL에는 timestamp(시간대 없음)와 timestamptz(시간대 인지)가 있는데, 실무 기본값은 timestamptz입니다. 이름과 달리 timestamptz는 시간대를 저장하지 않습니다. 입력을 UTC로 변환해 저장하고, 조회할 때 세션 시간대로 변환해 보여 줍니다. 즉 “절대 시점"을 저장하는 타입입니다. 반면 timestamp는 변환 없이 벽시계 숫자를 그대로 저장하므로, 서버·클라이언트의 시간대가 섞이는 순간 “이 9시가 어디의 9시인가” 문제가 시작됩니다.

timestamptz 컬럼
-- 절대 시점(이벤트 발생 시각)은 timestamptz
created_at timestamptz NOT NULL DEFAULT now()

예외는 “시간대와 무관한 벽시계 값"뿐입니다(매장 개점 시각 09:00 같은). 날짜만 필요하면 date, 기간은 interval이 따로 있습니다.

기본 키: bigint IDENTITY가 기본, UUID는 요건이 있을 때 #

PK 전략은 두 갈래입니다.

항목bigint IDENTITYUUID
크기·인덱스8바이트, 작고 빠름16바이트, 인덱스가 큼
생성 위치DB(순번)어디서든(클라이언트 포함)
추측 가능성순번 노출(개수 유추 가능)불가
삽입 지역성좋음(끝에 붙음)v4는 무작위라 나쁨, v7은 시간순이라 좋음

기본값은 bigint GENERATED ALWAYS AS IDENTITY입니다. 작고 빠르고 단순합니다. UUID가 요건이 되는 것은 분산 생성(DB에 오기 전에 ID가 필요), 외부 노출 ID의 추측 방지 같은 경우입니다. UUID를 쓴다면 v4의 무작위 삽입이 인덱스를 흩뜨리는 문제를 피해 시간순 정렬되는 uuidv7을 고르는 것이 현재의 정석이고, PostgreSQL 18부터는 uuidv7() 함수가 내장되어 확장 없이 쓸 수 있습니다. 내부 조인용 bigint PK + 외부 노출용 UUID 컬럼의 병행도 실무에서 흔한 절충입니다.

제약: 무결성은 DB 층에서 #

애플리케이션 검증은 우회당합니다(관리자 콘솔, 배치, 다음에 붙는 두 번째 서비스). 마지막 방어선은 DB 제약입니다.

제약을 건 orders 테이블
CREATE TABLE orders (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id     bigint NOT NULL REFERENCES users (id),
    status      text NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
    amount_krw  numeric NOT NULL CHECK (amount_krw >= 0),
    created_at  timestamptz NOT NULL DEFAULT now()
);
  • NOT NULL을 기본으로 두고, “없을 수 있음"이 진짜 의미일 때만 풉니다. NULL은 3값 논리(비교 결과가 true도 false도 아닌 unknown)를 끌고 들어오므로 적을수록 단순합니다.
  • CHECK는 값의 규칙을, REFERENCES(외래 키)는 관계의 무결성을 DB가 강제하게 만듭니다. 외래 키가 성능 때문에 망설여진다는 이야기는 대부분의 규모에서 기우이고, 고아 레코드의 수습 비용이 훨씬 비쌉니다.
  • 상태값은 위처럼 text + CHECK로 시작하는 것이 무난합니다. enum 타입도 있지만 값 추가·삭제의 운영이 번거로워, 값이 진짜로 고정된 도메인에만 씁니다. 배열(text[])은 “자식 테이블을 만들 정도는 아닌 다중값”(태그 등)에 유용하며, 검색이 필요해지면 GIN 인덱스(7편 JSONB와 같은 계열)가 받쳐 줍니다.

정리 #

  • 문자열은 text가 기본입니다. 길이 규칙은 varchar(n)이 아니라 CHECK로 표현합니다.
  • 돈·수량은 numeric입니다. float는 근사가 허용되는 곳 전용입니다. 정수는 bigint로 시작하는 것이 값싼 보험입니다.
  • 시각의 기본값은 timestamptz입니다. UTC로 저장되는 절대 시점이고, timestamp는 시간대 혼선의 씨앗입니다.
  • PK는 bigint IDENTITY가 기본, 분산 생성·추측 방지 요건이면 UUID(v7)입니다. PG 18부터 uuidv7()이 내장입니다.
  • 무결성의 마지막 방어선은 DB 제약입니다. NOT NULL 기본, CHECK와 외래 키를 아끼지 않는 것이 이 강좌의 설계 기조입니다.
X