-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[BE] feat: 축제 대학교 검색 기능 보완 및 정렬(#926) #927
Conversation
Test Results228 files 228 suites 29s ⏱️ Results for commit e8c4932. ♻️ This comment has been updated with latest results. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
고생하셨습니다 푸우!
코드의 가독성을 조금 개선해 볼 수 있으면 좋겠네요!
public boolean existsByName(String keyword) { | ||
return existsByExpression(getBooleanExpressionByKeyword(keyword)); | ||
} | ||
|
||
private boolean existsByExpression(BooleanExpression expression) { | ||
return !selectFrom(artist) | ||
.where(expression) | ||
.fetch() | ||
.isEmpty(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이렇게 사용할 수 있을 것 같네요!
public boolean existsByName(String keyword) {
return !selectFrom(artist)
.where(getBooleanExpressionByKeyword(keyword))
.fetch()
.isEmpty();
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
또한 #893 이슈에도 남겼지만, 해당 쿼리 실행하고 실행 계획 분석 부탁 드립니다!
private List<FestivalSearchV1Response> searchByExpression(BooleanExpression expression) {
return select(
new QFestivalSearchV1Response(
festival.id,
festival.name,
festival.festivalDuration.startDate,
festival.festivalDuration.endDate,
festival.posterImageUrl,
festivalQueryInfo.artistInfo))
.from(artist)
.innerJoin(stageArtist).on(expression.and(stageArtist.artistId.eq(artist.id)))
.innerJoin(stage).on(stage.id.eq(stageArtist.stageId))
.innerJoin(festival).on(festival.id.eq(stage.festival.id))
.innerJoin(festivalQueryInfo).on(festival.id.eq(festivalQueryInfo.festivalId))
.where(expression)
.fetch();
}
해당 쿼리에 .innerJoin(stageArtist).on(expression.and(stageArtist.artistId.eq(artist.id)))
실행 분석 결과 다음과 같은 쿼리와 동일한 실행을 보장합니다.
이유는 innerJoin 대상으로 옵티마이저가 최적화를 해줘서 그렇다고 하네요.
private List<FestivalSearchV1Response> searchByExpression(BooleanExpression expression) {
return select(
new QFestivalSearchV1Response(
festival.id,
festival.name,
festival.festivalDuration.startDate,
festival.festivalDuration.endDate,
festival.posterImageUrl,
festivalQueryInfo.artistInfo))
.from(artist)
.innerJoin(stageArtist).on(stageArtist.artistId.eq(artist.id))
.innerJoin(stage).on(stage.id.eq(stageArtist.stageId))
.innerJoin(festival).on(festival.id.eq(stage.festival.id))
.innerJoin(festivalQueryInfo).on(festival.id.eq(festivalQueryInfo.festivalId))
.where(expression)
.fetch();
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
그렇게 되면 최종적으로 다음과 같이 코드가 변할 것 같네요.
@Repository
public class ArtistFestivalSearchV1QueryDslRepository extends QueryDslRepositorySupport {
public ArtistFestivalSearchV1QueryDslRepository() {
super(Artist.class);
}
public List<FestivalSearchV1Response> executeSearch(String keyword) {
return select(new QFestivalSearchV1Response(
festival.id,
festival.name,
festival.festivalDuration.startDate,
festival.festivalDuration.endDate,
festival.posterImageUrl,
festivalQueryInfo.artistInfo)
)
.from(artist)
.innerJoin(stageArtist).on(stageArtist.artistId.eq(artist.id))
.innerJoin(stage).on(stage.id.eq(stageArtist.stageId))
.innerJoin(festival).on(festival.id.eq(stage.festival.id))
.innerJoin(festivalQueryInfo).on(festival.id.eq(festivalQueryInfo.festivalId))
.where(getBooleanExpressionByKeyword(keyword))
.fetch();
}
private BooleanExpression getBooleanExpressionByKeyword(String keyword) {
int keywordLength = keyword.length();
if (keywordLength == 0) {
throw new BadRequestException(ErrorCode.INVALID_KEYWORD);
}
if (keywordLength == 1) {
return artist.name.eq(keyword);
}
return artist.name.contains(keyword);
}
public boolean existsByName(String keyword) {
return !selectFrom(artist)
.where(getBooleanExpressionByKeyword(keyword))
.fetch()
.isEmpty();
}
}
private EnumMap<FestivalFilter, List<FestivalSearchV1Response>> divideByStatus( | ||
List<FestivalSearchV1Response> festivals) { | ||
return festivals.stream() | ||
.collect(Collectors.groupingBy( | ||
this::determineStatus, | ||
() -> new EnumMap<>(FestivalFilter.class), | ||
Collectors.toList() | ||
)); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
반환 타입이 구체적일 필요가 있을까요?
private List<FestivalSearchV1Response> sortFestival(List<FestivalSearchV1Response> festivals) { | ||
EnumMap<FestivalFilter, List<FestivalSearchV1Response>> festivalByStatus = divideByStatus(festivals); | ||
List<FestivalSearchV1Response> result = new ArrayList<>(); | ||
result.addAll(sortByStatus(PROGRESS, festivalByStatus.get(PROGRESS))); | ||
result.addAll(sortByStatus(PLANNED, festivalByStatus.get(PLANNED))); | ||
result.addAll(sortByStatus(END, festivalByStatus.get(END))); | ||
return result; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
result.addAll()
을 호출하는 순서가 결과에 영향을 미치네요
이렇게 되면 코드의 중복이 발생할 것 같구요. 😂
FestivalFilter
를 정렬한다면 호출하는 순서에 영향을 주지 않고도 중복을 제거할 수 있을 것 같네요!
private List<FestivalSearchV1Response> sortByStatus( | ||
FestivalFilter status, | ||
List<FestivalSearchV1Response> festivals) { | ||
if (festivals == null) { | ||
return Collections.emptyList(); | ||
} | ||
if (status == END) { | ||
sortEndFestival(festivals); | ||
} | ||
if (status == PROGRESS) { | ||
sortProgressFestival(festivals); | ||
} | ||
if (status == PLANNED) { | ||
sortPlannedFestival(festivals); | ||
} | ||
return festivals; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
인자로 들어오는 festivals
이 null이 아니라면 null 검사를 할 필요가 없지 않을까요?
Enum 타입에 대해선 switch문이 가독성이 더 높을 것 같네요!
private void sortEndFestival(List<FestivalSearchV1Response> festivals) { | ||
festivals.sort((festival1, festival2) -> { | ||
if (festival1.endDate().isAfter(festival2.endDate())) { | ||
return 1; | ||
} | ||
if (festival1.endDate().isEqual(festival2.endDate())) { | ||
return 0; | ||
} | ||
return -1; | ||
}); | ||
} | ||
|
||
private void sortProgressFestival(List<FestivalSearchV1Response> festivals) { | ||
festivals.sort((festival1, festival2) -> { | ||
if (festival1.startDate().isBefore(festival2.endDate())) { | ||
return 1; | ||
} | ||
if (festival1.startDate().isEqual(festival2.startDate())) { | ||
return 0; | ||
} | ||
return -1; | ||
}); | ||
} | ||
|
||
private void sortPlannedFestival(List<FestivalSearchV1Response> festivals) { | ||
festivals.sort((festival1, festival2) -> { | ||
if (festival1.startDate().isBefore(festival2.endDate())) { | ||
return 1; | ||
} | ||
if (festival1.startDate().isEqual(festival2.startDate())) { | ||
return 0; | ||
} | ||
return -1; | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comparator.comparing()
메서드를 사용하지 않으신 이유가 있을까요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
짤 때 하나의 값 비교가 아니고 StartDate, EndDate를 복합적으로 생각하지 않을까 생각했는데 하나의 값 비교로 가능하군요!! 변경했습니다
if (festival1.startDate().isBefore(festival2.endDate())) { | ||
return 1; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
진행 중, 진행 예정 축제의 경우 정렬 기준이 시작일 내림차순 일텐데, 이 부분은 의도하신걸까요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
앗 아닙니다
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
고생하셨습니다 푸우!
덕분에 코드가 보기 쉽게 바뀐것 같네요!
추가 리뷰는 가볍게 생각해주시면 될 것 같습니다.
그리고 제가 작성한 FestivalSearchV1QueryService
코드도 첨부할게요.
전 스트림을 사용해서 리팩터링 해봤네요 😁
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class FestivalSearchV1QueryService {
private final ArtistFestivalSearchV1QueryDslRepository artistFestivalSearchV1QueryDslRepository;
private final SchoolFestivalSearchV1QueryDslRepository schoolFestivalSearchV1QueryDslRepository;
private final Clock clock;
public List<FestivalSearchV1Response> search(String keyword) {
List<FestivalSearchV1Response> festivals = findFestivals(keyword);
Map<FestivalFilter, List<FestivalSearchV1Response>> statusToFestivals = groupByStatus(festivals);
return sortAndMerge(statusToFestivals);
}
private List<FestivalSearchV1Response> findFestivals(String keyword) {
if (artistFestivalSearchV1QueryDslRepository.existsByName(keyword)) {
return artistFestivalSearchV1QueryDslRepository.executeSearch(keyword);
}
return schoolFestivalSearchV1QueryDslRepository.executeSearch(keyword);
}
private Map<FestivalFilter, List<FestivalSearchV1Response>> groupByStatus(
List<FestivalSearchV1Response> festivals
) {
return festivals.stream()
.collect(groupingBy(this::determineStatus, () -> new EnumMap<>(FestivalFilter.class), toList()));
}
private FestivalFilter determineStatus(FestivalSearchV1Response festival) {
LocalDate now = LocalDate.now(clock);
if (now.isAfter(festival.endDate())) {
return END;
}
if (now.isBefore(festival.startDate())) {
return PLANNED;
}
return PROGRESS;
}
private List<FestivalSearchV1Response> sortAndMerge(
Map<FestivalFilter, List<FestivalSearchV1Response>> statusToFestivals
) {
statusToFestivals.forEach((status, festival) -> festival.sort(getFestivalComparatorByStatus(status)));
return statusToFestivals.entrySet().stream()
.sorted(comparing(entry -> getStatusOrder(entry.getKey())))
.flatMap(entry -> entry.getValue().stream())
.toList();
}
private Comparator<FestivalSearchV1Response> getFestivalComparatorByStatus(FestivalFilter status) {
return switch (status) {
case END -> comparing(FestivalSearchV1Response::endDate)
.thenComparing(FestivalSearchV1Response::id);
case PROGRESS, PLANNED -> comparing(FestivalSearchV1Response::startDate).reversed()
.thenComparing(FestivalSearchV1Response::id);
};
}
private int getStatusOrder(FestivalFilter status) {
return switch (status) {
case PROGRESS -> 0;
case PLANNED -> 1;
case END -> 2;
};
}
}
private void sortByStatus( | ||
FestivalFilter status, | ||
List<FestivalSearchV1Response> festivals) { | ||
|
||
switch (status) { | ||
case END -> festivals.sort(Comparator.comparing(FestivalSearchV1Response::endDate).reversed()); | ||
case PROGRESS, PLANNED -> festivals.sort(Comparator.comparing(FestivalSearchV1Response::startDate)); | ||
} | ||
return artistFestivalSearchV1QueryDslRepository.executeSearch(keyword); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍👍
추가로 .thenComparing(FestivalSearchV1Response::id)
추가해서 일정이 같은 축제의 경우 두 번째 정렬 조건을 추가해줄 수 있을 것 같네요!
private List<FestivalFilter> determineFestivalOrder() { | ||
return List.of(PROGRESS, PLANNED, END); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 함수 대신 FestivalFilter.values()
를 사용할 수 있을 것 같은데..
사용하지 않으신 이유가 있을까요?
만약, 있으시다면 해당 리스트를 static으로 만들어 사용해도 될 것 같네요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이거 values를 쓸까 고민했는데 FestivalFilter에서 적힌 순서가 정렬 순서라는게 명시적이지 않은 것이라 생각하여 후에 순서를 변경할 수 있다 생각했습니다!! 그래서 정렬이 필요한 곳에서 정렬 조건을 정의하고자 순서를 명시해줬네요
private Map<FestivalFilter, List<FestivalSearchV1Response>> divideByStatus( | ||
List<FestivalSearchV1Response> festivals) { | ||
return festivals.stream() | ||
.collect(Collectors.groupingBy( | ||
this::determineStatus, | ||
() -> new EnumMap<>(FestivalFilter.class), | ||
Collectors.toList() | ||
)); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
줄바꿈을 다음과 같이 사용하는건 어떤가요?
private Map<FestivalFilter, List<FestivalSearchV1Response>> divideByStatus( | |
List<FestivalSearchV1Response> festivals) { | |
return festivals.stream() | |
.collect(Collectors.groupingBy( | |
this::determineStatus, | |
() -> new EnumMap<>(FestivalFilter.class), | |
Collectors.toList() | |
)); | |
} | |
private Map<FestivalFilter, List<FestivalSearchV1Response>> divideByStatus( | |
List<FestivalSearchV1Response> festivals | |
) { | |
return festivals.stream() | |
.collect(Collectors.groupingBy( | |
this::determineStatus, | |
() -> new EnumMap<>(FestivalFilter.class), | |
Collectors.toList() | |
)); | |
} |
List<FestivalSearchV1Response> festivalsByFilter = festivalByStatus.getOrDefault(festivalFilter, | ||
new ArrayList<>()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
반환되는 리스트에 변경이 없다면 Collections.emptyList()
를 사용해도 좋을 것 같네요!
List<FestivalSearchV1Response> festivalsByFilter = festivalByStatus.getOrDefault(festivalFilter, | |
new ArrayList<>()); | |
List<FestivalSearchV1Response> festivalsByFilter = festivalByStatus.getOrDefault(festivalFilter, | |
Collections.emptyList()); |
📌 관련 이슈
✨ PR 세부 내용
검색 로직은 기존에
~ 대학교 혹은 ~대로 끝난다면 축제 이름에 해당 대학교 이름이 적힌 축제를 반환하고 나머지 경우에 아티스트 이름을 기반으로 검색하였습니다.
변경된 로직은
해당 이름에 해당하는 아티스트가 있는지 확인하고 없으면 축제 이름에 해당 키워드가 포함되어 있다면 반환하도록 변경하였습니다.
이로 인해 부경만 쳤을 경우 부경이란 키워드가 포함된 모든 축제를 받을 수 있습니다.
축제 이름에 해당 키워드가 포함되었는지 확인하는 것이기 때문에 만약 "축제"로 검색을 한다면 "축제"가 이름에 포함된 모든 축제가 반환됩니다.
이 부분에 대한 방어 로직을 짜려했으나 앱 초기에는 데이터도 얼마 없을 뿐더러 후에 페이징 적용시에 고려해도 좋을 것이라 판단하여 최대한 검색이 자유롭게 되도록 그대로 두었습니다.