P PG DATA

PostgreSQL 18 테스트 환경과 10만 건 기준 테이블: 건수·통계·실행계획 기준값 만들기

읽는 시간 약 25분
PostgreSQL 18.6 테스트 환경의 기준 테이블 dba_lab.baseline_orders를 소개하는 이미지로, 100,000행, 834페이지, 인덱스 1개, 표본 30,000행이 표시됨

인덱스를 하나 추가했을 때 달라진 숫자는 무엇 때문인가

인덱스를 추가하거나 통계를 바꾼 뒤 숫자가 달라졌다면, 그 차이가 변경 때문인지 데이터나 환경이 달라서인지부터 가려야 합니다. 그러려면 PostgreSQL 테스트가 모두 같은 테이블, 같은 데이터에서 출발해야 합니다. 그 출발점으로 dba_lab.baseline_orders 테이블을 만들고, 이후 테스트에서 그대로 비교할 기준값을 확인했습니다.

기준값을 먼저 표로 두고, 각 값이 어떤 SQL과 조회 결과에서 나왔는지는 아래에서 차례로 설명합니다. 테스트는 Windows 로컬에 설치한 PostgreSQL 18.6에 DBeaver로 접속해 자동 커밋 상태에서 진행했습니다. 운영 환경인 AWS Aurora PostgreSQL과는 별개의 로컬 환경입니다.

baseline_orders 기준값 카드

항목기준값확인한 곳
버전PostgreSQL 18.6 (Windows, 64-bit)version()
접속 위치DB postgres, 사용자 postgres, 기본 스키마 publiccurrent_database(), current_schema()
대상 테이블dba_lab.baseline_orders (스키마를 항상 붙여서 사용)information_schema.tables
행 수100,000건count(*)
테이블 크기834페이지 (페이지당 약 120행)EXPLAIN ANALYZE의 shared hit
주문 상태completed 70,000 · processing 20,000 · pending 10,000GROUP BY order_status
인덱스baseline_orders_pkey 하나 (order_id)pg_indexes
통계입력 직후 수동 ANALYZE, 표본 30,000행pg_stat_user_tables, pg_stats
알고 써야 할 상관관계고객·날짜가 상태를 결정, amount = order_id ÷ 100생성 규칙 계산, 섞인 고객 0명

스키마 이름 pg_dba_lab이 거부된 이유

처음에는 스키마 이름을 pg_dba_lab으로 정했는데, CREATE SCHEMA 단계에서 바로 막혔습니다.

SQL Error [42939]
unacceptable schema name "pg_dba_lab"
The prefix "pg_" is reserved for system schemas.

PostgreSQL은 pg_catalog, pg_toast처럼 시스템 스키마에 pg_ 접두사를 쓰기 때문에, 사용자가 pg_로 시작하는 스키마를 만들 수 없게 해 둡니다. 42939는 예약된 이름(reserved_name)을 썼다는 오류 코드입니다.

권한 문제가 아니라서 슈퍼유저인 postgres로 접속해 있어도 결과는 같습니다. 설정을 바꿀 방법을 찾을 필요 없이 이름을 dba_lab으로 바꾸면 끝납니다. 역할(role) 이름도 마찬가지로, pg_read_all_data 같은 기본 제공 역할과 겹치지 않도록 pg_로 시작하는 이름은 만들 수 없습니다.

객체가 public에 만들어졌던 일과 접속 위치 확인

Aurora PostgreSQL 운영 환경에서 객체를 만들었는데, 의도한 스키마가 아니라 public에 생성된 적이 있습니다. 작업을 마치고 다시 접속해서 그 객체를 조회하니 없다는 메시지가 나와 꽤 당황했습니다. 스키마를 지정하지 않은 CREATE 문은 search_path의 첫 번째 유효한 스키마에 객체를 만들기 때문에 생긴 일이었습니다.

