Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions docs/backend/implements/BI-44-2026-08-07-map-bbox-plan-pinning.md
Original file line number Diff line number Diff line change
@@ -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이 그 상태를 잡는다.
Original file line number Diff line number Diff line change
@@ -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) 번호 발급 방식 자체를 볼 필요가 있다.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -88,6 +91,7 @@ public RecordService(PlaceRepository placeRepository, RecordRepository recordRep
this.contextKeywordRepository = contextKeywordRepository;
this.recordLatestCollectionRepository = recordLatestCollectionRepository;
this.events = events;
this.queryPlanPin = queryPlanPin;
}

/**
Expand Down Expand Up @@ -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<MapMarkerResponse> found = allPresent
? recordRepository.findMarkersWithinBounds(memberId, swLat, swLng, neLat, neLng, likeKeyword)
: recordRepository.findMarkers(memberId, likeKeyword);
Expand All @@ -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<TopKeywordRow> rows = bounded
? contextKeywordRepository.findTopKeywordsInBounds(
memberId, swLat, swLng, neLat, neLng, TOP_KEYWORD_LIMIT)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.pinlog.pinlogback.global.common;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

/**
* 선택도가 파라미터 값에 좌우되는 쿼리를 <b>custom plan에 묶는다</b>(S15P11A705-404).
*
* <p><b>왜 필요한가.</b> pgjdbc는 같은 문장을 5회 실행한 뒤 서버 프리페어로 전환하고, 그 시점에
* Postgres가 generic plan을 고를 수 있다. generic plan은 파라미터 값을 모르므로 선택도를 추정에
* 맡기는데, bbox처럼 값에 따라 걸러지는 양이 크게 달라지는 조건에서는 그 추정이 조인 순서를
* 뒤집는다. 지도 bbox 조회 실측(record 1,012만)에서 마커 조회가 2.8ms에서 45.4~61.6ms로,
* 키워드 조회가 5.4ms에서 32~34ms로 벌어졌다.
*
* <p><b>SQL을 두 갈래로 나누는 것으로는 막지 못한다.</b> {@code (:swLat IS NULL OR ...)}로 합치지
* 않고 bbox 있는 문장과 없는 문장을 따로 두면 <em>플래너가 bbox 조건의 존재를 안다</em>는 이득은
* 얻지만(S15P11A705-388), 값 자체는 여전히 파라미터다. generic plan은 그 값을 못 읽는다.
*
* <p><b>CTE로 감싸는 것도 답이 아니다.</b> 실측에서 bbox를 포함한 {@code MATERIALIZED} CTE는 전혀
* 나아지지 않았고(33ms), bbox를 뺀 CTE로 회원 record를 먼저 고정해도 절반만 회복했다(19ms —
* 플래너가 bbox 안 place를 해시로 쌓는다). 직관과 반대이므로 적어 둔다.
*
* <p><b>전역으로 걸지 않는 이유.</b> {@code prepareThreshold=0}이나 데이터소스 초기화 SQL로 한 번에
* 끄면 다른 쿼리가 계획을 재사용해 얻는 이득까지 함께 버린다. 어디까지 걸어야 하는지는
* S15P11A705-405의 전수 감사가 판단하며, 그때까지는 실측으로 확인된 자리에만 건다.
*/
@Component
public class QueryPlanPin {

private final JdbcTemplate jdbc;

public QueryPlanPin(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}

/**
* 이 트랜잭션이 끝날 때까지 계획 캐시를 끈다.
*
* <p><b>반드시 트랜잭션 안에서 불러야 한다.</b> {@code SET LOCAL}은 트랜잭션 밖에서는 경고만
* 남기고 아무 일도 하지 않는다 — 즉 호출부의 {@code @Transactional}이 사라지면 이 보호가
* <b>조용히</b> 없어진다. {@code MapBboxPlanCacheTests}가 그 상태를 잡는다.
*
* <p>범위를 트랜잭션으로 잡는 것이 요점이다. 커넥션 수준으로 걸면 그 커넥션을 물려받는 다음
* 요청의 계획까지 바꿔 놓는다.
*/
public void forceCustomPlanForThisTransaction() {
jdbc.execute("SET LOCAL plan_cache_mode = force_custom_plan");
}
}
Loading
Loading