본문 바로가기
Database/SQL

CREATE

by curious week 2026. 2. 10.

psql 내부 기본 메타 명령어

  • \l: 현재 DB 목록 
  • \dt: 테이블 목록 
  • \c db이름: 특정 DB로 이동 
  • \d 테이블명: 테이블 구조 보기 
  • \q: 종료

 

CREATE TABLE

  1. 컬럼 정의: 타입, DEFAULT, NOT NULL, GENERATED …
  2. 제약조건(Constraints): PK/UK/FK/CHECK 등 “규칙(무결성)”
  3. 물리/운영 옵션: TEMP, UNLOGGED, PARTITION, TABLESPACE 등 “저장/운영 방식”

1) 컬럼 정의(Column definition)

column_name data_type [COLLATE collation] [CONSTRAINT ...]
  [NOT NULL | NULL]
  [DEFAULT expr]
  [GENERATED ...]
  [IDENTITY ...]
  • data_type: 무엇을 저장하나(형식/범위/정렬), 어떤 값인가? 
  • NULL/NOT NULL: 값이 비어도 되나?
  • DEFAULT: 값을 안 주면 무엇이 들어가야 하나?
  • GENERATED: DB가 자동으로 계산/생성해야 하나?
  • IDENTITY: 숫자 자동 증가(키 생성)
    • 계산이면 GENERATED
    • 자동 증가면 IDENTITY
  • 정렬 규칙이 필요하면 COLLATE

(1) data_type (타입)

컬럼에 저장될 값의 형식/표현/범위를 결정한다.

id bigint
name text
email varchar(100)
age integer
created_at timestamptz

PostgreSQL에서 자주 쓰는 타입

숫자

  • smallint (2바이트)
  • integer 또는 int (4바이트)
  • bigint (8바이트)
  • numeric(p,s) (정확한 소수와 10진수, 돈/정산용)
  • 팁:
    • PK는 보통 bigint가 안정적(서비스 커지면 int 범위 초과 가능)
    • 돈은 float/double 쓰지 말고 numeric(정확도 문제)

문자열

  • text (길이 제한 없음, 필요하면 CHECK로 길이 강제 가능)
  • varchar(n) (최대 길이 제한, 길이 제한이 도메인 규칙일 때 사용)
  • Postgresql 특징:
    • text와 varchar는 성능 차이가 거의 없고,
    • varchar(n)는 “길이 제한을 DB가 강제”하는 의미가 크다.

날짜/시간

  • date
  • timestamp (timezone 없음)
  • timestamptz (timezone 포함: 내부는 UTC로 저장 + 표시할 때 변환)
    • created_at/updated_at 같은 “로그성 시간”은 timestamptz가 안전하다.

불리언

  • boolean (true/false)

JSON

  • jsonb (실무에서 주로 사용: 인덱싱/연산이 좋음)
  • json (원문 그대로 보존 목적)

MySQL 차이

  • 문자열: varchar(n) 사용 비율이 높음 (text도 있지만)
  • 시간: timezone 처리가 Postgres timestamptz처럼 “타입 자체로 강제”되진 않는 느낌이 있음(설계로 통제)
  • JSON 타입 존재(버전 의존)

Oracle 차이

  • 문자열: VARCHAR2
  • 숫자: NUMBER
  • 시간: DATE, TIMESTAMP (timezone 변형 타입도 있음)

(2) NULL / NOT NULL

“값이 비어도 되는지”를 DB가 강제한다.

name text NOT NULL
age int NULL  -- (기본이 NULL 허용이라 보통 안 씀)
  • “비면 안 되는 값”은 DB에서 NOT NULL로 막는 게 안전하다.
  • NOT NULL이면 PostgreSQL 내부에서 INSERT/UPDATE 때 NULL 여부를 검사한다.
  • PRIMARY KEY는 자동으로 NOT NULL을 포함한다.

(3) DEFAULT

INSERT에서 값을 주지 않았을 때 넣을 기본값을 정의한다. “DEFAULT는 없을 때만 적용”