비슷한 일이 데이터베이스 단위에서도 있었습니다. 다른 데이터베이스에 접속한 상태로 테이블을 조회했다가 테이블이 없다는 메시지를 받았고, 접속 데이터베이스를 바꾸고 나서야 정상적으로 조회됐습니다. 한 세션은 접속한 데이터베이스 하나만 보고, dblink나 postgres_fdw를 쓰지 않는 한 다른 데이터베이스의 테이블을 이름으로 참조할 수 없습니다. 그 뒤로는 쿼리를 실행하기 전에 지금 어디에 붙어 있는지부터 확인하고, 이번 테스트도 같은 순서로 시작했습니다.

SELECT
    current_database() AS database_name,
    current_user AS user_name,
    current_schema() AS current_schema,
    version() AS postgresql_version;
DBeaver에서 연결 정보를 조회한 결과로 database_name과 user_name이 postgres, current_schema가 public, 버전이 PostgreSQL 18.6 on x86_64-windows 64-bit로 표시된 화면
접속 데이터베이스와 사용자는 postgres, current_schema는 public이고 버전은 PostgreSQL 18.6 Windows 64-bit입니다.

데이터베이스와 사용자는 postgres, 버전은 PostgreSQL 18.6 on x86_64-windows(msvc-19.44.35229, 64-bit)입니다. 현재 스키마가 dba_lab이 아니라 public으로 나온 것은 정상입니다. 기본 search_path는 "$user", public이고, postgres라는 이름의 스키마가 없으니 다음 순서인 public이 잡힙니다.

운영에서 객체가 public에 생긴 것도 바로 이 구조 때문입니다. 그래서 이번 테스트와 이후 테스트의 SQL은 전부 dba_lab.baseline_orders처럼 스키마 이름을 붙여 씁니다. 같은 이름의 테이블이 다른 스키마에도 있는지는 아래 쿼리로 확인할 수 있습니다. 두 번째 결과에 dba_lab 외의 스키마가 함께 나오면, 스키마를 생략한 SQL은 search_path 순서에 따라 다른 테이블을 읽습니다.

SHOW search_path;

SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_name = 'baseline_orders'
ORDER BY table_schema;

테이블 정의와 매번 같은 값이 나오는 생성 규칙

CREATE SCHEMA IF NOT EXISTS dba_lab;

CREATE TABLE IF NOT EXISTS dba_lab.baseline_orders
(
    order_id     bigint PRIMARY KEY,
    customer_id  integer NOT NULL,
    order_status text NOT NULL
        CHECK (order_status IN ('pending', 'processing', 'completed')),
    amount       numeric(10, 2) NOT NULL,
    created_at   timestamptz NOT NULL
);

CREATE TABLE IF NOT EXISTS는 같은 이름의 테이블이 이미 있으면 생성을 건너뛰기만 합니다. 기존 테이블의 컬럼이나 제약 조건을 지금 정의에 맞게 고쳐 주지 않으니, 예전에 만든 테이블이 남아 있었다면 아래 구조 확인 쿼리를 꼭 다시 봐야 합니다.

데이터는 실행할 때마다 똑같이 나와야 다른 글과 비교할 수 있습니다. 그래서 random()이나 now() 없이 모든 값을 주문 ID 하나에서 계산했습니다.

INSERT INTO dba_lab.baseline_orders
    (order_id, customer_id, order_status, amount, created_at)
SELECT
    g AS order_id,
    ((g - 1) % 10000 + 1)::integer AS customer_id,
    CASE
        WHEN g % 10 = 0 THEN 'pending'
        WHEN g % 10 IN (1, 2) THEN 'processing'
        ELSE 'completed'
    END AS order_status,
    ((g % 500000)::numeric / 100)::numeric(10, 2) AS amount,
    timestamptz '2026-01-01 00:00:00+00'
        - ((g % 90) * interval '1 day') AS created_at
FROM generate_series(1, 100000) AS g
ON CONFLICT (order_id) DO NOTHING;

ANALYZE dba_lab.baseline_orders;
컬럼생성 규칙결과 범위
order_idgenerate_series 값 g1 ~ 100,000
customer_id(g − 1) % 10000 + 11 ~ 10,000 반복, 고객당 10건
order_statusg의 일의 자리0 → pending, 1·2 → processing, 3~9 → completed
amountg % 500000 / 1000.01 ~ 1,000.00
created_at2026-01-01 00:00 UTC − (g % 90)일2025-10-04 ~ 2026-01-01, 90개 날짜

