id < 11에는 인덱스를 사용하고 id > 11에는 순차 스캔을 수행할 수 있는 이유는 첫 번째 조건은 매우 적은 행과 일치하고 두 번째 조건은 테이블의 거의 전체 행과 일치하기 때문입니다. 두 연산자 모두 B-tree 인덱스를 지원하며, 플래너는 예상 비용을 비교합니다.

이 가이드는 Mac에서 작업하는 개발자가 인공적으로 만든 기사 행으로 이러한 선택을 재현하고, 실행 계획을 읽고, 차이에 주의가 필요한지 확인하는 데 도움을 줍니다. 검색된 최신 PostgreSQL 18 문서와 최신 node-postgres 문서를 따릅니다. 실행 계획 세부 사항을 비교하기 전에 서버 버전을 확인하세요. 예제가 여러 버전이나 컴퓨터에서 동일한 실행 계획을 보장하지는 않습니다. PostgreSQL: EXPLAIN 사용하기

동일한 인덱스에서 스캔 방식이 달라지는 이유는 무엇인가요?

B-tree 인덱스는 미만 및 초과 비교를 모두 지원합니다. 비교 방향을 바꿔도 인덱스를 사용할 수 없게 되는 것은 아닙니다. 달라지는 것은 값의 범위와, 그에 따라 플래너가 가져올 것으로 예상하는 행의 수입니다. PostgreSQL: 인덱스 유형

연속된 정수 ID가 1부터 100,000까지인 기사 행 100,000개가 정확히 들어 있는 예시 테이블을 생각해 보겠습니다.

조건식 일치하는 행 테이블에서 차지하는 비율
id < 11 10 0.01%
id > 11 99,989 99.989%
id = 11 1 0.001%

이 개수는 기존 데이터베이스를 측정한 값이 아니라 인공적으로 만든 입력에 따른 것입니다. 어느 범위에도 ID 11은 포함되지 않습니다. 인덱스는 첫 번째 범위에서 일치하는 ID 10개를 효율적으로 찾을 수 있습니다. 두 번째 범위에서는 어차피 거의 모든 행을 찾아야 합니다.

일반적인 PostgreSQL 인덱스 스캔은 인덱스에서 항목을 가져온 다음, 테이블의 주 저장 공간인 힙에서 해당 행을 가져옵니다. 힙 행은 여러 페이지에 흩어져 있을 수 있습니다. 거의 모든 페이지가 필요한 경우에는 인덱스를 탐색하고 인덱스가 가리키는 행을 가져오는 것보다 테이블을 직접 읽는 비용이 더 적을 수 있습니다. PostgreSQL: 인덱스 전용 스캔과 커버링 인덱스

따라서 숫자 11은 플래너에서 특별한 의미가 없습니다. 데이터가 다르면 초과 조건이 좁은 범위일 수도 있습니다. PostgreSQL은 테이블 통계를 사용해 일치하는 행의 비율, 즉 선택도를 추정합니다. 경계값의 크기만으로는 그 비율을 알 수 없습니다. PostgreSQL: 플래너가 사용하는 통계

Mac에서 이 비교를 재현하려면 어떻게 하나요?

PostgreSQL 연결과 Mac의 Terminal에서 사용할 수 있는 psql 명령이 필요합니다. 설치는 이 실습의 범위에 포함되지 않습니다. 평소 사용하는 데이터베이스 연결을 psql로 여세요. 이 명령은 연결 설정을 받고 대화형 SQL 프롬프트를 제공합니다. 새 세션 하나에서 다음 블록을 실행하세요. PostgreSQL: psql

다음 설정은 인공적인 기사 텍스트만 포함하는 임시 테이블을 만듭니다. 임시 테이블은 세션이 끝나면 사라집니다. 일반 정수 기본 키는 고유 B-tree 인덱스를 자동으로 생성하므로 id에 인덱스를 하나 더 만들 필요는 없습니다. PostgreSQL: CREATE TABLE

SHOW server_version;

CREATE TEMP TABLE article_scan_demo (
    id integer PRIMARY KEY,
    title text NOT NULL,
    body text NOT NULL
);

INSERT INTO article_scan_demo (id, title, body)
SELECT
    g,
    'Article ' || g,
    repeat('Sample article text. ', 20)
FROM generate_series(1, 100000) AS s(g);

ANALYZE article_scan_demo;

SELECT
    count(*) AS total_rows,
    count(*) FILTER (WHERE id < 11) AS below_11,
    count(*) FILTER (WHERE id > 11) AS above_11
FROM article_scan_demo;

PostgreSQL의 정수 generate_series은 기본 간격이 1일 때 양 끝값을 모두 포함합니다. 따라서 개수 확인 쿼리 결과는 전체 행 100,000개, 11 미만 10개, 11 초과 99,989개여야 합니다. PostgreSQL: 집합 반환 함수

행을 채운 뒤 ANALYZE을 실행해 플래너가 채워진 테이블의 통계를 확보하도록 하세요. 이 명령은 통계를 수집하며, EXPLAIN ANALYZE의 실행 측정 옵션과는 목적이 다릅니다. PostgreSQL: ANALYZE

