3P by GN⁺ | ★ favorite | 댓글 1개
  • Hatchet이 2년간 프로덕션에서 겪은 문제를 바탕으로, 초기 스키마·쿼리 설계부터 대량 쓰기와 테이블 마이그레이션까지 단계별 운영 원칙을 정리함
  • 빠른 읽기를 위해 인덱스와 ORDER BY를 맞추되, 쿼리 플래너가 통계와 비용에 따라 순차 스캔을 선택할 수 있으므로 EXPLAIN ANALYZE로 추정치와 실제 실행을 비교해야 함
  • 쓰기 성능과 안정성은 짧은 트랜잭션, 필요한 행만 잠그기, CREATE INDEX CONCURRENTLY, 연결 풀링에 달려 있으며, 배치 처리는 Hatchet 측정에서 처리량을 약 10배 높였음
  • 고빈도 쓰기 환경에서는 기본 autovacuum 설정이 dead tuple과 transaction ID를 제때 회수하지 못할 수 있으며, transaction ID wraparound에 이르면 큰 폭의 다운타임이 발생함
  • 규모가 커지면 FOR UPDATE SKIP LOCKED 기반 작업 큐, 파티셔닝, 트리거와 배치 백필을 활용하되, ORM 추상화 밖에서 SQL을 직접 제어할 수 있어야 함

대상 독자와 ORM의 한계

  • SQL, 행, 테이블, 인덱스의 기본 개념을 아는 개발자가 프로덕션 Postgres 문제에 대응할 수 있도록 구성한 가이드임
  • Postgres 매뉴얼은 포괄적이지만 장애 상황에서 빠르게 참고하기 어려워, Hatchet이 2년간 겪은 운영 경험을 중심으로 압축함
  • ORM을 사용해도 원칙은 적용되지만, 규모가 커질수록 추상화 계층을 벗어나 SQL을 직접 작성해야 가능한 최적화가 많음
    • Prisma TypedSQL 같은 기능으로 ORM과 직접 SQL을 함께 사용할 수 있음
    • Go 기반 Hatchet은 유사한 동작을 제공하는 sqlc를 사용함
    • Claude가 쿼리를 작성하는 환경에는 supabase/agent-skills를 권장함

변경하기 어려운 스키마 설계

  • 배포 후에는 스키마 변경이 가장 어려우므로, 테이블과 기본 키의 초안을 만든 뒤 애플리케이션에 필요한 쿼리를 작성하며 반복적으로 설계해야 함
  • 설계 과정에서는 다음 질문으로 테이블의 사용 방식을 확인함
    • 읽기와 쓰기 중 어느 쪽의 빈도가 높은가
    • 읽을 때 가장 자주 사용하는 필터는 무엇인가
    • 가장 자주 갱신하는 열은 무엇인가
  • 데이터베이스 정규화의 1NF·2NF·3NF를 적용할 수 있지만, 정규형이 쿼리 효율이나 빠른 개발에 필요한 사용 편의성과 충돌할 때도 있음
    • 일부 상황에서는 데이터를 jsonb 열에 넣는 편이 단순함
  • 스키마 설계에 적용한 경험칙은 다음과 같음
    • 기본 키에는 identity 열인 자동 증가 정수나 Postgres 내장 UUID를 사용함
    • identity 열은 bigserial보다 약간 빠름
    • 시간에는 항상 timestamptz를 사용함
    • 모든 테이블에 기본 키를 둠
    • 일관성과 정확성이 중요한 저용량 테이블에는 cascade delete를 포함한 외래 키를 사용하되, 고용량 환경에서는 주의해야 함

읽기 쿼리와 인덱스

  • 빠른 SELECT를 이해하기 위한 단순한 모델은 Postgres가 인덱스로 행 하나를 빠르게 찾거나, 순차 스캔(seq scan) 으로 테이블의 모든 행을 읽는다는 것임
  • 빠른 단일 행 탐색에는 다음 구조를 이용함
    • 명시적인 인덱스
    • 인덱스의 특수한 형태인 unique constraint
    • Postgres가 자동으로 인덱싱하는 기본 키
  • 기본 인덱스는 btree를 사용하며, 조회에 최적화된 형태로 데이터를 저장한 별도 테이블처럼 이해할 수 있음
    • 행 탐색 시간은 대략 log(n)이며, n은 테이블의 행 수임
  • 인덱스를 사용할 수 없으면 순차 스캔이 실행되지만, 현대 데이터베이스는 행을 메모리에 빠르게 올리므로 2만 행 미만 테이블에서는 거의 즉시 끝날 수 있음