다른 환경에서 똑같이 만들 때 걸릴 수 있는 부분은 세 가지입니다.

  • 주문 시각은 UTC 고정 시각에서 계산합니다. DBeaver 세션 시간대가 Asia/Seoul이면 화면에 09:00으로 보일 뿐, 저장된 값은 같습니다.
  • timestamptz에서 interval '1 day'를 빼는 계산은 세션 시간대의 달력을 따릅니다. 서머타임이 있는 시간대로 접속하면 경계 날짜에서 1시간이 어긋날 수 있으니, 세션 시간대도 함께 기록해 둡니다.
  • ON CONFLICT (order_id) DO NOTHING 덕분에 INSERT를 다시 실행해도 행이 늘지 않습니다. 대신 이미 바뀐 값을 되돌리지도 않으므로, 처음 상태가 필요하면 TRUNCATE 후 다시 넣습니다.

ANALYZE는 입력 직후 직접 실행했습니다. autovacuum도 통계를 갱신하지만 언제 돌지 알 수 없어서, 통계 기준 시점을 직접 정해 두려는 것입니다. 수동 실행 시각은 last_analyze, 자동 실행 시각은 last_autoanalyze에 남습니다.

SELECT relname, n_live_tup, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'dba_lab'
  AND relname = 'baseline_orders';

건수, 컬럼, 인덱스가 정의대로 들어갔는지

상태별 건수와 주문 ID 범위

INSERT에 100,000건을 지정했어도, 이전 실행의 행이 남아 있었다면 ON CONFLICT가 일부를 건너뛰었을 수 있습니다. 결과는 조회로 확인했습니다.

SELECT
    order_status,
    count(*) AS row_count,
    round(count(*) * 100.0 / sum(count(*)) OVER (), 2) AS percent,
    min(order_id) AS min_order_id,
    max(order_id) AS max_order_id
FROM dba_lab.baseline_orders
GROUP BY order_status
ORDER BY order_status;
baseline_orders 상태별 집계 결과로 completed 70,000건, processing 20,000건, pending 10,000건과 상태별 최소·최대 order_id가 표시된 화면
상태별 건수가 completed 70,000, processing 20,000, pending 10,000으로 생성 규칙과 일치합니다.
주문 상태행 수비율최소 order_id최대 order_id
completed70,00070%399,999
pending10,00010%10100,000
processing20,00020%199,992

최소·최대 주문 ID도 규칙과 맞습니다. processing은 일의 자리가 1·2인 ID라 1에서 시작해 99,992에서 끝나고, pending은 10부터 100,000까지입니다. 세 상태 모두 1~100,000 전 구간에 걸쳐 있으니, 상태별 행이 테이블 전체에 고르게 섞여 저장돼 있습니다.

컬럼 정의

SELECT table_schema, table_name, ordinal_position,
       column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'dba_lab'
  AND table_name = 'baseline_orders'
ORDER BY ordinal_position;
information_schema.columns 조회 결과로 order_id bigint, customer_id integer, order_status text, amount numeric, created_at timestamp with time zone이 모두 is_nullable NO로 표시된 화면
다섯 컬럼 모두 is_nullable이 NO이고, amount는 data_type에 정밀도 없이 numeric으로만 표시됩니다.

다섯 컬럼 모두 is_nullable이 NO입니다. order_id는 기본키라서, 나머지는 NOT NULL을 지정해서입니다. amount가 numeric으로만 보이는 건 data_type 컬럼에 정밀도가 나오지 않기 때문이고, numeric(10, 2)까지 보려면 같은 뷰의 numeric_precision, numeric_scale을 함께 조회하면 됩니다. 이번 캡처에는 넣지 않았습니다.

인덱스 구성

SELECT schemaname, tablename, indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'dba_lab'
  AND tablename = 'baseline_orders'
