Skip to content

feat: 지역 목록(더보기·필터) API 와 필터칩 지역 수 - #272

Open
sevineleven wants to merge 6 commits into
devfrom
feat/266-region-list-api
Open

feat: 지역 목록(더보기·필터) API 와 필터칩 지역 수#272
sevineleven wants to merge 6 commits into
devfrom
feat/266-region-list-api

Conversation

@sevineleven

@sevineleven sevineleven commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Situation

  • "이번달 추천 여행지 더보기" 를 눌러도 새 데이터가 오지 않았다. 지역 목록 전용 API 가 없어 그 화면이 홈(GET /api/v1/home) 응답을 그대로 재사용했는데, 홈이 주는 건 랭킹 상위 6곳뿐이다. 인구감소지역이 89곳인데 6곳만 보이고, 페이징도 카테고리 조회도 할 수 없었다.
  • 필터칩에 개수가 없었다. 홈의 filtersGET /api/v1/categories 가 라벨만 줘서, 앱이 개수를 전부 1 로 채워 "있다/없다" 만 판별하고 있었다. 칩에 개수를 보여주거나 빈 칩을 가리려면 실제 수가 필요하다.

Task

  • 89곳을 페이지로 끊어 주되, 더보기 몇 번으로 TourAPI 일일 한도가 마르지 않게 한다. 페이지마다 외부를 부르는 설계면 이 API 자체가 사고다.
  • 프론트가 적어 준 sort={reach|popular} 를 그대로 만들 것인가. 두 값이 코드 안에서 정의되는지 먼저 확인해야 했다.
  • 카테고리 개수를 어디서 언제 계산할 것인가. 89곳은 고정이고 느리게 변하는데 매 요청 세면 안 바뀌는 답을 반복해 만드는 셈이다.

Action

목록 API — GET /api/v1/regions

  • category·page·size 세 파라미터. 홈 카드와 같은 재료(한산도·볼거리 수·대표 이미지·카테고리)를 쓴다. 같은 화면의 더보기라 카드 모양이 달라질 이유가 없다.
  • 페이지네이션은 규약 그대로다. 기본·상한은 Paging 이 단독으로 소유하고(기본 20, 최대 100), 잘못된 값은 거절하지 않고 자른다 — 음수 page 는 0 으로, 상한 초과 size 는 100 으로. 마지막 다음 페이지는 400 이 아니라 빈 목록이다(무한 스크롤이 정상적으로 한 번 더 요청한다).
  • 정렬·필터·페이지 자르기를 메모리에서 한다. 대상이 고시로 정해진 89곳 고정이고, 랭킹 점수가 DB 컬럼이 아니라 전체 표본으로 계산되는 값이라 SQL 로 내릴 수 있는 정렬이 아니다.

외부 호출을 늘리지 않는다

응답 필드가 어디서 오는지부터 확인했다. 전부 이미 우리 DB 에 적재된 값이었다.

필드 출처 요청 경로에서
crowdLevel 방문자 집계 테이블(관광빅데이터, 주기 적재) DB 조회 1회
contentCount·categories 적재된 지역 콘텐츠(TourAPI, 주 1회 배치) DB 조회 1회 (89행)
imageUrl 관광사진 갤러리 + 중심 관광지, 없으면 콘텐츠 표본 이미지 DB 조회 2회 (이 페이지 것만)
name·regionId 지역 마스터 (부팅 시 1회 적재, 인메모리) 0회
  • 목록 한 번의 외부 호출 = 0 건. DB 조회 4회로 끝난다. 대표 사진은 페이지에 실릴 지역 것만 고른다 — 89곳 전부를 고르면 안 보여줄 카드까지 계산한다.
  • 통합 테스트가 이 숫자를 잠근다: 목록 3회 + 칩 1회를 호출한 뒤 두 외부(지역 콘텐츠·방문자 집계) 호출 횟수가 모두 0 인지 단언한다. 실측(리뷰, Hibernate statement 카운트): 정상 상태에서 요청당 DB 4회, size 를 1→100 으로 키워도 4로 유지된다.
  • 예외가 하나 있다. 방문자 집계가 통째로 비어 있으면(새 환경 첫 요청) 랭킹이 적재를 시도한다. 홈·추천과 공유하는 기존 경로다.
    • 처음엔 "한 번만 시도한다" 고 적었는데 사실이 아니었다. single-flight 는 동시 요청만 막고 곧바로 풀려, 순차 요청은 전부 새로 시도한다 — 실측에서 집계가 빈 상태의 목록 5회에 관광빅데이터 15건(요청당 3건)이 나갔다. 더보기는 페이지를 여러 번 넘기는 화면이라 이 곱셈이 그대로 한도로 간다.
    • 그래서 성과 없이 끝난 적재에 5분 재시도 간격을 뒀다(59480ed). 재시도가 트래픽과 무관하게 묶이고, 정상 상태 비용은 그대로 0 이다.