그다음 두 전체 행 쿼리를 비교하세요.

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM article_scan_demo WHERE id < 11;

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM article_scan_demo WHERE id > 11;

이 명령은 각 쿼리를 실행하고 실행 계획과 실제 행 개수를 보여 주지만, 선택된 기사 행 전체를 반환하지는 않습니다. BUFFERS는 블록 활동을 추가하며, TIMING OFF는 개별 노드의 시간 측정을 비활성화합니다. 실행 동작을 살펴보는 데 유용하지만, 일치하는 기사 데이터를 모두 데스크톱 애플리케이션으로 전송하는 데 필요한 시간은 측정하지 않습니다. PostgreSQL: EXPLAIN

일반적인 설정에서는 좁은 범위에 인덱스 기반 실행 계획이, 넓은 범위에 순차 스캔이 선택될 것으로 예상하세요. 다만 이는 예상일 뿐입니다. PostgreSQL은 추정치에 따라 비트맵 계획이나 다른 적합한 계획을 선택할 수 있습니다. 예제의 SQL은 검토했지만 준비 과정에서 PostgreSQL 서버로 실행하지는 않았습니다. 행 개수 계산은 별도로 확인했습니다.

실행 계획의 어떤 부분이 선택 이유를 설명하나요?

스캔 노드와 예상 rows, 실제 rows부터 살펴보세요. 첫 번째 쿼리에서는 Index Cond가 있는 Index Scan이나 Bitmap Heap Scan 아래의 Bitmap Index Scan을 찾으세요. 두 번째 쿼리에서는 Filter가 있는 Seq Scan을 찾으세요. 비트맵 계획도 인덱스를 사용합니다. 힙 페이지를 가져오기 전에 일치하는 행 위치를 정리하는 방식입니다. PostgreSQL: EXPLAIN 사용하기

그다음 다음 사항을 확인하세요.

이 예제의 관찰 결과 조사할 내용
예상치가 실제 행 10개와 99,989개에 대략 일치함 일치하는 행 수의 차이가 서로 다른 선택을 설명합니다.
예상 행 수가 실제 행 수와 크게 다름 플래너 설정을 바꾸기 전에 통계를 새로 고치세요.
훨씬 작은 샘플에서 두 조건 모두 순차 스캔을 사용함 테이블 크기 때문에 직접 스캔이 더 저렴할 수 있습니다.
비트맵 계획이 나타남 인덱스 기반 대안으로서 행 추정치와 힙 작업량을 비교하세요.

플래너 비용은 밀리초가 아니라 임의 단위의 추정치입니다. 비용 100을 실행 시간으로 해석하지 말고 PostgreSQL 모델의 일부로 비교하세요. 이 예제는 임시 테이블을 사용하므로 버퍼 활동은 로컬 블록으로 표시되며, 일반 테이블 및 인덱스 버퍼는 공유 블록으로 표시됩니다. PostgreSQL: EXPLAIN

추정치가 예상과 다르면 같은 세션에서 다음 진단 쿼리를 실행하세요.

SELECT
    schemaname,
    n_distinct,
    histogram_bounds,
    correlation
FROM pg_stats
WHERE schemaname = (
    SELECT nspname
    FROM pg_namespace
    WHERE oid = pg_my_temp_schema()
)
  AND tablename = 'article_scan_demo'
  AND attname = 'id';

히스토그램은 범위 추정에 사용되는 값 분포를 나타냅니다. 상관관계는 열의 논리적 순서가 행의 물리적 순서를 얼마나 밀접하게 따르는지 나타냅니다. 어느 한쪽 극단에 가까운 값은 힙에 접근하는 인덱스 스캔의 비용을 더 낮게 보이게 할 수 있습니다. 어느 필드도 PostgreSQL이 반드시 스캔 방식을 전환해야 하는 보편적인 비율을 정하지는 않습니다. PostgreSQL: pg_stats

node-postgres로 쿼리를 비교하려면 어떻게 하나요?

node-postgres는 쿼리 텍스트와 매개변수 값을 PostgreSQL로 전송하며, 계획 수립은 PostgreSQL에서 이루어집니다. 쿼리 API는 SQL 뒤에 값 배열을 받으므로 두 비교 모두 경계값 11을 사용할 수 있습니다. node-postgres: 쿼리

독립적인 Node.js 재현을 위해 정상적으로 작동하는 Node.js/npm 설치와 기존 PostgreSQL 연결 설정을 사용하세요. 새 프로젝트 디렉터리에서 npm init -y를 실행한 다음 npm install pg를 실행하세요. 아래 전체 프로그램을 compare-scans.mjs로 저장하고 node compare-scans.mjs를 실행하세요. 클라이언트는 PGHOST, PGPORT, PGDATABASE 등의 PostgreSQL 환경 설정을 사용합니다. 기존 로컬 데이터베이스에 해당하는 설정을 사용하세요. node-postgres: 연결하기

import pg from 'pg';

const client = new pg.Client();