ORDER BY indexname;
pg_indexes 조회 결과로 dba_lab.baseline_orders에 order_id 기준 고유 B-tree 인덱스 baseline_orders_pkey 한 건만 있는 화면
인덱스는 기본키가 자동으로 만든 baseline_orders_pkey 하나뿐입니다.

인덱스는 CREATE UNIQUE INDEX baseline_orders_pkey ON dba_lab.baseline_orders USING btree (order_id) 하나뿐입니다. 기본키를 지정하면 PostgreSQL이 테이블명_pkey 이름으로 자동 생성하는 인덱스입니다. customer_id, order_status, created_at에는 인덱스가 없고, 이후 인덱스 관련 글은 모두 이 상태에서 시작합니다.

플래너가 보는 값은 표본 30,000행에서 나옵니다

count(*)는 실제 행을 세지만, 실행계획을 고르는 플래너는 ANALYZE가 만든 통계를 봅니다. ANALYZE는 테이블 전체가 아니라 표본을 읽고, 표본 크기는 default_statistics_target 기본값 100의 300배인 30,000행입니다. 100,000건 중 30%만 보고 만든 값이라는 뜻입니다.

SELECT attname, n_distinct, most_common_vals, most_common_freqs, correlation
FROM pg_stats
WHERE schemaname = 'dba_lab'
  AND tablename = 'baseline_orders'
ORDER BY attname;
pg_stats 조회 결과로 amount와 order_id의 n_distinct -1과 correlation 1, customer_id의 n_distinct -0.1001과 최빈값 1324, order_status의 n_distinct 3과 completed 비율 0.70203334, correlation 0.54103744가 표시된 화면
표본 30,000행으로 만든 통계에서 customer_id 최빈값으로 고객 1324가 잡혔고, order_status correlation은 0.54였습니다.
컬럼n_distinctmost_common_vals / freqscorrelation실제 데이터
amount-1없음1모든 값 고유, order_id ÷ 100
created_at9090개 날짜 모두, 최고 0.0124666660.008469273날짜당 1/90 ≈ 0.0111
customer_id-0.1001{1324} / 3.0E-40.09714857510,000명, 고객당 10건(0.0001)
order_id-1없음1모든 값 고유
order_status3completed 0.70203334 외 2개0.541037440.7 · 0.2 · 0.1

order_id와 amount의 correlation이 1인 것은 예상대로입니다. 두 값 모두 입력 순서대로 커지고, 행도 그 순서로 저장됐습니다. created_at은 90행마다 날짜가 처음으로 돌아가니 0.0085로 사실상 0입니다.

눈여겨볼 값은 customer_id입니다. n_distinct -0.1001은 전체 행의 10.01%, 약 10,010명으로 추정했다는 뜻이라 실제 10,000명과 거의 같습니다. 그런데 최빈값 목록에 고객 1324 한 명이 3.0E-4로 올라 있습니다. 30,000행 표본에 이 고객이 9번 들어갔다는 계산이 나옵니다. 실제로는 모든 고객이 정확히 10건씩이니, 표본이 우연히 이 고객을 많이 뽑은 것입니다. 이 통계를 그대로 쓰면 플래너는 고객 1324의 주문을 30건 정도로, 다른 고객은 10건 정도로 보게 됩니다. 통계값으로 계산한 숫자이고, 실행계획으로 확인하지는 않았습니다. ANALYZE를 다시 돌리면 표본이 바뀌므로 이 값도 달라질 수 있습니다.

order_status의 correlation 0.54도 처음엔 이상해 보입니다. 세 상태가 10행 주기로 고르게 섞여 있으니 0에 가까울 것 같기 때문입니다. ANALYZE는 표본을 값 순서로 정렬할 때 같은 값끼리는 물리 위치 순서로 줄을 세웁니다. 값 종류가 셋뿐이면 같은 값 안의 순서가 물리 순서와 그대로 맞물려 correlation이 높게 잡히는 것으로 보입니다. 이 계산 과정을 직접 확인하지는 않았습니다. 고유값이 적은 컬럼의 correlation은 고유값이 많은 컬럼과 같은 무게로 읽으면 안 됩니다. completed 비율 0.702가 실제 0.7과 소수 셋째 자리에서 다른 것도 표본 통계에서 생기는 정도의 오차입니다.

