목록으로

Programming Notes

PostgreSQL v17이 잘못된 플랜을 선택할 때: 데이터 왜곡과 CTE 심층 분석

공통 테이블 표현식(Common Table Expressions, CTE)은 단일 복잡한 SQL 문 내에서 참조할 수 있는 임시 이름이 붙은 결과 집합을 제공합니다. CTE는 복잡한 SQL 쿼리를 읽기 쉬운 여러 부분으로 나누는 데 뛰어난 유연성을 제공하며 재귀를 가능하게 합니다. 또한, CTE는 결과 집합을 구체화(Materialize)할 수 있어 사용자가 동일한 결과 집합을 여러 번 참조할 수 있도록 돕습니다.

PostgreSQL 17은 MATERIALIZED CTE의 열 통계를 부모 쿼리로 전파하여, 플래너가 해시 집계(Hash Aggregation) 및 조인 카디널리티(Join Cardinalities)를 더욱 정확하게 추정할 수 있도록 합니다.

하지만 쿼리가 **고도로 왜곡된 데이터 분포(Highly Skewed Data Distributions)**와 런타임 무작위성(예: ORDER BY random() LIMIT 1)을 결합할 경우, 플래너의 높아진 확신이 때로는 이론적으로는 옳지만 특정 런타임 값에 대해서는 치명적인 실행 플랜으로 이어질 수 있습니다.

데이터 왜곡(Data Skew)이란 무엇이며 플랜에 어떤 영향을 미치는가?

데이터 왜곡은 컬럼의 값 분포가 불균일하여 하나 또는 몇 개의 값이 다른 값들보다 훨씬 더 자주 나타나는 현상을 말합니다. 효율적인 실행 플랜을 선택하기 위해 PostgreSQL 플래너는 pg_statistic에 저장된 n_distinct, 히스토그램, 최빈값(Most-Common-Values, MCV) 빈도와 같은 통계를 사용하여 행 수를 추정합니다. 만약 이러한 추정치에 오차가 생기면(특히 선택도(Selectivity)가 과대평가되는 경우), 쿼리 성능이 심각하게 저하될 수 있습니다.

기본 쿼리 및 관찰된 플래너 동작

PostgreSQL 17에서 데이터 왜곡의 성능 영향을 측정하기 위해 계정(Accounts), 구매(Purchases), 감사(Audit)와 관련된 3개의 테이블에 데이터를 생성했습니다.

이 기본 쿼리는 무작위로 하나의 HIGH_RISK 계정을 선택하고, 두 개의 구체화된(Materialized) CTE를 통해 대규모 audit_logs 테이블에서 일치하는 행을 검색합니다.

원본 쿼리

with selected_account as materialized (  
select account_id
    from accounts
    where account_type = 'HIGH_RISK'
    order by random()
    limit 1
),
audit_purchases as materialized (
    select distinct p.account_id
    from purchases p
    join selected_account sa
      on p.account_id = sa.account_id
)
select al.*
from audit_logs al
where exists (
 select 1 
from audit_purchases ap 
where al.account_id = ap.account_id);

PostgreSQL 14 실행 플랜

PostgreSQL 14는 구체화된 CTE를 최적화 펜스(Optimization Fence)로 취급하며 상세 통계를 부모 쿼리로 전파하지 않습니다.

그 결과, 플래너는 CTE 결과 집합이 상대적으로 작다고 가정하고 일관되게 **매개변수화된 인덱스 스캔을 사용하는 중첩 루프 조인(Nested Loop Joins with Parameterized Index Scans)**을 선호합니다. 이러한 추정이 엄격하게 정확하지는 않더라도, 이 보수적인 계획 방식은 전체 테이블 스캔을 방지하고 극단적인 데이터 왜곡 상황에서도 치명적인 성능 저하를 피할 수 있게 해줍니다.

이 워크로드에서 PostgreSQL 14는 약 7ms 만에 쿼리를 완료합니다.

                                                                            QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------
