공통 테이블 표현식(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) 방법을 통해 실행 플랜 수정을 시도해 보았습니다.
OFFSET 0을 포함한LATERAL JOINPurchasesCTE에LIMIT적용- 서브쿼리 사용
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와 같이 매개변수화된 인덱스 스캔을 강제하는 쿼리 재작성 방식이 예측 가능하고 안정적인 성능을 제공합니다.