생성 규칙 안에 숨어 있는 세 가지 상관관계

규칙으로 만든 데이터는 재현하기 쉬운 대신, 컬럼 사이에 의도하지 않은 강한 관계가 생깁니다. 생성 SQL을 따라 계산해 보면 세 가지가 나옵니다.

  1. 고객이 상태를 정합니다. 고객 ID는 g를 10,000으로 나눈 나머지에서 나오고 10,000은 10의 배수입니다. 그래서 고객 ID의 일의 자리가 곧 g의 일의 자리이고, 한 고객의 10건은 모두 같은 상태입니다. pending 고객 1,000명, processing 2,000명, completed 7,000명입니다.
  2. 날짜도 상태를 정합니다. 90 역시 10의 배수라, 90개 날짜 중 9일은 pending만, 18일은 processing만, 63일은 completed만 있습니다.
  3. 금액은 주문 ID와 같은 순서입니다. g가 최대 100,000이므로 g % 500000은 g 그대로이고, amount는 정확히 order_id ÷ 100입니다. pg_stats의 correlation 1이 이를 보여 줍니다.

첫 번째 관계는 상태가 두 개 이상 섞인 고객 수를 세어 보면 바로 확인됩니다.

SELECT count(*) AS mixed_status_customers
FROM (SELECT customer_id
      FROM dba_lab.baseline_orders
      GROUP BY customer_id
      HAVING count(DISTINCT order_status) > 1) AS t;
상태가 두 개 이상 섞인 고객 수를 센 쿼리 결과로 mixed_status_customers가 0으로 표시된 화면
주문 상태가 두 가지 이상 섞인 고객은 0명으로, 고객 ID가 상태를 정하는 구조임을 보여 줍니다.

결과는 0명입니다. 10,000명 모두 한 가지 상태만 가지고 있습니다.

실제 10행을 1행으로 본 실행계획

PostgreSQL 플래너는 기본적으로 WHERE 조건의 컬럼들이 서로 독립이라고 보고 선택도를 곱합니다. 위의 상관관계를 모르는 상태에서 어떤 값을 내는지 보려고, 주문 10건이 모두 pending인 고객 10으로 두 조건을 같이 걸었습니다.

EXPLAIN ANALYZE
SELECT *
FROM dba_lab.baseline_orders
WHERE order_status = 'pending'
  AND customer_id = 10;
EXPLAIN ANALYZE 결과로 Seq Scan on baseline_orders의 예상 rows=1, 실제 rows=10.00, Rows Removed by Filter 99990, Buffers shared hit=834, Execution Time 3.694 ms가 표시된 화면
독립 가정으로 계산한 예상 rows=1과 달리 실제로는 10행이 나왔고, 834페이지를 모두 읽었습니다.
Seq Scan on baseline_orders  (cost=0.00..2334.00 rows=1 width=35) (actual time=0.009..3.681 rows=10.00 loops=1)
  Filter: ((order_status = 'pending'::text) AND (customer_id = 10))
  Rows Removed by Filter: 99990
  Buffers: shared hit=834
Planning:
  Buffers: shared hit=9
Planning Time: 0.094 ms
Execution Time: 3.694 ms

예상 1행, 실제 10행입니다. 상태 선택도 약 10%와 고객 선택도 약 1/10,000을 곱하면 100,000 × 0.1 × 0.0001 = 1행이 나오니, 독립 가정을 그대로 따른 계산입니다. 실제로는 고객 10의 주문이 전부 pending이라 두 조건은 사실상 하나였습니다.