Nested Loop  (cost=1412.94..212757485.16 rows=1500600064 width=27) (actual time=5.226..7.192 rows=30 loops=1)
   Buffers: shared hit=277 read=13
   CTE selected_account
     ->  Limit  (cost=971.00..971.00 rows=1 width=12) (actual time=4.460..4.460 rows=1 loops=1)
           Buffers: shared hit=271
           ->  Sort  (cost=971.00..996.00 rows=10000 width=12) (actual time=4.459..4.459 rows=1 loops=1)
                 Sort Key: (random())
                 Sort Method: top-N heapsort  Memory: 25kB
                 Buffers: shared hit=271
                 ->  Seq Scan on accounts  (cost=0.00..921.00 rows=10000 width=12) (actual time=0.011..3.533 rows=10000 loops=1)
                       Filter: (account_type = 'HIGH_RISK'::text)
                       Rows Removed by Filter: 40000
                       Buffers: shared hit=271
   CTE audit_purchases
     ->  HashAggregate  (cost=317.44..355.57 rows=3813 width=4) (actual time=4.735..4.741 rows=1 loops=1)
           Group Key: p.account_id
           Batches: 1  Memory Usage: 217kB
           Buffers: shared hit=274 read=1
           ->  Nested Loop  (cost=0.44..292.52 rows=9966 width=4) (actual time=4.722..4.726 rows=10 loops=1)
                 Buffers: shared hit=274 read=1
                 ->  CTE Scan on selected_account sa  (cost=0.00..0.02 rows=1 width=4) (actual time=4.461..4.461 rows=1 loops=1)
                       Buffers: shared hit=271
                 ->  Index Only Scan using idx_purchases_account on purchases p  (cost=0.44..192.84 rows=9966 width=4) (actual time=0.260..0.262 rows=10 loops=1)
                       Index Cond: (account_id = sa.account_id)
                       Heap Fetches: 0
                       Buffers: shared hit=3 read=1
   ->  HashAggregate  (cost=85.79..87.79 rows=200 width=4) (actual time=4.743..4.744 rows=1 loops=1)
         Group Key: ap.account_id
         Batches: 1  Memory Usage: 40kB
         Buffers: shared hit=274 read=1
         ->  CTE Scan on audit_purchases ap  (cost=0.00..76.26 rows=3813 width=4) (actual time=4.737..4.742 rows=1 loops=1)
               Buffers: shared hit=274 read=1
   ->  Index Scan using idx_account_audit on audit_logs al  (cost=0.58..817780.34 rows=24600001 width=27) (actual time=0.480..2.438 rows=30 loops=1)
         Index Cond: (account_id = ap.account_id)
         Buffers: shared hit=3 read=12
 Planning Time: 1.121 ms
 Execution Time: 7.253 ms
(37 rows)

PostgreSQL 17 실행 플랜

PostgreSQL 17은 audit_purchases CTE가 수천 개의 행을 포함할 수 있으며, 이를 audit_logs와 조인할 경우 수천만 개의 일치 항목이 발생할 수 있다고 정확하게 추정합니다.

이러한 추정에 근거하여 플래너는 많은 행이 일치할 것으로 예상될 때 최적인 **순차 스캔(Sequential Scan)을 포함한 해시 세미 조인(Hash Semi Join)**을 선택합니다.

그러나 런타임 시 선택된 계정은 약 30개의 감사 행만 생성합니다. 그럼에도 불구하고 PostgreSQL은 선택된 계획을 완전히 실행해야 하며, 이 과정에서 디스크로부터 수십억 개의 행을 스캔하게 됩니다. 그 결과 실행 시간은 **521,625 ms(약 8.7분)**에 달합니다.

중요한 점은 이 동작이 비용 추정 버그가 아니라는 것입니다. 통계적으로 추정치는 정확합니다.

