Fix : 수정

This commit is contained in:
lsh9672 2025-06-13 00:55:52 +09:00
parent d92f6b7bac
commit 4f173d2982
3 changed files with 220 additions and 155 deletions

View File

@ -5,20 +5,18 @@ import com.ktds.hi.recommend.biz.usecase.out.*;
import com.ktds.hi.recommend.biz.domain.*;
import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest;
import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse;
import com.ktds.hi.recommend.infra.dto.response.StoreDetailResponse;
import com.ktds.hi.common.dto.PageResponse;
import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.Arrays;
/**
* 매장 추천 인터랙터 클래스
@ -36,128 +34,193 @@ public class StoreRecommendInteractor implements StoreRecommendUseCase {
private final UserPreferenceRepository userPreferenceRepository;
@Override
public List<RecommendStoreResponse> recommendStores(Long memberId, RecommendStoreRequest request) {
// 사용자 취향 프로필 조회
TasteProfile tasteProfile = userPreferenceRepository.getMemberPreferences(memberId)
.orElseThrow(() -> new BusinessException("사용자 취향 정보를 찾을 수 없습니다. 취향 등록을 먼저 해주세요."));
public List<RecommendStoreResponse> recommendPersonalizedStores(Long memberId, RecommendStoreRequest request) {
try {
// 사용자 취향 프로필 조회
TasteProfile tasteProfile = userPreferenceRepository.getMemberPreferences(memberId)
.orElse(createDefaultTasteProfile(memberId));
// AI 기반 추천
Map<String, Object> preferences = Map.of(
"categories", tasteProfile.getPreferredCategories(),
"tags", tasteProfile.getPreferredTags(),
"pricePreference", tasteProfile.getPricePreference(),
"distancePreference", tasteProfile.getDistancePreference(),
"latitude", request.getLatitude(),
"longitude", request.getLongitude()
);
// 위치 기반 매장 검색
List<RecommendStore> nearbyStores = locationRepository.findStoresWithinDistance(
request.getLatitude(),
request.getLongitude(),
request.getRadius()
);
List<RecommendStore> aiRecommendStores = aiRecommendRepository.recommendStoresByAI(memberId, preferences);
// 취향 기반 필터링 점수 계산
List<RecommendStore> recommendedStores = aiRecommendRepository.filterByPreferences(
nearbyStores,
tasteProfile,
request.getTags()
);
// 위치 기반 추천 결합
List<RecommendStore> locationStores = locationRepository.findStoresWithinRadius(
request.getLatitude(), request.getLongitude(), request.getRadius());
// 추천 로그 저장
recommendRepository.logRecommendation(memberId,
recommendedStores.stream().map(RecommendStore::getStoreId).toList(),
"개인화추천"
);
// 추천 결과 통합 점수 계산
List<RecommendStore> combinedStores = combineRecommendations(aiRecommendStores, locationStores, tasteProfile);
return convertToResponseList(recommendedStores);
// 추천 히스토리 저장
RecommendHistory history = RecommendHistory.builder()
.memberId(memberId)
.recommendedStoreIds(combinedStores.stream().map(RecommendStore::getStoreId).collect(Collectors.toList()))
.recommendType(RecommendType.TASTE_BASED)
.criteria("취향 + AI + 위치 기반 통합 추천")
.createdAt(LocalDateTime.now())
.build();
recommendRepository.saveRecommendHistory(history);
log.info("매장 추천 완료: memberId={}, 추천 매장 수={}", memberId, combinedStores.size());
return combinedStores.stream()
.map(this::toRecommendStoreResponse)
.collect(Collectors.toList());
} catch (Exception e) {
log.error("개인화 매장 추천 실패: memberId={}", memberId, e);
return getDefaultRecommendations();
}
}
@Override
@Transactional(readOnly = true)
public List<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius) {
List<RecommendStore> stores = locationRepository.findStoresWithinRadius(latitude, longitude, radius);
public PageResponse<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius, String category, Pageable pageable) {
try {
List<RecommendStore> stores = locationRepository.findStoresWithinDistance(latitude, longitude, radius);
return stores.stream()
.map(store -> store.updateRecommendReason("위치 기반 추천"))
.map(this::toRecommendStoreResponse)
.collect(Collectors.toList());
// 카테고리 필터링
if (category != null && !category.isEmpty()) {
stores = stores.stream()
.filter(store -> category.equals(store.getCategory()))
.toList();
}
// 페이징 처리
int start = (int) pageable.getOffset();
int end = Math.min(start + pageable.getPageSize(), stores.size());
List<RecommendStore> pagedStores = stores.subList(start, end);
List<RecommendStoreResponse> responses = convertToResponseList(pagedStores);
return PageResponse.of(responses, pageable.getPageNumber(), pageable.getPageSize(), stores.size());
} catch (Exception e) {
log.error("위치 기반 매장 추천 실패: lat={}, lng={}", latitude, longitude, e);
return PageResponse.of(getDefaultRecommendations(), 0, pageable.getPageSize(), 0);
}
}
@Override
@Transactional(readOnly = true)
public List<RecommendStoreResponse> recommendPopularStores(String category, Integer limit) {
// Mock 구현 - 실제로는 인기도 기반 쿼리 필요
List<RecommendStore> popularStores = List.of(
RecommendStore.builder()
try {
List<RecommendStore> popularStores = recommendRepository.findPopularStores(category, limit);
return convertToResponseList(popularStores);
} catch (Exception e) {
log.error("인기 매장 추천 실패: category={}", category, e);
return getDefaultRecommendations();
}
}
@Override
public StoreDetailResponse getRecommendedStoreDetail(Long storeId, Long memberId) {
try {
RecommendStore store = recommendRepository.findStoreById(storeId)
.orElseThrow(() -> new BusinessException("매장을 찾을 수 없습니다."));
// 클릭 로그 저장 (조회도 클릭으로 간주)
if (memberId != null) {
recommendRepository.logStoreClick(memberId, storeId);
}
// AI 요약 정보 조회
String aiSummary = aiRecommendRepository.getStoreSummary(storeId);
// 개인화 추천 이유 생성
String personalizedReason = "";
if (memberId != null) {
TasteProfile tasteProfile = userPreferenceRepository.getMemberPreferences(memberId).orElse(null);
if (tasteProfile != null) {
personalizedReason = generatePersonalizedReason(store, tasteProfile);
}
}
return StoreDetailResponse.builder()
.storeId(store.getStoreId())
.storeName(store.getStoreName())
.address(store.getAddress())
.category(store.getCategory())
.rating(store.getRating())
.distance(store.getDistance())
.tags(store.getTags())
.aiSummary(aiSummary)
.personalizedReason(personalizedReason)
.build();
} catch (Exception e) {
log.error("매장 상세 조회 실패: storeId={}", storeId, e);
throw new BusinessException("매장 상세 정보를 조회할 수 없습니다.");
}
}
@Override
public void logRecommendClick(Long memberId, Long storeId) {
try {
recommendRepository.logStoreClick(memberId, storeId);
log.info("추천 클릭 로그 저장: memberId={}, storeId={}", memberId, storeId);
} catch (Exception e) {
log.error("추천 클릭 로그 저장 실패: memberId={}, storeId={}", memberId, storeId, e);
}
}
// 기본 취향 프로필 생성
private TasteProfile createDefaultTasteProfile(Long memberId) {
return TasteProfile.builder()
.memberId(memberId)
.cuisinePreferences(Arrays.asList("한식", "중식", "일식"))
.priceRange("중간")
.distancePreference(3000)
.tasteTags(Arrays.asList("맛있는", "친절한"))
.build();
}
// 개인화 추천 이유 생성
private String generatePersonalizedReason(RecommendStore store, TasteProfile tasteProfile) {
StringBuilder reason = new StringBuilder();
// 취향 태그 매칭
List<String> matchingTags = store.getTags().stream()
.filter(tasteProfile.getTasteTags()::contains)
.toList();
if (!matchingTags.isEmpty()) {
reason.append("당신이 좋아하는 '").append(String.join(", ", matchingTags))
.append("' 태그와 일치합니다. ");
}
// 가격대 매칭
if (tasteProfile.getPriceRange().equals(store.getPriceRange())) {
reason.append("선호하시는 ").append(tasteProfile.getPriceRange())
.append(" 가격대 매장입니다. ");
}
return reason.toString().trim();
}
// 응답 변환
private List<RecommendStoreResponse> convertToResponseList(List<RecommendStore> stores) {
return stores.stream()
.map(store -> RecommendStoreResponse.builder()
.storeId(store.getStoreId())
.storeName(store.getStoreName())
.address(store.getAddress())
.category(store.getCategory())
.rating(store.getRating())
.distance(store.getDistance())
.tags(store.getTags())
.recommendReason(store.getRecommendReason())
.build())
.toList();
}
// 기본 추천 목록 (에러 발생 )
private List<RecommendStoreResponse> getDefaultRecommendations() {
return Arrays.asList(
RecommendStoreResponse.builder()
.storeId(1L)
.storeName("인기 매장 1")
.address("서울시 강남구")
.category(category)
.storeName("맛집 플레이스")
.address("서울시 강남구 테헤란로 123")
.category("한식")
.rating(4.5)
.reviewCount(100)
.recommendScore(95.0)
.recommendType(RecommendType.POPULARITY_BASED)
.recommendReason("높은 평점과 많은 리뷰")
.distance(500)
.tags(Arrays.asList("맛있는", "친절한"))
.recommendReason("인기 매장입니다")
.build()
);
return popularStores.stream()
.limit(limit != null ? limit : 10)
.map(this::toRecommendStoreResponse)
.collect(Collectors.toList());
}
/**
* 추천 결과 통합 점수 계산
*/
private List<RecommendStore> combineRecommendations(List<RecommendStore> aiStores,
List<RecommendStore> locationStores,
TasteProfile profile) {
// AI 추천과 위치 기반 추천을 통합하여 최종 점수 계산
// 실제로는 복잡한 로직이 필요
return aiStores.stream()
.map(store -> store.updateRecommendScore(
calculateFinalScore(store, profile)
))
.sorted((s1, s2) -> Double.compare(s2.getRecommendScore(), s1.getRecommendScore()))
.limit(20)
.collect(Collectors.toList());
}
/**
* 최종 추천 점수 계산
*/
private Double calculateFinalScore(RecommendStore store, TasteProfile profile) {
double baseScore = store.getRecommendScore() != null ? store.getRecommendScore() : 0.0;
double ratingScore = store.getRating() != null ? store.getRating() * 10 : 0.0;
double reviewScore = store.getReviewCount() != null ? Math.min(store.getReviewCount() * 0.1, 10) : 0.0;
double distanceScore = store.getDistance() != null ? Math.max(0, 10 - store.getDistance() / 1000) : 0.0;
return (baseScore * 0.4) + (ratingScore * 0.3) + (reviewScore * 0.2) + (distanceScore * 0.1);
}
/**
* 도메인을 응답 DTO로 변환
*/
private RecommendStoreResponse toRecommendStoreResponse(RecommendStore store) {
return RecommendStoreResponse.builder()
.storeId(store.getStoreId())
.storeName(store.getStoreName())
.address(store.getAddress())
.category(store.getCategory())
.tags(store.getTags())
.rating(store.getRating())
.reviewCount(store.getReviewCount())
.distance(store.getDistance())
.recommendScore(store.getRecommendScore())
.recommendReason(store.getRecommendReason())
.build();
}
}
}