같은 실행계획에서 기준값으로 남겨 둘 숫자도 있습니다.

  • customer_id에 인덱스가 없어 Seq Scan으로 100,000행을 모두 읽고 99,990행을 버렸습니다.
  • shared hit=834는 테이블 전체가 834페이지라는 뜻이고, 페이지당 약 120행입니다.
  • 비용 2334.00은 기본 비용 상수로 다시 계산해도 정확히 맞습니다. 834페이지 × seq_page_cost 1.0에, 100,000행 × (cpu_tuple_cost 0.01 + 조건 두 개 × cpu_operator_cost 0.0025)를 더하면 834 + 1,500 = 2,334입니다.
  • PostgreSQL 18은 EXPLAIN ANALYZE에 BUFFERS를 지정하지 않아도 버퍼 정보를 보여 주고, 실제 행 수를 rows=10.00처럼 소수 둘째 자리까지 표시합니다. 이전 버전의 실행계획과 비교할 때 참고하세요.

이 쿼리처럼 단일 테이블을 Seq Scan으로 읽을 때는 1행과 10행의 차이가 결과에 영향을 주지 않습니다. 하지만 조인 순서나 조인 방식을 골라야 하는 쿼리라면 이런 과소 추정이 계획 자체를 바꿀 수 있습니다. 상태와 맞지 않는 조합(예: pending과 고객 11)은 실제로 0건이지만 추정은 비슷하게 나올 것으로 보는데, 이 경우는 이번에 실행하지 않았습니다.

이 테이블에 맡길 검증과 맡기지 않을 검증

이 테이블로 확인할 것이 테이블로는 확인하지 않을 것
단일 컬럼 조건에서 인덱스 유무에 따른 차이실제 서비스 수준의 인덱스 범위 검색 비용 (amount가 물리 순서와 완전히 일치)
행 수 변화와 통계 갱신 전후 비교상태·고객, 상태·날짜 조합 선택도를 일반적인 결과로 해석하는 일
다중 컬럼 통계(CREATE STATISTICS) 효과 — 위 상관관계를 일부러 활용값이 한쪽으로 치우친 분포(소수 고객에 주문 집중)가 필요한 주제

실제와 비슷한 분포가 필요한 주제는 데이터를 따로 만들고, 그 생성 규칙과 분포를 함께 공개하겠습니다.

다음 테스트를 시작하기 전에 돌릴 기준값 확인 SQL

이후 테스트에서는 본문에 들어가기 전에 아래 쿼리 하나로 접속 위치와 테이블 상태를 확인하고 결과를 같이 남기겠습니다. 위 기준값 카드와 다르게 나오면 결과 숫자를 비교하기 전에 그 차이부터 정리합니다.

SELECT
    current_database() AS database_name,
    current_schema() AS current_schema,
    (SELECT count(*) FROM dba_lab.baseline_orders) AS row_count,
    pg_relation_size('dba_lab.baseline_orders')
        / current_setting('block_size')::int AS table_pages,
    (SELECT count(*) FROM pg_indexes
      WHERE schemaname = 'dba_lab'
        AND tablename = 'baseline_orders') AS index_count,
    (SELECT last_analyze FROM pg_stat_user_tables
      WHERE schemaname = 'dba_lab'
        AND relname = 'baseline_orders') AS last_analyze;

지금 기준으로는 row_count 100,000, index_count 1이 나와야 합니다. table_pages는 실행계획에서 본 834페이지와 같은 크기를 가리킵니다. UPDATE를 거치면 새 행 버전이 쌓이면서 이 값이 늘어날 수 있고, DELETE 후에는 row_count가 줄어도 table_pages는 그대로 남을 수 있습니다. 두 값이 따로 움직이기 시작하면 테이블 안에 죽은 행 버전이나 빈 공간이 생겼다는 신호로 보고 따로 확인합니다.

PG DBA 노트
AWS Aurora PostgreSQL 환경을 운영하는 현업 DBA입니다. 블로그의 예제는 운영 DB가 아닌 로컬 PostgreSQL 18 테스트 환경에서 직접 실행하고 결과를 확인한 뒤 작성하며, 운영에서 판단할 때 필요한 기준을 함께 정리합니다.

함께 보면 좋은 글

댓글 0

첫 댓글을 남겨보세요.

광고 차단 알림

광고 클릭 제한을 초과하여 광고가 차단되었습니다.

단시간에 반복적인 광고 클릭은 시스템에 의해 감지되며, IP가 수집되어 사이트 관리자가 확인 가능합니다.