이번 글에서는 사용자의 관심 카테고리 목록 리스트 토글 및 목록 조회하기, 그리고 QueryDSL을 사용한 조건별 이벤트 목록 조회, 이번주 팝업 스테이션 조회를 살펴볼 예정이다. 마지막으로 카테고리 피드 및 상세 조회 관련 API까지 총 6가지 API를 살펴볼 것이다. 3편이 핵심이 될 것 같다.
public class SliceUtils {
public static <T> Slice<T> checkLastPage(Pageable pageable, List<T> content) {
boolean hasNext = false;
if (content.size() > pageable.getPageSize()) {
content.remove(pageable.getPageSize());
hasNext = true;
}
return new SliceImpl<>(content, pageable, hasNext);
}
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class SliceResponse<T> {
private List<T> content;
private int page;
private int size;
private boolean hasNext;
public static <T> SliceResponse<T> from(Slice<T> slice) {
return new SliceResponse<>(
slice.getContent(),
slice.getNumber(),
slice.getSize(),
slice.hasNext()
);
}
}
무한 스크롤을 구현 예정이라, Slice 관련 응답과 유틸을 미리 정의해놓았다.
프론트엔드 화면에서, 카테고리가 네모난 박스에 담겨 있고 그 박스의 클릭 여부로 인해 카테고리 리스트가 수정되기 때문에, 토글 형식으로 코드를 짰다. 클릭 한 번에, 이 API가 호출된다.
@PostMapping("/categories/{category}")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<ApiResponse<CategoryListResponse>> toggleUserCategory(
@AuthenticationPrincipal User loginUser,
@Parameter(description = "설정/해제할 카테고리 이름 (예: CAFE, FOOD, K_POP 등)", required = true)
@PathVariable Category category) {
userService.toggleCategory(CategoryToggleCommand.of(loginUser.getId(), category));
CategoryListResult result = userQueryService.getUserCategories(loginUser.getId());
return ResponseEntity.ok(new ApiResponse<>(true, 200, "카테고리 최신화 성공", CategoryListResponse.from(result)));
}
public void toggleCategory(CategoryToggleCommand command) {
User user = userRepository.findById(command.getUserId())
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
user.toggleCategory(command.getCategory());
}
public CategoryListResult getUserCategories(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
return CategoryListResult.of(user.getCategories());
}
@GetMapping("/categories")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<ApiResponse<CategoryListResponse>> getUserCategories(
@AuthenticationPrincipal User loginUser) {
CategoryListResult result = userQueryService.getUserCategories(loginUser.getId());
return ResponseEntity.ok(new ApiResponse<>(true, 200, "카테고리 조회 성공",CategoryListResponse.from(result)));
}
서비스는, 위의 getUserCategories()를 참고하면 된다.
파라미터로 전달된 필터(POPULAR: 현재 진행 중인 이벤트 중 좋아요 많은 순, ONGOING: 현재 진행 중인 이벤트 중 최신 등록 순(default), CLOSING_TODAY: 오늘 마감되는 이벤트 (마감 임박순), UPCOMING: 오픈 예정인 이벤트 (시작 임박순))에 따라 이벤트 목록을 조회한다. 비로그인자도 접근 가능하고, 로그인된 사람은 이벤트 객체들 중 자신이 좋아요를 눌렀는지(즐겨찾기) 여부를 알 수 있다.
@GetMapping
public ResponseEntity<ApiResponse<SliceResponse<EventSummaryResponse>>> getEvents(
@AuthenticationPrincipal User loginUser,
@RequestParam(required = false, defaultValue = "ONGOING") Filter filter,
@PageableDefault(size = 10) Pageable pageable) {
Long userId = (loginUser != null) ? loginUser.getId() : null;
Slice<EventSummaryResult> results = eventQueryService.getEventsByFilter(filter, userId, pageable);
SliceResponse<EventSummaryResponse> response = SliceResponse.from(results.map(EventSummaryResponse::from));
return ResponseEntity.ok(new ApiResponse<>(true, 200, "이벤트 목록 조회 성공", response));
}
public Slice<EventSummaryResult> getEventsByFilter(Filter filter, Long userId, Pageable pageable) {
Filter targetFilter = (filter == null) ? Filter.ONGOING : filter;
Slice<EventSummaryResult> slice = eventRepository.findEventsByFilter(targetFilter, LocalDate.now(), pageable);
if (slice.hasContent()) {
updateLikedStatus(slice.getContent(), userId);
}
return slice;
}
@Override
public Slice<EventSummaryResult> findEventsByFilter(Filter filter, LocalDate today, Pageable pageable) {
List<EventSummaryResult> content = queryFactory
.select(new QEventSummaryResult(
event.id,
event.name,
event.thumbnail,
event.description,
event.likeCount,
event.startDate,
event.endDate
))
.from(event)
.where(buildFilterCondition(filter, today))
.orderBy(buildOrderSpecifier(filter), event.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.fetch();
return SliceUtils.checkLastPage(pageable, content);
}
명세서대로 buildFilterCondition, buildOrderSpecifier 등을 작성하였다.
private BooleanExpression buildFilterCondition(Filter filter, LocalDate today) {
return switch (filter) {
case POPULAR, ONGOING -> event.startDate.loe(today).and(event.endDate.goe(today));
case CLOSING_TODAY -> event.endDate.eq(today);
case UPCOMING -> event.startDate.gt(today);
};
}
private OrderSpecifier<?> buildOrderSpecifier(Filter filter) {
return switch (filter) {
case POPULAR -> event.likeCount.desc();
case ONGOING -> event.startDate.desc();
case UPCOMING -> event.startDate.asc();
case CLOSING_TODAY -> event.endDate.asc();
};
}
@Getter
@NoArgsConstructor
public class EventSummaryResult {
private Long id;
private String name;
private String thumbnail;
private String description;
private int likeCount;
private LocalDate startDate;
private LocalDate endDate;
private boolean liked;
@QueryProjection
public EventSummaryResult(Long id, String name, String thumbnail, String description,
int likeCount, LocalDate startDate, LocalDate endDate) {
this.id = id;
this.name = name;
this.thumbnail = thumbnail;
this.description = description;
this.likeCount = likeCount;
this.startDate = startDate;
this.endDate = endDate;
}
public void updateLiked(boolean liked) {
this.liked = liked;
}
}
@GetMapping
public ResponseEntity<ApiResponse<SliceResponse<PopupSummaryResponse>>> getWeeklyPopups(
@AuthenticationPrincipal User loginUser,
@PageableDefault(size = 10) Pageable pageable) {
Long userId = (loginUser != null) ? loginUser.getId() : null;
Slice<PopupSummaryResult> results = popupQueryService.getWeeklyPopups(userId, pageable);
SliceResponse<PopupSummaryResponse> response = SliceResponse.from(results.map(PopupSummaryResponse::from));
return ResponseEntity.ok(new ApiResponse<>(true, 200, "이번 주 팝업 스테이션 조회 성공", response));
}
public Slice<PopupSummaryResult> getWeeklyPopups(Long userId, Pageable pageable) {
Slice<PopupSummaryResult> slice = popupRepository.findWeeklyPopups(LocalDate.now(), pageable);
if (slice.hasContent() && userId != null) {
updateLikedStatus(slice.getContent(), userId);
}
return slice;
}
@Override
public Slice<PopupSummaryResult> findWeeklyPopups(LocalDate today, Pageable pageable){
LocalDate startOfWeek = today.with(java.time.DayOfWeek.MONDAY);
LocalDate endOfWeek = today.with(java.time.DayOfWeek.SUNDAY);
List<PopupSummaryResult> content = queryFactory
.select(new QPopupSummaryResult(
popup.id,
popup.name,
popup.thumbnail,
popup.description,
popup.likeCount,
popup.startDate,
popup.endDate
))
.from(popup)
.where(
popup.startDate.loe(endOfWeek).and(popup.endDate.goe(startOfWeek))
)
.orderBy(popup.likeCount.desc(), popup.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.fetch();
return SliceUtils.checkLastPage(pageable, content);
}
@Getter
@NoArgsConstructor
public class PopupSummaryResult {
private Long id;
private String name;
private String thumbnail;
private String description;
private int likeCount;
private LocalDate startDate;
private LocalDate endDate;
private boolean liked;
@QueryProjection
public PopupSummaryResult(Long id, String name, String thumbnail, String description,
int likeCount, LocalDate startDate, LocalDate endDate) {
this.id = id;
this.name = name;
this.thumbnail = thumbnail;
this.description = description;
this.likeCount = likeCount;
this.startDate = startDate;
this.endDate = endDate;
}
public void updateLiked(boolean liked) {
this.liked = liked;
}
}
이제, 제일 구현이 힘들었던 카테고리 관련 API 2개이다.
카테고리별: 좋아요 순 상위 4개 아이템 반환
비로그인시, 전체 카테고리 알파벳순 카테고리별 반환(최대 28개)
로그인(USER): 관심 카테고리(최대 3개) 우선 정렬 후 나머지 알파벳순 반환
로그인(MERCHANT): 본인 가게 카테고리 우선 정렬 후 나머지 알파벳순 반환
@GetMapping
public ResponseEntity<ApiResponse<AllCategoryListResponse>> getCategoryFeed(
@AuthenticationPrincipal User loginUser) {
Long userId = (loginUser != null) ? loginUser.getId() : null;
AllCategoryListResult result = categoryQueryService.getAllCategoryFeed(userId);
return ResponseEntity.ok(new ApiResponse<>(true, 200, "전체 카테고리 목록 조회 성공", AllCategoryListResponse.from(result)));
}
public AllCategoryListResult getAllCategoryFeed(Long userId) {
//1. 유저 식별 및 카테고리 노출 순서 결정
User loginUser = (userId != null) ? userRepository.findById(userId).orElse(null) : null;
LocalDate today = LocalDate.now();
List<Category> orderedCategories = orderCategories(loginUser);
//2. 유저의 '좋아요' 목록 사전 확보
Set<Long> myStoreIds = getFavoriteIds(loginUser, "STORE");
Set<Long> myPopupIds = getFavoriteIds(loginUser, "POPUP");
Set<Long> myEventIds = getFavoriteIds(loginUser, "EVENT");
List<CategoryContentResult> blocks = new ArrayList<>();
// 3. 카테고리별 데이터 수집
for (Category category : orderedCategories) {
List<CategoryItem> allItems = new ArrayList<>();
allItems.addAll(categoryQueryRepository.findStoreItems(category, 4));
allItems.addAll(categoryQueryRepository.findPopupItems(category, today, 4));
allItems.addAll(categoryQueryRepository.findEventItems(category, today, 4));
//4. 개인화(좋아요 여부) 마킹 및 최종 Top 4 선별
allItems.forEach(item -> {
boolean liked = switch (item.getType()) {
case "STORE" -> myStoreIds.contains(item.getId());
case "POPUP" -> myPopupIds.contains(item.getId());
case "EVENT" -> myEventIds.contains(item.getId());
default -> false;
};
item.updateLiked(liked);
});
List<CategoryItem> sortedTop4 = allItems.stream()
.sorted(Comparator.comparing(CategoryItem::getLikeCount, Comparator.reverseOrder())
.thenComparing(CategoryItem::getId, Comparator.reverseOrder()))
.limit(4)
.toList();
//5. 결과 반환
blocks.add(CategoryContentResult.of(category, sortedTop4));
}
return AllCategoryListResult.of(blocks);
}
private List<Category> orderCategories(User user) {
List<Category> all = new ArrayList<>(Arrays.asList(Category.values()));
if (user == null) {
all.sort(Comparator.comparing(Enum::name));
return all;
}
List<Category> userCategories = user.getCategories();
all.sort((c1, c2) -> {
int p1 = userCategories.indexOf(c1);
int p2 = userCategories.indexOf(c2);
// 둘 다 유저의 관심사인 경우 -> 알파벳 순서대로 정렬
if (p1 != -1 && p2 != -1) return c1.name().compareTo(c2.name());
// c1만 관심사인 경우 -> c1을 위로
if (p1 != -1) return -1;
// c2만 관심사인 경우 -> c2를 위로
if (p2 != -1) return 1;
// 둘 다 관심사가 아닌 경우 -> 알파벳 순
return c1.name().compareTo(c2.name());
});
return all;
}
private Set<Long> getFavoriteIds(User user, String type) {
if (user == null) return Collections.emptySet();
return switch (type) {
case "STORE" -> favoriteStoreRepository.findStoreIdsByUserId(user.getId());
case "POPUP" -> favoritePopupRepository.findPopupIdsByUserId(user.getId());
case "EVENT" -> favoriteEventRepository.findEventIdsByUserId(user.getId());
default -> Collections.emptySet();
};
}
public List<CategoryItem> findStoreItems(Category category, int limit) {
return queryFactory
.select(new QCategoryItem(
Expressions.asString("STORE"), store.id, store.name,
store.thumbnail, store.likeCount, Expressions.nullExpression(),
Expressions.nullExpression(), Expressions.nullExpression()))
.from(store)
.where(store.category.eq(category))
.orderBy(store.likeCount.desc(), store.id.desc())
.limit(limit)
.fetch();
}
public List<CategoryItem> findPopupItems(Category category, LocalDate today, int limit) {
return queryFactory
.select(new QCategoryItem(
Expressions.asString("POPUP"), popup.id, popup.name,
popup.thumbnail, popup.likeCount, popup.description,
popup.startDate, popup.endDate))
.from(popup)
.where(popup.category.eq(category),
popup.startDate.loe(today),
popup.endDate.goe(today))
.orderBy(popup.likeCount.desc(), popup.id.desc())
.limit(limit)
.fetch();
}
public List<CategoryItem> findEventItems(Category category, LocalDate today, int limit) {
return queryFactory
.select(new QCategoryItem(
Expressions.asString("EVENT"),
event.id,
event.name,
event.thumbnail,
event.likeCount,
event.description,
event.startDate,
event.endDate
))
.from(event)
.join(event.store, store)
.where(
store.category.eq(category),
event.startDate.loe(today),
event.endDate.goe(today)
)
.orderBy(event.likeCount.desc(), event.id.desc())
.limit(limit)
.fetch();
}
Getter
@NoArgsConstructor
public class CategoryItem {
private String type;
private Long id;
private String name;
private String thumbnail;
private int likeCount;
private boolean liked;
private String description;
private LocalDate startDate;
private LocalDate endDate;
@QueryProjection
public CategoryItem(String type, Long id, String name, String thumbnail,
int likeCount, String description, LocalDate startDate, LocalDate endDate) {
this.type = type;
this.id = id;
this.name = name;
this.thumbnail = thumbnail;
this.likeCount = likeCount;
this.description = description;
this.startDate = startDate;
this.endDate = endDate;
this.liked = false;
}
public void updateLiked(boolean liked) {
this.liked = liked;
}
}
특정 카테고리의 모든 콘텐츠(가게, 이벤트, 팝업)를 무한 스크롤 방식으로 가져온다. 이벤트와 팝업은 현재 진행 중인 것만!
@GetMapping("/{category}")
public ResponseEntity<ApiResponse<SliceResponse<CategoryItem>>> getCategoryFeedByCategory(
@AuthenticationPrincipal User loginUser,
@PathVariable Category category,
@PageableDefault(size = 10) Pageable pageable) {
Long userId = (loginUser != null) ? loginUser.getId() : null;
Slice<CategoryItem> results = categoryQueryService.getSingleCategoryFeed(userId, category, pageable);
SliceResponse<CategoryItem> response = SliceResponse.from(results);
return ResponseEntity.ok(new ApiResponse<>(true, 200, "카테고리 상세 조회 성공", response));
}
public Slice<CategoryItem> getSingleCategoryFeed(Long userId, Category category, Pageable pageable) {
LocalDate today = LocalDate.now();
List<CategoryItem> allItems = new ArrayList<>();
allItems.addAll(categoryQueryRepository.findStoreItems(category, Integer.MAX_VALUE));
allItems.addAll(categoryQueryRepository.findPopupItems(category, today, Integer.MAX_VALUE));
allItems.addAll(categoryQueryRepository.findEventItems(category, today, Integer.MAX_VALUE));
User user = (userId != null) ? userRepository.findById(userId).orElse(null) : null;
Set<Long> myStoreIds = getFavoriteIds(user, "STORE");
Set<Long> myPopupIds = getFavoriteIds(user, "POPUP");
Set<Long> myEventIds = getFavoriteIds(user, "EVENT");
allItems.forEach(item -> {
boolean liked = switch (item.getType()) {
case "STORE" -> myStoreIds.contains(item.getId());
case "POPUP" -> myPopupIds.contains(item.getId());
case "EVENT" -> myEventIds.contains(item.getId());
default -> false;
};
item.updateLiked(liked);
});
List<CategoryItem> sortedItems = allItems.stream()
.sorted(Comparator.comparing(CategoryItem::getLikeCount, Comparator.reverseOrder())
.thenComparing(CategoryItem::getId, Comparator.reverseOrder()))
.toList();
int start = (int) pageable.getOffset();
int pageSize = pageable.getPageSize();
if (start >= sortedItems.size()) {
return new SliceImpl<>(new ArrayList<>(), pageable, false);
}
int end = Math.min(start + pageSize + 1, sortedItems.size());
List<CategoryItem> content = new ArrayList<>(sortedItems.subList(start, end));
return SliceUtils.checkLastPage(pageable, content);
}
각각의 FavoritePopupRepository, FavoriteStoreRepository, FavoriteEventRepository에서 n+1을 방지하기 위해 @Query 메소드를 사용하였다.
@Query("select f.store.id from FavoriteStore f where f.user.id = :userId")
Set<Long> findStoreIdsByUserId(@Param("userId") Long userId);
@Query("select f.popup.id from FavoritePopup f where f.user.id = :userId")
Set<Long> findPopupIdsByUserId(@Param("userId") Long userId);
@Query("select f.event.id from FavoriteEvent f where f.user.id = :userId")
Set<Long> findEventIdsByUserId(@Param("userId") Long userId);