sort 는 만들지 않았다

요청값 코드 안의 근거 결정
popular 방문자수를 베이지안 보정한 랭킹. 홈·추천이 이미 쓰는 그 순서다 기본이자 유일한 정렬로 채택
reach 도달시간은 출발지 좌표·이동수단이 있어야 정의된다. 이 엔드포인트는 그걸 받지 않는다 미지원
  • 도달시간 순은 POST /api/v1/regions/recommendations 가 이미 소유한다. 좌표를 받는 쪽이 그 정렬의 자리다.
  • 정렬이 하나뿐이라 sort 파라미터 자체를 두지 않았다. 값 하나만 받는 정렬 키는 있는 척하는 정렬이고, 그게 제일 나쁘다. 프론트가 sort=popular 를 보내도 무시되고 정상 동작한다(Spring 이 모르는 쿼리 파라미터는 그냥 버린다).

필터칩 지역 수

  • regionCount = 그 칩으로 좁혔을 때 나오는 지역 수. GET /api/v1/regions?category={key}pageResponse.totalElements 와 같은 값이고, ALL 은 전체 지역 수(89)다.
  • 목록 필터와 개수가 같은 판정을 쓴다. 지역 콘텐츠 값객체에 "이 칩에 걸리는가" 메서드 하나를 두고 필터와 집계가 그것을 공유한다. 판정이 둘로 갈리면 "12곳" 이라고 적힌 칩을 눌렀는데 9곳이 나온다 — 예외도 로그도 없이 조용히 틀리는 종류다.
  • 매 요청 세지 않는다. 세는 대상이 89곳 고정이고, 그 콘텐츠는 주 1회 배치가 갈아끼운다.
질문
캐시 키 공간 하나. 지역·사용자별로 갈리지 않고 서비스 전체에 칩 5개의 개수 묶음 하나뿐이라 상한을 설계할 키가 없다
언제 계산하나 기동 직후 1회 + 콘텐츠 적재가 끝나면 즉시 무효화 + 안전망으로 1시간마다
왜 무효화까지 두나 TTL 만 두면 새 배포의 첫 적재 직후 사용자가 "전부 0" 인 칩을 최대 한 시간 본다. 원본이 바뀐 순간을 아는 자리에서 버리는 편이 정확하다
재계산 비용 89행 조회 한 번. 안전망이 하루 24번 돌아도 무시할 만하다
  • 칩을 노출하는 두 자리(GET /api/v1/categories, 홈의 filters)가 같은 값을 쓴다. 개수가 필요한 곳이 목록 화면만이 아니라 홈이기도 해서, 한쪽만 고치면 앱이 화면마다 다른 규칙을 갖게 된다.

카테고리 칩과 지역 태그를 다른 타입으로 갈랐다

  • 기존에는 CategoryResponse.Item 하나가 두 역할을 겸했다. 필터칩("이 칩으로 좁히면 몇 곳인가")과 지역 카드의 분류 태그("이 지역에 이런 게 있다")다.
  • 여기에 개수를 얹으면 지역 카드마다 전체 지역 수가 따라붙어, 읽는 쪽이 그것을 그 지역의 수로 오해한다. 그래서 태그를 CategoryTagResponse 로 분리했다.
  • JSON 은 그대로다. 지역 카드의 categories 는 예전과 같은 {key, label} 이고, 개수는 필터칩에만 붙는다.

Result

프론트가 적어 준 계약과 다르게 둔 곳이 세 군데다. 붙이기 전에 확인이 필요하다.

