PostgreSQL vs MySQL 차이와 선택 가이드 | 스키마·트랜잭션·운영

PostgreSQL vs MySQL 차이와 선택 가이드 | 스키마·트랜잭션·운영

이 글의 핵심

스키마 유연성·트랜잭션·쿼리 기능·운영·생태계를 기준으로 PostgreSQL과 MySQL을 비교하고 선택 기준을 제시합니다.

들어가며

PostgreSQLMySQL(및 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·드라이버 선택 힌트를 얻습니다

목차

  1. 개념: RDB 공통점과 비교 축
  2. 스키마·데이터 타입
  3. 트랜잭션·동시성
  4. 쿼리·최적화·인덱싱
  5. 운영·복제·생태계
  6. 성능 비교: 벤치마크와 트레이드오프
  7. 실무 사례
  8. 트러블슈팅
  9. 마무리

개념: 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으로 팬텀 읽기 방지

격리 수준 비교

격리 수준PostgreSQLMySQL (InnoDB)
READ UNCOMMITTED지원 (비권장)지원
READ COMMITTED기본값지원
REPEATABLE READ지원기본값
SERIALIZABLESSI 알고리즘락 기반

락 메커니즘

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 캐싱 패턴에서 이어서 설계할 수 있습니다.