created_at timestamptz DEFAULT now()
age int DEFAULT 0
status text DEFAULT 'active'
  • INSERT에서 컬럼을 생략하면 DEFAULT가 들어감
  • INSERT에서 명시적으로 NULL을 넣으면 NULL이 들어감(그리고 NOT NULL이면 에러)
INSERT INTO users(name) VALUES ('a');  -- created_at 기본값 들어감
INSERT INTO users(name, created_at) VALUES ('a', NULL); -- NULL(가능하면)

DEFAULT의 타이밍

  • now() / transaction_timestamp():
    • 트랜잭션 시작 시각 기준(같은 트랜잭션 내 동일)
    • created_at에는 보통 now()가 일관성 좋음.
  • clock_timestamp():
    • 호출 순간의 실제 시각(행마다 달라질 수 있음)

MySQL 차이

  • DEFAULT CURRENT_TIMESTAMP 같은 문법을 자주 씀
  • 버전/설정에 따라 timestamp 기본값 동작이 Postgres와 약간 다르게 느껴질 수 있음

Oracle 차이

  • 기본값으로 DEFAULT SYSTIMESTAMP 같은 것을 씀

(4) GENERATED (계산 컬럼)

Postgres에서는 “다른 컬럼을 이용해 자동 계산되는 컬럼”을 만들 수 있다.

full_name text
  GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED
  • full_name 값은 사용자가 직접 입력하지 않고 DB가 계산해서 유지
  • 자주 조합되는 표시용 값이나 검색/정렬/인덱스가 필요한 파생 값이 필요할 때 사용

STORED

  • 계산 결과를 실제로 저장한다(조회가 빠름)
  • 반대로 “계산만 하고 저장은 안 하는” 형태는 DB마다 지원 수준이 다름

MySQL/Oracle 차이

  • 둘 다 generated column 개념은 있음
  • 표현식/저장 방식/제약은 DB마다 조금씩 차이

(5) IDENTITY (자동 증가)

id bigint GENERATED ALWAYS AS IDENTITY
  • INSERT에서 id를 안 주면 자동으로 증가값을 넣어준다.
  • Postgres에서 SERIAL 대체.
  • 내부적으로 “시퀀스”라는 별도 오브젝트를 만들고, id의 기본값이 “시퀀스 next value”로 연결된다. (“테이블 + 시퀀스”가 같이 생기는 구조)

ALWAYS vs BY DEFAULT

id bigint GENERATED ALWAYS AS IDENTITY
  • ALWAYS는 원칙적으로 사용자가 id를 직접 넣는 걸 막음(안전)
id bigint GENERATED BY DEFAULT AS IDENTITY
  • DEFAULT는 자동, 필요하면 수동 삽입도 가능(마이그레이션/데이터 이관 때 편함)

SERIAL과 IDENTITY의 차이

  • SERIAL: 관례적 문법 설탕(자동으로 여러 작업을 수행)
  • IDENTITY: 표준에 가깝고 “이 컬럼은 identity다”가 메타데이터로 남아 관리가 명확

MySQL/Oracle 차이

  • MySQL: AUTO_INCREMENT
  • Oracle: GENERATED AS IDENTITY 사용

(6) COLLATE (정렬/비교 규칙)

문자열 비교/정렬 규칙을 컬럼 단위로 지정할 수 있다

name text COLLATE "C"
  • 정렬 순서를 특정 규칙으로 강제해야 할 때
  • 로케일/대소문자 정렬이 민감할 때
  • “대부분 기본 collation”을 쓰지만, 검색/정렬 이슈 터질 때 COLLATE가 도움이 된다.

2) 제약조건(Constraints) 

제약조건은 한 줄로 말하면, “DB가 데이터가 잘못 들어오는 걸 물리적으로 막는 장치” — 규칙과 무결성

제약조건의 종류 핵심 4개

  1. PRIMARY KEY (PK)
  2. UNIQUE (UK)
  3. FOREIGN KEY (FK)
  4. CHECK

내부적으로

  • PK / UNIQUE는 “인덱스까지 같이 생성됨” → 조회 성능과 직결
  • FK / CHECK는 “규칙만 생성됨” → 성능은 별도 인덱스 설계 필요

이 차이가 성능/락/설계까지 이어진다.

