feat: 지역 목록(더보기·필터) API 와 필터칩 지역 수 - #272
Conversation
- 칩 목록이 라벨만 줘서 앱이 개수를 전부 1 로 채우고 있었다("있다/없다" 만 판별).
칩에 개수를 보여주거나 빈 칩을 가리려면 실제 수가 필요하다
- regionCount = 그 칩으로 좁혔을 때 나오는 지역 수. 목록 필터와 같은 판정
(RegionContent.has)을 쓴다. 판정이 둘로 갈리면 "12곳" 칩을 눌렀는데 9곳이
나오는, 예외도 로그도 없이 조용히 틀리는 어긋남이 생긴다
- 매 요청 세지 않는다. 세는 대상이 89곳 고정이고 그 콘텐츠는 주 1회 배치가
갈아끼운다. 기동 직후 1회 + 콘텐츠 적재 직후 무효화 + 1시간 안전망으로 둔다.
TTL 만 두면 새 배포의 첫 적재 뒤 최대 한 시간 동안 "전부 0" 인 칩이 나간다
- 캐시 키 공간은 하나다 — 지역·사용자별로 갈리지 않아 상한을 설계할 키가 없다
- 칩을 노출하는 두 자리(GET /categories, 홈의 filters)가 같은 값을 쓰게 했다.
한쪽만 고치면 앱이 화면마다 다른 규칙을 갖는다
- 필터칩과 지역 카드의 분류 태그를 다른 타입으로 갈랐다(CategoryTagResponse).
한 타입이면 지역 카드마다 전체 지역 수가 따라붙어 그 지역의 수로 읽힌다.
JSON 은 그대로 {key, label} 이다
- "더보기" 화면이 홈 응답을 재사용하고 있어 89곳 중 랭킹 상위 6곳만 보였다. 눌러도 새 데이터가 오지 않았고 페이징·카테고리 조회도 불가능했다 - 재료는 전부 이미 DB 에 있다 — 한산도는 방문자 집계, 볼거리 수·카테고리·이미지는 적재된 지역 콘텐츠, 대표 사진은 관광사진 갤러리. 목록 한 번의 외부 호출은 0 건이고 DB 조회 4회로 끝난다. 페이지마다 외부를 부르는 설계면 더보기 몇 번으로 TourAPI 일일 한도가 마른다 - 대표 사진은 이 페이지에 실릴 지역 것만 고른다. 89곳 전부를 고르면 안 보여줄 카드까지 계산한다 - 정렬·필터·페이지 자르기는 메모리에서 한다. 대상이 89곳 고정이고, 랭킹 점수가 DB 컬럼이 아니라 전체 표본으로 계산되는 값이라 SQL 로 내릴 정렬이 아니다 - sort 파라미터를 두지 않았다. popular 는 홈·추천이 쓰는 그 랭킹이라 기본값이 되고, reach 는 출발지 좌표가 있어야 정의되는데 이 엔드포인트는 그걸 받지 않는다 — 그쪽은 POST /regions/recommendations 가 이미 소유한다. 값 하나만 받는 정렬 키는 있는 척하는 정렬이라 아예 열지 않았다(FE 가 sort=popular 를 보내도 무시된다) - 트랜잭션으로 감싸지 않는다. 랭킹이 집계가 비었을 때 한 번 외부를 부르는 경로를 갖고 있어, 묶으면 그 호출이 read-timeout 동안 DB 커넥션을 잡는다(HomeService 와 같은 판단) - 페이지 메타는 data 안이 아니라 공통 래퍼의 pageResponse 로 나간다. 프론트가 적어 준 위치와 다르지만 모든 목록 API 가 같은 자리를 쓰게 하는 쪽을 택했다
- 페이지 경계를 전부 잠갔다: 첫 페이지·마지막 페이지(9건)·범위 밖 페이지(빈 목록) ·음수 page(0으로 자름)·상한 초과 size(100으로 자름) - 더보기가 실제로 새 데이터를 주는지 단언한다(0페이지와 1페이지 응답이 다른지). 이 작업이 고치려는 증상 그 자체라 회귀하면 여기서 걸린다 - 목록 3회 + 칩 1회를 부른 뒤 지역 콘텐츠 외부 호출 횟수가 0 인지 단언한다. 이 API 의 비용을 코드가 아니라 테스트가 잠그게 한 것. 관광빅데이터 최초 적재는 집계가 통째로 빌 때만 도는 별도 경로라 세지 않는다 - 칩 개수와 그 카테고리로 좁힌 pageResponse.totalElements 가 같은지 단언한다 - CategoryIntegrationTest 는 ALL 만 단언한다. 나머지 칩은 적재된 콘텐츠에 따라 달라지는 값이라 여기서 잠그면 콘텐츠와 함께 흔들린다
- living doc 이라 엔드포인트가 늘면 함께 갱신한다 - 지역 목록은 sort 파라미터가 없는 이유(도달시간 순은 출발지 좌표가 필요)와 페이지 메타가 공통 래퍼에 실린다는 점을 함께 적었다 — 프론트가 적어 준 계약과 다른 자리라 문서에서 먼저 드러나야 한다
|
Warning Review limit reached
Next review available in: 118 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (24)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
집계가 비어 있는 동안 `stored()` 가 요청마다 최초 적재를 다시 시도했다. `bootstrapping` 플래그는 동시 요청만 막고 finally 에서 바로 풀려, 순차로 들어오는 요청은 전부 새로 시도한다. 결과가 계속 비면(외부 장애·한도 소진· 미발행) 재시도가 트래픽에 비례해 늘어난다. 실측(2026-08-14, Testcontainers MySQL): 집계가 빈 상태에서 목록 5회에 관광빅데이터 15건(요청당 3건 = MAX_MONTHS_BACK 3개월 역행)이 나갔다. "더보기" 는 한 세션에 여러 페이지를 넘기는 화면이라 이 곱셈이 그대로 한도로 간다. 성과 없이 끝난 적재를 기억해 5분간 재시도를 막는다. 재시도가 트래픽과 무관하게 인스턴스당 시간당 12회로 묶이고, 외부가 회복하면 5분 안에 스스로 채워진다. 성공하면 집계가 차서 이 경로를 더 타지 않으므로 정상 상태 비용은 그대로 0 이다. `evictCache()` 는 대기도 함께 푼다 — 비워 놓고 대기가 남으면 강제 갱신이 아무 일도 하지 않는다. `refresh()` 의 "워머가 부른다" 주석도 함께 고친다. 이 집계에는 스케줄러도 워머도 없고, 부르는 곳은 요청 경로의 최초 적재 하나뿐이다.
"목록 한 번 = 외부 호출 0건" 을 잠근다던 단언이 TourAPI(areaCallCount)만 셌다. 정작 그 테스트는 방문자 집계를 비운 채 돌아서, 매 요청 관광빅데이터를 3건씩 부르는 동안 0 을 단언하고 지나갔다 — 가장 비싼 쪽을 세지 않은 셈이다. 집계를 채워 정상 상태를 만든 뒤 두 외부 모두 0 을 단언한다. 그리고 집계가 빈 degrade 상태에서 페이지를 5번 넘겨도 최초 적재를 되풀이하지 않는지를 따로 잠근다(직전 커밋의 재시도 간격). 이 테스트는 그 수정 없이는 실패한다 — 되돌려 확인했다.
리뷰 — 주장을 코드로 검증했다CodeRabbit 레이트리밋 대신 사람 리뷰어 자리에서 봤다. 주장이 실제로 성립하는지를 재는 데 집중했고, 재는 방법과 숫자를 아래 남긴다. 측정 환경: Testcontainers MySQL, Hibernate 검증한 것
① 의 DB 4회 내역 — 전부
좋았던 점 (근거 있는 것만)
🔴 [높음] "외부 호출 0건" 이 degrade 상태에서 성립하지 않았다 — 고쳤다실측 (수정 전): 방문자 집계가 빈 상태에서 목록 5회 →
원인은 기존 경로인 건 맞다. 다만 이 PR 이 그 비용이 곱해지는 자리를 새로 만들었다. "더보기" 는 한 세션에 페이지를 여러 번 넘기는 화면이라 홈 1회가 목록 5회가 된다. 자문 셋 2번(외부 API 한도)에 걸리는 항목이다. 덧붙여 확인한 것: 이 집계에는 스케줄러도 워머도 없다. 고침 — 🟠 [중간] "외부 호출 0건" 을 잠근다던 테스트가 가장 비싼 쪽을 세지 않았다 — 고쳤다
고침 — 🟡 [낮음]
|
| 안 | 내용 | 대가 |
|---|---|---|
| (a) 현행 유지 | name 하나 |
홈·추천과 한 글자도 안 다르다. 시군구만 쓰려는 화면은 파싱해야 한다 |
| (b) 필드를 나눠 추가 ← 추천 | name 은 그대로 두고 sigungu·sido 를 더한다 |
기존 화면 안 깨지고 앱이 필요한 조합을 고른다. 응답 2필드 증가 |
| (c) 프론트 요청대로 | name = "정선군" |
홈·추천 카드와 이름 규칙이 갈린다. 이미 나간 홈 응답과 불일치 |
(b) 로 간다면 홈·추천도 같이 해야 한다(HomeResponse.RegionCard·RegionRecommendResponse 도 서버에서 합쳐 내린다). 한쪽만 고치면 화면마다 규칙이 또 갈린다.
③ sort 파라미터를 뺀 것
"값 하나만 받는 정렬은 있는 척하는 정렬" 은 이 레포 결에 맞고, reach 가 좌표 없이는 정의되지 않는다는 근거도 코드로 확인했다. 다만 지금 앱이 sort=reach 를 보내고 있다면 Spring 이 모르는 파라미터를 버리므로 200 + 인기순이 나가고, 사용자는 "정렬이 안 먹네" 를 겪는다. 서버엔 아무 흔적도 안 남는다 — "조용한 실패를 만들지 않는다" 에 걸리는 모양이다.
| 안 | 내용 | 대가 |
|---|---|---|
| (a) 현행 유지 | 무시 | 가장 단순. 앱이 sort 를 안 보낸다는 확인이 전제다 |
(b) 받되 popular 만 허용 |
그 외 400 | 잘못 보내는 걸 즉시 안다. 목록 조회에서 잘못된 값은 자른다는 규약과 결이 다르지만, 그 규약은 page·size 자르기 얘기라 정렬 키에 그대로 적용되진 않는다 |
(c) reach 만 400 |
"출발지 좌표가 필요합니다 → POST /regions/recommendations" |
앱 개발자가 원인을 바로 안다. 파라미터를 다시 여는 셈이라 (b)와 같은 비용 |
무시로 가더라도 최소한 로그 한 줄은 남기는 편을 권한다. 프론트가 실제로 무엇을 보내는지 확인한 뒤 정할 일이라 코드는 건드리지 않았다.
정리
설계의 중심(외부를 요청 경로에서 뺀다 · 판정을 한 곳에 둔다 · 대표 사진을 페이지 것만 고른다)은 실측으로 확인했고 잘 서 있다. 고친 둘은 그 주장을 degrade 상태까지 참으로 만드는 작업이었다.
59480edfix: 방문자 집계가 비면 목록 요청마다 관광빅데이터를 다시 부르던 것be5d7e6test: 목록 조회의 외부 호출 0건을 두 외부 모두로 잠근다
수정 후 전체 1,344건 통과 · 컨벤션 훅 차단 0건. 머지는 하지 않았다 — ②·③ 합의가 남아 있다.
Situation
GET /api/v1/home) 응답을 그대로 재사용했는데, 홈이 주는 건 랭킹 상위 6곳뿐이다. 인구감소지역이 89곳인데 6곳만 보이고, 페이징도 카테고리 조회도 할 수 없었다.filters와GET /api/v1/categories가 라벨만 줘서, 앱이 개수를 전부 1 로 채워 "있다/없다" 만 판별하고 있었다. 칩에 개수를 보여주거나 빈 칩을 가리려면 실제 수가 필요하다.Task
sort={reach|popular}를 그대로 만들 것인가. 두 값이 코드 안에서 정의되는지 먼저 확인해야 했다.Action
목록 API —
GET /api/v1/regionscategory·page·size세 파라미터. 홈 카드와 같은 재료(한산도·볼거리 수·대표 이미지·카테고리)를 쓴다. 같은 화면의 더보기라 카드 모양이 달라질 이유가 없다.Paging이 단독으로 소유하고(기본 20, 최대 100), 잘못된 값은 거절하지 않고 자른다 — 음수 page 는 0 으로, 상한 초과 size 는 100 으로. 마지막 다음 페이지는 400 이 아니라 빈 목록이다(무한 스크롤이 정상적으로 한 번 더 요청한다).외부 호출을 늘리지 않는다
응답 필드가 어디서 오는지부터 확인했다. 전부 이미 우리 DB 에 적재된 값이었다.
crowdLevelcontentCount·categoriesimageUrlname·regionIdsize를 1→100 으로 키워도 4로 유지된다.single-flight는 동시 요청만 막고 곧바로 풀려, 순차 요청은 전부 새로 시도한다 — 실측에서 집계가 빈 상태의 목록 5회에 관광빅데이터 15건(요청당 3건)이 나갔다. 더보기는 페이지를 여러 번 넘기는 화면이라 이 곱셈이 그대로 한도로 간다.59480ed). 재시도가 트래픽과 무관하게 묶이고, 정상 상태 비용은 그대로 0 이다.sort는 만들지 않았다popularreachPOST /api/v1/regions/recommendations가 이미 소유한다. 좌표를 받는 쪽이 그 정렬의 자리다.sort파라미터 자체를 두지 않았다. 값 하나만 받는 정렬 키는 있는 척하는 정렬이고, 그게 제일 나쁘다. 프론트가sort=popular를 보내도 무시되고 정상 동작한다(Spring 이 모르는 쿼리 파라미터는 그냥 버린다).필터칩 지역 수
regionCount= 그 칩으로 좁혔을 때 나오는 지역 수.GET /api/v1/regions?category={key}의pageResponse.totalElements와 같은 값이고,ALL은 전체 지역 수(89)다.GET /api/v1/categories, 홈의filters)가 같은 값을 쓴다. 개수가 필요한 곳이 목록 화면만이 아니라 홈이기도 해서, 한쪽만 고치면 앱이 화면마다 다른 규칙을 갖게 된다.카테고리 칩과 지역 태그를 다른 타입으로 갈랐다
CategoryResponse.Item하나가 두 역할을 겸했다. 필터칩("이 칩으로 좁히면 몇 곳인가")과 지역 카드의 분류 태그("이 지역에 이런 게 있다")다.CategoryTagResponse로 분리했다.categories는 예전과 같은{key, label}이고, 개수는 필터칩에만 붙는다.Result
프론트가 적어 준 계약과 다르게 둔 곳이 세 군데다. 붙이기 전에 확인이 필요하다.
data안의page·totalPages·totalElementspageResponsename"정선군""정선군 · 강원특별자치도"sort{reach|popular}응답에
neighborIncluded를 함께 실었다.contentCount가 인접 50km 지역까지 합한 수인지 아닌지를 카드가 스스로 설명하게 하려는 것이다.작업을 마치기 전 자문 셋
검증
연관 이슈