Skip to content

perf(S15P11A705-404): 지도 bbox 쿼리를 custom plan에 묶는다 - #208

Open
minyongP wants to merge 2 commits into
devfrom
perf/S15P11A705-404-map-bbox-plan
Open

perf(S15P11A705-404): 지도 bbox 쿼리를 custom plan에 묶는다#208
minyongP wants to merge 2 commits into
devfrom
perf/S15P11A705-404-map-bbox-plan

Conversation

@minyongP

@minyongP minyongP commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

요약

지도 bbox 조회 두 개(GET /v1/records/map·GET /v1/records/map/keywords)가 실사용에서만 느린 실행 계획을 타고 있었습니다. pgjdbc가 같은 문장을 5회 실행한 뒤 서버 프리페어로 갈아타면 Postgres가 generic plan을 고르고, 그 계획은 bbox가 실제로 얼마나 걸러내는지 모르므로 조인 순서를 뒤집습니다. bbox가 넷 다 주어진 경로에만 SET LOCAL plan_cache_mode = force_custom_plan을 걸어 막습니다.

Jira (필수)

  • 키 또는 URL: S15P11A705-404

변경 사항

  • QueryPlanPin(신규) — SET LOCAL plan_cache_mode = force_custom_plan 한 문장을 쥔 컴포넌트. 왜 이 방식인지, 무엇이 듣지 않았는지가 클래스 주석에 있습니다.
  • RecordService.map·RecordService.mapKeywords — bbox가 넷 다 주어졌을 때만 위 핀을 겁니다. bbox 없는 경로는 다른 SQL을 쓰므로 대상이 아닙니다.
  • MapBboxPlanCacheTests(신규, 5개) — 트랜잭션의 plan_cache_modepg_prepared_statements.generic_plans 증분을 봅니다.
  • docs/backend/implements/BI-44 · 작업 로그 — 실측 원문과 탈락한 후보의 수치.

왜 이게 검증에서 안 걸렸나

리터럴을 넣어 EXPLAIN을 뜨면 항상 custom plan입니다. 드러내려면 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건)에서 출발하는 대신 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)

SQL을 두 갈래로 나눈 것(S15P11A705-388)으로는 막지 못합니다. 나누면 플래너가 bbox 조건의 존재를 알게 되지만, 은 여전히 파라미터이고 generic plan은 그 값을 못 읽습니다. BI-42의 성능 근거도 리터럴 측정이었습니다.

테스트 / 검증

  • ./gradlew clean check --no-daemon — BUILD SUCCESSFUL in 3m 6s
  • DB 변경 시 PostgreSQL 통합 테스트 — MapBboxPlanCacheTests 5개 통과
  • migration 변경 시 빈 DB migration 테스트 — 마이그레이션 없음
  • API 계약 변경 시 관련 문서 갱신 — 응답 계약 변경 없음(같은 결과를 더 빨리 줍니다)
  • 되돌리기 어려운 결정을 포함하면 docs/backend/decisions/에 BD 추가 — 새 BD 없음. 되돌리기 쉬운 설정 한 줄이고, 범위 확대 판단은 S15P11A705-405가 별도로 합니다

RED → GREEN

수정 전 3개 실패(autoforce_custom_plan, generic plan 증분 5), 수정 후 5개 통과. 핀을 일시 제거해 되돌리면 다시 3개가 깨지는 것(증분 2)까지 확인했습니다.

대량 볼륨 실사용 실측 — 같은 엔드포인트를 순차로 20회 호출, 10~20회차 중앙값:

경로 수정 전 수정 후
마커 bbox (HTTP) 67.5ms 38.8ms
키워드 bbox (HTTP) 72.1ms 25.6ms

수정 전에는 계단이 뚜렷했습니다(마커 9회차부터 2745ms→6587ms, 키워드 10회차부터 2237ms→60104ms). 수정 후 그 계단이 사라집니다.

