Tools mentioned in this article
Open the browser-based tool while you read and try the workflow immediately.
DDL에서 막히는 건 문법이 아니라 방언 차이
CREATE TABLE 문법 자체는 단순합니다. 그런데도 매번 찾아보게 되는 이유는 같은 의미의 컬럼을 MySQL·PostgreSQL·SQLite에서 다르게 써야 하기 때문입니다. “MySQL에서 잘 돌던 DDL을 PostgreSQL로 옮겼더니 AUTO_INCREMENT에서 구문 오류”, “로컬 SQLite에서는 통과하던 CHECK 제약이 운영 환경에서는 전혀 적용되지 않았다” — DDL 사고는 거의 전부 이 방언 차이로 수렴합니다.
이 글은 3개 데이터베이스를 가로지르는 타입·제약 조건 비교표를 중심으로 한 레퍼런스입니다. 설계 자체(정규화나 인덱스 전략)가 아니라, 이미 정한 설계를 어떻게 써 내려갈지에 초점을 맞췄습니다.
CREATE TABLE의 뼈대
먼저 3개 데이터베이스에서 공통으로 통하는 최소 구성입니다.
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(100),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
컬럼 정의는 컬럼명 → 타입 → 제약 조건 순으로 씁니다. 제약 조건에는 컬럼 제약(위처럼 타입 뒤에 쓰는 방식)과 테이블 제약(모든 컬럼을 정의한 뒤 한꺼번에 쓰는 방식) 두 가지가 있고, 복합 기본 키와 복합 유니크는 테이블 제약으로만 표현할 수 있습니다.
CREATE TABLE order_items (
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (order_id, product_id), -- 복합 기본 키
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products (id)
);
타입 비교표
용도별로 3개 데이터베이스에서 실제로 지정해야 하는 타입입니다.
| 용도 | MySQL | PostgreSQL | SQLite |
|---|---|---|---|
| 정수(일반) | INT | INTEGER | INTEGER |
| 정수(대) | BIGINT | BIGINT | INTEGER |
| 금액·정확한 소수 | DECIMAL(10,2) | NUMERIC(10,2) | NUMERIC |
| 짧은 문자열 | VARCHAR(255) | VARCHAR(255) 또는 TEXT | TEXT |
| 긴 텍스트 | TEXT | TEXT | TEXT |
| 불리언 | BOOLEAN(실체는 TINYINT(1)) | BOOLEAN | INTEGER(0/1) |
| 날짜만 | DATE | DATE | TEXT |
| 일시 | DATETIME | TIMESTAMPTZ | TEXT |
| JSON | JSON | JSONB | TEXT |
| UUID | CHAR(36) 또는 BINARY(16) | UUID | TEXT |
타입 선택에서 기억해 둘 점은 세 가지입니다.
SQLite에는 사실상 타입이 없습니다. SQLite는 값 단위로 타입을 갖는 동적 타이핑이며, 컬럼의 타입 선언은 “타입 친화성(type affinity)“이라는 힌트에 불과합니다. BOOLEAN이나 DATETIME이라고 써도 오류는 나지 않지만 내부적으로는 숫자나 텍스트로 저장됩니다. SQLite 대상 DDL에 별도의 방언 변환이 필요한 주된 이유입니다.
PostgreSQL의 VARCHAR(n)에는 성능상 이점이 없습니다. PostgreSQL에서는 TEXT와 VARCHAR의 구현이 같고, 길이 제한은 CHECK 제약에 가까운 취급입니다. MySQL과 달리 짧다고 빨라지지 않으므로, 명확한 업무상 상한이 없다면 TEXT로 충분합니다.
금액에 FLOAT / DOUBLE을 쓰지 마세요. 부동소수점은 10진 소수를 정확히 표현하지 못해 합계가 어긋납니다. DECIMAL / NUMERIC을 사용하세요.
자동 증가는 세 방언에서 완전히 다른 기능
일상적인 DDL에서 방언 차이가 가장 큰 부분입니다.
| 데이터베이스 | 작성 방법 |
|---|---|
| MySQL | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY |
| PostgreSQL(권장) | id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| PostgreSQL(구식) | id SERIAL PRIMARY KEY |
| SQLite | id INTEGER PRIMARY KEY |
PostgreSQL에서는 SERIAL보다 IDENTITY가 권장됩니다. SERIAL은 “INTEGER 타입 + 뒤에서 시퀀스 생성”이라는 유사 타입이고, PostgreSQL 10부터는 SQL 표준인 GENERATED ... AS IDENTITY를 쓸 수 있습니다. IDENTITY는 시퀀스의 소유 관계가 테이블에 제대로 묶이기 때문에 테이블 삭제 시의 뒤처리나 권한 관리가 덜 까다롭습니다.
SQLite의 AUTOINCREMENT는 대체로 불필요합니다. INTEGER PRIMARY KEY라고 쓴 시점에 그 컬럼은 내부 rowid의 별칭이 되어, 값을 생략하면 자동으로 채번됩니다. AUTOINCREMENT 키워드를 붙이면 “삭제된 ID를 다시 재사용하지 않는다”는 보장이 추가되지만, sqlite_sequence 테이블에 대한 쓰기가 늘어나 느려집니다. 공식 문서에서도 꼭 필요한 경우가 아니면 피하도록 안내합니다. 또한 AUTOINCREMENT는 INTEGER PRIMARY KEY 이외에 붙이면 구문 오류입니다.
제약 조건 비교표와 동작 차이
| 제약 조건 | 의미 | 방언상 주의점 |
|---|---|---|
PRIMARY KEY | 유일하며 NULL 불가 | 암묵적으로 NOT NULL. 테이블당 하나 |
NOT NULL | NULL 금지 | 차이는 거의 없음 |
UNIQUE | 값 중복 금지 | NULL은 중복으로 보지 않음(여러 행이 NULL 가능) |
DEFAULT | 생략 시의 값 | MySQL은 TEXT/JSON의 기본값 식에 괄호가 필요(8.0.13 이후) |
CHECK | 조건을 만족하는 값만 허용 | MySQL 8.0.16 이전에는 구문만 통과하고 무시됨 |
FOREIGN KEY | 참조 무결성 | SQLite는 기본적으로 비활성. InnoDB 이외의 MySQL 엔진에서는 무시 |
특히 사고로 이어지기 쉬운 것은 아래 두 가지입니다.
MySQL의 CHECK 제약: MySQL 8.0.16 이전 버전은 CHECK 절을 구문으로는 받아들이면서 제약으로는 전혀 적용하지 않았습니다. “DDL에 써 있으니 동작하고 있을 것”이라고 믿는 사이 잘못된 값이 계속 들어가는, 발견이 늦어지는 유형의 결함입니다. 구버전 환경에 올라갈 가능성이 있다면 애플리케이션 쪽 검증을 정본으로 삼으세요.
SQLite의 외래 키: SQLite는 하위 호환성 때문에 외래 키 제약의 적용이 기본적으로 꺼져 있습니다. 게다가 설정이 연결 단위라서 연결할 때마다 다음을 실행해야 합니다.
PRAGMA foreign_keys = ON;
이를 잊으면 REFERENCES를 써 두었는데도 존재하지 않는 부모 ID를 가진 행이 그대로 들어갑니다. 로컬은 SQLite, 운영은 PostgreSQL인 구성이라면 개발 환경에서만 무결성이 깨지는 형태로 나타납니다.
외래 키의 ON DELETE / ON UPDATE
참조 대상 행이 삭제·갱신될 때의 동작은 네 가지입니다.
| 옵션 | 동작 |
|---|---|
RESTRICT / NO ACTION | 자식 행이 남아 있으면 부모의 삭제·갱신을 거부(기본값) |
CASCADE | 부모에 맞춰 자식 행도 삭제·갱신 |
SET NULL | 자식의 외래 키 컬럼을 NULL로(컬럼이 NULL 허용이어야 함) |
SET DEFAULT | 자식을 기본값으로(MySQL/InnoDB는 미지원) |
-- 사용자 삭제 시 게시글은 함께 삭제, 댓글은 남기고 작성자만 비움
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY,
user_id INTEGER REFERENCES users (id) ON DELETE SET NULL -- NOT NULL로 두면 안 됨
);
CASCADE는 편리하지만 삭제 범위가 DDL을 읽지 않으면 보이지 않는다는 부작용이 있습니다. 감사 로그나 매출 명세처럼 “부모가 사라져도 남겨야 하는” 테이블에는 쓰지 마세요.
일시 컬럼 고르는 법
| 하고 싶은 일 | MySQL | PostgreSQL |
|---|---|---|
| 생성 일시를 자동으로 넣기 | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | TIMESTAMPTZ DEFAULT NOW() |
| 갱신 일시를 자동으로 갱신하기 | TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 트리거가 필요(컬럼 수준 기능은 없음) |
ON UPDATE CURRENT_TIMESTAMP는 MySQL 고유 기능입니다. PostgreSQL로 옮길 때는 BEFORE UPDATE 트리거로 대체해야 하므로, DDL을 그대로 복사하면 오류 하나 없이 updated_at이 삽입 시점 값에 멈춰 버리는 마이그레이션 사고가 생깁니다.
MySQL에서 일시 타입을 고를 때는 TIMESTAMP가 1970~2038년 범위만 담을 수 있고 세션 타임존으로 변환된다는 점, DATETIME은 범위가 넓고 타임존 변환이 없다는 점을 고려하세요. PostgreSQL에서는 타임존을 갖지 않는 TIMESTAMP보다 TIMESTAMPTZ가 기본적으로 무난합니다.
문자 인코딩은 utf8이 아니라 utf8mb4
MySQL의 utf8은 역사적 경위로 문자당 최대 3바이트만 다루는 별개의 인코딩이라, 이모지나 일부 한자를 넣으면 Incorrect string value 오류가 납니다. 명시할 거라면 utf8mb4를 지정하세요.
CREATE TABLE posts (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
body TEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
PostgreSQL과 SQLite는 데이터베이스/파일 단위로 UTF-8을 다루므로 테이블 정의에서 신경 쓸 필요가 없습니다.
설계를 그대로 DDL과 ER 다이어그램으로
타입과 제약을 정한 뒤 3개 방언을 손으로 나눠 쓰는 것은 단순 작업입니다. DDL 빌더 는 테이블과 컬럼을 화면에서 조립하기만 하면 MySQL / PostgreSQL / SQLite 각각의 서식에 맞는 CREATE TABLE 문과 Mermaid 형식 ER 다이어그램을 동시에 생성합니다. API 응답이나 로그 같은 JSON 샘플을 붙여 넣어 컬럼 구성을 자동 추론하게 할 수도 있어, 기존 데이터에서 역산할 때도 쓸 수 있습니다. 방언 차이는 도구가 흡수하므로 이 비교표를 매번 다시 찾을 필요가 없어집니다.
이미 DDL이 있다면 SQL to ER 다이어그램 에 붙여 넣어 테이블 간 참조 관계를 그림으로 확인할 수 있습니다. 테이블이 갖춰지면 Visual SQL Builder 로 SELECT 문을 조립하면 됩니다. JOIN 종류별 결과 차이는 SQL JOIN 완전 레퍼런스, ER 다이어그램 표기법 자체는 Mermaid ER 다이어그램 레퍼런스 에 정리해 두었습니다. 모든 도구는 브라우저 안에서 완결되며, 입력한 스키마 정보가 외부로 전송되지 않습니다.
정리
- 타입은 “SQLite는 타입 친화성일 뿐”, “PostgreSQL의
VARCHAR(n)에 속도 이점은 없다”, “금액은DECIMAL” 세 가지를 기억 - 자동 증가는
AUTO_INCREMENT/GENERATED ALWAYS AS IDENTITY/INTEGER PRIMARY KEY로 완전히 다른 기능. PostgreSQL 신규 설계에서는SERIAL보다 IDENTITY - MySQL 8.0.16 이전의
CHECK는 무시되고, SQLite 외래 키는 연결마다PRAGMA foreign_keys = ON이 필요 ON DELETE SET NULL을 쓰는 컬럼은NOT NULL로 둘 수 없음ON UPDATE CURRENT_TIMESTAMP는 MySQL 전용. PostgreSQL은 트리거로 대체- MySQL의 문자 인코딩은
utf8이 아니라utf8mb4
자주 묻는 질문
VARCHAR와 TEXT 중 어느 쪽을 써야 하나요?
데이터베이스에 따라 다릅니다. PostgreSQL에서는 둘의 구현이 거의 같으므로, 업무상 명확한 글자 수 상한이 없다면 TEXT로 문제없습니다. MySQL에서는 VARCHAR가 행 안에 저장되는 반면 TEXT는 별도 영역에 저장될 수 있어, 자주 검색·정렬하는 짧은 문자열은 VARCHAR(n)이 유리합니다. SQLite는 어느 쪽으로 선언해도 내부 처리가 같습니다.
SQLite의 AUTOINCREMENT는 붙이는 게 좋나요?
보통은 불필요합니다. INTEGER PRIMARY KEY라고 쓰면 값을 생략했을 때 자동으로 채번됩니다. AUTOINCREMENT를 붙이면 “삭제된 ID를 재사용하지 않는” 보장이 추가되는 대신 관리용 테이블에 대한 쓰기가 늘어 느려집니다. 외부에 공개하는 ID처럼 이미 발급된 값의 재사용을 반드시 피해야 하는 요건이 있을 때만 검토하세요.
CREATE TABLE에 쓴 CHECK 제약이 동작하지 않습니다
MySQL을 쓰고 있다면 버전이 8.0.16 이전인지 확인하세요. 구버전은 CHECK 절을 구문으로만 받아들이고 적용하지 않습니다. SELECT VERSION();으로 버전을 확인할 수 있습니다. SQLite에서 CHECK는 유효하지만 외래 키 제약이 기본적으로 꺼져 있으므로, 연결마다 PRAGMA foreign_keys = ON;을 실행해야 합니다.
만든 DDL을 붙여 넣어 확인하고 싶은데 데이터가 전송되나요?
아니요. DDL 빌더 와 SQL to ER 다이어그램 은 모두 브라우저 안에서 완결되며, 입력한 테이블 정의나 스키마 정보가 서버로 전송되는 일은 없습니다.