테이블을 만드는 일은 컬럼 이름을 나열하는 것보다 훨씬 중요합니다. 제약조건, 기본값, NULL 허용 여부를 어떻게 두느냐에 따라 애플리케이션 버그를 DB가 막아주기도 하고 방치하기도 합니다. 스키마 기본기를 알면 데이터 품질을 처음부터 지킬 수 있습니다.
데이터베이스 구조의 계층을 명확히 이해하고, DDL을 사용해 테이블을 설계하고 수정하는 실전 능력을 갖춥니다.
- 1Database > Schema > Table > Column > Row 계층 구조
- 2PostgreSQL과 MySQL의 Schema 개념 차이
- 3DDL: CREATE, ALTER, DROP
- 4컬럼 제약조건 (NOT NULL, UNIQUE, DEFAULT, CHECK)
- 5테이블 네이밍 컨벤션
테이블과 스키마 — 데이터베이스 구조 용어 완전 정리
스키마 설계 없이 기능부터 짜고 나중에 정리하면 된다고 생각했던 시절이 있다. users 테이블 하나에 컬럼을 계속 붙여가다 보니 어느 순간 user_extra_info, user_meta, user_data2 같은 컬럼이 수십 개 생겨났고, 어느 컬럼이 현재 쓰이는 건지 아무도 몰랐다. 거기다 분석팀이 같은 public 스키마에 stats 테이블을 잔뜩 쌓으면서, 특정 테이블 읽기 권한만 주려 해도 민감한 사용자 데이터까지 통째로 노출되는 구조가 돼버렸다. 처음부터 Database > Schema > Table > Column 계층을 이해하고 논리적으로 분리했다면 생기지 않았을 문제다. 이 모듈에서는 그 계층 구조의 의미와, DDL로 테이블을 안전하게 설계하고 수정하는 방법을 실무 관점에서 정리한다. "스키마가 뭐예요?"라는 질문에 MySQL 개발자와 PostgreSQL 개발자가 서로 다른 답을 하는 이유도 여기서 알게 된다.
데이터베이스를 처음 배울 때 가장 혼란스러운 것 중 하나가 용어입니다. "스키마가 데이터베이스야, 테이블이야?" "MySQL과 PostgreSQL에서 스키마의 의미가 다르다던데?" 이 모듈에서는 데이터베이스 구조의 계층과 각 용어의 정확한 의미를 정리하고, DDL로 테이블을 생성하고 수정하는 방법을 익힙니다.
DB 구조 계층 — Database > Schema > Table > Column > Row
DBA가 보낸 접속 정보에 database, schema, table이 따로 있습니다. MySQL에서 쓰던 방식으로 PostgreSQL에 접속했더니 "schema not found"가 납니다. 계층 구조를 이해하지 못하면 "database가 없다"와 "table이 없다"를 같은 오류로 혼동하고, 어디를 어떻게 만들어야 하는지 알 수 없습니다.