(1) PRIMARY KEY (PK)

행(row)을 유일하게 식별하는 기준.

id bigint PRIMARY KEY

또는 테이블 레벨에서 작성:

CONSTRAINT users_pk PRIMARY KEY (id)

 

 

PK를 만들면 DB는 3가지를 동시에 보장한다. (PK = 규칙 + 인덱스)

  1. NOT NULL 강제
  2. UNIQUE 강제
  3. 유니크 인덱스 생성

PostgreSQL에서는 B-tree 유니크 인덱스가 자동으로 만들어진다.

  • PK는 조회 성능의 기준이 된다
  • FK 참조의 기준이 된다
  • ORM(JPA 등)에서 엔티티 식별 기준

복합 PK

PRIMARY KEY (tenant_id, user_id)
  • 두 컬럼의 조합이 유일해야 함
  • 다중 테넌트, 매핑 테이블에 사용됨.

(2) UNIQUE (UK)

“중복 금지” 규칙

  • 내부적으로 유니크 인덱스 생성됨
email text UNIQUE

또는:

CONSTRAINT users_email_uk UNIQUE (email)

복합 유니크:

UNIQUE (tenant_id, email)

 

PostgreSQL에서 중요한 특징 UNIQUE는 NULL을 “값 없음”으로 보며 NULL끼리는 서로 다르다고 판단한다.

email = NULL
email = NULL

→ 여러 개 허용된다.

email text NOT NULL UNIQUE

그래서 실무에서는 NOT NULL + UNIQUE 조합이 일반적이다.

(3) FOREIGN KEY (FK)

“다른 테이블의 값과 연결된다는 규칙”

FOREIGN KEY (user_id)
REFERENCES users(id)
  • orders.user_id는 users.id에 존재해야만 한다.
  • FK는 인덱스를 자동으로 만들지 않는다. 대신:
    • INSERT 시 참조 존재 검사
    • UPDATE 시 참조 검사
    • DELETE 시 연쇄 규칙 검사

연쇄 규칙

ON DELETE CASCADE
ON UPDATE RESTRICT

옵션 설명:

CASCADE 부모 삭제 → 자식도 삭제 (위험: parent 한 번 삭제 → child 수십만 row 삭제
로그/결제/주문 → CASCADE 거의 안 쓴다, 매핑/종속 데이터 → CASCADE 가능)
RESTRICT 참조 중이면 삭제 불가
NO ACTION 기본값 (RESTRICT와 유사)
SET NULL 부모 삭제 → 자식 FK를 NULL
SET DEFAULT DEFAULT로 변경

FK 인덱스

CREATE INDEX orders_user_id_idx ON orders(user_id);
  • Postgres는 FK 컬럼에 인덱스를 자동 생성하지 않는다.
  • 그래서 이걸 안 하면 부모 삭제 시 → 자식 전체 스캔 발생 → 성능 급락 + lock 증가

DEFERRABLE (고급)

FOREIGN KEY (...) REFERENCES ...
DEFERRABLE INITIALLY DEFERRED
  • FK 검사 시점을 “커밋 시점”으로 미룸
  • 서로 참조하는 데이터 삽입이나 트랜잭션 내부 순서 제약이 있는 경우 사용

(4) CHECK

값의 범위/형식 규칙 강제.

age int CHECK (age BETWEEN 0 AND 150)
CHECK (status IN ('active', 'inactive'))
  • INSERT/UPDATE 때 표현식 평가한다.
  • 규칙 위반 시 에러 발생
  • CHECK는 DB에서 마지막 방어선 역할을 함.

(5) 제약조건 예제

CREATE TABLE users (
  id bigint GENERATED ALWAYS AS IDENTITY,
  tenant_id bigint NOT NULL,

  name text NOT NULL,
  email text NOT NULL,
  age int CHECK (age BETWEEN 0 AND 150),

  CONSTRAINT users_pk PRIMARY KEY (id),
  CONSTRAINT users_email_uk UNIQUE (tenant_id, email),

  CONSTRAINT users_tenant_fk
    FOREIGN KEY (tenant_id)
    REFERENCES tenants(id)
    ON DELETE RESTRICT
);