조인과 복합 인덱스

  • 내부 조인의 대상에는 대체로 기본 키를 사용해야 하며, 그렇지 않다면 스키마 설계나 정규화에 문제가 있을 수 있음
  • ON 절도 WHERE 절처럼 다루고, 조인 조건에 적절한 인덱스를 사용해야 함
  • 큰 테이블의 목록 조회는 애플리케이션에서 처음 느려지는 쿼리가 되기 쉬움
    • 조직과 생성 시각을 함께 필터링하고 정렬한다면 복합 인덱스를 사용할 수 있음
CREATE INDEX CONCURRENTLY idx_documents_org_created
    ON documents (organization_id, created_at DESC);
  • 복잡한 쿼리에서는 ORDER BY 열을 인덱스의 마지막에 배치하고 정렬 방향도 맞추는 것이 경험칙임
    • Postgres는 btree를 양방향으로 스캔하므로 단일 열에서는 DESC가 무의미할 수 있지만, 복합 인덱스에서는 맞춰 두는 편이 좋음
    • 내림차순 인덱스의 상세 동작은 관련 자료에서 확인할 수 있음

쓰기, 잠금, 마이그레이션

  • 성공적인 쓰기의 첫 번째 조건은 트랜잭션을 짧게 유지하는 것임
    • 특별한 이유가 없다면 트랜잭션 도중 외부 서비스를 조회하지 않음
  • 두 번째 조건은 필요한 행만 잠그는 것임
    • 행을 갱신하면 트랜잭션이 커밋될 때까지 해당 행에 잠금이 걸림
    • 시스템 부하가 커질수록 잠금의 영향도 두드러짐
  • 기존 대형 테이블에서 일반 CREATE INDEX를 실행하면 테이블이 잠겨 insert와 update가 차단되므로, 항상 CREATE INDEX CONCURRENTLY를 사용함
  • 좋은 스키마 마이그레이션 역량은 반복 개발 속도를 높이고 가동 시간을 늘림
    • 가능한 한 열 삭제나 제거를 피하고 추가 방식으로 변경함
    • 가능하면 트랜잭션 안에서 실행해 롤백과 부분 적용에 대응함
    • 더 발전된 방식으로 expand and contract 마이그레이션을 사용할 수 있음
  • 마이그레이션은 모든 쓰기를 차단하는지를 먼저 판단해야 함
    • CONCURRENTLY 없는 인덱스 생성은 모든 쓰기를 막아 다운타임을 일으킬 수 있음
    • ALTER TABLE 작업은 다시 검토해야 하며, 대형 테이블에 check constraint를 추가하는 작업도 쓰기를 차단할 수 있음
    • check constraint를 NOT VALID로 추가하면 해당 차단을 피할 수 있음

연결 관리

  • 모든 쿼리와 트랜잭션은 데이터베이스 연결을 사용하며, 연결은 CPU와 메모리 비용이 크므로 오래 유지해야 함
  • 연결을 자주 만들고 제거하면 자원이 낭비됨
    • 많은 신규 연결이 동시에 발생하는 connection storm은 Postgres 내부 잠금과 관련된 디버깅하기 어려운 문제를 일으킬 수 있음
  • 외부 연결 풀러인 pgbouncer를 우선 고려하고, 사용할 수 없다면 인메모리 연결 풀을 대안으로 삼음
    • Hatchet은 사용자의 데이터베이스가 외부 풀러를 쓴다고 가정할 수 없어 Go용 pgxpool을 사용함

쿼리 플래너와 통계

  • 조인이 많거나 여러 조인 방식을 섞은 복잡한 쿼리는 단순히 인덱스를 추가하는 것만으로 해결되지 않음
    • 인덱스 자체에도 오버헤드가 있으므로 무제한으로 추가해서는 안 됨
  • 쿼리 플래너는 SQL을 내부 데이터베이스 연산으로 변환하고 인덱스 사용 여부 등을 결정하지만, 제한된 정보 때문에 최적 계획을 선택하지 못할 수 있음
  • 플래너가 사용하는 정보는 테이블 통계이며 pg_stats에서 조회할 수 있음