이 실패는 PostgreSQL 17 플래너가 audit_purchases CTE에서 생성된 값의 카디널리티를 기반으로 대규모 일치 결과 집합을 예상했기 때문에 발생합니다. 결과적으로 단일 런타임 선택 계정이 아닌, 예상되는 대량의 데이터 볼륨에 최적화된 플랜을 선택하게 된 것입니다.

                                                                            QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Hash Semi Join  (cost=1691.60..93353890.16 rows=3001396992 width=26) (actual time=86577.858..521625.286 rows=30 loops=1)
   Hash Cond: (al.account_id = ap.account_id)
   Buffers: shared hit=28879 read=22040419
   CTE selected_account
     ->  Limit  (cost=971.00..971.00 rows=1 width=12) (actual time=4.486..4.487 rows=1 loops=1)
           Buffers: shared hit=274
           ->  Sort  (cost=971.00..996.00 rows=10000 width=12) (actual time=4.485..4.486 rows=1 loops=1)
                 Sort Key: (random())
                 Sort Method: top-N heapsort  Memory: 25kB
                 Buffers: shared hit=274
                 ->  Seq Scan on accounts  (cost=0.00..921.00 rows=10000 width=12) (actual time=0.021..3.551 rows=10000 loops=1)
                       Filter: (account_type = 'HIGH_RISK'::text)
                       Rows Removed by Filter: 40000
                       Buffers: shared hit=271
   CTE audit_purchases
     ->  HashAggregate  (cost=658.72..673.28 rows=1456 width=4) (actual time=4.907..4.912 rows=1 loops=1)
           Group Key: p.account_id
           Batches: 1  Memory Usage: 73kB
           Buffers: shared hit=277 read=1
           ->  Nested Loop  (cost=0.44..606.86 rows=20742 width=4) (actual time=4.898..4.902 rows=10 loops=1)
                 Buffers: shared hit=277 read=1
                 ->  CTE Scan on selected_account sa  (cost=0.00..0.02 rows=1 width=4) (actual time=4.487..4.487 rows=1 loops=1)
                       Buffers: shared hit=274
                 ->  Index Only Scan using idx_purchases_account on purchases p  (cost=0.44..399.42 rows=20742 width=4) (actual time=0.410..0.411 rows=10 loops=1)
                       Index Cond: (account_id = sa.account_id)
                       Heap Fetches: 0
                       Buffers: shared hit=3 read=1
   ->  Seq Scan on audit_logs al  (cost=0.00..52082989.92 rows=3001396992 width=26) (actual time=0.012..286755.571 rows=3001400050 loops=1)
         Buffers: shared hit=28602 read=22040418
   ->  Hash  (cost=29.12..29.12 rows=1456 width=4) (actual time=4.919..4.920 rows=1 loops=1)
         Buckets: 2048  Batches: 1  Memory Usage: 17kB
         Buffers: shared hit=277 read=1
         ->  CTE Scan on audit_purchases ap  (cost=0.00..29.12 rows=1456 width=4) (actual time=4.908..4.911 rows=1 loops=1)
               Buffers: shared hit=277 read=1
 Planning:
   Buffers: shared hit=197
 Planning Time: 1.831 ms
 Execution Time: 521625.433 ms
(38 rows)

중첩 루프 강제 실행 (Forced Nested Loop)

