All you need is PostgreSQL

Author: Eduardo Bellani | Source: ebellani.github.io/blog | Published: 2026-06-25


한 줄 요약

“감사 추적이 필요하니 event sourcing DBMS를 쓰자”, “앱이 느리니 in-memory KV로 캐시하자” 같은 아키텍처 cargo culting에 맞서, 흔한 반대 논거 5가지를 vanilla PostgreSQL 18 + RDS 표준 확장만으로 해소해 보인 글.

핵심 주장/내용

  • 문제 제기: 현대 엔지니어링에는 문제가 나타나면 first principle로 생각하는 대신 패키지 솔루션에 손을 뻗는 깊은 문화적 반사가 있다. 관계형 DB는 여전히 필수로 여겨지니 회사들은 PostgreSQL 위에 제품을 쌓고 또 쌓아 움직이는 부품 수, 운영 리스크, 인력 수요, 시스템 엔트로피를 부풀린다. 복잡도가 자라는 이유는 문제가 요구해서가 아니라 누군가 컨퍼런스 발표에서 본 도구에 손을 뻗었기 때문이다
  • 다루는 5가지 반대 논거 (금융 거래·transfer 도메인으로 예시. Transaction은 불변 기록이고 Transfer는 상태를 갖고 생명주기를 거치며, Pending Transaction은 가변):
    1. “감사와 상태 재구성이 필요하다” → system-time temporal table이 모든 transfer 상태 전이의 완전하고 자동적인 감사 추적을 준다. event store도, append-only 로그 인프라도 필요 없고 트리거가 유지하는 히스토리 테이블 하나면 된다
    2. “쓰기 처리량이 부족하다” → 워킹셋 사이징 + HOT(Heap-Only Tuple) 업데이트
    3. “트랜잭션 쿼리가 너무 느리다” → 복합 기본키 위의 keyset pagination이 깊이 페이징해도 저하되지 않는 안정적 index-backed 리스팅 성능을 준다. transfer 히스토리 쿼리는 인덱스된 테이블에 대한 단일 UNION ALL
    4. “분석 쿼리가 너무 느리다” → balance ledger가 트리거로 running balance 스냅샷을 증분 유지해, 풀 테이블 집계가 될 것을 임의 시점의 단일 index-backed 조회로 바꾼다
    5. “앱이 DB에 결합된다” → updatable view
  • Capacity planning — 워킹셋을 먼저 계산하라: wire transfer는 보통 1영업일에, 국제는 1~5일(대부분 2일 내) 정산되므로 2일치 transfer/transaction을 shared_buffers에 유지하는 것을 목표로 잡는다
    • 가장 넓은 row(transfer)가 24바이트 row 헤더 포함 약 160바이트, transaction 테이블은 약 120바이트
    • transfer row 하나가 status log 2 row + transaction 3개(pending 2, settled 1)에 대응한다고 가정 → ((3 * 160) + (3 * 120)) * 1.4 = 1,176바이트
    • 인덱스 오버헤드는 1.4 배수 — 인덱스 항목마다 8바이트 오버헤드 + 인덱스된 데이터. transfer 기본키가 5컬럼 ~54바이트를 인덱싱해 항목당 ~62바이트로 row 크기의 약 40%
    • 규모별 2일 워킹셋: 스타트업(일 1만 transfer) 22M / 중·대형 은행(일 5,000만) 110G / 글로벌 프로세서(일 9억) 1.9T. 중형 은행까지는 현대 클라우드 DB 서버에 워킹셋이 편안히 들어간다 (AWS RDS는 PostgreSQL 호환 인스턴스에 최대 4TiB 메모리 지원)
  • 쓰기 처리량 — write amplification을 낮춰라: 의미론적 강제(fkey, constraint trigger)를 유지하면서 처리량을 최적화하는 최선은 인덱스를 똑똑하게 쓰는 것이다. “인덱스는 신중히 고르지 않으면 write-heavy 애플리케이션의 성능을 죽인다”
    • HOT 업데이트 활성화 — transfer 테이블의 기본키를 불변 컬럼과 정렬시켜 두면 status 전이(pending → completed/returned) 같은 흔한 연산에서 HOT이 가능해진다. status와 transfer_period가 어떤 인덱스에도 참여하지 않으므로 PostgreSQL이 새 tuple 버전을 같은 페이지에 쓰고(공간이 있으면) 인덱스 갱신을 통째로 건너뛸 수 있다
    • 페이지 공간 확보: alter table finance.transfer set (fillfactor = 70) — 페이지당 30% 여유를 남겨 갱신된 tuple이 같은 페이지에 들어가게. 얻는 것: non-HOT 대비 2~3배 빠른 업데이트, status 변경으로 인한 테이블·인덱스 bloat 없음, 더 단순한 vacuum 유지보수, 전체 쓰기 활동이 줄어 더 작은 WAL
    • 미사용 인덱스 제거 — 핵심 테이블에 쓰이지 않는 인덱스가 쌓이는 것은 흔한 문제다(직접 보이지 않고 실험이 잊히기 때문). pg_stat_user_indexes의 idx_scan(인덱스가 스캔된 횟수 — 앱 쿼리 직접 또는 JOIN 간접), idx_tup_read(인덱스 스캔 결과 반환된 항목 수), idx_tup_fetch(인덱스 스캔 결과 테이블에서 fetch된 row 수 — positive와 false positive 둘 다에 증가. 예: 인덱스에 없는 컬럼 조건이 붙으면 그 값을 검사하려 tuple을 디스크에서 로드해야 하므로 하나만 반환돼도 2 증가)로 확인
  • Serializable isolation — read-then-write 패턴의 공통 취약점: constraint trigger와 balance ledger trigger 모두 상태를 읽고 → 그에 근거해 결정하고 → 쓴다. 기본 READ COMMITTED에서는 동시 트랜잭션이 서로의 미커밋 변경을 반영하지 않은 스냅샷을 각자 읽고 둘 다 써서 불일치를 만든다
    • 구체 사례: lost balance update(같은 계좌에 대한 두 동시 insert가 최신 ledger를 current_total = 1000으로 읽고 둘 다 1000 + amount를 삽입 — 두 번째는 1000 + A + B여야 했는데 running total이 조용히 손상), 닫히는 transfer에 대한 pending transaction(하나는 status를 pending으로 읽고 허용, 동시에 다른 하나가 completed로 갱신 → 둘 다 커밋되어 pending transaction이 completed transfer를 참조), transfer 기간 밖의 transaction, 닫힐 때 빠져나가는 미래 transaction
    • 모두 snapshot isolation 하의 write skew라는 같은 근본 문제의 사례다. 해법은 alter role finance set default_transaction_isolation = 'serializable'
    • PostgreSQL의 SSI(Serializable Snapshot Isolation) 는 동시 트랜잭션 간 read-write 의존성을 추적하고, 어떤 직렬 실행으로도 불가능한 결과를 낼 수 있는 의존성 사이클을 탐지하면 하나를 serialization failure로 abort한다. 애플리케이션은 재시도를 준비해야 하지만 데이터 불변식이 절대 위반되지 않는다
    • 글로벌 설정으로 하는 이유는 문제가 글로벌하기 때문이다 — 모든 read-then-write constraint trigger와 모든 증분 balance 갱신이 취약하다. 트랜잭션별로 설정하려면 애플리케이션이 어떤 트랜잭션이 어떤 테이블을 건드리는지 알아야 하고 주석 하나만 빠져도 조용히 일관성 구멍이 열린다. DB 기본값이 그 리스크를 없앤다
    • 비용은 일부 트랜잭션의 abort·재시도인데, 이 경우 낮다 — serialization 충돌이 같은 계좌에 대한 동시 쓰기 사이에서만 일어나고 계좌당 동시 트랜잭션 무리를 예상하지 않기 때문. 지수 백오프로 충분하고 금융 시스템에서 조용한 데이터 손상을 감수하는 대안은 확실히 수용 불가
  • Decoupling — 마이크로서비스 대신 view: 뷰는 SQL DBMS에서 모듈성을 구현하는 표준 방법이다. Codd가 원래 구상했듯 뷰는 논리적 데이터 독립성을 제공한다 — 데이터의 내부 표현이 바뀌어도 애플리케이션 프로그램과 단말 활동이 영향받지 않는다. 비전문 용어로: 인터페이스(뷰)와 구현(base table)의 관계가 many-to-many이고 그것이 정확히 모듈성의 정의다
    • 따라서 직렬화 오버헤드·분산 일관성 문제·운영 복잡도라는 대가를 치르는 마이크로서비스 대신 단일 PostgreSQL 인스턴스 위의 뷰로 같은 논리적 속성을 얻을 수 있다
    • INSTEAD OF 트리거로 뷰를 updatable하게 만들면, 애플리케이션이 finance.transfer_activity를 단일 통합 스트림으로 다룬다: transfer와 transaction의 전체 히스토리를 읽고, kind에 따라 새 transfer/pending/settled를 삽입(트리거가 올바른 기저 테이블로 라우팅하고 거기서 모든 constraint trigger·temporal foreign key·balance ledger 유지가 평소대로 발화), transfer status를 갱신
    • 기저 테이블 구조가 바뀌어도(컬럼 rename, 추가, 테이블 분할·병합) 뷰 정의를 한 번 갱신하면 모든 애플리케이션이 변경 없이 계속 동작한다. 네트워크 홉 없고 직렬화 오버헤드 없고 분산 일관성 문제 없는 데이터 레벨의 decoupling — 뷰가 인터페이스이고 base table이 구현이다

