diff --git a/docs/backend/implements/BI-44-2026-08-07-map-bbox-plan-pinning.md b/docs/backend/implements/BI-44-2026-08-07-map-bbox-plan-pinning.md new file mode 100644 index 00000000..a97977de --- /dev/null +++ b/docs/backend/implements/BI-44-2026-08-07-map-bbox-plan-pinning.md @@ -0,0 +1,127 @@ +# BI-44. 지도 bbox 쿼리를 custom plan에 묶는다 + +- **상태**: ✅ 완료 +- **날짜**: 2026-08-07 +- **관련**: Jira S15P11A705-404, 브랜치 `perf/S15P11A705-404-map-bbox-plan`, + [BI-42](BI-42-2026-08-07-map-keyword-chips.md)(bbox SQL을 두 갈래로 나눈 근거), + [BI-37](BI-37-2026-08-02-load-profiles-report.md)(부하 프로파일), + [BI-38](BI-38-2026-08-03-massive-scale-plan-observation.md)(대량 볼륨 계획 관측) + +## 무엇을 고쳤나 + +지도 bbox 조회 두 개가 **실사용에서만** 느린 실행 계획을 타고 있었다. + +- `GET /v1/records/map` (bbox 있는 경로) — `RecordRepository.findMarkersWithinBounds` +- `GET /v1/records/map/keywords` (bbox 있는 경로) — `ContextKeywordRepository.findTopKeywordsInBounds` + +두 서비스 메서드에서 bbox가 넷 다 주어진 경우에만 `SET LOCAL plan_cache_mode = force_custom_plan`을 +걸어 계획 캐시를 끈다. 새 컴포넌트 `QueryPlanPin` 하나가 그 문장을 쥐고 있고, 호출은 두 +자리(`RecordService.map`·`RecordService.mapKeywords`)뿐이다. + +## 왜 리터럴 EXPLAIN이 이걸 놓쳤나 + +**이 리포트의 핵심이다.** pgjdbc는 같은 문장을 5회 실행한 뒤 서버 프리페어로 전환하고, 그 시점에 +Postgres가 generic plan을 고를 수 있다. generic plan은 파라미터 값을 모르므로 bbox가 실제로 얼마나 +걸러내는지 읽지 못한다. + +리터럴을 넣어 `EXPLAIN`을 뜨면 **항상 custom plan**이라, 이 전환은 검증에서 절대 드러나지 않는다. +BI-42가 남긴 성능 근거도 리터럴 측정이었다. + +드러내려면 `PREPARE` 후 **6회 이상** 실행한 계획을 봐야 한다. record 1,012만 볼륨(대량 벤치 시드)에서 +실측한 결과다. + +| 쿼리 | 1~5회차 (custom plan) | 6·7회차 (generic plan) | 배율 | +|---|---|---|---| +| 마커 bbox | 2.76ms | 45.4 / 61.6ms | **16~22배** | +| 키워드 bbox | 5.45ms | 32.0 / 34.1ms | **6배** | + +generic plan은 **조인 순서를 뒤집는다.** 회원의 record(697건)에서 출발해 place를 PK로 찌르는 대신, +`ix_place_lat_lng`로 bbox 안 place 24,503건을 훑고 건마다 record를 찔러 대부분 0행을 얻는다. + +``` +-> Index Scan using ix_place_lat_lng on place p (actual time=0.030..12.976 rows=24503 loops=1) + -> Index Scan using uq_record_active on record r (actual time=0.001..0.001 rows=0 loops=24503) +``` + +지도 화면은 항상 bbox를 보내고 운영 커넥션은 오래 살아 있으므로, 이것은 드문 경우가 아니라 +**사실상 상시 경로**다. + +### SQL을 두 갈래로 나눈 것으로는 막지 못한다 + +BI-42는 `(:swLat IS NULL OR ...)`로 두 경로를 합치지 않은 이유를 "합치면 플래너가 bbox 선택도를 +못 읽어 실행 계획이 나빠진다"로 적었다. 그 판단 자체는 맞다 — 나누면 플래너가 **bbox 조건의 존재**를 +안다. 그러나 **값**은 여전히 파라미터이고, generic plan은 그 값을 못 읽는다. 나누는 것으로 얻는 것과 +계획 캐시가 가져가는 것이 다른 층위다. + +## 어떻게 고칠지 — 후보 넷을 재봤다 + +| 후보 | generic plan 구간 실행 시간 | 판정 | +|---|---|---| +| **`plan_cache_mode = force_custom_plan`** | **4.63ms** (키워드) / 2.76ms (마커) | ✅ 채택. 계획이 회원 인덱스에서 출발하는 상태로 복원된다 | +| bbox 없는 `MATERIALIZED` CTE로 회원 record 먼저 고정 | 18.94ms | ⚠️ 절반만 회복. 플래너가 bbox 안 place 24,503건을 해시로 쌓는다 | +| bbox 포함 `MATERIALIZED` CTE | 33.17ms | ❌ 효과 없음. CTE 안에서 같은 순서로 뒤집힌다 | +| 데이터소스 전역(`prepareThreshold=0` 등) | — | 보류. 다른 쿼리의 계획 재사용 이득까지 버린다 | + +**CTE로 감싸는 방식이 듣지 않는다는 것이 직관과 반대여서 적어 둔다.** "회원 record를 먼저 고정하면 +플래너가 그 순서를 지킬 것"이라는 기대가 실측에서 빗나갔다. 다음에 같은 증상을 만나면 CTE부터 +시도하지 말 것. + +전역 설정을 보류한 이유는 범위 판단이 아직 없기 때문이다. 어디까지 걸어야 하는지는 +**S15P11A705-405의 전수 감사**가 정한다. 그때까지는 실측으로 확인된 두 자리에만 건다. + +## bbox 없는 경로에는 걸지 않았다 + +`TOP_KEYWORDS_FOR_OWNER_SQL`(bbox 생략 시 전체 집계)에는 그 SQL 주석이 밝힌 자기 취약점이 따로 있다 +— 회원당 Context가 6,000 부근에서 `ai.context_ai_state` Seq Scan으로 뒤집힌다. 여기서 함께 걸면 +감사 결과를 미리 단정하는 것이 되므로 남겨 두었고, 테스트가 그 경계를 고정한다. + +## 실사용 실측 (수정 전/후) + +대량 볼륨(record 1,012만)에서 같은 엔드포인트를 순차로 20회 호출했다. pgjdbc가 5회 후 갈아타므로 +뒷부분이 실사용 상태다. 10~20회차 중앙값이다. + +| 경로 | 수정 전 | 수정 후 | +|---|---|---| +| 마커 bbox (HTTP) | 67.5ms | **38.8ms** | +| 키워드 bbox (HTTP) | 72.1ms | **25.6ms** | + +수정 전에는 계단이 뚜렷했다 — 마커는 9회차부터 27~45ms에서 65~87ms로, 키워드는 10회차부터 +22~37ms에서 60~104ms로 뛰었다. 수정 후에는 그 계단이 사라진다. + +**HTTP 수치가 DB 배율(16~22배)만큼 좋아지지 않는 것은 정상이다.** 마커 응답은 418건을 조립하고 최근 +컬렉션 id를 붙이고 Java `Collator`로 정렬하는데, 그 시간은 계획과 무관하다. DB 밖 비용이 남는 만큼 +개선 폭이 줄어든다. + +측정 환경에 자원 한도를 얹지 않았다(`compose.bench.yaml` 미적용). 1,012만 행이 전부 페이지 캐시에 +들어간 상태이므로 **디스크 I/O가 빠진 하한값**이며, 절대 수치를 운영 예측에 쓰면 안 된다. 배율과 +계획 모양이 이 측정의 산출물이다. + +## 테스트가 보장하는 것과 못 보는 것 + +`MapBboxPlanCacheTests`(5개)가 덮는 것: + +1. bbox 경로가 도는 트랜잭션의 `plan_cache_mode`가 실제로 `force_custom_plan`이다 (마커·키워드 각각) +2. bbox 없는 경로는 `auto`로 남는다 — 위 두 단정의 판별력을 만드는 음성 대조 +3. 고정한 상태에서 프리페어 기준(7회)을 넘겨도 `pg_prepared_statements.generic_plans` 증분이 0이다 +4. 같은 자리에서 `force_generic_plan`으로 뒤집으면 그 계수가 오른다 — 계수가 관측 도구로 쓸 만하다는 확인 + +수정을 되돌리면 이 중 3개가 깨지는 것을 확인했다(계수 증분 2). + +**계획 모양은 단정하지 않는다.** 조인 순서 뒤집힘은 bbox 안 place 수가 회원 record 수보다 훨씬 많아야 +일어나고, Testcontainers DB는 그 조건을 만들 수 없다. 계획을 단정하면 고치기 전에도 통과해 판별력이 +없고, 억지로 데이터를 맞추면 `FeedChannelPlanTests`가 겪은 플레이크(행 수·통계 갱신 타이밍에 판정이 +붙었다 떨어졌다 하는 것)를 되풀이한다. 그래서 계획 모양과 실행 시간은 위 실측으로 갈음하고 이 문서에 +남긴다. + +**계수를 절대값이 아니라 증분으로 본다.** 프리페어된 문장은 트랜잭션이 끝나도 풀 커넥션에 남고 계수도 +함께 남는다. 절대값으로 단정했더니 같은 커넥션을 먼저 쓴 테스트가 올려 둔 값까지 세어 실행 순서에 따라 +판정이 갈렸다(실측으로 3이 새어 들어왔다). + +## 남은 것 + +- **전수 감사**(S15P11A705-405) — 같은 실패가 범위·부등호·`LIKE`·`IN`을 쓰는 다른 쿼리에도 있는지. + 피드·검색은 AI 파트 소유라 그 티켓이 전달 문구까지만 만든다. +- **bbox 없는 키워드 집계**의 계획 취약점 — 위 절 참조. 감사가 판단한다. +- **자원 한도를 얹은 재측정** — 이 측정의 한계를 해소한다. +- `SET LOCAL`은 트랜잭션 밖에서 경고만 남기고 아무 일도 하지 않는다. 호출부의 + `@Transactional(readOnly = true)`가 사라지면 보호가 조용히 없어지는데, 위 테스트 1이 그 상태를 잡는다. diff --git a/docs/backend/worklog/2026-08-07-S15P11A705-404-map-bbox-plan-pinning.md b/docs/backend/worklog/2026-08-07-S15P11A705-404-map-bbox-plan-pinning.md new file mode 100644 index 00000000..35a39659 --- /dev/null +++ b/docs/backend/worklog/2026-08-07-S15P11A705-404-map-bbox-plan-pinning.md @@ -0,0 +1,47 @@ +# 리터럴 EXPLAIN이 5회까지만 보여 준다는 것을 알고 지도 bbox 계획을 고정했다 + +- **날짜**: 2026-08-07 +- **추적**: S15P11A705-404 +- **관련**: [BI-44](../implements/BI-44-2026-08-07-map-bbox-plan-pinning.md) · + [BI-42](../implements/BI-42-2026-08-07-map-keyword-chips.md)(bbox SQL을 나눈 근거) · + [BI-37](../implements/BI-37-2026-08-02-load-profiles-report.md) + +부하 측정을 하려고 최근 API 세 개(#207·#203·#202)를 대량 볼륨에서 재던 중에, 지도 키워드 조회의 +HTTP 실측(36ms)이 PR #202 본문의 EXPLAIN 실측(5.4ms)과 어긋나는 것을 봤다. 처음에는 애플리케이션 +쪽 비용을 의심했는데, 원인은 계획 캐시였다 — pgjdbc가 5회 후 서버 프리페어로 갈아타면 Postgres가 +generic plan을 고르고, 그 계획은 bbox가 실제로 얼마나 걸러내는지 모른 채 조인 순서를 뒤집는다. + +**이 판단에서 가장 중요했던 것은 "왜 검증에서 안 걸렸나"였다.** 리터럴을 넣어 EXPLAIN을 뜨면 항상 +custom plan이다. 즉 지금까지의 성능 검증 방식으로는 이 실패를 볼 수 없었고, BI-42의 근거도 그 +방식이었다. 그래서 고치는 것과 별개로 **같은 실패가 다른 쿼리에 얼마나 퍼져 있는지 모른다**는 +사실을 티켓으로 분리했다(S15P11A705-405). + +찾다 보니 이 티켓이 원래 겨눈 키워드 조회보다 **기존 마커 조회가 더 심했다**. 지도 본 경로인데 +2.8ms에서 45~62ms로 갈렸다(16~22배). 처음 부하 측정에서 이걸 놓친 이유는 내가 `/v1/records/map`을 +bbox 없이 호출했기 때문이다 — bbox 없는 경로는 다른 SQL을 쓰므로 이 함정을 지나지 않는다. 대상을 +정할 때 "같은 엔드포인트"가 아니라 "같은 SQL"을 봐야 했다. + +수정 방식을 정하면서 후보 넷을 실제로 재봤고, **CTE로 감싸는 두 방식이 듣지 않는다는 것**이 이번 +작업에서 가장 예상과 달랐던 부분이다. "회원 record를 MATERIALIZED CTE로 먼저 고정하면 플래너가 그 +순서를 지킬 것"이라 기대했는데 bbox를 포함한 형태는 전혀 나아지지 않았고(33ms), bbox를 뺀 형태도 +절반만 회복했다(19ms — 플래너가 bbox 안 place 24,503건을 해시로 쌓는다). 실효 있는 것은 +`plan_cache_mode = force_custom_plan`뿐이었다. 다음에 같은 증상을 만나면 CTE부터 시도하지 않도록 +BI-44에 실측치와 함께 남겼다. + +전역으로 걸지 않은 것은 범위 판단이 아직 없기 때문이다. `prepareThreshold=0`이나 데이터소스 초기화 +SQL로 한 번에 끄면 다른 쿼리가 계획을 재사용해 얻는 이득까지 버리는데, 그 손익을 지금 근거 없이 +정하고 싶지 않았다. 감사가 끝나면 다시 본다. + +테스트는 **행 수에 의존하지 않는 것만** 자동화했다. 계획 모양을 단정하려면 bbox 안 place가 회원 +record보다 훨씬 많아야 하는데 Testcontainers DB는 그 조건을 만들 수 없고, 억지로 맞추면 +`FeedChannelPlanTests`가 겪은 플레이크를 되풀이한다. 그래서 트랜잭션의 `plan_cache_mode`와 +`pg_prepared_statements.generic_plans` **증분**만 본다. 증분으로 바꾼 것은 절대값으로 단정했다가 +같은 풀 커넥션을 먼저 쓴 테스트가 남긴 계수 3이 새어 들어와 실패했기 때문이다 — 프리페어된 문장은 +트랜잭션이 끝나도 커넥션에 남는다. + +## 곁에서 관측한 것 + +`docs/backend/implements/`의 번호가 두 쌍 겹쳐 있다 — BI-43(#203과 #206이 오늘 30분 차로 같은 +번호를 가져갔다)과 BI-38(`massive-scale-plan-observation`·`unlink-before-withdrawal`). 이 티켓의 +범위가 아니고 BI-43 한쪽은 검색 담당자 작업이라 옮기지 않고 관측만 남긴다. 내 리포트는 BI-44를 +썼다. 오늘 BD-46에서도 같은 일이 있었으므로(PR #201) 번호 발급 방식 자체를 볼 필요가 있다. diff --git a/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java b/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java index 40c5d033..aa24b15f 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java +++ b/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java @@ -45,6 +45,7 @@ import com.pinlog.pinlogback.domain.record.entity.Record; import com.pinlog.pinlogback.domain.record.repository.ContextRepository; import com.pinlog.pinlogback.domain.record.repository.RecordRepository; +import com.pinlog.pinlogback.global.common.QueryPlanPin; import com.pinlog.pinlogback.global.exception.InvalidRequestException; import com.pinlog.pinlogback.global.exception.ResourceNotFoundException; import com.pinlog.pinlogback.global.response.BoundsResponse; @@ -75,11 +76,13 @@ public class RecordService { private final ContextKeywordRepository contextKeywordRepository; private final RecordLatestCollectionRepository recordLatestCollectionRepository; private final ApplicationEventPublisher events; + private final QueryPlanPin queryPlanPin; public RecordService(PlaceRepository placeRepository, RecordRepository recordRepository, ContextRepository contextRepository, ContextAiStateRepository contextAiStateRepository, AiDerivedDataRepository aiDerivedDataRepository, ContextKeywordRepository contextKeywordRepository, - RecordLatestCollectionRepository recordLatestCollectionRepository, ApplicationEventPublisher events) { + RecordLatestCollectionRepository recordLatestCollectionRepository, ApplicationEventPublisher events, + QueryPlanPin queryPlanPin) { this.placeRepository = placeRepository; this.recordRepository = recordRepository; this.contextRepository = contextRepository; @@ -88,6 +91,7 @@ public RecordService(PlaceRepository placeRepository, RecordRepository recordRep this.contextKeywordRepository = contextKeywordRepository; this.recordLatestCollectionRepository = recordLatestCollectionRepository; this.events = events; + this.queryPlanPin = queryPlanPin; } /** @@ -256,6 +260,11 @@ public MapResponse map(Long memberId, BigDecimal swLat, BigDecimal swLng, BigDec String keyword) { boolean allPresent = requireWholeBbox(swLat, swLng, neLat, neLng); String likeKeyword = toLikeKeyword(keyword); + if (allPresent) { + // bbox 값이 걸러내는 양은 화면마다 다른데 generic plan은 그 값을 못 읽어 조인 순서를 + // 뒤집는다(S15P11A705-404). bbox 없는 경로는 이 문제가 없으므로 걸지 않는다. + queryPlanPin.forceCustomPlanForThisTransaction(); + } List found = allPresent ? recordRepository.findMarkersWithinBounds(memberId, swLat, swLng, neLat, neLng, likeKeyword) : recordRepository.findMarkers(memberId, likeKeyword); @@ -275,6 +284,11 @@ public MapResponse map(Long memberId, BigDecimal swLat, BigDecimal swLng, BigDec public MapKeywordsResponse mapKeywords(Long memberId, BigDecimal swLat, BigDecimal swLng, BigDecimal neLat, BigDecimal neLng) { boolean bounded = requireWholeBbox(swLat, swLng, neLat, neLng); + if (bounded) { + // 마커 조회와 같은 이유다(S15P11A705-404). bbox 없는 집계는 자기 취약점이 따로 있으나 + // 그 범위는 S15P11A705-405의 전수 감사가 판단한다. + queryPlanPin.forceCustomPlanForThisTransaction(); + } List rows = bounded ? contextKeywordRepository.findTopKeywordsInBounds( memberId, swLat, swLng, neLat, neLng, TOP_KEYWORD_LIMIT) diff --git a/src/main/java/com/pinlog/pinlogback/global/common/QueryPlanPin.java b/src/main/java/com/pinlog/pinlogback/global/common/QueryPlanPin.java new file mode 100644 index 00000000..f937d491 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/global/common/QueryPlanPin.java @@ -0,0 +1,49 @@ +package com.pinlog.pinlogback.global.common; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * 선택도가 파라미터 값에 좌우되는 쿼리를 custom plan에 묶는다(S15P11A705-404). + * + *