SELECT *
FROM pg_stats
WHERE tablename = 'mytable';
  • 통계는 ANALYZE 때 수집되고 autovacuum 실행 시에도 갱신됨
    • autovacuum 빈도를 높이면 쿼리 통계도 최신 상태를 유지함
    • 쿼리가 잘못 동작하는 흔한 원인 중 하나는 분석 빈도가 부족한 것임
  • 쿼리를 순차 스캔 여부로 단순하게 판단하면 미세 최적화로 플래너의 예측 불가능성을 키우는 일을 줄일 수 있음
    • 기본 키와 인덱스 중심으로 조회하면 플래너가 계획을 선택하기 쉬워짐

실행 계획 분석과 순차 스캔

  • 일부 제공자는 Google CloudSQL처럼 쿼리를 샘플링해 느린 쿼리를 저장하지만, 모든 서비스가 이를 지원하지는 않음
  • EXPLAIN ANALYZE는 쿼리를 실제로 실행하며, 테이블 통계에 따른 예상 행 수와 실제 스캔 행 수를 비교함
    • 프로덕션에서는 실제 쿼리가 실행되므로 주의해야 함
    • 실행 없이 계획만 확인하려면 ANALYZE를 뺀 EXPLAIN을 사용함
  • 상세 계획을 JSON으로 저장한 뒤 explain.dalibo.com에서 시각화할 수 있음
psql -XqAt -f explain.sql -d $DATABASE_URL > analyze.json
  • 통계와 인덱스가 정상인데도 순차 스캔한다면, 플래너가 순차 스캔 비용이 더 낮다고 계산했을 수 있음
    • 인덱스는 실제 테이블 데이터가 있는 heap과 별도로 저장되므로, 인덱스에서 찾은 여러 행을 heap에서 다시 읽는 비용이 발생함
    • 쿼리를 크게 재구성할 수 없다면 순차 스캔을 받아들이거나 파티셔닝을 검토해야 함

대량 쓰기와 배치 처리

  • 각 쿼리에는 데이터베이스 왕복 시간, 애플리케이션 연결 풀에서 연결을 얻는 시간, Postgres 처리 시간이라는 오버헤드가 있음
    • Postgres 내부 잠금도 고처리량 환경에서 병목이 될 수 있음
  • 한 쿼리에 여러 행을 묶으면 이러한 비용을 줄일 수 있음
    • 가장 단순한 방법은 암묵적 트랜잭션으로 여러 쿼리를 서버에 한꺼번에 보내는 것임
    • Go에서는 pgxSendBatch를 사용할 수 있음
  • Hatchet에서는 배치 처리로 처리량이 약 10배 증가했으며, 추가 삽입 최적화는 빠른 Postgres 삽입 가이드에 정리돼 있음

autovacuum과 transaction ID wraparound

  • autovacuum은 dead tuple 정리와 transaction ID 관리를 담당하며, 고빈도 쓰기 환경에서는 설정 조정이 필요할 수 있음
  • tuple은 파일 시스템에 저장된 행의 한 버전임
    • 행을 갱신하거나 삭제해도 그 전에 시작한 모든 트랜잭션이 커밋 또는 롤백될 때까지 기존 버전이 남음
    • 어떤 트랜잭션에서도 더 이상 읽을 수 없는 버전이 dead tuple임
  • 쓰기 속도가 너무 빠르면 autovacuum이 dead tuple 생성 속도를 따라가지 못해 데이터베이스 상태가 급격히 악화될 수 있음
  • pg_stat_activity에서 활성 프로세스를 확인했을 때 autovacuum 쿼리가 약 1시간 이상 실행 중이라면 설정 변경을 검토해야 함
  • autovacuum이 회수하기 전에 모든 transaction ID를 소진하면 transaction ID wraparound가 발생하고 큰 폭의 다운타임으로 이어짐

테이블과 인덱스 팽창

  • Postgres는 디스크의 8KB 페이지에 행을 저장하며, 기존 페이지에 새 행을 넣지 못하면 새 페이지를 생성함
  • dead tuple이 회수된 뒤 페이지가 부분적으로 비어 있으면 테이블 팽창(table bloat)이 발생해 디스크 사용량이 크게 늘 수 있음
    • 가장 좋은 예방법은 팽창하기 전에 autovacuum을 조정하는 것임
    • 이미 팽창한 테이블에는 pg_repack 같은 확장을 사용할 수 있음
    • 내장 VACUUM FULL은 거의 좋은 선택이 아님
    • Postgres 19에는 동시 테이블 재패킹을 위한 REPACK...CONCURRENTLY가 추가될 예정이지만 Hatchet은 아직 시험하지 않았음
  • 인덱스 팽창도 테이블 팽창의 특수한 형태이며, 적절한 autovacuum 설정으로 줄일 수 있음
    • 이미 팽창한 인덱스에는 내장 명령 REINDEX INDEX CONCURRENTLY를 사용할 수 있음

FOR UPDATE SKIP LOCKED 기반 동시 처리

  • FOR UPDATE SKIP LOCKED는 선택한 행을 현재 트랜잭션용으로 예약하면서 다른 쿼리를 방해하지 않음
  • Hatchet은 이를 작업 큐에 사용하며, 한 쿼리에서 대기 중인 작업을 잠그고 상태를 RUNNING으로 바꿀 수 있음
WITH eligible_tasks AS (
    SELECT *
    FROM tasks
    WHERE status = 'QUEUED'
    ORDER BY id ASC
    FOR UPDATE SKIP LOCKED
    LIMIT 100
)
UPDATE tasks
SET status = 'RUNNING'
FROM eligible_tasks
WHERE tasks.id = eligible_tasks.id
RETURNING tasks.*;
  • 서로 독립적인 행을 동시에 갱신하거나 여러 애플리케이션 인스턴스가 객체의 lease를 관리할 때도 유용함
    • Hatchet은 여러 엔진에 tenant lease를 분배하는 데 사용함

파티셔닝

  • Postgres의 내장 파티셔닝은 timestamp나 hash 같은 행 값을 기준으로 테이블을 분할함
  • 시계열 데이터와 Hatchet의 과거 작업 데이터에서 다음 이점을 제공함
    • 파티션마다 독립적으로 autovacuum을 실행해 테이블의 autovacuum 처리 규모를 키울 수 있음
    • 오래된 데이터를 행 단위로 삭제하지 않고 파티션 테이블을 내려 거의 즉시 제거할 수 있음
  • 계획 단계에서 Postgres가 불필요한 파티션을 제거하지 못하면 읽기 쿼리에 오버헤드가 생길 수 있음
    • 최근 Postgres 릴리스에서는 파티션 가지치기가 개선됨
    • Hatchet의 운영 경험은 Postgres 파티셔닝 글에 정리돼 있음

대형 테이블 간 데이터 이동

  • 여기서 말하는 대형 테이블 마이그레이션은 스키마 변경이 아니라, 한 테이블에서 다른 테이블로 대량 데이터를 이동하는 작업임
  • 매우 큰 테이블을 단일 트랜잭션으로 복사하면 수 시간이 걸릴 수 있음
    • 장시간 트랜잭션은 autovacuum의 정상 동작을 막아 dead tuple 팽창을 일으킴
    • 이전 테이블에 계속 쓰기가 발생하면 새 테이블에는 해당 데이터가 반영되지 않음
  • Hatchet은 트랜잭션 밖에서 큰 배치 백필을 실행하고, 마이그레이션 시작 후의 새 쓰기는 Postgres 트리거로 새 테이블에 복사함
    • 기본 키의 unique constraint를 이용해 중복 쓰기를 방지함

댓글과 토론

