MySQL 있는데 왜 굳이 PostgreSQL이에요?

윤뿔소·2026년 1월 23일
post-thumbnail

신규 프로젝트 DB 선정 회의에서 PostgreSQL을 채택했는데, 회의가 끝나고 누군가 물어봤습니다.

"MySQL 쓰면 되지 않아요?"

그 순간 저는 말문이 막혔습니다. 신규 프로젝트에는 당연히 전부 PostgreSQL을 썼거든요. 이 질문을 통해, 기술 선택에 참여한 것과 기술을 이해한 건 다른 얘기란 걸 알게 됐습니다.

그래서 처음부터 파봤습니다. 이 글은 그 과정의 기록입니다.

MVCC: 동시에 읽고 써도 안 꼬임

DB에서 가장 풀기 어려운 문제 중 하나가 동시성입니다. 여러 사용자가 같은 데이터를 동시에 읽고 쓰면 어떻게 될까요? MySQL과 PostgreSQL은 이 문제를 다르게 접근합니다.

MySQL 방식: Lock으로 Queueing

사실 MySQL InnoDB도 MVCC를 씁니다. 다만 undo log 기반이라 특정 isolation level에서는 Lock 경합이 생기는 케이스가 있어요.

-- MySQL: FOR UPDATE 걸면 다른 트랜잭션은 대기
BEGIN;
SELECT * FROM messages WHERE id = 1 FOR UPDATE;
-- 이 순간 다른 트랜잭션은 락이 풀릴 때까지 블로킹됩니다

PostgreSQL 방식: 버전 복사

MVCC (Multi-Version Concurrency Control): 데이터 변경 시 기존 데이터를 지우지 않고 새 버전을 추가해, 각 트랜잭션이 독립된 스냅샷을 바라보게 하는 동시성 제어 방식

PostgreSQL은 기존 행을 지우지 않고 새 버전을 추가합니다. 각 트랜잭션이 자기만의 스냅샷을 바라보기 때문에 읽기와 쓰기가 서로를 막지 않아요. MySQL과의 차이는 구현 방식에서 옵니다. PostgreSQL은 데이터 파일 자체에 버전을 저장하는 반면, MySQL은 undo log라는 별도 공간을 씁니다. 이 차이가 높은 동시성 환경에서 Lock 경합 빈도 차이로 이어지죠.

-- 트랜잭션 A가 쓰는 동안 트랜잭션 B는 이전 버전을 즉시 읽어요
SELECT * FROM send_logs WHERE user_id = 100; -- 대기 없이 즉시 응답

FDW: 외부 DB를 내 테이블처럼

사실 이번 프로젝트에서 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 연동이 필요한 상황에서 최적이죠. 거의 메타몽 같은 존재입니다.

JSONB: NoSQL 뺨치는 유연함

관계형 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' 쿼리를 날리면 어떻게 될까요?

인덱스의 기본: B-tree

두 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';

전체 로그 중 실패 건만 빠르게 조회하고 싶을 때, 수억 개 행 전체에 인덱스를 걸 필요가 없어요. 인덱스 크기도 작고, 쓰기 성능 저하도 줄어듭니다.

CONSTRAINT: DB 수준 방패

회원 유형마다 필수 데이터가 다른 서비스를 생각해보세요. 기업 회원은 사업자번호가 필수고, 개인 회원은 생년월일이 필수입니다.

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 쪽이 더 안전합니다.

MySQL vs PostgreSQL 정리

두 DB 모두 잘 만들어져 있고, 대부분의 서비스는 어느 쪽이든 잘 동작합니다. 판단 기준은 서비스의 특성이에요.

PostgreSQL이 유리한 상황

  • 외부 이기종 DB와 연동이 필요할 때 (FDW)
  • 금융, 결제처럼 트랜잭션 정합성이 최우선일 때 (MVCC)
  • 대용량 로그나 시계열 데이터를 다룰 때 (파티셔닝 + 부분 인덱스)
  • DB 레벨에서 복잡한 무결성 보장이 필요할 때 (CHECK CONSTRAINT)

MySQL이 유리한 상황

  • 단순한 CRUD 중심 서비스
  • 읽기 성능이 압도적으로 중요한 경우 (레플리카 구성이 간단)
  • 팀에 MySQL 운영 경험이 많은 경우

마치며

회의록 한 줄로 끝났던 "PostgreSQL 채택"이 이렇게 긴 여정이 될 줄 몰랐습니다. 맨날 부족합니다 ㅠ

정리하면 이렇습니다.

  1. MVCC : MySQL도 쓰지만, 구현 방식 차이로 고동시성 환경에서 Lock 경합 적음
  2. FDW : 이기종 DB 연동이 필요하다면 PostgreSQL !
  3. 파티셔닝 + 부분 인덱스 : 대용량 데이터를 유연하게 관리 가능
  4. CHECK CONSTRAINT : DB 레벨 무결성을 버전에 관계없이 안정적으로 보장

PostgreSQL이 항상 옳은 선택은 아닙니다. 하지만 이 네 가지가 맞아떨어지는 서비스라면 MySQL보다 확실히 유리한 선택이에요.

다음엔 FDW 실제 설정하면서 겪은 삽질기 들고 올게요. 기대해주세요!@@

profile
코뿔소처럼 저돌적으로

0개의 댓글