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
@@ -1,6 +1,7 @@
package com.wayble.server.explore.dto.common;

import com.wayble.server.explore.entity.EsWaybleZoneFacility;
import com.wayble.server.wayblezone.entity.WaybleZoneFacility;
import lombok.Builder;

@Builder
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.wayble.server.explore.dto.common;

import com.wayble.server.explore.entity.EsWaybleZoneFacility;
import com.wayble.server.explore.entity.WaybleZoneDocument;
import com.wayble.server.wayblezone.entity.WaybleZone;
import com.wayble.server.wayblezone.entity.WaybleZoneType;
import lombok.Builder;

Expand Down Expand Up @@ -31,4 +33,20 @@ public static WaybleZoneInfoResponseDto from(WaybleZoneDocument document) {
.facility(FacilityResponseDto.from(document.getFacility()))
.build();
}

public static WaybleZoneInfoResponseDto fromEntity(WaybleZone waybleZone) {
return WaybleZoneInfoResponseDto.builder()
.zoneId(waybleZone.getId())
.zoneName(waybleZone.getZoneName())
.zoneType(waybleZone.getZoneType())
.thumbnailImageUrl(waybleZone.getMainImageUrl())
.address(waybleZone.getAddress().toFullAddress())
.latitude(waybleZone.getAddress().getLatitude())
.longitude(waybleZone.getAddress().getLongitude())
.averageRating(waybleZone.getRating())
.reviewCount(waybleZone.getReviewCount())
.facility(waybleZone.getFacility() != null ?
FacilityResponseDto.from(EsWaybleZoneFacility.from(waybleZone.getFacility())) : null)
.build();
}
Comment on lines +37 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

검증 스크립트: ES 변환을 거쳐 FacilityResponseDto로 매핑하는 패턴 사용 여부 점검.

리팩터 시 영향 범위를 빠르게 확인하려면 다음 검색으로 호출 패턴을 점검하세요.


🏁 Script executed:

#!/bin/bash
# EsWaybleZoneFacility.from(...)를 거쳐 FacilityResponseDto.from(...)으로 들어오는 호출 검색
rg -nP -C2 'FacilityResponseDto\.from\s*\(\s*EsWaybleZoneFacility\.from\s*\(' --type=java

Length of output: 3157


ES 변환 후 FacilityResponseDto 매핑 위치 확인 및 반영 요청

전체 코드베이스에서 EsWaybleZoneFacility.from(...) → FacilityResponseDto.from(...) 호출 패턴이 아래 위치에서 발견되었습니다. 리팩터링 시 동일한 매핑 로직 변경이 누락되지 않도록 반드시 반영해 주세요.

  • src/main/java/com/wayble/server/explore/dto/common/WaybleZoneInfoResponseDto.java – 49행
  • src/main/java/com/wayble/server/wayblezone/repository/WaybleZoneRepositoryImpl.java – 51행, 98행
  • src/main/java/com/wayble/server/explore/repository/recommend/WaybleZoneQueryRecommendMysqlRepository.java – 142행

위 파일들의 해당 라인에서 FacilityResponseDto.from(EsWaybleZoneFacility.from(...)) 부분이 모두 동일하게 업데이트되었는지 검증 후 반영 바랍니다.

🤖 Prompt for AI Agents
In
src/main/java/com/wayble/server/explore/dto/common/WaybleZoneInfoResponseDto.java
around lines 37–51, the Facility mapping still uses
FacilityResponseDto.from(EsWaybleZoneFacility.from(waybleZone.getFacility()));
update this line to match the new, refactored mapping used elsewhere in the
codebase (the same replacement applied in WaybleZoneRepositoryImpl and
WaybleZoneQueryRecommendMysqlRepository), ensuring the mapping is null-safe and
uses the canonical EsWaybleZoneFacility→FacilityResponseDto flow introduced by
the refactor; verify the exact new method call/signature in the other files and
apply it here, then run compilation/tests to confirm no signature or
null-handling regressions.

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.wayble.server.explore.dto.facility;

