3P by GN⁺ | ★ favorite | 댓글 1개
  • 저장 공간 부족 경고를 계기로 PostgreSQL 정리를 진행했고, 인덱스나 데이터를 삭제하지 않고 70GB 이상의 비최적화·미사용 공간을 확보함
  • 처음에는 미사용 인덱스, 테이블·인덱스 bloat, REINDEX CONCURRENTLY, pg_repack, PostgreSQL 13의 B-Tree deduplication 같은 일반적인 회수 기법을 점검함
  • 결정적 차이는 PostgreSQL이 Oracle과 달리 NULL 값도 인덱싱한다는 점이었고, NULL이 대부분인 cancelled_by_user_id 전체 인덱스를 partial index로 바꾸자 769MB가 5MB 미만으로 줄어듦
  • pg_stats.null_frac가 높은 단일 컬럼 인덱스를 찾아 WHERE column IS NOT NULL 조건의 partial index 후보를 선별하고, 통계 카운터 리셋 뒤 pg_stat_all_indexes로 실제 사용 여부를 확인함
  • Django에서는 nullable ForeignKey가 기본 B-Tree 인덱스를 만들 수 있으므로 db_index=False를 명시하고 models.Index(..., condition=Q(...__isnull=False))로 부분 인덱스를 직접 정의해야 함

저장 공간 부족 경고에서 시작된 정리

  • 몇 달마다 데이터베이스 모니터링에서 저장 공간 부족 경고가 발생했고, 평소에는 스토리지를 추가해 대응함
  • 당시에는 격리 기간으로 시스템 부하가 낮아, 평소보다 부담이 큰 정리 작업을 시도하기 좋은 상황이었음
  • 결과적으로 인덱스를 삭제하거나 데이터를 지우지 않고 70GB 이상의 비최적화·미사용 공간을 확보함
  • 일반적인 인덱스·테이블 재구성만으로도 많은 공간을 줄였고, 이후 예상 밖의 발견으로 약 20GB의 미사용 인덱스 값을 추가로 제거함

먼저 의심한 원인들

  • 미사용 인덱스

    • 미사용 인덱스는 조회 성능을 위해 만들지만, 공간을 차지하고 INSERTUPDATE를 느리게 만들 수 있음
    • pg_stat_all_indexes에서 idx_scan = 0, idx_tup_read = 0, idx_tup_fetch = 0인 인덱스를 찾아 마지막 통계 리셋 이후 스캔되거나 fetch되지 않은 인덱스를 확인함
    • 겉으로는 미사용처럼 보여도 제거하면 안 되는 경우가 있음
      • PostgreSQL 옵티마이저가 인덱스 자체가 아니라 메타데이터를 사용하는 경우
      • 오래 업데이트되지 않은 테이블의 unique 또는 primary key 제약을 보장하는 인덱스
    • 실제 삭제 가능한 인덱스인지는 목록을 하나씩 검토해야 하며, 통계 카운터는 점검 후 리셋하는 방식으로 관리함
    • 이 사례에서는 주기적으로 같은 작업을 해왔기 때문에 삭제할 미사용 인덱스가 없었음
  • 인덱스와 테이블 bloat

    • PostgreSQL은 행을 업데이트할 때 기존 tuple을 dead로 표시하고 새 tuple을 다른 공간에 추가하므로 bloat가 생길 수 있음
    • bloat는 테이블뿐 아니라 인덱스에도 영향을 주며, 실제 필요한 크기보다 더 많은 저장 공간을 쓰게 만듦
    • bloat 추정은 단순하지 않아 PostgreSQL 위키와 pgsql-bloat-estimation의 쿼리로 테이블과 B-Tree 인덱스 bloat를 추정함

