From 62264e86dbd6704d9dd64fd27df019cae1a02d0d Mon Sep 17 00:00:00 2001 From: LeeJeongHeon02 Date: Tue, 21 Jul 2026 12:04:56 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=A7=A4=EC=B2=B4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20API=EC=97=90=20offset/limit=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매체 사진(photoUrl)이 고해상도 원본이라 목록을 한 번에 전부 내려주면 트래픽이 크다. 이미지 자체를 리사이즈하는 대신, 화면에 실제로 보일 만큼만 불러오도록 offset/limit 기반 페이지네이션을 추가했다 (예: 첫 진입 limit=10, 스크롤마다 offset을 누적하며 limit=6으로 추가 로드하는 무한 스크롤 전제). - MediaUnitListResponse에 hasMore 추가 - MediaUnitQueryService: RealtimeGraphService와 동일한 clamp 패턴 (limit 기본 10, 최대 50, 범위 밖이면 자동 clamp). 페이지네이션을 먼저 적용한 뒤 기간 충돌 조회(findConflictingMediaUnitIds)를 페이지 분량의 ID로만 실행하도록 순서를 바꿔 쿼리 규모도 줄였다 - Swagger 문서, campaign-registration-api-spec.md 갱신 검증: 페이지네이션 케이스(기본 10개+hasMore, offset 적용한 다음 페이지, limit 최대치 clamp) 테스트 추가, 전체 295개 테스트 통과. Co-Authored-By: Claude Sonnet 5 --- docs/campaign-registration-api-spec.md | 19 ++++++- .../media/controller/MediaUnitController.java | 7 ++- .../controller/MediaUnitControllerDocs.java | 20 ++++++- .../media/dto/MediaUnitListResponse.java | 9 ++- .../media/service/MediaUnitQueryService.java | 44 +++++++++++++-- .../service/MediaUnitQueryServiceTest.java | 55 ++++++++++++++++++- 6 files changed, 138 insertions(+), 16 deletions(-) diff --git a/docs/campaign-registration-api-spec.md b/docs/campaign-registration-api-spec.md index 110479c..c661746 100644 --- a/docs/campaign-registration-api-spec.md +++ b/docs/campaign-registration-api-spec.md @@ -306,7 +306,8 @@ HTTP 상태는 `201 Created`다. ## 6. 매체 목록·검색·지역 필터 API 하나의 GET API가 전체 목록, 키워드 검색, 시/구 필터와 캠페인 기간 가용성 확인을 모두 담당한다. -매체 수가 적은 MVP를 전제로 페이지네이션 없이 조건에 맞는 ACTIVE 매체를 전부 반환한다. +매체 사진(`photoUrl`)이 고해상도 원본이라 목록 전체를 한 번에 내려주면 트래픽이 커서, +`offset`/`limit` 기반 페이지네이션으로 나눠 받는다(무한 스크롤 전제). ### Request @@ -317,6 +318,8 @@ GET /api/v1/media-units &sigungu=강남구 &executionStartDate=2026-07-11 &executionEndDate=2026-07-12 + &offset=0 + &limit=10 Authorization: Bearer {accessToken} ``` @@ -327,10 +330,19 @@ Authorization: Bearer {accessToken} | `sigungu` | N | 시/군/구 정확히 일치. 지정하려면 `sido`도 필요 | | `executionStartDate` | Y | 선택 캠페인 시작일 `yyyy-MM-dd` | | `executionEndDate` | Y | 선택 캠페인 종료일 `yyyy-MM-dd` | +| `offset` | N | 건너뛸 개수. 생략하면 `0` | +| `limit` | N | 가져올 개수. 생략하면 `10`, 최대 `50`(범위를 벗어나면 서버가 자동으로 clamp) | `keyword`는 앞뒤 공백 제거 후 빈 문자열이면 없는 것과 같다. `%`, `_`는 와일드카드가 아니라 일반 문자로 검색한다. 종료일이 시작일보다 빠르면 `400`이다. +### 페이지네이션(무한 스크롤) + +`keyword`/`sido`/`sigungu`/기간 필터가 전부 적용된 결과 기준으로 `offset`/`limit`을 적용한다. +예: 화면 진입 시 `limit=10`으로 첫 호출, 스크롤을 내릴 때마다 직전 `offset + limit`을 다음 +`offset`으로 삼아 `limit=6`으로 반복 호출. 응답의 `hasMore`가 `false`면 더 불러올 매체가 없다는 +뜻이다. + ### 가용성 규칙 다음 조건을 만족하는 기존 캠페인이 하나라도 있으면 `available = false`다. @@ -373,13 +385,14 @@ existing.executionEndDate >= requestedStartDate "available": true, "unavailableReason": null } - ] + ], + "hasMore": true } } ``` 기간 충돌이면 `unavailableReason = "PERIOD_CONFLICT"`다. 목록에서 제외하지 않고 반환해 프론트가 -비활성 표시할 수 있게 한다. 매체가 없으면 에러가 아니라 `mediaUnits: []`를 반환한다. +비활성 표시할 수 있게 한다. 매체가 없으면 에러가 아니라 `mediaUnits: []`, `hasMore: false`를 반환한다. ### 에러 케이스 diff --git a/src/main/java/com/shinhan/klljs/domain/media/controller/MediaUnitController.java b/src/main/java/com/shinhan/klljs/domain/media/controller/MediaUnitController.java index ceda35c..47275a1 100644 --- a/src/main/java/com/shinhan/klljs/domain/media/controller/MediaUnitController.java +++ b/src/main/java/com/shinhan/klljs/domain/media/controller/MediaUnitController.java @@ -47,12 +47,15 @@ public ApiResponse getMediaUnits( @RequestParam(required = false) String sido, @RequestParam(required = false) String sigungu, @RequestParam(required = false) String executionStartDate, - @RequestParam(required = false) String executionEndDate + @RequestParam(required = false) String executionEndDate, + @RequestParam(required = false) Integer offset, + @RequestParam(required = false) Integer limit ) { return ApiResponse.onSuccess( GeneralSuccessCode.OK, queryService.getMediaUnits( - keyword, sido, sigungu, parseDate(executionStartDate), parseDate(executionEndDate) + keyword, sido, sigungu, parseDate(executionStartDate), parseDate(executionEndDate), + offset, limit ) ); } diff --git a/src/main/java/com/shinhan/klljs/domain/media/controller/MediaUnitControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/media/controller/MediaUnitControllerDocs.java index aed356a..f5d273a 100644 --- a/src/main/java/com/shinhan/klljs/domain/media/controller/MediaUnitControllerDocs.java +++ b/src/main/java/com/shinhan/klljs/domain/media/controller/MediaUnitControllerDocs.java @@ -17,7 +17,19 @@ public interface MediaUnitControllerDocs { @Operation(summary = "관리자 매체 등록", description = "MVP 운영자가 인증 없이 매체 마스터 데이터를 등록합니다.") ResponseEntity> create(MediaUnitCreateRequest request); - @Operation(summary = "매체 목록·검색", description = "ACTIVE 매체와 선택 기간의 캠페인 등록 가능 여부를 반환합니다.") + @Operation( + summary = "매체 목록·검색", + description = """ + ACTIVE 매체와 선택 기간의 캠페인 등록 가능 여부를 반환합니다. + + ### 페이지네이션 (무한 스크롤) + 매체 사진(photoUrl)이 고해상도 원본이라 목록 전체를 한 번에 내려주면 트래픽이 큽니다. + `offset`/`limit`으로 한 페이지씩 나눠 받으세요 - 예: 처음 진입 시 `limit=10`으로 호출, + 이후 스크롤을 내릴 때마다 이전 `offset + limit`을 다음 `offset`으로 삼아 `limit=6`으로 반복 호출. + `hasMore=false`가 나오면 더 불러올 매체가 없다는 뜻입니다. `keyword`/`sido`/`sigungu` + 필터가 적용된 뒤의 결과 기준으로 페이지가 나뉩니다. + """ + ) ApiResponse getMediaUnits( @Parameter(description = "매체명 부분 검색어. 생략하면 전체 조회", example = "파르나스") String keyword, @@ -28,7 +40,11 @@ ApiResponse getMediaUnits( @Parameter(description = "캠페인 등록 가능 여부 조회 기간 시작일 (yyyy-MM-dd)", example = "2026-07-11") String executionStartDate, @Parameter(description = "캠페인 등록 가능 여부 조회 기간 종료일 (yyyy-MM-dd)", example = "2026-08-15") - String executionEndDate + String executionEndDate, + @Parameter(description = "건너뛸 개수. 생략하면 0", example = "10") + Integer offset, + @Parameter(description = "가져올 개수. 생략하면 10, 최대 50 (범위를 벗어나면 서버가 자동으로 clamp)", example = "6") + Integer limit ); @Operation(summary = "매체 지역 목록", description = "ACTIVE 매체에 실제 존재하는 시/도와 시/군/구를 반환합니다.") diff --git a/src/main/java/com/shinhan/klljs/domain/media/dto/MediaUnitListResponse.java b/src/main/java/com/shinhan/klljs/domain/media/dto/MediaUnitListResponse.java index a7fdf09..22e36da 100644 --- a/src/main/java/com/shinhan/klljs/domain/media/dto/MediaUnitListResponse.java +++ b/src/main/java/com/shinhan/klljs/domain/media/dto/MediaUnitListResponse.java @@ -2,8 +2,13 @@ import java.util.List; -/** 페이지네이션 없이 조건에 맞는 ACTIVE 매체 전체를 반환한다. */ -public record MediaUnitListResponse(List mediaUnits) { +/** + * 조건에 맞는 ACTIVE 매체를 offset/limit 기반으로 한 페이지씩 반환한다. + * 매체 사진(photoUrl)이 고해상도 원본이라 한 번에 다 불러오면 트래픽이 커서, 목록을 무한 스크롤로 + * 나눠 받도록 페이지네이션을 추가했다 - 이미지 자체를 리사이즈하는 게 아니라 화면에 실제로 보여줄 + * 만큼만 우선 불러오는 방식이다. + */ +public record MediaUnitListResponse(List mediaUnits, boolean hasMore) { public MediaUnitListResponse { mediaUnits = List.copyOf(mediaUnits); } diff --git a/src/main/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryService.java b/src/main/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryService.java index 0b18f24..00d3d6a 100644 --- a/src/main/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryService.java +++ b/src/main/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryService.java @@ -30,6 +30,10 @@ @RequiredArgsConstructor public class MediaUnitQueryService { + private static final int DEFAULT_OFFSET = 0; + private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 50; + private final MediaUnitRepository mediaUnitRepository; private final CampaignRepository campaignRepository; @@ -39,7 +43,9 @@ public MediaUnitListResponse getMediaUnits( String sido, String sigungu, LocalDate executionStartDate, - LocalDate executionEndDate + LocalDate executionEndDate, + Integer offsetParam, + Integer limitParam ) { validatePeriod(executionStartDate, executionEndDate); @@ -64,21 +70,47 @@ public MediaUnitListResponse getMediaUnits( .toList(); if (filtered.isEmpty()) { - return new MediaUnitListResponse(List.of()); + return new MediaUnitListResponse(List.of(), false); } - List mediaUnitIds = filtered.stream().map(MediaUnit::getId).toList(); + // 매체 사진(photoUrl)이 고해상도 원본이라 한 번에 다 내려주면 트래픽이 크다 - 화면에 보일 + // 만큼만 페이지로 잘라서 응답한다(무한 스크롤). 매체 수가 많아져도 findConflictingMediaUnitIds가 + // 이 페이지의 ID만 조회하도록, 페이지네이션을 먼저 적용한 뒤 기간 충돌을 계산한다. + int offset = clampOffset(offsetParam); + int limit = clampLimit(limitParam); + List page = filtered.stream().skip(offset).limit(limit).toList(); + boolean hasMore = offset + page.size() < filtered.size(); + + if (page.isEmpty()) { + return new MediaUnitListResponse(List.of(), false); + } + + List pageMediaUnitIds = page.stream().map(MediaUnit::getId).toList(); Set conflictingIds = new HashSet<>(campaignRepository.findConflictingMediaUnitIds( - mediaUnitIds, + pageMediaUnitIds, CampaignStatus.REGISTRATION_FAILED, executionStartDate, executionEndDate )); - List summaries = filtered.stream() + List summaries = page.stream() .map(media -> MediaUnitSummary.from(media, !conflictingIds.contains(media.getId()))) .toList(); - return new MediaUnitListResponse(summaries); + return new MediaUnitListResponse(summaries, hasMore); + } + + private int clampOffset(Integer offsetParam) { + if (offsetParam == null) { + return DEFAULT_OFFSET; + } + return Math.max(0, offsetParam); + } + + private int clampLimit(Integer limitParam) { + if (limitParam == null) { + return DEFAULT_LIMIT; + } + return Math.max(1, Math.min(limitParam, MAX_LIMIT)); } @Transactional(readOnly = true) diff --git a/src/test/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryServiceTest.java b/src/test/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryServiceTest.java index d57ac56..b99237b 100644 --- a/src/test/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryServiceTest.java +++ b/src/test/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryServiceTest.java @@ -46,7 +46,9 @@ void getMediaUnits_treatsWildcardCharactersLiterallyAndMarksPeriodConflict() { "서울특별시", "강남구", LocalDate.of(2026, 7, 12), - LocalDate.of(2026, 7, 13) + LocalDate.of(2026, 7, 13), + null, + null ); assertThat(response.mediaUnits()).singleElement().satisfies(media -> { @@ -54,6 +56,57 @@ void getMediaUnits_treatsWildcardCharactersLiterallyAndMarksPeriodConflict() { assertThat(media.available()).isFalse(); assertThat(media.unavailableReason()).isEqualTo("PERIOD_CONFLICT"); }); + assertThat(response.hasMore()).isFalse(); + } + + @Test + void getMediaUnits_defaultsToFirstTenAndReportsHasMore() { + // media_name 오름차순 정렬이라 "매체 01".."매체 12"는 그 순서 그대로 정렬된다. + for (int i = 1; i <= 12; i++) { + persistMedia("매체 %02d".formatted(i), "서울특별시", "강남구"); + } + entityManager.flush(); + + MediaUnitListResponse response = service.getMediaUnits( + null, null, null, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 2), null, null + ); + + assertThat(response.mediaUnits()).hasSize(10); + assertThat(response.mediaUnits().get(0).mediaName()).isEqualTo("매체 01"); + assertThat(response.mediaUnits().get(9).mediaName()).isEqualTo("매체 10"); + assertThat(response.hasMore()).isTrue(); + } + + @Test + void getMediaUnits_secondPageWithOffsetReturnsRemainingItemsAndHasMoreFalse() { + for (int i = 1; i <= 12; i++) { + persistMedia("매체 %02d".formatted(i), "서울특별시", "강남구"); + } + entityManager.flush(); + + MediaUnitListResponse response = service.getMediaUnits( + null, null, null, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 2), 10, 6 + ); + + assertThat(response.mediaUnits()).hasSize(2); + assertThat(response.mediaUnits().get(0).mediaName()).isEqualTo("매체 11"); + assertThat(response.mediaUnits().get(1).mediaName()).isEqualTo("매체 12"); + assertThat(response.hasMore()).isFalse(); + } + + @Test + void getMediaUnits_limitAboveMaxIsClampedTo50() { + for (int i = 1; i <= 60; i++) { + persistMedia("매체 %02d".formatted(i), "서울특별시", "강남구"); + } + entityManager.flush(); + + MediaUnitListResponse response = service.getMediaUnits( + null, null, null, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 2), null, 1000 + ); + + assertThat(response.mediaUnits()).hasSize(50); + assertThat(response.hasMore()).isTrue(); } @Test From 0f2c99df20568558a50f4c0098f9cbfe26827957 Mon Sep 17 00:00:00 2001 From: LeeJeongHeon02 Date: Tue, 21 Jul 2026 12:22:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=9D=8C=EC=88=98=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0/=EB=B2=94=EC=9C=84=20=EC=B4=88?= =?UTF-8?q?=EA=B3=BC=20offset=20=EA=B2=BD=EA=B3=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 지적 검증 결과 clampOffset/clampLimit과 빈 페이지 처리 로직은 이미 올바르게 동작했지만(코드 트레이싱으로 확인), 회귀 방지를 위해 테스트로 고정해둔다. - offset=-5, limit=-3 -> offset 0, limit 1로 clamp되어 첫 항목 하나만 반환 - offset이 전체 개수를 넘으면 빈 목록 + hasMore=false DB 레벨 페이징 전환 제안은 코드 변경 없이 스킵 - MediaUnitQueryService 클래스 주석에 이미 "매체 수가 작은 MVP" 전제가 문서화되어 있고, 트래픽이 커지면 그때 전환한다는 계획도 이미 있다. Co-Authored-By: Claude Sonnet 5 --- .../service/MediaUnitQueryServiceTest.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/test/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryServiceTest.java b/src/test/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryServiceTest.java index b99237b..f52229b 100644 --- a/src/test/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryServiceTest.java +++ b/src/test/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryServiceTest.java @@ -109,6 +109,39 @@ void getMediaUnits_limitAboveMaxIsClampedTo50() { assertThat(response.hasMore()).isTrue(); } + @Test + void getMediaUnits_negativeOffsetAndLimitAreClampedToValidMinimums() { + for (int i = 1; i <= 3; i++) { + persistMedia("매체 %02d".formatted(i), "서울특별시", "강남구"); + } + entityManager.flush(); + + MediaUnitListResponse response = service.getMediaUnits( + null, null, null, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 2), -5, -3 + ); + + // offset은 0으로, limit은 1(음수/0 허용 안 함)로 clamp -> 첫 페이지 첫 항목 하나만. + assertThat(response.mediaUnits()).singleElement().satisfies( + media -> assertThat(media.mediaName()).isEqualTo("매체 01") + ); + assertThat(response.hasMore()).isTrue(); + } + + @Test + void getMediaUnits_offsetBeyondTotalCountReturnsEmptyWithHasMoreFalse() { + for (int i = 1; i <= 3; i++) { + persistMedia("매체 %02d".formatted(i), "서울특별시", "강남구"); + } + entityManager.flush(); + + MediaUnitListResponse response = service.getMediaUnits( + null, null, null, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 2), 100, 10 + ); + + assertThat(response.mediaUnits()).isEmpty(); + assertThat(response.hasMore()).isFalse(); + } + @Test void getRegions_returnsSortedDistinctValuesFromActiveMediaOnly() { persistMedia("해운대 A", "부산광역시", "해운대구");