세션 레벨에서 해시 조인을 비활성화하면 PostgreSQL 17이 인덱스 스캔을 사용하는 중첩 루프 플랜으로 되돌아가도록 강제할 수 있습니다. 이 진단 단계를 거치면 약 8ms 만에 완료되며, 이는 실제 런타임 워크로드에 인덱스 기반 실행이 충분함을 확인시켜 줍니다. 그러나 플래너 기능을 비활성화하는 것은 운영 환경에는 적합하지 않으며 근본 원인을 확인하는 용도로만 사용해야 합니다.

        	                                                                    QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Nested Loop  (cost=1677.62..3361930001.36 rows=3001396992 width=26) (actual time=5.513..8.194 rows=30 loops=1)
   Buffers: shared hit=277 read=12
   CTE selected_account
     ->  Limit  (cost=971.00..971.00 rows=1 width=12) (actual time=4.524..4.524 rows=1 loops=1)
           Buffers: shared hit=271
           ->  Sort  (cost=971.00..996.00 rows=10000 width=12) (actual time=4.523..4.523 rows=1 loops=1)
                 Sort Key: (random())
                 Sort Method: top-N heapsort  Memory: 25kB
                 Buffers: shared hit=271
                 ->  Seq Scan on accounts  (cost=0.00..921.00 rows=10000 width=12) (actual time=0.014..3.608 rows=10000 loops=1)
                       Filter: (account_type = 'HIGH_RISK'::text)
                       Rows Removed by Filter: 40000
                       Buffers: shared hit=271
   CTE audit_purchases
     ->  HashAggregate  (cost=658.72..673.28 rows=1456 width=4) (actual time=4.890..4.894 rows=1 loops=1)
           Group Key: p.account_id
           Batches: 1  Memory Usage: 73kB
           Buffers: shared hit=274 read=1
           ->  Nested Loop  (cost=0.44..606.86 rows=20742 width=4) (actual time=4.882..4.885 rows=10 loops=1)
                 Buffers: shared hit=274 read=1
                 ->  CTE Scan on selected_account sa  (cost=0.00..0.02 rows=1 width=4) (actual time=4.525..4.525 rows=1 loops=1)
                       Buffers: shared hit=271
                 ->  Index Only Scan using idx_purchases_account on purchases p  (cost=0.44..399.42 rows=20742 width=4) (actual time=0.354..0.356 rows=10 loops=1)
                       Index Cond: (account_id = sa.account_id)
                       Heap Fetches: 0
                       Buffers: shared hit=3 read=1
   ->  HashAggregate  (cost=32.76..47.32 rows=1456 width=4) (actual time=4.896..4.899 rows=1 loops=1)
         Group Key: ap.account_id
         Batches: 1  Memory Usage: 73kB
         Buffers: shared hit=274 read=1
         ->  CTE Scan on audit_purchases ap  (cost=0.00..29.12 rows=1456 width=4) (actual time=4.892..4.895 rows=1 loops=1)
               Buffers: shared hit=274 read=1
   ->  Index Scan using idx_account_audit on audit_logs al  (cost=0.58..1782455.82 rows=52656088 width=26) (actual time=0.615..3.283 rows=30 loops=1)
         Index Cond: (account_id = ap.account_id)
         Buffers: shared hit=3 read=11
 Planning Time: 0.184 ms
 Execution Time: 8.252 ms
(37 rows)

해결 전략

해결책이 간단하지는 않지만, 다음과 같은 쿼리 재작성(Query Rewrite) 방법을 통해 실행 플랜 수정을 시도해 보았습니다.

  1. OFFSET 0을 포함한 LATERAL JOIN
  2. Purchases CTE에 LIMIT 적용
  3. 서브쿼리 사용

1. OFFSET 0을 포함한 LATERAL JOIN

LATERAL 서브쿼리 내부에 OFFSET 0을 추가하면 조인 재정렬을 방지하는 **최적화 장벽(Optimizer Barrier)**이 생성됩니다.

이것은 PostgreSQL이 런타임 계정 ID를 audit_logs의 인덱스 스캔으로 전달하는 **매개변수화된 중첩 루프(Parameterized Nested Loop)**를 강제로 실행하도록 만듭니다.

이 재작성을 통해 PostgreSQL 17은 쿼리를 6.519 ms 만에 실행하며, 이는 원래 플랜 대비 99.9988%의 성능 향상입니다.

with selected_account as materialized (
  select account_id
  from accounts
  where account_type = 'HIGH_RISK'
  order by random()
  limit 1
),
audit_purchases as materialized (
  select p.account_id
  from purchases p
  join selected_account sa
    on p.account_id = sa.account_id
)
select al.*
from audit_purchases ap
join lateral (
  select *
  from audit_logs al where al.account_id = ap.account_id offset 0) al on true;