인덱스 bloat를 줄이는 방법

  • 인덱스 재생성·재구성

    • 인덱스 bloat를 제거하려면 인덱스를 다시 만들어야 함
    • 직접 drop 후 create하면 최적화된 형태로 새 인덱스를 만들 수 있음
    • PostgreSQL의 REINDEX는 기존 인덱스를 제자리에서 재구성함
    • 일반적인 재생성 방식은 테이블에 lock을 걸어 변경을 막기 때문에, 운영 환경에서는 REINDEX INDEX CONCURRENTLY index_name 방식이 더 적합함
    • REINDEX CONCURRENTLY_ccnew 접미사를 가진 새 인덱스를 만들고 변경분을 동기화한 뒤, 완료 시 기존 인덱스와 교체하고 예전 인덱스를 삭제함
    • 중간에 재구성이 중단되면 새 인덱스가 invalid 상태로 남아 공간을 차지할 수 있으므로, _ccnew이면서 indisvalid가 아닌 인덱스를 찾아 제거해야 함
  • PostgreSQL 13의 B-Tree deduplication

    • PostgreSQL 13은 B-Tree 인덱스에서 중복 값을 더 효율적으로 저장하는 B-Tree Deduplication을 도입함
    • PostgreSQL 12까지는 중복 값이 많아도 각 중복 값이 인덱스 leaf에 반복 저장되어 공간을 많이 사용할 수 있었음
    • PostgreSQL 13에서 deduplication이 활성화되면 중복 값을 한 번만 저장하므로, 중복 값이 많은 인덱스 크기에 큰 영향을 줄 수 있음
    • PostgreSQL 13에서는 deduplicate_items = ON이 기본값이며, 이전 버전에서 업그레이드한 경우 전체 효과를 얻으려면 REINDEX로 인덱스를 재구성해야 함
    • 100만 행 예시에서 중복이 많은 비고유 컬럼 인덱스는 deduplication 적용 시 21MB에서 6840kB로 줄었고, 고유 컬럼 인덱스는 21MB로 변화가 없었음
    • 이 사례에서는 PostgreSQL 13이 아직 새 버전이었고 클라우드 제공자가 지원하지 않아 deduplication으로 공간을 줄일 수 없었음

테이블 bloat와 pg_repack

  • 테이블 bloat 제거의 제약

    • 테이블도 dead tuple로 인해 bloat와 단편화가 생길 수 있음
    • 인덱스와 달리 테이블은 단순히 다시 만들기 어렵고, 새 테이블 생성, 데이터 이관, 동기화, 인덱스·제약·참조 제약 재생성이 필요함
    • VACUUM FULL table_name은 bloat와 dead tuple이 차지한 공간을 회수할 수 있지만 테이블 lock을 필요로 함
    • 테이블 재생성과 VACUUM FULL은 각각 큰 개발 작업이나 다운타임으로 이어질 수 있음
  • pg_repack 사용

    • pg_repack은 다운타임 없이 테이블과 인덱스를 재구성하는 데 쓰이는 PostgreSQL 확장임
    • 사용하려면 CREATE EXTENSION pg_repack;로 확장을 생성하고, 콘솔에서 pg_repack -k --table table_name db_name 형태로 실행함
    • pg_repack은 새 테이블을 만들고 원본 데이터를 적재하면서 새 데이터와 동기화한 뒤, 인덱스도 재구성하고 마지막에 두 테이블을 교체함
    • 운영에 적용할 때는 몇 가지 제약이 있음
      • 재구성 대상 테이블과 인덱스 크기만큼의 추가 저장 공간이 대략 필요함
      • repack이 실패하거나 수동 중단되면 중간 객체가 남아 수동 정리가 필요할 수 있음
      • 이미 저장 공간이 부족한 상태에서는 추가 공간 요구 때문에 적합하지 않을 수 있으므로, 여유 공간을 모니터링하고 미리 계획해야 함

예상 밖의 발견: PostgreSQL은 NULL도 인덱싱함

  • 일반적인 기법으로 미사용 인덱스 제거와 테이블·인덱스 bloat 정리를 마친 뒤에도 줄일 공간이 남아 있었음
  • 가장 큰 테이블 중 하나는 거래 데이터를 저장했고, 결제 후 사용자가 취소해 환불받는 경우도 기록함
  • 거래 테이블에는 구매 사용자와 취소 사용자 모두에 대한 foreign key가 있었고, 각 필드에 B-Tree 인덱스가 정의되어 있었음
    • 구매 사용자는 NOT NULL 제약이 있어 모든 행에 값이 있음
    • 취소 사용자는 nullable이고, 취소가 드물어 대부분의 값이 NULL
  • 취소 사용자 인덱스는 구매 사용자 인덱스보다 훨씬 작을 것으로 예상했지만 실제 크기가 같았음
  • Oracle에서는 NULLs are not indexed로 배웠지만, PostgreSQL에서는 NULL 값도 인덱싱됨
  • 기존 전체 인덱스는 다음과 같았음