항목 프론트 요청 실제 이유
페이지 메타 data 안의 page·totalPages·totalElements 공통 래퍼의 pageResponse 모든 목록 API 가 같은 자리에서 페이지 정보를 준다(api-convention). 코스 목록·지역 장소 목록이 이미 그렇다
name "정선군" "정선군 · 강원특별자치도" 홈·추천 카드가 쓰는 포맷. 같은 카드가 화면마다 다른 이름을 갖지 않게
sort {reach|popular} 파라미터 없음 위 표 참고. 보내도 무시되고 정상 동작

응답에 neighborIncluded 를 함께 실었다. contentCount 가 인접 50km 지역까지 합한 수인지 아닌지를 카드가 스스로 설명하게 하려는 것이다.

작업을 마치기 전 자문 셋

  1. 운영에서 버티는가 — 테이블·인덱스·부팅 적재를 늘리지 않았다. 마이그레이션이 없다. 요청당 89행 조회 한 번이 늘 뿐이고(약 20KB), 칩 개수는 인메모리 한 덩어리다.
  2. 외부 API 한도 — 늘어난 호출이 0 건이다. 배치·스케줄러도 건드리지 않았다. 이 PR 은 오히려 "더보기 = 홈 재호출" 이던 화면을 한도를 안 쓰는 경로로 옮긴다.
  3. 코스의 완성도 — 직접적으로는 무관한 조회 API 다. 다만 사용자가 볼 수 있는 후보 지역이 6곳에서 89곳으로 열리고, 빈 카테고리를 칩에서 가릴 수 있게 되어 "눌렀는데 아무것도 없다" 가 줄어든다.

검증

  • 페이지 경계를 전부 잠갔다: 첫 페이지·마지막 페이지(9건)·범위 밖 페이지(빈 목록)·음수 page(0으로 자름)·상한 초과 size(100으로 자름).
  • 더보기가 실제로 새 데이터를 주는지를 단언한다(0페이지와 1페이지 응답이 다른지). 이 PR 이 고치려는 증상 그 자체다.
  • 칩 개수와 그 카테고리로 좁힌 전체 건수가 같은지 단언한다.

연관 이슈

- 칩 목록이 라벨만 줘서 앱이 개수를 전부 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 파라미터가 없는 이유(도달시간 순은 출발지 좌표가 필요)와
  페이지 메타가 공통 래퍼에 실린다는 점을 함께 적었다 — 프론트가 적어 준 계약과
  다른 자리라 문서에서 먼저 드러나야 한다
@sevineleven sevineleven added the feat 새 기능 (외부에 보이는 변화) label Aug 13, 2026
@sevineleven sevineleven self-assigned this Aug 13, 2026
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sevineleven, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 79f81d82-2d08-46c4-a2c5-152b0492c46c

📥 Commits

Reviewing files that changed from the base of the PR and between c20ff40 and be5d7e6.

📒 Files selected for processing (24)
  • docs/specs/api-spec.md
  • src/main/java/com/offway/core/trip/controller/CategoryApi.java
  • src/main/java/com/offway/core/trip/controller/CategoryController.java
  • src/main/java/com/offway/core/trip/controller/RegionListApi.java
  • src/main/java/com/offway/core/trip/controller/RegionListController.java
  • src/main/java/com/offway/core/trip/controller/dto/CategoryResponse.java
  • src/main/java/com/offway/core/trip/controller/dto/CategoryTagResponse.java
  • src/main/java/com/offway/core/trip/controller/dto/HomeResponse.java
  • src/main/java/com/offway/core/trip/controller/dto/RegionListResponse.java
  • src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java
  • src/main/java/com/offway/core/trip/domain/CategoryCounts.java
  • src/main/java/com/offway/core/trip/domain/RegionContent.java
  • src/main/java/com/offway/core/trip/service/HomeService.java
  • src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java
  • src/main/java/com/offway/core/trip/service/RegionContentRefreshService.java
  • src/main/java/com/offway/core/trip/service/RegionListService.java
  • src/main/java/com/offway/core/trip/service/RegionRankingService.java
  • src/main/java/com/offway/core/trip/service/dto/HomeResult.java
  • src/main/java/com/offway/core/trip/service/dto/RegionList.java
  • src/test/java/com/offway/core/trip/controller/CategoryIntegrationTest.java
  • src/test/java/com/offway/core/trip/controller/HomeIntegrationTest.java
  • src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java
  • src/test/java/com/offway/core/trip/domain/CategoryCountsTest.java
  • src/test/java/com/offway/core/trip/domain/RegionContentTest.java

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