주요 수치 / 사실

  • pgbench 벤치마크 (개발 노트북, 프로덕션 하드웨어 아님. 10 클라이언트, 4 스레드, 60초, 80/20 read/write 믹스):
    • 전체 766 TPS, 실패 트랜잭션 0건. 스타트업 목표 일 1만 transfer는 ~0.12 TPS이므로 6,000배 이상의 여유
    • serialization failure 0건 — SERIALIZABLE 하에서 10개 동시 클라이언트가 100개 계좌에 썼는데도 abort된 트랜잭션이 없었다. 워킹셋이 충분히 많은 계좌에 분산되어 이 규모에서 쓰기 경합이 무시할 수준
    • 읽기 평균 8ms — 4개 테이블에 대한 UNION ALL 뷰 + balance ledger 조회가 60초 실행 동안 테이블이 커져도 대화형 응답 시간 안에 완료
    • 쓰기 평균 33ms — 각 쓰기 트랜잭션이 감사 추적·시간 제약·잔액 등 복잡한 비즈니스 로직을 구현하는 전체 constraint 집합을 33ms 안에 통과. 모든 비즈니스 규칙을 데이터 레벨에서 강제하는 진짜 비용이고 충분히 수용 가능
  • 결론의 주장: 특화 시스템이 결코 적절하지 않다는 것이 아니라, 데이터 문제에 대한 기본 가정이 “우리 회사는 PostgreSQL만으로 괜찮다”여야 한다는 것. 이 모든 것이 RDS에서 이용 가능한 표준 확장을 쓰는 단일 vanilla PostgreSQL 18 인스턴스에서 돌아간다 — 분산 시스템 코스프레도, 인프라 증식도, 운영 복잡도 세금도 없이

관련 위키


Source: 원문 보기