- 계약 채택 — schema 2 → 4. 독자 질문, 후보 36건(PROMOTE 24 · MERGE_INTO 8 · KEEP_IN_SSOT 4), 종류별 칸, kind:slug 관계. 저장소 github-project/ca-tmpl @ 761384d (문서가 인용한 IT 7종이 그 커밋에 있다) - 재선별 결과 기존 24건이 전부 살아남았다. 측정이 Reference 안에 들어 있었지만 독립성 검사를 통과하지 못해 Case 로 빼지 않았다 - 평문 칸의 백틱 제거, 「기준선」을 실제 이름으로, frontmatter 에 source·sourceRevision - 리뷰 39건 반영 — 설명 뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 지웠다. 사실을 담은 절반이 있는 문장은 평가만 뺐다 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4.5 KiB
id, kind, slug, title, topic, topicName, topicName, project, status, studio, decisionStatus, sourceRevision, source
| id | kind | slug | title | topic | topicName | topicName | project | status | studio | decisionStatus | sourceRevision | source | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ae6c9bea-d3a3-46e1-bbd4-8d580d336394 | PROJECT_DECISION | measure-plan-on-real-postgresql | Query Plan은 실제 PostgreSQL에서 측정한다 | jpa-feed-query-performance | JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/ae6c9bea-d3a3-46e1-bbd4-8d580d336394/edit | PROPOSED | n+1liner-lab@2026-08 |
|
id: ae6c9bea-d3a3-46e1-bbd4-8d580d336394 kind: PROJECT_DECISION slug: measure-plan-on-real-postgresql title: Query Plan은 실제 PostgreSQL에서 측정한다 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/ae6c9bea-d3a3-46e1-bbd4-8d580d336394/edit" decisionStatus: PROPOSED sourceRevision: n+1liner-lab@2026-08 source:
- final/document.md#4-1
- final/document.md#4-6
Query Plan은 실제 PostgreSQL에서 측정한다
조회 성능 측정은 H2 같은 인메모리 DB가 아니라 운영과 같은 PostgreSQL에서 실행한다.
근거
- PostgreSQL Query Plan 측정 기준 이 결정을 실제로 잴 때 지킬 규칙으로 폈다.
- Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1 실제 엔진에서 실행계획과 통계 차이를 관측했다.
- Visibility OR이 Keyset Index를 깨뜨린 문제 부분 인덱스와 정렬 인덱스 기능에 기대어 측정했다.
결정문
퍼시스턴스 조회 측정은 Testcontainers로 띄운 실제 PostgreSQL에서 수행한다. H2 같은 인메모리 DB로는 실행계획이나 인덱스 동작을 판단하지 않는다.
스키마는 운영 마이그레이션인 Flyway V6__feed.sql을 그대로 적용하고, ddl-auto=validate로 엔티티가 기대하는 테이블·컬럼·타입의 기본 불일치를 조기에 잡는다.
판단 이유
비용 기반 옵티마이저는 가능한 여러 계획의 비용을 추정해 가장 싼 것을 고른다. 그런데 그 추정값도, 애초에 고를 수 있는 선택지도 엔진마다 다르다. 갈리는 축은 넷이다. 비용 상수, ANALYZE가 모으는 통계, 저장 구조와 가시성 처리, 쓸 수 있는 인덱스 종류와 기능이 저마다 다르다.
추정 행수 1과 실제 행수 500이 500배로 벌어진 관측은 통계를 어떻게 수집하느냐에 달렸다. 순차 스캔과 인덱스 스캔 중 무엇을 고르는지는 비용 모델과 선택도 추정이 정하므로, 엔진이 바뀌면 다른 임계에서 갈린다. 가시성 조건과 정렬 페이징은 부분 인덱스와 정렬 인덱스 기능에 기댄다.
다른 엔진에서 재면 순차 스캔과 인덱스 스캔의 선택이 뒤집히고, 부분·표현식·정렬 인덱스처럼 한쪽에만 있는 접근 경로가 통째로 사라지며, PostgreSQL 특유의 가시성 맵과 index-only scan 동작이 재현되지 않는다.
실행계획은 엔진이 실제로 고른 것 자체가 필요해서 PostgreSQL의 EXPLAIN (ANALYZE, BUFFERS)로 읽었다. APM(애플리케이션 성능 모니터링)은 운영을 관측하는 데 더 적합하다.
부르는 지점도 골랐다. 퍼시스턴스 어댑터 FeedQueryAdapter를 JPA 슬라이스에서 직접 호출하고 HTTP는 거치지 않는다. 이유는 둘이다. N+1은 조회 계층의 현상이므로 웹·보안·직렬화 노이즈를 배제하고 쿼리 행동만 관찰한다. 그리고 슬라이스 트랜잭션이 열려 있어야 지연 로딩이 결정적으로 재현된다.
영향
- 측정을 돌리려면 컨테이너 런타임이 있어야 한다. @Testcontainers(disabledWithoutDocker = true)를 걸어 두어 Docker가 없는 환경에서는 이 테스트가 비활성화된다.
- 인메모리 DB보다 기동과 실행이 느리다. 컨테이너 필드를 static으로 두어 테스트 메서드마다 새로 띄우지 않고 FeedPersistenceIT 실행 동안 하나를 공유한다. 첫 테스트 전에 한 번 기동하고 마지막 테스트가 끝나면 종료한다.
- 재현성을 더 높이려면 postgres:16-alpine 태그보다 patch 버전이나 digest(@sha256:...)를 고정하는 편이 낫다. 같은 태그가 시점에 따라 다른 patch를 가리킬 수 있기 때문이다.
- ddl-auto=validate만으로 모든 드리프트를 막지는 못한다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 삭제 정책, 컬럼 순서는 검증 범위 밖이라 마이그레이션 검증과 카탈로그 조회로 따로 확인한다.
- 측정이 열린 테스트 트랜잭션 안에 있어야 하므로, 격리 JVM과 steady-state를 전제하는 JMH(Java Microbenchmark Harness)나 HTTP 종단을 전제하는 도구는 이 지점을 잡지 못한다.
- 측정값은 warm DB 캐시·동일 JVM·단일 스레드에서 잰 근삿값이다. 절대값보다 N에 따른 증가 방향만 확인했으므로 운영 지연으로 옮겨 읽을 수 없다.