실행 플랜
                                                                         QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------
 Nested Loop  (cost=1578.44..42162640233.98 rows=1092192577296 width=26) (actual time=4.980..6.481 rows=300 loops=1)
   Buffers: shared hit=403 read=12
   CTE selected_account
     ->  Limit  (cost=971.00..971.00 rows=1 width=12) (actual time=4.296..4.297 rows=1 loops=1)
           Buffers: shared hit=271
           ->  Sort  (cost=971.00..996.00 rows=10000 width=12) (actual time=4.295..4.296 rows=1 loops=1)
                 Sort Key: (random())
                 Sort Method: top-N heapsort  Memory: 25kB
                 Buffers: shared hit=271
                 ->  Seq Scan on accounts  (cost=0.00..921.00 rows=10000 width=12) (actual time=0.013..3.357 rows=10000 loops=1)
                       Filter: (account_type = 'HIGH_RISK'::text)
                       Rows Removed by Filter: 40000
                       Buffers: shared hit=271
   CTE audit_purchases
     ->  Nested Loop  (cost=0.44..606.86 rows=20742 width=4) (actual time=4.585..4.588 rows=10 loops=1)
           Buffers: shared hit=274 read=1
           ->  CTE Scan on selected_account sa  (cost=0.00..0.02 rows=1 width=4) (actual time=4.298..4.298 rows=1 loops=1)
                 Buffers: shared hit=271
           ->  Index Only Scan using idx_purchases_account on purchases p  (cost=0.44..399.42 rows=20742 width=4) (actual time=0.286..0.287 rows=10 loops=1)
                 Index Cond: (account_id = sa.account_id)
                 Heap Fetches: 0
                 Buffers: shared hit=3 read=1
   ->  CTE Scan on audit_purchases ap  (cost=0.00..414.84 rows=20742 width=4) (actual time=4.587..4.591 rows=10 loops=1)
         Buffers: shared hit=274 read=1
   ->  Index Scan using idx_account_audit on audit_logs al  (cost=0.58..1506157.19 rows=52656088 width=26) (actual time=0.040..0.185 rows=30 loops=10)
         Index Cond: (account_id = ap.account_id)
         Buffers: shared hit=129 read=11
 Planning:
   Buffers: shared hit=8
 Planning Time: 0.238 ms
 Execution Time: 6.519 ms
(31 rows)

2. Purchases에 LIMIT 적용

audit_purchases CTE에 LIMIT 1을 적용하면 카디널리티가 단일 행으로 제한됩니다. 이로 인해 기획 단계에서 중첩 루프가 해시 조인보다 저렴해집니다.

PostgreSQL 17은 인덱스 기반 실행 플랜을 선택하고 7.742 ms 만에 완료하여 99.9985%의 개선을 보여주었습니다.