그리고:

CREATE INDEX users_tenant_idx ON users(tenant_id);

3) 물리 / 운영 옵션 개요

CREATE TABLE은 사실 데이터 저장 방식”까지 같이 설계하는 명령이다.

“디스크 + 로그 + 복구 + 성능”과 직결되는 대표 옵션:

  1. TEMP / TEMPORARY
  2. UNLOGGED
  3. PARTITION
  4. TABLESPACE
TEMP 세션/트랜잭션
UNLOGGED WAL / 복구
PARTITION 성능 / 대용량
TABLESPACE 디스크 / 운영

(1) TEMP TABLE

“세션 또는 트랜잭션 동안만 존재하는 테이블”

  • 세션 종료 → 자동 삭제
  • 다른 세션에서는 보이지 않음
CREATE TEMP TABLE temp_users (...);

또는

CREATE TEMPORARY TABLE ...

내부 동작

  • 일반 테이블처럼 디스크에 쓰일 수도 있음
  • WAL 로그는 최소화
  • session scope에서만 존재

1) 중간 계산

SELECT ...
INTO TEMP temp_result
FROM ...

2) 배치 처리: 복잡한 JOIN 결과 저장, 집계 중간 단계

3) 성능 튜닝: 반복 계산 방지

운영 시 이슈

  • Connection pool 환경에서 문제 발생 가능.
  • 예들 들어, Spring / Node, 세션 재사용 시 TEMP TABLE이 예상보다 오래 살아남을 수 있음.
  • 그래서 웹 서비스에서는 TEMP TABLE을 신중하게 사용

(2) UNLOGGED TABLE

CREATE UNLOGGED TABLE logs_ingest (...);
  • WAL(Write-Ahead Log) 로그를 남기지 않는 테이블
  • PostgreSQL는 데이터 쓰기 전에 로그 먼저 기록한다. → 장애 시 복구 가능 / UNLOGGED로 만들면 로그를 기록하지 않음.
  • 장점: INSERT 속도 빠름,  디스크 I/O 감소
  • 단점: DB crash → 데이터 사라질 수 있음, replication 대상 아님
  • 캐시 테이블, 임시 데이터, ETL 중간 결과 등에는 사용가능하지만,
  • 주문, 결제, 회원 등에는 절대로 사용해서는 안된다.

(3) PARTITION TABLE

대용량 DB의 핵심이자 거의 필수.

  • 내부적으로는 부모 테이블은 “메타”로 실제 데이터는 자식 파티션에 저장됨
CREATE TABLE events (
  id bigint,
  created_at timestamptz
) PARTITION BY RANGE (created_at);

테이블을 여러 물리 테이블로 나눔.

events
 ├─ events_2025
 ├─ events_2026
 └─ events_2027
  • 대용량 테이블을 사용하면, 수억 row, 인덱스 커짐, 조회 느림, vacuum 부담 등이 발생
  • 이를 파티션으로 해결이 가능하다. (조회 성능 향상, 백업/삭제 쉬움, 아카이빙 편함)
    • 범위별로 나눠 저장
    • 필요한 파티션만 조회

RANGE: 시간 기반.

PARTITION BY RANGE (created_at)

LIST: 카테고리.

PARTITION BY LIST (country)

HASH: 분산.

PARTITION BY HASH (user_id)

 

(4) TABLESPACE

  • 성능 튜닝, 스토리지 분리, 대규모 운영 시 사용됨.
CREATE TABLE users (...) TABLESPACE fast_disk;
  • 테이블을 저장할 “디스크 위치” 지정
  • 서버에 디스크 여러 개 존재 시 필요하다.
  • 테이블을 특정 물리 위치로 보냄.
SSD
HDD
Network Storage
cluster
 ├─ tablespace A
 ├─ tablespace B
 └─ default

실제 테이블 분석

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    email VARCHAR(100) UNIQUE,
    age INT,
    created_at TIMESTAMP DEFAULT NOW()
);