왜 필요한가. pgjdbc는 같은 문장을 5회 실행한 뒤 서버 프리페어로 전환하고, 그 시점에 + * Postgres가 generic plan을 고를 수 있다. generic plan은 파라미터 값을 모르므로 선택도를 추정에 + * 맡기는데, bbox처럼 값에 따라 걸러지는 양이 크게 달라지는 조건에서는 그 추정이 조인 순서를 + * 뒤집는다. 지도 bbox 조회 실측(record 1,012만)에서 마커 조회가 2.8ms에서 45.4~61.6ms로, + * 키워드 조회가 5.4ms에서 32~34ms로 벌어졌다. + * + *

SQL을 두 갈래로 나누는 것으로는 막지 못한다. {@code (:swLat IS NULL OR ...)}로 합치지 + * 않고 bbox 있는 문장과 없는 문장을 따로 두면 플래너가 bbox 조건의 존재를 안다는 이득은 + * 얻지만(S15P11A705-388), 값 자체는 여전히 파라미터다. generic plan은 그 값을 못 읽는다. + * + *

CTE로 감싸는 것도 답이 아니다. 실측에서 bbox를 포함한 {@code MATERIALIZED} CTE는 전혀 + * 나아지지 않았고(33ms), bbox를 뺀 CTE로 회원 record를 먼저 고정해도 절반만 회복했다(19ms — + * 플래너가 bbox 안 place를 해시로 쌓는다). 직관과 반대이므로 적어 둔다. + * + *

