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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ public class CourseService {
public PageResponse<CourseListResponse> getCourses(CourseListRequest request) {
CourseSort courseSort = CourseSort.from(request.sort());
LocalDate today = LocalDate.now(SERVICE_ZONE);
boolean shouldPrioritize = Boolean.TRUE.equals(request.prioritizeFull());

Specification<CourseSession> spec = Specification.allOf(
CourseSessionSpecification.withKeyword(request.keyword()),
CourseSessionSpecification.withTrngAreaCd(request.trngAreaCd()),
Expand All @@ -71,9 +73,30 @@ public PageResponse<CourseListResponse> 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<CourseSession> withFetch = spec.and((root, query, cb) -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@
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;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

/**
Expand All @@ -24,6 +31,18 @@ public class CourseSessionSpecification {
private CourseSessionSpecification() {
}

/**
* 이미 생성된 JOIN이 있으면 재사용하고, 없으면 새로 생성합니다.
* 동일 Specification 체인 내에서 같은 연관 경로에 대한 중복 JOIN을 방지합니다.
*/
@SuppressWarnings("unchecked")
private static <Y> Join<?, Y> findOrJoin(From<?, ?> from, String attribute, JoinType joinType) {
return (Join<?, Y>) from.getJoins().stream()
.filter(j -> attribute.equals(j.getAttribute().getName()))
.findFirst()
.orElseGet(() -> from.join(attribute, joinType));
}

/**
* 과정명(course.title) 또는 기관명(course.institution.institutionName) 부분 일치 검색
*/
Expand Down Expand Up @@ -162,6 +181,110 @@ public static Specification<CourseSession> orderByBookmarkCountDesc() {
};
}

/**
* 최신순 Criteria 정렬 — prioritizeFull과 함께 사용할 때 Pageable Sort 대신 적용합니다.
*/
public static Specification<CourseSession> 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<CourseSession> 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<CourseSession> 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<CourseSession> 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<Integer> reviewExists = query.subquery(Integer.class);
Root<Review> 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<Integer> crawledExists = query.subquery(Integer.class);
Root<CrawledReview> 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<Order> orders = new ArrayList<>();
orders.add(cb.asc(completePriority));
orders.addAll(query.getOrderList());
query.orderBy(orders);

return cb.conjunction();
};
}

/**
* 모집 마감 임박순 정렬입니다.
* 시작 예정 과정은 시작일 오름차순으로 먼저 노출하고, 이미 시작했거나 시작일이 없는 과정은 뒤로 보냅니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ void getCoursesAppliesRequestedSort(String sort, String expectedProperty, Sort.D
null,
sort,
0,
10
10,
null
);

courseService.getCourses(request);
Expand Down Expand Up @@ -100,7 +101,8 @@ void getCoursesUsesQueryParameterSizeAndSpecificationOrderForPopularSort() {
null,
"popular",
0,
7
7,
null
);

courseService.getCourses(request);
Expand Down Expand Up @@ -132,7 +134,8 @@ void getCoursesUsesSpecificationOrderForDeadlineSort() {
null,
"deadline",
0,
10
10,
null
);

courseService.getCourses(request);
Expand Down
Loading