5단계 계층 구조
관계형 데이터베이스는 아래 다이어그램과 같이 5단계 계층으로 구성됩니다. 가장 바깥쪽 Database Cluster는 PostgreSQL 서버 인스턴스 자체이고, 그 안에 여러 Database가 존재합니다. 각 Database 안에 Schema(네임스페이스)가 있고, Schema 안에 Table이 있습니다. Table은 열(Column)과 행(Row)으로 이루어진 2차원 구조입니다.
┌─────────────────────────────────────────────────┐
│ Database Cluster (서버 인스턴스) │
│ ┌──────────────────────────────────────────┐ │
│ │ Database: ecommerce │ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ Schema: public │ │ │
│ │ │ ┌──────────────────────────────┐ │ │ │
│ │ │ │ Table: users │ │ │ │
│ │ │ │ ┌────────┬───────┬────────┐ │ │ │ │
│ │ │ │ │ id(PK) │ email │ name │ │ │ │ │
│ │ │ │ ├────────┼───────┼────────┤ │ │ │ │
│ │ │ │ │ 1 │ a@b.c │ Alice │ │← Row │ │
│ │ │ │ │ 2 │ c@d.e │ Bob │ │ │ │ │
│ │ │ │ └────────┴───────┴────────┘ │ │ │ │
│ │ │ │ ↑ Column │ │ │ │
│ │ │ └──────────────────────────────┘ │ │ │
│ │ │ Table: products, Table: orders... │ │ │
│ │ └────────────────────────────────────┘ │ │
│ │ Schema: analytics, Schema: audit... │ │
│ └──────────────────────────────────────────┘ │
│ Database: warehouse, Database: logs... │
└─────────────────────────────────────────────────┘
각 계층의 역할
| 계층 | 설명 | PostgreSQL 예시 |
|---|---|---|
| Database | 독립적인 데이터 컨테이너. 별도 DB 간 쿼리 불가 | ecommerce, warehouse |
| Schema | DB 내 논리적 네임스페이스. 테이블 그룹화 | public, analytics, audit |
| Table | 실제 데이터가 저장되는 2차원 구조 | users, products, orders |
| Column | 테이블의 세로축. 이름과 타입 정의 | id, email, created_at |
| Row | 테이블의 가로축. 실제 데이터 레코드 | 특정 사용자 1명의 데이터 |
PostgreSQL vs MySQL — Schema 개념 차이
PostgreSQL과 MySQL은 Schema 개념이 완전히 다릅니다. PostgreSQL은 Database와 Schema를 명확히 구분합니다. 하나의 Database 안에 여러 Schema가 공존할 수 있고, 각 Schema는 독립된 네임스페이스로 테이블을 격리합니다. 반면 MySQL/MariaDB에서는 DATABASE와 SCHEMA가 완전히 같은 의미입니다. MySQL에는 PostgreSQL 방식의 중간 Schema 계층이 존재하지 않습니다.
\c ecommerce
CREATE SCHEMA analytics;
CREATE TABLE analytics.daily_stats (
date DATE PRIMARY KEY,
visits INT
);
SELECT * FROM public.users;
SELECT * FROM users;
실행 완료 또는 조회 결과가 표시됩니다.
- 제약조건—NOT NULL, UNIQUE, CHECK가 데이터 규칙을 막아주는지 확인합니다.
- 기본값—INSERT 시 누락 가능한 컬럼에 안전한 DEFAULT가 있는지 봅니다.
- DDL 영향—ALTER/DROP/TRUNCATE가 운영 테이블에 미치는 락과 복구 위험을 점검합니다.
CREATE DATABASE ecommerce;
CREATE SCHEMA ecommerce;
USE ecommerce;
Schema 활용 예시 — 멀티 테넌트 구조
SaaS 서비스에서 고객사별로 Schema를 분리하면, 동일한 Database에서 고객 데이터를 논리적으로 격리할 수 있습니다. search_path를 설정하면 쿼리에서 스키마 이름을 생략할 수 있어 코드 변경 없이 테넌트를 전환할 수 있습니다.
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_bigcorp;
CREATE TABLE tenant_acme.users (id SERIAL PRIMARY KEY, name VARCHAR(100));
CREATE TABLE tenant_bigcorp.users (id SERIAL PRIMARY KEY, name VARCHAR(100));
SET search_path = tenant_acme;
SELECT * FROM users;
개발 초기에 편의상 모든 테이블을 public 스키마에 집어넣었습니다. 서비스가 커지면서 분석팀, 백엔드팀, 감사(audit) 목적의 테이블이 public에 수백 개 섞이게 되었습니다. 분석팀에게 읽기 권한만 주려 해도 public에 있는 모든 테이블이 노출되는 문제가 발생했습니다. 민감한 users 테이블을 분석 쿼리에서 실수로 조회하는 사고도 발생했습니다.
원인: public 스키마는 기본적으로 모든 사용자에게 접근이 허용됩니다. 역할 분리 없이 테이블을 모두 public에 쌓으면 권한 제어가 불가능해집니다.
해결 방법: 초기 설계 단계에서 app, analytics, audit, internal 등의 스키마로 분리합니다. GRANT USAGE ON SCHEMA analytics TO analyst_role;처럼 스키마 단위로 권한을 부여하면 세밀한 접근 제어가 가능합니다. PostgreSQL 15+에서는 public 스키마의 기본 권한이 강화되었으므로 버전도 확인하세요.
DDL로 테이블 만들기 — CREATE, ALTER, DROP
운영 서버에서 컬럼 하나를 추가하려는데 ALTER TABLE이 수백만 건 테이블에서 수 분간 락을 잡습니다. 더 나쁜 경우, DROP COLUMN을 잘못 치면 롤백이 안 됩니다. DDL은 DML과 달리 대부분 즉시 커밋되고, 실수의 복구 비용이 매우 높습니다. 각 DDL 명령어가 어떻게 동작하는지 정확히 알아야 스키마 변경을 안전하게 할 수 있습니다.

