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..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 @@ -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,90 @@ 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 + 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