Hacker News 의견들
  • 운영 데이터베이스라면 가장 먼저 백업·복구 계획부터 세워야 하지 않나 싶음. 고가용성은 초기에는 선택 사항일 수 있어도 생존 가이드에 백업과 복구가 빠진 건 의아함
    PostgreSQL 백업에는 요즘도 Barman(https://pgbarman.org/)을 많이 쓰는지 궁금함

    • PostgreSQL 전문가가 아니라면 직접 운영하지 말고 RDS 같은 관리형 데이터베이스를 쓰는 편이 나음. 직접 호스팅해서 절약하는 비용은 검증된 고가용성, 백업·복구, 특정 시점 복구, 읽기 복제본을 얻는 비용에 비하면 미미함
    • pgBackRest를 사용하고 있음. 예전에 쓰던 야간 백업 자체 솔루션보다 나은 특정 시점 복구를 제공하며, Backblaze B2에 백업하도록 비교적 쉽게 설정했고 별다른 문제도 없었음
    • 대부분은 cron에서 pg_dump_all을 실행해 zstd로 압축한 뒤 S3나 FTP 등에 복사하는 정도면 충분함. 데이터가 커지면 전체 백업의 시간과 비용이 부담되지만, 이 단순한 방식으로도 상당히 오래 버틸 수 있음
    • 전원 장애에도 내구성을 보장하는 데이터베이스라면 원자적 볼륨 스냅샷으로 백업할 수 있음. 복구 시간을 줄이려면 먼저 체크포인트를 만들고, 데이터 손상을 막으려면 스냅샷의 원자성이 반드시 보장돼야 함
      AWS에서 수 TB 규모 MongoDB를 EBS 스냅샷으로 백업해 빠른 증분 백업과 복구를 구현했음. 특정 시점 복구는 안 되지만 시간 단위로 자주 찍을 수 있으므로 PostgreSQL 전용 도구와 병행할 보조 전략으로 적합함
    • 이미 Kubernetes를 운영한다면 CloudNativePG를 쓰면 됨
  • 몇 가지 보완할 내용이 있음. 일반적인 UUIDv4보다 UUIDv7을 사용하고, 잠그는 행 수뿐 아니라 모든 쿼리에서 잠금 순서를 id ASC처럼 결정적으로 통일해야 교착 상태를 피할 수 있음
    EXPLAIN (GENERIC_PLAN)을 사용하면 매개변수 자리표시자를 유지한 채 쿼리를 복사할 수 있고, PostgreSQL이 실제 값을 모를 때의 최적화 계획도 볼 수 있음. 비어 있거나 작은 테이블에서는 SET enable_seqscan = off로 인덱스 사용 가능성을 확인할 수 있음
    모두가 기본으로 쓰는 B-tree 인덱스는 무겁고 비대화되기 쉬우므로, 정렬이나 범위 검색 없이 단순 조회만 한다면 해시 인덱스도 고려할 만함. 고유 해시 인덱스는 만들 수 없지만 해시 제외 제약으로 비슷한 효과를 낼 수 있으며, 다중 열 고유 인덱스는 지원하지 않음
    GIN·GiST 인덱스도 익혀두면 좋음. MySQL 사용자에게는 의외일 수 있지만 전문 검색으로 바꾸지 않고도 평범한 LIKE '%foo%' 쿼리를 가속할 수 있음

    • 잠글 행 집합에 일관된 ORDER BY가 없는 경우뿐 아니라 테이블 잠금 순서가 다를 때도 교착 상태가 발생함. 한 트랜잭션이 table_a, table_b 순서로 잠그고 다른 트랜잭션이 반대로 잠그면, 각 테이블 내부에서 ORDER BYFOR UPDATE를 써도 교착 상태가 됨
      이론상 명백하지만 실제로는 모든 쓰기가 건드리는 테이블을 전역적으로 파악해야 해서 디버깅이 훨씬 어려우며, 특정 확장 기능에서 실제로 당한 적이 있음. JSONB 키-값 조회에 GIN을 시험 중인데 성능 향상이 매우 컸고, ANDOR 사이의 성능 차이도 상당했음
    • 어떤 UUID든 기본 키로 쓰면 기본 키 조인이 잦아서 비용이 크고 대개 이득이 적음. 기본값으로는 순차 증가 기본 키를 쓰고, 외부 공개가 필요하면 보조 인덱스를 둔 UUIDv4 열을 추가하는 편이 안전함. UUIDv7이 UUIDv4보다 B-tree 성능이 실제로 더 좋은지 궁금함
    • 순차 스캔을 끄면 인덱스가 하나라도 있는 경우 PostgreSQL이 그 인덱스를 억지로 사용하지 않나 싶음. 따라서 올바른 인덱스인지까지 알려주지는 못할 것 같음
    • UUIDv7과 UUIDv4 변환 도구로 https://github.com/ali-master/uuidv47https://github.com/stateless-me/uuidv47이 여러 번 소개된 적 있음
  • 이 조언도 좋지만, 함께 일한 스타트업들은 확장성보다 더 낮은 곳에 있는 조직적 문제부터 부딪혔음. ORM을 쓰지 않고, 의미 있는 필드 대신 순차 증가 기본 키를 사용하며, JSONB는 꼭 필요할 때만 제한적으로 쓰는 편이 좋음
    원천 데이터는 삽입만 가능한 추가 전용으로 두고 수정·삭제하지 않아야 함. 성능과 편의를 위한 비정규화 보조 테이블은 변경해도 되지만 진실의 원천으로 삼으면 안 됨
    연결 풀은 쓰되 연결 수를 주의하고, 문제가 없다면 PgBouncer까지는 필요하지 않을 수 있음. 명확한 이유가 없으면 명시적 트랜잭션을 피하고, 열어둔 상태에서 RPC 같은 장시간 작업을 하면 안 되며 SERIALIZABLE도 거의 사용하지 않는 편이 좋음
    SELECT FOR UPDATE 같은 명시적 잠금이 필요하다면 설계가 잘못됐을 가능성이 있음. type int 값에 따라 한 테이블의 행이 여러 의미를 갖게 만들어 타입 체계를 재발명하거나, 자기 자신을 참조하는 node·edge 테이블로 그래프 데이터베이스를 흉내 내지 말아야 함. 대부분은 일반적인 정규화 테이블로 해결 가능함

    • 작업 중인 PHP 백엔드에서는 권한 검사 등을 위해 객체를 인스턴스화해야 하므로 ORM이 매우 유용함. ORM 없이 구현하면 훨씬 많은 작업이 필요해 보이는데, 왜 나쁜 선택인지 궁금함
    • 개발자 급여가 가장 큰 비용이라면 ORM을 쓰지 말라는 원칙은 논쟁적임. 테이블의 비즈니스 요구, 고객 압박, 빠듯한 예산 아래에서는 DBA와 올바른 설계를 오래 논의하는 동안에도 비용이 계속 타기 때문에, 타입 열이나 그래프형 구조를 피하라는 원칙도 말처럼 쉽지 않음
    • 빠르게 제품을 띄워야 하는 스타트업에 ORM은 충분히 좋은 선택임. N+1 쿼리와 지연 로딩 방식 같은 함정을 이해한다면, 쿼리 관리와 매개변수화를 또 직접 만드는 것보다 나은 절충안임
      프로젝트 초기에 데이터베이스 스키마를 과도하게 고민하고 성급하게 최적화하기보다 제품 개발에 시간을 쓰는 편을 택하겠음
    • SELECT FOR UPDATE를 여러 곳에서 유용하게 썼는데 무엇이 문제인지 궁금함. 추가 전용 진실의 원천을 사용하면 이런 잠금이 필요 없어지는 것인지도 알고 싶음
    • 추가 전용 원천 데이터는 매력적이지만, 작업했던 여러 시스템에서는 의심스러운 이득을 위해 상당수 테이블의 저장량을 폭증시켰을 것임. 유용한 기법이기는 해도 모든 곳에 강제할 원칙인지는 의문임
      반대로 전통적인 변경 가능 관계형 테이블을 진실의 원천으로 두고 트리거로 변경 로그를 기록하는 방식은 어떤지 궁금함
  • 전파 삭제는 싫어함. 대부분의 개발자는 데이터베이스보다 Python, Node, Go 같은 애플리케이션 계층에서 생활하므로, 테이블 A의 행을 지웠더니 테이블 B의 데이터까지 사라지는 전파 삭제는 마법처럼 보이기 쉬움. 잘못 설정되면 더 위험하므로 장기 유지보수에는 명시적인 삭제문이 낫고, 외래 키만 올바르게 써도 일관성을 지킬 수 있음
    대형 테이블 마이그레이션의 함정과 우회법은 맞지만 pg-osc 같은 도구가 이미 있음. 명령 하나를 실행한 뒤 데이터가 복사되는 24시간 동안 긴장하며 관찰하는 정도로 단순해야 함
    애플리케이션과 데이터베이스 배포는 일찍부터 분리해야 함. 스키마와 애플리케이션 변경을 완전히 동시에 트랜잭션으로 배포할 수 없으므로, 운영에 들어가면 새 열을 nullable로 만들거나 기본값을 두고 테이블·열 이름을 바꾸지 않는 등 하위 호환 스키마 변경만 하는 습관이 필요함
    스키마 관리 전략도 일찍 정해야 함. 선임 개발자가 자기 컴퓨터에서 운영 DB에 DDL을 수동 실행하는 배포 절차는 피해야 하며, 익숙한 Liquibase나 Flyway 같은 도구를 사용할 수 있음

    • 선언형 스키마 관리 도구인 pgschema를 만들었음
  • 쿼리 계획기는 평균적인 경우를 최적화하지만 애플리케이션에는 최악의 경우를 최적화하는 편이 더 유용할 때가 있음. 평균 사용자는 행이 적어 특정 인덱스로 10ms 안에 결과가 나왔지만, 사용량이 많은 사용자는 같은 쿼리가 매개변수에 따라 1초 넘게 걸렸음
    더 복잡한 쿼리로 다른 인덱스 경로를 강제해 평균 성능은 조금 느려졌지만 최악의 경우도 100ms 미만으로 줄였음. 회사에는 평균 10ms 절약보다 시간 초과 방지가 훨씬 중요했음

  • SKIP LOCKED는 애플리케이션이 작업하는 동안 트랜잭션을 열고 행을 잠그는 대화형 트랜잭션 기반 작업 큐에 유용함. 고성능 애플리케이션에서는 이런 트랜잭션 자체를 피하고 행을 즉시 pending으로 갱신하면 되므로 SKIP LOCKED가 필요하지 않음
    규모가 커질수록 데이터베이스 메모리에 유지하는 상태를 줄여야 하며, 대화형 트랜잭션도 그런 상태에 해당함. 확장 환경에서는 멱등성이 원자성보다 유리함

  • 장시간 트랜잭션은 데이터베이스 상태를 해칠 수 있으므로 강한 근거가 있을 때만 사용해야 함. idle_in_transaction_session_timeout으로 유휴 트랜잭션이 잠금이나 튜플을 오래 붙잡지 못하게 하고, 마이그레이션에는 lock_timeout을 설정해 DDL 하나가 시스템 전체를 멈추지 않게 해야 함
    비싼 쿼리 하나가 시스템을 마비시키지 않도록 statement_timeout도 설정해야 함

  • 스타트업 초기에 PostgreSQL을 운영해 보니 이 글은 모니터링과 경보를 충분히 강조하지 않음. PostgreSQL에는 반드시 피해야 할 핵심 장애 유형이 몇 가지 있으며, 경보로 위험을 조기에 포착할 수 있음
    AWS가 트랜잭션 ID 순환에 가까워졌다고 이메일을 보내도 스타트업에서는, 특히 박싱 데이 같은 날 쉽게 놓칠 수 있음. AWS가 감시하는 신호를 이메일이 아니라 호출기와 연결해야 함

  • 연결 풀 구현에는 잘 알려지지 않은 큰 차이가 있음. 대부분의 애플리케이션 연결 풀은 선입선출(FIFO) 로 낮은 지연 시간과 연결 가용성을 최적화하지만, 연결을 계속 따뜻하게 유지하므로 불필요한 연결을 줄이기 어려움
    PgBouncer와 일부 외부 풀러는 후입선출(LIFO) 을 사용해 PostgreSQL에 도달하는 연결 수와 처리량을 최적화함. 가장 최근 연결을 먼저 재사용하면 남는 연결은 자연스럽게 식어 종료됨
    새 애플리케이션에는 FIFO로 충분하지만 규모가 커지면 PgBouncer 같은 도구로 수백 개의 연결을 90%가량 줄이는 편이 좋음. 연결마다 프로세스를 만드는 PostgreSQL 구조는 연결 수가 적을수록 더 잘 동작함

  • 아주 특정한 상황에서는 애플리케이션 메모리에서 조인해 좋은 결과를 얻었음. 데이터베이스 왕복을 줄이려다 복잡한 JOIN, UNION, CASE가 뒤엉킨 단일 쿼리를 만드는 경우가 있음
    대신 단순한 쿼리 여러 개를 독립적으로 실행한 뒤 결과를 순회하며 맵으로 관련 행을 연결하면, 왕복과 반복 비용이 추가돼도 쿼리 계획이 더 예측 가능해져 오히려 유리할 수 있음. 제한적으로만 쓰며, 일부 ORM이 내부적으로 이렇게 동작하는 것을 무조건 권장하지는 않음

    • 이 방식의 효과는 상황에 크게 좌우됨. 조인으로 원본보다 훨씬 큰 전체 곱이 만들어진다면 원본 집합만 가져와 로컬에서 조합하는 것이 DB 부하와 네트워크 트래픽을 줄일 수 있음
      하지만 선택적인 내부 조인은 원본보다 훨씬 작은 결과를 만들므로 모든 레코드를 가져와 로컬에서 교집합과 필터링을 수행하는 편이 훨씬 비쌈. 인덱스 조인은 쿼리 계획기가 인덱스를 활용해 무차별 테이블 스캔, 정렬, 필터링을 피할 수도 있음
    • 복잡한 단일 쿼리 대신 뷰 두 개를 만든 뒤 조인하는 방식도 쓰는 것으로 알고 있음