Skip to content

Commit a16ce9e

Browse files
authored
feat: 대학지원정보 텍스트 검색 기능 구현 (#486)
* feat: 텍스트 기반 검색 레포지토리 함수 구현 * feat: 텍스트 기반 검색 서비스 함수 구현 - 캐싱 적용 * test: 테스트 코드 작성 * refactor: N+1 최소화 * chore: 오해 방지를 위해 주석 수정 * refactor: term을 검색 캐싱에 포함하도록 * fix: api 규격에 맞게 path variable 이름 변경
1 parent c60163f commit a16ce9e

File tree

7 files changed

+219
-16
lines changed

7 files changed

+219
-16
lines changed

src/main/java/com/example/solidconnection/university/controller/UnivApplyInfoController.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import jakarta.validation.Valid;
1414
import java.util.List;
1515
import lombok.RequiredArgsConstructor;
16+
import org.springframework.beans.factory.annotation.Value;
1617
import org.springframework.http.ResponseEntity;
1718
import org.springframework.web.bind.annotation.DeleteMapping;
1819
import org.springframework.web.bind.annotation.GetMapping;
@@ -32,6 +33,9 @@ public class UnivApplyInfoController {
3233
private final LikedUnivApplyInfoService likedUnivApplyInfoService;
3334
private final UnivApplyInfoRecommendService univApplyInfoRecommendService;
3435

36+
@Value("${university.term}")
37+
public String term;
38+
3539
@GetMapping("/recommend")
3640
public ResponseEntity<UnivApplyInfoRecommendsResponse> getUnivApplyInfoRecommends(
3741
@AuthorizedUser(required = false) Long siteUserId
@@ -91,15 +95,15 @@ public ResponseEntity<UnivApplyInfoDetailResponse> getUnivApplyInfoDetails(
9195
public ResponseEntity<UnivApplyInfoPreviewResponses> searchUnivApplyInfoByFilter(
9296
@Valid @ModelAttribute UnivApplyInfoFilterSearchRequest request
9397
) {
94-
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request);
98+
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request, term);
9599
return ResponseEntity.ok(response);
96100
}
97101

98102
@GetMapping("/search/text")
99103
public ResponseEntity<UnivApplyInfoPreviewResponses> searchUnivApplyInfoByText(
100-
@RequestParam(required = false) String text
104+
@RequestParam(required = false) String value
101105
) {
102-
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(text);
106+
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(value, term);
103107
return ResponseEntity.ok(response);
104108
}
105109
}

src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ public interface UnivApplyInfoFilterRepository {
99
List<UnivApplyInfo> findAllByRegionCodeAndKeywords(String regionCode, List<String> keywords);
1010

1111
List<UnivApplyInfo> findAllByFilter(LanguageTestType testType, String testScore, String term, List<String> countryKoreanNames);
12+
13+
List<UnivApplyInfo> findAllByText(String text, String term);
1214
}

src/main/java/com/example/solidconnection/university/repository/custom/UnivApplyInfoFilterRepositoryImpl.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package com.example.solidconnection.university.repository.custom;
22

33
import com.example.solidconnection.location.country.domain.QCountry;
4+
import com.example.solidconnection.location.region.domain.QRegion;
45
import com.example.solidconnection.university.domain.LanguageTestType;
56
import com.example.solidconnection.university.domain.QLanguageRequirement;
67
import com.example.solidconnection.university.domain.QUnivApplyInfo;
78
import com.example.solidconnection.university.domain.QUniversity;
89
import com.example.solidconnection.university.domain.UnivApplyInfo;
10+
import com.querydsl.core.BooleanBuilder;
911
import com.querydsl.core.types.dsl.BooleanExpression;
12+
import com.querydsl.core.types.dsl.CaseBuilder;
1013
import com.querydsl.core.types.dsl.Expressions;
14+
import com.querydsl.core.types.dsl.NumberExpression;
1115
import com.querydsl.core.types.dsl.StringPath;
16+
import com.querydsl.jpa.impl.JPAQuery;
1217
import com.querydsl.jpa.impl.JPAQueryFactory;
1318
import jakarta.persistence.EntityManager;
1419
import java.util.List;
@@ -136,4 +141,46 @@ private boolean isGivenScoreOverMinPassScore(
136141
.map(requirement -> givenTestType.compare(givenTestScore, requirement.getMinScore()))
137142
.orElse(-1) >= 0;
138143
}
144+
145+
@Override
146+
public List<UnivApplyInfo> findAllByText(String text, String term) {
147+
QUnivApplyInfo univApplyInfo = QUnivApplyInfo.univApplyInfo;
148+
QUniversity university = QUniversity.university;
149+
QLanguageRequirement languageRequirement = QLanguageRequirement.languageRequirement;
150+
QCountry country = QCountry.country;
151+
QRegion region = QRegion.region;
152+
153+
JPAQuery<UnivApplyInfo> base = queryFactory.selectFrom(univApplyInfo)
154+
.join(univApplyInfo.university, university).fetchJoin()
155+
.join(university.country, country).fetchJoin()
156+
.join(region).on(country.regionCode.eq(region.code))
157+
.leftJoin(univApplyInfo.languageRequirements, languageRequirement).fetchJoin()
158+
.where(termEq(univApplyInfo, term));
159+
160+
// text 가 비어있다면 모든 대학 지원 정보를 id 오름차순으로 정렬하여 반환
161+
if (text == null || text.isBlank()) {
162+
return base.orderBy(univApplyInfo.id.asc()).fetch();
163+
}
164+
165+
// 매칭 조건 (대학 지원 정보명/국가명/지역명 중 하나라도 포함)
166+
BooleanExpression univApplyInfoLike = univApplyInfo.koreanName.contains(text);
167+
BooleanExpression countryLike = country.koreanName.contains(text);
168+
BooleanExpression regionLike = region.koreanName.contains(text);
169+
BooleanBuilder where = new BooleanBuilder()
170+
.or(univApplyInfoLike)
171+
.or(countryLike)
172+
.or(regionLike);
173+
174+
// 우선순위 랭크: 대학 지원 정보명(0) > 국가명(1) > 지역명(2) > 그 외(3)
175+
NumberExpression<Integer> rank = new CaseBuilder()
176+
.when(univApplyInfoLike).then(0)
177+
.when(countryLike).then(1)
178+
.when(regionLike).then(2)
179+
.otherwise(3);
180+
181+
// 정렬 조건: 랭크 오름차순 > 대학지원정보 id 오름차순
182+
return base.where(where)
183+
.orderBy(rank.asc(), univApplyInfo.id.asc())
184+
.fetch();
185+
}
139186
}

