Skip to content

Commit 8f56366

Browse files
authored
[feat] Elastic Search와 MySQL을 썼을 때 기능별 성능 비교
[feat] Elastic Search와 MySQL을 썼을 때 기능별 성능 비교
2 parents c8de428 + 692464b commit 8f56366

12 files changed

+717
-25
lines changed

src/main/java/com/wayble/server/explore/dto/common/FacilityResponseDto.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.wayble.server.explore.dto.common;
22

33
import com.wayble.server.explore.entity.EsWaybleZoneFacility;
4+
import com.wayble.server.wayblezone.entity.WaybleZoneFacility;
45
import lombok.Builder;
56

67
@Builder

src/main/java/com/wayble/server/explore/dto/common/WaybleZoneInfoResponseDto.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.wayble.server.explore.dto.common;
22

3+
import com.wayble.server.explore.entity.EsWaybleZoneFacility;
34
import com.wayble.server.explore.entity.WaybleZoneDocument;
5+
import com.wayble.server.wayblezone.entity.WaybleZone;
46
import com.wayble.server.wayblezone.entity.WaybleZoneType;
57
import lombok.Builder;
68

@@ -31,4 +33,20 @@ public static WaybleZoneInfoResponseDto from(WaybleZoneDocument document) {
3133
.facility(FacilityResponseDto.from(document.getFacility()))
3234
.build();
3335
}
36+
37+
public static WaybleZoneInfoResponseDto fromEntity(WaybleZone waybleZone) {
38+
return WaybleZoneInfoResponseDto.builder()
39+
.zoneId(waybleZone.getId())
40+
.zoneName(waybleZone.getZoneName())
41+
.zoneType(waybleZone.getZoneType())
42+
.thumbnailImageUrl(waybleZone.getMainImageUrl())
43+
.address(waybleZone.getAddress().toFullAddress())
44+
.latitude(waybleZone.getAddress().getLatitude())
45+
.longitude(waybleZone.getAddress().getLongitude())
46+
.averageRating(waybleZone.getRating())
47+
.reviewCount(waybleZone.getReviewCount())
48+
.facility(waybleZone.getFacility() != null ?
49+
FacilityResponseDto.from(EsWaybleZoneFacility.from(waybleZone.getFacility())) : null)
50+
.build();
51+
}
3452
}

src/main/java/com/wayble/server/explore/dto/facility/WaybleFacilityResponseDto.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.wayble.server.explore.dto.facility;
22

33
import com.wayble.server.explore.entity.FacilityType;
4+
import com.wayble.server.explore.entity.WaybleFacilityMySQL;
45
import com.wayble.server.explore.entity.WaybleFacilityDocument;
56
import lombok.AccessLevel;
67
import lombok.Builder;
@@ -20,4 +21,12 @@ public static WaybleFacilityResponseDto from(WaybleFacilityDocument facilityDocu
2021
.facilityType(facilityDocument.getFacilityType())
2122
.build();
2223
}
24+
25+
public static WaybleFacilityResponseDto fromEntity(WaybleFacilityMySQL waybleFacilityMySQL) {
26+
return WaybleFacilityResponseDto.builder()
27+
.latitude(waybleFacilityMySQL.getLatitude())
28+
.longitude(waybleFacilityMySQL.getLongitude())
29+
.facilityType(waybleFacilityMySQL.getFacilityType())
30+
.build();
31+
}
2332
}

src/main/java/com/wayble/server/explore/dto/search/response/WaybleZoneSearchResponseDto.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.wayble.server.explore.dto.common.WaybleZoneInfoResponseDto;
44
import com.wayble.server.explore.entity.WaybleZoneDocument;
5+
import com.wayble.server.wayblezone.entity.WaybleZone;
56
import lombok.AccessLevel;
67
import lombok.Builder;
78

