본문으로 건너뛰기
Previous
Next
PostgreSQL vs MySQL 차이와 선택 가이드 | 스키마·트랜잭션·운영

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

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

이 글의 핵심

PostgreSQL vs MySQL 차이 선택: 스키마·ACID·쿼리·복제·운영 관점에서 비교하고 Node.js·팀 상황에 맞는 선택 기준을 실무 중심으로 정리합니다.

들어가며

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

개념: 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 위주면 둘 다 충분한 경우가 많습니다.


트랜잭션·동시성

일상 비유로 이해하기: 동시성은 주방에서 여러 요리를 동시에 하는 것과 비슷합니다. 한 명의 요리사(싱글 스레드)가 국을 끓이다가 불을 줄이고, 그 사이에 야채를 썰고, 다시 국을 확인하는 식이죠. 반면 병렬성은 요리사 여러 명(멀티 스레드)이 각자 다른 요리를 동시에 만드는 겁니다. {#transactions}

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

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「PostgreSQL vs MySQL 차이와 선택 가이드 | 스키마·트랜잭션·운영」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「PostgreSQL vs MySQL 차이와 선택 가이드 | 스키마·트랜잭션·운영」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. PostgreSQL vs MySQL 차이 선택: 스키마·ACID·쿼리·복제·운영 관점에서 비교하고 Node.js·팀 상황에 맞는 선택 기준을 실무 중심으로 정리합니다. 데이터베이스·PostgreSQL·MySQL 중심… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

데이터베이스, PostgreSQL, MySQL, 비교, SQL, 쿼리 최적화, 선택 가이드 등으로 검색하시면 이 글이 도움이 됩니다.