import com.wayble.server.explore.entity.FacilityType;
import com.wayble.server.explore.entity.WaybleFacilityMySQL;
import com.wayble.server.explore.entity.WaybleFacilityDocument;
import lombok.AccessLevel;
import lombok.Builder;
Expand All @@ -20,4 +21,12 @@ public static WaybleFacilityResponseDto from(WaybleFacilityDocument facilityDocu
.facilityType(facilityDocument.getFacilityType())
.build();
}

public static WaybleFacilityResponseDto fromEntity(WaybleFacilityMySQL waybleFacilityMySQL) {
return WaybleFacilityResponseDto.builder()
.latitude(waybleFacilityMySQL.getLatitude())
.longitude(waybleFacilityMySQL.getLongitude())
.facilityType(waybleFacilityMySQL.getFacilityType())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.wayble.server.explore.dto.common.WaybleZoneInfoResponseDto;
import com.wayble.server.explore.entity.WaybleZoneDocument;
import com.wayble.server.wayblezone.entity.WaybleZone;
import lombok.AccessLevel;
import lombok.Builder;

Expand All @@ -19,4 +20,12 @@ public static WaybleZoneSearchResponseDto from(WaybleZoneDocument waybleZoneDocu
.distance(distance)
.build();
}

public static WaybleZoneSearchResponseDto fromEntity(WaybleZone waybleZone, Double distance) {

return WaybleZoneSearchResponseDto.builder()
.waybleZoneInfo(WaybleZoneInfoResponseDto.fromEntity(waybleZone))
.distance(distance)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.wayble.server.explore.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "wayble_facility")
public class WaybleFacilityMySQL {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "latitude", nullable = false)
private Double latitude;

@Column(name = "longitude", nullable = false)
private Double longitude;

@Enumerated(EnumType.STRING)
@Column(name = "facility_type", nullable = false)
private FacilityType facilityType;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.wayble.server.explore.repository.facility;

import com.wayble.server.explore.entity.WaybleFacilityMySQL;
import org.springframework.data.jpa.repository.JpaRepository;

public interface WaybleFacilityMySQLRepository extends JpaRepository<WaybleFacilityMySQL, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.wayble.server.explore.repository.facility;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.wayble.server.explore.dto.facility.WaybleFacilityConditionDto;
import com.wayble.server.explore.dto.facility.WaybleFacilityResponseDto;
import com.wayble.server.explore.entity.WaybleFacilityMySQL;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

import static com.wayble.server.explore.entity.QWaybleFacilityMySQL.waybleFacilityMySQL;

@Repository
@RequiredArgsConstructor
public class WaybleFacilityQuerySearchMysqlRepository {

private final JPAQueryFactory queryFactory;

private static final int LIMIT = 50;

/**
* 위도, 경도, 시설 타입을 바탕으로 WaybleFacility를 거리순으로 N개 반환 (MySQL/QueryDSL)
*/
public List<WaybleFacilityResponseDto> findNearbyFacilitiesByType(
WaybleFacilityConditionDto condition) {

// Haversine 거리 계산식 (QueryDSL Expression)
NumberExpression<Double> distanceExpression = calculateHaversineDistance(
condition.latitude(), condition.longitude());

// 조건 빌더
BooleanBuilder whereClause = new BooleanBuilder();

// 시설 타입 조건 추가
if (condition.facilityType() != null) {
whereClause.and(waybleFacilityMySQL.facilityType.eq(condition.facilityType()));
}

// 반경 10km 이내 필터링
whereClause.and(distanceExpression.loe(10.0));

List<WaybleFacilityMySQL> facilities = queryFactory
.selectFrom(waybleFacilityMySQL)
.where(whereClause)
.orderBy(distanceExpression.asc())
.limit(LIMIT)
.fetch();

return facilities.stream()
.map(WaybleFacilityResponseDto::fromEntity)
.toList();
}
Comment on lines +28 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

반경/Limit 하드코딩 → 조건/설정으로 외부화 권장 + 사전 바운딩 박스 필터 추가

  • 반경 10km, LIMIT 50은 하드코딩되어 있습니다. 비교 실험에서는 동일 파라미터를 ES와 MySQL에 모두 적용해야 합니다. WaybleFacilityConditionDto에 radiusKm/limit가 있다면 이를 사용하고, 없다면 추가를 검토하세요.
  • Haversine는 인덱스를 타지 못하므로, 먼저 바운딩 박스로 대략 필터링(위도±Δ, 경도±Δ) 후 Haversine로 정밀 필터/정렬하는 이단계 전략이 MySQL에서 유효합니다.
- whereClause.and(distanceExpression.loe(10.0));
+ double radius = condition.radiusKm() != null ? condition.radiusKm() : 10.0;
+ // 1) 바운딩 박스(≈간단 필터)
+ double lat = condition.latitude();
+ double lon = condition.longitude();
+ double latDelta = radius / 111.0;
+ double lonDelta = radius / (111.0 * Math.cos(Math.toRadians(lat)));
+ whereClause.and(waybleFacilityMySQL.latitude.between(lat - latDelta, lat + latDelta));
+ whereClause.and(waybleFacilityMySQL.longitude.between(lon - lonDelta, lon + lonDelta));
+ // 2) 정밀 반경 필터
+ whereClause.and(distanceExpression.loe(radius));
...
- .limit(LIMIT)
+ .limit(condition.limit() != null ? condition.limit() : LIMIT)

Committable suggestion skipped: line range outside the PR's diff.


/**
* Haversine 거리 계산 (QueryDSL Expression)
*/
private NumberExpression<Double> calculateHaversineDistance(double userLat, double userLon) {
// 지구 반지름 (km)
final double EARTH_RADIUS = 6371.0;

return Expressions.numberTemplate(Double.class,
"{0} * 2 * ASIN(SQRT(" +
"POWER(SIN(RADIANS({1} - {2}) / 2), 2) + " +
"COS(RADIANS({2})) * COS(RADIANS({1})) * " +
"POWER(SIN(RADIANS({3} - {4}) / 2), 2)" +
"))",
EARTH_RADIUS,
waybleFacilityMySQL.latitude,
userLat,
waybleFacilityMySQL.longitude,
userLon
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package com.wayble.server.explore.repository.recommend;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.wayble.server.explore.dto.common.FacilityResponseDto;
import com.wayble.server.explore.dto.common.WaybleZoneInfoResponseDto;
import com.wayble.server.explore.dto.recommend.WaybleZoneRecommendResponseDto;
import com.wayble.server.explore.entity.EsWaybleZoneFacility;
import com.wayble.server.user.entity.User;
import com.wayble.server.user.entity.Gender;
import com.wayble.server.common.entity.AgeGroup;
import com.wayble.server.wayblezone.entity.WaybleZone;
import com.wayble.server.wayblezone.entity.WaybleZoneVisitLog;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;

import static com.wayble.server.wayblezone.entity.QWaybleZone.waybleZone;
import static com.wayble.server.wayblezone.entity.QWaybleZoneVisitLog.waybleZoneVisitLog;

@Repository
@RequiredArgsConstructor
public class WaybleZoneQueryRecommendMysqlRepository {

private final JPAQueryFactory queryFactory;

// === [가중치 설정] === //
private static final double DISTANCE_WEIGHT = 0.55; // 거리 기반 점수 가중치
private static final double SIMILARITY_WEIGHT = 0.15; // 유사 사용자 방문 이력 기반 점수 가중치
private static final double RECENCY_WEIGHT = 0.3; // 최근 추천 내역 기반 감점 가중치

private static final int MAX_DAY_DIFF = 30; // 추천 감점 최대 기준일 (30일 전까지 고려)

public List<WaybleZoneRecommendResponseDto> searchPersonalWaybleZones(User user, double latitude, double longitude, int size) {

AgeGroup userAgeGroup = AgeGroup.fromBirthDate(user.getBirthDate());
Gender userGender = user.getGender();

// Step 1: 50km 반경 이내 장소들 조회 (MySQL Haversine 공식 사용)
NumberExpression<Double> distanceExpression = calculateHaversineDistance(latitude, longitude);

List<WaybleZone> nearbyZones = queryFactory
.selectFrom(waybleZone)
.leftJoin(waybleZone.facility).fetchJoin()
.where(distanceExpression.loe(50.0)) // 50km 이내
.orderBy(distanceExpression.asc())
.limit(100) // 상위 100개만 가져와서 성능 최적화
.fetch();

// Step 2: 최근 30일 이내 방문 로그 조회 (MySQL)
LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);

List<WaybleZoneVisitLog> visitLogs = queryFactory
.selectFrom(waybleZoneVisitLog)
.where(waybleZoneVisitLog.visitedAt.goe(thirtyDaysAgo))
.limit(10000)
.fetch();

Comment on lines +59 to +64
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

방문 로그 조회 범위 과도: 후보 zoneId로 축소해 I/O/연산량 감소

현재 최근 30일 로그를 최대 10,000건까지 전부 읽습니다. 1차 후보(반경 50km, 상위 100개)에 대해서만 zoneId IN 필터를 적용하면 메모리/CPU 사용량이 크게 줄어듭니다.

다음 패치를 적용하세요:

-        List<WaybleZoneVisitLog> visitLogs = queryFactory
-                .selectFrom(waybleZoneVisitLog)
-                .where(waybleZoneVisitLog.visitedAt.goe(thirtyDaysAgo))
-                .limit(10000)
-                .fetch();
+        List<Long> candidateZoneIds = nearbyZones.stream()
+                .map(WaybleZone::getId)
+                .toList();
+
+        List<WaybleZoneVisitLog> visitLogs = queryFactory
+                .selectFrom(waybleZoneVisitLog)
+                .where(
+                        waybleZoneVisitLog.visitedAt.goe(thirtyDaysAgo)
+                        .and(waybleZoneVisitLog.zoneId.in(candidateZoneIds))
+                )
+                .fetch();

추가 최적화: 유저와 동일한 연령대/성별만 먼저 필터링하면 가중치 계산 비용도 줄일 수 있습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
List<WaybleZoneVisitLog> visitLogs = queryFactory
.selectFrom(waybleZoneVisitLog)
.where(waybleZoneVisitLog.visitedAt.goe(thirtyDaysAgo))
.limit(10000)
.fetch();
// before: unfiltered 30-day logs up to 10,000 records
- List<WaybleZoneVisitLog> visitLogs = queryFactory
- .selectFrom(waybleZoneVisitLog)
- .where(waybleZoneVisitLog.visitedAt.goe(thirtyDaysAgo))
- .limit(10000)
// first compute the candidate zone IDs (e.g. top-100 nearby zones)
List<Long> candidateZoneIds = nearbyZones.stream()
.map(WaybleZone::getId)
.toList();
// then restrict the visit-log query to those zones within the last 30 days
List<WaybleZoneVisitLog> visitLogs = queryFactory
.selectFrom(waybleZoneVisitLog)
.where(
waybleZoneVisitLog.visitedAt.goe(thirtyDaysAgo)
.and(waybleZoneVisitLog.zoneId.in(candidateZoneIds))
)
.fetch();

// Step 3: zoneId 별로 유사 사용자 방문 횟수 가중치 계산
Map<Long, Double> zoneVisitScoreMap = new HashMap<>();

for (WaybleZoneVisitLog log : visitLogs) {
double weight = 0.0;
boolean ageMatch = log.getAgeGroup() == userAgeGroup;
boolean genderMatch = log.getGender() == userGender;

if (ageMatch && genderMatch) {
weight = 1.0; // 성별, 연령 둘 다 일치
} else if (ageMatch) {
weight = 0.7; // 연령만 일치
} else if (genderMatch) {
weight = 0.2; // 성별만 일치
}

zoneVisitScoreMap.merge(log.getZoneId(), weight, Double::sum);
}

// Step 4: 최근 추천 날짜 조회 (MySQL - 실제 추천 로그 테이블이 있다면)
// 여기서는 간단히 빈 맵으로 처리 (실제로는 추천 로그 테이블에서 조회)
Map<Long, LocalDate> recentRecommendDateMap = new HashMap<>();

// 실제 구현시에는 아래와 같이 추천 로그 테이블에서 조회
/*
List<RecommendLog> recommendLogs = queryFactory
.selectFrom(recommendLog)
.where(recommendLog.userId.eq(user.getId()))
.limit(1000)
.fetch();
Map<Long, LocalDate> recentRecommendDateMap = recommendLogs.stream()
.collect(Collectors.toMap(
RecommendLog::getZoneId,
RecommendLog::getRecommendationDate,
(existing, replacement) -> existing.isAfter(replacement) ? existing : replacement
));
*/

// Step 5: 각 장소마다 점수 계산 후 DTO로 변환
return nearbyZones.stream()
.map(zone -> {
// 거리 계산 (Java로 정확한 계산)
double distanceKm = calculateHaversineDistanceJava(
latitude, longitude,
zone.getAddress().getLatitude(), zone.getAddress().getLongitude()
);

// 거리 점수 계산 (가까울수록 높음)
double distanceScore = (1.0 / (1.0 + distanceKm)) * DISTANCE_WEIGHT;

// 유사도 점수 (비슷한 사용자 방문수 반영)
double similarityScore = (zoneVisitScoreMap.getOrDefault(zone.getId(), 0.0) / 10.0) * SIMILARITY_WEIGHT;

// 최근 추천일 기반 감점 계산
double recencyScore = RECENCY_WEIGHT;
LocalDate lastRecommendDate = recentRecommendDateMap.get(zone.getId());

if (lastRecommendDate != null) {
long daysSince = ChronoUnit.DAYS.between(lastRecommendDate, LocalDate.now());
double factor = 1.0 - Math.min(daysSince, MAX_DAY_DIFF) / (double) MAX_DAY_DIFF; // 0~1
recencyScore = RECENCY_WEIGHT * (1.0 - factor); // days=0 -> 0점, days=30 -> full 점수
}

double totalScore = distanceScore + similarityScore + recencyScore;

WaybleZoneInfoResponseDto waybleZoneInfo = WaybleZoneInfoResponseDto.builder()
.zoneId(zone.getId())
.zoneName(zone.getZoneName())
.zoneType(zone.getZoneType())
.thumbnailImageUrl(zone.getMainImageUrl())
.address(zone.getAddress().toFullAddress())
.latitude(zone.getAddress().getLatitude())
.longitude(zone.getAddress().getLongitude())
.averageRating(zone.getRating())
.reviewCount(zone.getReviewCount())
.facility(zone.getFacility() != null ?
FacilityResponseDto.from(EsWaybleZoneFacility.from(zone.getFacility())) : null)
.build();

return WaybleZoneRecommendResponseDto.builder()
.waybleZoneInfo(waybleZoneInfo)
.distanceScore(distanceScore)
.similarityScore(similarityScore)
.recencyScore(recencyScore)
.totalScore(totalScore)
.build();
})
.sorted(Comparator.comparingDouble(WaybleZoneRecommendResponseDto::totalScore).reversed()) // 점수 내림차순 정렬
.limit(size) // 상위 size 개수만 반환
.toList();
}

/**
* Haversine 거리 계산 (QueryDSL Expression)
*/
private NumberExpression<Double> calculateHaversineDistance(double userLat, double userLon) {
// 지구 반지름 (km)
final double EARTH_RADIUS = 6371.0;

return Expressions.numberTemplate(Double.class,
"{0} * 2 * ASIN(SQRT(" +
"POWER(SIN(RADIANS({1} - {2}) / 2), 2) + " +
"COS(RADIANS({2})) * COS(RADIANS({1})) * " +
"POWER(SIN(RADIANS({3} - {4}) / 2), 2)" +
"))",
EARTH_RADIUS,
waybleZone.address.latitude,
userLat,
waybleZone.address.longitude,
userLon
);
}

/**
* Haversine 거리 계산 (Java 구현)
*/
private double calculateHaversineDistanceJava(double lat1, double lon1, double lat2, double lon2) {
final double R = 6371; // 지구 반지름 (km)
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat/2) * Math.sin(dLat/2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon/2) * Math.sin(dLon/2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
}
Loading