src/main/java/com/example/solidconnection/university/service/UnivApplyInfoQueryService.java

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import com.example.solidconnection.university.repository.UnivApplyInfoRepository;
1111
import java.util.List;
1212
import lombok.RequiredArgsConstructor;
13-
import org.springframework.beans.factory.annotation.Value;
1413
import org.springframework.stereotype.Service;
1514
import org.springframework.transaction.annotation.Transactional;
1615

@@ -20,15 +19,12 @@ public class UnivApplyInfoQueryService {
2019

2120
private final UnivApplyInfoRepository univApplyInfoRepository;
2221

23-
@Value("${university.term}")
24-
public String term;
25-
2622
/*
2723
* 대학교 상세 정보를 불러온다.
2824
* - 대학교(University) 정보와 대학 지원 정보(UniversityInfoForApply) 정보를 조합하여 반환한다.
2925
* */
3026
@Transactional(readOnly = true)
31-
@ThunderingHerdCaching(key = "univApplyInfo:{0}", cacheManager = "customCacheManager", ttlSec = 86400)
27+
@ThunderingHerdCaching(key = "univApplyInfo:{0}:{1}", cacheManager = "customCacheManager", ttlSec = 86400)
3228
public UnivApplyInfoDetailResponse getUnivApplyInfoDetail(Long univApplyInfoId) {
3329
UnivApplyInfo univApplyInfo
3430
= univApplyInfoRepository.getUnivApplyInfoById(univApplyInfoId);
@@ -38,7 +34,7 @@ public UnivApplyInfoDetailResponse getUnivApplyInfoDetail(Long univApplyInfoId)
3834
}
3935

4036
@Transactional(readOnly = true)
41-
public UnivApplyInfoPreviewResponses searchUnivApplyInfoByFilter(UnivApplyInfoFilterSearchRequest request) {
37+
public UnivApplyInfoPreviewResponses searchUnivApplyInfoByFilter(UnivApplyInfoFilterSearchRequest request, String term) {
4238
List<UnivApplyInfoPreviewResponse> responses = univApplyInfoRepository
4339
.findAllByFilter(request.languageTestType(), request.testScore(), term, request.countryCode())
4440
.stream()
@@ -48,8 +44,12 @@ public UnivApplyInfoPreviewResponses searchUnivApplyInfoByFilter(UnivApplyInfoFi
4844
}
4945

5046
@Transactional(readOnly = true)
51-
public UnivApplyInfoPreviewResponses searchUnivApplyInfoByText(String text) {
52-
// todo: 구현
53-
return null;
47+
@ThunderingHerdCaching(key = "univApplyInfoTextSearch:{0}:{1}", cacheManager = "customCacheManager", ttlSec = 86400)
48+
public UnivApplyInfoPreviewResponses searchUnivApplyInfoByText(String text, String term) {
49+
List<UnivApplyInfoPreviewResponse> responses = univApplyInfoRepository.findAllByText(text, term)
50+
.stream()
51+
.map(UnivApplyInfoPreviewResponse::from)
52+
.toList();
53+
return new UnivApplyInfoPreviewResponses(responses);
5454
}
5555
}

src/test/java/com/example/solidconnection/university/fixture/UnivApplyInfoFixture.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ public class UnivApplyInfoFixture {
3939
.create();
4040
}
4141

42+
public UnivApplyInfo 아칸소주립대학_지원_정보() {
43+
return univApplyInfoFixtureBuilder.univApplyInfo()
44+
.term(term)
45+
.koreanName("아칸소 주립 대학")
46+
.university(universityFixture.아칸소_주립_대학())
47+
.create();
48+
}
49+
4250
public UnivApplyInfo 메모리얼대학_세인트존스_A_지원_정보() {
4351
return univApplyInfoFixtureBuilder.univApplyInfo()
4452
.term(term)

src/test/java/com/example/solidconnection/university/fixture/UniversityFixture.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ public final class UniversityFixture {
3232
.create();
3333
}
3434

35+
public University 아칸소_주립_대학() {
36+
return universityFixtureBuilder.university()
37+
.koreanName("아칸소 주립 대학")
38+
.englishName("Arkansas State University")
39+
.country(countryFixture.미국())
40+
.region(regionFixture.영미권())
41+
.create();
42+
}
43+
3544
public University 메모리얼_대학_세인트존스() {
3645
return universityFixtureBuilder.university()
3746
.koreanName("메모리얼 대학 세인트존스")

src/test/java/com/example/solidconnection/university/service/UnivApplyInfoQueryServiceTest.java

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static com.example.solidconnection.common.exception.ErrorCode.UNIV_APPLY_INFO_NOT_FOUND;
44
import static com.example.solidconnection.university.domain.LanguageTestType.TOEIC;
55
import static org.assertj.core.api.Assertions.assertThat;
6+
import static org.assertj.core.api.Assertions.assertThatCode;
67
import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType;
78
import static org.junit.jupiter.api.Assertions.assertAll;
89
import static org.mockito.BDDMockito.then;
@@ -23,6 +24,7 @@
2324
import org.junit.jupiter.api.Nested;
2425
import org.junit.jupiter.api.Test;
2526
import org.springframework.beans.factory.annotation.Autowired;
27+
import org.springframework.beans.factory.annotation.Value;
2628
import org.springframework.boot.test.mock.mockito.SpyBean;
2729

2830
@TestContainerSpringBootTest
@@ -41,6 +43,9 @@ class UnivApplyInfoQueryServiceTest {
4143
@Autowired
4244
private LanguageRequirementFixture languageRequirementFixture;
4345

46+
@Value("${university.term}")
47+
public String term;
48+
4449
@Nested
4550
class 대학_지원_정보_상세_조회 {
4651

@@ -97,7 +102,7 @@ class 대학_지원_정보_필터링_검색 {
97102
languageRequirementFixture.토플_70(괌대학_B_지원_정보);
98103

99104
// when
100-
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request);
105+
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request, term);
101106

102107
// then
103108
assertThat(response.univApplyInfoPreviews())
@@ -114,7 +119,7 @@ class 대학_지원_정보_필터링_검색 {
114119
languageRequirementFixture.토익_900(괌대학_B_지원_정보);
115120

116121
// when
117-
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request);
122+
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request, term);
118123

119124
// then
120125
assertThat(response.univApplyInfoPreviews())
@@ -132,8 +137,8 @@ class 대학_지원_정보_필터링_검색 {
132137
languageRequirementFixture.토익_800(메모리얼대학_세인트존스_A_지원_정보);
133138

134139
// when
135-
UnivApplyInfoPreviewResponses response1 = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request1);
136-
UnivApplyInfoPreviewResponses response2 = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request2);
140+
UnivApplyInfoPreviewResponses response1 = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request1, term);
141+
UnivApplyInfoPreviewResponses response2 = univApplyInfoQueryService.searchUnivApplyInfoByFilter(request2, term);
137142

138143
// then
139144
assertAll(
@@ -147,4 +152,132 @@ class 대학_지원_정보_필터링_검색 {
147152
);
148153
}
149154
}
155+
156+
@Nested
157+
class 대학_지원_정보_텍스트_검색 {
158+
159+
@Test
160+
void 텍스트가_없으면_전체_대학을_id_순으로_정렬하여_반환한다() {
161+
// given
162+
UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보();
163+
UnivApplyInfo 메이지대학_지원_정보 = univApplyInfoFixture.메이지대학_지원_정보();
164+
165+
// when
166+
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(null, term);
167+
168+
// then
169+
assertThat(response.univApplyInfoPreviews())
170+
.containsExactly(
171+
UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보),
172+
UnivApplyInfoPreviewResponse.from(메이지대학_지원_정보)
173+
);
174+
}
175+
176+
@Nested
177+
class 각각의_검색_대상에_대해_검색한다 {
178+
179+
@Test
180+
void 국문_대학_지원_정보명() {
181+
// given
182+
String text = "메";
183+
UnivApplyInfo 메이지대학_지원_정보 = univApplyInfoFixture.메이지대학_지원_정보();
184+
UnivApplyInfo 메모리얼대학_세인트존스_A_지원_정보 = univApplyInfoFixture.메모리얼대학_세인트존스_A_지원_정보();
185+
univApplyInfoFixture.괌대학_A_지원_정보();
186+
187+
// when
188+
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term);
189+
190+
// then
191+
assertThat(response.univApplyInfoPreviews())
192+
.containsExactly(
193+
UnivApplyInfoPreviewResponse.from(메이지대학_지원_정보),
194+
UnivApplyInfoPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보)
195+
);
196+
}
197+
198+
@Test
199+
void 국문_국가명() {
200+
// given
201+
String text = "미국";
202+
UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보();
203+
UnivApplyInfo 괌대학_B_지원_정보 = univApplyInfoFixture.괌대학_B_지원_정보();
204+
univApplyInfoFixture.메이지대학_지원_정보();
205+
206+
// when
207+
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term);
208+
209+
// then
210+
assertThat(response.univApplyInfoPreviews())
211+
.containsExactly(
212+
UnivApplyInfoPreviewResponse.from(괌대학_A_지원_정보),
213+
UnivApplyInfoPreviewResponse.from(괌대학_B_지원_정보)
214+
);
215+
}
216+
217+
@Test
218+
void 국문_권역명() {
219+
// given
220+
String text = "유럽";
221+
UnivApplyInfo 린츠_카톨릭대학_지원_정보 = univApplyInfoFixture.린츠_카톨릭대학_지원_정보();
222+
UnivApplyInfo 서던덴마크대학교_지원_정보 = univApplyInfoFixture.서던덴마크대학교_지원_정보();
223+
univApplyInfoFixture.메이지대학_지원_정보();
224+
225+
// when
226+
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term);
227+
228+
// then
229+
assertThat(response.univApplyInfoPreviews())
230+
.containsExactly(
231+
UnivApplyInfoPreviewResponse.from(린츠_카톨릭대학_지원_정보),
232+
UnivApplyInfoPreviewResponse.from(서던덴마크대학교_지원_정보)
233+
);
234+
}
235+
}
236+
237+
@Test
238+
void 대학_국가_권역_일치_순서로_정렬하여_응답한다() {
239+
// given
240+
String text = "아";
241+
UnivApplyInfo 권역_아 = univApplyInfoFixture.메이지대학_지원_정보();
242+
UnivApplyInfo 국가_아 = univApplyInfoFixture.그라츠대학_지원_정보();
243+
UnivApplyInfo 대학지원정보_아 = univApplyInfoFixture.아칸소주립대학_지원_정보();
244+
245+
// when
246+
UnivApplyInfoPreviewResponses response = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term);
247+
248+
// then
249+
assertThat(response.univApplyInfoPreviews())
250+
.containsExactly(
251+
UnivApplyInfoPreviewResponse.from(대학지원정보_아),
252+
UnivApplyInfoPreviewResponse.from(국가_아),
253+
UnivApplyInfoPreviewResponse.from(권역_아)
254+
);
255+
}
256+
257+
@Test
258+
void 캐시가_적용된다() {
259+
// given
260+
String text = "Guam";
261+
UnivApplyInfo 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보();
262+
263+
// when
264+
UnivApplyInfoPreviewResponses firstResponse = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term);
265+
UnivApplyInfoPreviewResponses secondResponse = univApplyInfoQueryService.searchUnivApplyInfoByText(text, term);
266+
267+
// then
268+
assertThatCode(() -> {
269+
List<Long> firstResponseIds = extractIds(firstResponse);
270+
List<Long> secondResponseIds = extractIds(secondResponse);
271+
assertThat(firstResponseIds).isEqualTo(secondResponseIds);
272+
}).doesNotThrowAnyException();
273+
then(univApplyInfoRepository).should(times(1)).findAllByText(text, term);
274+
}
275+
276+
private List<Long> extractIds(UnivApplyInfoPreviewResponses responses) {
277+
return responses.univApplyInfoPreviews()
278+
.stream()
279+
.map(UnivApplyInfoPreviewResponse::id)
280+
.toList();
281+
}
282+
}
150283
}

0 commit comments

Comments
 (0)