
신규 프로젝트 DB 선정 회의에서 PostgreSQL을 채택했는데, 회의가 끝나고 누군가 물어봤습니다.
"MySQL 쓰면 되지 않아요?"
그 순간 저는 말문이 막혔습니다. 신규 프로젝트에는 당연히 전부 PostgreSQL을 썼거든요. 이 질문을 통해, 기술 선택에 참여한 것과 기술을 이해한 건 다른 얘기란 걸 알게 됐습니다.
그래서 처음부터 파봤습니다. 이 글은 그 과정의 기록입니다.
DB에서 가장 풀기 어려운 문제 중 하나가 동시성입니다. 여러 사용자가 같은 데이터를 동시에 읽고 쓰면 어떻게 될까요? MySQL과 PostgreSQL은 이 문제를 다르게 접근합니다.
사실 MySQL InnoDB도 MVCC를 씁니다. 다만 undo log 기반이라 특정 isolation level에서는 Lock 경합이 생기는 케이스가 있어요.
-- MySQL: FOR UPDATE 걸면 다른 트랜잭션은 대기
BEGIN;
SELECT * FROM messages WHERE id = 1 FOR UPDATE;
-- 이 순간 다른 트랜잭션은 락이 풀릴 때까지 블로킹됩니다
MVCC (Multi-Version Concurrency Control): 데이터 변경 시 기존 데이터를 지우지 않고 새 버전을 추가해, 각 트랜잭션이 독립된 스냅샷을 바라보게 하는 동시성 제어 방식
PostgreSQL은 기존 행을 지우지 않고 새 버전을 추가합니다. 각 트랜잭션이 자기만의 스냅샷을 바라보기 때문에 읽기와 쓰기가 서로를 막지 않아요. MySQL과의 차이는 구현 방식에서 옵니다. PostgreSQL은 데이터 파일 자체에 버전을 저장하는 반면, MySQL은 undo log라는 별도 공간을 씁니다. 이 차이가 높은 동시성 환경에서 Lock 경합 빈도 차이로 이어지죠.
-- 트랜잭션 A가 쓰는 동안 트랜잭션 B는 이전 버전을 즉시 읽어요
SELECT * FROM send_logs WHERE user_id = 100; -- 대기 없이 즉시 응답
사실 이번 프로젝트에서 PostgreSQL을 선택한 가장 핵심 이유가 이거였어요.
다중 통신사 연동 서비스는 외부 DB가 여러 개입니다. 보통이라면 각 통신사 DB에 따로 붙어서 데이터를 가져온 다음, 애플리케이션 레벨에서 합치는 배치 작업이 필요해요. 복잡하고, 유지보수도 까다롭죠.
PostgreSQL의 FDW(Foreign Data Wrapper)는 외부 데이터 소스를 로컬 테이블처럼 쿼리할 수 있게 해주는 확장 기능입니다. MySQL, MongoDB, S3, CSV까지 붙어요.
-- 외부 통신사 DB 서버 등록
CREATE SERVER kt_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'kt-db.internal', dbname 'kt_messages');
-- 외부 테이블을 로컬처럼 매핑
CREATE FOREIGN TABLE kt_send_logs (
id BIGINT,
phone VARCHAR(20),
status VARCHAR(10),
sent_at TIMESTAMP
) SERVER kt_server;
-- 이제 JOIN도 그냥 됩니다
SELECT m.id, k.status
FROM my_messages m
JOIN kt_send_logs k ON m.phone = k.phone;
MySQL에도 Federated 엔진이 있지만 MySQL 서버끼리만 연동됩니다. 즉, PostgreSQL의 FDW는 이기종 DB 연동이 필요한 상황에서 최적이죠. 거의 메타몽 같은 존재입니다.
관계형 DB는 스키마가 고정돼 있어요. 컬럼을 미리 정의해야 하죠. 근데 실무에선 서비스마다 구조가 다른 데이터를 저장해야 할 때가 생깁니다. 통신사마다 응답 포맷이 다른 경우처럼요.
JSONB는 JSON 데이터를 바이너리 형태로 저장하는 PostgreSQL 타입입니다. 파싱된 상태로 저장되기 때문에 내부 키 검색과 인덱싱이 빠르게 동작해요.
CREATE TABLE api_responses (
id BIGSERIAL PRIMARY KEY,
carrier VARCHAR(10),
payload JSONB,
received_at TIMESTAMP DEFAULT NOW()
);
-- JSONB 내부 키로 바로 쿼리 가능
SELECT * FROM api_responses
WHERE payload->>'status' = 'delivered'
AND payload->'meta'->>'region' = 'Seoul';
GIN 인덱스를 같이 쓰면 내부 키 검색도 인덱스를 탑니다.
CREATE INDEX idx_payload ON api_responses USING GIN (payload);
MySQL도 JSON 타입을 지원합니다. 다만 바이너리 저장이 아니라서 GIN 인덱스 같은 고급 검색은 지원하지 않아요. 내부 키 검색이 잦다면 이 차이가 체감됩니다. NoSQL 쓸까 말까 고민될 때 JSONB가 중간 타협점이 되는 이유입니다.
문자 발송 서비스는 로그가 무섭게 쌓입니다. 하루에 수십만 건이면 1년 뒤엔 테이블 하나가 수억 개 행이 돼요. 이 상태에서 WHERE sent_at > '2025-01-01' 쿼리를 날리면 어떻게 될까요?
두 DB 모두 기본 인덱스는 B-tree(Balanced Tree)입니다. 이 부분은 차이가 없어요. 데이터를 정렬된 트리 구조로 저장해서 =, <, >, BETWEEN, ORDER BY 같은 연산을 빠르게 처리합니다.
-- 기본 B-tree 인덱스
CREATE INDEX idx_phone ON send_logs (phone);
PostgreSQL은 B-tree 외에도 데이터 특성에 맞는 인덱스 타입을 선택할 수 있어요.
| 인덱스 타입 | 언제 쓰나요? |
|---|---|
| B-tree | 일반 정렬/범위 검색 (기본값) |
| GIN | 배열, JSONB, 전문 검색 |
| GiST | 지리 데이터, 범위 타입 |
| BRIN | 시계열처럼 물리적으로 정렬된 대용량 데이터 |
MySQL도 파티셔닝을 지원하지만 파티션 테이블에 외래 키를 쓸 수 없는 등 제약이 있어요. PostgreSQL은 이 부분에서 더 유연합니다.
CREATE TABLE send_logs (
id BIGSERIAL,
phone VARCHAR(20),
status VARCHAR(10),
sent_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (sent_at);
CREATE TABLE send_logs_2025_01 PARTITION OF send_logs
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
WHERE sent_at BETWEEN '2025-01-01' AND '2025-01-31' 쿼리가 들어오면 PostgreSQL은 해당 월의 파티션만 스캔합니다. 이걸 Partition Pruning이라고 해요.
MySQL에는 없는 기능입니다. 특정 조건을 만족하는 행에만 인덱스를 걸어요.
-- status = 'FAILED'인 행에만 인덱스가 생깁니다
CREATE INDEX idx_failed_logs
ON send_logs (phone, sent_at)
WHERE status = 'FAILED';
전체 로그 중 실패 건만 빠르게 조회하고 싶을 때, 수억 개 행 전체에 인덱스를 걸 필요가 없어요. 인덱스 크기도 작고, 쓰기 성능 저하도 줄어듭니다.
회원 유형마다 필수 데이터가 다른 서비스를 생각해보세요. 기업 회원은 사업자번호가 필수고, 개인 회원은 생년월일이 필수입니다.
PostgreSQL의 CHECK CONSTRAINT는 이걸 DB 레벨에서 원천 차단합니다.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
user_type VARCHAR(10) NOT NULL,
birth_date DATE,
business_number VARCHAR(20),
CONSTRAINT chk_personal_birth
CHECK (user_type != 'PERSONAL' OR birth_date IS NOT NULL),
CONSTRAINT chk_business_number
CHECK (user_type != 'BUSINESS' OR business_number IS NOT NULL)
);
MySQL도 8.0.16부터는 CHECK CONSTRAINT가 실제로 동작합니다. 다만 그 이전 버전에서는 선언만 되고 무시됐어요. 선언은 있으나 실행은 없는, 형식적 제약의 DB 버전이었습니다. 버전 관리가 느슨한 환경이라면 PostgreSQL 쪽이 더 안전합니다.
두 DB 모두 잘 만들어져 있고, 대부분의 서비스는 어느 쪽이든 잘 동작합니다. 판단 기준은 서비스의 특성이에요.
PostgreSQL이 유리한 상황
MySQL이 유리한 상황
회의록 한 줄로 끝났던 "PostgreSQL 채택"이 이렇게 긴 여정이 될 줄 몰랐습니다. 맨날 부족합니다 ㅠ
정리하면 이렇습니다.
PostgreSQL이 항상 옳은 선택은 아닙니다. 하지만 이 네 가지가 맞아떨어지는 서비스라면 MySQL보다 확실히 유리한 선택이에요.
다음엔 FDW 실제 설정하면서 겪은 삽질기 들고 올게요. 기대해주세요!@@