Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions docs/campaign-registration-api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ HTTP 상태는 `201 Created`다.
## 6. 매체 목록·검색·지역 필터 API

하나의 GET API가 전체 목록, 키워드 검색, 시/구 필터와 캠페인 기간 가용성 확인을 모두 담당한다.
매체 수가 적은 MVP를 전제로 페이지네이션 없이 조건에 맞는 ACTIVE 매체를 전부 반환한다.
매체 사진(`photoUrl`)이 고해상도 원본이라 목록 전체를 한 번에 내려주면 트래픽이 커서,
`offset`/`limit` 기반 페이지네이션으로 나눠 받는다(무한 스크롤 전제).

### Request

Expand All @@ -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}
```

Expand All @@ -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`다.
Expand Down Expand Up @@ -373,13 +385,14 @@ existing.executionEndDate >= requestedStartDate
"available": true,
"unavailableReason": null
}
]
],
"hasMore": true
}
}
```

기간 충돌이면 `unavailableReason = "PERIOD_CONFLICT"`다. 목록에서 제외하지 않고 반환해 프론트가
비활성 표시할 수 있게 한다. 매체가 없으면 에러가 아니라 `mediaUnits: []`를 반환한다.
비활성 표시할 수 있게 한다. 매체가 없으면 에러가 아니라 `mediaUnits: []`, `hasMore: false`를 반환한다.

### 에러 케이스

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,15 @@ public ApiResponse<MediaUnitListResponse> 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
)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,19 @@ public interface MediaUnitControllerDocs {
@Operation(summary = "관리자 매체 등록", description = "MVP 운영자가 인증 없이 매체 마스터 데이터를 등록합니다.")
ResponseEntity<ApiResponse<MediaUnitCreateResponse>> 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<MediaUnitListResponse> getMediaUnits(
@Parameter(description = "매체명 부분 검색어. 생략하면 전체 조회", example = "파르나스")
String keyword,
Expand All @@ -28,7 +40,11 @@ ApiResponse<MediaUnitListResponse> 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 매체에 실제 존재하는 시/도와 시/군/구를 반환합니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

import java.util.List;

/** 페이지네이션 없이 조건에 맞는 ACTIVE 매체 전체를 반환한다. */
public record MediaUnitListResponse(List<MediaUnitSummary> mediaUnits) {
/**
* 조건에 맞는 ACTIVE 매체를 offset/limit 기반으로 한 페이지씩 반환한다.
* 매체 사진(photoUrl)이 고해상도 원본이라 한 번에 다 불러오면 트래픽이 커서, 목록을 무한 스크롤로
* 나눠 받도록 페이지네이션을 추가했다 - 이미지 자체를 리사이즈하는 게 아니라 화면에 실제로 보여줄
* 만큼만 우선 불러오는 방식이다.
*/
public record MediaUnitListResponse(List<MediaUnitSummary> mediaUnits, boolean hasMore) {
public MediaUnitListResponse {
mediaUnits = List.copyOf(mediaUnits);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -39,7 +43,9 @@ public MediaUnitListResponse getMediaUnits(
String sido,
String sigungu,
LocalDate executionStartDate,
LocalDate executionEndDate
LocalDate executionEndDate,
Integer offsetParam,
Integer limitParam
) {
validatePeriod(executionStartDate, executionEndDate);

Expand All @@ -64,21 +70,47 @@ public MediaUnitListResponse getMediaUnits(
.toList();

if (filtered.isEmpty()) {
return new MediaUnitListResponse(List.of());
return new MediaUnitListResponse(List.of(), false);
}

List<Long> mediaUnitIds = filtered.stream().map(MediaUnit::getId).toList();
// 매체 사진(photoUrl)이 고해상도 원본이라 한 번에 다 내려주면 트래픽이 크다 - 화면에 보일
// 만큼만 페이지로 잘라서 응답한다(무한 스크롤). 매체 수가 많아져도 findConflictingMediaUnitIds가
// 이 페이지의 ID만 조회하도록, 페이지네이션을 먼저 적용한 뒤 기간 충돌을 계산한다.
int offset = clampOffset(offsetParam);
int limit = clampLimit(limitParam);
List<MediaUnit> 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<Long> pageMediaUnitIds = page.stream().map(MediaUnit::getId).toList();
Set<Long> conflictingIds = new HashSet<>(campaignRepository.findConflictingMediaUnitIds(
mediaUnitIds,
pageMediaUnitIds,
CampaignStatus.REGISTRATION_FAILED,
executionStartDate,
executionEndDate
));

List<MediaUnitSummary> summaries = filtered.stream()
List<MediaUnitSummary> summaries = page.stream()
.map(media -> MediaUnitSummary.from(media, !conflictingIds.contains(media.getId())))
.toList();
return new MediaUnitListResponse(summaries);
return new MediaUnitListResponse(summaries, hasMore);
}

Comment on lines +73 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial

메모리 기반 페이징 처리 관련 운영 조언

현재 MVP 특성상 매체 수가 적어 전체 데이터를 메모리에 로드한 뒤 stream().skip().limit()으로 페이징을 처리하고 있습니다. 이 방식은 현재로서는 안전하지만, 향후 활성 매체 수가 급증할 경우 다음과 같은 문제가 발생할 수 있습니다.

  • 모든 엔티티를 영속성 컨텍스트 및 메모리에 적재함에 따른 힙 메모리 사용량 증가
  • 가비지 컬렉션(GC) 빈도 증가로 인한 응답 지연

당장은 변경할 필요가 없으나, 서비스 규모가 커지면 DB 레벨의 페이징(Spring Data JPA의 Pageable) 및 검색 최적화(Full Text Search 인덱스 등)로 전환하는 것을 백로그에 추가하여 관리하시기를 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/shinhan/klljs/domain/media/service/MediaUnitQueryService.java`
around lines 73 - 101, 현재 메모리 기반 페이징 구현은 변경하지 말고, 향후 활성 매체 수 증가에 대비해 DB 레벨 페이징과
검색 최적화 전환 작업을 백로그에 추가하세요. MediaUnitQueryService의 filtered 및
stream().skip().limit() 흐름을 참고해 Pageable 기반 조회와 Full Text Search 인덱스 검토 항목을
기록하세요.

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,100 @@ 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 -> {
assertThat(media.mediaUnitId()).isEqualTo(percentMedia.getId());
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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

@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
Expand Down
Loading