From 7d8b65a4007e567b6dbf519960e36544dcb70e70 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Mon, 18 Aug 2025 03:43:23 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[SCRUM-241]=20FEAT:=20=EA=B4=80=EA=B4=91?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebClient를 활용해 [국문 관광정보] 관광정보 동기화 목록 조회 API를 호출하고, 응답을 DTO로 파싱하는 코드를 구현했습니다. --- .../domain/spot/client/SpotSyncApiClient.java | 59 +++++++++++++ .../spot/dto/SpotSyncApiResponseDto.java | 82 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java create mode 100644 src/main/java/com/server/running_handai/domain/spot/dto/SpotSyncApiResponseDto.java diff --git a/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java b/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java new file mode 100644 index 0000000..b305cec --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java @@ -0,0 +1,59 @@ +package com.server.running_handai.domain.spot.client; + +import com.server.running_handai.domain.spot.dto.SpotSyncApiResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpotSyncApiClient { + private final WebClient webClient; + + @Value("${external.api.spot.base-url}") + private String baseUrl; + + @Value("${external.api.spot.service-key}") + private String serviceKey; + + /** + * [국문 관광정보] 관광정보 동기화 목록 조회 API를 요청합니다. + * 요청한 수정일을 기준으로 해당 날짜에 변경된 콘텐츠가 있는 경우 해당 콘텐츠의 정보를 응답합니다. + * + * @param areaCode 지역 코드 + * @return SpotSyncApiResponseDto + */ + public SpotSyncApiResponseDto fetchSpotSyncData(int areaCode) { + // 호출 기준 전날 날짜 계산 (YYYYMMDD) + String modifiedDate = LocalDate.now() + .minusDays(1) + .format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + // URL 생성 + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path("/areaBasedSyncList2") + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "runninghandai") + .queryParam("_type", "json") + .queryParam("areaCode", String.valueOf(areaCode)) + .queryParam("modifiedtime", modifiedDate) + .queryParam("serviceKey", serviceKey); + + URI uri = builder.build(true).toUri(); + + // API 호출 + return webClient.get() + .uri(uri) + .retrieve() + .bodyToMono(SpotSyncApiResponseDto.class) + .block(); + } +} diff --git a/src/main/java/com/server/running_handai/domain/spot/dto/SpotSyncApiResponseDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotSyncApiResponseDto.java new file mode 100644 index 0000000..4773ab6 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotSyncApiResponseDto.java @@ -0,0 +1,82 @@ +package com.server.running_handai.domain.spot.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.List; + +@Hidden +@Getter +@ToString +@NoArgsConstructor +public class SpotSyncApiResponseDto { + @JsonProperty("response") + private SpotSyncApiResponseDto.Response response; + + @Getter + @ToString + @NoArgsConstructor + public static class Response { + @JsonProperty("header") + private SpotSyncApiResponseDto.Header header; + + @JsonProperty("body") + private SpotSyncApiResponseDto.Body body; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Header { + @JsonProperty("resultCode") + private String resultCode; + + @JsonProperty("resultMsg") + private String resultMsg; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Body { + @JsonProperty("items") + private SpotSyncApiResponseDto.Items items; + + @JsonProperty("numOfRows") + private int numOfRows; + + @JsonProperty("pageNo") + private int pageNo; + + @JsonProperty("totalCount") + private int totalCount; + } + + @Getter + @ToString + @NoArgsConstructor + public static class Items { + @JsonProperty("item") + private List itemList; + } + + /** + * [국문 관광정보] 관광정보 동기화 목록 조회 + */ + @Getter + @ToString + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Item { + + @JsonProperty("contentid") + private String spotExternalId; // 장소 고유번호 (Spot.externalId) + + @JsonProperty("showflag") + private String spotShowflag; // 콘텐츠 표출여부 (1: 표출, 0: 비표출) + } +} From 5607354c7aae9dbf6cad5665d0c0c831895d90c3 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 21 Aug 2025 20:16:23 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[SCRUM-241]=20FEAT:=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=8F=99=EA=B8=B0=ED=99=94=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [국문 관광정보] 관광정보 동기화 목록 조회 API를 통해 기존에 DB에 저장된 장소 중 업데이트된 장소를 감지하고 다시 공통정보 조회 API를 호출해 장소 정보를 동기화하는 기능을 구현했습니다. 만약 showflag가 0일 경우, 해당 장소를 삭제합니다. --- .../domain/spot/client/SpotSyncApiClient.java | 9 +- .../domain/spot/entity/Spot.java | 42 ++++ .../domain/spot/service/SpotDataService.java | 231 +++++++++++++++--- 3 files changed, 243 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java b/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java index b305cec..4665ec4 100644 --- a/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java +++ b/src/main/java/com/server/running_handai/domain/spot/client/SpotSyncApiClient.java @@ -31,12 +31,7 @@ public class SpotSyncApiClient { * @param areaCode 지역 코드 * @return SpotSyncApiResponseDto */ - public SpotSyncApiResponseDto fetchSpotSyncData(int areaCode) { - // 호출 기준 전날 날짜 계산 (YYYYMMDD) - String modifiedDate = LocalDate.now() - .minusDays(1) - .format(DateTimeFormatter.ofPattern("yyyyMMdd")); - + public SpotSyncApiResponseDto fetchSpotSyncData(int areaCode, String date) { // URL 생성 UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl) .path("/areaBasedSyncList2") @@ -44,7 +39,7 @@ public SpotSyncApiResponseDto fetchSpotSyncData(int areaCode) { .queryParam("MobileApp", "runninghandai") .queryParam("_type", "json") .queryParam("areaCode", String.valueOf(areaCode)) - .queryParam("modifiedtime", modifiedDate) + .queryParam("modifiedtime", date) .queryParam("serviceKey", serviceKey); URI uri = builder.build(true).toUri(); diff --git a/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java index afc4985..d516534 100644 --- a/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java +++ b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java @@ -63,6 +63,48 @@ public Spot(String externalId, String name, String address, String description, this.lon = lon; } + /** + * API 데이터와 비교하여 Spot 엔티티의 필드를 업데이트합니다. + * 변경이 발생했을 경우에만 true를 반환합니다. + * + * @param source 비교 대상이 되는, API 응답으로부터 변환된 Spot 객체 + * @return 내용 변경이 있었는지 여부 + */ + public boolean syncWith(Spot source) { + boolean isUpdated = false; + + if (!this.name.equals(source.getName())) { + this.name = source.getName(); + isUpdated = true; + } + if (!this.address.equals(source.getAddress())) { + this.address = source.getAddress(); + isUpdated = true; + } + if (!this.description.equals(source.getDescription())) { + this.description = source.getDescription(); + isUpdated = true; + } + if (this.spotCategory != source.getSpotCategory()) { + this.spotCategory = source.getSpotCategory(); + isUpdated = true; + } + if (this.lat != source.getLat()) { + this.lat = source.getLat(); + isUpdated = true; + } + if (this.lon != source.getLon()) { + this.lon = source.getLon(); + isUpdated = true; + } + if (this.lon != source.getLon()) { + this.lon = source.getLon(); + isUpdated = true; + } + + return isUpdated; + } + // ==== 연관관계 편의 메서드 ==== // public void setSpotImage(SpotImage spotImage) { this.spotImage = spotImage; diff --git a/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java index 223bec3..69a2f0d 100644 --- a/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java +++ b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java @@ -7,8 +7,10 @@ import com.server.running_handai.domain.course.service.FileService; import com.server.running_handai.domain.spot.client.SpotApiClient; import com.server.running_handai.domain.spot.client.SpotLocationApiClient; +import com.server.running_handai.domain.spot.client.SpotSyncApiClient; import com.server.running_handai.domain.spot.dto.SpotApiResponseDto; import com.server.running_handai.domain.spot.dto.SpotLocationApiResponseDto; +import com.server.running_handai.domain.spot.dto.SpotSyncApiResponseDto; import com.server.running_handai.domain.spot.entity.SpotCategory; import com.server.running_handai.domain.spot.entity.CourseSpot; import com.server.running_handai.domain.spot.entity.Spot; @@ -24,6 +26,7 @@ import java.util.*; import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @Slf4j @@ -32,6 +35,7 @@ public class SpotDataService { private final SpotLocationApiClient spotLocationApiClient; private final SpotApiClient spotApiClient; + private final SpotSyncApiClient spotSyncApiClient; private final CourseRepository courseRepository; private final TrackPointRepository trackPointRepository; private final SpotRepository spotRepository; @@ -42,6 +46,9 @@ public class SpotDataService { private static final int TOURIST_SPOT_TYPE = 12; private static final int RESTAURANT_TYPE = 39; + // [국문 관광정보] 지역 코드 + private static final int BUSAN_AREA_CODE = 6; + /** * 코스에 맞는 즐길거리 정보를 [국문 관광정보]의 위치기반 관광정보 API와 공통정보조회 API를 통해 가져옵니다. * @@ -101,55 +108,123 @@ public void updateSpots(Long courseId) { } /** - * [국문 관광정보] 위치기반 관광정보 조회 API를 요청해 장소의 externalId를 수집합니다. - * API 응답값의 spotExternalId가 유효하지 않을 경우, Set에 포함하지 않습니다. + * DB에 저장된 즐길거리 정보를 [국문 관광정보]의 관광정보 동기화 목록 조회 API 호출을 통해 동기화합니다. + * 변경된 정보가 있을 경우, 공통정보 조회 API를 호출하여 DB와 비교한 후 업데이트합니다. * - * @param lon 경도 (x) - * @param lat 위도 (y) - * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) - * @return 유효한 externalId의 Set + * @param date 국문 관광정보 API에서 정보가 수정된 날짜 (YYYYMMDD) */ - private Set fetchSpotsByLocation(double lon, double lat, int contentTypeId) { - SpotLocationApiResponseDto spotLocationApiResponseDto = spotLocationApiClient.fetchSpotLocationData(1, 5, "E", lon, lat, contentTypeId); + @Transactional + public void syncSpots(String date) { + // 1. 변경된 장소 정보 수집 + List modifiedItems = fetchSpotsSync(date); - if (spotLocationApiResponseDto == null || spotLocationApiResponseDto.getResponse() == null || - spotLocationApiResponseDto.getResponse().getBody() == null || spotLocationApiResponseDto.getResponse().getBody().getItems() == null) { - return Collections.emptySet(); + if (modifiedItems.isEmpty()) { + log.info("[즐길거리 동기화] 변경된 장소 데이터가 없습니다."); + return; } - List items = spotLocationApiResponseDto.getResponse().getBody().getItems().getItemList(); + // 2. 현재 DB에 있는 External Id만 필터링 + Set modifiedExternalIds = modifiedItems.stream() + .map(SpotSyncApiResponseDto.Item::getSpotExternalId) + .collect(Collectors.toSet()); + + Set existingExternalIds = spotRepository.findExternalIdsByExternalIdIn(modifiedExternalIds); - if (items == null || items.isEmpty()) { - return Collections.emptySet(); + if (existingExternalIds.isEmpty()) { + log.info("[즐길거리 동기화] DB에 존재하는 장소 데이터 변경 사항이 없어 종료합니다."); + return; } - return items.stream() - .filter(item -> isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) - .map(SpotLocationApiResponseDto.Item::getSpotExternalId) + log.info("[즐길거리 동기화] 변경된 장소 중 DB에 저장된 장소: {}개", existingExternalIds.size()); + + // 3. showflag별로 분류 + // 표출(1)일 경우 업데이트하고, 비표출(0)일 경우 DB에서 삭제 + Set toDelete = modifiedItems.stream() + .filter(item -> existingExternalIds.contains(item.getSpotExternalId())) + .filter(item -> "0".equals(item.getSpotShowflag())) + .map(SpotSyncApiResponseDto.Item::getSpotExternalId) + .collect(Collectors.toSet()); + + Set toUpdate = modifiedItems.stream() + .filter(item -> existingExternalIds.contains(item.getSpotExternalId())) + .filter(item -> "1".equals(item.getSpotShowflag())) + .map(SpotSyncApiResponseDto.Item::getSpotExternalId) .collect(Collectors.toSet()); + + // 4. DB 삭제 혹은 업데이트 + List updatedSpots = new ArrayList<>(); + int deletedSpots = 0; + + if (!toDelete.isEmpty()) { + for (String externalId : toDelete) { + Spot spot = spotRepository.findByExternalId(externalId); + if (spot != null) { + spotRepository.delete(spot); + log.info("[즐길거리 동기화] 장소 삭제: externalId={}, 이전 이미지 주소={}", externalId, spot.getSpotImage().getImgUrl()); + deletedSpots++; + } + } + } + + if (!toUpdate.isEmpty()) { + // 공통정보 조회 API 호출하여 최신 정보 가져오기 + List items = fetchSpotsInParallel(toUpdate); + + for (SpotApiResponseDto.Item item : items) { + Optional newSpot = createSpot(item); + if (newSpot.isPresent()) { + Spot spot = spotRepository.findByExternalId(item.getSpotExternalId()); + if (spot != null) { + boolean isUpdated = spot.syncWith(newSpot.get()); + updateSpotImage(spot, item); + if (isUpdated) { + updatedSpots.add(spot); + } + } + } + } + } + + log.info("[즐길거리 동기화] 완료: 업데이트 대상={}, 삭제 대상={}, 실제 업데이트={}, 실제 삭제={}", toUpdate.size(), toDelete.size(), updatedSpots.size(), deletedSpots); + } + + @Transactional + public void syncSpotsByLocation() { + } /** - * [국문 관광정보] 공통정보 조회 API를 요청해 externalId에 대한 SpotApiResponseDto.Item을 반환합니다. + * [국문 관광정보] 위치기반 관광정보 조회 API를 요청해 장소의 externalId를 수집합니다. + * API 응답값의 spotExternalId가 유효하지 않을 경우, Set에 포함하지 않습니다. * - * @param externalId 장소 고유번호 - * @return SpotApiResponseDto.Item 정보, 없으면 Optional.empty() + * @param lon 경도 (x) + * @param lat 위도 (y) + * @param contentTypeId 관광 타입 (12: 관광지, 39: 음식점) + * @return externalId Set */ - public Optional fetchSpot(String externalId) { - SpotApiResponseDto spotApiResponseDto = spotApiClient.fetchSpotData(externalId); + private Set fetchSpotsByLocation(double lon, double lat, int contentTypeId) { + try { + SpotLocationApiResponseDto spotLocationApiResponseDto = spotLocationApiClient.fetchSpotLocationData(1, 5, "E", lon, lat, contentTypeId); - if (spotApiResponseDto == null || spotApiResponseDto.getResponse() == null || - spotApiResponseDto.getResponse().getBody() == null || spotApiResponseDto.getResponse().getBody().getItems() == null) { - return Optional.empty(); - } + if (spotLocationApiResponseDto == null || spotLocationApiResponseDto.getResponse() == null || + spotLocationApiResponseDto.getResponse().getBody() == null || spotLocationApiResponseDto.getResponse().getBody().getItems() == null) { + return Collections.emptySet(); + } - List items = spotApiResponseDto.getResponse().getBody().getItems().getItemList(); + List items = spotLocationApiResponseDto.getResponse().getBody().getItems().getItemList(); - if (items == null || items.isEmpty()) { - return Optional.empty(); - } + if (items == null || items.isEmpty()) { + return Collections.emptySet(); + } - return Optional.of(items.getFirst()); + return items.stream() + .filter(item -> isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) + .map(SpotLocationApiResponseDto.Item::getSpotExternalId) + .collect(Collectors.toSet()); + } catch (Exception e) { + log.warn("[즐길거리 수정] 위치기반 관광정보 조회 API 응답 파싱 오류: {}", e.getMessage()); + return Collections.emptySet(); + } } /** @@ -157,7 +232,7 @@ public Optional fetchSpot(String externalId) { * * @param startPoint 시작점 * @param endPoint 도착점 - * @return 수집한 중복 없는 externalId Set + * @return externalId Set */ private Set fetchSpotsByLocationInParallel(TrackPoint startPoint, TrackPoint endPoint) { List>> tasks = List.of( @@ -181,6 +256,34 @@ private Set fetchSpotsByLocationInParallel(TrackPoint startPoint, TrackP .collect(Collectors.toSet()); } + /** + * [국문 관광정보] 공통정보 조회 API를 요청해 externalId에 대한 SpotApiResponseDto.Item을 반환합니다. + * + * @param externalId 장소 고유번호 + * @return SpotApiResponseDto.Item 정보, 없으면 Optional.empty() + */ + private Optional fetchSpot(String externalId) { + try { + SpotApiResponseDto spotApiResponseDto = spotApiClient.fetchSpotData(externalId); + + if (spotApiResponseDto == null || spotApiResponseDto.getResponse() == null || + spotApiResponseDto.getResponse().getBody() == null || spotApiResponseDto.getResponse().getBody().getItems() == null) { + return Optional.empty(); + } + + List items = spotApiResponseDto.getResponse().getBody().getItems().getItemList(); + + if (items == null || items.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(items.getFirst()); + } catch (Exception e){ + log.warn("[즐길거리 수정] 공통정보 조회 API 응답 파싱 오류: {}", e.getMessage()); + return Optional.empty(); + } + } + /** * [국문 관광정보] 공통정보 조회 API를 요청을 병렬로 요청해 externalId의 관광 정보를 수집합니다. * @@ -202,6 +305,47 @@ private List fetchSpotsInParallel(Set externalI .collect(Collectors.toList()); } + /** + * [국문 관광정보] 관광정보 동기화 목록 조회 API를 요청해 변경된 데이터의 SpotSyncApiResponseDto.Item을 수집합니다. + * + * @param date 국문 관광정보 API에서 정보가 수정된 날짜 (YYYYMMDD) + * @return SpotSyncApiResponseDto.Item List + */ + private List fetchSpotsSync(String date) { + try { + SpotSyncApiResponseDto spotSyncApiResponseDto = spotSyncApiClient.fetchSpotSyncData(BUSAN_AREA_CODE, date); + + if (spotSyncApiResponseDto == null || spotSyncApiResponseDto.getResponse() == null || spotSyncApiResponseDto.getResponse().getBody() == null) { + return Collections.emptyList(); + } + + SpotSyncApiResponseDto.Body body = spotSyncApiResponseDto.getResponse().getBody(); + + // 변경 사항이 없는 경우, items = "" 형태로 응답되기 때문에 totalCount 기준으로 분기 처리 + if (body.getTotalCount() == 0) { + return Collections.emptyList(); + } + + if (body.getItems() == null) { + return Collections.emptyList(); + } + + List items = body.getItems().getItemList(); + + if (items == null || items.isEmpty()) { + return Collections.emptyList(); + } + + return items.stream() + .filter(item -> isFieldValid(item.getSpotExternalId(), "spotExternalId", null)) + .filter(item -> isFieldValid(item.getSpotShowflag(), "spotShowflag", null)) + .collect(Collectors.toList()); + } catch (Exception e) { + log.warn("[즐길거리 동기화] 관광정보 동기화 목록 조회 API 응답 파싱 오류: {}", e.getMessage()); + return Collections.emptyList(); + } + } + /** * Spot 객체를 생성합니다. * 이때 SpotApiResponse,Item의 필드를 검사하여 유효할 경우에만 Spot을 생성합니다. @@ -278,9 +422,32 @@ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { .originalUrl(thumbnailImage) .build(); } + return null; } + /** + * API 데이터와 비교하여 Spot 엔티티의 이미지를 업데이트합니다. + * 기존 이미지 URL과 새로운 이미지 URL들이 모두 다른 경우에만 S3 업로드 및 업데이트를 수행합니다. + * + * @param spot DB에서 조회한 기존 Spot 엔티티 + * @param item 공통정보 조회 API로부터 받은 응답 + */ + private void updateSpotImage(Spot spot, SpotApiResponseDto.Item item) { + String oldOriginalUrl = spot.getSpotImage() != null ? spot.getSpotImage().getOriginalUrl() : null; + + String newOriginalImage = item.getSpotOriginalImage(); + String newThumbnailImage = item.getSpotThumbnailImage(); + + if (!Objects.equals(oldOriginalUrl, newOriginalImage) && !Objects.equals(oldOriginalUrl, newThumbnailImage)) { + SpotImage spotImage = createSpotImage(item); + if (spotImage != null) { + spot.setSpotImage(spotImage); + log.info("[즐길거리 동기화] 이미지 업데이트: externalId={}, 이전 이미지 주소={}", item.getSpotExternalId(), spot.getSpotImage().getImgUrl()); + } + } + } + /** * API 응답값의 필드가 null이거나 빈 문자열인지 검사합니다. * null 또는 빈 문자열일 경우 객체를 생성하지 않고 넘어갑니다. From e178df327b2b75fbbfb158451e23f3241be0d6f8 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Tue, 9 Sep 2025 04:03:08 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[SCRUM-241]=20FEAT:=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=8F=99=EA=B8=B0=ED=99=94=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 DB에 저장된 모든 코스를 조건에 따라 [국문 관광정보] 위치기반 관광정보 조회 API를 호출해 업데이트된 장소를 감지하고, 공통정보 조회 API를 다시 호출해 장소 정보를 동기화하는 기능을 구현했습니다. --- .../course/dto/CourseTrackPointDto.java | 10 + .../repository/TrackPointRepository.java | 21 ++ .../spot/controller/SpotDataController.java | 27 +- .../domain/spot/dto/SpotExternalIdsDto.java | 22 ++ .../spot/repository/SpotRepository.java | 57 +++- .../domain/spot/service/SpotDataService.java | 288 +++++++++++++----- .../global/config/AppConfig.java | 2 + 7 files changed, 338 insertions(+), 89 deletions(-) create mode 100644 src/main/java/com/server/running_handai/domain/course/dto/CourseTrackPointDto.java create mode 100644 src/main/java/com/server/running_handai/domain/spot/dto/SpotExternalIdsDto.java diff --git a/src/main/java/com/server/running_handai/domain/course/dto/CourseTrackPointDto.java b/src/main/java/com/server/running_handai/domain/course/dto/CourseTrackPointDto.java new file mode 100644 index 0000000..8192111 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/CourseTrackPointDto.java @@ -0,0 +1,10 @@ +package com.server.running_handai.domain.course.dto; + +public record CourseTrackPointDto( + long courseId, + double startPointLat, + double startPointLon, + double endPointLat, + double endPointLon +) { +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/course/repository/TrackPointRepository.java b/src/main/java/com/server/running_handai/domain/course/repository/TrackPointRepository.java index b7bc687..2425a1d 100644 --- a/src/main/java/com/server/running_handai/domain/course/repository/TrackPointRepository.java +++ b/src/main/java/com/server/running_handai/domain/course/repository/TrackPointRepository.java @@ -1,10 +1,12 @@ package com.server.running_handai.domain.course.repository; +import com.server.running_handai.domain.course.dto.CourseTrackPointDto; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.TrackPoint; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface TrackPointRepository extends JpaRepository { @@ -32,4 +34,23 @@ public interface TrackPointRepository extends JpaRepository { * 코스 ID 목록으로 모든 트랙포인트를 한 번에 조회 */ List findByCourseIdInOrderBySequenceAsc(List courseIds); + + /** + * DB에 저장된 모든 코스별 시작점, 도착점 조회 + */ + @Query( + value = "SELECT " + + " c.course_id AS courseId, " + + " stp.lat AS startPointLat, " + + " stp.lon AS startPointLon, " + + " etp.lat AS endPointLat, " + + " etp.lon AS endPointLon " + + "FROM course c " + + "JOIN track_point stp ON c.course_id = stp.course_id AND stp.sequence = 1 " + + "JOIN track_point etp ON c.course_id = etp.course_id AND etp.sequence = (" + + "SELECT MAX(mtp.sequence) FROM track_point mtp WHERE mtp.course_id = c.course_id" + + ")", + nativeQuery = true + ) + List findAllCourseTrackPoint(); } diff --git a/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java b/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java index fe542e8..fc65f07 100644 --- a/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java +++ b/src/main/java/com/server/running_handai/domain/spot/controller/SpotDataController.java @@ -5,10 +5,10 @@ import com.server.running_handai.global.response.ResponseCode; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; @RestController @RequestMapping("/api/admin/courses") @@ -21,4 +21,23 @@ public ResponseEntity> updateSpots(@PathVariable Long courseId spotDataService.updateSpots(courseId); return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); } + + @PostMapping("/sync-spots/date") + public ResponseEntity> syncSpotsByDate(@RequestParam(required = false) String date) { + String targetDate = date; + if (targetDate == null) { + // 호출 기준 전날 날짜 계산 (YYYYMMDD) + targetDate = LocalDate.now() + .minusDays(1) + .format(DateTimeFormatter.ofPattern("yyyyMMdd")); + } + spotDataService.syncSpotsByDate(targetDate); + return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); + } + + @PostMapping("/sync-spots/location") + public ResponseEntity> syncSpotsByLocation() { + spotDataService.syncSpotsByLocation(); + return ResponseEntity.ok().body(CommonResponse.success(ResponseCode.SUCCESS, null)); + } } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/dto/SpotExternalIdsDto.java b/src/main/java/com/server/running_handai/domain/spot/dto/SpotExternalIdsDto.java new file mode 100644 index 0000000..c2439f1 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/dto/SpotExternalIdsDto.java @@ -0,0 +1,22 @@ +package com.server.running_handai.domain.spot.dto; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +public record SpotExternalIdsDto( + Long courseId, + String externalIds +) { + public Set getSpotExternalIds() { + if (externalIds == null || externalIds.isEmpty()) { + return Collections.emptySet(); + } + + return Arrays.stream(externalIds.split(",")) + .map(String::trim) + .filter(externalId -> !externalId.isEmpty()) + .collect(Collectors.toSet()); + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java b/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java index d4cea41..38bfc11 100644 --- a/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java +++ b/src/main/java/com/server/running_handai/domain/spot/repository/SpotRepository.java @@ -1,5 +1,6 @@ package com.server.running_handai.domain.spot.repository; +import com.server.running_handai.domain.spot.dto.SpotExternalIdsDto; import com.server.running_handai.domain.spot.dto.SpotInfoDto; import com.server.running_handai.domain.spot.entity.Spot; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,6 +9,7 @@ import java.util.Collection; import java.util.List; +import java.util.Set; public interface SpotRepository extends JpaRepository { /** @@ -15,6 +17,17 @@ public interface SpotRepository extends JpaRepository { */ List findByExternalIdIn(Collection externalIds); + /** + * ExternalId Set에서 DB에 존재하는 ExternalId만 반환합니다. + */ + @Query("SELECT s.externalId FROM Spot s WHERE s.externalId IN :externalIds") + Set findExistingExternalIds(Collection externalIds); + + /** + * ExternalId로 Spot을 조회합니다. + */ + Spot findByExternalId(String externalId); + /** * CourseId와 일치하는 Spot을 SpotImage와 함께 가져옵니다. */ @@ -28,16 +41,38 @@ public interface SpotRepository extends JpaRepository { /** * CourseId와 일치하는 Spot을 SpotImage와 함께 랜덤으로 3개 가져옵니다. */ - @Query(value = "SELECT " + - " s.spot_id AS spotId, " + - " s.name, " + - " s.description, " + - " si.img_url As imageUrl " + - "FROM spot s " + - "LEFT JOIN spot_image si ON s.spot_id = si.spot_id " + - "JOIN course_spot cs ON cs.spot_id = s.spot_id " + - "WHERE cs.course_id = :courseId " + - "ORDER BY RAND() LIMIT 3", - nativeQuery = true) + @Query( + value = "SELECT " + + " s.spot_id AS spotId, " + + " s.name, " + + " s.description, " + + " si.img_url As imageUrl " + + "FROM spot s " + + "LEFT JOIN spot_image si ON s.spot_id = si.spot_id " + + "JOIN course_spot cs ON cs.spot_id = s.spot_id " + + "WHERE cs.course_id = :courseId " + + "ORDER BY RAND() LIMIT 3", + nativeQuery = true + ) List findRandom3ByCourseId(@Param("courseId") Long courseId); + + /** + * DB에 저장된 모든 Course의 ExternalId를 조회합니다. + */ + @Query( + value = "SELECT " + + " cs.course_id AS courseId, " + + " GROUP_CONCAT(s.external_id) AS externalIds " + + "FROM course_spot cs " + + "JOIN spot s ON cs.spot_id = s.spot_id " + + "GROUP BY cs.course_id", + nativeQuery = true + ) + List findAllCourseExternalIds(); + + /** + * Course와 연관관계가 없는 Spot을 조회합니다. + */ + @Query("SELECT s FROM Spot s LEFT JOIN CourseSpot cs ON s.id = cs.spot.id WHERE cs.id IS NULL") + List findSpotsWithoutCourses(); } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java index 69a2f0d..4d7a894 100644 --- a/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java +++ b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java @@ -1,5 +1,6 @@ package com.server.running_handai.domain.spot.service; +import com.server.running_handai.domain.course.dto.CourseTrackPointDto; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.TrackPoint; import com.server.running_handai.domain.course.repository.CourseRepository; @@ -9,6 +10,7 @@ import com.server.running_handai.domain.spot.client.SpotLocationApiClient; import com.server.running_handai.domain.spot.client.SpotSyncApiClient; import com.server.running_handai.domain.spot.dto.SpotApiResponseDto; +import com.server.running_handai.domain.spot.dto.SpotExternalIdsDto; import com.server.running_handai.domain.spot.dto.SpotLocationApiResponseDto; import com.server.running_handai.domain.spot.dto.SpotSyncApiResponseDto; import com.server.running_handai.domain.spot.entity.SpotCategory; @@ -26,7 +28,6 @@ import java.util.*; import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @Slf4j @@ -50,7 +51,7 @@ public class SpotDataService { private static final int BUSAN_AREA_CODE = 6; /** - * 코스에 맞는 즐길거리 정보를 [국문 관광정보]의 위치기반 관광정보 API와 공통정보조회 API를 통해 가져옵니다. + * 코스에 맞는 즐길거리 정보를 [국문 관광정보]의 위치기반 관광정보 조회 API와 공통정보 조회 API를 통해 가져옵니다. * * @param courseId 코스 id */ @@ -70,6 +71,7 @@ public void updateSpots(Long courseId) { // 이미 externalId에 해당하는 Spot 정보가 있을 경우, 해당 정보를 가져옴 List existingSpots = spotRepository.findByExternalIdIn(externalIds); List spots = new ArrayList<>(existingSpots); + List newSpots; Set existingIds = existingSpots.stream() .map(Spot::getExternalId) @@ -80,31 +82,23 @@ public void updateSpots(Long courseId) { List items = fetchSpotsInParallel(externalIds); // Spot, SpotImage 객체 생성 - for (SpotApiResponseDto.Item item : items) { - Optional spotOptional = createSpot(item); - spotOptional.ifPresent(spot -> { - SpotImage spotImage = createSpotImage(item); - if (spotImage != null) { - spot.setSpotImage(spotImage); - } - spots.add(spot); - }); - } + newSpots = createSpots(items); + spots.addAll(newSpots); // 3. Course와 Spot의 연관관계 초기화 courseSpotRepository.deleteByCourseId(courseId); log.info("[즐길거리 수정] 기존 즐길거리 데이터 삭제 완료: courseId={}", courseId); // 4. Spot, CourseSpot DB 저장 - List newSpots = spotRepository.saveAll(spots); + spotRepository.saveAll(newSpots); - List courseSpots = newSpots.stream() + List courseSpots = spots.stream() .map(spot -> CourseSpot.builder().course(course).spot(spot).build()) .collect(Collectors.toList()); courseSpotRepository.saveAll(courseSpots); - log.info("[즐길거리 수정] DB에 즐길거리 정보 갱신 완료: courseId={}, 개수={}", courseId, spots.size()); + log.info("[즐길거리 수정] DB에 즐길거리 정보 갱신 완료: courseId={}, 새로운 장소={}, 기존 장소={}", courseId, newSpots.size(), existingSpots.size()); } /** @@ -114,12 +108,12 @@ public void updateSpots(Long courseId) { * @param date 국문 관광정보 API에서 정보가 수정된 날짜 (YYYYMMDD) */ @Transactional - public void syncSpots(String date) { + public void syncSpotsByDate(String date) { // 1. 변경된 장소 정보 수집 - List modifiedItems = fetchSpotsSync(date); + List modifiedItems = fetchSpotsSyncByDate(date); if (modifiedItems.isEmpty()) { - log.info("[즐길거리 동기화] 변경된 장소 데이터가 없습니다."); + log.info("[즐길거리 장소 정보 동기화] {} | 변경된 장소 데이터가 없습니다.", date); return; } @@ -128,14 +122,14 @@ public void syncSpots(String date) { .map(SpotSyncApiResponseDto.Item::getSpotExternalId) .collect(Collectors.toSet()); - Set existingExternalIds = spotRepository.findExternalIdsByExternalIdIn(modifiedExternalIds); + Set existingExternalIds = spotRepository.findExistingExternalIds(modifiedExternalIds); if (existingExternalIds.isEmpty()) { - log.info("[즐길거리 동기화] DB에 존재하는 장소 데이터 변경 사항이 없어 종료합니다."); + log.info("[즐길거리 장소 정보 동기화] {} | DB에 존재하는 장소 데이터 변경 사항이 없어 종료합니다.", date); return; } - log.info("[즐길거리 동기화] 변경된 장소 중 DB에 저장된 장소: {}개", existingExternalIds.size()); + log.info("[즐길거리 장소 정보 동기화] {} | 변경된 장소 중 DB에 저장된 장소: {}개", date, existingExternalIds.size()); // 3. showflag별로 분류 // 표출(1)일 경우 업데이트하고, 비표출(0)일 경우 DB에서 삭제 @@ -153,44 +147,104 @@ public void syncSpots(String date) { // 4. DB 삭제 혹은 업데이트 List updatedSpots = new ArrayList<>(); - int deletedSpots = 0; - - if (!toDelete.isEmpty()) { - for (String externalId : toDelete) { - Spot spot = spotRepository.findByExternalId(externalId); - if (spot != null) { - spotRepository.delete(spot); - log.info("[즐길거리 동기화] 장소 삭제: externalId={}, 이전 이미지 주소={}", externalId, spot.getSpotImage().getImgUrl()); - deletedSpots++; - } - } - } + int deletedSpotsCount = deleteSpots(toDelete); if (!toUpdate.isEmpty()) { // 공통정보 조회 API 호출하여 최신 정보 가져오기 List items = fetchSpotsInParallel(toUpdate); - - for (SpotApiResponseDto.Item item : items) { - Optional newSpot = createSpot(item); - if (newSpot.isPresent()) { - Spot spot = spotRepository.findByExternalId(item.getSpotExternalId()); - if (spot != null) { - boolean isUpdated = spot.syncWith(newSpot.get()); - updateSpotImage(spot, item); - if (isUpdated) { - updatedSpots.add(spot); - } - } - } - } + updatedSpots = updateSpots(items); } - log.info("[즐길거리 동기화] 완료: 업데이트 대상={}, 삭제 대상={}, 실제 업데이트={}, 실제 삭제={}", toUpdate.size(), toDelete.size(), updatedSpots.size(), deletedSpots); + log.info("[즐길거리 장소 정보 동기화] {} | 완료: 업데이트 대상={}, 삭제 대상={}, 실제 업데이트={}, 실제 삭제={}", date, toUpdate.size(), toDelete.size(), updatedSpots.size(), deletedSpotsCount); } + /** + * DB에 저장된 코스 시작점, 출발점에 따른 모든 Course의 Spot 정보를 [국문 관광정보]의 위치기반 관광정보 조회 API 호출을 통해 동기화합니다. + */ @Transactional public void syncSpotsByLocation() { + // 1. DB에 코스별 저장된 Spot의 ExternalId 조회 + Map> storedExternalIds = spotRepository.findAllCourseExternalIds().stream() + .collect(Collectors.toMap( + SpotExternalIdsDto::courseId, + SpotExternalIdsDto::getSpotExternalIds + )); + + // 2. 위치기반 관광정보 조회 API를 호출하여 코스별 새로운 External Id 수집 + Map> fetchExternalIds = fetchSpotsByLocationAllCourseInParallel(); + + // 3. 각 코스별로 수정된 Spot의 External Id만 필터링 + Map> modifiedExternalIds = new HashMap<>(); + fetchExternalIds.forEach((courseId, externalIds) -> { + Set oldExternalIds = storedExternalIds.getOrDefault(courseId, Collections.emptySet()); + Set newExternalIds = new HashSet<>(externalIds); + newExternalIds.removeAll(oldExternalIds); + + if (!newExternalIds.isEmpty()) { + modifiedExternalIds.put(courseId, newExternalIds); + } + }); + + // 4-1. 변경이 없는 경우 종료 + if (modifiedExternalIds.isEmpty()) { + log.info("[즐길거리 위치 정보 동기화] DB에 존재하는 장소 데이터 변경 사항이 없어 종료합니다."); + return; + } + // 4-2. 변경이 있는 경우 DB 업데이트 + log.info("[즐길거리 위치 정보 동기화] {}개 코스에서 새로운 externalId 발견", modifiedExternalIds.size()); + modifiedExternalIds.forEach((courseId, externalIds) -> { + Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); + + // 기존 Spot이 있는 경우 + List existingSpots = spotRepository.findByExternalIdIn(externalIds); + Set existingExternalIds = existingSpots.stream() + .map(Spot::getExternalId) + .collect(Collectors.toSet()); + + // 기존 Spot이 없는 경우 + Set toUpdate = new HashSet<>(externalIds); + toUpdate.removeAll(existingExternalIds); + + List spots = new ArrayList<>(existingSpots); + List newSpots = new ArrayList<>(); + + if (!toUpdate.isEmpty()) { + log.info("[즐길거리 위치 정보 동기화] 코스 {}: {}개 새 Spot 생성 필요", courseId, toUpdate.size()); + List items = fetchSpotsInParallel(toUpdate); + newSpots = createSpots(items); + spots.addAll(newSpots); + } + + spotRepository.saveAll(newSpots); + courseSpotRepository.deleteByCourseId(courseId); + + List courseSpots = spots.stream() + .map(spot -> + CourseSpot.builder().course(course).spot(spot).build()) + .collect(Collectors.toList()); + + courseSpotRepository.saveAll(courseSpots); + log.info("[즐길거리 위치 정보 동기화] 완료: courseId={}", courseId); + }); + + int orphanedSpotsCount = cleanOrphanedSpots(); + log.info("[즐길거리 위치 정보 동기화] Course와 연결 없는 Spot {}개 삭제", orphanedSpotsCount); + } + + /** + * Course와 연결이 하나도 없는 Spot을 모두 삭제합니다. + * + * @return 삭제된 Spot 개수 + */ + private int cleanOrphanedSpots() { + List orphanedSpots = spotRepository.findSpotsWithoutCourses(); + + if (!orphanedSpots.isEmpty()) { + spotRepository.deleteAll(orphanedSpots); + } + + return orphanedSpots.size(); } /** @@ -222,13 +276,13 @@ private Set fetchSpotsByLocation(double lon, double lat, int contentType .map(SpotLocationApiResponseDto.Item::getSpotExternalId) .collect(Collectors.toSet()); } catch (Exception e) { - log.warn("[즐길거리 수정] 위치기반 관광정보 조회 API 응답 파싱 오류: {}", e.getMessage()); + log.warn("위치기반 관광정보 조회 API 응답 파싱 오류: {}", e.getMessage()); return Collections.emptySet(); } } /** - * [국문 관광정보] 위치기반 관광정보 조회 API를 요청을 병렬로 요청해 4가지 조건(시작점, 도착점, 관광지, 음식점)의 externalId를 수집합니다. + * [국문 관광정보] 위치기반 관광정보 조회 API를 요청을 병렬로 요청해 4가지 조건(시작점, 도착점, 관광지, 음식점)에 해당하는 externalId를 수집합니다. * * @param startPoint 시작점 * @param endPoint 도착점 @@ -248,7 +302,7 @@ private Set fetchSpotsByLocationInParallel(TrackPoint startPoint, TrackP try { return task.call(); } catch (Exception e) { - log.error("[즐길거리 수정] 위치기반 관광정보 조회 API 호출 중 오류 발생: error={}", e.getMessage()); + log.error("위치기반 관광정보 조회 API 호출 중 오류 발생: error={}", e.getMessage()); return Collections.emptyList(); } }) @@ -256,6 +310,31 @@ private Set fetchSpotsByLocationInParallel(TrackPoint startPoint, TrackP .collect(Collectors.toSet()); } + /** + * DB에 저장된 모든 Course에 대해 [국문 관광정보] 위치기반 관광정보 조회 API를 병렬로 호출하여 + * 각 코스마다 4가지 조건(시작점, 도착점, 관광지, 음식점)에 해당하는 externalId를 수집합니다. + * + * @return externalId Map + */ + private Map> fetchSpotsByLocationAllCourseInParallel() { + List courseTrackPoints = trackPointRepository.findAllCourseTrackPoint(); + return courseTrackPoints.parallelStream() + .collect(Collectors.toConcurrentMap( + CourseTrackPointDto::courseId, + + courseTrackPointDto -> { + Set externalIds = new HashSet<>(); + + externalIds.addAll(fetchSpotsByLocation(courseTrackPointDto.startPointLon(), courseTrackPointDto.startPointLat(), TOURIST_SPOT_TYPE)); + externalIds.addAll(fetchSpotsByLocation(courseTrackPointDto.endPointLon(), courseTrackPointDto.endPointLat(), TOURIST_SPOT_TYPE)); + externalIds.addAll(fetchSpotsByLocation(courseTrackPointDto.startPointLon(), courseTrackPointDto.startPointLat(), RESTAURANT_TYPE)); + externalIds.addAll(fetchSpotsByLocation(courseTrackPointDto.endPointLon(), courseTrackPointDto.endPointLat(), RESTAURANT_TYPE)); + + return externalIds; + } + )); + } + /** * [국문 관광정보] 공통정보 조회 API를 요청해 externalId에 대한 SpotApiResponseDto.Item을 반환합니다. * @@ -279,7 +358,7 @@ private Optional fetchSpot(String externalId) { return Optional.of(items.getFirst()); } catch (Exception e){ - log.warn("[즐길거리 수정] 공통정보 조회 API 응답 파싱 오류: {}", e.getMessage()); + log.warn("공통정보 조회 API 응답 파싱 오류: {}", e.getMessage()); return Optional.empty(); } } @@ -296,7 +375,7 @@ private List fetchSpotsInParallel(Set externalI try { return fetchSpot(externalId); } catch (Exception e) { - log.error("[즐길거리 수정] 공통정보 조회 API 호출 중 오류 발생: externalId={}, error={}", externalId, e.getMessage()); + log.error("공통정보 조회 API 호출 중 오류 발생: externalId={}, error={}", externalId, e.getMessage()); return Optional.empty(); } }) @@ -311,26 +390,16 @@ private List fetchSpotsInParallel(Set externalI * @param date 국문 관광정보 API에서 정보가 수정된 날짜 (YYYYMMDD) * @return SpotSyncApiResponseDto.Item List */ - private List fetchSpotsSync(String date) { + private List fetchSpotsSyncByDate(String date) { try { SpotSyncApiResponseDto spotSyncApiResponseDto = spotSyncApiClient.fetchSpotSyncData(BUSAN_AREA_CODE, date); - if (spotSyncApiResponseDto == null || spotSyncApiResponseDto.getResponse() == null || spotSyncApiResponseDto.getResponse().getBody() == null) { + if (spotSyncApiResponseDto == null || spotSyncApiResponseDto.getResponse() == null || + spotSyncApiResponseDto.getResponse().getBody() == null || spotSyncApiResponseDto.getResponse().getBody().getItems() == null) { return Collections.emptyList(); } - SpotSyncApiResponseDto.Body body = spotSyncApiResponseDto.getResponse().getBody(); - - // 변경 사항이 없는 경우, items = "" 형태로 응답되기 때문에 totalCount 기준으로 분기 처리 - if (body.getTotalCount() == 0) { - return Collections.emptyList(); - } - - if (body.getItems() == null) { - return Collections.emptyList(); - } - - List items = body.getItems().getItemList(); + List items = spotSyncApiResponseDto.getResponse().getBody().getItems().getItemList(); if (items == null || items.isEmpty()) { return Collections.emptyList(); @@ -341,7 +410,7 @@ private List fetchSpotsSync(String date) { .filter(item -> isFieldValid(item.getSpotShowflag(), "spotShowflag", null)) .collect(Collectors.toList()); } catch (Exception e) { - log.warn("[즐길거리 동기화] 관광정보 동기화 목록 조회 API 응답 파싱 오류: {}", e.getMessage()); + log.warn("관광정보 동기화 목록 조회 API 응답 파싱 오류: {}", e.getMessage()); return Collections.emptyList(); } } @@ -398,12 +467,35 @@ private Optional createSpot(SpotApiResponseDto.Item item) { return Optional.of(spot); } + /** + * Spot 객체들을 생성합니다. + * + * @param items 공통정보 조회 API로부터 받은 응답 + * @return 새로 생성된 Spot List + */ + private List createSpots(List items) { + List newSpots = new ArrayList<>(); + + for (SpotApiResponseDto.Item item : items) { + Optional spotOptional = createSpot(item); + spotOptional.ifPresent(spot -> { + SpotImage spotImage = createSpotImage(item); + if (spotImage != null) { + spot.setSpotImage(spotImage); + } + newSpots.add(spot); + }); + } + + return newSpots; + } + /** * SpotImage 객체를 생성합니다. * originalImage를 우선 저장하고, originalImage이 없는 경우 thumbnailImage를 저장합니다. * - * @param item 공통정보 조회 API로부터 받은 응답 - * @return 새로 생성된 SpotImage 객체, 없으면 null + * @param item 공통정보 조회 API로부터 받은 응답 + * @return 새로 생성된 SpotImage 객체, 없으면 null */ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { String originalImage = item.getSpotOriginalImage(); @@ -428,7 +520,7 @@ private SpotImage createSpotImage(SpotApiResponseDto.Item item) { /** * API 데이터와 비교하여 Spot 엔티티의 이미지를 업데이트합니다. - * 기존 이미지 URL과 새로운 이미지 URL들이 모두 다른 경우에만 S3 업로드 및 업데이트를 수행합니다. + * 기존 이미지 URL과 새로운 이미지 URL들이 모두 다른 경우에만 업데이트합니다. * * @param spot DB에서 조회한 기존 Spot 엔티티 * @param item 공통정보 조회 API로부터 받은 응답 @@ -443,9 +535,57 @@ private void updateSpotImage(Spot spot, SpotApiResponseDto.Item item) { SpotImage spotImage = createSpotImage(item); if (spotImage != null) { spot.setSpotImage(spotImage); - log.info("[즐길거리 동기화] 이미지 업데이트: externalId={}, 이전 이미지 주소={}", item.getSpotExternalId(), spot.getSpotImage().getImgUrl()); + fileService.deleteFile(oldOriginalUrl); + } + } + } + + /** + * API 데이터와 비교하여 Spot을 업데이트합니다. + * 기존 DB에 있는 Spot과 비교하여 변경 사항이 있을 경우 업데이트합니다. + * + * @param items 공통 관광정보 API에서 가져온 새로운 Spot 데이터 + * @return 업데이트된 Spot List + */ + private List updateSpots(List items) { + List updatedSpots = new ArrayList<>(); + + for (SpotApiResponseDto.Item item : items) { + Optional newSpot = createSpot(item); + if (newSpot.isPresent()) { + Spot spot = spotRepository.findByExternalId(item.getSpotExternalId()); + if (spot != null) { + boolean isUpdated = spot.syncWith(newSpot.get()); + updateSpotImage(spot, item); + if (isUpdated) { + updatedSpots.add(spot); + } + } + } + } + + return updatedSpots; + } + + /** + * showflag가 0인 Spot의 External Id 목록을 받아 DB와 S3 버킷에서 삭제합니다. + * + * @param toDelete 삭제할 Spot의 External Id Set + * @return 삭제된 Spot의 개수 + */ + private int deleteSpots(Set toDelete) { + int deletedSpotCount = 0; + + for (String externalId : toDelete) { + Spot spot = spotRepository.findByExternalId(externalId); + if (spot != null) { + spotRepository.delete(spot); + fileService.deleteFile(spot.getSpotImage().getImgUrl()); + deletedSpotCount++; } } + + return deletedSpotCount; } /** @@ -459,7 +599,7 @@ private void updateSpotImage(Spot spot, SpotApiResponseDto.Item item) { */ private boolean isFieldValid(String value, String fieldName, String externalId) { if (value == null || value.isBlank()) { - log.warn("[즐길거리 수정] API 응답값 필드가 null 또는 빈 문자열이어서 건너뜀: externalId={}, fieldName={}", externalId, fieldName); + log.warn("API 응답값 필드가 null 또는 빈 문자열이어서 건너뜀: externalId={}, fieldName={}", externalId, fieldName); return false; } @@ -480,7 +620,7 @@ private boolean isFieldDouble(String doubleString, String fieldName, String exte Double.parseDouble(doubleString); return true; } catch (NumberFormatException e) { - log.warn("[즐길거리 수정] API 응답값 필드가 Double로 변환되지 않아 건너뜀: externalId={}, fieldName={}", externalId, fieldName); + log.warn("API 응답값 필드가 Double로 변환되지 않아 건너뜀: externalId={}, fieldName={}", externalId, fieldName); return false; } } diff --git a/src/main/java/com/server/running_handai/global/config/AppConfig.java b/src/main/java/com/server/running_handai/global/config/AppConfig.java index a710f38..aa14709 100644 --- a/src/main/java/com/server/running_handai/global/config/AppConfig.java +++ b/src/main/java/com/server/running_handai/global/config/AppConfig.java @@ -28,6 +28,8 @@ public ObjectMapper objectMapper() { ObjectMapper objectMapper = new ObjectMapper(); // OpenAI API 관련에서 발생하는 알 수 없는 필드가 있다는 예외를 무시하기 위함 objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + // 빈 문자열을 null 객체로 허용해 JSON 파싱 오류 방지 + objectMapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); return objectMapper; } From 1a7bfae8ef7b56508902cb4ada7f13a20db6ace4 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Tue, 9 Sep 2025 04:23:59 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[SCRUM-241]=20FEAT:=20=EA=B5=AD=EB=AC=B8?= =?UTF-8?q?=EA=B4=80=EA=B4=91=EC=A0=95=EB=B3=B4=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20API=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위치 정보 동기화는 매주 월요일 새벽 2시에, 장소 정보 동기화는 매일 새벽 5시 30분에 실행되도록 스케줄러를 등록했습니다. --- .../domain/spot/scheduler/SpotScheduler.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java diff --git a/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java b/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java new file mode 100644 index 0000000..1cd7008 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java @@ -0,0 +1,52 @@ +package com.server.running_handai.domain.spot.scheduler; + +import com.server.running_handai.domain.spot.service.SpotDataService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpotScheduler { + private final SpotDataService spotDataService; + + /** + * 매주 월요일 새벽 2시에 즐길거리 위치 정보 동기화 작업을 실행합니다. + * cron = "[초] [분] [시] [일] [월] [요일]" + */ + @Scheduled(cron = "0 0 2 * * 1", zone = "Asia/Seoul") + public void scheduleSyncSpotsByLocation() { + log.info("[스케줄러] 즐길거리 위치 정보 동기화 작업을 시작합니다."); + try { + spotDataService.syncSpotsByLocation(); + log.info("[스케줄러] 즐길거리 위치 정보 동기화 작업을 성공적으로 완료했습니다."); + } catch (Exception e) { + log.error("[스케줄러] 즐길거리 위치 정보 동기화 작업 중 오류가 발생했습니다.", e); + } + } + + /** + * 매일 새벽 5시 30분에 즐길거리 장소 정보 동기화 작업을 실행합니다. + * cron = "[초] [분] [시] [일] [월] [요일]" + */ + @Scheduled(cron = "0 30 5 * * *", zone = "Asia/Seoul") + public void scheduleSyncSpotsByDate() { + log.info("[스케줄러] 즐길거리 장소 정보 동기화 작업을 시작합니다."); + try { + // 호출 기준 전날 날짜 계산 (YYYYMMDD) + String date = LocalDate.now() + .minusDays(1) + .format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + spotDataService.syncSpotsByDate(date); + log.info("[스케줄러] 즐길거리 장소 정보 동기화 작업을 성공적으로 완료했습니다."); + } catch (Exception e) { + log.error("[스케줄러] 즐길거리 장소 정보 동기화 작업 중 오류가 발생했습니다.", e); + } + } +} From b9711f72a65e99d3959450134a88080fe5cd324c Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Tue, 9 Sep 2025 04:44:49 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[SCRUM-241]=20CHORE:=20spot.syncWith()=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=82=B4=EC=9D=98=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/server/running_handai/domain/spot/entity/Spot.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java index d516534..6f2e0b8 100644 --- a/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java +++ b/src/main/java/com/server/running_handai/domain/spot/entity/Spot.java @@ -97,10 +97,6 @@ public boolean syncWith(Spot source) { this.lon = source.getLon(); isUpdated = true; } - if (this.lon != source.getLon()) { - this.lon = source.getLon(); - isUpdated = true; - } return isUpdated; } From d3de5063b341b7c470755e1facd1414d6458e312 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Tue, 16 Sep 2025 00:22:40 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[SCRUM-241]=20FIX:=20=EC=A6=90=EA=B8=B8?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=9C=84=EC=B9=98=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20API=EC=97=90=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20CourseSpot=20=EC=9C=A0=EC=8B=A4=EB=90=98=EB=8A=94?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 즐길거리 위치 정보 동기화 API에서 해당 코스와 연결된 모든 장소 연관관계를 모두 삭제하고 새로 생성하여 기존의 연관관계 정보가 유실되는 문제가 있었습니다. 따라서 전체 코스를 기준으로 DB와 위치기반 관광정보 조회 API 데이터를 비교하여 추가할 Spot과 삭제할 Spot을 선별하여 처리하도록 개선했습니다. --- .../spot/repository/CourseSpotRepository.java | 12 ++ .../domain/spot/service/SpotDataService.java | 104 +++++++++--------- 2 files changed, 66 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java b/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java index 57a2a39..cf7e537 100644 --- a/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java +++ b/src/main/java/com/server/running_handai/domain/spot/repository/CourseSpotRepository.java @@ -6,8 +6,20 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Set; + public interface CourseSpotRepository extends JpaRepository { + /** + * 특정 코스에 연결된 모든 장소의 연관관계를 모두 삭제합니다. + */ @Modifying @Query("DELETE FROM CourseSpot cs WHERE cs.course.id = :courseId") void deleteByCourseId(@Param("courseId") Long courseId); + + /** + * 특정 코스에 연결된 장소 중 External Id의 목록에 포함된 장소의 연관관계만 삭제합니다. + */ + @Modifying + @Query("DELETE FROM CourseSpot cs WHERE cs.course.id = :courseId AND cs.spot.externalId IN :externalIds") + void deleteByCourseIdAndSpotExternalIdIn(@Param("courseId") Long courseId, @Param("externalIds") Set externalIds); } \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java index 4d7a894..c3b0b17 100644 --- a/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java +++ b/src/main/java/com/server/running_handai/domain/spot/service/SpotDataService.java @@ -133,17 +133,15 @@ public void syncSpotsByDate(String date) { // 3. showflag별로 분류 // 표출(1)일 경우 업데이트하고, 비표출(0)일 경우 DB에서 삭제 - Set toDelete = modifiedItems.stream() + Map> partitioned = modifiedItems.stream() .filter(item -> existingExternalIds.contains(item.getSpotExternalId())) - .filter(item -> "0".equals(item.getSpotShowflag())) - .map(SpotSyncApiResponseDto.Item::getSpotExternalId) - .collect(Collectors.toSet()); + .collect(Collectors.partitioningBy( + item -> "1".equals(item.getSpotShowflag()), // true이면 toUpdate, false이면 toDelete + Collectors.mapping(SpotSyncApiResponseDto.Item::getSpotExternalId, Collectors.toSet()) + )); - Set toUpdate = modifiedItems.stream() - .filter(item -> existingExternalIds.contains(item.getSpotExternalId())) - .filter(item -> "1".equals(item.getSpotShowflag())) - .map(SpotSyncApiResponseDto.Item::getSpotExternalId) - .collect(Collectors.toSet()); + Set toUpdate = partitioned.get(true); + Set toDelete = partitioned.get(false); // 4. DB 삭제 혹은 업데이트 List updatedSpots = new ArrayList<>(); @@ -173,59 +171,65 @@ public void syncSpotsByLocation() { // 2. 위치기반 관광정보 조회 API를 호출하여 코스별 새로운 External Id 수집 Map> fetchExternalIds = fetchSpotsByLocationAllCourseInParallel(); - // 3. 각 코스별로 수정된 Spot의 External Id만 필터링 - Map> modifiedExternalIds = new HashMap<>(); - fetchExternalIds.forEach((courseId, externalIds) -> { + // 3. 전체 코스를 기준으로 DB와 위치기반 관광정보 조회 API 데이터를 비교하여 변경사항 동기화 + fetchExternalIds.forEach((courseId, newExternalIds) -> { Set oldExternalIds = storedExternalIds.getOrDefault(courseId, Collections.emptySet()); - Set newExternalIds = new HashSet<>(externalIds); - newExternalIds.removeAll(oldExternalIds); - if (!newExternalIds.isEmpty()) { - modifiedExternalIds.put(courseId, newExternalIds); + // 3-1. 변경이 없는 경우 다음 코스로 넘어감 + if (oldExternalIds.equals(newExternalIds)) { + log.debug("[즐길거리 위치 정보 동기화] DB에 존재하는 장소 데이터 변경 사항이 없어 종료합니다: courseId={}", courseId); + return; } - }); - // 4-1. 변경이 없는 경우 종료 - if (modifiedExternalIds.isEmpty()) { - log.info("[즐길거리 위치 정보 동기화] DB에 존재하는 장소 데이터 변경 사항이 없어 종료합니다."); - return; - } - - // 4-2. 변경이 있는 경우 DB 업데이트 - log.info("[즐길거리 위치 정보 동기화] {}개 코스에서 새로운 externalId 발견", modifiedExternalIds.size()); - modifiedExternalIds.forEach((courseId, externalIds) -> { + log.info("[즐길거리 위치 정보 동기화] 새로운 externalId 발견: courseId={}", courseId); Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); - // 기존 Spot이 있는 경우 - List existingSpots = spotRepository.findByExternalIdIn(externalIds); - Set existingExternalIds = existingSpots.stream() - .map(Spot::getExternalId) - .collect(Collectors.toSet()); - - // 기존 Spot이 없는 경우 - Set toUpdate = new HashSet<>(externalIds); - toUpdate.removeAll(existingExternalIds); + // 3-2. 추가해야 할 External Id 수집 + Set toAdd = new HashSet<>(newExternalIds); + toAdd.removeAll(oldExternalIds); - List spots = new ArrayList<>(existingSpots); - List newSpots = new ArrayList<>(); + // 3-3. 삭제해야 할 External Id 수집 + Set toDelete = new HashSet<>(oldExternalIds); + toDelete.removeAll(newExternalIds); - if (!toUpdate.isEmpty()) { - log.info("[즐길거리 위치 정보 동기화] 코스 {}: {}개 새 Spot 생성 필요", courseId, toUpdate.size()); - List items = fetchSpotsInParallel(toUpdate); - newSpots = createSpots(items); - spots.addAll(newSpots); + // 4. DB 삭제 혹은 업데이트 + // 4-1. 삭제해야 할 External Id Course-Spot 연관관계 삭제 + if (!toDelete.isEmpty()) { + courseSpotRepository.deleteByCourseIdAndSpotExternalIdIn(courseId, toDelete); + log.info("[즐길거리 위치 정보 동기화] {}개 Spot Course와 연결 삭제!: courseId={}", toDelete.size(), courseId); } - spotRepository.saveAll(newSpots); - courseSpotRepository.deleteByCourseId(courseId); + // 4-2. 추가해야 할 External Id 추가 + if (!toAdd.isEmpty()) { + // DB에 이미 존재하는 Spot인지 확인 + List existingSpots = spotRepository.findByExternalIdIn(toAdd); + Set existingExternalIds = existingSpots.stream() + .map(Spot::getExternalId) + .collect(Collectors.toSet()); + log.debug("[즐길거리 위치 정보 동기화] 기존과 겹치는 {}개의 Spot 존재: courseId={}", existingSpots.size(), courseId); + + // 기존에 없는 Spot은 새로 공통정보 조회 API를 호출하여 추가해야 함 + Set toCreate = new HashSet<>(toAdd); + toCreate.removeAll(existingExternalIds); + + // 기존에 있는 Spot은 Course-Spot 연관관계만 추가해야 함 + List toConnect = new ArrayList<>(existingSpots); + + if (!toCreate.isEmpty()) { + log.debug("[즐길거리 위치 정보 동기화] {}개 Spot 생성 필요: courseId={}", toCreate.size(), courseId); + List items = fetchSpotsInParallel(toCreate); + List newSpots = createSpots(items); + spotRepository.saveAll(newSpots); + toConnect.addAll(newSpots); + } - List courseSpots = spots.stream() - .map(spot -> - CourseSpot.builder().course(course).spot(spot).build()) - .collect(Collectors.toList()); + List newCourseSpots = toConnect.stream() + .map(spot -> CourseSpot.builder().course(course).spot(spot).build()) + .toList(); - courseSpotRepository.saveAll(courseSpots); - log.info("[즐길거리 위치 정보 동기화] 완료: courseId={}", courseId); + courseSpotRepository.saveAll(newCourseSpots); + log.info("[즐길거리 위치 정보 동기화] {}개 Spot Course와 연결 추가!: courseId={}", newCourseSpots.size(), courseId); + } }); int orphanedSpotsCount = cleanOrphanedSpots(); From d5663d1012fcdae9076717a623e50bc9c44b7037 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Tue, 16 Sep 2025 00:37:38 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[SCRUM-241]=20CHORE:=20=EC=A6=90=EA=B8=B8?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=9C=84=EC=B9=98=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=8B=A4=ED=96=89=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=83=88=EB=B2=BD=204=EC=8B=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 새벽 2시에 설정한 즐길거리 위치 정보 동기화 작업의 실행 시간을 트래픽이 더 적은 새벽 4시로 변경했습니다. --- .../running_handai/domain/spot/scheduler/SpotScheduler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java b/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java index 1cd7008..b8a37f0 100644 --- a/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java +++ b/src/main/java/com/server/running_handai/domain/spot/scheduler/SpotScheduler.java @@ -16,10 +16,10 @@ public class SpotScheduler { private final SpotDataService spotDataService; /** - * 매주 월요일 새벽 2시에 즐길거리 위치 정보 동기화 작업을 실행합니다. + * 매주 월요일 새벽 4시에 즐길거리 위치 정보 동기화 작업을 실행합니다. * cron = "[초] [분] [시] [일] [월] [요일]" */ - @Scheduled(cron = "0 0 2 * * 1", zone = "Asia/Seoul") + @Scheduled(cron = "0 0 4 * * 1", zone = "Asia/Seoul") public void scheduleSyncSpotsByLocation() { log.info("[스케줄러] 즐길거리 위치 정보 동기화 작업을 시작합니다."); try {