집계가 비어 있는 동안 `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번 넘겨도 최초 적재를 되풀이하지 않는지를
따로 잠근다(직전 커밋의 재시도 간격). 이 테스트는 그 수정 없이는 실패한다 —
되돌려 확인했다.
@sevineleven

Copy link
Copy Markdown
Contributor Author

리뷰 — 주장을 코드로 검증했다

CodeRabbit 레이트리밋 대신 사람 리뷰어 자리에서 봤다. 주장이 실제로 성립하는지를 재는 데 집중했고, 재는 방법과 숫자를 아래 남긴다.

측정 환경: Testcontainers MySQL, Hibernate Statistics.getPrepareStatementCount() + org.hibernate.SQL 캡처. 측정용 임시 테스트는 커밋하지 않았다.

검증한 것

# PR 의 주장 어떻게 쟀나 결과
목록 한 번 = 외부 0건 · DB 4회 size 1·5·20·50·100 으로 쿼리 수 실측 정상 상태에선 사실. 요청당 정확히 4, 페이지 크기와 무관 (N+1 없음). 단 degrade 상태는 아래
적재 직후 칩 개수 즉시 무효화 배선 추적 + invalidate() 를 빼고 스위트 실행 사실. RegionContentRefreshService.refresh() 에서 replaceAll 직후 호출되고, 빼면 HomeIntegrationTest 가 빨간불
CategoryTagResponse 신설해도 JSON 그대로 dev 의 옛 CategoryResponse.Item 과 필드명·선언 순서 대조 사실. {key,label} 동일·순서 동일 → 직렬화 결과 동일. 홈 filtersregionCount추가됨(호환)
페이지네이션 규약 코드 + 경계 테스트 5종 준수. page·size 두 개 고정, 선택, Paging 이 기본 20·상한 100 단독 소유, 잘못된 값은 자름
@ApiResponse 전수 · @Parameter 숫자 명시 RegionListApi + SecurityConfig 대조 준수. 200·400·401 전부 도달 가능하고 문서화됨(401 은 anyRequest().authenticated() 라 맞다)
컨벤션 훅 변경 파일 전수 차단 0건
전체 테스트 ./gradlew cleanTest test 1,344건 통과 (실패 0 · skip 21)
dev 와의 거리 git fetch origin dev 뒤처짐 0 (dev 기준 4 커밋 앞)

① 의 DB 4회 내역 — 전부 IN 절 배치라 지역마다 묻는 곳이 없다. 이게 핵심이고, 실제로 그렇다.

  1. region_content (89 id) · 2. region_visitor_aggregate · 3. gallery_photo (페이지 id 만) · 4. hub_attraction (페이지 id 만)

size=100 한 번도 DB 4회다. "페이지 크기가 커져도 4" 는 실측으로 확인했다.

좋았던 점 (근거 있는 것만)

  • 판정을 한 곳에 뒀다. RegionContent.has(Category) 를 목록 필터와 칩 집계가 공유한다. "12곳" 칩을 눌렀는데 9곳이 나오는 종류의 어긋남을 규약이 아니라 구조로 막았고, 통합 테스트가 실제로 그 일치를 단언한다.
  • 대표 사진을 페이지 것만 고른다. 89곳 전부를 고르는 흔한 실수를 피했고, 그래서 3·4번 쿼리가 페이지 크기에 비례해도 건수는 1회씩이다.
  • 무효화가 말뿐이 아니다. 빼고 돌려 보니 스위트가 빨간불이 된다. "TTL 만 두면 새 배포 첫 적재 뒤 전부 0" 이라는 진단도 정확하다.
  • 마이그레이션 0건 · 부팅 적재 0건. 자문 셋 1번에 대한 답이 실제로 맞다.

🔴 [높음] "외부 호출 0건" 이 degrade 상태에서 성립하지 않았다 — 고쳤다

실측 (수정 전): 방문자 집계가 빈 상태에서 목록 5회 →

DB 관광빅데이터 TourAPI
집계 채워짐(정상) 20 (4/요청) 0 0
집계 비어 있음 35 (7/요청) 15 (3/요청) 0

원인은 RegionRankingService.stored() 다. bootstrapping 플래그는 동시 요청만 막고 finally 에서 곧바로 풀려, 순차로 들어오는 요청은 전부 새로 적재를 시도한다. 결과가 계속 비면(외부 장애·한도 소진·미발행) 재시도가 트래픽에 비례해 늘어난다. 본문의 "한 번 적재를 시도한다 / single-flight 로 묶여 있어" 는 여기를 과소 서술했다 — single-flight 는 동시성만 막지 반복은 못 막는다.

기존 경로인 건 맞다. 다만 이 PR 이 그 비용이 곱해지는 자리를 새로 만들었다. "더보기" 는 한 세션에 페이지를 여러 번 넘기는 화면이라 홈 1회가 목록 5회가 된다. 자문 셋 2번(외부 API 한도)에 걸리는 항목이다.

덧붙여 확인한 것: 이 집계에는 스케줄러도 워머도 없다. refresh() 의 프로덕션 호출자는 요청 경로 하나뿐인데 주석은 "워머가 부른다(#193)" 라고 되어 있었다(hasLatest() 도 테스트에서만 쓰인다 — 워머가 사라진 흔적으로 보인다. 이 PR 범위 밖이라 두었다).

고침59480ed: 성과 없이 끝난 적재를 기억해 5분간 재시도를 막는다. 재시도가 트래픽과 무관하게 인스턴스당 시간당 12회로 묶이고, 외부가 회복하면 5분 안에 스스로 채워진다. 성공 시 집계가 차서 이 경로를 더 타지 않으므로 정상 상태 비용은 그대로 0이다. evictCache() 는 대기도 함께 푼다. 낡은 주석도 정정.

🟠 [중간] "외부 호출 0건" 을 잠근다던 테스트가 가장 비싼 쪽을 세지 않았다 — 고쳤다

목록_조회는_지역_콘텐츠_외부_호출을_한_번도_하지_않는다tourApiClient.areaCallCount() 셌다. 카운터 자체는 진짜다(loadContent() 에서 증가하는 걸 확인했다). 문제는 정작 그 테스트가 방문자 집계를 비운 채 돌아서, 매 요청 관광빅데이터를 3건씩 부르는 동안 0 을 단언하고 지나갔다는 점이다. 단언이 거짓은 아니지만 비용이 새는 쪽을 보지 않았다.

고침be5d7e6: 집계를 채워 정상 상태를 만든 뒤 두 외부 모두 0 을 단언한다. 그리고 degrade 상태에서 페이지를 5번 넘겨도 적재를 되풀이하지 않는지를 따로 잠갔다. 이 테스트는 위 수정 없이는 실패한다 — 되돌려 확인했다.

🟡 [낮음] api-spec.mdregionCount 예시가 실측값이 아니다

61·34·12·47 은 지어낸 수로 보인다. 예시 페이로드라 치명적이진 않지만, 프론트가 이 문서로 분포를 가늠하면(“체험은 12곳뿐이니 칩을 숨기자”) 어긋난다. 실측으로 바꾸거나 "예시" 임을 한 줄 붙이는 편이 낫다.


사람이 프론트와 합의할 것 — 고치지 않았다

여긴 코드 문제가 아니라 계약 문제라 손대지 않았다. 선택지와 대가만 정리한다.

name"정선군 · 강원특별자치도" 로 준 것

"홈·추천 카드와 같은 포맷" 이라는 이유는 타당하다. 문제는 앱이 시군구만 필요할 때다 — 카드 폭에 안 맞거나 목록에서 지역명만 굵게 쓰려면 " · " 로 잘라야 하고, 그건 클라이언트가 서버 문자열을 파싱하는 것이다. 구분자를 바꾸는 순간 조용히 깨지고, "서버가 판정을 소유한다" 는 이 레포 원칙과도 어긋난다.

내용 대가
(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 상태까지 참으로 만드는 작업이었다.

  • 59480ed fix: 방문자 집계가 비면 목록 요청마다 관광빅데이터를 다시 부르던 것
  • be5d7e6 test: 목록 조회의 외부 호출 0건을 두 외부 모두로 잠근다

수정 후 전체 1,344건 통과 · 컨벤션 훅 차단 0건. 머지는 하지 않았다 — ②·③ 합의가 남아 있다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 새 기능 (외부에 보이는 변화)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

지역 목록(더보기·필터) API — 89곳 페이지 조회 + 카테고리별 개수

1 participant