Skip to content

Commit 093b0ac

Browse files
committed
fix(be): ES search 오류 원인 수정
1 parent b517a02 commit 093b0ac

File tree

6 files changed

+118
-74
lines changed

6 files changed

+118
-74
lines changed

backend/src/main/java/com/deliveranything/domain/delivery/service/DeliveryService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ private CursorPageResponse<DeliveredDetailsDto> getDeliveredDetailsCursor(
202202
Long lastOrderId = null;
203203

204204
if (nextPageToken != null) {
205-
String[] decoded = CursorUtil.decode(nextPageToken);
205+
String[] decoded = (String[]) CursorUtil.decode(nextPageToken);
206206

207207
if (decoded != null && decoded.length == 2) {
208208
try {

backend/src/main/java/com/deliveranything/domain/order/service/StoreOrderService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public CursorPageResponse<OrderResponse> getStoreOrdersByCursor(
3737
) {
3838
LocalDateTime lastCreatedAt = null;
3939
Long lastOrderId = null;
40-
String[] decodedParts = CursorUtil.decode(nextPageToken);
40+
String[] decodedParts = (String[]) CursorUtil.decode(nextPageToken);
4141

4242
if (decodedParts != null && decodedParts.length == 2) {
4343
try {

backend/src/main/java/com/deliveranything/domain/review/service/ReviewService.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@
3939
import org.springframework.data.redis.connection.ReturnType;
4040
import org.springframework.data.redis.core.RedisCallback;
4141
import org.springframework.data.redis.core.RedisTemplate;
42-
import org.springframework.data.redis.core.ZSetOperations;
4342
import org.springframework.data.redis.core.ZSetOperations.TypedTuple;
4443
import org.springframework.stereotype.Service;
4544
import org.springframework.transaction.annotation.Transactional;
@@ -179,7 +178,7 @@ public ReviewRatingAndListResponseDto getMyReviews(Long userId, Profile profile,
179178
ProfileType profileType = profile.getType();
180179
Long profileId = profile.getId();
181180

182-
String[] decodedCursor = CursorUtil.decode(cursor);
181+
String[] decodedCursor = (String[]) CursorUtil.decode(cursor);
183182

184183
//실제 조회
185184
List<ReviewResponse> reviewList = getReviewsByProfile(profileType, profileId, sort,
@@ -227,7 +226,7 @@ public ReviewRatingAndListResponseDto getStoreReviews(Long storeId, StoreReviewS
227226
log.info("상점 리뷰 리스트 조회 요청 - storeId: {}, sort: {}, cursor: {}, size: {}", storeId, sort, cursor,
228227
size);
229228

230-
String[] decodedCursor = CursorUtil.decode(cursor);
229+
String[] decodedCursor = (String[]) CursorUtil.decode(cursor);
231230

232231
//실제 조회
233232
List<Review> reviews = reviewRepository.getStoreReviews(storeId, sort,

backend/src/main/java/com/deliveranything/domain/search/store/document/StoreDocument.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22

33
import com.deliveranything.domain.store.store.entity.Store;
44
import com.deliveranything.domain.store.store.enums.StoreStatus;
5+
import java.time.OffsetDateTime;
6+
import java.time.ZoneOffset;
57
import java.util.ArrayList;
68
import java.util.List;
79
import lombok.Builder;
810
import lombok.Getter;
911
import lombok.Setter;
1012
import org.springframework.data.annotation.Id;
13+
import org.springframework.data.elasticsearch.annotations.DateFormat;
1114
import org.springframework.data.elasticsearch.annotations.Document;
1215
import org.springframework.data.elasticsearch.annotations.Field;
1316
import org.springframework.data.elasticsearch.annotations.FieldType;
17+
import org.springframework.data.elasticsearch.annotations.GeoPointField;
1418
import org.springframework.data.elasticsearch.core.geo.GeoPoint;
1519

1620
@Getter
@@ -40,12 +44,15 @@ public class StoreDocument {
4044
@Field(type = FieldType.Text, name = "road_address")
4145
private String roadAddress;
4246

43-
@Field(type = FieldType.Object, name = "location")
47+
@GeoPointField
4448
private GeoPoint location;
4549

4650
@Field(type = FieldType.Keyword, name = "status")
4751
private StoreStatus status;
4852

53+
@Field(type = FieldType.Date, name = "created_at", format = DateFormat.date_time)
54+
private OffsetDateTime createdAt;
55+
4956
@Setter
5057
@Builder.Default
5158
@Field(type = FieldType.Text, name = "keywords")
@@ -62,6 +69,7 @@ public static StoreDocument from(Store store) {
6269
.categoryId(store.getStoreCategory().getId())
6370
.roadAddress(store.getRoadAddr())
6471
.imageUrl(store.getImageUrl())
72+
.createdAt(store.getCreatedAt().atOffset(ZoneOffset.ofHours(9)))
6573
.build();
6674
}
6775
}
Lines changed: 91 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package com.deliveranything.domain.search.store.repository;
22

3+
import co.elastic.clients.elasticsearch._types.DistanceUnit;
4+
import co.elastic.clients.elasticsearch._types.GeoDistanceType;
35
import co.elastic.clients.elasticsearch._types.SortOrder;
46
import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
57
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
68
import com.deliveranything.domain.search.store.document.StoreDocument;
79
import com.deliveranything.domain.search.store.dto.StoreSearchRequest;
810
import com.deliveranything.global.common.CursorPageResponse;
911
import com.deliveranything.global.util.CursorUtil;
12+
import java.util.Arrays;
1013
import java.util.List;
1114
import java.util.stream.Collectors;
1215
import lombok.RequiredArgsConstructor;
@@ -25,78 +28,119 @@ public class StoreSearchRepositoryImpl implements StoreSearchRepositoryCustom {
2528

2629
private final ElasticsearchOperations elasticsearchOperations;
2730

28-
// 검색에 사용할 필드명은 상수로 관리
2931
private static final String FIELD_NAME = "name";
3032
private static final String FIELD_DESCRIPTION = "description";
3133
private static final String FIELD_KEYWORDS = "keywords";
3234
private static final String FIELD_CATEGORY_ID = "category_id";
3335
private static final String FIELD_LOCATION = "location";
34-
private static final String SORT_SCORE = "_score";
35-
private static final String SORT_ID = "_id";
36+
private static final String SORT_ID = "id";
3637

3738
@Override
3839
public CursorPageResponse<StoreDocument> search(StoreSearchRequest request) {
3940
int querySize = request.limit() + 1;
4041

42+
// 검색 조건 구성
4143
BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder();
42-
43-
if(StringUtils.hasText(request.searchText())) {
44-
boolQueryBuilder.must(m -> m
45-
.multiMatch(mm -> mm
46-
.query(request.searchText())
47-
.fields(FIELD_NAME, FIELD_DESCRIPTION, FIELD_KEYWORDS)
48-
));
49-
}
50-
51-
if(request.categoryId() != null) {
52-
boolQueryBuilder.filter(f -> f
53-
.term(t -> t
54-
.field(FIELD_CATEGORY_ID)
55-
.value(request.categoryId())
56-
));
57-
}
58-
59-
boolQueryBuilder.filter(f -> f
60-
.geoDistance(g -> g
61-
.field(FIELD_LOCATION)
62-
.location(l -> l.latlon(ll -> ll.lat(request.lat()).lon(request.lng())))
63-
.distance(request.distanceKm() + "km")
64-
));
44+
applyFilters(boolQueryBuilder, request);
6545

6646
NativeQueryBuilder queryBuilder = new NativeQueryBuilder()
6747
.withQuery(Query.of(q -> q.bool(boolQueryBuilder.build())))
68-
.withPageable(PageRequest.of(0, querySize))
69-
.withSort(s -> s.field(f -> f.field(SORT_SCORE).order(SortOrder.Desc)))
70-
.withSort(s -> s.field(f -> f.field(SORT_ID).order(SortOrder.Asc)));
71-
72-
if(StringUtils.hasText(request.nextPageToken())) {
73-
try {
74-
String[] decodedCursor = CursorUtil.decode(request.nextPageToken());
75-
if (decodedCursor != null) {
76-
queryBuilder.withSearchAfter(List.of(decodedCursor));
77-
}
78-
} catch (IllegalArgumentException e) {
79-
// 유효하지 않은 토큰일 경우 로그를 남기고 첫 페이지를 반환
80-
System.err.println("Invalid next page token: " + e.getMessage());
48+
.withPageable(PageRequest.of(0, querySize));
49+
50+
// 정렬 조건
51+
if (request.lat() != null && request.lng() != null) {
52+
queryBuilder
53+
.withSort(s -> s.geoDistance(g -> g
54+
.field(FIELD_LOCATION)
55+
.location(l -> l.latlon(ll -> ll.lat(request.lat()).lon(request.lng())))
56+
.unit(DistanceUnit.Kilometers)
57+
.distanceType(GeoDistanceType.Arc)
58+
.order(SortOrder.Asc)));
59+
}
60+
61+
// _id 정렬 또한 알 수 없는 이유로 실패하여, 가장 안정적인 _doc 순서로 정렬;;
62+
queryBuilder.withSort(s -> s.field(f -> f.field("_doc").order(SortOrder.Asc)));
63+
64+
// 커서(search_after) 처리
65+
if (StringUtils.hasText(request.nextPageToken())) {
66+
Object[] decodedCursor = CursorUtil.decode(request.nextPageToken());
67+
if (decodedCursor != null) {
68+
Object[] safeCursor = Arrays.stream(decodedCursor)
69+
.map(value -> {
70+
if (value instanceof Number) return value;
71+
if (value instanceof String s) return s;
72+
return value;
73+
})
74+
.toArray();
75+
queryBuilder.withSearchAfter(Arrays.asList(safeCursor));
8176
}
8277
}
8378

8479
NativeQuery searchQuery = queryBuilder.build();
85-
SearchHits<StoreDocument> searchHits = elasticsearchOperations.search(searchQuery, StoreDocument.class);
8680

87-
List<StoreDocument> documents = searchHits.getSearchHits().stream()
88-
.map(SearchHit::getContent).collect(Collectors.toList());
81+
// 검색 실행
82+
SearchHits<StoreDocument> searchHits;
83+
try {
84+
searchHits = elasticsearchOperations.search(searchQuery, StoreDocument.class);
85+
} catch (Exception e) {
86+
throw new RuntimeException("Failed to search store documents. Check logs for mapping or query errors.", e);
87+
}
88+
89+
List<SearchHit<StoreDocument>> hits = searchHits.getSearchHits();
90+
List<StoreDocument> documents = hits.stream()
91+
.map(SearchHit::getContent)
92+
.collect(Collectors.toList());
8993

94+
// 다음 페이지 여부 확인
9095
boolean hasNext = documents.size() > request.limit();
91-
List<StoreDocument> responseDocuments = hasNext ? documents.subList(0, request.limit()) : documents;
96+
List<StoreDocument> responseDocuments = hasNext
97+
? documents.subList(0, request.limit())
98+
: documents;
9299

100+
// 다음 페이지 토큰 생성
93101
String nextToken = null;
94-
if(hasNext) {
95-
SearchHit<StoreDocument> lastHit = searchHits.getSearchHits().get(responseDocuments.size() - 1);
96-
// _score, _id 순서에 맞게 정렬 값을 배열로 전달
97-
nextToken = CursorUtil.encode(lastHit.getSortValues().toArray());
102+
if (hasNext) {
103+
SearchHit<StoreDocument> lastHit = hits.get(request.limit() - 1);
104+
Object[] lastSortValues = lastHit.getSortValues().stream()
105+
.map(value -> {
106+
if (value instanceof Number) return value;
107+
if (value instanceof String s) return s;
108+
return value;
109+
})
110+
.toArray();
111+
nextToken = CursorUtil.encode(lastSortValues);
98112
}
99113

100114
return new CursorPageResponse<>(responseDocuments, nextToken, hasNext);
101115
}
116+
117+
// 검색 필터 구성
118+
private void applyFilters(BoolQuery.Builder boolQueryBuilder, StoreSearchRequest request) {
119+
if (StringUtils.hasText(request.searchText())) {
120+
boolQueryBuilder.must(m -> m.multiMatch(mm -> mm
121+
.query(request.searchText())
122+
.fields(FIELD_NAME, FIELD_DESCRIPTION, FIELD_KEYWORDS)
123+
));
124+
}
125+
126+
if (request.categoryId() != null) {
127+
boolQueryBuilder.filter(f -> f.term(t -> t
128+
.field(FIELD_CATEGORY_ID)
129+
.value(request.categoryId())));
130+
}
131+
132+
if (request.lat() != null && request.lng() != null) {
133+
if (request.distanceKm() != null) {
134+
boolQueryBuilder.filter(f -> f.geoDistance(g -> g
135+
.field(FIELD_LOCATION)
136+
.location(l -> l.latlon(ll -> ll.lat(request.lat()).lon(request.lng())))
137+
.distance(request.distanceKm() + "km")
138+
.distanceType(GeoDistanceType.Arc)
139+
));
140+
} else {
141+
// 거리순 정렬 시 location 필드가 없는 도큐먼트는 오류를 유발할 수 있으므로, 필드가 존재하는 도큐먼트만 필터링
142+
boolQueryBuilder.filter(f -> f.exists(e -> e.field(FIELD_LOCATION)));
143+
}
144+
}
145+
}
102146
}

backend/src/main/java/com/deliveranything/global/util/CursorUtil.java

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,31 @@
11
package com.deliveranything.global.util;
22

3-
import java.nio.charset.StandardCharsets;
4-
import java.util.Arrays;
3+
import com.fasterxml.jackson.databind.ObjectMapper;
54
import java.util.Base64;
6-
import java.util.stream.Collectors;
75
import org.springframework.util.StringUtils;
86

97
public final class CursorUtil {
108

11-
private static final String DELIMITER = "_";
9+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
1210

13-
private CursorUtil() {
14-
// Prevent instantiation
15-
}
11+
private CursorUtil() {}
1612

1713
public static String encode(Object... keys) {
18-
if (keys == null || keys.length == 0) {
19-
return null;
14+
if (keys == null || keys.length == 0) return null;
15+
try {
16+
byte[] jsonBytes = OBJECT_MAPPER.writeValueAsBytes(keys);
17+
return Base64.getEncoder().encodeToString(jsonBytes);
18+
} catch (Exception e) {
19+
throw new RuntimeException("Failed to encode cursor", e);
2020
}
21-
String rawCursor = Arrays.stream(keys)
22-
.map(String::valueOf)
23-
.collect(Collectors.joining(DELIMITER));
24-
return Base64.getEncoder().encodeToString(rawCursor.getBytes(StandardCharsets.UTF_8));
2521
}
2622

27-
public static String[] decode(String cursor) {
28-
if (!StringUtils.hasText(cursor)) {
29-
return null;
30-
}
23+
public static Object[] decode(String cursor) {
24+
if (!StringUtils.hasText(cursor)) return null;
3125
try {
32-
String decoded = new String(Base64.getDecoder().decode(cursor), StandardCharsets.UTF_8);
33-
return decoded.split(DELIMITER);
34-
} catch (IllegalArgumentException e) {
35-
// Handle invalid Base64 format
26+
byte[] decodedBytes = Base64.getDecoder().decode(cursor);
27+
return OBJECT_MAPPER.readValue(decodedBytes, Object[].class);
28+
} catch (Exception e) {
3629
return null;
3730
}
3831
}

0 commit comments

Comments
 (0)