겉으로는 테이블 하나지만, 실제로는 최소 이것들이 만들어진다:

  1. 테이블 users
  2. 컬럼 타입/제약의 메타데이터 (시스템 카탈로그에 기록)
  3. SERIAL 때문에 시퀀스(sequence) 가 추가로 생성되고, id의 DEFAULT로 연결됨
  4. PRIMARY KEY 때문에 유니크 인덱스가 생성됨 (Postgres는 PK를 “인덱스”로 구현)
  5. UNIQUE(email) 때문에 유니크 인덱스가 추가로 생성됨
  6. DEFAULT NOW() 는 실행 시점마다 평가되는 default expression로 저장됨

즉 “테이블 1개”가 아니라 오브젝트 여러 개가 묶음으로 생긴다.

1) id SERIAL PRIMARY KEY

(1) SERIAL의 정체

  • SERIAL은 진짜 타입(type)이 아니다. 문법 설탕(syntactic sugar) 이다.
  • SERIAL은 “auto increment” 기능을 만들어주는 패키지 문법이라고 보면 된다.'
  • 요즘 Postgres에서는 SERIAL보다 GENERATED ... AS IDENTITY를 권장하는 흐름이 많다. (표준 SQL에 가깝고 관리가 더 명확함)
  • 내부적으로 이렇게 바꿔서 처리한다(개념적으로):
    • id integer NOT NULL
    • CREATE SEQUENCE users_id_seq
    • id DEFAULT nextval('users_id_seq')
    • 시퀀스 소유 연결 (ALTER SEQUENCE ... OWNED BY users.id)
    • PK 인덱스 생성

권장 예시:

id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY

또는

id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
  • ALWAYS: 사용자가 직접 넣는 걸 기본적으로 막음
  • BY DEFAULT: 필요하면 직접 값 삽입 가능

(2) PRIMARY KEY의 의미

PK는 2가지를 강제한다:

  • NOT NULL
  • UNIQUE

그리고 Postgres에서는 이를 구현하기 위해 유니크 B-tree 인덱스가 만들어진다.

2) name VARCHAR(50) NOT NULL

(1) VARCHAR(50)의 의미

  • 문자열 길이 제한(최대 50)
  • 다만 Postgres에서는 varchar(n)과 text의 성능 차이가 거의 없고, varchar(n)은 “검증(제약)” 목적이 크다.
  • 엄격한 길이 제한이 의미 있는 도메인이면 varchar(n) 그렇지 않으면 text + 체크 제약/앱 검증으로도 충분

(2) NOT NULL

  • NULL을 허용하지 않는다.
  • 인덱스/쿼리 측면에서 NULL은 3-valued logic(참/거짓/Unknown) 때문에 조건이 복잡해질 수 있어, 도메인 상 필수면 NOT NULL이 좋다.

3) email VARCHAR(100) UNIQUE

UNIQUE는 결국 유니크 인덱스 생성이다.

  • UNIQUE는 NULL을 “특수하게” 다룬다.
  • PostgreSQL에서 UNIQUE는 NULL을 서로 다르다고 취급해서,
    • email이 NULL인 행을 여러 개 넣을 수 있다.
  • email을 정말 유일하게 강제하려면:
    • email NOT NULL UNIQUE 가 가장 흔함
  • “활성 사용자만 유니크” 같은 조건부 유니크 Partial unique index(부분 유니크 인덱스)를 쓴다.
CREATE UNIQUE INDEX users_email_unique_active
ON users(email)
WHERE deleted_at IS NULL;

 

4) age INT

INT는 integer의 별칭(alias). 여기서 깊게 들어가면 “도메인 제약”이 중요하다.

  • age는 0~150 같은 범위를 강제하고 싶다면 CHECK가 더 안전하다.
  • 이건 앱 검증을 뚫고 들어오는 데이터(배치, 다른 서비스, 운영 실수)를 DB에서 막는다.
age INT CHECK (age BETWEEN 0 AND 150)

 

5) created_at TIMESTAMP DEFAULT NOW()

(1) TIMESTAMP vs TIMESTAMPTZ

  • TIMESTAMP : timezone 정보 없음(그냥 “벽시계 시간”)
  • TIMESTAMPTZ : 실제로는 UTC 저장 + 표시할 때 timezone 적용
  • 실무에서 생성 시간(created_at)은 거의 항상 timestamptz가 더 안전하다.
