From a31e815862fec822f5313711b61498fc7ccfaa5c Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 24 Jun 2026 15:21:00 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EA=B3=BC=EC=A0=95=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=99=84=EC=A0=84?= =?UTF-8?q?=ED=95=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B3=B4=EC=9C=A0=20?= =?UTF-8?q?=EA=B3=BC=EC=A0=95=20=EC=9A=B0=EC=84=A0=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/course/dto/CourseListRequest.java | 8 +- .../domain/course/service/CourseService.java | 23 +++- .../CourseSessionSpecification.java | 109 ++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) 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..0ab240f 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,28 @@ 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; + }; + } + + if (shouldPrioritize) { + // 기본 정렬 Specification 뒤에 완전한 데이터 우선순위를 삽입합니다. + 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..13dc529 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,10 @@ 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.JoinType; +import jakarta.persistence.criteria.Order; import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Subquery; @@ -14,6 +17,8 @@ import org.springframework.util.StringUtils; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import java.util.Set; /** @@ -162,6 +167,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 = root.join("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(); + } + + var courseJoin = root.join("course", JoinType.LEFT); + var institutionJoin = courseJoin.join("institution", JoinType.LEFT); + + // 사용자 리뷰 존재 여부 서브쿼리 + Subquery reviewCount = query.subquery(Long.class); + Root review = reviewCount.from(Review.class); + reviewCount.select(cb.count(review.get("id"))); + reviewCount.where( + cb.and( + cb.equal(review.get("course").get("id"), courseJoin.get("id")), + cb.isNull(review.get("deletedAt")) + ) + ); + + // 크롤링 리뷰 존재 여부 서브쿼리 + Subquery crawledReviewCount = query.subquery(Long.class); + Root crawled = crawledReviewCount.from(CrawledReview.class); + crawledReviewCount.select(cb.count(crawled.get("id"))); + crawledReviewCount.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.greaterThan(reviewCount, 0L), + cb.greaterThan(crawledReviewCount, 0L) + ) + ), + 0 + ) + .otherwise(1); + + List orders = new ArrayList<>(); + orders.add(cb.asc(completePriority)); + orders.addAll(query.getOrderList()); + query.orderBy(orders); + + return cb.conjunction(); + }; + } + /** * 모집 마감 임박순 정렬입니다. * 시작 예정 과정은 시작일 오름차순으로 먼저 노출하고, 이미 시작했거나 시작일이 없는 과정은 뒤로 보냅니다. From 621475e50111d0fd52d7e60310161b93fdf6e39a Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 24 Jun 2026 15:24:30 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20CourseListRequest=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/course/service/CourseServiceTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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); From 82b1b6545126231a6fb05f8b9422db047a4cbdee Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 24 Jun 2026 15:33:59 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20JOIN=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20EXISTS=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EB=A1=9C=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findOrJoin() 헬퍼 추가: 동일 Specification 체인 내 course/institution JOIN을 재사용하여 orderBySatisfaction + orderByCompleteDataFirst 조합 시 발생하던 중복 JOIN 방지 - reviewCount/crawledReviewCount COUNT 서브쿼리를 EXISTS 서브쿼리로 교체: 첫 번째 일치 행에서 단락(short-circuit)되어 row별 집계 비용 제거 --- .../CourseSessionSpecification.java | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) 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 13dc529..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 @@ -8,6 +8,8 @@ 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; @@ -29,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) 부분 일치 검색 */ @@ -185,7 +199,7 @@ public static Specification orderByLatest() { public static Specification orderBySatisfaction() { return (root, query, cb) -> { if (query != null && Long.class != query.getResultType() && long.class != query.getResultType()) { - var courseJoin = root.join("course", JoinType.LEFT); + var courseJoin = findOrJoin(root, "course", JoinType.LEFT); query.orderBy( cb.desc(courseJoin.get("stdgScor")), cb.desc(root.get("id")) @@ -221,25 +235,25 @@ public static Specification orderByCompleteDataFirst() { return cb.conjunction(); } - var courseJoin = root.join("course", JoinType.LEFT); - var institutionJoin = courseJoin.join("institution", JoinType.LEFT); + // 기존 Specification 체인에서 생성된 JOIN을 재사용하여 중복 JOIN을 방지합니다. + Join courseJoin = findOrJoin(root, "course", JoinType.LEFT); + Join institutionJoin = findOrJoin(courseJoin, "institution", JoinType.LEFT); - // 사용자 리뷰 존재 여부 서브쿼리 - Subquery reviewCount = query.subquery(Long.class); - Root review = reviewCount.from(Review.class); - reviewCount.select(cb.count(review.get("id"))); - reviewCount.where( + // 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 crawledReviewCount = query.subquery(Long.class); - Root crawled = crawledReviewCount.from(CrawledReview.class); - crawledReviewCount.select(cb.count(crawled.get("id"))); - crawledReviewCount.where( + 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")) @@ -254,8 +268,8 @@ public static Specification orderByCompleteDataFirst() { cb.isNotNull(courseJoin.get("stdgScor")), cb.isNotNull(root.get("employmentRate")), cb.or( - cb.greaterThan(reviewCount, 0L), - cb.greaterThan(crawledReviewCount, 0L) + cb.exists(reviewExists), + cb.exists(crawledExists) ) ), 0 From 415aad9fb3aa491b3c44abce30d7707dc861b4e7 Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 24 Jun 2026 15:43:07 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20POPULAR/DEADLINE=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=EC=97=90=20completePriority=20=EC=9A=B0=EC=84=A0?= =?UTF-8?q?=EC=88=9C=EC=9C=84=EA=B0=80=20=EC=A0=81=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/bootsignal/domain/course/service/CourseService.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 0ab240f..1cd2d38 100644 --- a/src/main/java/com/bootsignal/domain/course/service/CourseService.java +++ b/src/main/java/com/bootsignal/domain/course/service/CourseService.java @@ -83,8 +83,10 @@ public PageResponse getCourses(CourseListRequest request) { }; } - if (shouldPrioritize) { - // 기본 정렬 Specification 뒤에 완전한 데이터 우선순위를 삽입합니다. + // POPULAR/DEADLINE은 각각 북마크 수·마감일 기준 정렬이 주목적이므로 완전성 우선순위를 적용하지 않습니다. + if (shouldPrioritize + && courseSort != CourseSort.POPULAR + && courseSort != CourseSort.DEADLINE) { spec = spec.and(CourseSessionSpecification.orderByCompleteDataFirst()); }