with selected_account as materialized (
  select account_id
  from accounts
  where account_type = 'HIGH_RISK'
  order by random()
  limit 1
),
audit_purchases as materialized (
  select p.account_id
  from purchases p
  join selected_account sa
    on p.account_id = sa.account_id limit 1
)
select al.*
from audit_logs al where exists (select 1 from audit_purchases ap where al.account_id=ap.account_id);
실행 플랜
                                                                           QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------------
 Nested Loop  (cost=972.07..933697.99 rows=24600001 width=27) (actual time=5.693..7.686 rows=30 loops=1)
   Buffers: shared hit=276 read=13
   CTE selected_account
     ->  Limit  (cost=971.00..971.00 rows=1 width=12) (actual time=4.816..4.817 rows=1 loops=1)
           Buffers: shared hit=271
           ->  Sort  (cost=971.00..996.00 rows=10000 width=12) (actual time=4.816..4.817 rows=1 loops=1)
                 Sort Key: (random())
                 Sort Method: top-N heapsort  Memory: 25kB
                 Buffers: shared hit=271
                 ->  Seq Scan on accounts  (cost=0.00..921.00 rows=10000 width=12) (actual time=0.014..3.825 rows=10000 loops=1)
                       Filter: (account_type = 'HIGH_RISK'::text)
                       Rows Removed by Filter: 40000
                       Buffers: shared hit=271
   CTE audit_purchases
     ->  Limit  (cost=0.44..0.47 rows=1 width=4) (actual time=5.133..5.134 rows=1 loops=1)
           Buffers: shared hit=274 read=1
           ->  Nested Loop  (cost=0.44..292.52 rows=9966 width=4) (actual time=5.132..5.132 rows=1 loops=1)
                 Buffers: shared hit=274 read=1
                 ->  CTE Scan on selected_account sa  (cost=0.00..0.02 rows=1 width=4) (actual time=4.817..4.817 rows=1 loops=1)
                       Buffers: shared hit=271
                 ->  Index Only Scan using idx_purchases_account on purchases p  (cost=0.44..192.84 rows=9966 width=4) (actual time=0.313..0.313 rows=1 loops=1)
                       Index Cond: (account_id = sa.account_id)
                       Heap Fetches: 0
                       Buffers: shared hit=3 read=1
   ->  HashAggregate  (cost=0.02..0.03 rows=1 width=4) (actual time=5.138..5.139 rows=1 loops=1)
         Group Key: ap.account_id
         Batches: 1  Memory Usage: 24kB
         Buffers: shared hit=274 read=1
         ->  CTE Scan on audit_purchases ap  (cost=0.00..0.02 rows=1 width=4) (actual time=5.135..5.135 rows=1 loops=1)
               Buffers: shared hit=274 read=1
   ->  Index Scan using idx_account_audit on audit_logs al  (cost=0.58..686726.47 rows=24600001 width=27) (actual time=0.552..2.534 rows=30 loops=1)
         Index Cond: (account_id = ap.account_id)
         Buffers: shared hit=2 read=12
 Planning Time: 0.216 ms
 Execution Time: 7.742 ms
(35 rows)

3. 서브쿼리 사용

스칼라 서브쿼리를 사용하여 쿼리를 재작성하면 선택된 계정 ID가 InitPlan으로 변환됩니다.

결과 값은 audit_logs에 대한 인덱스 스캔에서 런타임 매개변수로 사용됩니다. 이는 조인 재정렬 기회를 제거하고 매개변수화된 액세스 경로를 보장합니다.

두 가지 스칼라 서브쿼리 변형 모두 약 7.3~7.6 ms 만에 완료되어 99.9986% 이상의 개선 효과를 얻었습니다.

서브쿼리 예시 1
select * from audit_logs al where al.account_id in (select p.account_id from purchases p where p.account_id=(
select account_id from accounts where account_type = 'HIGH_RISK' order by random() limit 1));
실행 플랜
                                                                       QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------
 Nested Loop Semi Join  (cost=972.02..1302907.98 rows=24600001 width=27) (actual time=5.257..7.260 rows=30 loops=1)
   Buffers: shared hit=277 read=12
   InitPlan 1 (returns $0)
     ->  Limit  (cost=971.00..971.00 rows=1 width=12) (actual time=4.612..4.613 rows=1 loops=1)
           Buffers: shared hit=271
           ->  Sort  (cost=971.00..996.00 rows=10000 width=12) (actual time=4.611..4.611 rows=1 loops=1)
                 Sort Key: (random())
                 Sort Method: top-N heapsort  Memory: 25kB
                 Buffers: shared hit=271
                 ->  Seq Scan on accounts  (cost=0.00..921.00 rows=10000 width=12) (actual time=0.012..3.674 rows=10000 loops=1)
                       Filter: (account_type = 'HIGH_RISK'::text)
                       Rows Removed by Filter: 40000
                       Buffers: shared hit=271
   ->  Index Scan using idx_account_audit on audit_logs al  (cost=0.58..686755.98 rows=24600001 width=27) (actual time=5.067..7.059 rows=30 loops=1)
         Index Cond: (account_id = $0)
         Buffers: shared hit=274 read=11
   ->  Materialize  (cost=0.44..242.67 rows=9966 width=4) (actual time=0.006..0.006 rows=1 loops=30)
         Buffers: shared hit=3 read=1
         ->  Index Only Scan using idx_purchases_account on purchases p  (cost=0.44..192.84 rows=9966 width=4) (actual time=0.187..0.188 rows=1 loops=1)
               Index Cond: (account_id = $0)
               Heap Fetches: 0
               Buffers: shared hit=3 read=1
 Planning Time: 0.160 ms
 Execution Time: 7.286 ms