created_at TIMESTAMPTZ NOT NULL DEFAULT now()

(2) NOW()가 언제 평가되나

PostgreSQL에서 now()는 보통 “트랜잭션 시작 시각” 기준으로 동작한다(여러 row를 insert해도 동일한 값이 들어갈 수 있음). created_at은 일반적으로 now()가 맞다(트랜잭션 일관성).

반면 “실제 현재 시각”이 필요하면 clock_timestamp()를 쓴다.

  • now() / transaction_timestamp() : 트랜잭션 시간
  • clock_timestamp() : 호출 순간의 진짜 시간

MySQL

  • auto increment는 AUTO_INCREMENT
  • DB=스키마 개념으로 묶여 있어서 권한 구조가 Postgres와 다르게 느껴짐
  • UNIQUE에서 NULL 처리도 DB/버전에 따라 차이가 체감될 수 있음(대체로 NULL 여러 개 허용)
id BIGINT AUTO_INCREMENT PRIMARY KEY

Oracle

  • 전통적으로는 “시퀀스 + 트리거” 조합이 많았음
  • 최근 Oracle은 identity 컬럼 지원
  • 스키마=사용자 개념이 강함
id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY

 

6) 처음 예제를 안전 버전으로 업그레이드

CREATE TABLE users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  age INT CHECK (age BETWEEN 0 AND 150),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
  • SERIAL → IDENTITY
  • created_at → timestamptz
  • email → not null + unique
  • age → check로 도메인 강제

문제 1 — 기본 테이블 생성

users 테이블 생성.

컬럼:

  • id → 자동 증가 PK
  • username → VARCHAR(30), NOT NULL
  • email → UNIQUE
  • age → 0 이상만 허용
  • created_at → 기본값 현재시간

요구사항:

  • PK
  • UNIQUE
  • CHECK
  • DEFAULT 포함
CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username VARCHAR(30) NOT NULL,
    email TEXT NOT NULL UNIQUE,
    age INT CHECK (age >= 0),
    created_at TIMESTAMP DEFAULT NOW()
);

문제 2 — FK 포함 테이블

orders 테이블 생성.

컬럼:

  • id → 자동 증가 PK
  • user_id → users.id 참조
  • total_price → 0 이상
  • status → 기본값 'PENDING'
  • created_at → 현재시간

조건:

  • FK
  • CHECK
  • DEFAULT
CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    total_price NUMERIC NOT NULL CHECK (total_price >= 0),
    status VARCHAR(30) NOT NULL
        CHECK (status IN ('pending', 'processing', 'delivered'))
        DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT NOW(),
    CONSTRAINT orders_user_id_fk
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE RESTRICT
);

CREATE INDEX idx_orders_user_id ON orders(user_id);
  • 실제로 많이 사용하며, ON DELETE RESTRICT는 기본값이기 때문에 생략 가능하다.
CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    total_price NUMERIC NOT NULL CHECK (total_price >= 0),
    status VARCHAR(30) NOT NULL
        CHECK (status IN ('pending', 'processing', 'delivered'))
        DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_orders_user_id ON orders(user_id);
  • 둘 다 가능하지만, 참조 컬럼을 옆에 쓰면 복합 FK는 사용할 수 없다.

문제 3 — 복합 UNIQUE

products 테이블 생성.

컬럼:

  • id → 자동 증가 PK
  • name → NOT NULL
  • category → NOT NULL
  • price → 0 이상
  • created_at → 현재시간

조건:

  • (name, category) 조합 UNIQUE
CREATE TABLE products (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT NOT NULL,
    price NUMERIC NOT NULL CHECK(price >= 0),
    created_at TIMESTAMP DEFAULT NOW(),
    
    CONSTRAINT name_category_uk
        UNIQUE(name, category)
)
  • 상품 카테고리당 이름 중복 금지를 위해 사용했지만
  • UNIQUE 제약을 만들면 → 자동으로 인덱스 생성되므로
  • 성능도 향상의 이점도 얻을 수 있다.
  • (user_id, product_id) UNIQUE 이런 식으로 한 사용자가 같은 상품에 리뷰 1개만 달 수 있도록 한다.
