diff --git a/src/main/java/com/bootsignal/domain/course/dto/CourseListRequest.java b/src/main/java/com/bootsignal/domain/course/dto/CourseListRequest.java index db92fc8..112ebc7 100644 --- a/src/main/java/com/bootsignal/domain/course/dto/CourseListRequest.java +++ b/src/main/java/com/bootsignal/domain/course/dto/CourseListRequest.java @@ -44,7 +44,13 @@ public record CourseListRequest( @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") @Max(value = 100, message = "페이지 크기는 100 이하이어야 합니다.") - Integer size + Integer size, + + /** + * true 전달 시 기관 이미지·만족도·취업률·별점이 모두 있는 과정을 기존 정렬 내에서 우선 노출합니다. + * null 또는 false이면 기존 정렬 동작을 그대로 유지합니다. + */ + Boolean prioritizeFull ) { // page와 size가 null이거나 기본값이 누락되었을 때 방어 코드 적용 public CourseListRequest { diff --git a/src/main/java/com/bootsignal/domain/course/service/CourseService.java b/src/main/java/com/bootsignal/domain/course/service/CourseService.java index 4043ea1..1cd2d38 100644 --- a/src/main/java/com/bootsignal/domain/course/service/CourseService.java +++ b/src/main/java/com/bootsignal/domain/course/service/CourseService.java @@ -55,6 +55,8 @@ public class CourseService { public PageResponse getCourses(CourseListRequest request) { CourseSort courseSort = CourseSort.from(request.sort()); LocalDate today = LocalDate.now(SERVICE_ZONE); + boolean shouldPrioritize = Boolean.TRUE.equals(request.prioritizeFull()); + Specification spec = Specification.allOf( CourseSessionSpecification.withKeyword(request.keyword()), CourseSessionSpecification.withTrngAreaCd(request.trngAreaCd()), @@ -71,9 +73,30 @@ public PageResponse getCourses(CourseListRequest request) { } else if (courseSort == CourseSort.DEADLINE) { // 모집 마감 임박순은 이미 시작한 과정보다 시작 예정 과정을 우선 노출합니다. spec = spec.and(CourseSessionSpecification.orderByDeadlineSoon(today)); + } else if (shouldPrioritize) { + // prioritizeFull=true 시 Pageable Sort 대신 Criteria ORDER BY 로 전환하여 우선순위와 결합합니다. + spec = switch (courseSort) { + case LATEST -> spec.and(CourseSessionSpecification.orderByLatest()); + case SATISFACTION -> spec.and(CourseSessionSpecification.orderBySatisfaction()); + case EMPLOYMENT_RATE -> spec.and(CourseSessionSpecification.orderByEmploymentRate()); + default -> spec; + }; + } + + // POPULAR/DEADLINE은 각각 북마크 수·마감일 기준 정렬이 주목적이므로 완전성 우선순위를 적용하지 않습니다. + if (shouldPrioritize + && courseSort != CourseSort.POPULAR + && courseSort != CourseSort.DEADLINE) { + spec = spec.and(CourseSessionSpecification.orderByCompleteDataFirst()); } - Pageable pageable = PageRequest.of(request.page(), request.size(), toSort(courseSort)); + // prioritizeFull=true 이고 LATEST/SATISFACTION/EMPLOYMENT_RATE 정렬인 경우 + // Criteria ORDER BY 를 사용하므로 Pageable Sort 는 비워둡니다. + boolean useCriteriaSort = shouldPrioritize + && courseSort != CourseSort.POPULAR + && courseSort != CourseSort.DEADLINE; + Pageable pageable = PageRequest.of(request.page(), request.size(), + useCriteriaSort ? Sort.unsorted() : toSort(courseSort)); // N+1 방지를 위해 course와 institution을 fetch join Specification withFetch = spec.and((root, query, cb) -> { diff --git a/src/main/java/com/bootsignal/domain/course_session/repository/CourseSessionSpecification.java b/src/main/java/com/bootsignal/domain/course_session/repository/CourseSessionSpecification.java index 45b421a..8215ff9 100644 --- a/src/main/java/com/bootsignal/domain/course_session/repository/CourseSessionSpecification.java +++ b/src/main/java/com/bootsignal/domain/course_session/repository/CourseSessionSpecification.java @@ -6,7 +6,12 @@ import com.bootsignal.domain.course.dto.PriceRange; import com.bootsignal.domain.course.dto.FieldCategory; import com.bootsignal.domain.course_session.entity.CourseSession; +import com.bootsignal.domain.crawled_review.entity.CrawledReview; +import com.bootsignal.domain.review.entity.Review; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Order; import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Subquery; @@ -14,6 +19,8 @@ import org.springframework.util.StringUtils; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import java.util.Set; /** @@ -24,6 +31,18 @@ public class CourseSessionSpecification { private CourseSessionSpecification() { } + /** + * 이미 생성된 JOIN이 있으면 재사용하고, 없으면 새로 생성합니다. + * 동일 Specification 체인 내에서 같은 연관 경로에 대한 중복 JOIN을 방지합니다. + */ + @SuppressWarnings("unchecked") + private static Join findOrJoin(From from, String attribute, JoinType joinType) { + return (Join) from.getJoins().stream() + .filter(j -> attribute.equals(j.getAttribute().getName())) + .findFirst() + .orElseGet(() -> from.join(attribute, joinType)); + } + /** * 과정명(course.title) 또는 기관명(course.institution.institutionName) 부분 일치 검색 */ @@ -162,6 +181,110 @@ public static Specification orderByBookmarkCountDesc() { }; } + /** + * 최신순 Criteria 정렬 — prioritizeFull과 함께 사용할 때 Pageable Sort 대신 적용합니다. + */ + public static Specification orderByLatest() { + return (root, query, cb) -> { + if (query != null && Long.class != query.getResultType() && long.class != query.getResultType()) { + query.orderBy(cb.desc(root.get("id"))); + } + return cb.conjunction(); + }; + } + + /** + * 만족도순 Criteria 정렬 — prioritizeFull과 함께 사용할 때 Pageable Sort 대신 적용합니다. + */ + public static Specification orderBySatisfaction() { + return (root, query, cb) -> { + if (query != null && Long.class != query.getResultType() && long.class != query.getResultType()) { + var courseJoin = findOrJoin(root, "course", JoinType.LEFT); + query.orderBy( + cb.desc(courseJoin.get("stdgScor")), + cb.desc(root.get("id")) + ); + } + return cb.conjunction(); + }; + } + + /** + * 취업률순 Criteria 정렬 — prioritizeFull과 함께 사용할 때 Pageable Sort 대신 적용합니다. + */ + public static Specification orderByEmploymentRate() { + return (root, query, cb) -> { + if (query != null && Long.class != query.getResultType() && long.class != query.getResultType()) { + query.orderBy( + cb.desc(root.get("employmentRate")), + cb.desc(root.get("id")) + ); + } + return cb.conjunction(); + }; + } + + /** + * 기관 이미지·만족도·취업률·별점이 모두 있는 과정을 기존 정렬 기준 내에서 우선 노출합니다. + * 기존 ORDER BY 앞에 완전성 우선순위(0=완전, 1=불완전)를 삽입합니다. + * 반드시 기본 정렬 Specification을 체이닝한 뒤 마지막에 추가해야 합니다. + */ + public static Specification orderByCompleteDataFirst() { + return (root, query, cb) -> { + if (query == null || Long.class == query.getResultType() || long.class == query.getResultType()) { + return cb.conjunction(); + } + + // 기존 Specification 체인에서 생성된 JOIN을 재사용하여 중복 JOIN을 방지합니다. + Join courseJoin = findOrJoin(root, "course", JoinType.LEFT); + Join institutionJoin = findOrJoin(courseJoin, "institution", JoinType.LEFT); + + // EXISTS 서브쿼리: 첫 번째 일치 행에서 단락(short-circuit)되어 COUNT보다 효율적입니다. + Subquery reviewExists = query.subquery(Integer.class); + Root review = reviewExists.from(Review.class); + reviewExists.select(cb.literal(1)); + reviewExists.where( + cb.and( + cb.equal(review.get("course").get("id"), courseJoin.get("id")), + cb.isNull(review.get("deletedAt")) + ) + ); + + Subquery crawledExists = query.subquery(Integer.class); + Root crawled = crawledExists.from(CrawledReview.class); + crawledExists.select(cb.literal(1)); + crawledExists.where( + cb.and( + cb.equal(crawled.get("course").get("id"), courseJoin.get("id")), + cb.isNotNull(crawled.get("rating")) + ) + ); + + // 4가지 조건 모두 충족 시 우선순위 0, 아닐 경우 1 + var completePriority = cb.selectCase() + .when( + cb.and( + cb.isNotNull(institutionJoin.get("profileImageUrl")), + cb.isNotNull(courseJoin.get("stdgScor")), + cb.isNotNull(root.get("employmentRate")), + cb.or( + cb.exists(reviewExists), + cb.exists(crawledExists) + ) + ), + 0 + ) + .otherwise(1); + + List orders = new ArrayList<>(); + orders.add(cb.asc(completePriority)); + orders.addAll(query.getOrderList()); + query.orderBy(orders); + + return cb.conjunction(); + }; + } + /** * 모집 마감 임박순 정렬입니다. * 시작 예정 과정은 시작일 오름차순으로 먼저 노출하고, 이미 시작했거나 시작일이 없는 과정은 뒤로 보냅니다. diff --git a/src/test/java/com/bootsignal/domain/course/service/CourseServiceTest.java b/src/test/java/com/bootsignal/domain/course/service/CourseServiceTest.java index 7290722..8c0898f 100644 --- a/src/test/java/com/bootsignal/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/bootsignal/domain/course/service/CourseServiceTest.java @@ -68,7 +68,8 @@ void getCoursesAppliesRequestedSort(String sort, String expectedProperty, Sort.D null, sort, 0, - 10 + 10, + null ); courseService.getCourses(request); @@ -100,7 +101,8 @@ void getCoursesUsesQueryParameterSizeAndSpecificationOrderForPopularSort() { null, "popular", 0, - 7 + 7, + null ); courseService.getCourses(request); @@ -132,7 +134,8 @@ void getCoursesUsesSpecificationOrderForDeadlineSort() { null, "deadline", 0, - 10 + 10, + null ); courseService.getCourses(request);