@@ -19,4 +20,12 @@ public static WaybleZoneSearchResponseDto from(WaybleZoneDocument waybleZoneDocu
1920
.distance(distance)
2021
.build();
2122
}
23+
24+
public static WaybleZoneSearchResponseDto fromEntity(WaybleZone waybleZone, Double distance) {
25+
26+
return WaybleZoneSearchResponseDto.builder()
27+
.waybleZoneInfo(WaybleZoneInfoResponseDto.fromEntity(waybleZone))
28+
.distance(distance)
29+
.build();
30+
}
2231
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.wayble.server.explore.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.*;
5+
6+
@Entity
7+
@Getter
8+
@Builder
9+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
10+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
11+
@Table(name = "wayble_facility")
12+
public class WaybleFacilityMySQL {
13+
14+
@Id
15+
@GeneratedValue(strategy = GenerationType.IDENTITY)
16+
private Long id;
17+
18+
@Column(name = "latitude", nullable = false)
19+
private Double latitude;
20+
21+
@Column(name = "longitude", nullable = false)
22+
private Double longitude;
23+
24+
@Enumerated(EnumType.STRING)
25+
@Column(name = "facility_type", nullable = false)
26+
private FacilityType facilityType;
27+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.wayble.server.explore.repository.facility;
2+
3+
import com.wayble.server.explore.entity.WaybleFacilityMySQL;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
public interface WaybleFacilityMySQLRepository extends JpaRepository<WaybleFacilityMySQL, Long> {
7+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.wayble.server.explore.repository.facility;
2+
3+
import com.querydsl.core.BooleanBuilder;
4+
import com.querydsl.core.types.dsl.Expressions;
5+
import com.querydsl.core.types.dsl.NumberExpression;
6+
import com.querydsl.jpa.impl.JPAQueryFactory;
7+
import com.wayble.server.explore.dto.facility.WaybleFacilityConditionDto;
8+
import com.wayble.server.explore.dto.facility.WaybleFacilityResponseDto;
9+
import com.wayble.server.explore.entity.WaybleFacilityMySQL;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Repository;
12+
13+
import java.util.List;
14+
15+
import static com.wayble.server.explore.entity.QWaybleFacilityMySQL.waybleFacilityMySQL;
16+
17+
@Repository
18+
@RequiredArgsConstructor
19+
public class WaybleFacilityQuerySearchMysqlRepository {
20+
21+
private final JPAQueryFactory queryFactory;
22+
23+
private static final int LIMIT = 50;
24+
25+
/**
26+
* 위도, 경도, 시설 타입을 바탕으로 WaybleFacility를 거리순으로 N개 반환 (MySQL/QueryDSL)
27+
*/
28+
public List<WaybleFacilityResponseDto> findNearbyFacilitiesByType(
29+
WaybleFacilityConditionDto condition) {
30+
31+
// Haversine 거리 계산식 (QueryDSL Expression)
32+
NumberExpression<Double> distanceExpression = calculateHaversineDistance(
33+
condition.latitude(), condition.longitude());
34+
35+
// 조건 빌더
36+
BooleanBuilder whereClause = new BooleanBuilder();
37+
38+
// 시설 타입 조건 추가
39+
if (condition.facilityType() != null) {
40+
whereClause.and(waybleFacilityMySQL.facilityType.eq(condition.facilityType()));
41+
}
42+
43+
// 반경 10km 이내 필터링
44+
whereClause.and(distanceExpression.loe(10.0));
45+
46+
List<WaybleFacilityMySQL> facilities = queryFactory
47+
.selectFrom(waybleFacilityMySQL)
48+
.where(whereClause)
49+
.orderBy(distanceExpression.asc())
50+
.limit(LIMIT)
51+
.fetch();
52+
53+
return facilities.stream()
54+
.map(WaybleFacilityResponseDto::fromEntity)
55+
.toList();
56+
}
57+
58+
/**
59+
* Haversine 거리 계산 (QueryDSL Expression)
60+
*/
61+
private NumberExpression<Double> calculateHaversineDistance(double userLat, double userLon) {
62+
// 지구 반지름 (km)
63+
final double EARTH_RADIUS = 6371.0;
64+
65+
return Expressions.numberTemplate(Double.class,
66+
"{0} * 2 * ASIN(SQRT(" +
67+
"POWER(SIN(RADIANS({1} - {2}) / 2), 2) + " +
68+
"COS(RADIANS({2})) * COS(RADIANS({1})) * " +
69+
"POWER(SIN(RADIANS({3} - {4}) / 2), 2)" +
70+
"))",
71+
EARTH_RADIUS,
72+
waybleFacilityMySQL.latitude,
73+
userLat,
74+
waybleFacilityMySQL.longitude,
75+
userLon
76+
);
77+
}
78+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package com.wayble.server.explore.repository.recommend;
2+
3+
import com.querydsl.core.BooleanBuilder;
4+
import com.querydsl.core.types.dsl.Expressions;
5+
import com.querydsl.core.types.dsl.NumberExpression;
6+
import com.querydsl.jpa.impl.JPAQueryFactory;
7+
import com.wayble.server.explore.dto.common.FacilityResponseDto;
8+
import com.wayble.server.explore.dto.common.WaybleZoneInfoResponseDto;
9+
import com.wayble.server.explore.dto.recommend.WaybleZoneRecommendResponseDto;
10+
import com.wayble.server.explore.entity.EsWaybleZoneFacility;
11+
import com.wayble.server.user.entity.User;
12+
import com.wayble.server.user.entity.Gender;
13+
import com.wayble.server.common.entity.AgeGroup;
14+
import com.wayble.server.wayblezone.entity.WaybleZone;
15+
import com.wayble.server.wayblezone.entity.WaybleZoneVisitLog;
16+
import lombok.RequiredArgsConstructor;
17+
import org.springframework.stereotype.Repository;
18+
19+
import java.time.LocalDate;
20+
import java.time.temporal.ChronoUnit;
21+
import java.util.*;
22+
import java.util.stream.Collectors;
23+
24+
import static com.wayble.server.wayblezone.entity.QWaybleZone.waybleZone;
25+
import static com.wayble.server.wayblezone.entity.QWaybleZoneVisitLog.waybleZoneVisitLog;
26+
27+
@Repository
28+
@RequiredArgsConstructor
29+
public class WaybleZoneQueryRecommendMysqlRepository {
30+
31+
private final JPAQueryFactory queryFactory;
32+
33+
// === [가중치 설정] === //
34+
private static final double DISTANCE_WEIGHT = 0.55; // 거리 기반 점수 가중치
35+
private static final double SIMILARITY_WEIGHT = 0.15; // 유사 사용자 방문 이력 기반 점수 가중치
36+
private static final double RECENCY_WEIGHT = 0.3; // 최근 추천 내역 기반 감점 가중치
37+
38+
private static final int MAX_DAY_DIFF = 30; // 추천 감점 최대 기준일 (30일 전까지 고려)
39+
40+
public List<WaybleZoneRecommendResponseDto> searchPersonalWaybleZones(User user, double latitude, double longitude, int size) {
41+
42+
AgeGroup userAgeGroup = AgeGroup.fromBirthDate(user.getBirthDate());
43+
Gender userGender = user.getGender();
44+
45+
// Step 1: 50km 반경 이내 장소들 조회 (MySQL Haversine 공식 사용)
46+
NumberExpression<Double> distanceExpression = calculateHaversineDistance(latitude, longitude);
47+
48+
List<WaybleZone> nearbyZones = queryFactory
49+
.selectFrom(waybleZone)
50+
.leftJoin(waybleZone.facility).fetchJoin()
51+
.where(distanceExpression.loe(50.0)) // 50km 이내
52+
.orderBy(distanceExpression.asc())
53+
.limit(100) // 상위 100개만 가져와서 성능 최적화
54+
.fetch();
55+
56+
// Step 2: 최근 30일 이내 방문 로그 조회 (MySQL)
57+
LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);
58+
59+
List<WaybleZoneVisitLog> visitLogs = queryFactory
60+
.selectFrom(waybleZoneVisitLog)
61+
.where(waybleZoneVisitLog.visitedAt.goe(thirtyDaysAgo))
62+
.limit(10000)
63+
.fetch();
64+
65+
// Step 3: zoneId 별로 유사 사용자 방문 횟수 가중치 계산
66+
Map<Long, Double> zoneVisitScoreMap = new HashMap<>();
67+
68+
for (WaybleZoneVisitLog log : visitLogs) {
69+
double weight = 0.0;
70+
boolean ageMatch = log.getAgeGroup() == userAgeGroup;
71+
boolean genderMatch = log.getGender() == userGender;
72+
73+
if (ageMatch && genderMatch) {
74+
weight = 1.0; // 성별, 연령 둘 다 일치
75+
} else if (ageMatch) {
76+
weight = 0.7; // 연령만 일치
77+
} else if (genderMatch) {
78+
weight = 0.2; // 성별만 일치
79+
}
80+
81+
zoneVisitScoreMap.merge(log.getZoneId(), weight, Double::sum);
82+
}
83+
84+
// Step 4: 최근 추천 날짜 조회 (MySQL - 실제 추천 로그 테이블이 있다면)
85+
// 여기서는 간단히 빈 맵으로 처리 (실제로는 추천 로그 테이블에서 조회)
86+
Map<Long, LocalDate> recentRecommendDateMap = new HashMap<>();
87+
88+
// 실제 구현시에는 아래와 같이 추천 로그 테이블에서 조회
89+
/*
90+
List<RecommendLog> recommendLogs = queryFactory
91+
.selectFrom(recommendLog)
92+
.where(recommendLog.userId.eq(user.getId()))
93+
.limit(1000)
94+
.fetch();
95+
96+
Map<Long, LocalDate> recentRecommendDateMap = recommendLogs.stream()
97+
.collect(Collectors.toMap(
98+
RecommendLog::getZoneId,
99+
RecommendLog::getRecommendationDate,
100+
(existing, replacement) -> existing.isAfter(replacement) ? existing : replacement
101+
));
102+
*/
103+
104+
// Step 5: 각 장소마다 점수 계산 후 DTO로 변환
105+
return nearbyZones.stream()
106+
.map(zone -> {
107+
// 거리 계산 (Java로 정확한 계산)
108+
double distanceKm = calculateHaversineDistanceJava(
109+
latitude, longitude,
110+
zone.getAddress().getLatitude(), zone.getAddress().getLongitude()
111+
);
112+
113+
// 거리 점수 계산 (가까울수록 높음)
114+
double distanceScore = (1.0 / (1.0 + distanceKm)) * DISTANCE_WEIGHT;
115+
116+
// 유사도 점수 (비슷한 사용자 방문수 반영)
117+
double similarityScore = (zoneVisitScoreMap.getOrDefault(zone.getId(), 0.0) / 10.0) * SIMILARITY_WEIGHT;
118+
119+
// 최근 추천일 기반 감점 계산
120+
double recencyScore = RECENCY_WEIGHT;
121+
LocalDate lastRecommendDate = recentRecommendDateMap.get(zone.getId());
122+
123+
if (lastRecommendDate != null) {
124+
long daysSince = ChronoUnit.DAYS.between(lastRecommendDate, LocalDate.now());
125+
double factor = 1.0 - Math.min(daysSince, MAX_DAY_DIFF) / (double) MAX_DAY_DIFF; // 0~1
126+
recencyScore = RECENCY_WEIGHT * (1.0 - factor); // days=0 -> 0점, days=30 -> full 점수
127+
}
128+
129+
double totalScore = distanceScore + similarityScore + recencyScore;
130+
131+
WaybleZoneInfoResponseDto waybleZoneInfo = WaybleZoneInfoResponseDto.builder()
132+
.zoneId(zone.getId())
133+
.zoneName(zone.getZoneName())
134+
.zoneType(zone.getZoneType())
135+
.thumbnailImageUrl(zone.getMainImageUrl())
136+
.address(zone.getAddress().toFullAddress())
137+
.latitude(zone.getAddress().getLatitude())
138+
.longitude(zone.getAddress().getLongitude())
139+
.averageRating(zone.getRating())
140+
.reviewCount(zone.getReviewCount())
141+
.facility(zone.getFacility() != null ?
142+
FacilityResponseDto.from(EsWaybleZoneFacility.from(zone.getFacility())) : null)
143+
.build();
144+
145+
return WaybleZoneRecommendResponseDto.builder()
146+
.waybleZoneInfo(waybleZoneInfo)
147+
.distanceScore(distanceScore)
148+
.similarityScore(similarityScore)
149+
.recencyScore(recencyScore)
150+
.totalScore(totalScore)
151+
.build();
152+
})
153+
.sorted(Comparator.comparingDouble(WaybleZoneRecommendResponseDto::totalScore).reversed()) // 점수 내림차순 정렬
154+
.limit(size) // 상위 size 개수만 반환
155+
.toList();
156+
}
157+
158+
/**
159+
* Haversine 거리 계산 (QueryDSL Expression)
160+
*/
161+
private NumberExpression<Double> calculateHaversineDistance(double userLat, double userLon) {
162+
// 지구 반지름 (km)
163+
final double EARTH_RADIUS = 6371.0;
164+
165+
return Expressions.numberTemplate(Double.class,
166+
"{0} * 2 * ASIN(SQRT(" +
167+
"POWER(SIN(RADIANS({1} - {2}) / 2), 2) + " +
168+
"COS(RADIANS({2})) * COS(RADIANS({1})) * " +
169+
"POWER(SIN(RADIANS({3} - {4}) / 2), 2)" +
170+
"))",
171+
EARTH_RADIUS,
172+
waybleZone.address.latitude,
173+
userLat,
174+
waybleZone.address.longitude,
175+
userLon
176+
);
177+
}
178+
179+
/**
180+
* Haversine 거리 계산 (Java 구현)
181+
*/
182+
private double calculateHaversineDistanceJava(double lat1, double lon1, double lat2, double lon2) {
183+
final double R = 6371; // 지구 반지름 (km)
184+
double dLat = Math.toRadians(lat2 - lat1);
185+
double dLon = Math.toRadians(lon2 - lon1);
186+
double a = Math.sin(dLat/2) * Math.sin(dLat/2)
187+
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
188+
* Math.sin(dLon/2) * Math.sin(dLon/2);
189+
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
190+
return R * c;
191+
}
192+
}

0 commit comments

Comments
 (0)