리뷰 포인트

  1. QueryPlanPin을 서비스에서 부르는 배치가 맞는지. 보호해야 할 SQL은 리포지터리에 있는데 핀은 서비스에서 겁니다. 마커는 JPQL(Spring Data 인터페이스)이라 문장을 끼울 자리가 없고, 키워드만 리포지터리에 넣으면 두 자리가 갈라져서 서비스로 모았습니다. 커스텀 프래그먼트를 만들어 SQL 옆에 두는 편이 낫다고 보시면 그렇게 바꾸겠습니다.
  2. SET LOCAL은 트랜잭션 밖에서 경고만 남기고 아무 일도 하지 않습니다. 즉 호출부의 @Transactional(readOnly = true)가 사라지면 이 보호가 조용히 없어집니다. 테스트가 그 상태를 잡지만, 더 튼튼한 방법이 있으면 알려주세요.
  3. 계획 모양을 테스트로 단정하지 않은 판단. 조인 순서 뒤집힘은 bbox 안 place 수가 회원 record 수보다 훨씬 많아야 일어나고 Testcontainers DB는 그 조건을 못 만듭니다. 계획을 단정하면 고치기 전에도 통과해 판별력이 없고, 억지로 데이터를 맞추면 FeedChannelPlanTests가 겪은 플레이크를 되풀이합니다. 그래서 행 수와 무관한 두 가지(설정·계수 증분)만 자동화하고 계획 모양은 BI-44의 실측으로 갈음했습니다.
  4. CTE로 감싸는 방식이 듣지 않는다는 실측. 직관과 반대라 BI-44에 수치까지 남겼습니다 — bbox 포함 MATERIALIZED CTE는 33.17ms(개선 0), bbox 제외 CTE는 18.94ms(절반만 회복, place 24,503건을 해시로 쌓습니다).

미결 / 후속

  • 전수 감사(S15P11A705-405) — 같은 실패가 범위·부등호·LIKE·IN을 쓰는 다른 쿼리에도 있는지. 피드·검색은 AI 파트 소유라 그 티켓은 전달 문구까지만 만듭니다.
  • bbox 없는 키워드 집계의 계획 취약점(TOP_KEYWORDS_FOR_OWNER_SQL 주석)은 이 PR에서 건드리지 않았습니다. 함께 걸면 감사 결과를 미리 단정하는 것이 됩니다.
  • 데이터소스 전역 설정(prepareThreshold=0 등)은 감사 결과를 보고 판단합니다.
  • 측정에 자원 한도를 얹지 않았습니다(compose.bench.yaml 미적용). 1,012만 행이 전부 페이지 캐시에 들어간 하한값이라 절대 수치를 운영 예측에 쓰면 안 됩니다 — 이 PR이 근거로 삼는 것은 배율과 계획 모양입니다.

이 PR과 무관한 관측

docs/backend/implements/의 번호가 두 쌍 겹쳐 있습니다 — BI-43(#203과 #206이 오늘 30분 차로 같은 번호를 가져갔습니다)과 BI-38(massive-scale-plan-observation·unlink-before-withdrawal). 기록 구역이라 옮기지 않고 관측만 남기며, 제 리포트는 BI-44를 썼습니다. BI-43 한쪽은 검색 담당자(@colosair) 작업이라 합의가 필요합니다. 오늘 BD-46에서도 같은 일이 있었으니(#201) 번호 발급 방식 자체를 볼 필요가 있어 보입니다.

pgjdbc가 같은 문장을 5회 실행한 뒤 서버 프리페어로 갈아타면 Postgres가 generic plan을
고른다. 그 계획은 bbox가 실제로 얼마나 걸러내는지 모르므로 조인 순서를 뒤집는다 —
회원의 record에서 출발하는 대신 bbox 안 place를 전부 훑고 건마다 record를 찔러 대부분
0행을 얻는다. record 1,012만 볼륨에서 마커 조회 2.8ms→45~62ms(16~22배), 키워드 조회
5.4ms→32~34ms(6배).

리터럴을 넣어 EXPLAIN을 뜨면 항상 custom plan이라 이 전환은 검증에서 드러나지 않는다.
SQL을 두 갈래로 나눈 것(S15P11A705-388)으로도 막지 못한다 — 나누면 플래너가 bbox 조건의
존재를 알게 되지만 값은 여전히 파라미터다.

bbox가 넷 다 주어진 경로에만 SET LOCAL plan_cache_mode = force_custom_plan을 건다.
CTE로 감싸는 두 방식은 실측에서 듣지 않았다(bbox 포함 33ms, bbox 제외 19ms). 전역
설정은 다른 쿼리의 계획 재사용 이득까지 버리므로 범위 판단(S15P11A705-405)까지 보류한다.

테스트는 행 수에 의존하지 않는 것만 자동화했다 — 트랜잭션의 plan_cache_mode와
pg_prepared_statements.generic_plans 증분. 계획 모양은 Testcontainers에서 재현되지 않아
대량 볼륨 실측으로 갈음하고 BI-44에 남겼다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@minyongP minyongP added the performance 성능 개선 (perf) label Aug 7, 2026
@minyongP minyongP self-assigned this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance 성능 개선 (perf)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant