--- id: e8c2e9ea-cd87-46f8-9469-849dbd433d86 kind: REFERENCE slug: postgresql-query-plan-measurement title: PostgreSQL Query Plan 측정 기준 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd433d86/edit" sourceRevision: n+1liner-lab@2026-08 source: - final/document.md#4-1 - final/document.md#4-6 - final/document.md#4-7 - final/document.md#6-4 --- # PostgreSQL Query Plan 측정 기준 실행계획과 인덱스 동작을 재려면 운영과 같은 DB 엔진에서 재야 한다. 비용 모델, 통계, 저장 구조, 쓸 수 있는 인덱스 기능이 엔진마다 달라서, 인메모리 DB에서 잰 계획은 운영 엔진이 고를 계획이 아니다. ## 관계 - **Query Plan은 실제 PostgreSQL에서 측정한다** 이 기준에서 나온 결정이다. - **JPA N+1 정량 진단 기준** 같은 측정에서 쿼리 수를 다루는 기준이다. - **ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가** 통계를 갱신하면 추정 행수가 어떻게 달라지는지는 아직 재지 않았다. ## 목적 쿼리가 몇 개 나갔는지만 세면 그 한 쿼리가 무엇을 얼마나 읽는지는 보이지 않는다. 반복되는 하이라이트 조회를 EXPLAIN으로 열어 보니 feed_item_id를 Index Scan으로 찾아 들어가는데도 한 번에 rows=500을 읽고 있었고, 응답에 필요한 것은 그중 최신 3개뿐이었다. 한 쿼리가 실어 나르는 행수, 스캔과 정렬 방식, 인덱스를 탔는지, 읽은 블록 수는 엔진이 실제로 고른 계획에만 적혀 있다. ## 규칙 ### 1. 운영과 같은 엔진에서 측정한다 비용 기반 옵티마이저는 고를 수 있는 계획마다 비용을 추정해 가장 싼 것을 고른다. 그런데 그 추정값도, 애초에 고를 수 있는 선택지도 엔진마다 다르다. 네 가지가 갈린다. 비용 모델은 PostgreSQL에서 random_page_cost=4와 seq_page_cost=1 같은 상수로 랜덤 접근을 순차 접근보다 비싸게 잡는데, 이 상수와 추정 규칙이 엔진마다 다르다. 나머지 셋은 ANALYZE가 수집하는 통계의 종류, heap과 가시성 맵을 거치는 저장 구조, 부분 인덱스와 표현식 인덱스처럼 쓸 수 있는 인덱스 기능이다. H2 같은 인메모리 DB에서 재면 Seq Scan과 Index Scan 사이의 선택이 뒤집히고, 부분·표현식·정렬 인덱스처럼 한쪽에만 있는 접근 경로가 통째로 사라진다. 가시성 맵과 Index Only Scan의 heap 재방문 같은 PostgreSQL 특유의 동작도 재현되지 않는다. ### 2. 스키마를 운영 마이그레이션과 같게 맞춘다 운영에 쓰는 Flyway 마이그레이션을 그대로 적용하고, ddl-auto=validate로 엔티티가 기대하는 테이블·컬럼·타입이 스키마와 어긋난 것을 조기에 잡는다. 다만 validate가 모든 드리프트를 막지는 못한다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 삭제 정책, 컬럼 순서는 검증 범위 밖이라 마이그레이션 검증과 카탈로그 조회로 따로 확인한다. ### 3. EXPLAIN은 ANALYZE와 BUFFERS를 함께 쓴다 옵션 없이 EXPLAIN만 하면 계획과 추정값만 나온다. ANALYZE를 붙이면 쿼리를 실제로 실행해 노드마다 actual time과 실제 rows, loops가 함께 찍힌다. BUFFERS를 붙이면 Buffers: shared hit처럼 그 노드가 읽은 블록 수가 따라 붙는다. 추정과 실제를 대조하고 무엇을 얼마나 읽었는지 보려면 두 옵션이 다 필요하다. ### 4. 추정 행수와 실제 행수의 차이를 기록한다 둘이 벌어지면 통계가 데이터 분포를 담지 못한 것일 수 있고, 대량으로 데이터를 넣은 직후에 특히 그렇다. 위 계획도 추정은 rows=1인데 실제는 rows=500이라 500배가 벌어졌다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 feed_item_id별 편중을 반영하지 못했다는 가설을 세웠다. 차이를 발견하면 통계를 갱신한 뒤 다시 재고 전후를 비교한다. 이 가설을 검증할 ANALYZE highlights 뒤의 재측정은 아직 실행하지 않았다. ### 5. 캐시가 채워진 상태에서 잰 값을 디스크를 읽은 값으로 읽지 않는다 Buffers에 shared hit만 찍히고 read=0이면 읽은 블록이 모두 버퍼 캐시에 있었다는 뜻이어서 디스크에서 읽어 오는 시간이 그 실행시간에 빠져 있다. 위 조회를 잴 때도 Buffers는 shared hit=14, read=0이었다. 캐시가 채워진 상태(warm)에서 쟀는지 아닌지를 계획과 함께 적는다. ### 6. Execution Time을 애플리케이션 지연과 합산하지 않는다 Execution Time은 PostgreSQL executor 안에서 쓴 시간에 가깝고 ORM 엔티티 생성, JDBC 결과 전달, DTO 매핑, 직렬화, HTTP는 여기에 들어 있지 않다. 같은 조회에서 계획의 Execution Time은 0.173 ms였고 N=1,000에서 피드 한 번 로딩은 194 ms였는데, 두 값은 재는 구간이 서로 다르므로 더하거나 나란히 비교하지 않는다. ### 7. 여러 방식을 비교할 때는 같은 실행에서 잰다 비교할 SQL을 같은 테스트 실행 안에서 연속으로 EXPLAIN (ANALYZE, BUFFERS)로 잰다. 실행을 나누면 어느 쪽이 먼저 캐시를 채웠는지가 달라지기 때문에 그 차이가 buffers와 실행시간 비교에 섞인다. 그래서 buffers와 실행시간은 같은 실행 안에서 상대 비교로만 읽는다. ### 8. 인덱스에 기댄 결과인지 보려면 인덱스를 지우고 다시 잰다 어떤 방식이 빠른 이유가 SQL 문법 때문인지 인덱스 때문인지 가르려면 그 인덱스를 지운 뒤 같은 쿼리를 다시 잰다. 실제로 인덱스를 지웠을 때 같은 쿼리가 Seq Scan으로 떨어졌고 읽는 블록 수도 늘었다. 측정이 끝나면 인덱스를 되돌린다. 다만 Seq Scan이 찍혔다는 것만으로 그 계획을 곧바로 문제로 판정하지는 않는다. 테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있다. ### 9. 측정 도구의 정밀도를 주장 강도에 맞춘다 지연을 더 엄밀하게 재는 JMH(Java Microbenchmark Harness), 종단 지연을 보는 APM(Application Performance Monitoring, 애플리케이션 성능 모니터링) 같은 전용 도구가 있다. 다만 방향성만 확인하는 값에 그 엄밀도를 붙인다고 근거가 더 강해지지는 않고, 오히려 측정한 데이터보다 정밀한 결론처럼 보일 수 있다. 같은 이유로 표본이 적으면 p50이나 p99 같은 백분위수로 부르지 않는다. 이 측정도 5회를 재고 중앙값과 최댓값으로 적었다. ### 10. 재현 조건을 함께 남긴다 같은 태그가 시점에 따라 다른 patch를 가리킬 수 있기 때문에, 컨테이너 이미지 태그보다 patch 버전이나 digest를 고정하는 편이 낫다. 측정을 시작할 때 엔진 버전과 주요 플래너 설정도 함께 기록한다. 이 측정은 Testcontainers로 띄운 PostgreSQL 16에서 했다. ## 적용 조건 - 인덱스 설계나 쿼리 형태를 바꾸고 효과를 확인할 때 - 스캔 방식이나 정렬 방식이 바뀌었는지 확인할 때 - 여러 SQL 표현의 비용을 비교할 때 ## 예외 - 쿼리 발생 횟수만 확인하면 되는 단계에서는 실행계획까지 필요하지 않다. - 운영 종단 지연이나 처리량이 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다. - p99 같은 안정적인 꼬리 지연이 필요하면 워밍업 뒤 100회 넘게 반복하는 독립 세트나 JMH로 따로 잰다. ## 예시 - 엔진 : 운영과 같은 PostgreSQL, H2 같은 인메모리 DB로 대체하지 않는다 - 명령 : EXPLAIN (ANALYZE, BUFFERS) - 캐시 : 버퍼 캐시가 채워진 상태(warm)였는지 함께 기록 - 추정과 실제 : rows=1과 rows=500처럼 벌어지면 통계를 갱신하고 다시 잰다 - 비교 : 같은 테스트 실행 안에서 연속으로 측정 - 인덱스 의존 : 인덱스를 지우고 다시 잰 뒤 되돌린다 - Execution Time : 애플리케이션 지연과 재는 구간이 다르다