PostgreSQL vs MySQL 차이와 선택 가이드 | 스키마·트랜잭션·운영
이 글의 핵심
스키마 유연성·트랜잭션·쿼리 기능·운영·생태계를 기준으로 PostgreSQL과 MySQL을 비교하고 선택 기준을 제시합니다.
들어가며
PostgreSQL과 MySQL(및 MariaDB)은 관계형 DB 시장에서 가장 자주 마주치는 선택지입니다. 둘 다 ACID 트랜잭션, 복제, 풍부한 클라이언트 라이브러리를 갖추지만, 스키마 표현력, 쿼리 최적화기, 운영 도구, 호스팅 생태에서 차이가 납니다. “어느 쪽이 더 빠르다” 한 줄로 끝나지 않고, 워크로드·팀 역량·레거시에 맞춰 고르는 것이 안전합니다.
이 글은 벤더 홍보가 아니라 실무에서 의사결정에 쓰는 비교 축—스키마, 트랜잭션, SQL 기능, 운영—을 정리합니다.
비유로 말씀드리면, PostgreSQL은 규칙과 도구를 많이 갖춘 설계 사무실, MySQL은 가볍게 짓고 널리 호스팅되는 현장에 가깝습니다. 둘 다 집을 짓지만(데이터를 저장하지만), 복잡한 제약·분석 쿼리를 DB에 두고 싶은지, 운영·호스팅 친숙도를 우선할지가 갈림길이 됩니다.
언제 PostgreSQL을, 언제 MySQL(MariaDB)을 쓰나요?
| 관점 | PostgreSQL을 검토하시면 좋은 경우 | MySQL/MariaDB를 검토하시면 좋은 경우 |
|---|---|---|
| 성능·기능 | 복잡한 SQL, jsonb, 배열·범위 타입, 분석·윈도 함수에 강하게 의존할 때 | 단순 CRUD, 익숙한 레이어·호스팅 위주일 때 |
| 사용성 | 스키마·제약·DDL을 트랜잭션 안에서 다루는 패턴이 중요할 때 | 기존 팀·레거시·매뉴얼이 MySQL 중심일 때 |
| 적용 시나리오 | 데이터 무결성·표현력을 DB에 두는 도메인 | 웹 호스팅·레플리카 생태·주변 도구와의 궁합 |
이 글을 읽으면
- 스키마·제약·인덱스 관점에서 PostgreSQL vs MySQL 차이를 짚을 수 있습니다
- 트랜잭션 격리·락·복제를 운영 관점에서 비교합니다
- Node.js 스택과 함께 쓸 때의 ORM·드라이버 선택 힌트를 얻습니다
목차
개념: RDB 공통점과 비교 축
기본 개념
둘 다 SQL을 사용하는 관계형 데이터베이스이며, 테이블·행·열, 기본키·외래키, 조인으로 데이터를 모델링합니다. Node.js에서는 pg, mysql2, Prisma, Sequelize, Knex 등으로 접근합니다.
왜 “하나의 정답”이 없는가
성능은 스키마 설계, 인덱스, 쿼리 패턴, 하드웨어, 설정에 좌우됩니다. 따라서 기능 적합성(필요한 SQL·타입·무결성)과 운영 비용(모니터링, 백업, 인력)이 더 중요한 결정 요인인 경우가 많습니다.
스키마·데이터 타입
스키마 구조 차이
PostgreSQL: 계층적 네임스페이스
-- 하나의 데이터베이스에 여러 스키마
CREATE DATABASE myapp;
\c myapp
CREATE SCHEMA sales;
CREATE SCHEMA inventory;
CREATE TABLE sales.orders (id SERIAL PRIMARY KEY, ...);
CREATE TABLE inventory.products (id SERIAL PRIMARY KEY, ...);
-- 스키마 경로 설정
SET search_path TO sales, public;
MySQL: 데이터베이스 = 스키마
-- 각 스키마가 별도 데이터베이스
CREATE DATABASE sales;
CREATE DATABASE inventory;
USE sales;
CREATE TABLE orders (id INT AUTO_INCREMENT PRIMARY KEY, ...);
USE inventory;
CREATE TABLE products (id INT AUTO_INCREMENT PRIMARY KEY, ...);
고급 데이터 타입
PostgreSQL의 풍부한 타입:
-- 배열 타입
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title TEXT,
tags TEXT[] -- 문자열 배열
);
INSERT INTO posts (title, tags)
VALUES ('C++ 가이드', ARRAY['cpp', 'programming', 'tutorial']);
SELECT * FROM posts WHERE 'cpp' = ANY(tags);
-- 범위 타입
CREATE TABLE reservations (
id SERIAL PRIMARY KEY,
room_id INT,
period TSRANGE -- 시간 범위
);
INSERT INTO reservations (room_id, period)
VALUES (101, '[2026-03-31 14:00, 2026-03-31 16:00)');
-- 겹치는 예약 검색
SELECT * FROM reservations
WHERE period && '[2026-03-31 15:00, 2026-03-31 17:00)';
-- UUID 타입
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT
);
-- JSONB (바이너리 JSON, 인덱싱 가능)
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT,
specs JSONB
);
INSERT INTO products (name, specs)
VALUES ('노트북', '{"cpu": "i7", "ram": "16GB", "storage": "512GB SSD"}');
-- JSONB 쿼리
SELECT * FROM products WHERE specs->>'cpu' = 'i7';
SELECT * FROM products WHERE specs @> '{"ram": "16GB"}';
-- JSONB 인덱스
CREATE INDEX idx_specs_gin ON products USING GIN (specs);
MySQL의 JSON 지원:
-- JSON 타입 (MySQL 5.7+)
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
specs JSON
);
INSERT INTO products (name, specs)
VALUES ('노트북', '{"cpu": "i7", "ram": "16GB", "storage": "512GB SSD"}');
-- JSON 쿼리
SELECT * FROM products WHERE JSON_EXTRACT(specs, '$.cpu') = 'i7';
SELECT * FROM products WHERE specs->>'$.cpu' = 'i7'; -- MySQL 8.0+
-- JSON 가상 컬럼 + 인덱스
ALTER TABLE products
ADD COLUMN cpu VARCHAR(50) AS (specs->>'$.cpu') VIRTUAL;
CREATE INDEX idx_cpu ON products(cpu);
CHECK 제약 및 도메인 무결성
PostgreSQL:
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
age INT CHECK (age >= 18 AND age <= 100),
email TEXT CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$'),
salary NUMERIC(10, 2) CHECK (salary > 0),
department TEXT CHECK (department IN ('engineering', 'sales', 'hr'))
);
-- 도메인 정의 (재사용 가능한 타입)
CREATE DOMAIN email_type AS TEXT
CHECK (VALUE ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$');
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email email_type
);
MySQL:
-- CHECK 제약 (MySQL 8.0.16+)
CREATE TABLE employees (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
age INT CHECK (age >= 18 AND age <= 100),
email VARCHAR(255) CHECK (email REGEXP '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}$'),
salary DECIMAL(10, 2) CHECK (salary > 0),
department ENUM('engineering', 'sales', 'hr') -- ENUM 타입 활용
);
부분 인덱스
PostgreSQL:
-- 조건부 인덱스 (활성 사용자만)
CREATE INDEX idx_active_users ON users(email) WHERE active = true;
-- 특정 범위만 인덱싱
CREATE INDEX idx_recent_orders ON orders(created_at)
WHERE created_at > '2026-01-01';
-- 인덱스 크기 절감 + 쿼리 속도 향상
MySQL:
-- 부분 인덱스 미지원 (전체 인덱스만 가능)
CREATE INDEX idx_users_email ON users(email);
-- 대안: 필터링된 뷰 + 인덱스
CREATE VIEW active_users AS
SELECT * FROM users WHERE active = true;
실무 해석: 도메인이 복잡한 제약·타입을 DB에 두고 싶다면 PostgreSQL이 유리한 경우가 많습니다. 단순한 키·값 CRUD 위주면 둘 다 충분한 경우가 많습니다.
트랜잭션·동시성
MVCC (Multi-Version Concurrency Control)
둘 다 MVCC를 사용하지만 구현 방식이 다릅니다.
PostgreSQL:
-- 트랜잭션 격리 수준 설정
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 동시성 충돌 시 자동 재시도 필요
-- Serialization failure 에러 발생 가능
특징:
- 읽기는 락을 걸지 않음 (MVCC)
- VACUUM으로 오래된 버전 정리 필요
- Serializable 격리 수준이 정확히 동작
MySQL (InnoDB):
-- 기본 격리 수준: REPEATABLE READ
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
특징:
- 읽기도 MVCC 사용
- Undo 로그로 이전 버전 관리
- Gap Lock으로 팬텀 읽기 방지
격리 수준 비교
| 격리 수준 | PostgreSQL | MySQL (InnoDB) |
|---|---|---|
| READ UNCOMMITTED | 지원 (비권장) | 지원 |
| READ COMMITTED | 기본값 | 지원 |
| REPEATABLE READ | 지원 | 기본값 |
| SERIALIZABLE | SSI 알고리즘 | 락 기반 |
락 메커니즘
PostgreSQL: 명시적 락
-- 행 락
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 공유 락 (읽기 락)
SELECT * FROM orders WHERE id = 1 FOR SHARE;
-- 테이블 락
LOCK TABLE orders IN EXCLUSIVE MODE;
-- Advisory Lock (애플리케이션 레벨 락)
SELECT pg_advisory_lock(12345);
-- 작업 수행
SELECT pg_advisory_unlock(12345);
MySQL: 행 락 및 갭 락
-- 행 락
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 공유 락
SELECT * FROM orders WHERE id = 1 LOCK IN SHARE MODE;
-- Gap Lock (범위 락, REPEATABLE READ에서 자동)
SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- id 10-20 사이의 "존재하지 않는 행"도 락
-- Next-Key Lock = Record Lock + Gap Lock
DDL 트랜잭션
PostgreSQL: DDL도 트랜잭션 가능
BEGIN;
CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);
ALTER TABLE users ADD COLUMN email TEXT;
CREATE INDEX idx_email ON users(email);
-- 에러 발생 시 모든 DDL 롤백
ROLLBACK;
-- 성공 시 커밋
COMMIT;
MySQL: DDL은 암묵적 커밋
START TRANSACTION;
INSERT INTO logs (message) VALUES ('start');
CREATE TABLE temp (id INT); -- 여기서 자동 COMMIT 발생!
INSERT INTO logs (message) VALUES ('end'); -- 새 트랜잭션
ROLLBACK; -- 'end'만 롤백, 'start'는 이미 커밋됨
실무 영향:
- PostgreSQL: 마이그레이션 스크립트를 트랜잭션으로 감싸서 안전하게 실행
- MySQL: DDL 실패 시 수동 롤백 필요, 신중한 스크립트 작성 필요
동시성 제어 예제
PostgreSQL: Optimistic Locking
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT,
stock INT,
version INT DEFAULT 0 -- 버전 컬럼
);
-- 재고 감소 (낙관적 락)
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5; -- 현재 버전 확인
-- 영향받은 행이 0이면 충돌 발생 → 재시도
MySQL: Pessimistic Locking
-- 재고 감소 (비관적 락)
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 다른 트랜잭션은 대기
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
실무 해석: 긴 트랜잭션·복잡한 리포트 쿼리가 많다면 데드락·대기 프로파일링이 필수이며, 엔진별로 기본 격리 수준과 인덱스 설계가 다르게 먹힙니다.
쿼리·최적화·인덱싱
CTE (Common Table Expression)
PostgreSQL: 재귀 CTE
-- 조직도 계층 조회
WITH RECURSIVE org_tree AS (
-- 기본 케이스: 최상위 관리자
SELECT id, name, manager_id, 1 as level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- 재귀: 하위 직원
SELECT e.id, e.name, e.manager_id, ot.level + 1
FROM employees e
INNER JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT * FROM org_tree ORDER BY level, name;
-- 재귀 CTE로 그래프 탐색
WITH RECURSIVE path AS (
SELECT id, name, ARRAY[id] as path
FROM nodes
WHERE id = 1 -- 시작 노드
UNION ALL
SELECT n.id, n.name, p.path || n.id
FROM nodes n
INNER JOIN edges e ON n.id = e.to_id
INNER JOIN path p ON e.from_id = p.id
WHERE NOT (n.id = ANY(p.path)) -- 순환 방지
)
SELECT * FROM path;
MySQL: 재귀 CTE (8.0+)
-- 동일한 재귀 CTE 지원
WITH RECURSIVE org_tree AS (
SELECT id, name, manager_id, 1 as level
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, ot.level + 1
FROM employees e
INNER JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT * FROM org_tree ORDER BY level, name;
윈도 함수
PostgreSQL: 고급 윈도 함수
-- 순위 함수
SELECT
name,
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) as rank,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) as dense_rank,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) as row_num
FROM employees;
-- 이동 평균
SELECT
date,
revenue,
AVG(revenue) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as moving_avg_7days
FROM daily_sales;
-- FILTER 절 (조건부 집계)
SELECT
department,
COUNT(*) as total,
COUNT(*) FILTER (WHERE salary > 50000) as high_earners
FROM employees
GROUP BY department;
MySQL: 윈도 함수 (8.0+)
-- 기본 윈도 함수 지원
SELECT
name,
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) as rank
FROM employees;
-- 이동 평균
SELECT
date,
revenue,
AVG(revenue) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as moving_avg_7days
FROM daily_sales;
풀 텍스트 검색
PostgreSQL: 내장 FTS
-- tsvector 타입으로 검색 인덱스
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT,
content TEXT,
search_vector TSVECTOR
);
-- 검색 벡터 생성
UPDATE articles
SET search_vector =
setweight(to_tsvector('korean', title), 'A') ||
setweight(to_tsvector('korean', content), 'B');
-- GIN 인덱스
CREATE INDEX idx_search ON articles USING GIN(search_vector);
-- 검색 (한국어 지원)
SELECT title, ts_rank(search_vector, query) as rank
FROM articles, to_tsquery('korean', 'C++ & 메모리') as query
WHERE search_vector @@ query
ORDER BY rank DESC;
-- 하이라이트
SELECT ts_headline('korean', content, to_tsquery('korean', 'C++'))
FROM articles
WHERE id = 1;
MySQL: FULLTEXT 인덱스
-- FULLTEXT 인덱스 생성
CREATE TABLE articles (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255),
content TEXT,
FULLTEXT KEY idx_fulltext (title, content)
) ENGINE=InnoDB;
-- 자연어 검색
SELECT title, MATCH(title, content) AGAINST('C++ 메모리') as score
FROM articles
WHERE MATCH(title, content) AGAINST('C++ 메모리' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC;
-- 불린 모드 (AND, OR, NOT)
SELECT title
FROM articles
WHERE MATCH(title, content) AGAINST('+C++ +메모리 -포인터' IN BOOLEAN MODE);
쿼리 최적화 및 EXPLAIN
PostgreSQL:
-- 상세한 실행 계획
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > '2026-01-01';
-- 출력 예시:
-- Buffers: shared hit=1234 read=56
-- Planning Time: 0.123 ms
-- Execution Time: 45.678 ms
MySQL:
-- 실행 계획
EXPLAIN FORMAT=JSON
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > '2026-01-01';
-- 실제 실행 분석
EXPLAIN ANALYZE
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > '2026-01-01';
인덱스 전략
PostgreSQL: 다양한 인덱스 타입
-- B-tree (기본)
CREATE INDEX idx_email ON users(email);
-- Hash 인덱스 (등호 비교만)
CREATE INDEX idx_hash_email ON users USING HASH(email);
-- GIN (배열, JSONB, 풀텍스트)
CREATE INDEX idx_tags ON posts USING GIN(tags);
-- GiST (지리 데이터, 범위)
CREATE INDEX idx_location ON stores USING GIST(location);
-- BRIN (대용량 시계열 데이터)
CREATE INDEX idx_created ON logs USING BRIN(created_at);
-- 복합 인덱스
CREATE INDEX idx_user_date ON orders(user_id, created_at DESC);
-- 표현식 인덱스
CREATE INDEX idx_lower_email ON users(LOWER(email));
MySQL: 주로 B-tree
-- B-tree 인덱스 (기본)
CREATE INDEX idx_email ON users(email);
-- 복합 인덱스
CREATE INDEX idx_user_date ON orders(user_id, created_at DESC);
-- 함수 기반 인덱스 (MySQL 8.0+)
CREATE INDEX idx_lower_email ON users((LOWER(email)));
-- FULLTEXT 인덱스
CREATE FULLTEXT INDEX idx_content ON articles(content);
-- 공간 인덱스
CREATE SPATIAL INDEX idx_location ON stores(location);
쿼리 힌트
PostgreSQL: 설정 기반
-- 특정 쿼리에만 설정 변경
SET LOCAL enable_seqscan = off; -- 순차 스캔 비활성화
SELECT * FROM large_table WHERE id > 1000;
-- 병렬 쿼리 설정
SET max_parallel_workers_per_gather = 4;
MySQL: 힌트 구문
-- 인덱스 힌트
SELECT * FROM orders USE INDEX (idx_created)
WHERE created_at > '2026-01-01';
-- 조인 순서 힌트
SELECT /*+ JOIN_ORDER(o, c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;
-- 인덱스 강제
SELECT * FROM orders FORCE INDEX (idx_user_date)
WHERE user_id = 123;
실무 해석: PostgreSQL은 오래부터 분석 쿼리에 강점이 있다는 평가가 많습니다. 복잡한 CTE, 윈도 함수, 다양한 인덱스 타입이 필요하면 PostgreSQL이 유리합니다.
운영·복제·생태계
복제 (Replication)
PostgreSQL: 스트리밍 복제
-- 마스터 설정 (postgresql.conf)
wal_level = replica
max_wal_senders = 3
wal_keep_size = 1GB
-- 슬레이브 설정
primary_conninfo = 'host=master port=5432 user=replicator password=...'
특징:
- 물리적 복제 (바이트 단위)
- 논리적 복제 (테이블 단위, PostgreSQL 10+)
- 동기/비동기 복제 선택 가능
논리적 복제 예제:
-- 마스터: 발행
CREATE PUBLICATION my_pub FOR TABLE users, orders;
-- 슬레이브: 구독
CREATE SUBSCRIPTION my_sub
CONNECTION 'host=master dbname=mydb user=replicator'
PUBLICATION my_pub;
MySQL: 바이너리 로그 복제
-- 마스터 설정 (my.cnf)
[mysqld]
server-id = 1
log-bin = mysql-bin
binlog-format = ROW
-- 슬레이브 설정
[mysqld]
server-id = 2
relay-log = relay-bin
-- 슬레이브 시작
CHANGE MASTER TO
MASTER_HOST='master',
MASTER_USER='replicator',
MASTER_PASSWORD='...',
MASTER_LOG_FILE='mysql-bin.000001',
MASTER_LOG_POS=154;
START SLAVE;
특징:
- Statement-based, Row-based, Mixed 복제
- GTID (Global Transaction ID) 지원
- 멀티 소스 복제 가능
백업 및 복구
PostgreSQL:
# 논리적 백업
pg_dump mydb > backup.sql
pg_dump -Fc mydb > backup.dump # 커스텀 포맷 (압축)
# 복구
psql mydb < backup.sql
pg_restore -d mydb backup.dump
# 물리적 백업 (PITR - Point-In-Time Recovery)
pg_basebackup -D /backup/base -Fp -Xs -P
# WAL 아카이빙으로 특정 시점 복구 가능
MySQL:
# 논리적 백업
mysqldump mydb > backup.sql
mysqldump --single-transaction mydb > backup.sql # 일관성 보장
# 복구
mysql mydb < backup.sql
# 물리적 백업 (Percona XtraBackup)
xtrabackup --backup --target-dir=/backup/
xtrabackup --prepare --target-dir=/backup/
xtrabackup --copy-back --target-dir=/backup/
모니터링
PostgreSQL:
-- 활성 쿼리 확인
SELECT pid, usename, state, query, now() - query_start as duration
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;
-- 느린 쿼리 찾기
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- 테이블 통계
SELECT schemaname, tablename, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
-- 인덱스 사용률
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
WHERE idx_scan = 0 -- 사용되지 않는 인덱스
ORDER BY pg_relation_size(indexrelid) DESC;
-- 캐시 히트율
SELECT
sum(heap_blks_read) as heap_read,
sum(heap_blks_hit) as heap_hit,
sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) as ratio
FROM pg_statio_user_tables;
MySQL:
-- 활성 쿼리 확인
SELECT id, user, host, db, command, time, state, info
FROM information_schema.processlist
WHERE command != 'Sleep'
ORDER BY time DESC;
-- 느린 쿼리 (Performance Schema)
SELECT
DIGEST_TEXT,
COUNT_STAR as exec_count,
AVG_TIMER_WAIT/1000000000000 as avg_sec,
SUM_TIMER_WAIT/1000000000000 as total_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
-- 테이블 통계
SELECT
table_schema,
table_name,
table_rows,
data_length,
index_length
FROM information_schema.tables
WHERE table_schema NOT IN ('mysql', 'information_schema', 'performance_schema')
ORDER BY data_length DESC;
-- 인덱스 사용률
SELECT
object_schema,
object_name,
index_name,
count_star,
count_read
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
ORDER BY count_star DESC;
연결 풀링
PostgreSQL: pgBouncer
# pgbouncer.ini
[databases]
mydb = host=localhost port=5432 dbname=mydb
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
pool_mode = transaction # session, transaction, statement
max_client_conn = 1000
default_pool_size = 25
Node.js 연동:
const { Pool } = require('pg');
const pool = new Pool({
host: 'localhost',
port: 6432, // pgBouncer 포트
database: 'mydb',
user: 'myuser',
password: 'mypass',
max: 20, // 애플리케이션 레벨 풀
idleTimeoutMillis: 30000
});
const result = await pool.query('SELECT * FROM users WHERE id = $1', [123]);
MySQL: ProxySQL
# proxysql.cnf
mysql_servers =
(
{ address="127.0.0.1", port=3306, hostgroup=0 }
)
mysql_users =
(
{ username="myuser", password="mypass", default_hostgroup=0 }
)
Node.js 연동:
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: 'localhost',
port: 6033, // ProxySQL 포트
database: 'mydb',
user: 'myuser',
password: 'mypass',
waitForConnections: true,
connectionLimit: 20,
queueLimit: 0
});
const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [123]);
확장 및 플러그인
PostgreSQL: 풍부한 확장
-- PostGIS (지리 데이터)
CREATE EXTENSION postgis;
CREATE TABLE stores (
id SERIAL PRIMARY KEY,
name TEXT,
location GEOGRAPHY(POINT, 4326)
);
-- 반경 검색
SELECT name FROM stores
WHERE ST_DWithin(
location,
ST_MakePoint(127.0, 37.5)::geography,
1000 -- 1km
);
-- pg_trgm (유사 문자열 검색)
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_name_trgm ON users USING GIN(name gin_trgm_ops);
SELECT * FROM users WHERE name % 'Alice'; -- 유사도 검색
-- uuid-ossp (UUID 생성)
CREATE EXTENSION "uuid-ossp";
SELECT uuid_generate_v4();
MySQL: 플러그인
-- 공간 데이터 (내장)
CREATE TABLE stores (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
location POINT NOT NULL SRID 4326
);
CREATE SPATIAL INDEX idx_location ON stores(location);
-- 반경 검색
SELECT name FROM stores
WHERE ST_Distance_Sphere(
location,
ST_GeomFromText('POINT(127.0 37.5)', 4326)
) < 1000;
실무 해석: 팀에 MySQL 운영 경험이 압도적으로 많다면 비용이 낮습니다. PostGIS 등 지리 확장이 필요하면 PostgreSQL이 자연스럽습니다.
성능 비교: 벤치마크와 트레이드오프
공개 TPC-C, sysbench 결과는 하드웨어·버전·튜닝에 따라 매번 달라집니다. 아래는 방향성만 잡는 체크리스트입니다.
| 시나리오 | 메모 |
|---|---|
| 단순 PK 조회·소량 쓰기 | 둘 다 매우 빠름—인덱스가 핵심 |
| 복잡한 조인·집계 | 스키마·통계·병렬 쿼리 설정에 따라 역전 |
| 고동시 쓰기 | 연결 풀·파티셔닝·샤딩 설계가 DB 종류보다 큰 경우 많음 |
트레이드오프: “조금 더 빠른 벤치”보다 백업·복구 시간, 장애 시 RPO/RTO, 마이그레이션 난이도를 함께 적으면 결정이 안정됩니다.
실무 사례
- 금융·재고 등 강한 무결성: CHECK·외래키·트랜잭션 경계를 DB에 두는 편—PostgreSQL 선호 사례가 많음.
- 기존 CMS·쇼핑몰 스택: MySQL이 기본인 경우 레거시 호환 우선.
- JSON 반정규화 + 인덱스: PostgreSQL
jsonb로 시작 후 검색 요구가 커지면 전문 검색 엔진을 병행.
Node 연동은 Node.js 데이터베이스 연동과 Docker Compose 스택을 함께 보세요. 캐시·엣지·클러스터는 Redis 캐싱, Nginx, Kubernetes(minikube) 순으로, 운영 중 디스크 이슈는 Linux 디스크/inode와 연결됩니다.
트러블슈팅
| 증상 | 점검 |
|---|---|
| 이관 후 쿼리만 느림 | 실행 계획·통계 갱신·인덱스 타입 차이 |
| 문자열 정렬 불일치 | 콜레이션·대소문자 규칙 |
| 날짜·타임존 버그 | TIMESTAMP vs TIMESTAMPTZ(PostgreSQL), MySQL의 타임존 처리 |
| ORM 마이그레이션 실패 | 벤더별 DDL 차이—수동 SQL 분기 |
마무리
- PostgreSQL은 타입·SQL 표현력·확장(예: PostGIS)에서 강점이 자주 언급되고, MySQL은 레거시·호스팅·단순 워크로드와의 궁합이 좋습니다.
- 실제 선택은 팀 운영 역량, 워크로드, 버전으로 확정하세요.
- 캐시 계층은 Redis 캐싱 패턴에서 이어서 설계할 수 있습니다.