CREATE TABLE — 실전 예시
아래는 이커머스 상품 테이블의 완성도 높은 설계 예시입니다. 기본키에는 자동 증가 정수(SERIAL)를 사용하고, 외래키에는 삭제 정책을 명시합니다. 가격과 재고에는 CHECK 제약으로 음수를 방지하고, status 컬럼은 허용된 값 목록을 CHECK로 제한합니다. 자주 검색되는 category_id와 status 컬럼에는 별도 인덱스를 생성합니다.
CREATE TABLE products (
id SERIAL PRIMARY KEY,
category_id INT NOT NULL
REFERENCES categories(id)
ON DELETE RESTRICT,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
price DECIMAL(12, 2) NOT NULL
CHECK (price >= 0),
stock INT NOT NULL DEFAULT 0
CHECK (stock >= 0),
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'inactive', 'discontinued')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_status ON products(status);
주요 컬럼 제약조건
각 제약조건의 역할을 이해하고 적절히 조합하는 것이 중요합니다. NOT NULL은 필수 입력 필드를 보장하고, UNIQUE는 이메일처럼 중복 불가한 값을 관리합니다. DEFAULT는 생략 가능한 값에 자동으로 초기값을 넣어줍니다. CHECK는 비즈니스 규칙을 DB 레벨에서 강제합니다. NOT NULL + DEFAULT 조합이 가장 흔한 실전 패턴입니다.
email VARCHAR(255) NOT NULL
email VARCHAR(255) UNIQUE
created_at TIMESTAMP DEFAULT NOW()
is_active BOOLEAN DEFAULT TRUE
age INT CHECK (age >= 0 AND age <= 150)
price DECIMAL CHECK (price > 0)
status VARCHAR(20) CHECK (status IN ('active','inactive'))
stock INT NOT NULL DEFAULT 0
ALTER TABLE — 테이블 구조 변경
운영 중인 테이블의 구조를 변경할 때는 작업 유형마다 위험도가 다릅니다. 새 컬럼 추가(ADD COLUMN)는 기존 데이터에 영향이 없어 안전합니다. 반면 컬럼 타입 변경이나 대용량 테이블에 NOT NULL 추가는 전체 테이블 재작성(Table Rewrite)을 유발해 서비스 중단을 일으킬 수 있습니다. NOT NULL 추가는 반드시 2단계(DEFAULT 설정 → NULL 제거)로 나눠 진행합니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE products
ADD COLUMN weight_kg DECIMAL(8, 3);
ALTER TABLE products
ADD COLUMN is_featured BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE products
RENAME COLUMN description TO product_description;
ALTER TABLE products
ALTER COLUMN price TYPE DECIMAL(15, 2);
ALTER TABLE products ALTER COLUMN weight_kg SET DEFAULT 0;
UPDATE products SET weight_kg = 0 WHERE weight_kg IS NULL;
ALTER TABLE products ALTER COLUMN weight_kg SET NOT NULL;
ALTER TABLE products DROP COLUMN weight_kg;
ALTER TABLE products ADD CONSTRAINT chk_price CHECK (price >= 0);
ALTER TABLE products DROP CONSTRAINT chk_price;
DROP 명령어 주의사항
DROP TABLE은 테이블과 모든 데이터를 복구 불가능하게 삭제합니다. CASCADE 옵션은 이 테이블을 FK로 참조하는 다른 테이블의 제약조건까지 함께 삭제하므로 특히 주의가 필요합니다. TRUNCATE는 구조는 유지하면서 데이터만 전체 삭제하며, DELETE보다 훨씬 빠릅니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
DROP TABLE products;
DROP TABLE products CASCADE;
DROP TABLE IF EXISTS products;
TRUNCATE TABLE products;
TRUNCATE TABLE products RESTART IDENTITY;
네이밍 컨벤션
일관된 네이밍 컨벤션은 팀 전체의 코드 가독성과 유지보수성을 높입니다. SQL 예약어(order, user, select)를 테이블명으로 사용하면 쿼리마다 따옴표로 감싸야 하므로 반드시 피합니다.
권장 규칙:
테이블명: 복수형 스네이크케이스 → users, order_items, product_categories
컬럼명: 단수형 스네이크케이스 → user_id, created_at, is_active
PK: id (또는 테이블명_id) → id
FK: 참조테이블_id → user_id, category_id
인덱스: idx_테이블_컬럼 → idx_products_category_id
제약조건: chk_테이블_컬럼 → chk_products_price
피해야 할 것:
✗ 예약어 사용: order, user, select (따옴표로 감싸야 해서 불편)
✗ 대소문자 혼용: UserId, ProductName (DB마다 대소문자 처리 다름)
✗ 너무 짧은 이름: u, p, dt (가독성 저하)
B2B SaaS 서비스를 설계할 때 고객사 데이터 격리 방법으로 세 가지 선택지가 있습니다. 첫째, 테이블에 tenant_id 컬럼을 추가하는 방식(Row-level isolation)은 구현이 단순하지만 쿼리마다 WHERE tenant_id = ? 조건을 빠뜨리면 데이터 유출 사고로 이어집니다. 둘째, schema per tenant 패턴은 고객사마다 독립된 스키마를 만들어 완전히 격리하는 방식입니다. SET search_path = tenant_acme만 설정하면 기존 쿼리 코드를 수정하지 않아도 됩니다. 셋째, database per tenant는 격리 수준이 가장 높지만 DB 연결 수와 운영 비용이 급증합니다.
중소 규모 SaaS에서는 schema per tenant가 보안과 운영 비용의 균형이 좋습니다. PostgreSQL의 search_path 기능과 연결 풀러(PgBouncer)를 함께 사용하면 수백 개 테넌트도 효율적으로 관리할 수 있습니다.
다음 모듈에서는 숫자, 문자, 날짜 등 정밀한 데이터 타입 선택 기준과 잘못된 타입 선택이 가져오는 실무 문제를 다룹니다.