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
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -56,6 +59,8 @@ public class PlaceServiceV2Impl implements PlaceServiceV2 {
private final StationRepository stationRepository;
private final VisitKoreaPlaceService visitKoreaPlaceService;

private final ConcurrentHashMap<Long, AtomicBoolean> refreshInProgress = new ConcurrentHashMap<>();

@Override
public Page<AvailablePlace> findAvailablePlaces(
final Long stationId,
Expand Down Expand Up @@ -110,7 +115,9 @@ public Page<AvailablePlace> findAvailablePlaces(

log.info("GEO 장소 정렬 완료: sortedCount={}", sorted.size());

Page<AvailablePlace> result = buildAvailablePlacePage(sorted, pageable, userLat, userLon, remainingMinutes);
Page<AvailablePlace> result = buildAvailablePlacePage(
sorted, pageable, userLat, userLon, remainingMinutes, stationId
);

log.info("방문 가능 장소 조회 완료: totalElements={}, page={}, size={}, totalPages={}",
result.getTotalElements(), result.getNumber(), result.getSize(), result.getTotalPages());
Expand All @@ -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);
Expand Down Expand Up @@ -299,7 +321,8 @@ private Page<AvailablePlace> buildAvailablePlacePage(
final Pageable pageable,
final double userLat,
final double userLon,
final int remainingMinutes
final int remainingMinutes,
final Long stationId
) {
int start = (int) pageable.getOffset();

Expand All @@ -309,7 +332,7 @@ private Page<AvailablePlace> buildAvailablePlacePage(
.map(place -> {
try {
return buildAvailablePlace(
place, userLat, userLon, remainingMinutes
place, userLat, userLon, remainingMinutes, stationId
);
} catch (Exception e) {
log.warn("AvailablePlace 빌드 실패: placeId={}, error={}",
Expand All @@ -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());

Expand Down Expand Up @@ -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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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 캐시를 강제 갱신합니다.
* <p>
* GEO 캐시 삭제 → 전체 API 동기화 → PlaceCard 캐시 일괄 저장
* 이 작업으로 PlaceCard 캐시 (TTL 24 시간) 만료를 사전에 방지합니다.
* </p>
*/
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
public void refreshAllPlaceCaches() {
log.info("========== VisitKorea 캐시 전체 갱신 시작 ==========");

List<Station> 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,33 @@ public interface VisitKoreaPlaceService {
*/
PlaceCardCache getPlaceCardWithFallback(String placeId);

/**
* VisitKorea API 에서 장소 데이터를 강제 동기화 (GEO 캐시 유무 무시)
* <p>
* 스케줄러에 의한 주기적 갱신용. 기존 GEO 캐시가 있어도 무시하고
* API 에서 전체 데이터를 다시 가져와 캐시를 갱신합니다.
* </p>
*
* @param stationId 역 ID
* @param longitude 기준 경도
* @param latitude 기준 위도
* @param searchRadius 검색 반경 (미터)
* @return 동기화된 장소 목록
*/
List<GeoPlace> forceSyncPlacesFromVisitKorea(Long stationId, double longitude, double latitude, int searchRadius);

/**
* 특정 역의 PlaceCard 캐시를 배치로 갱신
* <p>
* GEO 캐시에 존재하는 장소 ID 목록을 기반으로
* PlaceCard 캐시가 없는 장소들을 일괄 API 조회하여 캐싱합니다.
* </p>
*
* @param stationId 역 ID
* @param longitude 기준 경도
* @param latitude 기준 위도
* @return 갱신된 PlaceCard 캐시 수
*/
int syncPlaceCardsInBatch(Long stationId, double longitude, double latitude);

}
Loading