CREATE INDEX transaction_cancelled_by_ix
ON transactions(cancelled_by_user_id);
  • NULL 값을 제외하는 partial index로 바꿔 가설을 확인함
DROP INDEX transaction_cancelled_by_ix;

CREATE INDEX transaction_cancelled_by_part_ix
ON transactions(cancelled_by_user_id)
WHERE cancelled_by_user_id IS NOT NULL;
  • 재인덱싱 후 전체 인덱스 크기는 769MB였고 값의 99% 이상이 NULL이었음
  • NULL을 제외한 partial index는 5MB 미만이었고, 인덱스의 99% 이상을 줄인 결과가 됨
  • 테이블 통계를 리셋하고 일정 시간 관찰한 결과 새 인덱스가 기존 인덱스처럼 사용되었고, 성능 저하 없이 760MB 이상의 미사용 indexed tuple을 제거함

partial index 후보 찾기

  • 한 partial index에서 좋은 결과를 얻은 뒤, 비슷한 인덱스를 찾기 위해 pg_stats.null_frac가 높은 컬럼의 인덱스를 검색함
  • null_frac는 PostgreSQL이 추정한 해당 컬럼 값 중 NULL 비율
  • 후보 검색 쿼리는 다음 조건을 적용함
    • primary key는 partial이 될 수 없으므로 제외
    • 이미 partial index인 경우 제외
    • composite index는 제외
    • 크기가 10MB 초과인 인덱스만 포함
  • 결과 예시에서는 tx_cancelled_by_ix가 1418MB이고 null_frac가 96.15%라 약 1363MB 절감 가능성이 있는 후보로 나타남
  • 모든 NULL을 인덱스에서 제외하는 방식이 항상 유리한 것은 아님
    • IS NULL 조건으로 NULL을 찾는 쿼리는 NULL 값에 대한 인덱스 이점을 받을 수 있음
    • NULL이 아닌 값뿐 아니라 거의 조회하지 않는 다른 값도 partial index로 제외할 수 있음
  • 제목의 20GB는 primary 데이터베이스에서 약 10GB를 줄이면 replica에서도 비슷한 저장 공간이 함께 줄어드는 구조 때문에 가능했음

Django ORM에서 partial index로 마이그레이션하기

  • nullable ForeignKey와 암묵적 인덱스

    • Django는 db_index=False를 명시하지 않으면 models.ForeignKey 필드에 B-Tree 인덱스를 암묵적으로 생성
    • 취소 사용자를 저장하는 nullable ForeignKey에 아무 설정을 하지 않으면 Django가 전체 인덱스를 생성함
    • partial index를 만들려면 FK 필드에 db_index=False를 지정하고, Meta.indexes에 조건부 models.Index를 추가함
    class Transaction(models.Model):
        cancelled_by_user = models.ForeignKey(
            to=User,
            null=True,
            on_delete=models.CASCADE,
            db_index=False,
        )
    
        class Meta:
            indexes = (
                models.Index(
                    fields=('cancelled_by_user_id', ),
                    name='%(class_name)s_cancelled_by_part_ix',
                    condition=Q(cancelled_by_user_id__isnull=False),
                ),
            )
    
    • nullable foreign key는 partial index 후보가 되기 좋음
    • 암묵적 기능이 모르게 인덱스를 만들지 않도록, Django check를 만들어 foreign key에서 db_index를 항상 명시하게 강제함
  • 다운타임 없이 전체 인덱스를 partial index로 바꾸기

    • 전체 인덱스를 partial index로 교체할 때 다운타임이나 성능 저하를 피하려면 단계적으로 진행해야 함
    • 관련 Django 모델을 수정해 전체 인덱스를 partial index로 바꾸되, Django가 생성한 마이그레이션은 그대로 실행하지 않음
    • 해당 마이그레이션은 FK 제약을 비활성화하고 기존 전체 인덱스를 drop한 뒤 새 partial index를 만들 수 있어 다운타임과 성능 저하를 유발할 수 있음
    • ./manage.py sqlmigrate로 SQL을 만들고 CREATE INDEX만 추출해 CONCURRENTLY로 수정한 뒤 데이터베이스에서 수동 실행함
    • 기존 전체 인덱스가 아직 남아 있으므로, partial index를 만드는 동안 쿼리는 기존 인덱스를 계속 사용할 수 있음
    • 새 partial index가 사용되는지 확인하기 위해 전체 인덱스 통계 카운터를 pg_stat_reset_single_table_counters(<full index oid>)로 리셋함
    • 이후 pg_stat_all_indexesidx_scan, idx_tup_read, idx_tup_fetch를 관찰하며 전체 인덱스와 partial index 사용을 비교함
    • partial index가 사용된다고 판단되면 전체 인덱스를 drop하고, 실제 확보할 저장 공간을 확인함
    • 데이터베이스 상태가 모델 상태와 맞아진 뒤 ./manage.py migrate --fake로 Django 마이그레이션을 실행된 것으로 등록함
    • dev, QA, staging처럼 다운타임 고려가 적은 환경에서는 Django 마이그레이션이 정상 실행되어 전체 인덱스가 partial index로 교체됨