try {
  await client.connect();

  await client.query(`
    CREATE TEMP TABLE article_scan_demo (
      id integer PRIMARY KEY,
      title text NOT NULL,
      body text NOT NULL
    )
  `);

  await client.query(`
    INSERT INTO article_scan_demo (id, title, body)
    SELECT
      g,
      'Article ' || g,
      repeat('Sample article text. ', 20)
    FROM generate_series(1, 100000) AS s(g)
  `);

  await client.query('ANALYZE article_scan_demo');

  const queries = [
    'EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM article_scan_demo WHERE id < $1',
    'EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM article_scan_demo WHERE id > $1'
  ];

  for (const text of queries) {
    const result = await client.query(text, [11]);
    console.log(text);
    console.log(
      result.rows.map(row => row['QUERY PLAN']).join('\n')
    );
  }
} finally {
  await client.end();
}

프로그램은 자체 샘플 테이블을 만들고 모든 작업을 하나의 연결에서 수행합니다. 두 실행 계획을 출력한 다음 연결을 닫습니다. JavaScript 구문은 Node.js로 확인했지만, 준비 과정에서 데이터베이스 실행은 하지 않았습니다. 연결 설정에 실패하면 스캔 동작을 해석하기 전에 그 문제부터 해결하세요.

이름이 지정된 준비된 문이 비교 결과를 바꿀 수 있나요?

node-postgres 쿼리 설정에 name을 추가하면 각 연결에 이름이 지정된 준비된 문이 생성됩니다. 이 경우 PostgreSQL은 매개변수 값을 바탕으로 사용자 지정 계획을 사용하거나 여러 실행에서 공유하는 일반 계획을 사용할 수 있습니다. node-postgres: 쿼리, PostgreSQL: PREPARE

PostgreSQL 18의 기본 자동 정책에서는 처음 다섯 번의 실행에 사용자 지정 계획을 사용합니다. 그다음 PostgreSQL은 일반 계획의 예상 비용을 사용자 지정 계획 비용의 평균과 비교합니다. 여섯 번째 실행에서 자동으로 일반 계획으로 전환되는 것은 아닙니다. PostgreSQL: PREPARE

애플리케이션에서 이름이 지정된 문이 리터럴 SQL 테스트와 다르게 작동한다면 같은 연결에서 EXPLAIN EXECUTE를 사용해 해당 문을 살펴보세요. 일반 계획에는 매개변수 기호가 표시되고, 사용자 지정 계획에는 전달된 값이 표시됩니다. SQL 텍스트가 다르므로 두 비교 연산자에는 서로 다른 이름이 지정된 문을 사용하세요. PostgreSQL: PREPARE

쿼리나 플래너 설정은 언제 바꿔야 하나요?

먼저 쿼리가 실제로 수행해야 하는 작업을 유지하세요. SELECT *를 SELECT id로 바꾸면 PostgreSQL이 가져와야 하는 데이터가 달라집니다. 필요한 모든 열을 적절한 인덱스에서 가져올 수 있다면 인덱스 전용 스캔이 가능해집니다. 하지만 힙 가져오기를 피할 수 있는지는 가시성 맵에도 달려 있으므로 인덱스 전용 계획이라고 해서 힙에 전혀 접근하지 않는다고 보장할 수는 없습니다. PostgreSQL: 인덱스 전용 스캔과 커버링 인덱스

일치하는 기사 전체가 필요한 작업이라면 실행 계획을 비교할 때도 그 요구 사항을 유지하세요. 정렬된 결과 중 일부만 필요하다면 실제 ORDER BY와 LIMIT 쿼리를 테스트하세요. 정렬과 조기 종료가 선호되는 계획을 바꿀 수 있습니다. PostgreSQL: EXPLAIN 사용하기

통계를 확인한 후 기존 SQL 세션에서 다음과 같이 대안 계획을 비교할 수 있습니다.

BEGIN;
SET LOCAL enable_seqscan = off;

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM article_scan_demo WHERE id > 11;

ROLLBACK;

이 설정은 순차 스캔을 덜 선호하도록 하지만 완전히 억제하지는 않습니다. 결과를 진단용 비교로 사용하세요. PostgreSQL은 seq_page_cost과 random_page_cost를 통해 순차 페이지 접근 및 비순차 페이지 접근도 모델링합니다. 두 값의 상대적 비율을 바꾸면 실행 계획 선택이 달라질 수 있지만, 문서에서는 몇 가지 실험이 아니라 전체 쿼리 작업 부하를 기준으로 비용 설정을 평가하라고 권고합니다. PostgreSQL: 쿼리 계획

이 인공 테이블에서는 일치하는 행 10개와 99,989개의 차이가 결정적입니다. 실제 쿼리를 확인하고 통계를 새로 고친 뒤 예상 행 수와 실제 행 수를 비교하고, 해당하는 경우 준비된 계획의 동작을 살펴보세요. 이 단계들을 통해 순차 스캔이 요청된 작업에 맞는지 확인하고, 추가 변경에 필요한 근거를 얻을 수 있습니다.

출처