ALTER TABLE products DROP CONSTRAINT name_category_uk;
  • 복함 UNIQUE 삭제 시 사용

문제 4 — INDEX까지 포함

reviews 테이블 생성.

컬럼:

  • id → identity PK
  • user_id → users FK
  • product_id → products FK
  • rating → 1~5 범위만 허용
  • content → TEXT
  • created_at → 현재시간

조건:

  • FK 2개
  • rating CHECK
  • rating 컬럼에 INDEX 생성
  • (user_id, product_id) 복합 UNIQUE 추가
    (같은 유저가 같은 상품 리뷰 1개만 작성 가능)
CREATE TABLE reviews (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
    content TEXT,
    created_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT reviews_user_id_fk
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE NO ACTION,

    CONSTRAINT reviews_product_id_fk
        FOREIGN KEY (product_id)
        REFERENCES products(id)
        ON DELETE NO ACTION,

    CONSTRAINT user_product_uk UNIQUE (user_id, product_id)
);


CREATE INDEX idx_reviews_rating ON reviews(rating);

문제 5 — GENERATED 컬럼

payments 테이블 생성.

컬럼:

  • id → 자동 증가 PK
  • amount → INT
  • tax → INT
  • total → amount + tax 자동 계산
  • created_at → 현재시간

조건:

  • GENERATED ALWAYS AS 사용
CREATE TABLE payments (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    amount INTEGER NOT NULL CHECK (amount >= 0),
    tax INTEGER NOT NULL CHECK (tax >= 0),
    total NUMERIC GENERATED ALWAYS AS (amount + tax) STORED,
    created_at TIMESTAMP DEFAULT NOW()
);
  • NOT NULL + CHECK 조합을 잊지말 것!

문제 6 — 운영 옵션

logs 테이블 생성.

컬럼:

  • id → BIGSERIAL PK
  • message → TEXT
  • created_at → 현재시간

조건:

  • UNLOGGED 테이블
CREATE TABLE events (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    type TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL,
    PRIMARY KEY(id, created_at)
)
PARTITION BY RANGE (created_at);
  • PK를 바로 쓰지 못하고, partition table의 PK/UNIQUE는 partition key를 포함해야 함.
  • PRIMARY KEY (id, created_at)에서 CONSTRAINT events_pk을 생략한 형태

자식 파티션 생성

CREATE TABLE events_2026_01
PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE events_2026_02
PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
  • 1월 데이터 → events_2026_01
  • 2월 데이터 → events_2026_02
  • 보통 자식은 배포 파이프라인에서 SQL migration으로 만들거나, 또는 스케줄러/크론으로 생성한다.

자동 분산 저장됨. 조회는 부모 테이블만 보면 됨.

INSERT INTO events(type, created_at)
VALUES ('login', '2026-01-15');

INSERT INTO events(type, created_at)
VALUES ('logout', '2026-02-10');
  • INSERT를 해보면 만들어지지 않은 이벤트에는 INSERT 되지 않는다.
INSERT INTO events (...) VALUES (...);
UPDATE events SET ... WHERE ...;
DELETE FROM events WHERE ...;
  • NSERT/UPDATE/DELETE는 부모 테이블로만 한다
CREATE INDEX ON events (created_at);
  • 파티션 테이블에서 이렇게 부모에 만들면, PostgreSQL이 보통 각 파티션에 인덱스를 만들어준다

 


문제 6 — 운영 옵션

logs 테이블 생성.

컬럼:

  • id → BIGSERIAL PK
  • message → TEXT
  • created_at → 현재시간

조건:

  • UNLOGGED 테이블
CREATE UNLOGGED TABLE logs (
    id BIGSERIAL PRIMARY KEY,
    message TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

'Database > SQL' 카테고리의 다른 글

INSERT, UPDATE, DELETE  (0) 2026.02.10
ALTER, DROP, TRUNCATE  (0) 2026.02.10
String, Date  (0) 2026.02.09
GROUP BY  (0) 2026.02.09
JOIN  (0) 2026.02.08