From a43de0bb4740ecedbe1843c4358b0ec0daf11cc8 Mon Sep 17 00:00:00 2001 From: loadingKKamo21 Date: Sun, 5 Apr 2026 14:25:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VisitKorea API 데이터 강제 동기화 및 배치 갱신 기능 구현 - Google Places API 연동을 통한 장소별 요일별 영업시간 정보 누락 추가 - 캐시 부재 시 자동 배치 갱신 트리거 및 중복 실행 방지 로직 적용 - 주기적 데이터 최신화를 위한 스케줄러 및 설정 클래스 도입 --- .../common/config/EnableSchedulingConfig.java | 22 +++ .../place/service/PlaceServiceV2Impl.java | 78 +++++++- .../scheduler/VisitKoreaCacheScheduler.java | 74 +++++++ .../service/VisitKoreaPlaceService.java | 29 +++ .../service/VisitKoreaPlaceServiceImpl.java | 182 +++++++++++++++++- 5 files changed, 369 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/timespot/backend/common/config/EnableSchedulingConfig.java create mode 100644 src/main/java/com/timespot/backend/infra/visitkorea/scheduler/VisitKoreaCacheScheduler.java diff --git a/src/main/java/com/timespot/backend/common/config/EnableSchedulingConfig.java b/src/main/java/com/timespot/backend/common/config/EnableSchedulingConfig.java new file mode 100644 index 0000000..ad890bd --- /dev/null +++ b/src/main/java/com/timespot/backend/common/config/EnableSchedulingConfig.java @@ -0,0 +1,22 @@ +package com.timespot.backend.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * PackageName : com.timespot.backend.common.config + * FileName : EnableSchedulingConfig + * Author : loadingKKamo21 + * Date : 26. 4. 5. + * Description : 스케줄링 활성화 설정 + * ===================================================================================================================== + * DATE AUTHOR DESCRIPTION + * --------------------------------------------------------------------------------------------------------------------- + * 26. 4. 5. loadingKKamo21 Initial creation + */ +@Profile("!test") +@Configuration +@EnableScheduling +public class EnableSchedulingConfig { +} diff --git a/src/main/java/com/timespot/backend/domain/place/service/PlaceServiceV2Impl.java b/src/main/java/com/timespot/backend/domain/place/service/PlaceServiceV2Impl.java index 2843981..8b372a5 100644 --- a/src/main/java/com/timespot/backend/domain/place/service/PlaceServiceV2Impl.java +++ b/src/main/java/com/timespot/backend/domain/place/service/PlaceServiceV2Impl.java @@ -1,5 +1,6 @@ package com.timespot.backend.domain.place.service; +import static com.timespot.backend.common.response.ErrorCode.PLACE_NOT_FOUND; import static com.timespot.backend.common.response.ErrorCode.STATION_NOT_FOUND; import static com.timespot.backend.domain.place.constant.PlaceConst.MINIMUM_STAY_TIME; import static com.timespot.backend.domain.place.constant.PlaceConst.PLATFORM_WAIT_TIME; @@ -26,6 +27,8 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -56,6 +59,8 @@ public class PlaceServiceV2Impl implements PlaceServiceV2 { private final StationRepository stationRepository; private final VisitKoreaPlaceService visitKoreaPlaceService; + private final ConcurrentHashMap refreshInProgress = new ConcurrentHashMap<>(); + @Override public Page findAvailablePlaces( final Long stationId, @@ -110,7 +115,9 @@ public Page findAvailablePlaces( log.info("GEO 장소 정렬 완료: sortedCount={}", sorted.size()); - Page result = buildAvailablePlacePage(sorted, pageable, userLat, userLon, remainingMinutes); + Page result = buildAvailablePlacePage( + sorted, pageable, userLat, userLon, remainingMinutes, stationId + ); log.info("방문 가능 장소 조회 완료: totalElements={}, page={}, size={}, totalPages={}", result.getTotalElements(), result.getNumber(), result.getSize(), result.getTotalPages()); @@ -129,7 +136,22 @@ public PlaceDetail getPlaceDetail( log.info("장소 상세 정보 조회: placeId={}, stationId={}, remainingMinutes={}", placeId, stationId, remainingMinutes); - PlaceCardCache cardInfo = visitKoreaPlaceService.getPlaceCardWithFallback(placeId); + PlaceCardCache cardInfo = visitKoreaPlaceService.getPlaceCardCache(placeId) + .orElse(null); + if (cardInfo == null) { + log.warn("PlaceCardCache 없음, 배치 갱신 트리거: placeId={}", placeId); + Station station = validateStation(stationId); + visitKoreaPlaceService.syncPlaceCardsInBatch( + stationId, + station.getLongitude(), + station.getLatitude() + ); + cardInfo = visitKoreaPlaceService.getPlaceCardCache(placeId) + .orElse(null); + + if (cardInfo == null) throw new GlobalException(PLACE_NOT_FOUND); + } + PlaceDetailCache detailInfo = visitKoreaPlaceService.getPlaceDetailWithFallback(placeId); Station station = validateStation(stationId); @@ -299,7 +321,8 @@ private Page buildAvailablePlacePage( final Pageable pageable, final double userLat, final double userLon, - final int remainingMinutes + final int remainingMinutes, + final Long stationId ) { int start = (int) pageable.getOffset(); @@ -309,7 +332,7 @@ private Page buildAvailablePlacePage( .map(place -> { try { return buildAvailablePlace( - place, userLat, userLon, remainingMinutes + place, userLat, userLon, remainingMinutes, stationId ); } catch (Exception e) { log.warn("AvailablePlace 빌드 실패: placeId={}, error={}", @@ -330,11 +353,19 @@ private AvailablePlace buildAvailablePlace( final GeoPlace geoPlace, final double userLat, final double userLon, - final int remainingMinutes + final int remainingMinutes, + final Long stationId ) { log.debug("AvailablePlace 빌드 시작: placeId={}", geoPlace.getPlaceId()); - PlaceCardCache cardInfo = visitKoreaPlaceService.getPlaceCardWithFallback(geoPlace.getPlaceId()); + PlaceCardCache cardInfo = visitKoreaPlaceService.getPlaceCardCache(geoPlace.getPlaceId()) + .orElse(null); + + if (cardInfo == null) { + log.warn("PlaceCardCache 없음, 배치 갱신 트리거: placeId={}", geoPlace.getPlaceId()); + triggerPlaceCardBatchRefresh(stationId); + return null; + } log.debug("PlaceCardCache 조회 성공: placeId={}, name={}", geoPlace.getPlaceId(), cardInfo.getName()); @@ -856,4 +887,39 @@ private int calculateWalkTime(final double distance) { return (int) Math.ceil(distance / WALK_SPEED_PER_MINUTE); } + /** + * PlaceCard 캐시 배치 갱신 트리거 (중복 실행 방지) + */ + private void triggerPlaceCardBatchRefresh(final Long stationId) { + AtomicBoolean flag = refreshInProgress.computeIfAbsent(stationId, k -> new AtomicBoolean(false)); + + if (!flag.compareAndSet(false, true)) { + log.debug("이미 배치 갱신 진행 중, 스킵: stationId={}", stationId); + return; + } + + try { + log.info("PlaceCard 배치 갱신 시작 (트리거): stationId={}", stationId); + Station station = stationRepository.findById(stationId) + .orElse(null); + if (station == null) { + log.warn("역을 찾을 수 없어 배치 갱신 불가: stationId={}", stationId); + return; + } + + int refreshed = visitKoreaPlaceService.syncPlaceCardsInBatch( + stationId, + station.getLongitude(), + station.getLatitude() + ); + + log.info("PlaceCard 배치 갱신 완료 (트리거): stationId={}, refreshed={}", stationId, refreshed); + } catch (Exception e) { + log.error("PlaceCard 배치 갱신 실패 (트리거): stationId={}, error={}", + stationId, e.getMessage(), e); + } finally { + flag.set(false); + } + } + } diff --git a/src/main/java/com/timespot/backend/infra/visitkorea/scheduler/VisitKoreaCacheScheduler.java b/src/main/java/com/timespot/backend/infra/visitkorea/scheduler/VisitKoreaCacheScheduler.java new file mode 100644 index 0000000..242474a --- /dev/null +++ b/src/main/java/com/timespot/backend/infra/visitkorea/scheduler/VisitKoreaCacheScheduler.java @@ -0,0 +1,74 @@ +package com.timespot.backend.infra.visitkorea.scheduler; + +import com.timespot.backend.domain.station.dao.StationRepository; +import com.timespot.backend.domain.station.model.Station; +import com.timespot.backend.infra.visitkorea.service.VisitKoreaPlaceService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * PackageName : com.timespot.backend.infra.visitkorea.scheduler + * FileName : VisitKoreaCacheScheduler + * Author : loadingKKamo21 + * Date : 26. 4. 5. + * Description : VisitKorea 캐시 주기적 갱신 스케줄러 + * ===================================================================================================================== + * DATE AUTHOR DESCRIPTION + * --------------------------------------------------------------------------------------------------------------------- + * 26. 4. 5. loadingKKamo21 Initial creation + */ +@Profile("!test") +@Component +@RequiredArgsConstructor +@Slf4j +public class VisitKoreaCacheScheduler { + + private final StationRepository stationRepository; + private final VisitKoreaPlaceService visitKoreaPlaceService; + + /** + * 매일 00:00 에 모든 역의 VisitKorea 캐시를 강제 갱신합니다. + *

+ * GEO 캐시 삭제 → 전체 API 동기화 → PlaceCard 캐시 일괄 저장 + * 이 작업으로 PlaceCard 캐시 (TTL 24 시간) 만료를 사전에 방지합니다. + *

+ */ + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + public void refreshAllPlaceCaches() { + log.info("========== VisitKorea 캐시 전체 갱신 시작 =========="); + + List stations = stationRepository.findAll(); + log.info("갱신 대상 역 수: {}", stations.size()); + + int successCount = 0; + int failCount = 0; + + for (Station station : stations) { + try { + log.info("역 캐시 갱신 시작: stationId={}, name={}", station.getId(), station.getName()); + + visitKoreaPlaceService.forceSyncPlacesFromVisitKorea( + station.getId(), + station.getLongitude(), + station.getLatitude(), + 20000 + ); + + successCount++; + log.info("역 캐시 갱신 완료: stationId={}", station.getId()); + } catch (Exception e) { + failCount++; + log.error("역 캐시 갱신 실패: stationId={}, error={}", + station.getId(), e.getMessage(), e); + } + } + + log.info("========== VisitKorea 캐시 전체 갱신 완료: success={}, fail={} ==========", + successCount, failCount); + } + +} diff --git a/src/main/java/com/timespot/backend/infra/visitkorea/service/VisitKoreaPlaceService.java b/src/main/java/com/timespot/backend/infra/visitkorea/service/VisitKoreaPlaceService.java index b56d3ba..4b43604 100644 --- a/src/main/java/com/timespot/backend/infra/visitkorea/service/VisitKoreaPlaceService.java +++ b/src/main/java/com/timespot/backend/infra/visitkorea/service/VisitKoreaPlaceService.java @@ -122,4 +122,33 @@ public interface VisitKoreaPlaceService { */ PlaceCardCache getPlaceCardWithFallback(String placeId); + /** + * VisitKorea API 에서 장소 데이터를 강제 동기화 (GEO 캐시 유무 무시) + *

+ * 스케줄러에 의한 주기적 갱신용. 기존 GEO 캐시가 있어도 무시하고 + * API 에서 전체 데이터를 다시 가져와 캐시를 갱신합니다. + *

+ * + * @param stationId 역 ID + * @param longitude 기준 경도 + * @param latitude 기준 위도 + * @param searchRadius 검색 반경 (미터) + * @return 동기화된 장소 목록 + */ + List forceSyncPlacesFromVisitKorea(Long stationId, double longitude, double latitude, int searchRadius); + + /** + * 특정 역의 PlaceCard 캐시를 배치로 갱신 + *

+ * GEO 캐시에 존재하는 장소 ID 목록을 기반으로 + * PlaceCard 캐시가 없는 장소들을 일괄 API 조회하여 캐싱합니다. + *

+ * + * @param stationId 역 ID + * @param longitude 기준 경도 + * @param latitude 기준 위도 + * @return 갱신된 PlaceCard 캐시 수 + */ + int syncPlaceCardsInBatch(Long stationId, double longitude, double latitude); + } diff --git a/src/main/java/com/timespot/backend/infra/visitkorea/service/VisitKoreaPlaceServiceImpl.java b/src/main/java/com/timespot/backend/infra/visitkorea/service/VisitKoreaPlaceServiceImpl.java index 9473b80..e73dbeb 100644 --- a/src/main/java/com/timespot/backend/infra/visitkorea/service/VisitKoreaPlaceServiceImpl.java +++ b/src/main/java/com/timespot/backend/infra/visitkorea/service/VisitKoreaPlaceServiceImpl.java @@ -11,6 +11,8 @@ import static com.timespot.backend.infra.visitkorea.model.ContentType.getAllContentTypes; import com.timespot.backend.common.error.GlobalException; +import com.timespot.backend.infra.google.places.client.GooglePlacesApiClient; +import com.timespot.backend.infra.google.places.dto.GooglePlacesResponse; import com.timespot.backend.infra.redis.dao.RedisGeoRepository; import com.timespot.backend.infra.redis.dao.RedisRepository; import com.timespot.backend.infra.redis.model.GeoPlace; @@ -51,10 +53,11 @@ @Slf4j public class VisitKoreaPlaceServiceImpl implements VisitKoreaPlaceService { - private final RedisRepository redisRepository; - private final RedisGeoRepository redisGeoRepository; - private final VisitKoreaApiClient visitKoreaApiClient; - private final VisitKoreaProperties visitKoreaProperties; + private final RedisRepository redisRepository; + private final RedisGeoRepository redisGeoRepository; + private final VisitKoreaApiClient visitKoreaApiClient; + private final GooglePlacesApiClient googlePlacesApiClient; + private final VisitKoreaProperties visitKoreaProperties; @Override public Optional getPlaceCardCache(final String placeId) { @@ -119,7 +122,127 @@ public List syncPlacesFromVisitKorea(final Long stationId, return existingPlaces; } - log.info("GEO 캐시 없음, VisitKorea API 동기화 시작: stationId={}, radius={}", stationId, searchRadius); + return doSyncPlaces(stationId, longitude, latitude, searchRadius); + } + + @Override + @Transactional + public List forceSyncPlacesFromVisitKorea(final Long stationId, + final double longitude, + final double latitude, + final int searchRadius) { + String geoKey = GEO_KEY_PREFIX + stationId; + + log.info("GEO 캐시 강제 삭제 후 전체 동기화 시작: stationId={}", stationId); + redisGeoRepository.deleteGeoKey(geoKey); + + return doSyncPlaces(stationId, longitude, latitude, searchRadius); + } + + @Override + @Transactional + public int syncPlaceCardsInBatch(final Long stationId, + final double longitude, + final double latitude) { + String geoKey = GEO_KEY_PREFIX + stationId; + + List geoPlaces = findPlacesWithinRadius(geoKey, longitude, latitude, + visitKoreaProperties.getMaxRadiusMeters()); + if (geoPlaces.isEmpty()) { + log.warn("GEO 캐시가 비어 있어 PlaceCard 배치 갱신 불가: stationId={}", stationId); + return 0; + } + + log.info("PlaceCard 배치 갱신 시작: stationId={}, totalGeoPlaces={}", stationId, geoPlaces.size()); + + List uncachedPlaces = new ArrayList<>(); + List cardBatch = new ArrayList<>(); + int cachedCount = 0; + + for (GeoPlace place : geoPlaces) { + if (getPlaceCardCache(place.getPlaceId()).isPresent()) { + cachedCount++; + continue; + } + uncachedPlaces.add(place); + } + + log.info("PlaceCard 배치 갱신 대상: stationId={}, uncached={}, alreadyCached={}", + stationId, uncachedPlaces.size(), cachedCount); + + if (uncachedPlaces.isEmpty()) { + log.info("모든 PlaceCard 캐시가 존재, 배치 갱신 스킵: stationId={}", stationId); + return cachedCount; + } + + Set placeIdSet = new HashSet<>(); + Set validCategories = Set.of("관광지", "음식점", "문화시설", "레포츠", "쇼핑"); + int syncedCount = 0; + + for (ContentType contentType : getAllContentTypes()) { + for (int page = 1; page <= visitKoreaProperties.getSyncPages(); page++) { + LocationBasedListResponse response = visitKoreaApiClient.locationBasedList( + longitude, + latitude, + visitKoreaProperties.getMaxRadiusMeters(), + contentType, + page, + visitKoreaProperties.getPageSize() + ); + + if (!response.isSuccess()) { + log.error("PlaceCard 배치 갱신 API 실패: stationId={}, contentType={}, page={}", + stationId, contentType, page); + break; + } + + if (response.getBody().getItems().getItem() == null) break; + + List items = response.getBody().getItems().getItem(); + + for (LocationBasedListItem item : items) { + if (item.getMapX() == null || item.getMapY() == null) continue; + + String placeId = item.getContentId(); + if (!placeIdSet.add(placeId)) continue; + + String category = mapContentTypeIdToCategory(item.getContentTypeId()); + if (!validCategories.contains(category)) continue; + + PlaceCardCache cardCache = new PlaceCardCache( + placeId, + item.getName(), + category, + item.getFullAddress(), + item.getMapY(), + item.getMapX(), + item.getDist() != null ? item.getDist() : 0.0, + item.getFirstImage() + ); + + cardBatch.add(cardCache); + syncedCount++; + } + + if (items.size() < visitKoreaProperties.getPageSize()) break; + } + } + + log.info("PlaceCard 배치 저장: stationId={}, count={}", stationId, cardBatch.size()); + for (PlaceCardCache cache : cardBatch) + savePlaceCardCache(cache.getPlaceId(), cache); + + log.info("PlaceCard 배치 갱신 완료: stationId={}, synced={}", stationId, syncedCount); + return syncedCount; + } + + private List doSyncPlaces(final Long stationId, + final double longitude, + final double latitude, + final int searchRadius) { + String geoKey = GEO_KEY_PREFIX + stationId; + + log.info("VisitKorea API 동기화 시작: stationId={}, radius={}", stationId, searchRadius); List allPlaces = new ArrayList<>(); Set placeIdSet = new HashSet<>(); @@ -304,7 +427,10 @@ private PlaceDetailCache fetchAndCachePlaceDetail(final String placeId) { List images = fetchPlaceImages(placeId); - PlaceDetailCache detailCache = buildPlaceDetailCache(cardCache, detailResponse, images); + String[] googleWeekdayDescriptions = fetchGoogleWeekdayDescriptions(cardCache); + + PlaceDetailCache detailCache = buildPlaceDetailCache( + cardCache, detailResponse, images, googleWeekdayDescriptions); savePlaceDetailCache(placeId, detailCache); @@ -333,17 +459,52 @@ private List fetchPlaceImages(final String placeId) { return List.of(); } + /** + * Google Places API 에서 요일별 영업 시간 조회 + * + * @param cardCache 장소 카드 캐시 (장소명, 좌표 포함) + * @return 요일별 영업 시간 설명 (없으면 null) + */ + private String[] fetchGoogleWeekdayDescriptions(final PlaceCardCache cardCache) { + try { + List results = googlePlacesApiClient.searchByPlaceNameAndLocation( + cardCache.getName(), + cardCache.getLatitude(), + cardCache.getLongitude() + ); + + if (results == null || results.isEmpty()) { + log.debug("Google Places 검색 결과 없음: placeId={}", cardCache.getPlaceId()); + return null; + } + + String[] weekdayDescriptions = results.get(0).getWeekdayDescriptions(); + if (weekdayDescriptions != null && weekdayDescriptions.length > 0) { + log.info("Google Places 요일별 영업 시간 조회 성공: placeId={}", cardCache.getPlaceId()); + return weekdayDescriptions; + } + + log.debug("Google Places 요일별 영업 시간 없음: placeId={}", cardCache.getPlaceId()); + return null; + } catch (Exception e) { + log.warn("Google Places API 호출 실패: placeId={}, error={}", cardCache.getPlaceId(), e.getMessage()); + return null; + } + } + /** * PlaceDetailCache 빌드 * - * @param cardCache 장소 카드 캐시 - * @param detailResponse 상세 정보 API 응답 - * @param images 이미지 URL 목록 + * @param cardCache 장소 카드 캐시 + * @param detailResponse 상세 정보 API 응답 + * @param images 이미지 URL 목록 + * @param googleWeekdayDescriptions Google Places 요일별 영업 시간 * @return 장소 상세 캐시 */ private PlaceDetailCache buildPlaceDetailCache(final PlaceCardCache cardCache, final DetailInfoResponse detailResponse, - final List images) { + final List images, + final String[] googleWeekdayDescriptions) { if (!detailResponse.isSuccess() || detailResponse.getBody().getItems() == null) { log.warn("상세 정보 API 응답 없음: placeId={}", cardCache.getPlaceId()); return PlaceDetailCache.empty(); @@ -394,6 +555,7 @@ private PlaceDetailCache buildPlaceDetailCache(final PlaceCardCache cardCache, .shopGuide(item.getShopGuide()) .scaleShopping(item.getScaleShopping()) .fairDay(item.getFairDay()) + .googleWeekdayDescriptions(googleWeekdayDescriptions) .build(); }