전역으로 걸지 않는 이유. {@code prepareThreshold=0}이나 데이터소스 초기화 SQL로 한 번에 + * 끄면 다른 쿼리가 계획을 재사용해 얻는 이득까지 함께 버린다. 어디까지 걸어야 하는지는 + * S15P11A705-405의 전수 감사가 판단하며, 그때까지는 실측으로 확인된 자리에만 건다. + */ +@Component +public class QueryPlanPin { + + private final JdbcTemplate jdbc; + + public QueryPlanPin(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * 이 트랜잭션이 끝날 때까지 계획 캐시를 끈다. + * + *

반드시 트랜잭션 안에서 불러야 한다. {@code SET LOCAL}은 트랜잭션 밖에서는 경고만 + * 남기고 아무 일도 하지 않는다 — 즉 호출부의 {@code @Transactional}이 사라지면 이 보호가 + * 조용히 없어진다. {@code MapBboxPlanCacheTests}가 그 상태를 잡는다. + * + *

범위를 트랜잭션으로 잡는 것이 요점이다. 커넥션 수준으로 걸면 그 커넥션을 물려받는 다음 + * 요청의 계획까지 바꿔 놓는다. + */ + public void forceCustomPlanForThisTransaction() { + jdbc.execute("SET LOCAL plan_cache_mode = force_custom_plan"); + } +} diff --git a/src/test/java/com/pinlog/pinlogback/domain/ai/repository/MapBboxPlanCacheTests.java b/src/test/java/com/pinlog/pinlogback/domain/ai/repository/MapBboxPlanCacheTests.java new file mode 100644 index 00000000..798c3943 --- /dev/null +++ b/src/test/java/com/pinlog/pinlogback/domain/ai/repository/MapBboxPlanCacheTests.java @@ -0,0 +1,194 @@ +package com.pinlog.pinlogback.domain.ai.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +import com.pinlog.pinlogback.domain.record.service.RecordService; +import com.pinlog.pinlogback.integration.IntegrationContainerSupport; + +/** + * 지도 bbox 조회 두 개가 generic plan으로 갈아타지 않는지 본다(S15P11A705-404). + * + *

무엇이 문제였나. pgjdbc는 같은 문장을 5회 실행한 뒤 서버 프리페어로 전환하고, 그 + * 시점에 Postgres가 generic plan을 고를 수 있다. generic plan은 bbox 선택도를 읽을 수 없어 + * 조인 순서를 뒤집는다 — 회원의 record에서 출발하는 대신 bbox 안 place를 전부 훑고 건마다 + * record를 찔러 대부분 0행을 얻는다. record 1,012만 볼륨 실측에서 마커 조회가 2.8ms에서 + * 45.4~61.6ms로, 키워드 조회가 5.4ms에서 32~34ms로 벌어졌다. + * + *

왜 계획 모양을 단정하지 않는가. 그 뒤집힘은 bbox 안 place 수가 회원의 record 수보다 + * 훨씬 많아야 일어난다. Testcontainers DB는 그 조건을 만들 수 없어 계획을 단정하면 고치기 전에도 + * 통과한다 — 판별력이 없다. 억지로 데이터를 만들어 맞추면 {@code FeedChannelPlanTests}가 겪은 + * 플레이크(행 수와 통계 갱신 타이밍에 판정이 붙었다 떨어졌다 하는 것)를 되풀이한다. 그래서 여기서는 + * 행 수와 무관한 두 가지만 본다. + * + *

    + *
  1. bbox 경로가 도는 트랜잭션의 {@code plan_cache_mode}가 실제로 고정되는가 + *
  2. 그 고정 상태에서 프리페어된 문장이 generic plan을 한 번도 쓰지 않는가 + * ({@code pg_prepared_statements.generic_plans}) + *
+ * + *

계획 모양과 실행 시간은 대량 볼륨에서 손으로 확인하고 구현 보고서에 남긴다 — 이 스위트가 + * 덮지 못하는 부분이므로 덮은 척하지 않는다. + * + *

패키지가 {@code ai.repository}인 이유: 이 판정은 {@code ContextKeywordRepository}의 + * bbox SQL을 대상으로 하며, 뒤집힘을 재현하는 쪽이 그 문장이다. {@code FeedChannelPlanTests}가 + * 같은 이유로 리포지터리 패키지에 있다. + */ +@SpringBootTest +class MapBboxPlanCacheTests extends IntegrationContainerSupport { + + /** 고정하지 않은 상태의 기본값. Postgres 기본값이며 이 값이면 프리페어 후 generic plan이 열린다. */ + private static final String NOT_PINNED = "auto"; + + private static final String PINNED = "force_custom_plan"; + + /** 서울 도심 한 조각. 실제 데이터가 없어도 계획·설정 판정에는 영향이 없다. */ + private static final BigDecimal SW_LAT = new BigDecimal("37.4"); + private static final BigDecimal SW_LNG = new BigDecimal("126.8"); + private static final BigDecimal NE_LAT = new BigDecimal("37.7"); + private static final BigDecimal NE_LNG = new BigDecimal("127.2"); + + /** pgjdbc가 서버 프리페어로 갈아타는 기준(prepareThreshold 기본 5)을 넘기는 횟수. */ + private static final int PAST_PREPARE_THRESHOLD = 7; + + @Autowired + private RecordService recordService; + + @Autowired + private ContextKeywordRepository contextKeywordRepository; + + @Autowired + private NamedParameterJdbcTemplate jdbc; + + /** + * 마커 조회의 bbox 경로가 계획을 고정한다. + * + *

테스트가 {@code @Transactional}이므로 서비스의 {@code @Transactional(readOnly = true)}는 + * 이 트랜잭션에 합류한다. {@code SET LOCAL}은 트랜잭션 범위라, 서비스가 걸었으면 호출이 끝난 + * 뒤 이 자리에서 읽힌다 — 그것이 이 단정의 근거다. + */ + @Test + @Transactional + void markerBboxQueryPinsTheCustomPlan() { + recordService.map(insertMember(), SW_LAT, SW_LNG, NE_LAT, NE_LNG, null); + + assertThat(planCacheMode()) + .as("마커 bbox 조회가 계획을 고정하지 않는다 — 프리페어 전환 후 generic plan으로 뒤집힌다") + .isEqualTo(PINNED); + } + + /** 키워드 조회의 bbox 경로가 계획을 고정한다. */ + @Test + @Transactional + void keywordBboxQueryPinsTheCustomPlan() { + recordService.mapKeywords(insertMember(), SW_LAT, SW_LNG, NE_LAT, NE_LNG); + + assertThat(planCacheMode()) + .as("키워드 bbox 조회가 계획을 고정하지 않는다 — 프리페어 전환 후 generic plan으로 뒤집힌다") + .isEqualTo(PINNED); + } + + /** + * bbox를 생략한 경로는 고정하지 않는다. + * + *

이 테스트가 위 두 개의 판별력을 만든다. 이것이 없으면 커넥션마다 무조건 고정해 놓고도 + * 위 두 단정이 통과하므로, "bbox 경로에만 걸었다"는 것이 확인되지 않는다. + * + *

bbox 없는 경로에도 계획 취약점이 있다는 관찰은 별개다({@code TOP_KEYWORDS_FOR_OWNER_SQL}의 + * 주석). 그 범위는 S15P11A705-405의 전수 감사가 판단한다 — 여기서 함께 걸면 감사 결과를 미리 + * 단정하는 것이 된다. + */ + @Test + @Transactional + void pathsWithoutBboxAreLeftAlone() { + recordService.mapKeywords(insertMember(), null, null, null, null); + + assertThat(planCacheMode()) + .as("bbox 없는 경로까지 고정됐다 — 이 티켓의 범위는 bbox 조회 둘이다(S15P11A705-405 참조)") + .isEqualTo(NOT_PINNED); + } + + /** + * 고정한 상태에서 프리페어 기준을 넘겨도 generic plan을 쓰지 않는다. + * + *

한 트랜잭션 안에서 반복하므로 커넥션이 하나로 고정된다 — pgjdbc의 프리페어 횟수는 커넥션마다 + * 세므로, 트랜잭션을 나누면 풀이 커넥션을 갈아 기준을 못 넘긴다. + * + *

{@code from_sql = false}인 항목만 본다. 프로토콜로 프리페어된 문장(pgjdbc가 만든 것)이 + * 그쪽이고, SQL {@code PREPARE}로 만든 것과 섞이면 무엇을 세는지 흐려진다. + * + *

절대값이 아니라 증분을 본다. 프리페어된 문장은 트랜잭션이 끝나도 풀 커넥션에 남고 + * 계수도 함께 남는다. 절대값으로 단정하면 같은 커넥션을 먼저 쓴 테스트가 올려 둔 값까지 세어 + * 실행 순서에 따라 판정이 갈린다 — 실제로 그렇게 실패했다(먼저 돈 + * {@link #theGenericPlanCounterActuallyMoves}가 남긴 3). 이 호출들이 새로 만든 generic + * plan이 없다는 것이 단정할 값이다. + */ + @Test + @Transactional + void pinnedTransactionNeverFallsBackToTheGenericPlan() { + long memberId = insertMember(); + long before = genericPlanCount(); + + for (int i = 0; i < PAST_PREPARE_THRESHOLD; i++) { + recordService.mapKeywords(memberId, SW_LAT, SW_LNG, NE_LAT, NE_LNG); + } + + assertThat(genericPlanCount() - before) + .as("고정했는데도 generic plan으로 갈아탔다. 계획 고정이 실제 쿼리 커넥션에 닿지 않는다") + .isZero(); + } + + /** + * 같은 자리에서 generic plan을 강제하면 계수가 오른다. + * + *

위 테스트의 판별력을 보이는 자리다. 계수가 어떤 상황에서도 0이면 위 단정은 아무것도 + * 확인하지 않는다. 여기서는 {@code force_generic_plan}으로 뒤집어 계수가 실제로 움직이는 것을 + * 보인다 — 그래야 위의 0이 "고정이 먹었다"는 뜻이 된다. + * + *

Testcontainers의 적은 행 수와 무관하게 결정적이다. 계획 선택을 비용 추정에 맡기지 않고 + * 강제하기 때문이다. + */ + @Test + @Transactional + void theGenericPlanCounterActuallyMoves() { + long memberId = insertMember(); + long before = genericPlanCount(); + + jdbc.getJdbcTemplate().execute("SET LOCAL plan_cache_mode = force_generic_plan"); + for (int i = 0; i < PAST_PREPARE_THRESHOLD; i++) { + contextKeywordRepository.findTopKeywordsInBounds(memberId, SW_LAT, SW_LNG, NE_LAT, NE_LNG, 5); + } + + assertThat(genericPlanCount() - before) + .as("generic plan을 강제했는데 계수가 오르지 않는다 — 이 계수로는 계획 전환을 관측할 수 없다") + .isPositive(); + } + + private String planCacheMode() { + return jdbc.queryForObject("SELECT current_setting('plan_cache_mode')", Map.of(), String.class); + } + + /** pgjdbc가 프리페어한 bbox 키워드 문장이 generic plan을 쓴 횟수. */ + private long genericPlanCount() { + List counts = jdbc.queryForList(""" + SELECT generic_plans FROM pg_prepared_statements + WHERE from_sql = false AND statement LIKE '%keyword_preset%' + """, Map.of(), Long.class); + return counts.stream().mapToLong(Long::longValue).sum(); + } + + private long insertMember() { + Long id = jdbc.queryForObject( + "INSERT INTO core.member (created_at) VALUES (now()) RETURNING id", Map.of(), Long.class); + return id == null ? 0L : id; + } +}