(24 rows)
서브쿼리 예시 2
select * from audit_logs al where al.account_id=(select distinct p.account_id from purchases p where p.account_id=(
select account_id from accounts where account_type = 'HIGH_RISK' order by random() limit 1));
실행 플랜
                                                                         QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------
 Index Scan using idx_account_audit on audit_logs al  (cost=972.04..1507128.65 rows=52656088 width=26) (actual time=5.510..7.637 rows=30 loops=1)
   Index Cond: (account_id = (InitPlan 2).col1)
   Buffers: shared hit=277 read=12
   InitPlan 2
     ->  Limit  (cost=971.44..971.46 rows=1 width=4) (actual time=4.901..4.902 rows=1 loops=1)
           Buffers: shared hit=274 read=1
           InitPlan 1
             ->  Limit  (cost=971.00..971.00 rows=1 width=12) (actual time=4.455..4.456 rows=1 loops=1)
                   Buffers: shared hit=271
                   ->  Sort  (cost=971.00..996.00 rows=10000 width=12) (actual time=4.455..4.455 rows=1 loops=1)
                         Sort Key: (random())
                         Sort Method: top-N heapsort  Memory: 25kB
                         Buffers: shared hit=271
                         ->  Seq Scan on accounts  (cost=0.00..921.00 rows=10000 width=12) (actual time=0.018..3.522 rows=10000 loops=1)
                               Filter: (account_type = 'HIGH_RISK'::text)
                               Rows Removed by Filter: 40000
                               Buffers: shared hit=271
           ->  Index Only Scan using idx_purchases_account on purchases p  (cost=0.44..399.42 rows=20742 width=4) (actual time=4.900..4.900 rows=1 loops=1)
                 Index Cond: (account_id = (InitPlan 1).col1)
                 Heap Fetches: 0
                 Buffers: shared hit=274 read=1
 Planning:
   Buffers: shared hit=4
 Planning Time: 0.154 ms
 Execution Time: 7.663 ms
(25 rows)

쿼리 실행 요약

아래 표는 기존 PostgreSQL 17 및 14 쿼리 실행 플랜과 비교하여 테스트된 모든 재작성 방식의 실행 시간 개선 사항을 요약한 것입니다.

설정 / 재작성 방식 실행 시간 (ms) 속도 향상 배수 (×)
PG17 원본 (Hash Semi Join + Seq Scan) 521,625.433 1.0×
PG14 원본 쿼리 형태 7.253 71,919×
중첩 루프 강제 (hashjoin off – 진단용) 8.252 63,212×
LATERAL JOIN + OFFSET 0 6.519 80,016×
Purchases에 LIMIT 적용 7.742 67,376×
서브쿼리 – IN 및 InitPlan (예시 1) 7.286 71,593×
서브쿼리 – 스칼라 서브쿼리 (형태 2) 7.663 68,071×

핵심 요약

PostgreSQL 17의 플래너 개선 사항은 대개 정확하고 의도적이며 대부분의 워크로드에 유익합니다.

하지만 CTE, 런타임 무작위성, 고도로 왜곡된 데이터가 결합된 쿼리는 플래너가 전역적으로는 최적이지만 국소적으로는 비효율적인 플랜을 선택하게 만들 수 있습니다.

런타임 값이 결과 집합을 급격하게 좁히는 경우, LATERAL 조인, 스칼라 서브쿼리 또는 제한된 CTE와 같이 매개변수화된 인덱스 스캔을 강제하는 쿼리 재작성 방식이 예측 가능하고 안정적인 성능을 제공합니다.