중앙해커톤 리팩터링-3

EauLune01·2026년 2월 13일

이번 글에서는 사용자의 관심 카테고리 목록 리스트 토글 및 목록 조회하기, 그리고 QueryDSL을 사용한 조건별 이벤트 목록 조회, 이번주 팝업 스테이션 조회를 살펴볼 예정이다. 마지막으로 카테고리 피드 및 상세 조회 관련 API까지 총 6가지 API를 살펴볼 것이다. 3편이 핵심이 될 것 같다.

SliceUtils

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);
    }
}

SliceResponse

@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 관련 응답과 유틸을 미리 정의해놓았다.

User

카테고리 리스트 토글

프론트엔드 화면에서, 카테고리가 네모난 박스에 담겨 있고 그 박스의 클릭 여부로 인해 카테고리 리스트가 수정되기 때문에, 토글 형식으로 코드를 짰다. 클릭 한 번에, 이 API가 호출된다.

Controller

@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)));
    }
  • CQRS 원칙을 지키기 위해 userService에서 command를 하고 userQueryService에서 최신 카테고리 리스트를 조회해왔다.

Service

public void toggleCategory(CategoryToggleCommand command) {
        User user = userRepository.findById(command.getUserId())
                .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
        user.toggleCategory(command.getCategory());
    }
  • 유저를 id로 찾아와 카테고리를 토글
public CategoryListResult getUserCategories(Long userId) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));

        return CategoryListResult.of(user.getCategories());
    }
  • 유저를 id로 찾아와 카테고리 목록 반환

카테고리 목록 반환

Controller

@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: 오픈 예정인 이벤트 (시작 임박순))에 따라 이벤트 목록을 조회한다. 비로그인자도 접근 가능하고, 로그인된 사람은 이벤트 객체들 중 자신이 좋아요를 눌렀는지(즐겨찾기) 여부를 알 수 있다.

Controller

@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));
    }
  • security = @SecurityRequirement(name = "bearerAuth") 필수!

Service

 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;
    }

Repository

@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();
        };
    }

DTO

@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;
    }
}
  • QueryDSL용 dto 생성(엔티티를 사용 안해서 네트워크 이득)

이번 주 팝업 스테이션 조회

Controller

@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));
    }

Service

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;
    }

Repository

@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);
    }
  • 이번 주의 팝업을 (좋아요 많은 순서->최신 등록) Slice 객체로 return

DTO

@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;
    }
}
  • 이것 역시 QueryDSL용 DTO이다.

이제, 제일 구현이 힘들었던 카테고리 관련 API 2개이다.

전체 카테고리 메인 피드 조회

카테고리별: 좋아요 순 상위 4개 아이템 반환

비로그인시, 전체 카테고리 알파벳순 카테고리별 반환(최대 28개)
로그인(USER): 관심 카테고리(최대 3개) 우선 정렬 후 나머지 알파벳순 반환
로그인(MERCHANT): 본인 가게 카테고리 우선 정렬 후 나머지 알파벳순 반환

Controller

@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)));
    }
  • security = @SecurityRequirement(name = "bearerAuth") 붙여주기

Service

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();
        };
    }
  • 사용한 helper method들

Repository

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();
    }

DTO

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;
    }
}
  • Store, Event, Popup 다 담을 수 있는 QueryDSL용 DTO

단일 카테고리 상세 피드 조회

특정 카테고리의 모든 콘텐츠(가게, 이벤트, 팝업)를 무한 스크롤 방식으로 가져온다. 이벤트와 팝업은 현재 진행 중인 것만!

Controller

@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));
    }

Service

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);
    }
  • 위의 Service와 유사한데, 카테고리 1개에 대해 그런 거라 반복문이 없다.
  • 레포지토리도 위와 같은 메서드를 사용한다.

FavoriteRepository

각각의 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);
    
profile
Séoul

0개의 댓글