최종 정리

  • 디스크, 저장 파라미터, 설정 최적화만으로는 성능과 공간 개선에 한계가 있음
  • 마지막 개선 여지는 데이터베이스 객체 자체, 이 사례에서는 인덱스 정의를 바꾸는 데 있었음
  • 적용한 흐름은 세 단계로 압축됨
    • 미사용 인덱스 제거
    • 테이블과 인덱스 repack, 가능하면 B-Tree deduplication 활성화
    • 필요한 값만 인덱싱하도록 partial index 활용
  • 이런 정리는 추가 스토리지를 프로비저닝하기 전까지 며칠 더 여유를 만드는 실질적인 선택지가 될 수 있음

댓글과 토론

Hacker News 의견들
  • 저장공간이 많아도 인덱스를 작게 만드는 것은 좋음. 그래야 더 많은 데이터가 핫셋에 들어갈 수 있음
    다만 TB 단위 데이터베이스를 운영하는 입장에서는 “그냥 저장공간을 더 붙이면 된다”도 항상 유효한 선택지임. 특히 클라우드 밖이라면 더 그렇고, 자체 하드웨어에서는 엔터프라이즈 NVMe SSD가 약 $80/TB, DDR4 RAM은 약 $1.20/GB 수준임. 엔지니어링 4시간 비용을 대략 $1000로 보면 RAM 800GB나 저장공간 12TB를 살 수 있음

    • 명목 용량과 실제로 전체 시스템에 구매해야 하는 용량 사이에는 배수 효과가 있음. 1TB만 사는 게 아니라 최소한 라이브 장애조치 서버 2대 이상이 필요하고, 백업과 재해복구 계층도 여러 개 필요함
      1년치 주간 백업만 해도 1TB가 50TB가 되고, 오프라인 저장소여도 마찬가지임. 우리 회사도 라이브 DB에 저장공간을 더 붙이는 건 쉬워서 몇 년간 그렇게 해왔지만, 멀티 TB 백업을 옮기는 일이 점점 번거로워져서 엔지니어링 비용을 들이더라도 프로덕션 데이터를 줄여야 하는 상황임
    • 이 비교 방식은 별로 좋지 않음. 엔지니어링 시간은 한 번 지불하면 문제가 해결될 가능성이 있지만, SSD 10개는 랙 공간, 전기, PCIe 슬롯, 제때 교체, 관리 소프트웨어가 필요하고 대부분은 반복 비용이 됨
      한 번만 하는 경우 기존 인프라가 빈 랙 공간이나 여분 PCIe 슬롯 같은 비용을 흡수할 수도 있지만, 이런 상각은 소규모에서만 잘 작동함. 또 시스템이 커질수록 관리 비용과 지연시간 증가 때문에 장비 단위당 성능은 필연적으로 떨어짐. 계속 시스템을 키워서 해결하면 전체 시스템은 점점 둔해지다가 운영 불가능해질 수 있음. 반대로 필요한 시스템 자원을 줄이는 해법은 단위 자원당 성능을 높이고, 그 자체로 더 품질 좋은 자산을 만듦
    • 규모와 복잡도에 따라 다르지만, 자원 사용량을 통제하지 않으면 비용이 기하급수적으로 증가할 수 있음. 심지어 비즈니스가 성장하지 않아도 요구사항이 더 복잡해지면서 그렇게 될 수 있음
      어떤 최적화로 오늘 1TB를 아껴도 몇 년 뒤에는 2TB를 아끼게 될 수 있고, 이런 결정 몇 개만으로도 한 자릿수 이상 차이가 날 수 있음. 더 큰 규모에서는 특히 그렇고, 결국 항상 균형을 잡아야 함
    • $1000로 현재 서버에 들어갈 법한 RAM을 800GB 더 사기는 어려움. 그런 RAM은 보통 $3~6/GB 정도이고, 인증 안 된 제품은 $3, 메인보드 제조사 인증 RAM은 $6쯤 함. 기본 RAM 구성 대비 DIMM 슬롯 비용도 $0.50~$1/GB 정도 추가됨
      “하드웨어를 던져 넣는” 선택이 가끔 현명할 수는 있지만, 대부분의 경우 오늘 128GB RAM 서버를 내일 1TB RAM으로 $1K에 바꾸기는 어렵다고 봄
    • 클라우드에서도 저장공간을 덜 쓰는 구체적인 아이디어가 없다면 보통 저장공간을 더 프로비저닝하는 게 답임
      다만 한 번 지출이 아니라 월 비용이라 계산이 조금 더 복잡함. 물론 데이터베이스가 일정한 속도로 성장한다면 온프레미스의 일회성 프로비저닝도 사실상 월 비용에 가까움
  • 글에서는 PostgreSQL 13에는 있지만 사용 중인 12에는 없는 B-tree 중복 제거를 언급하면서, 일부 외래 키 인덱스 값의 대다수가 NULL이라고 함
    이 경우 B-tree 중복 제거가 도움이 됐을지 궁금함. PostgreSQL 13 문서의 63.4.2를 보면 그럴 것처럼 보임: “B-Tree deduplication is just as effective with ‘duplicates’ that contain a NULL value...” 물론 글에서 적용한 부분 인덱스만큼 효과적이진 않겠지만 흥미로움
    https://www.postgresql.org/docs/13/btree-implementation.html

  • 이 글은 핵심인 부분 인덱스 “발견”뿐 아니라, PostgreSQL에서 공간을 비효율적으로 쓰는 게 걱정될 때 알아둘 좋은 기법들을 전반적으로 정리해줘서 유용했음
    다만 스타트업이나 초기 단계 회사라면 이런 저장공간 문제는 크기 최적화에 신경 쓰기보다 디스크 공간을 더 붙이는 선택이 거의 항상 낫다는 점은 기억할 만함. 개발자는 비싸고 디스크는 저렴함

    • 이 조언은 좋음. 초반에는 일반적으로 가능한 한 단순하게 유지하는 편이 낫음
      예전에 빠르게 성장하던 스타트업에서 일할 때 창업자 중 한 명이 서버 한 대만 계속 업그레이드하자고 고집했는데, 중복성과 백업은 갖추되 아키텍처가 단순해서 관리, 디버깅, 복구가 쉬웠음. 회사 첫 5년 동안 전체 시스템이 서버 한 대에서 돌아가면서도 지수적으로 성장하고 전 세계 수백만 사용자를 처리했음. 그걸 본 뒤로는 필요할 때만, 가장 단순하고 직선적인 방식으로 업그레이드해야 한다고 확신함
    • 저장공간만 더 붙이면 된다는 쪽으로 많이 강조되지만, 불필요한 인덱싱은 쓰기와 읽기 성능에도 영향을 주고 경우에 따라 상당히 큼
      값 대부분이 NULL인 경우처럼 사용 사례와 명확히 맞는 부분 인덱스를 쓰는 건 올바른 모델링에 가깝고, 성급한 최적화나 개발자 시간 낭비로 봐서는 안 됨
  • 비슷한 맥락에서 이 스크립트들이 꽤 도움이 됐음
    https://github.com/NikolayS/postgres_dba
    큰 테이블의 컬럼 순서를 재구성해서 저장공간의 약 10%, 즉 100GB 정도를 확보할 수 있었음

  • 지난주에 reindexpg_repack만으로 600GB DB에서 200GB를 확보했음. 가장 심한 테이블은 데이터가 17GB인데 인덱스가 142GB였고, 재인덱싱 후 21GB로 줄었음
    이 테이블은 인덱싱이 과도해서 여러 컬럼 조합에 대한 인덱스가 여럿 있었음. 거대한 인덱스의 원인 중 하나는 데이터 분포였던 것 같음. 2015년부터 삽입, 갱신, 삭제가 계속 있었고 오래된 데이터일수록 삭제될 가능성이 높아 최근 연도 데이터가 더 많지만, 여전히 약 0.1%는 2015년 데이터임. 이런 긴 꼬리의 치우친 분포 때문에 vacuum이 인덱스 팽창을 처리하기 더 어려웠을 수 있다고 봄

  • 당시에도 논의됐음: An unexpected find that freed 20GB of unused index space in PostgreSQL - https://news.ycombinator.com/item?id=25988871 - 2021년 2월, 댓글 78개

  • 사용하지 않는 인덱스, 최적화 기회, 지연시간이 큰 쿼리를 찾는 데 pganalyze.com을 강력히 추천함

  • 정말 “이상한 트릭 하나로 20GB 절약”이라는 말이 과장이 아니었음. 오래된 Oracle / SQL Server 현장에서는 거의 들어보지 못한 PostgreSQL의 특이한 인덱스 형태를 쓰고 싶다는 요청을 많이 받는데, NULL 값도 인덱싱한다는 건 몰랐음

    • 그게 새롭다는 말을 들으니 내가 생각 못 했던 것도 좀 안심됨. 우리도 같은 방식으로 처리할 수 있는 큰 인덱스가 몇 개 있을 것 같음
  • 처음 참여한 대규모 프로젝트에서 데이터셋이 커지면서 왜 작업이 느려졌는지 팀이 파악하지 못했음
    인덱스는 레코드당 삽입 시간이 log(n) 임. 테스트 DB에 레코드가 1000개였다가 65k에 가까워지면 삽입 시간이 60% 늘어남(2^10 대 2^16). 성공이 모든 것을 느리게 만들고, 서버 업그레이드에도 한계가 있음. 비즈니스가 원한 모호한 기능 때문에 인덱스를 몇 개 더 추가하면 이제 두 배가 됨

    • 아주 작은 수치가 두 배가 되는 것뿐임. 그게 정말 문제라면 더 빠른 디스크를 쓰면 됨. 2015년 내 PC보다 IOPS가 낮은 EBS에서 DB를 돌리려는 경우가 너무 많음
      수억 레코드에 테이블/컬렉션당 인덱스 40개 이상인 DB도 많이 관리하고 있음
    • 그런 작은 인덱스에서는 삽입이 여전히 매우 빨라야 하지 않나 싶음
      B-tree 페이지 안에서 이진 검색은 100사이클 미만임. 1억 레코드에 대한 B-tree 탐색도 여전히 마이크로초 단위여야 하고, 그 위의 이진 검색도 수백 나노초가 아니더라도 마이크로초 단위일 것임
  • 값 하나나 몇 개가 매우 흔한 경우에도 이 원리가 적용될까? 예를 들어 정수 컬럼의 90%가 0인 경우

    • 맞음. Postgres는 가장 흔한 값을 추적함. 0이 흔하고 where val = 0이 행의 90%를 남긴다는 걸 알면, 인덱스 대신 테이블 스캔을 선택할 수 있음
    • 인덱스는 고유한 값 집합을 나타낼 때 가장 효율적임. 그룹화에도 여전히 유용하지만, 그룹이 극단적으로 적은 비율만 나타낸다면 인덱스를 뒤지는 데 공간과 사이클을 많이 낭비하게 됨
    • 0인 경우와 0이 아닌 경우에 대해 별도 인덱스를 만들 수 있지 않을까?
    • 공통 값을 NULL로 표현한 경우에만 그렇고, 일반적으로는 좋은 생각이 아님