View File

@ -1,28 +1,43 @@
package com.ktds.hi.recommend.biz.usecase.in;
import com.ktds.hi.common.dto.PageResponse;
import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest;
import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse;
import com.ktds.hi.recommend.infra.dto.response.StoreDetailResponse;
import java.util.List;
import org.springframework.data.domain.Pageable;
/**
* 매장 추천 유스케이스 인터페이스
* 사용자 취향 기반 매장 추천 기능을 정의
*/
public interface StoreRecommendUseCase {
/**
* 사용자 취향 기반 매장 추천
* 개인화 매장 추천 (Controller에서 호출하는 메서드명으로 수정)
*/
List<RecommendStoreResponse> recommendStores(Long memberId, RecommendStoreRequest request);
List<RecommendStoreResponse> recommendPersonalizedStores(Long memberId, RecommendStoreRequest request);
/**
* 위치 기반 매장 추천
* 위치 기반 매장 추천 (PageResponse 반환으로 수정)
*/
List<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius);
PageResponse<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius,
String category, Pageable pageable);
/**
* 인기 매장 추천
*/
List<RecommendStoreResponse> recommendPopularStores(String category, Integer limit);
/**
* 추천 매장 상세 조회 (추가)
*/
StoreDetailResponse getRecommendedStoreDetail(Long storeId, Long memberId);
/**
* 추천 클릭 로깅 (추가)
*/
void logRecommendClick(Long memberId, Long storeId);
}

View File

@ -17,21 +17,24 @@ spring:
password: ${RECOMMEND_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver
hikari:
connection-timeout: 20000
maximum-pool-size: 10
minimum-idle: 5
idle-timeout: 300000
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: ${DB_POOL_MIN_IDLE:5}
connection-timeout: ${DB_CONNECTION_TIMEOUT:30000}
idle-timeout: ${DB_IDLE_TIMEOUT:600000}
max-lifetime: ${DB_MAX_LIFETIME:1800000}
pool-name: RecommendHikariCP
# JPA 설정
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
ddl-auto: ${JPA_DDL_AUTO:create}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: ${JPA_FORMAT_SQL:true}
show_sql: ${JPA_SHOW_SQL:false}
use_sql_comments: ${JPA_USE_SQL_COMMENTS:true}
jdbc:
batch_size: 20
order_inserts: true
@ -48,9 +51,9 @@ spring:
database: ${REDIS_DATABASE:0}
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
max-active: ${REDIS_POOL_MAX_ACTIVE:8}
max-idle: ${REDIS_POOL_MAX_IDLE:8}
min-idle: ${REDIS_POOL_MIN_IDLE:2}
max-wait: -1ms
shutdown-timeout: 100ms
@ -108,43 +111,19 @@ resilience4j:
max-attempts: 3
wait-duration: 1000
# 추천 알고리즘 설정
recommend:
algorithm:
distance-weight: 0.3
rating-weight: 0.3
taste-weight: 0.4
max-distance: 50000 # 50km
default-radius: 5000 # 5km
cache:
ttl:
recommendation: 30m
store-detail: 1h
taste-analysis: 6h
popular-stores: 2h
batch:
size: 100
max-concurrent: 5
# Actuator 설정
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,configprops
base-path: /actuator
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
show-components: always
metrics:
enabled: true
show-details: always
metrics:
export:
prometheus:
enabled: true
tags:
application: ${spring.application.name}
# Swagger/OpenAPI 설정
springdoc:
@ -188,6 +167,14 @@ security:
allowed-headers: "*"
allow-credentials: true
recommend:
cache:
recommendation-ttl: ${RECOMMENDATION_CACHE_TTL:1800}
user-preference-ttl: ${USER_PREFERENCE_CACHE_TTL:3600}
algorithm:
max-recommendations: ${MAX_RECOMMENDATIONS:20}
default-radius: ${DEFAULT_SEARCH_RADIUS:5000}
max-radius: ${MAX_SEARCH_RADIUS:10000}
---
# Local 환경 설정
spring: