From 81d0e1a7e0b2dfeddaca09c4f395394581801454 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sun, 26 Jan 2025 16:34:12 +0900 Subject: [PATCH 01/84] =?UTF-8?q?feat:=20redis=20=EC=84=A4=EC=A0=95=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../global/config/RedisConfig.java | 39 +++++++++++++++++++ .../{application-dev.yml => application.yml} | 5 +++ 3 files changed, 45 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java rename src/main/resources/{application-dev.yml => application.yml} (93%) diff --git a/build.gradle b/build.gradle index c516923..be911b3 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,7 @@ dependencies { // DB implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'org.postgresql:postgresql' // Actuator diff --git a/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java b/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java new file mode 100644 index 0000000..cc9601b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java @@ -0,0 +1,39 @@ +package com.example.mohago_nocar.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private String redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); + redisStandaloneConfiguration.setHostName(redisHost); + redisStandaloneConfiguration.setPort(Integer.parseInt(redisPort)); + return new LettuceConnectionFactory(redisStandaloneConfiguration); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); +// redisTemplate.setEnableTransactionSupport(true); + return redisTemplate; + } + +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application.yml similarity index 93% rename from src/main/resources/application-dev.yml rename to src/main/resources/application.yml index e139c52..e7c3a32 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application.yml @@ -22,6 +22,11 @@ spring: show-sql: true open-in-view: false + data: + redis: + host: localhost + port: 6379 + springdoc: default-consumes-media-type: application/json default-produces-media-type: application/json From 773e783d489055d39f66944b09f09ce78e0e32a6 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sun, 26 Jan 2025 16:34:44 +0900 Subject: [PATCH 02/84] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20redis=20=EC=84=A4=EC=A0=95=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/EmbeddedRedisConfig.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java diff --git a/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java b/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java new file mode 100644 index 0000000..1ff43f1 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.global.config; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import redis.embedded.RedisServer; + +@Configuration +@Profile("test") +public class EmbeddedRedisConfig { + + @Value("${spring.data.redis.port}") + private int port; + + private RedisServer redisServer; + + @PostConstruct + public void startEmbeddedRedis() { + redisServer = new RedisServer(port); + redisServer.start(); + } + + @PreDestroy + public void stopEmbeddedRedis() { + if (redisServer != null) { + redisServer.stop(); + } + } + +} From 57ab209b3c64e4dbef4e9b3e2e78c8440c3c6615 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 29 Jan 2025 01:53:53 +0900 Subject: [PATCH 03/84] =?UTF-8?q?fix:=20=EC=99=B8=EB=B6=80=20API=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EB=B0=8F=20=EC=BA=90=EC=8B=B1=20=EB=B0=A9?= =?UTF-8?q?=EB=B2=95=20=EB=B3=80=EA=B2=BD=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -Google Place API를 Kakao Local API로 대체하였습니다. Kakao Local API를 이용하여 장소에 대한 정보를 가져옵니다. - 장소 정보를 RDB가 아닌 NoSQL에 저장합니다. --- .../global/common/domain/vo/Location.java | 8 +- .../global/util/ObjectMapperUtil.java | 32 +++ .../place/application/PlaceService.java | 87 +++----- .../application/converter/PlaceConverter.java | 36 ++++ .../mapper/FestivalNearPlaceMapper.java | 42 ---- .../place/domain/model/FestivalNearPlace.java | 93 -------- .../domain/model/FestivalNearPlaceImage.java | 42 ---- .../place/domain/model/OperatingSchedule.java | 64 ------ .../place/domain/model/Place.java | 71 +++++++ .../place/domain/model/PlaceCategory.java | 39 ++++ .../place/domain/model/PlaceType.java | 19 -- .../FestivalNearPlaceImageRepository.java | 11 - .../FestivalNearPlaceRepository.java | 17 -- .../domain/repository/PlaceRepository.java | 14 ++ .../place/domain/service/PlaceUseCase.java | 8 +- .../FestivalNearPlaceImageJpaRepository.java | 11 - .../FestivalNearPlaceImageRepositoryImpl.java | 24 --- .../FestivalNearPlaceJpaRepository.java | 20 -- .../FestivalNearPlaceRepositoryImpl.java | 42 ---- .../infrastructure/PlaceRepositoryImpl.java | 73 +++++++ .../externalApi/GoogleApiClient.java | 199 ------------------ .../dto/request/GoogleNearPlaceRequest.java | 32 --- .../dto/request/LocationRestriction.java | 40 ---- .../response/GoogleNearbyPlaceResponse.java | 8 - .../response/GooglePlaceImageResponse.java | 6 - .../dto/response/PlaceResponseDto.java | 95 --------- .../externalApi/kakao/KakaoApiClient.java | 50 +++++ .../dto/response/KakaoPlacesResponse.java | 22 ++ .../externalApi/mapper/GooglePlaceMapper.java | 100 --------- .../presentation/NearPlaceResponseDto.java | 34 ++- .../place/presentation/PlaceController.java | 19 +- .../request/PlanTravelCourseRequestDto.java | 4 +- .../response/PlanTravelCourseResponseDto.java | 10 +- 33 files changed, 403 insertions(+), 969 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java create mode 100644 src/main/java/com/example/mohago_nocar/place/application/converter/PlaceConverter.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/application/mapper/FestivalNearPlaceMapper.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlace.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlaceImage.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/domain/model/OperatingSchedule.java create mode 100644 src/main/java/com/example/mohago_nocar/place/domain/model/Place.java create mode 100644 src/main/java/com/example/mohago_nocar/place/domain/model/PlaceCategory.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/domain/model/PlaceType.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceImageRepository.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/place/domain/repository/PlaceRepository.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageJpaRepository.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageRepositoryImpl.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceJpaRepository.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceRepositoryImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/GoogleApiClient.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/GoogleNearPlaceRequest.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/LocationRestriction.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GoogleNearbyPlaceResponse.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GooglePlaceImageResponse.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/PlaceResponseDto.java create mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java create mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/dto/response/KakaoPlacesResponse.java delete mode 100644 src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/mapper/GooglePlaceMapper.java diff --git a/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java b/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java index 93e39d8..ed3dd16 100644 --- a/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java +++ b/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java @@ -6,9 +6,10 @@ @Embeddable @Builder @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor @AllArgsConstructor(access = AccessLevel.PRIVATE) @EqualsAndHashCode(of = {"longitude", "latitude"}) +@ToString public class Location { private Double longitude; // x @@ -20,4 +21,9 @@ public static Location from(Double longitude, Double latitude) { .latitude(latitude) .build(); } + + public static Location from(String longitude, String latitude) { + return Location.from(Double.valueOf(longitude), Double.valueOf(latitude)); + } + } diff --git a/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java b/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java new file mode 100644 index 0000000..6e39aac --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.global.util; + +import com.example.mohago_nocar.global.common.exception.InternalServerException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ObjectMapperUtil { + + private final ObjectMapper objectMapper; + + public T readValue(String value, TypeReference typeReference) { + try { + return objectMapper.readValue(value, typeReference); + } catch (JsonProcessingException e) { + throw new InternalServerException(e.getMessage()); + } + } + + public String writeValue(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new InternalServerException(e.getMessage()); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java index 9ebbe23..cd4460f 100644 --- a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java +++ b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java @@ -2,22 +2,15 @@ import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; -import com.example.mohago_nocar.festival.presentation.response.FestivalLocationResponseDto; -import com.example.mohago_nocar.global.common.dto.PagedResponseDto; -import com.example.mohago_nocar.place.application.mapper.FestivalNearPlaceMapper; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import com.example.mohago_nocar.place.domain.model.OperatingSchedule; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceImageRepository; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceRepository; +import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.place.application.converter.PlaceConverter; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.repository.PlaceRepository; import com.example.mohago_nocar.place.domain.service.PlaceUseCase; -import com.example.mohago_nocar.place.infrastructure.externalApi.GoogleApiClient; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.KakaoApiClient; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse; import com.example.mohago_nocar.place.presentation.NearPlaceResponseDto; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -26,65 +19,37 @@ @RequiredArgsConstructor public class PlaceService implements PlaceUseCase { - private final GoogleApiClient googleApiClient; - private final FestivalUseCase festivalUseCase; - private final FestivalNearPlaceRepository festivalNearPlaceRepository; - private final FestivalNearPlaceImageRepository festivalNearPlaceImageRepository; + private static final int RADIUS = 3000; + private static final int PAGE_SIZE = 10; - private static final int RADIUS = 1500; + private final FestivalUseCase festivalUseCase; + private final PlaceRepository placeRepository; + private final KakaoApiClient kakaoApiClient; - @Override @Transactional - public void updateAllFestivalNearbyPlaces() { - List festivals = festivalUseCase.getAllFestivals(); - festivals.forEach(festival -> updateFestivalNearbyPlaces(festival.getId())); - } - - @Transactional(readOnly = true) @Override - public PagedResponseDto getFestivalNearPlaces(Long festivalId, Pageable pageable) { + public List getFestivalNearPlaces(Long festivalId) { Festival festival = festivalUseCase.getFestival(festivalId); - Page pagedPlaces = festivalNearPlaceRepository.getFestivalNearPlaceByFestivalId(festivalId, pageable); - Page nearPlaceResponseDtos = pagedPlaces.map(this::convertPlaceToNearPlaceResponseDto); - return new PagedResponseDto<>(nearPlaceResponseDtos); - } + List places = placeRepository.getFestivalAroundPlaces(festivalId); + if (places.isEmpty()) { + places = cachePlaces(festivalId, festival.getLocation()); + } - private NearPlaceResponseDto convertPlaceToNearPlaceResponseDto(FestivalNearPlace place) { - List images = festivalNearPlaceImageRepository.getAllPlaceImageByPlaceId(place.getId()); - List imageUrls = images.stream().map(FestivalNearPlaceImage::getImageUrl).toList(); - List operatingHours = place.getOperatingSchedule().getOperatingHours().stream().map(OperatingSchedule.OperatingHour::getOperatingHour).toList(); - return NearPlaceResponseDto.of(place, operatingHours, imageUrls); + return PlaceConverter.convertToNearPlaceResponseDtos(festivalId, places); } - private void updateFestivalNearbyPlaces(Long festivalId) { - FestivalLocationResponseDto festivalLocation = festivalUseCase.getFestivalLocation(festivalId); - List nearbyPlaces = searchNearbyPlacesWithImages(festivalLocation); - saveNearbyPlacesWithImages(festivalId, nearbyPlaces); + public List cachePlaces(Long festivalId, Location centerLocation) { + KakaoPlacesResponse placesFromExternalApi = searchPlacesAround(centerLocation); + List places = PlaceConverter.convertToPlaces(placesFromExternalApi); + return placeRepository.saveAllToCache(festivalId, places); } - private List searchNearbyPlacesWithImages(FestivalLocationResponseDto festivalLocation) { - return googleApiClient.searchNearbyPlacesWithImageUris( - festivalLocation.location().getLatitude(), - festivalLocation.location().getLongitude(), - RADIUS + private KakaoPlacesResponse searchPlacesAround(Location centerLocation) { + return kakaoApiClient.searchAttractionPlaces( + centerLocation, + RADIUS, + PAGE_SIZE ); } - private void saveNearbyPlacesWithImages(Long festivalId, List nearbyPlaces) { - nearbyPlaces.forEach(placeDto -> { - FestivalNearPlace savedPlace = saveFestivalNearPlace(festivalId, placeDto); - saveFestivalNearPlaceImages(savedPlace.getId(), placeDto.getPhotos()); - }); - } - - private FestivalNearPlace saveFestivalNearPlace(Long festivalId, PlaceResponseDto placeDto) { - FestivalNearPlace place = FestivalNearPlaceMapper.convertToFestivalNearPlace(festivalId, placeDto); - return festivalNearPlaceRepository.save(place); - } - - private void saveFestivalNearPlaceImages(Long placeId, List photos) { - List placeImages = FestivalNearPlaceMapper.convertToFestivalNearPlaceImage(placeId, photos); - placeImages.forEach(festivalNearPlaceImageRepository::save); - } - } \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/place/application/converter/PlaceConverter.java b/src/main/java/com/example/mohago_nocar/place/application/converter/PlaceConverter.java new file mode 100644 index 0000000..1f9b58e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/application/converter/PlaceConverter.java @@ -0,0 +1,36 @@ +package com.example.mohago_nocar.place.application.converter; + +import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.place.domain.model.*; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse.KakaoPlaceResponse; +import com.example.mohago_nocar.place.presentation.NearPlaceResponseDto; + +import java.util.List; + +public class PlaceConverter { + + public static List convertToPlaces(KakaoPlacesResponse places) { + return places.documents().stream() + .map(PlaceConverter::convertToPlace) + .toList(); + } + + public static Place convertToPlace(KakaoPlaceResponse dto) { + return Place.from( + dto.id(), + dto.place_name(), + Location.from(dto.x(), dto.y()), + dto.address_name(), + dto.place_url(), + PlaceCategory.getCategoryByCode(dto.category_group_code()) + ); + } + + public static List convertToNearPlaceResponseDtos(Long festivalId, List places) { + return places.stream() + .map(place -> NearPlaceResponseDto.of(festivalId, place)) + .toList(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/application/mapper/FestivalNearPlaceMapper.java b/src/main/java/com/example/mohago_nocar/place/application/mapper/FestivalNearPlaceMapper.java deleted file mode 100644 index 6d350cd..0000000 --- a/src/main/java/com/example/mohago_nocar/place/application/mapper/FestivalNearPlaceMapper.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.mohago_nocar.place.application.mapper; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import com.example.mohago_nocar.place.domain.model.OperatingSchedule; -import com.example.mohago_nocar.place.domain.model.PlaceType; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; -import java.util.List; - - -public class FestivalNearPlaceMapper { - - public static FestivalNearPlace convertToFestivalNearPlace(Long festivalId, PlaceResponseDto dto) { - - String placeName = dto.getPlaceName(); - OperatingSchedule schedule = getSchedule(dto); - Location location = getLocation(dto); - String address = dto.getAddress(); - String description = dto.getDescription(); - PlaceType placeType = getPlaceType(dto); - String googlePlaceId = dto.getId(); - - return FestivalNearPlace.from(festivalId, placeName, schedule, location, address, description, placeType, googlePlaceId); - } - - private static OperatingSchedule getSchedule(PlaceResponseDto dto) { - return OperatingSchedule.from(dto.getSchedule()); - } - - private static Location getLocation(PlaceResponseDto dto) { - return Location.from(dto.getLongitude(), dto.getLatitude()); - } - - private static PlaceType getPlaceType(PlaceResponseDto dto) { - return PlaceType.from(dto.getPlaceType()); - } - - public static List convertToFestivalNearPlaceImage(Long festivalId, List photos) { - return photos.stream().map(photo -> FestivalNearPlaceImage.from(festivalId, photo)).toList(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlace.java b/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlace.java deleted file mode 100644 index 3984a46..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlace.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.example.mohago_nocar.place.domain.model; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import jakarta.persistence.*; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import static jakarta.persistence.EnumType.STRING; -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -@Entity -@Getter -@NoArgsConstructor(access = PROTECTED) -public class FestivalNearPlace { - - @Id - @GeneratedValue(strategy = IDENTITY) - private Long id; - - @NotNull - private String name; - - @NotNull - private Long festivalId; - - @NotNull - @Embedded - private OperatingSchedule operatingSchedule; - - @NotNull - @Embedded - private Location location; - - @NotNull - private String address; - - @NotNull - @Column(columnDefinition = "TEXT") - private String description; - - @NotNull - @Enumerated(value = STRING) - private PlaceType placeType; - - @NotNull - private String googlePlaceId; - - public static FestivalNearPlace from( - Long festivalId, - String name, - OperatingSchedule operatingSchedule, - Location location, - String address, - String description, - PlaceType placeType, - String googlePlaceId - ) { - return FestivalNearPlace.builder() - .festivalId(festivalId) - .name(name) - .operatingSchedule(operatingSchedule) - .location(location) - .address(address) - .description(description) - .placeType(placeType) - .googlePlaceId(googlePlaceId) - .build(); - } - - @Builder - private FestivalNearPlace( - Long festivalId, - String name, - OperatingSchedule operatingSchedule, - Location location, - String address, - String description, - PlaceType placeType, - String googlePlaceId - ) { - this.festivalId = festivalId; - this.name = name; - this.operatingSchedule = operatingSchedule; - this.location = location; - this.address = address; - this.description = description; - this.placeType = placeType; - this.googlePlaceId = googlePlaceId; - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlaceImage.java b/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlaceImage.java deleted file mode 100644 index 8623e6d..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlaceImage.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.mohago_nocar.place.domain.model; - -import com.example.mohago_nocar.festival.domain.model.FestivalImage; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -@Entity -@Getter -@NoArgsConstructor(access = PROTECTED) -public class FestivalNearPlaceImage { - - @Id - @GeneratedValue(strategy = IDENTITY) - private Long id; - - @NotNull - private Long festivalNearPlaceId; - - @NotNull - private String imageUrl; - - public static FestivalNearPlaceImage from(Long festivalId, String imageUrl) { - return FestivalNearPlaceImage.builder() - .festivalNearPlaceId(festivalId) - .imageUrl(imageUrl) - .build(); - } - - @Builder - private FestivalNearPlaceImage(Long festivalNearPlaceId, String imageUrl) { - this.festivalNearPlaceId = festivalNearPlaceId; - this.imageUrl = imageUrl; - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/OperatingSchedule.java b/src/main/java/com/example/mohago_nocar/place/domain/model/OperatingSchedule.java deleted file mode 100644 index aac112f..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/OperatingSchedule.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.example.mohago_nocar.place.domain.model; - -import jakarta.persistence.CollectionTable; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Embeddable; -import jakarta.persistence.JoinColumn; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; - -import static lombok.AccessLevel.PROTECTED; - -@Embeddable -@NoArgsConstructor(access = PROTECTED) -public class OperatingSchedule { - - private static final int MAX_DAYS_IN_WEEK = 7; - - @ElementCollection - @CollectionTable(name = "operating_hours", joinColumns = @JoinColumn(name = "festival_near_place_id")) - private List schedule = new ArrayList<>(); - - public List getOperatingHours() { - return schedule; - } - - public static OperatingSchedule from(List operatingHours) { - if (operatingHours.size() > MAX_DAYS_IN_WEEK) { - throw new IllegalArgumentException("Operating hours cannot exceed " + MAX_DAYS_IN_WEEK + " days."); - } - - OperatingSchedule operatingSchedule = new OperatingSchedule(); - operatingHours.forEach(operatingSchedule::addOperatingHours); - - return operatingSchedule; - } - - private void addOperatingHours(String operatingHour) { - schedule.add(OperatingHour.from(operatingHour)); - } - - - @Embeddable - @Getter - @NoArgsConstructor(access = PROTECTED) - public static class OperatingHour { - - private String operatingHour; - - public static OperatingHour from(String operatingHour) { - return OperatingHour.builder() - .operatingHour(operatingHour) - .build(); - } - - @Builder - private OperatingHour(String operatingHour) { - this.operatingHour = operatingHour; - } - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java b/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java new file mode 100644 index 0000000..9c52030 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java @@ -0,0 +1,71 @@ +package com.example.mohago_nocar.place.domain.model; + +import com.example.mohago_nocar.global.common.domain.vo.Location; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.EnumType.STRING; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor +public class Place { + + @NotNull + private String id; + + @NotNull + private String name; + + @NotNull + private Location location; + + @NotNull + private String address; + + @NotNull + private String placeUrl; + + @NotNull + @Enumerated(value = STRING) + private PlaceCategory category; + + public static Place from( + String id, + String name, + Location location, + String address, + String placeUrl, + PlaceCategory category + ) { + return Place.builder() + .id(id) + .name(name) + .location(location) + .address(address) + .placeUrl(placeUrl) + .category(category) + .build(); + } + + @Builder + private Place( + String id, + String name, + Location location, + String address, + String placeUrl, + PlaceCategory category + ) { + this.id = id; + this.name = name; + this.location = location; + this.address = address; + this.placeUrl = placeUrl; + this.category = category; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceCategory.java b/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceCategory.java new file mode 100644 index 0000000..303237e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceCategory.java @@ -0,0 +1,39 @@ +package com.example.mohago_nocar.place.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RequiredArgsConstructor +@Getter +public enum PlaceCategory { + + ATTRACTION("관광명소", "AT4"), + FOOD("음식점", "FD6"), + CULTURAL_FACILITIES("문화시설", "CT1"); + + private final String name; + private final String code; + + public static List getTravelCategories() { + return List.of(ATTRACTION, FOOD, CULTURAL_FACILITIES); + } + + public static PlaceCategory getCategoryByCode(String code) { + if (code.equals(ATTRACTION.getCode())) { + return ATTRACTION; + } + + if (code.equals(FOOD.getCode())) { + return FOOD; + } + + if (code.equals(CULTURAL_FACILITIES.getCode())) { + return CULTURAL_FACILITIES; + } + + return null; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceType.java b/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceType.java deleted file mode 100644 index 193973f..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceType.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.mohago_nocar.place.domain.model; - -public enum PlaceType { - RESTAURANT, ATTRACTION; - - public static PlaceType from(String placeType) { - if (placeType.contains("음식점") - || placeType.contains("식당") - || placeType.contains("카페") - || placeType.contains("커피숍/커피 전문점") - || placeType.contains("제과점") - || placeType.contains("아이스크림 가게") - ) { - return RESTAURANT; - } - - return ATTRACTION; - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceImageRepository.java b/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceImageRepository.java deleted file mode 100644 index ebeb2f3..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceImageRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.place.domain.repository; - -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import java.util.List; - -public interface FestivalNearPlaceImageRepository { - - FestivalNearPlaceImage save(FestivalNearPlaceImage image); - - List getAllPlaceImageByPlaceId(Long id); -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceRepository.java b/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceRepository.java deleted file mode 100644 index 0773bec..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.mohago_nocar.place.domain.repository; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -public interface FestivalNearPlaceRepository { - - FestivalNearPlace save(FestivalNearPlace place); - - FestivalNearPlace findById(Long id); - - String getPlaceNameByLocation(Location location); - - Page getFestivalNearPlaceByFestivalId(Long festivalId, Pageable pageable); -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/repository/PlaceRepository.java b/src/main/java/com/example/mohago_nocar/place/domain/repository/PlaceRepository.java new file mode 100644 index 0000000..3ba7804 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/domain/repository/PlaceRepository.java @@ -0,0 +1,14 @@ +package com.example.mohago_nocar.place.domain.repository; + +import com.example.mohago_nocar.place.domain.model.Place; + +import java.util.List; + +public interface PlaceRepository { + + List findByIds(Long festivalId, List placeIds); + + List getFestivalAroundPlaces(Long festivalId); + + List saveAllToCache(Long festivalId, List nearPlaces); +} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java b/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java index 6876a56..f32ca01 100644 --- a/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java +++ b/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java @@ -1,15 +1,11 @@ package com.example.mohago_nocar.place.domain.service; -import com.example.mohago_nocar.global.common.dto.PagedResponseDto; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; import com.example.mohago_nocar.place.presentation.NearPlaceResponseDto; + import java.util.List; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; public interface PlaceUseCase { - void updateAllFestivalNearbyPlaces(); + List getFestivalNearPlaces(Long festivalId); - PagedResponseDto getFestivalNearPlaces(Long festivalId, Pageable pageable); } diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageJpaRepository.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageJpaRepository.java deleted file mode 100644 index 6c3aa11..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageJpaRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure; - -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceImageRepository; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface FestivalNearPlaceImageJpaRepository extends JpaRepository { - - List findAllByFestivalNearPlaceId(Long id); -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageRepositoryImpl.java deleted file mode 100644 index 9385f81..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageRepositoryImpl.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure; - -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceImageRepository; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class FestivalNearPlaceImageRepositoryImpl implements FestivalNearPlaceImageRepository { - - private final FestivalNearPlaceImageJpaRepository festivalNearPlaceImageJpaRepository; - - @Override - public FestivalNearPlaceImage save(FestivalNearPlaceImage image) { - return festivalNearPlaceImageJpaRepository.save(image); - } - - @Override - public List getAllPlaceImageByPlaceId(Long id) { - return festivalNearPlaceImageJpaRepository.findAllByFestivalNearPlaceId(id); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceJpaRepository.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceJpaRepository.java deleted file mode 100644 index 641338b..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceJpaRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure; - -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface FestivalNearPlaceJpaRepository extends JpaRepository { - - @Query("SELECT place.name FROM FestivalNearPlace place " + - "WHERE place.location.latitude = :latitude " + - "AND place.location.longitude = :longitude") - Page findNamesByLocation(@Param("latitude") Double latitude, - @Param("longitude") Double longitude, - Pageable pageable); - - Page findAllByFestivalId(Long festivalId, Pageable pageable); -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceRepositoryImpl.java deleted file mode 100644 index d262772..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceRepositoryImpl.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.global.common.exception.EntityNotFoundException; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Repository; - -import static com.example.mohago_nocar.place.presentation.exception.PlaceErrorCode.PLACE_NOT_FOUND; - -@Repository -@RequiredArgsConstructor -public class FestivalNearPlaceRepositoryImpl implements FestivalNearPlaceRepository { - - private final FestivalNearPlaceJpaRepository festivalNearPlaceJpaRepository; - - @Override - public FestivalNearPlace save(FestivalNearPlace place) { - return festivalNearPlaceJpaRepository.save(place); - } - - @Override - public FestivalNearPlace findById(Long id) { - return festivalNearPlaceJpaRepository.findById(id) - .orElseThrow(()-> new EntityNotFoundException(PLACE_NOT_FOUND)); - } - - @Override - public String getPlaceNameByLocation(Location location) { - PageRequest pageRequest = PageRequest.of(0, 1); - Page result = festivalNearPlaceJpaRepository.findNamesByLocation(location.getLatitude(), location.getLongitude(), pageRequest); - return result.getContent().get(0); - } - - public Page getFestivalNearPlaceByFestivalId(Long festivalId, Pageable pageable) { - return festivalNearPlaceJpaRepository.findAllByFestivalId(festivalId, pageable); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java new file mode 100644 index 0000000..a51855a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java @@ -0,0 +1,73 @@ +package com.example.mohago_nocar.place.infrastructure; + +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.repository.PlaceRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Repository +@Slf4j +@RequiredArgsConstructor +public class PlaceRepositoryImpl implements PlaceRepository { + + private static final String KEY_PREFIX = "festival:places:"; + + private final RedisTemplate redisTemplate; + private final ObjectMapperUtil objectMapperUtil; + + @Override + public List findByIds(Long festivalId, List placeIds) { + List places = getFestivalAroundPlaces(festivalId); + + return places.stream() + .filter(place -> placeIds.contains(place.getId())) + .toList(); + } + + @Override + public List getFestivalAroundPlaces(Long festivalId) { + String placesInJson = readCache(generateCacheKey(festivalId)); + + if (StringUtils.isEmpty(placesInJson)) { + return Collections.emptyList(); + } + + return objectMapperUtil.readValue(placesInJson, new TypeReference<>() { + }); + } + + @Override + public List saveAllToCache(Long festivalId, List toSavePlaces) { + String key = generateCacheKey(festivalId); + saveToCache(key, toSavePlaces); + return readFromSavedCache(key); + } + + private void saveToCache(String key, List places) { + String placesJson = objectMapperUtil.writeValue(places); + redisTemplate.opsForValue().set(key, placesJson, 2, TimeUnit.HOURS); + } + + private List readFromSavedCache(String key) { + return objectMapperUtil.readValue(readCache(key), new TypeReference<>() { + }); + } + + private String readCache(String redisKey) { + return redisTemplate.opsForValue().get(redisKey); + } + + private String generateCacheKey(Long festivalId) { + return KEY_PREFIX + festivalId; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/GoogleApiClient.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/GoogleApiClient.java deleted file mode 100644 index bc1e0fc..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/GoogleApiClient.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi; - -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.request.GoogleNearPlaceRequest; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.GoogleNearbyPlaceResponse; -import java.net.URI; -import java.util.List; -import java.util.stream.Collectors; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.GooglePlaceImageResponse; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; -import com.example.mohago_nocar.place.infrastructure.externalApi.mapper.GooglePlaceMapper; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.springframework.web.util.UriComponentsBuilder; - -@Component -@Slf4j -public class GoogleApiClient { - - private final RestClient restClient; - private final String apiKey; - private final String baseUrl; - - private static final int MAX_RESULT_COUNT = 20; - - private static final String RANK_PREFERENCE = "POPULARITY"; - - private static final String LANGUAGE_CODE = "ko"; - - private static final String FIELD_MASK = String.join(",", - "places.displayName", - "places.nationalPhoneNumber", - "places.id", - "places.formattedAddress", - "places.rating", - "places.userRatingCount", - "places.location", - "places.websiteUri", - "places.regularOpeningHours.weekdayDescriptions", - "places.primaryTypeDisplayName", - "places.editorialSummary", - "places.generativeSummary", - "places.photos.name"); - - private static final List REQUEST_PLACE_TYPES = List.of( - "art_gallery", - "museum", - "performing_arts_theater", - "amusement_center", - "amusement_park", - "aquarium", - "banquet_hall", - "bowling_alley", - "casino", - "hiking_area", - "historical_landmark", - "marina", - "movie_rental", - "movie_theater", - "national_park", - "night_club", - "park", - "zoo", - "american_restaurant", - "bakery", - "bar", - "barbecue_restaurant", - "breakfast_restaurant", - "brunch_restaurant", - "cafe", - "chinese_restaurant", - "coffee_shop", - "fast_food_restaurant", - "french_restaurant", - "hamburger_restaurant", - "ice_cream_shop", - "indian_restaurant", - "indonesian_restaurant", - "italian_restaurant", - "japanese_restaurant", - "korean_restaurant", - "mediterranean_restaurant", - "mexican_restaurant", - "middle_eastern_restaurant", - "pizza_restaurant", - "ramen_restaurant", - "restaurant", - "sandwich_shop", - "seafood_restaurant", - "steak_house", - "sushi_restaurant", - "spa", - "gift_shop", - "shopping_mall", - "playground" - ); - - public GoogleApiClient( - RestClient.Builder restClient, - @Value("${google.api-key}") String apiKey, - @Value("${google.url}") String baseUrl) { - this.restClient = restClient.build(); - this.apiKey = apiKey; - this.baseUrl = baseUrl; - } - - public List searchNearbyPlacesWithImageUris(double latitude, double longitude, int radius) { - URI requestURI = buildNearPlaceRequestURI(); - GoogleNearbyPlaceResponse googleNearPlaceResponse = fetchNearPlaceResponse(requestURI, latitude, longitude, radius); - List placeResponseDtos = GooglePlaceMapper.mapGoogleNearPlaceResponseToPlaceResponseDtos(googleNearPlaceResponse); - - return convertPhotoNamesToUris(placeResponseDtos); - } - - private URI buildNearPlaceRequestURI() { - return UriComponentsBuilder.fromUriString(baseUrl) - .path("places:searchNearby") - .build(true) - .toUri(); - } - - private GoogleNearbyPlaceResponse fetchNearPlaceResponse(URI requestURI, double latitude, double longitude, int radius) { - GoogleNearPlaceRequest requestBody = GoogleNearPlaceRequest.of( - REQUEST_PLACE_TYPES, MAX_RESULT_COUNT, latitude, longitude, radius, RANK_PREFERENCE, LANGUAGE_CODE); - - try { - return restClient.post() - .uri(requestURI) - .header("X-Goog-Api-Key", apiKey) - .header("Content-Type", "application/json") - .header("X-Goog-FieldMask", FIELD_MASK) - .body(requestBody) - .retrieve() - .body(GoogleNearbyPlaceResponse.class); - - } catch (Exception e) { - // TODO: 커스텀 에러 변경 - throw new RuntimeException(e.getMessage()); - } - - } - - /** - * Google API 요청을 통해 주어진 PlaceResponseDto List의 photos 필드 값을 photoName에서 photoUri로 변환합니다. - * - * @param placeResponseDtos 변환할 PlaceResponseDto 목록 (변환 전의 photos 필드 값은 photoName) - * @return 변환된 PlaceResponseDto 목록 (photos 필드 값은 photoUri) - */ - private List convertPhotoNamesToUris(List placeResponseDtos) { - return placeResponseDtos.stream() - .map(this::convertPhotoNamesToUrisForDto) - .collect(Collectors.toList()); - } - - private PlaceResponseDto convertPhotoNamesToUrisForDto(PlaceResponseDto placeResponseDto) { - List photoNames = placeResponseDto.getPhotos(); - List photoUris = searchPlaceImagesFrom(photoNames); - - return placeResponseDto.withUpdatedPhotos(photoUris); - } - - private List searchPlaceImagesFrom(List photoNames) { - return photoNames.stream() - .map(this::searchPlaceImageUri) - .collect(Collectors.toList()); - } - - private String searchPlaceImageUri(String photoName) { - URI uri = buildPlaceImageRequestURI(photoName); - GooglePlaceImageResponse imageResponse = fetchPlaceImageResponse(uri); - - return imageResponse.photoUri(); - } - - private URI buildPlaceImageRequestURI(String name) { - return UriComponentsBuilder.fromUriString(baseUrl) - .path(name) - .path("/media") - .queryParam("key", apiKey) - .queryParam("maxHeightPx", 4800) - .queryParam("skipHttpRedirect", true) - .build(true) - .toUri(); - } - - private GooglePlaceImageResponse fetchPlaceImageResponse(URI uri) { - try { - return restClient.get() - .uri(uri) - .retrieve() - .body(GooglePlaceImageResponse.class); - - } catch (Exception e) { - // TODO: 커스텀 에러 변경 - throw new RuntimeException(e.getMessage()); - } - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/GoogleNearPlaceRequest.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/GoogleNearPlaceRequest.java deleted file mode 100644 index a5780b6..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/GoogleNearPlaceRequest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.request; - -import lombok.Builder; -import java.util.List; - - -@Builder -public record GoogleNearPlaceRequest( - List includedTypes, - int maxResultCount, - LocationRestriction locationRestriction, - String rankPreference, - String languageCode -) { - public static GoogleNearPlaceRequest of( - List includedTypes, - int maxResultCount, - double latitude, - double longitude, - double radius, - String rankPreference, - String languageCode - ) { - return GoogleNearPlaceRequest.builder() - .includedTypes(includedTypes) - .maxResultCount(maxResultCount) - .locationRestriction(LocationRestriction.of(latitude, longitude, radius)) - .rankPreference(rankPreference) - .languageCode(languageCode) - .build(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/LocationRestriction.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/LocationRestriction.java deleted file mode 100644 index 99b9b6c..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/LocationRestriction.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.request; - -import lombok.Builder; - -@Builder -public record LocationRestriction( - Circle circle -) { - public static LocationRestriction of(double latitude, double longitude, double radius) { - return LocationRestriction.builder() - .circle(Circle.of(latitude, longitude, radius)) - .build(); - } - - @Builder - public record Circle( - Center center, - double radius - ) { - public static Circle of(double latitude, double longitude, double radius) { - return Circle.builder() - .center(Center.of(latitude, longitude)) - .radius(radius) - .build(); - } - } - - @Builder - public record Center( - double latitude, - double longitude - ) { - public static Center of(double latitude, double longitude) { - return Center.builder() - .latitude(latitude) - .longitude(longitude) - .build(); - } - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GoogleNearbyPlaceResponse.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GoogleNearbyPlaceResponse.java deleted file mode 100644 index 462d160..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GoogleNearbyPlaceResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.response; - -import com.fasterxml.jackson.databind.JsonNode; - -public record GoogleNearbyPlaceResponse( - JsonNode places -) { -} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GooglePlaceImageResponse.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GooglePlaceImageResponse.java deleted file mode 100644 index a396389..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GooglePlaceImageResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.response; - -public record GooglePlaceImageResponse( - String photoUri -) { -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/PlaceResponseDto.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/PlaceResponseDto.java deleted file mode 100644 index c9e1042..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/PlaceResponseDto.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.response; - -import lombok.Builder; -import lombok.Getter; - -import java.util.List; - -@Getter -@Builder -public class PlaceResponseDto { - - private final String id; - private final String address; - private final Double latitude; - private final Double longitude; - private final Double rating; - private final Integer ratingCount; - private final String placeName; - private final String placeType; - private final List photos; - private final String editorialSummary; - private final String generativeSummary; - private final List schedule; - - private static final String NO_DESCRIPTION_MESSAGE = "장소 소개가 제공되지 않습니다"; - - private static final String N0_SCHEDULE_MESSAGE = "영업 시간이 제공되지 않는 장소입니다"; - - public static PlaceResponseDto of( - String id, - String address, - Double latitude, - Double longitude, - Double rating, - Integer userRatingCount, - String placeName, - String placeType, - List photos, - String editorialSummary, - String generativeSummary, - List schedule - ) { - return PlaceResponseDto.builder() - .id(id) - .address(address) - .latitude(latitude) - .longitude(longitude) - .rating(rating) - .ratingCount(userRatingCount) - .placeName(placeName) - .placeType(placeType) - .photos(photos) - .editorialSummary(editorialSummary) - .generativeSummary(generativeSummary) - .schedule(schedule) - .build(); - } - - public PlaceResponseDto withUpdatedPhotos(List photos) { - return PlaceResponseDto.builder() - .id(this.id) - .address(this.address) - .latitude(this.latitude) - .longitude(this.longitude) - .rating(this.rating) - .ratingCount(this.ratingCount) - .placeName(this.placeName) - .placeType(this.placeType) - .photos(photos) - .editorialSummary(this.editorialSummary) - .generativeSummary(this.generativeSummary) - .schedule(this.schedule) - .build(); - } - - public String getDescription() { - if (editorialSummary != null && !editorialSummary.isEmpty()) { - return editorialSummary; - } - - if (generativeSummary != null && !generativeSummary.isEmpty()) { - return generativeSummary; - } - - return NO_DESCRIPTION_MESSAGE; - } - - public List getSchedule() { - if (this.schedule.isEmpty()) { - return List.of(N0_SCHEDULE_MESSAGE); - } - - return this.schedule; - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java new file mode 100644 index 0000000..ab3b233 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java @@ -0,0 +1,50 @@ +package com.example.mohago_nocar.place.infrastructure.externalApi.kakao; + +import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.place.domain.model.PlaceCategory; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +@Component +public class KakaoApiClient { + + private static final String AUTHORIZATION_PREFIX = "KakaoAK "; + + private final String baseUrl; + private final String apiKey; + private final RestClient restClient; + + public KakaoApiClient( + @Value("${kakao.local.category}") String baseUrl, + @Value("${kakao.api-key}") String apiKey, + RestClient restClient) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.restClient = restClient; + } + + public KakaoPlacesResponse searchAttractionPlaces(Location centerLocation, int radius, int size) { + URI uri = UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("x", centerLocation.getLongitude()) + .queryParam("y", centerLocation.getLatitude()) + .queryParam("radius", radius) + .queryParam("size", size) + .queryParam("category_group_code", PlaceCategory.ATTRACTION.getCode()) + .build(true) + .toUri(); + + return restClient.get() + .uri(uri) + .header("Authorization", AUTHORIZATION_PREFIX + apiKey) + .retrieve() + .body(new ParameterizedTypeReference<>() { } + ); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/dto/response/KakaoPlacesResponse.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/dto/response/KakaoPlacesResponse.java new file mode 100644 index 0000000..c9933e6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/dto/response/KakaoPlacesResponse.java @@ -0,0 +1,22 @@ +package com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response; + +import java.util.List; + +public record KakaoPlacesResponse( + List documents +) { + + public record KakaoPlaceResponse( + String x, + String y, + String id, + String place_name, + String category_group_code, + String phone, + String address_name, + String place_url, + String distance + ) { + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/mapper/GooglePlaceMapper.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/mapper/GooglePlaceMapper.java deleted file mode 100644 index 5c7ae8e..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/mapper/GooglePlaceMapper.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.mapper; - -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.GoogleNearbyPlaceResponse; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; -import com.fasterxml.jackson.databind.JsonNode; -import java.util.List; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; - -public class GooglePlaceMapper { - - public static List mapGoogleNearPlaceResponseToPlaceResponseDtos(GoogleNearbyPlaceResponse googleNearbyPlaceResponse) { - JsonNode placesNode = googleNearbyPlaceResponse.places(); - - return mapPlacesNodeToResponseDtos(placesNode); - } - - private static List mapPlacesNodeToResponseDtos(JsonNode placesNode) { - - return streamJsonNodeOrEmpty(placesNode) - .map(GooglePlaceMapper::mapPlaceNodeToDto) - .toList(); - } - - private static PlaceResponseDto mapPlaceNodeToDto(JsonNode place) { - String id = place.get("id").asText(); - String address = place.get("formattedAddress").asText(); - - // location - JsonNode locationNode = place.get("location"); - Double latitude = locationNode.get("latitude").asDouble(); - Double longitude = locationNode.get("longitude").asDouble(); - - // place name - String placeName = place.get("displayName").get("text").asText(); - - // place type (nullable) - String placeType = place.has("primaryTypeDisplayName") && !place.get("primaryTypeDisplayName").isNull() - ? place.get("primaryTypeDisplayName").get("text").asText() : "unknown"; - - // rating (nullable) - Double rating = place.has("rating") && !place.get("rating").isNull() - ? place.get("rating").asDouble() : (double) 0; - - // userRatingCount (nullable) - Integer userRatingCount = place.has("userRatingCount") && !place.get("userRatingCount").isNull() - ? place.get("userRatingCount").asInt() : 0; - - // photos (nullable) - JsonNode photoNodes = place.has("photos") && !place.get("photos").isNull() - ? place.get("photos") : null; - List photoNames = extractPhotoName(photoNodes); - - // summary (nullable) - String editorialSummary = place.has("editorialSummary") && !place.get("editorialSummary").isNull() - ? place.get("editorialSummary").get("text").asText() : null; - - String generativeSummary = place.has("generativeSummary") && !place.get("generativeSummary").isNull() - ? place.get("generativeSummary").get("description").get("text").asText() : null; - - // schedule (nullable) - JsonNode scheduleNode = place.has("regularOpeningHours") && !place.get("regularOpeningHours").isNull() - ? place.get("regularOpeningHours").get("weekdayDescriptions") : null; - List schedule = extractSchedule(scheduleNode); - - return PlaceResponseDto.of( - id, - address, - latitude, - longitude, - rating, - userRatingCount, - placeName, - placeType, - photoNames, - editorialSummary, - generativeSummary, - schedule - ); - } - - private static List extractPhotoName(JsonNode photoNodes) { - return streamJsonNodeOrEmpty(photoNodes) - .map(photo -> photo.get("name").asText()) - .toList(); - } - - private static List extractSchedule(JsonNode scheduleNode) { - return streamJsonNodeOrEmpty(scheduleNode) - .map(JsonNode::asText) - .toList(); - } - - private static Stream streamJsonNodeOrEmpty(JsonNode node) { - if (node == null || !node.isArray()) { - return Stream.empty(); - } - return StreamSupport.stream(node.spliterator(), false); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java b/src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java index 4eaeb78..068d194 100644 --- a/src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java @@ -1,37 +1,29 @@ package com.example.mohago_nocar.place.presentation; import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.model.OperatingSchedule; -import com.example.mohago_nocar.place.domain.model.PlaceType; -import java.util.List; +import com.example.mohago_nocar.place.domain.model.Place; + import lombok.Builder; @Builder public record NearPlaceResponseDto( - Long id, + String id, String name, Long festivalId, - List operatingSchedule, Location location, String address, - String description, - PlaceType placeType, - String googlePlaceId, - List imageUrlList + String placeUrl, + String category ) { - public static NearPlaceResponseDto of(FestivalNearPlace festivalNearPlace, List operatingHours, List imageUrlList) { + public static NearPlaceResponseDto of(Long festivalId, Place place) { return new NearPlaceResponseDtoBuilder() - .id(festivalNearPlace.getId()) - .name(festivalNearPlace.getName()) - .festivalId(festivalNearPlace.getFestivalId()) - .operatingSchedule(operatingHours) - .location(festivalNearPlace.getLocation()) - .address(festivalNearPlace.getAddress()) - .description(festivalNearPlace.getDescription()) - .placeType(festivalNearPlace.getPlaceType()) - .googlePlaceId(festivalNearPlace.getGooglePlaceId()) - .imageUrlList(imageUrlList) + .id(place.getId()) + .name(place.getName()) + .festivalId(festivalId) + .location(place.getLocation()) + .address(place.getAddress()) + .placeUrl(place.getPlaceUrl()) + .category(place.getCategory().name()) .build(); } } diff --git a/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java b/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java index b2c65e9..6ce8d47 100644 --- a/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java +++ b/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java @@ -3,7 +3,6 @@ import com.example.mohago_nocar.global.common.dto.PagedResponseDto; import com.example.mohago_nocar.global.common.response.ApiResponse; import com.example.mohago_nocar.place.domain.service.PlaceUseCase; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -14,20 +13,23 @@ import java.util.List; @RestController -@RequestMapping("/api/v1/nearby-places") +@RequestMapping("/api/v1/festivals") @RequiredArgsConstructor @Tag(name = "Place", description = "축제 주변 장소") public class PlaceController { private final PlaceUseCase placeUseCase; - @Operation(summary = "축제 주변 장소 업데이트", description = "축제 주변 장소 정보를 업데이트합니다. ") - @PatchMapping("/update") - public ApiResponse> updateFestivalNearPlaces() { - placeUseCase.updateAllFestivalNearbyPlaces(); - return ApiResponse.ok(null); + @Operation(summary = "축제 주변 장소 조회하기", description = "축제 주변 장소 정보를 조회합니다. ") + @GetMapping("/{festivalId}/nearby-places") + public ApiResponse> getFestivalNearPlaces( + @PathVariable(name = "festivalId") Long festivalId + ) { + List places = placeUseCase.getFestivalNearPlaces(festivalId); + return ApiResponse.ok(places); } +/* @Operation(summary = "축제 주변 장소 조회하기", description = "축제 주변 장소 정보를 조회합니다. ") @GetMapping("/{festivalId}") public ApiResponse> getFestivalNearPlaces( @@ -38,5 +40,6 @@ public ApiResponse> getFestivalNearPlaces Pageable pageable = PageRequest.of(page, size); PagedResponseDto places = placeUseCase.getFestivalNearPlaces(festivalId, pageable); return ApiResponse.ok(places); - } + }*/ + } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java index ffaba01..056a441 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java @@ -16,7 +16,7 @@ public record PlanTravelCourseRequestDto( @Schema(description = "도착 시간", example = "10:00") LocalTime arrivalTime, - @Schema(description = "선택된 여행 장소들의 Id", example = "[1234]") - List travelPlaceIds + @Schema(description = "선택된 여행 장소들의 아이디", example = "[1234]") + List placeIds ) { } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java index b5feb79..fd29af7 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java @@ -1,7 +1,7 @@ package com.example.mohago_nocar.plan.presentation.response; import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.transit.domain.model.TransitInfo; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; import lombok.Builder; import java.util.List; @@ -24,7 +24,7 @@ public static PlanTravelCourseResponseDto of( String fromName, Location toLocation, String toName, - TransitInfo transitInfo + TransitRoute transitRoute ) { return PlanTravelCourseResponseDto.builder() .startPlaceName(fromName) @@ -33,9 +33,9 @@ public static PlanTravelCourseResponseDto of( .endPlaceName(toName) .endLongitude(toLocation.getLongitude()) .endLatitude(toLocation.getLatitude()) - .totalTime(transitInfo.getTotalTime()) - .totalDistance(transitInfo.getTotalDistance()) - .subPaths(transitInfo.getSubPaths().stream() + .totalTime(transitRoute.getTotalTime()) + .totalDistance(transitRoute.getTotalDistance()) + .subPaths(transitRoute.getSubPaths().stream() .map(subPath -> switch (subPath.getPathType()) { case BUS -> BusPathResponseDto.of(subPath); From ab9d8bea7c91e08d6cb77f9199a647cb57883620 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 29 Jan 2025 02:15:56 +0900 Subject: [PATCH 04/84] =?UTF-8?q?fix:=20=EC=9D=B4=EB=8F=99=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=B0=8F=20=EA=B1=B0=EB=A6=AC=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8F=84=EB=A1=9D=20Goo?= =?UTF-8?q?gle=20API=20=ED=98=B8=EC=B6=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 ODsay API가 수행하던 장소 간의 이동 시간 및 거리 정보를 Google Distance Matrix API가 수행하도록 합니다. --- .../converter/DistanceMatrixConverter.java | 22 ++++++ .../externalApi/google/GoogleApiClient.java | 76 +++++++++++++++++++ .../GoogleDistanceMatrixResponse.java | 30 ++++++++ .../dto/response/RouteSpecification.java | 38 ++++++++++ 4 files changed, 166 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/converter/DistanceMatrixConverter.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/GoogleApiClient.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/GoogleDistanceMatrixResponse.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/converter/DistanceMatrixConverter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/converter/DistanceMatrixConverter.java new file mode 100644 index 0000000..ef657cb --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/converter/DistanceMatrixConverter.java @@ -0,0 +1,22 @@ +package com.example.mohago_nocar.transit.infrastructure.externalApi.converter; + +import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.RouteSpecification; +import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.GoogleDistanceMatrixResponse; + +import java.util.List; +import java.util.stream.IntStream; + +public class DistanceMatrixConverter { + + public static List convertMatrixToRouteSpecs( + GoogleDistanceMatrixResponse distanceMatrix, int toVisits, Location origin, List destinations) { + return IntStream.range(0, toVisits) + .mapToObj(visit -> RouteSpecification.from( + distanceMatrix.rows().getFirst().elements().get(visit), + origin, + destinations.get(visit))) + .toList(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/GoogleApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/GoogleApiClient.java new file mode 100644 index 0000000..e050038 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/GoogleApiClient.java @@ -0,0 +1,76 @@ +package com.example.mohago_nocar.transit.infrastructure.externalApi.google; + +import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.GoogleDistanceMatrixResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class GoogleApiClient { + + private final RestClient restClient; + private final String apiKey; + private final String baseUrl; + + public GoogleApiClient( + RestClient restClient, + @Value("${google.api-key}") String apiKey, + @Value("${google.maps.distance}") String baseUrl + ) { + this.restClient = restClient; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + /** + * 하나의 출발지와 여러 목적지로 이루어진 행렬을 반환합니다. + * 각 셀의 값은 `(거리, 소요 시간)` 형식입니다. + * + * + * + * + * + * + * + * + *
Destination 1 Destination 2 Destination 3
Origin 1 Value 1 Value 2 Value 3
+ * @return 행렬에 기반한 (출발지, 목적지)와 관련된 데이터를 반환합니다. + */ + public GoogleDistanceMatrixResponse getDistanceMatrix(Location origin, List destinations) { + URI requestUri = UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("origins", formatOriginCoordinates(origin)) + .queryParam("destinations", formatLocations(destinations)) + .queryParam("language", "ko") + .queryParam("mode", "transit") + .queryParam("key", apiKey) + .build(true) + .toUri(); + + return restClient.get() + .uri(requestUri) + .retrieve() + .body(GoogleDistanceMatrixResponse.class); + } + + private String formatOriginCoordinates(Location origin) { + return origin.getLatitude() + "," + origin.getLongitude(); + } + + private String formatLocations(List locations) { + return URLEncoder.encode( + locations.stream() + .map(location -> location.getLatitude() + "," + location.getLongitude()) + .collect(Collectors.joining("|")) + ,StandardCharsets.UTF_8 + ); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/GoogleDistanceMatrixResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/GoogleDistanceMatrixResponse.java new file mode 100644 index 0000000..984df26 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/GoogleDistanceMatrixResponse.java @@ -0,0 +1,30 @@ +package com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response; + +import java.util.List; + +public record GoogleDistanceMatrixResponse( + List destinationAddresses, + List originAddresses, + List rows +) { + + public record Row( + List elements + ){} + + public record Element( + Distance distance, + Duration duration + ){} + + public record Distance( + String text, + Long value + ){} + + public record Duration( + String text, + Long value + ){} + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java new file mode 100644 index 0000000..3bf4ca9 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java @@ -0,0 +1,38 @@ +package com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response; + +import com.example.mohago_nocar.global.common.domain.vo.Location; +import lombok.Builder; + +/** + * + * @param distanceInKm + * @param durationInMinutes + * @param origin + * @param destination + */ +@Builder +public record RouteSpecification( + Double distanceInKm, + Long durationInMinutes, + Location origin, + Location destination +) { + + public static RouteSpecification from( + GoogleDistanceMatrixResponse.Element element, + Location origin, + Location destination + ) { + return RouteSpecification.builder() + .distanceInKm(element.distance().value() / 1000.0) + .durationInMinutes(element.duration().value() / 60L) + .origin(origin) + .destination(destination) + .build(); + } + + public boolean isEqualLocation(Location origin, Location destination) { + return this.origin.equals(origin) && this.destination.equals(destination); + } + +} From 8553c7cf1b675f31420a89da74e59b41c210300a Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 29 Jan 2025 02:22:08 +0900 Subject: [PATCH 05/84] =?UTF-8?q?fix:=20ODsay=20API=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=20=EA=B0=84=EA=B2=A9=20=EC=A4=80=EC=88=98=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20429=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 변경 전: 스레드 슬립 - 변경 후: 세마포어와 스케줄러 --- build.gradle | 4 + .../converter/TransitRouteConverter.java} | 73 +++++++++-- .../{TransitInfo.java => TransitRoute.java} | 9 +- .../error/code/OdsayErrorCode.java | 21 ++- ...ion.java => OdsayBadRequestException.java} | 7 +- .../error/exception/OdsayException.java | 15 --- .../error/exception/OdsayServerException.java | 16 +++ .../OdsayTooManyRequestsException.java | 13 ++ .../externalApi/ODsayApiClient.java | 83 ------------ .../response/OdsaySearchRouteResponseDto.java | 11 -- .../dto/response/RouteResponseDto.java | 15 --- .../externalApi/odsay/ODsayApiClient.java | 124 ++++++++++++++++++ .../odsay/ODsayApiResponseDeserializer.java | 121 +++++++++++++++++ .../externalApi/odsay/ResponseValidator.java | 22 ++++ .../dto/response/OdsayRouteResponse.java | 19 +++ .../mapper/OdsayResponseDeserializer.java | 45 ------- .../mapper/OdsayRouteMapper.java | 58 -------- 17 files changed, 405 insertions(+), 251 deletions(-) rename src/main/java/com/example/mohago_nocar/transit/{application/mapper/TransitMapper.java => domain/converter/TransitRouteConverter.java} (61%) rename src/main/java/com/example/mohago_nocar/transit/domain/model/{TransitInfo.java => TransitRoute.java} (75%) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/{OdsayDistanceException.java => OdsayBadRequestException.java} (56%) delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayException.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayServerException.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayTooManyRequestsException.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/ODsayApiClient.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/OdsaySearchRouteResponseDto.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/RouteResponseDto.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiClient.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiResponseDeserializer.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ResponseValidator.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/dto/response/OdsayRouteResponse.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayResponseDeserializer.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayRouteMapper.java diff --git a/build.gradle b/build.gradle index be911b3..a1f3162 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,10 @@ dependencies { // valid implementation 'org.springframework.boot:spring-boot-starter-validation' + // retry + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/example/mohago_nocar/transit/application/mapper/TransitMapper.java b/src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java similarity index 61% rename from src/main/java/com/example/mohago_nocar/transit/application/mapper/TransitMapper.java rename to src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java index 54f96be..e0388f1 100644 --- a/src/main/java/com/example/mohago_nocar/transit/application/mapper/TransitMapper.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java @@ -1,7 +1,8 @@ -package com.example.mohago_nocar.transit.application.mapper; +package com.example.mohago_nocar.transit.domain.converter; +import com.example.mohago_nocar.global.common.domain.vo.Location; import com.example.mohago_nocar.transit.domain.model.*; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.RouteResponseDto; +import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response.OdsayRouteResponse; import com.fasterxml.jackson.databind.JsonNode; import lombok.extern.slf4j.Slf4j; @@ -10,19 +11,66 @@ import java.util.stream.StreamSupport; @Slf4j -public class TransitMapper { +public class TransitRouteConverter { + + private static final int SUBWAY = 1; + private static final int BUS = 2; + private static final int WALKING = 3; + private static final int EARTH_RADIUS = 6371; + private static final double METER_TO_KILOMETER = 0.001; + + public static TransitRoute convertRouteResponseDtoToTransitRoute( + OdsayRouteResponse routeResponseDto, Location origin, Location destination) { + if (routeResponseDto.isTooShortDistance()) { + return createWalkingRoute(origin, destination); + } - public static TransitInfo mapRouteResponseDtoToTransitInfo(RouteResponseDto routeResponseDto) { JsonNode path = extractPath(routeResponseDto); - double totalDistance = extractTotalDistance(path); int totalTime = extractTotalTime(path); List subPaths = extractSubPaths(path); - return TransitInfo.from(totalTime, totalDistance, subPaths); + return TransitRoute.from(totalTime, totalDistance, subPaths); + } + + private static TransitRoute createWalkingRoute(Location origin, Location destination) { + double walkingDistance = getKmDist(origin, destination); + int walkingTime = (int) Math.round(walkingDistance * 15); + WalkPath walkPath = new WalkPath(walkingDistance, walkingTime); + + return TransitRoute.from(walkingTime, walkingDistance, List.of(walkPath)); + } + + + /** + * 두 위치(Location) 사이의 거리를 킬로미터 단위로 계산합니다. + * @param departure + * @param arrival + * @return 두 위치(Location) 사이의 거리 + */ + private static Double getKmDist(Location departure, Location arrival) { + Double dx = Math.abs(departure.getLongitude() - arrival.getLongitude()); + dx = Math.min(dx, 360 - dx); + + Double dy = Math.abs(departure.getLatitude() - arrival.getLatitude()); + + Double longitudeDist = convertLongitudeToKmDist(dx, departure.getLatitude()); + Double latitudeDist = convertLatitudeToKmDist(dy); + + return Math.sqrt(longitudeDist * longitudeDist + latitudeDist * latitudeDist); + } + + private static Double convertLongitudeToKmDist(Double dx, Double stdLatitude) { + + return EARTH_RADIUS * dx * Math.cos(stdLatitude) * Math.PI / 180; } - private static JsonNode extractPath(RouteResponseDto routeResponseDto) { + private static Double convertLatitudeToKmDist(Double dy) { + + return EARTH_RADIUS * dy * Math.PI / 180; + } + + private static JsonNode extractPath(OdsayRouteResponse routeResponseDto) { return routeResponseDto.result().get("path").get(0); } @@ -30,7 +78,7 @@ private static double extractTotalDistance(JsonNode path) { JsonNode infoNode = path.get("info"); double distanceInMeters = infoNode.get("totalDistance").asDouble(); - return distanceInMeters * 0.001; + return distanceInMeters * METER_TO_KILOMETER; } private static int extractTotalTime(JsonNode path) { @@ -46,7 +94,7 @@ private static List extractSubPaths(JsonNode path) { private static List convertPathesNodeToSubPaths(JsonNode subPathsNode) { return streamJsonNodeOrEmpty(subPathsNode) - .map(TransitMapper::convertPathNodeToSubPath) + .map(TransitRouteConverter::convertPathNodeToSubPath) .toList(); } @@ -56,9 +104,9 @@ private static SubPath convertPathNodeToSubPath(JsonNode subPathNode) { int trafficType = subPathNode.get("trafficType").asInt(); return switch (trafficType) { - case 1 -> createSubwayPath(distance, sectionTime, subPathNode); - case 2 -> createBusPath(distance, sectionTime, subPathNode); - case 3 -> createWalkPath(distance, sectionTime); + case SUBWAY -> createSubwayPath(distance, sectionTime, subPathNode); + case BUS -> createBusPath(distance, sectionTime, subPathNode); + case WALKING -> createWalkPath(distance, sectionTime); default -> null; }; } @@ -125,4 +173,5 @@ private static Stream streamJsonNodeOrEmpty(JsonNode node) { } return StreamSupport.stream(node.spliterator(), false); } + } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitInfo.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java similarity index 75% rename from src/main/java/com/example/mohago_nocar/transit/domain/model/TransitInfo.java rename to src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java index f5408f7..0710e13 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitInfo.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java @@ -2,19 +2,18 @@ import lombok.Builder; import lombok.Getter; -import lombok.Setter; import java.util.List; @Getter -public class TransitInfo { +public class TransitRoute { private final int totalTime; private final double totalDistance; private final List subPaths; - public static TransitInfo from(int totalTime, double totalDistance, List subPaths) { - return TransitInfo.builder() + public static TransitRoute from(int totalTime, double totalDistance, List subPaths) { + return TransitRoute.builder() .totalTime(totalTime) .totalDistance(totalDistance) .subPaths(subPaths) @@ -22,7 +21,7 @@ public static TransitInfo from(int totalTime, double totalDistance, List subPaths) { + private TransitRoute(int totalTime, double totalDistance, List subPaths) { this.totalTime = totalTime; this.totalDistance = totalDistance; this.subPaths = subPaths; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java index 20b511c..28aa83e 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java @@ -1,7 +1,7 @@ package com.example.mohago_nocar.transit.infrastructure.error.code; import com.example.mohago_nocar.global.common.exception.Status; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayException; +import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayServerException; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; @@ -24,7 +24,10 @@ public enum OdsayErrorCode implements Status { ARRIVAL_POINT_MISSING(HttpStatus.NOT_FOUND, "ODSAY404", "도착지 정류장이 없습니다."), START_ARRIVAL_POINTS_MISSING(HttpStatus.NOT_FOUND, "ODSAY404", "출발지, 도착지 정류장이 없습니다."), SERVICE_AREA_NOT_AVAILABLE(HttpStatus.NOT_FOUND, "ODSAY404", "서비스 지역이 아닙니다."), - NO_SEARCH_RESULTS(HttpStatus.NOT_FOUND, "ODSAY404", "길찾기 검색결과가 없습니다.") + NO_SEARCH_RESULTS(HttpStatus.NOT_FOUND, "ODSAY404", "길찾기 검색결과가 없습니다."), + + // TOO_MANY_REQUESTS + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "ODSAY429", "Too Many Requests"), ; private final HttpStatus httpStatus; @@ -43,11 +46,21 @@ public static OdsayErrorCode from(String code) { case "-98" -> POINTS_WITHIN_DISTANCE; case "-99" -> NO_SEARCH_RESULTS; case "-1" -> COMPONENT_ERROR; - default -> throw new OdsayException("unknown Error Code 발생 : "+ code, ODSAY_SERVER_ERROR); + case "429" -> TOO_MANY_REQUESTS; + default -> throw new OdsayServerException("unknown Error Code 발생 : "+ code, ODSAY_SERVER_ERROR); }; } - public boolean isDistanceException() { + public boolean isDistanceError() { return this == POINTS_WITHIN_DISTANCE; } + + public boolean isServerError() { + return this == ODSAY_SERVER_ERROR; + } + + public boolean isTooManyRequests() { + return this == TOO_MANY_REQUESTS; + } + } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayDistanceException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayBadRequestException.java similarity index 56% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayDistanceException.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayBadRequestException.java index 63f5c91..eebfbaf 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayDistanceException.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayBadRequestException.java @@ -1,12 +1,13 @@ package com.example.mohago_nocar.transit.infrastructure.error.exception; import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.Status; import static com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode.POINTS_WITHIN_DISTANCE; -public class OdsayDistanceException extends CustomException { +public class OdsayBadRequestException extends CustomException { - public OdsayDistanceException() { - super(POINTS_WITHIN_DISTANCE); + public OdsayBadRequestException(Status status) { + super(status); } } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayException.java deleted file mode 100644 index 5562b47..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.error.exception; - -import com.example.mohago_nocar.global.common.exception.CustomException; -import com.example.mohago_nocar.global.common.exception.Status; - -public class OdsayException extends CustomException { - - public OdsayException(Status status) { - super(status); - } - - public OdsayException(String message, Status status) { - super(message, status); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayServerException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayServerException.java new file mode 100644 index 0000000..363bc30 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayServerException.java @@ -0,0 +1,16 @@ +package com.example.mohago_nocar.transit.infrastructure.error.exception; + +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.Status; +import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; + +public class OdsayServerException extends CustomException { + + public OdsayServerException() { + super(OdsayErrorCode.ODSAY_SERVER_ERROR); + } + + public OdsayServerException(String message, Status status) { + super(message, status); + } +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayTooManyRequestsException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayTooManyRequestsException.java new file mode 100644 index 0000000..fa6a623 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayTooManyRequestsException.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.transit.infrastructure.error.exception; + +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.Status; +import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; + +public class OdsayTooManyRequestsException extends CustomException { + + public OdsayTooManyRequestsException() { + super(OdsayErrorCode.TOO_MANY_REQUESTS); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/ODsayApiClient.java deleted file mode 100644 index 2703c25..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/ODsayApiClient.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi; - -import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayException; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.OdsaySearchRouteResponseDto; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.RouteResponseDto; -import com.example.mohago_nocar.transit.infrastructure.mapper.OdsayRouteMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.springframework.web.util.UriComponentsBuilder; -import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URLEncoder; -import java.util.Objects; - -import static com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode.ODSAY_SERVER_ERROR; - -@Component -public class ODsayApiClient { - - private final RestClient restClient; - private final String apiKey; - private final String baseUrl; - - public static final int SLEEP_DURATION_MS = 200; - - public ODsayApiClient( - RestClient.Builder restClientBuilder, - @Value("${odsay.api-key}") String apiKey, - @Value("${odsay.url}") String baseUrl) { - this.restClient = restClientBuilder.build(); - this.apiKey = apiKey; - this.baseUrl = baseUrl; - } - - public RouteResponseDto searchRoute(double startX, double startY, double endX, double endY) { - URI requestURI = buildRequestURI(startX, startY, endX, endY); - OdsaySearchRouteResponseDto odsayRouteResponse = fetchOdsayRouteResponse(requestURI); - - return OdsayRouteMapper.mapOdsayRouteResponseToRouteResponse(odsayRouteResponse); - } - - private URI buildRequestURI(double startX, double startY, double endX, double endY) { - String encodedApiKey = createEncodedApiKey(); - - return UriComponentsBuilder.fromUriString(baseUrl) - .queryParam("SX", startX) - .queryParam("SY", startY) - .queryParam("EX", endX) - .queryParam("EY", endY) - .queryParam("apiKey", encodedApiKey) - .build(true) - .toUri(); - } - - private String createEncodedApiKey() { - try { - return URLEncoder.encode(apiKey, "UTF-8"); - - } catch (UnsupportedEncodingException e) { - throw new OdsayException(e.getMessage(), ODSAY_SERVER_ERROR); - } - } - - private OdsaySearchRouteResponseDto fetchOdsayRouteResponse(URI requestURI) { - try { - Thread.sleep(SLEEP_DURATION_MS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new OdsayException("Thread interrupted while sleeping", ODSAY_SERVER_ERROR); - } - - OdsaySearchRouteResponseDto response = restClient.get() - .uri(requestURI) - .retrieve() - .body(OdsaySearchRouteResponseDto.class); - - return Objects.requireNonNullElseGet(response, () -> { - throw new OdsayException(ODSAY_SERVER_ERROR); - }); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/OdsaySearchRouteResponseDto.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/OdsaySearchRouteResponseDto.java deleted file mode 100644 index bd86836..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/OdsaySearchRouteResponseDto.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response; - -import com.fasterxml.jackson.databind.JsonNode; - -import java.util.Optional; - -public record OdsaySearchRouteResponseDto( - Optional error, - Optional result -) { -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/RouteResponseDto.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/RouteResponseDto.java deleted file mode 100644 index 77d002f..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/RouteResponseDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response; - -import com.fasterxml.jackson.databind.JsonNode; -import lombok.Builder; - -@Builder -public record RouteResponseDto( - JsonNode result -) { - public static RouteResponseDto of(JsonNode result) { - return RouteResponseDto.builder() - .result(result) - .build(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiClient.java new file mode 100644 index 0000000..f6794da --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiClient.java @@ -0,0 +1,124 @@ +package com.example.mohago_nocar.transit.infrastructure.externalApi.odsay; + +import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayServerException; +import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayTooManyRequestsException; +import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response.OdsayRouteResponse; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLEncoder; +import java.util.concurrent.*; + +import static com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode.ODSAY_SERVER_ERROR; + +/** + * ODsay API 클라이언트: 대중교통 경로 검색 요청을 처리하고 결과를 반환합니다. + *
  • 요청 빈도를 제어하기 위해 세마포어와 스케줄러 사용
  • + *
  • 재시도를 위해 RetryTemplate 활용
  • + */ +@Component +@Slf4j +public class ODsayApiClient { + + private static final int MAX_ATTEMPTS = 20; + private static final int MIN_INTERVAL_MS = 170; + private static final int PERMIT_THREAD_SIZE = 1; + private static final boolean ENABLE_FIFO_ORDERING = true; + + private final RestClient restClient; + private final String apiKey; + private final String baseUrl; + private final RetryTemplate retryTemplate; + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final Semaphore semaphore; + + public ODsayApiClient( + RestClient.Builder restClientBuilder, + @Value("${odsay.api-key}") String apiKey, + @Value("${odsay.url}") String baseUrl) { + this.restClient = restClientBuilder.build(); + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.retryTemplate = createRetryTemplate(); + this.semaphore = new Semaphore(PERMIT_THREAD_SIZE, ENABLE_FIFO_ORDERING); + } + + public OdsayRouteResponse searchTransitRoute(double startX, double startY, double endX, double endY) { + URI requestURI = buildRequestURI(startX, startY, endX, endY); + return executeWithRetry(requestURI); + } + + private URI buildRequestURI(double startX, double startY, double endX, double endY) { + String encodedApiKey = createEncodedApiKey(); + + return UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("SX", startX) + .queryParam("SY", startY) + .queryParam("EX", endX) + .queryParam("EY", endY) + .queryParam("apiKey", encodedApiKey) + .build(true) + .toUri(); + } + + private String createEncodedApiKey() { + try { + return URLEncoder.encode(apiKey, "UTF-8"); + + } catch (UnsupportedEncodingException e) { + throw new OdsayServerException(e.getMessage(), ODSAY_SERVER_ERROR); + } + } + + private OdsayRouteResponse executeWithRetry(URI requestURI) { + + try { + return retryTemplate.execute(retryContext -> callTransitRouteAPI(requestURI)); + } catch (InterruptedException e) { + log.error("ODsay API 호출 중 인터럽트 발생: {}. 요청 URI: {}", e.getMessage(), requestURI); + throw new RuntimeException(e.getMessage()); + } + } + + private OdsayRouteResponse callTransitRouteAPI(URI requestURI) throws InterruptedException { + semaphore.acquire(); + + try { + return restClient.get() + .uri(requestURI) + .retrieve() + .body(OdsayRouteResponse.class); + } finally { + scheduler.schedule(() -> semaphore.release(), MIN_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + } + + private RetryTemplate createRetryTemplate() { + return RetryTemplate.builder() + .maxAttempts(MAX_ATTEMPTS) + .noBackoff() + .retryOn(OdsayTooManyRequestsException.class) + .build(); + } + + @PreDestroy + public void destroy() { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + scheduler.shutdownNow(); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiResponseDeserializer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiResponseDeserializer.java new file mode 100644 index 0000000..bd1c0c8 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiResponseDeserializer.java @@ -0,0 +1,121 @@ +package com.example.mohago_nocar.transit.infrastructure.externalApi.odsay; + +import com.example.mohago_nocar.global.common.exception.InternalServerException; +import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; +import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayBadRequestException; +import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayServerException; +import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayTooManyRequestsException; +import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response.OdsayRouteResponse; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.jackson.JsonComponent; +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; + +@JsonComponent +@Slf4j +public class ODsayApiResponseDeserializer extends JsonDeserializer { + + private static final boolean TOO_SHORT_DISTANCE = true; + private static final boolean NORMAL_DISTANCE = false; + + @Override + public OdsayRouteResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) { + try { + JsonNode response = parseJsonResponse(jsonParser); + return processResponse(response); + } catch (IOException exception) { + log.error("IOException 발생"); + throw new InternalServerException(exception.getMessage()); + } + } + + private OdsayRouteResponse processResponse(JsonNode response) { + return ResponseValidator.hasError(response) ? + handleErrorResponse(response) : + handleSuccessResponse(response); + } + + private OdsayRouteResponse handleErrorResponse(JsonNode response) { + OdsayErrorCode errorCode = extractErrorCode(response); + + if (errorCode.isServerError()) { + log.error("ODsay API server error :{}", errorCode.getMessage()); + throw new OdsayServerException(); + } + + if (errorCode.isDistanceError()) { + return createTooShortDistanceResponse(); + } + + if (errorCode.isTooManyRequests()) { + throw new OdsayTooManyRequestsException(); + } + + log.error("ODsay API bad request error :{}", errorCode.getMessage()); + throw new OdsayBadRequestException(errorCode); + } + + private OdsayRouteResponse handleSuccessResponse(JsonNode response) { + return createSuccessResponse(response); + } + + /** + * 출, 도착지 사이 거리가 700m 이내여서 발생하는 에러를 성공 응답으로 변환합니다. + */ + private OdsayRouteResponse createTooShortDistanceResponse() { + return OdsayRouteResponse.of(null, TOO_SHORT_DISTANCE); + } + + private OdsayRouteResponse createSuccessResponse(JsonNode node) { + Optional result = find(node, "result"); + + if (result.isEmpty()) { + log.error("[ODsay] result 바인딩을 실패하였습니다."); + log.error("ODsay API response : {}", node.toPrettyString()); + throw new InternalServerException("ODsay API result 바인딩에 실패하였습니다."); + } + + return OdsayRouteResponse.of(result.get(), NORMAL_DISTANCE); + } + + private JsonNode parseJsonResponse(JsonParser jsonParser) throws IOException { + return jsonParser.getCodec().readTree(jsonParser); + } + + private OdsayErrorCode extractErrorCode(JsonNode response) { + JsonNode errorResponse = find(response, "error").get(); + + String errorCode = extractErrorInfo(errorResponse, "code"); + String errorMessage = extractErrorInfo(errorResponse, "message", "msg"); + OdsayErrorCode odsayErrorCode = OdsayErrorCode.from(errorCode); + + log.warn("ODsay errorMessage: {}", errorMessage); + log.warn("ODsay API returns error response : {}", odsayErrorCode); + + return odsayErrorCode; + } + + private String extractErrorInfo(JsonNode errorNode, String... fields) { + return Arrays.stream(fields) + .map(errorNode::findPath) + .filter(node -> !node.isMissingNode()) + .findFirst() + .map(JsonNode::asText) + .orElse(null); + } + + private Optional find(JsonNode node, String fieldName) { + try { + JsonNode result = node.get(fieldName); + return Optional.of(result); + } catch (NullPointerException exception) { + return Optional.empty(); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ResponseValidator.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ResponseValidator.java new file mode 100644 index 0000000..a9635a3 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ResponseValidator.java @@ -0,0 +1,22 @@ +package com.example.mohago_nocar.transit.infrastructure.externalApi.odsay; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.Optional; + +public class ResponseValidator { + + public static boolean hasError(JsonNode response) { + return find(response, "error").isPresent(); + } + + private static Optional find(JsonNode node, String fieldName) { + try { + JsonNode result = node.get(fieldName); + return Optional.of(result); + } catch (NullPointerException exception) { + return Optional.empty(); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/dto/response/OdsayRouteResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/dto/response/OdsayRouteResponse.java new file mode 100644 index 0000000..240763c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/dto/response/OdsayRouteResponse.java @@ -0,0 +1,19 @@ +package com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Builder; + +@Builder +public record OdsayRouteResponse( + JsonNode result, + boolean isTooShortDistance +) { + + public static OdsayRouteResponse of(JsonNode result, boolean isTooShortDistance) { + return OdsayRouteResponse.builder() + .result(result) + .isTooShortDistance(isTooShortDistance) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayResponseDeserializer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayResponseDeserializer.java deleted file mode 100644 index fc6a1aa..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayResponseDeserializer.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.mapper; - -import com.example.mohago_nocar.global.common.exception.InternalServerException; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.OdsaySearchRouteResponseDto; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.jackson.JsonComponent; -import java.io.IOException; -import java.util.Optional; - -@JsonComponent -@Slf4j -public class OdsayResponseDeserializer extends JsonDeserializer { - - @Override - public OdsaySearchRouteResponseDto deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) { - try { - JsonNode node = jsonParser.getCodec().readTree(jsonParser); - - return parse(node); - } catch (IOException exception) { - log.error("IOException 발생"); - throw new InternalServerException(exception.getMessage()); - } - } - - private OdsaySearchRouteResponseDto parse(JsonNode node) { - Optional errorNode = find(node, "error"); - Optional result = find(node, "result"); - - return new OdsaySearchRouteResponseDto(errorNode, result); - } - - private Optional find(JsonNode node, String fieldName) { - try { - JsonNode result = node.get(fieldName); - return Optional.of(result); - } catch (NullPointerException exception) { - return Optional.empty(); - } - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayRouteMapper.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayRouteMapper.java deleted file mode 100644 index 98bf99c..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayRouteMapper.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.mapper; - -import com.example.mohago_nocar.global.common.exception.InternalServerException; -import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayDistanceException; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayException; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.OdsaySearchRouteResponseDto; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.RouteResponseDto; -import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; - -import java.util.Arrays; - - -@Slf4j -public class OdsayRouteMapper { - - public static RouteResponseDto mapOdsayRouteResponseToRouteResponse(OdsaySearchRouteResponseDto response) { - checkForOdsayErrors(response); - - return response.result() - .map(RouteResponseDto::of) - .orElseThrow(() -> new InternalServerException("ODsay API Result is missing")); - } - - private static void checkForOdsayErrors(OdsaySearchRouteResponseDto response) { - response.error().ifPresent(errorNode -> { - String errorCode = extractErrorInfo(errorNode, "code"); - String errorMessage = extractErrorInfo(errorNode, "message", "msg"); - - if (errorNode == null) { - log.error("에러 응답 바인딩을 실패하였습니다."); - log.warn("error : {}", errorNode.toPrettyString()); - throw new InternalServerException("ODsay API error 처리에 실패하였습니다."); - } - - OdsayErrorCode odsayErrorCode = OdsayErrorCode.from(errorCode); - - log.info("ODsay errorMessage: {}", errorMessage); - log.warn("ODsay API returns error response : {}", odsayErrorCode); - - if (odsayErrorCode.isDistanceException()) { - throw new OdsayDistanceException(); - } - - throw new OdsayException(odsayErrorCode); - }); - } - - private static String extractErrorInfo(JsonNode errorNode, String... fields) { - return Arrays.stream(fields) - .map(errorNode::findPath) - .filter(node -> !node.isMissingNode()) - .findFirst() - .map(JsonNode::asText) - .orElse(null); - } -} From 678f193e87d228df0f1981370dc974b5230e93ba Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 29 Jan 2025 02:35:58 +0900 Subject: [PATCH 06/84] =?UTF-8?q?fix:=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20API=20=ED=98=B8=EC=B6=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 경위도가 같은 장소에 대한 API 중복 호출 방지 --- .../plan/application/TravelPlanService.java | 348 +++++++++++------- .../plan/domain/model/TravelCatalog.java | 63 ++++ .../domain/model/TravelLocationWithName.java | 35 ++ .../domain/service/TravelPlanUseCase.java | 1 + .../application/service/TransitService.java | 29 -- .../domain/service/TransitUseCase.java | 9 - 6 files changed, 315 insertions(+), 170 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/plan/domain/model/TravelCatalog.java create mode 100644 src/main/java/com/example/mohago_nocar/plan/domain/model/TravelLocationWithName.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/application/service/TransitService.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/domain/service/TransitUseCase.java diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java index 2c5f677..6578d6a 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java @@ -3,24 +3,32 @@ import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.festival.domain.repository.FestivalRepository; import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.exception.InternalServerException; import com.example.mohago_nocar.global.common.exception.InvalidValueException; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceRepository; +import com.example.mohago_nocar.place.application.PlaceService; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.repository.PlaceRepository; +import com.example.mohago_nocar.plan.domain.model.TravelLocationWithName; +import com.example.mohago_nocar.plan.domain.model.TravelCatalog; import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; import com.example.mohago_nocar.plan.presentation.response.PlanTravelCourseResponseDto; -import com.example.mohago_nocar.transit.domain.model.TransitInfo; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; import com.example.mohago_nocar.transit.domain.model.WalkPath; -import com.example.mohago_nocar.transit.domain.service.TransitUseCase; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayDistanceException; +import com.example.mohago_nocar.transit.infrastructure.externalApi.google.GoogleApiClient; +import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.GoogleDistanceMatrixResponse; +import com.example.mohago_nocar.transit.infrastructure.externalApi.converter.DistanceMatrixConverter; +import com.example.mohago_nocar.transit.domain.converter.TransitRouteConverter; +import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.RouteSpecification; +import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.ODsayApiClient; +import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response.OdsayRouteResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.LocalDate; import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; +import java.util.stream.IntStream; import static com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode.TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD; @@ -29,83 +37,96 @@ @Slf4j public class TravelPlanService implements TravelPlanUseCase { - private final FestivalNearPlaceRepository festivalNearPlaceRepository; + private static final int FIRST = 0; + + private final PlaceRepository placeRepository; private final FestivalRepository festivalRepository; - private final TransitUseCase transitUseCase; + private final PlaceService placeService; + private final GoogleApiClient googleApiClient; + private final ODsayApiClient oDsayApiClient; - private static final int EARTH_RADIUS = 6371; + @Override + public List planCourse(PlanTravelCourseRequestDto dto) { + Festival festival = validateAndGetFestival(dto); + List places = getTravelPlaces(festival, dto.placeIds()); - private int calcTravelTime(List route, Map> transitMaps) { - int n = route.size(); + TravelCatalog travelCatalog = TravelCatalog.of(festival, places); + List distinctLocations = travelCatalog.getDistinctLocations(); - int travelTime = 0; - for (int i = 0; i < n - 1; i++) { - travelTime += transitMaps.get(route.get(i)).get(route.get(i + 1)).getTotalTime(); - } - return travelTime; - } + List routeSpecifications = fetchDistanceAndDurationBetween(distinctLocations); + List optimizedRoute = getOptimalRoute(distinctLocations, routeSpecifications); - private void routeBacktracking(int k, List locations, Map> transitMaps, - List optimal, List route, List isSelected) { - int n = locations.size(); + List travelCourse = createTravelCourse(optimizedRoute, travelCatalog); + log.info(travelCourse.toString()); + return travelCourse; + } - if (k == n) - { - int t1 = calcTravelTime(optimal, transitMaps); - int t2 = calcTravelTime(route, transitMaps); + private Festival validateAndGetFestival(PlanTravelCourseRequestDto dto) { + Festival festival = festivalRepository.getFestivalById(dto.festivalId()); + ensureTravelDateDuringFestival(festival, dto.travelDate()); + return festival; + } - if (t1 > t2) { - for (int i = 0; i < n; i++) { - optimal.set(i, route.get(i)); - } - } - return; + private void ensureTravelDateDuringFestival(Festival festival, LocalDate travelDate) { + if (!festival.isDateDuringFestival(travelDate)) { + throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); } + } - for (int i = 0; i < n; i++) { - if (isSelected.get(i)) { - continue; - } - - isSelected.set(i, true); - route.add(locations.get(i)); - routeBacktracking(k + 1, locations, transitMaps, optimal, route, isSelected); - route.removeLast(); - isSelected.set(i, false); + private List getTravelPlaces(Festival festival, List placeIds) { + List places = placeRepository.findByIds(festival.getId(), placeIds); + if (places.isEmpty()) { + places = placeService.cachePlaces(festival.getId(), festival.getLocation()).stream() + .filter(place -> placeIds.contains(place.getId())) + .toList(); } + return places; } - private Double convertLongitudeToKmDist(Double dx, Double stdLatitude) { + private List fetchDistanceAndDurationBetween(List distinctLocations) { + List routeSpecs = new ArrayList<>(); + int toVisitSize = distinctLocations.size(); - return EARTH_RADIUS * dx * Math.cos(stdLatitude) * Math.PI / 180; - } + for (int originIndex = FIRST; originIndex < toVisitSize; originIndex++) { + Location origin = distinctLocations.get(originIndex); + List destinations = createDestination(distinctLocations, originIndex); - private Double convertLatitudeToKmDist(Double dy) { + GoogleDistanceMatrixResponse matrix = googleApiClient.getDistanceMatrix(origin, destinations); + routeSpecs.addAll( + DistanceMatrixConverter.convertMatrixToRouteSpecs(matrix, toVisitSize -1, origin, destinations)); + } - return EARTH_RADIUS * dy * Math.PI / 180; + return routeSpecs; + } + + private List createDestination(List distinctLocations, int excludeIndex) { + return IntStream.range(0, distinctLocations.size()) + .filter(index -> index != excludeIndex) + .mapToObj(distinctLocations::get) + .toList(); } - private List getOptimalRoute(List allLocations) { - int locationCount = allLocations.size(); - Map> fromToTransitInfoMap = new HashMap<>(); + private List getOptimalRoute(List distinctLocations, List routeSpecificationBetweenLocations) { + int locationCount = distinctLocations.size(); + Map> fromToTransitInfoMap = new HashMap<>(); - for (int fromIndex = 0; fromIndex < locationCount; fromIndex++) { - Map toLocationTransitInfoMap = new HashMap<>(); + for (int originIndex = FIRST; originIndex < locationCount; originIndex++) { + Map toLocationTransitInfoMap = new HashMap<>(); - for (int toIndex = 0; toIndex < locationCount; toIndex++) { + for (int destinationIndex = FIRST; destinationIndex < locationCount; destinationIndex++) { - if (fromIndex == toIndex) { + if (originIndex == destinationIndex) { continue; } - Location fromLocation = allLocations.get(fromIndex); - Location toLocation = allLocations.get(toIndex); + Location origin = distinctLocations.get(originIndex); + Location destination = distinctLocations.get(destinationIndex); - TransitInfo transitInfo = getTransitInfoBetweenLocations(fromLocation, toLocation); - toLocationTransitInfoMap.put(toLocation, transitInfo); + RouteSpecification routeSpec = getMatchedRouteSpec(origin, destination, routeSpecificationBetweenLocations); + toLocationTransitInfoMap.put(destination, routeSpec); } - fromToTransitInfoMap.put(allLocations.get(fromIndex), toLocationTransitInfoMap); + fromToTransitInfoMap.put(distinctLocations.get(originIndex), toLocationTransitInfoMap); } List route = new ArrayList<>(); @@ -113,117 +134,180 @@ private List getOptimalRoute(List allLocations) { List optimalRoute = new ArrayList<>(); for (int i = 0; i < locationCount; i++) { isSelected.add(false); - optimalRoute.add(allLocations.get(i)); + optimalRoute.add(distinctLocations.get(i)); } - routeBacktracking(0, allLocations, fromToTransitInfoMap , optimalRoute, route, isSelected); + routeBacktracking(0, distinctLocations, fromToTransitInfoMap , optimalRoute, route, isSelected); return optimalRoute; } - @Override - public List planCourse(PlanTravelCourseRequestDto dto) { - Festival festival = validateAndGetFestival(dto); - List travelPlaces = getTravelPlaces(dto.travelPlaceIds()); - - List allLocations = combineLocations(festival, travelPlaces); - List optimizedRoute = getOptimalRoute(allLocations); - - Map locationNameInfo = createLocationNameMap(festival, optimizedRoute); + private RouteSpecification getMatchedRouteSpec( + Location origin, + Location destination, + List routeSpecificationBetweenLocations + ) { + Optional routeSpecification = routeSpecificationBetweenLocations.stream() + .filter(route -> route.isEqualLocation(origin, destination)) + .findFirst(); + + if (routeSpecification.isEmpty()) { + log.error("origin-{}, destination-{}를 가지는 RouteSpec을 찾을 수 없습니다.", origin, destination); + throw new InternalServerException(); + } - return createTravelCourse(optimizedRoute, locationNameInfo); + return routeSpecification.get(); } - private Festival validateAndGetFestival(PlanTravelCourseRequestDto dto) { - Festival festival = festivalRepository.getFestivalById(dto.festivalId()); - ensureTravelDateDuringFestival(festival, dto.travelDate()); - return festival; - } + private void routeBacktracking( + int k, + List locations, + Map> transitMaps, + List optimal, + List route, + List isSelected + ) { + int n = locations.size(); - private void ensureTravelDateDuringFestival(Festival festival, LocalDate travelDate) { - if (!festival.isDateDuringFestival(travelDate)) { - throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); + if (k == n) + { + int t1 = calcTravelTime(optimal, transitMaps); + int t2 = calcTravelTime(route, transitMaps); + + if (t1 > t2) { + for (int i = 0; i < n; i++) { + optimal.set(i, route.get(i)); + } + } + return; } - } - private List getTravelPlaces(List placeIds) { - return placeIds.stream() - .map(festivalNearPlaceRepository::findById) - .toList(); - } + for (int i = 0; i < n; i++) { + if (isSelected.get(i)) { + continue; + } - private List combineLocations(Festival festival, List travelPlaces) { - return Stream.concat( - Stream.of(festival.getLocation()), - travelPlaces.stream().map(FestivalNearPlace::getLocation) - ).toList(); + isSelected.set(i, true); + route.add(locations.get(i)); + routeBacktracking(k + 1, locations, transitMaps, optimal, route, isSelected); + route.removeLast(); + isSelected.set(i, false); + } } - private Map createLocationNameMap(Festival festival, List locations) { - return locations.stream() - .collect(Collectors.toMap( - location -> location, - location -> getPlaceName(festival, location) - )); - } + private int calcTravelTime(List route, Map> routeMaps) { + int n = route.size(); - private String getPlaceName(Festival festival, Location location) { - if (location.equals(festival.getLocation())) { - return festival.getName(); + int travelTime = 0; + for (int i = 0; i < n - 1; i++) { + travelTime += routeMaps.get(route.get(i)).get(route.get(i + 1)).durationInMinutes(); } - - return festivalNearPlaceRepository.getPlaceNameByLocation(location); + return travelTime; } - private List createTravelCourse(List optimizedRoute, Map locationNameInfo) { + + private List createTravelCourse( + List optimizedRoute, + TravelCatalog travelCatalog + ) { + Map locationNameMap = travelCatalog.createLocationNameMap(); List travelCourse = new ArrayList<>(); - for (int i = 0; i < optimizedRoute.size() - 1; i++) { - Location fromLocation = optimizedRoute.get(i); - Location toLocation = optimizedRoute.get(i + 1); + for (int index = FIRST; index < optimizedRoute.size() - 1; index++) { + Location origin = optimizedRoute.get(index); + Location destination = optimizedRoute.get(index + 1); - String fromName = locationNameInfo.get(fromLocation); - String toName = locationNameInfo.get(toLocation); + String originName = handleDuplicateOrigins(origin, travelCourse, locationNameMap, travelCatalog); + String destinationName = locationNameMap.get(destination); - TransitInfo transitInfo = getTransitInfoBetweenLocations(fromLocation, toLocation); + TransitRoute transitRoute = getTransitRouteBetweenLocations(origin, destination); + travelCourse.add(createResponse(origin, originName, destination, destinationName, transitRoute)); - travelCourse.add(createResponseDto(fromLocation, fromName, toLocation, toName, transitInfo)); + handleDuplicateDestinations(travelCatalog, travelCourse, destination, destinationName); } return travelCourse; } - private TransitInfo getTransitInfoBetweenLocations(Location fromLocation, Location toLocation) { - try { - return transitUseCase.findRouteTransitBetweenPlaces(fromLocation, toLocation); + private String handleDuplicateOrigins( + Location location, + List travelCourse, + Map locationNameMap, + TravelCatalog travelCatalog + ) { + if (travelCatalog.checkDuplicateLocation(location)) { + List duplicatedLocations = travelCatalog.getTravelLocations(location); + List responses = createResponses(duplicatedLocations); + travelCourse.addAll(responses); + return responses.getLast().endPlaceName(); // 마지막 장소 이름 반환 + } + + return locationNameMap.get(location); // 중복이 없으면 기존 이름 반환 + } - } catch (OdsayDistanceException e) { - double dist = getKmDist(fromLocation, toLocation); - int totalTime = (int) Math.round(dist * 15); - WalkPath walkPath = new WalkPath(dist, totalTime); + private TransitRoute getTransitRouteBetweenLocations(Location origin, Location destination) { + OdsayRouteResponse response = oDsayApiClient.searchTransitRoute( + origin.getLongitude(), origin.getLatitude(), destination.getLongitude(), destination.getLatitude()); + return TransitRouteConverter.convertRouteResponseDtoToTransitRoute(response, origin, destination); + } - return TransitInfo.from(totalTime, dist, List.of(walkPath)); + private void handleDuplicateDestinations( + TravelCatalog travelCatalog, List travelCourse, + Location destinationLocation, String destinationName + ) { + if (travelCatalog.checkDuplicateLocation(destinationLocation)) { + List duplicatedLocations = travelCatalog.getTravelLocations(destinationLocation); + List responseDtos = createResponsesStartWith(destinationName, duplicatedLocations); + travelCourse.addAll(responseDtos); } } - /** - * 두 위치(Location) 사이의 거리를 킬로미터 단위로 계산합니다. - * @param startLocation - * @param endLocation - * @return 두 위치(Location) 사이의 거리 - */ - private Double getKmDist(Location startLocation, Location endLocation) { - Double dx = Math.abs(startLocation.getLongitude() - endLocation.getLongitude()); - dx = Math.min(dx, 360 - dx); + private List createResponsesStartWith( + String startName, List duplicatedLocations) { + // 시작 지점 탐색 + int startIndex = -1; + for (int i = 0; i < duplicatedLocations.size(); i++) { + if (duplicatedLocations.get(i).getName().equals(startName)) { + startIndex = i; + break; + } + } - Double dy = Math.abs(startLocation.getLatitude() - endLocation.getLatitude()); + // 시작 지점이 없을 경우 예외 처리 + if (startIndex == -1) { + throw new IllegalArgumentException("Start name not found in locations."); + } - Double longitudeDist = convertLongitudeToKmDist(dx, startLocation.getLatitude()); - Double latitudeDist = convertLatitudeToKmDist(dy); + if (startIndex != 0) { + Collections.swap(duplicatedLocations, 0, startIndex); + } + + return createResponses(duplicatedLocations); + } - return Math.sqrt(longitudeDist * longitudeDist + latitudeDist * latitudeDist); + private List createResponses(List duplicatedLocations) { + List responseBetweenSameLocations = new ArrayList<>(); + + for (int from = 0; from < duplicatedLocations.size() -1; from++) { + PlanTravelCourseResponseDto dto = PlanTravelCourseResponseDto.of( + duplicatedLocations.get(from).getLocation(), + duplicatedLocations.get(from).getName(), + duplicatedLocations.get(from + 1).getLocation(), + duplicatedLocations.get(from + 1).getName(), + TransitRoute.from(0, 0, List.of(new WalkPath((double) 0, 1))) + ); + responseBetweenSameLocations.add(dto); + } + + return responseBetweenSameLocations; } - private PlanTravelCourseResponseDto createResponseDto(Location fromLocation, String fromName, Location toLocation, String toName, TransitInfo transitInfo) { - return PlanTravelCourseResponseDto.of(fromLocation, fromName, toLocation, toName, transitInfo); + private PlanTravelCourseResponseDto createResponse( + Location originLocation, String originName, + Location destinationLocation, String destinationName, + TransitRoute transitRoute + ) { + return PlanTravelCourseResponseDto.of( + originLocation, originName, destinationLocation, destinationName, transitRoute); } + } diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelCatalog.java b/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelCatalog.java new file mode 100644 index 0000000..8a5bb79 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelCatalog.java @@ -0,0 +1,63 @@ +package com.example.mohago_nocar.plan.domain.model; + +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.place.domain.model.Place; +import lombok.Builder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class TravelCatalog { + + private final List travelLocations; + + public static TravelCatalog of(List travelLocations) { + return TravelCatalog.builder() + .travelLocations(travelLocations) + .build(); + } + + public static TravelCatalog of(Festival festival, List places) { + List travelLocations = new ArrayList<>(); + travelLocations.add(TravelLocationWithName.of(festival)); + travelLocations.addAll(places.stream() + .map(TravelLocationWithName::of) + .toList()); + + return TravelCatalog.of(travelLocations); + } + + public List getDistinctLocations() { + return travelLocations.stream() + .map(TravelLocationWithName::getLocation) + .distinct() + .collect(Collectors.toList()); + } + + public boolean checkDuplicateLocation(Location location) { + long locationCount = travelLocations.stream() + .filter(place -> place.getLocation().equals(location)) + .count(); + return locationCount > 1; + } + + public List getTravelLocations(Location location) { + return travelLocations.stream() + .filter(place -> place.getLocation().equals(location)) + .toList(); + } + + public Map createLocationNameMap() { + return this.travelLocations.stream() + .collect(Collectors.toMap(TravelLocationWithName::getLocation, TravelLocationWithName::getName)); + } + + @Builder + private TravelCatalog(List travelLocations) { + this.travelLocations = travelLocations; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelLocationWithName.java b/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelLocationWithName.java new file mode 100644 index 0000000..dc3adfd --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelLocationWithName.java @@ -0,0 +1,35 @@ +package com.example.mohago_nocar.plan.domain.model; + +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.place.domain.model.Place; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class TravelLocationWithName { + + private String name; + private Location location; + + public static TravelLocationWithName of(Festival festival) { + return TravelLocationWithName.builder() + .name(festival.getName()) + .location(festival.getLocation()) + .build(); + } + + public static TravelLocationWithName of(Place place) { + return TravelLocationWithName.builder() + .name(place.getName()) + .location(place.getLocation()) + .build(); + } + + @Builder + private TravelLocationWithName(String name, Location location) { + this.name = name; + this.location = location; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java index 37b881f..c031756 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java @@ -8,4 +8,5 @@ public interface TravelPlanUseCase { List planCourse(PlanTravelCourseRequestDto dto); + } diff --git a/src/main/java/com/example/mohago_nocar/transit/application/service/TransitService.java b/src/main/java/com/example/mohago_nocar/transit/application/service/TransitService.java deleted file mode 100644 index fcc66a2..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/application/service/TransitService.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.mohago_nocar.transit.application.service; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.transit.application.mapper.TransitMapper; -import com.example.mohago_nocar.transit.domain.model.TransitInfo; -import com.example.mohago_nocar.transit.domain.service.TransitUseCase; -import com.example.mohago_nocar.transit.infrastructure.externalApi.ODsayApiClient; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.RouteResponseDto; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class TransitService implements TransitUseCase { - - private final ODsayApiClient oDsayApiClient; - - @Override - public TransitInfo findRouteTransitBetweenPlaces(Location from, Location to) { - RouteResponseDto response = oDsayApiClient.searchRoute( - from.getLongitude(), - from.getLatitude(), - to.getLongitude(), - to.getLatitude() - ); - - return TransitMapper.mapRouteResponseDtoToTransitInfo(response); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitUseCase.java b/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitUseCase.java deleted file mode 100644 index b58fc35..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.mohago_nocar.transit.domain.service; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.transit.domain.model.TransitInfo; - -public interface TransitUseCase { - - TransitInfo findRouteTransitBetweenPlaces(Location from, Location to); -} From 1aed56bcaaeb12446a7b274d9d74eb8f85fe78b7 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 29 Jan 2025 02:37:30 +0900 Subject: [PATCH 07/84] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8C=8C=EC=9D=BC=20=EB=B0=8F=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=9E=91=EC=84=B1=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 +++ .../global/config/EmbeddedRedisConfig.java | 5 +- src/main/resources/application.yml | 47 +++++++++++++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index a1f3162..24a87fb 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,10 @@ java { } configurations { + all { + exclude group: "commons-logging", module: "commons-logging" + exclude group: "org.slf4j", module: "slf4j-simple" + } compileOnly { extendsFrom annotationProcessor } @@ -56,6 +60,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'it.ozimov:embedded-redis:0.7.3' + runtimeOnly 'com.h2database:h2' } tasks.named('test') { diff --git a/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java b/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java index 1ff43f1..eda478b 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java @@ -18,7 +18,10 @@ public class EmbeddedRedisConfig { @PostConstruct public void startEmbeddedRedis() { - redisServer = new RedisServer(port); + redisServer = RedisServer.builder() + .port(port) + .setting("maxmemory 128M") + .build(); redisServer.start(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e7c3a32..36909f8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,11 +2,10 @@ server: port: ${SERVER_PORT:8080} spring: - config: - activate: - on-profile: "dev" application: name: mohago-nocar + profiles: + active: dev datasource: url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mohago_nocar} username: ${DB_USERNAME} @@ -39,5 +38,43 @@ odsay: api-key: ${ODSAY_API_KEY} google: - url : https://places.googleapis.com/v1/ - api-key : ${GOOGLE_API_KEY} \ No newline at end of file + maps: + distance : https://maps.googleapis.com/maps/api/distancematrix/json + api-key : ${GOOGLE_API_KEY} + +kakao: + local: + category: https://dapi.kakao.com/v2/local/search/category + api-key: ${KAKAO_API_KEY} +--- +spring: + config: + activate: + on-profile: dev +--- +spring: + config: + activate: + on-profile: test +# datasource: +# driver-class-name: org.h2.Driver +# url: jdbc:h2:mem:~/mohagoNoCar +# username: sa +# password: +# jpa: +# database-platform: org.hibernate.dialect.H2Dialect +# hibernate: +# ddl-auto: create +# properties: +# hibernate: +# dialect: org.hibernate.dialect.H2Dialect +# show_sql: true +# format_sql: true +# h2: +# console: +# enabled: true +# path: /h2-console + data: + redis: + host: localhost + port: 6378 \ No newline at end of file From 3fb1711d4d836665d8fa220d91e85fe3b6344732 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 29 Jan 2025 02:39:07 +0900 Subject: [PATCH 08/84] =?UTF-8?q?refactor:=20=EC=BB=A8=EB=B2=84=ED=84=B0?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/mohago_nocar/place/application/PlaceService.java | 2 +- .../place/{application => domain}/converter/PlaceConverter.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/main/java/com/example/mohago_nocar/place/{application => domain}/converter/PlaceConverter.java (95%) diff --git a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java index cd4460f..2760912 100644 --- a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java +++ b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java @@ -3,7 +3,7 @@ import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.place.application.converter.PlaceConverter; +import com.example.mohago_nocar.place.domain.converter.PlaceConverter; import com.example.mohago_nocar.place.domain.model.Place; import com.example.mohago_nocar.place.domain.repository.PlaceRepository; import com.example.mohago_nocar.place.domain.service.PlaceUseCase; diff --git a/src/main/java/com/example/mohago_nocar/place/application/converter/PlaceConverter.java b/src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java similarity index 95% rename from src/main/java/com/example/mohago_nocar/place/application/converter/PlaceConverter.java rename to src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java index 1f9b58e..4621f7f 100644 --- a/src/main/java/com/example/mohago_nocar/place/application/converter/PlaceConverter.java +++ b/src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.place.application.converter; +package com.example.mohago_nocar.place.domain.converter; import com.example.mohago_nocar.global.common.domain.vo.Location; import com.example.mohago_nocar.place.domain.model.*; From 5a4c2594e762f7a138535673c04515be953b9df5 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 19 Feb 2025 16:25:41 +0900 Subject: [PATCH 09/84] =?UTF-8?q?fix:=20Location=20->=20Coordinate=20class?= =?UTF-8?q?=20=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradlew.bat | 4 ++-- .../domain/model/routeStep/RouteStep.java | 18 +++++++++--------- .../festival/application/FestivalService.java | 2 +- .../festival/domain/model/Festival.java | 12 ++++++------ .../response/FestivalLocationResponseDto.java | 8 ++++---- .../response/FestivalResponseDto.java | 6 +++--- .../vo/{Location.java => Coordinate.java} | 10 +++++----- .../place/domain/converter/PlaceConverter.java | 4 ++-- .../mohago_nocar/place/domain/model/Place.java | 13 ++++++------- .../{ => response}/NearPlaceResponseDto.java | 6 +++--- .../converter/TransitRouteConverter.java | 1 + .../dto/response/RouteSpecification.java | 12 ++++++------ 12 files changed, 48 insertions(+), 48 deletions(-) rename src/main/java/com/example/mohago_nocar/global/common/domain/vo/{Location.java => Coordinate.java} (54%) rename src/main/java/com/example/mohago_nocar/place/presentation/{ => response}/NearPlaceResponseDto.java (83%) diff --git a/gradlew.bat b/gradlew.bat index 9d21a21..5c644b7 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -49,7 +49,7 @@ echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo coordinate of your Java installation. 1>&2 goto fail @@ -63,7 +63,7 @@ echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo coordinate of your Java installation. 1>&2 goto fail diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java index 701e1d7..f3d8053 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java @@ -1,7 +1,7 @@ package com.example.mohago_nocar.course.domain.model.routeStep; -import com.example.mohago_nocar.global.common.domain.vo.Location; import com.example.mohago_nocar.global.common.domain.BaseEntity; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.global.util.DurationToIntervalConverter; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; @@ -38,7 +38,7 @@ public class RouteStep extends BaseEntity { @AttributeOverride(name = "longitude", column = @Column(name = "start_longitude")), @AttributeOverride(name = "latitude", column = @Column(name = "start_latitude")) }) - private Location startLocation; + private Coordinate startCoordinate; @NotNull @Embedded @@ -46,34 +46,34 @@ public class RouteStep extends BaseEntity { @AttributeOverride(name = "longitude", column = @Column(name = "end_longitude")), @AttributeOverride(name = "latitude", column = @Column(name = "end_latitude")) }) - private Location endLocation; + private Coordinate endCoordinate; @NotNull @Convert(converter = DurationToIntervalConverter.class) private Duration timeTaken; public static RouteStep from(Long courseId, Integer distance, Integer stepOrder, - Location startLocation, Location endLocation, Duration timeTaken + Coordinate startCoordinate, Coordinate endCoordinate, Duration timeTaken ) { return RouteStep.builder() .courseId(courseId) .distance(distance) .stepOrder(stepOrder) - .startLocation(startLocation) - .endLocation(endLocation) + .startCoordinate(startCoordinate) + .endCoordinate(endCoordinate) .timeTaken(timeTaken) .build(); } @Builder private RouteStep(Long courseId, Integer distance, Integer stepOrder, - Location startLocation, Location endLocation, Duration timeTaken + Coordinate startCoordinate, Coordinate endCoordinate, Duration timeTaken ) { this.courseId = courseId; this.distance = distance; this.stepOrder = stepOrder; - this.startLocation = startLocation; - this.endLocation = endLocation; + this.startCoordinate = startCoordinate; + this.endCoordinate = endCoordinate; this.timeTaken = timeTaken; } } diff --git a/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java b/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java index 4b94e95..163faa9 100644 --- a/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java +++ b/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java @@ -42,7 +42,7 @@ public PagedResponseDto fetchFestivals(Pageable pageable) { public FestivalLocationResponseDto getFestivalLocation(Long festivalId) { Festival festival = festivalRepository.getFestivalById(festivalId); - return FestivalLocationResponseDto.of(festival.getLocation()); + return FestivalLocationResponseDto.of(festival.getCoordinate()); } @Override diff --git a/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java b/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java index f15a337..b5aa311 100644 --- a/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java +++ b/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java @@ -2,7 +2,7 @@ import com.example.mohago_nocar.festival.domain.model.vo.ActivePeriod; import com.example.mohago_nocar.global.common.domain.BaseEntity; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.*; @@ -37,25 +37,25 @@ public class Festival extends BaseEntity { @NotNull @Embedded - private Location location; + private Coordinate coordinate; - public static Festival from(String name, ActivePeriod activePeriod, String description, String address, Location location) { + public static Festival from(String name, ActivePeriod activePeriod, String description, String address, Coordinate coordinate) { return Festival.builder() .name(name) .activePeriod(activePeriod) .description(description) .address(address) - .location(location) + .coordinate(coordinate) .build(); } @Builder - private Festival(String name, ActivePeriod activePeriod, String description, String address, Location location) { + private Festival(String name, ActivePeriod activePeriod, String description, String address, Coordinate coordinate) { this.name = name; this.activePeriod = activePeriod; this.description = description; this.address = address; - this.location = location; + this.coordinate = coordinate; } diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java b/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java index 6aa7c38..9bcb1fe 100644 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java @@ -1,15 +1,15 @@ package com.example.mohago_nocar.festival.presentation.response; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import lombok.Builder; @Builder public record FestivalLocationResponseDto( - Location location + Coordinate coordinate ) { - public static FestivalLocationResponseDto of(Location location) { + public static FestivalLocationResponseDto of(Coordinate coordinate) { return new FestivalLocationResponseDtoBuilder() - .location(location) + .coordinate(coordinate) .build(); } } diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java b/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java index c30d6b7..ae7dd7a 100644 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java @@ -4,7 +4,7 @@ import com.example.mohago_nocar.festival.domain.model.vo.ActivePeriod; import java.util.List; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import lombok.Builder; @Builder @@ -14,7 +14,7 @@ public record FestivalResponseDto( ActivePeriod activePeriod, String description, String address, - Location location, + Coordinate coordinate, List imageUrlList ) { @@ -25,7 +25,7 @@ public static FestivalResponseDto of(Festival festival, List imageUrlLis .activePeriod(festival.getActivePeriod()) .description(festival.getDescription()) .address(festival.getAddress()) - .location(festival.getLocation()) + .coordinate(festival.getCoordinate()) .imageUrlList(imageUrlList) .build(); } diff --git a/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java b/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Coordinate.java similarity index 54% rename from src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java rename to src/main/java/com/example/mohago_nocar/global/common/domain/vo/Coordinate.java index ed3dd16..50bb403 100644 --- a/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java +++ b/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Coordinate.java @@ -10,20 +10,20 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @EqualsAndHashCode(of = {"longitude", "latitude"}) @ToString -public class Location { +public class Coordinate { private Double longitude; // x private Double latitude; // y - public static Location from(Double longitude, Double latitude) { - return Location.builder() + public static Coordinate from(Double longitude, Double latitude) { + return com.example.mohago_nocar.global.common.domain.vo.Coordinate.builder() .longitude(longitude) .latitude(latitude) .build(); } - public static Location from(String longitude, String latitude) { - return Location.from(Double.valueOf(longitude), Double.valueOf(latitude)); + public static Coordinate from(String longitude, String latitude) { + return com.example.mohago_nocar.global.common.domain.vo.Coordinate.from(Double.valueOf(longitude), Double.valueOf(latitude)); } } diff --git a/src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java b/src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java index 4621f7f..085af6c 100644 --- a/src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java +++ b/src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java @@ -1,6 +1,6 @@ package com.example.mohago_nocar.place.domain.converter; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.place.domain.model.*; import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse; import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse.KakaoPlaceResponse; @@ -20,7 +20,7 @@ public static Place convertToPlace(KakaoPlaceResponse dto) { return Place.from( dto.id(), dto.place_name(), - Location.from(dto.x(), dto.y()), + Coordinate.from(dto.x(), dto.y()), dto.address_name(), dto.place_url(), PlaceCategory.getCategoryByCode(dto.category_group_code()) diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java b/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java index 9c52030..44a5919 100644 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java +++ b/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java @@ -1,6 +1,6 @@ package com.example.mohago_nocar.place.domain.model; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.Builder; @@ -8,7 +8,6 @@ import lombok.NoArgsConstructor; import static jakarta.persistence.EnumType.STRING; -import static lombok.AccessLevel.PROTECTED; @Getter @NoArgsConstructor @@ -21,7 +20,7 @@ public class Place { private String name; @NotNull - private Location location; + private Coordinate coordinate; @NotNull private String address; @@ -36,7 +35,7 @@ public class Place { public static Place from( String id, String name, - Location location, + Coordinate coordinate, String address, String placeUrl, PlaceCategory category @@ -44,7 +43,7 @@ public static Place from( return Place.builder() .id(id) .name(name) - .location(location) + .coordinate(coordinate) .address(address) .placeUrl(placeUrl) .category(category) @@ -55,14 +54,14 @@ public static Place from( private Place( String id, String name, - Location location, + Coordinate coordinate, String address, String placeUrl, PlaceCategory category ) { this.id = id; this.name = name; - this.location = location; + this.coordinate = coordinate; this.address = address; this.placeUrl = placeUrl; this.category = category; diff --git a/src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java b/src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java similarity index 83% rename from src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java rename to src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java index 068d194..8875cd6 100644 --- a/src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java @@ -1,6 +1,6 @@ package com.example.mohago_nocar.place.presentation; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.place.domain.model.Place; import lombok.Builder; @@ -10,7 +10,7 @@ public record NearPlaceResponseDto( String id, String name, Long festivalId, - Location location, + Coordinate coordinate, String address, String placeUrl, String category @@ -20,7 +20,7 @@ public static NearPlaceResponseDto of(Long festivalId, Place place) { .id(place.getId()) .name(place.getName()) .festivalId(festivalId) - .location(place.getLocation()) + .coordinate(place.getCoordinate()) .address(place.getAddress()) .placeUrl(place.getPlaceUrl()) .category(place.getCategory().name()) diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java b/src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java index e0388f1..3850aee 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java @@ -1,6 +1,7 @@ package com.example.mohago_nocar.transit.domain.converter; import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.transit.domain.model.*; import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response.OdsayRouteResponse; import com.fasterxml.jackson.databind.JsonNode; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java index 3bf4ca9..792675a 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java @@ -1,6 +1,6 @@ package com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import lombok.Builder; /** @@ -14,14 +14,14 @@ public record RouteSpecification( Double distanceInKm, Long durationInMinutes, - Location origin, - Location destination + Coordinate origin, + Coordinate destination ) { public static RouteSpecification from( GoogleDistanceMatrixResponse.Element element, - Location origin, - Location destination + Coordinate origin, + Coordinate destination ) { return RouteSpecification.builder() .distanceInKm(element.distance().value() / 1000.0) @@ -31,7 +31,7 @@ public static RouteSpecification from( .build(); } - public boolean isEqualLocation(Location origin, Location destination) { + public boolean isEqualLocation(Coordinate origin, Coordinate destination) { return this.origin.equals(origin) && this.destination.equals(destination); } From 4417c2463157677f34b412b85af4b71d0b17150c Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 19 Feb 2025 18:54:50 +0900 Subject: [PATCH 10/84] =?UTF-8?q?feat:=20Location=20class=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/domain/model/Location.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java b/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java new file mode 100644 index 0000000..eed8edc --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java @@ -0,0 +1,42 @@ +package com.example.mohago_nocar.plan.domain.model; + +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.model.Place; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class Location { + + private String name; + private Coordinate coordinate; + + public static Location of(Festival festival) { + return Location.builder() + .name(festival.getName()) + .coordinate(festival.getCoordinate()) + .build(); + } + + public static Location of(Place place) { + return Location.builder() + .name(place.getName()) + .coordinate(place.getCoordinate()) + .build(); + } + + public static Location of(String name, Coordinate coordinate) { + return Location.builder() + .name(name) + .coordinate(coordinate) + .build(); + } + + @Builder + private Location(String name, Coordinate coordinate) { + this.name = name; + this.coordinate = coordinate; + } + +} From 1af9a981b76ab1ff628f793126c12dc00f0b4669 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 19 Feb 2025 18:54:29 +0900 Subject: [PATCH 11/84] =?UTF-8?q?feat:=20=EA=B2=BD=EB=A1=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98?= =?UTF-8?q?=EC=97=90=20=EC=A0=84=EB=9E=B5=20=ED=8C=A8=ED=84=B4=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteOptimizationStrategy.java | 12 ++ .../ShortestTimeRouteStrategy.java | 116 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/plan/application/RouteOptimizationStrategy.java create mode 100644 src/main/java/com/example/mohago_nocar/plan/application/ShortestTimeRouteStrategy.java diff --git a/src/main/java/com/example/mohago_nocar/plan/application/RouteOptimizationStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/RouteOptimizationStrategy.java new file mode 100644 index 0000000..7244165 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/RouteOptimizationStrategy.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.plan.application; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.RouteSpecification; + +import java.util.List; + +public interface RouteOptimizationStrategy { + + List calculateOptimalRoute(List coordinates, List routeSpecification); + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/plan/application/ShortestTimeRouteStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/ShortestTimeRouteStrategy.java new file mode 100644 index 0000000..b093626 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/ShortestTimeRouteStrategy.java @@ -0,0 +1,116 @@ +package com.example.mohago_nocar.plan.application; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.global.common.exception.InternalServerException; +import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.RouteSpecification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Component +@Slf4j +public class ShortestTimeRouteStrategy implements RouteOptimizationStrategy{ + + private static final int FIRST = 0; + + @Override + public List calculateOptimalRoute(List coordinates, List routeSpecification) { + int locationCount = coordinates.size(); + Map> fromToTransitInfoMap = new HashMap<>(); + + for (int originIndex = FIRST; originIndex < locationCount; originIndex++) { + Map toLocationTransitInfoMap = new HashMap<>(); + + for (int destinationIndex = FIRST; destinationIndex < locationCount; destinationIndex++) { + + if (originIndex == destinationIndex) { + continue; + } + + Coordinate origin = coordinates.get(originIndex); + Coordinate destination = coordinates.get(destinationIndex); + + RouteSpecification routeSpec = getMatchedRouteSpec(origin, destination, routeSpecification); + toLocationTransitInfoMap.put(destination, routeSpec); + } + + fromToTransitInfoMap.put(coordinates.get(originIndex), toLocationTransitInfoMap); + } + + List route = new ArrayList<>(); + List isSelected = new ArrayList<>(); + List optimalRoute = new ArrayList<>(); + for (int i = 0; i < locationCount; i++) { + isSelected.add(false); + optimalRoute.add(coordinates.get(i)); + } + + routeBacktracking(0, coordinates, fromToTransitInfoMap , optimalRoute, route, isSelected); + return optimalRoute; + } + + private RouteSpecification getMatchedRouteSpec( + Coordinate origin, + Coordinate destination, + List routeSpecificationBetweenLocations + ) { + Optional routeSpecification = routeSpecificationBetweenLocations.stream() + .filter(route -> route.isEqualLocation(origin, destination)) + .findFirst(); + + if (routeSpecification.isEmpty()) { + log.error("origin-{}, destination-{}를 가지는 RouteSpec을 찾을 수 없습니다.", origin, destination); + throw new InternalServerException(); + } + + return routeSpecification.get(); + } + + private void routeBacktracking( + int k, + List coordinates, + Map> transitMaps, + List optimal, + List route, + List isSelected + ) { + int n = coordinates.size(); + + if (k == n) + { + int t1 = calcTravelTime(optimal, transitMaps); + int t2 = calcTravelTime(route, transitMaps); + + if (t1 > t2) { + for (int i = 0; i < n; i++) { + optimal.set(i, route.get(i)); + } + } + return; + } + + for (int i = 0; i < n; i++) { + if (isSelected.get(i)) { + continue; + } + + isSelected.set(i, true); + route.add(coordinates.get(i)); + routeBacktracking(k + 1, coordinates, transitMaps, optimal, route, isSelected); + route.removeLast(); + isSelected.set(i, false); + } + } + + private int calcTravelTime(List route, Map> routeMaps) { + int n = route.size(); + + int travelTime = 0; + for (int i = 0; i < n - 1; i++) { + travelTime += routeMaps.get(route.get(i)).get(route.get(i + 1)).durationInMinutes(); + } + return travelTime; + } + +} From ae5f303f43ae2b1898e3ff3b49f9adf270e033f0 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:01:14 +0900 Subject: [PATCH 12/84] =?UTF-8?q?feat:=20=EA=B0=80=EC=83=81=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20enabled=20true=20=EC=84=A4=EC=A0=95=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 36909f8..c992382 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,9 @@ server: port: ${SERVER_PORT:8080} spring: + threads: + virtual: + enabled: true application: name: mohago-nocar profiles: @@ -46,6 +49,12 @@ kakao: local: category: https://dapi.kakao.com/v2/local/search/category api-key: ${KAKAO_API_KEY} + +#logging: +# level: +# root: debug +# jdk.virtualThread: debug + --- spring: config: From 5b897f154c8fc278f2fc12578c21b306f9a32bfd Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:04:52 +0900 Subject: [PATCH 13/84] =?UTF-8?q?feat:=20RestClient=20RequestFactory=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=B2=B4=20=EB=B3=80=EA=B2=BD=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/RestClientConfig.java | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java b/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java index f26c702..2561cbd 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java @@ -1,23 +1,39 @@ package com.example.mohago_nocar.global.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import java.net.http.HttpClient; import java.time.Duration; +import java.util.concurrent.Executors; @Configuration public class RestClientConfig { + @Value("${spring.threads.virtual.enabled}") + private boolean isVirtualThreadEnabled; + @Bean public RestClient restClient() { - HttpComponentsClientHttpRequestFactory clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory(); - clientHttpRequestFactory.setConnectTimeout(Duration.ofSeconds(10)); - clientHttpRequestFactory.setConnectionRequestTimeout(Duration.ofSeconds(5)); + var httpRequestFactory = createHttpRequestFactory(); + httpRequestFactory.setReadTimeout(Duration.ofSeconds(10)); return RestClient.builder() - .requestFactory(clientHttpRequestFactory) + .requestFactory(httpRequestFactory) .build(); } + + private JdkClientHttpRequestFactory createHttpRequestFactory() { + if (isVirtualThreadEnabled) { + return new JdkClientHttpRequestFactory(HttpClient.newBuilder() + .executor(Executors.newVirtualThreadPerTaskExecutor()) + .build()); + } + + return new JdkClientHttpRequestFactory(); + } + } From 22fb88f0d38248a83346f6825af2784ae3e71682 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:15:03 +0900 Subject: [PATCH 14/84] =?UTF-8?q?refactor:=20Coordinate=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=A2=8C=ED=91=9C=20=ED=91=9C=ED=98=84=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../place/application/PlaceService.java | 12 +++--- .../externalApi/kakao/KakaoApiClient.java | 8 ++-- .../response/BusPathResponseDto.java | 8 ++-- .../response/SubwayPathResponseDto.java | 8 ++-- .../transit/domain/model/BusPath.java | 36 ++++------------ .../transit/domain/model/SubwayPath.java | 41 ++++++------------- .../google/GoogleApiClient.java | 10 ++--- 7 files changed, 44 insertions(+), 79 deletions(-) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/{externalApi => distanceDuration}/google/GoogleApiClient.java (87%) diff --git a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java index 2760912..32afc10 100644 --- a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java +++ b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java @@ -2,7 +2,7 @@ import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.place.domain.converter.PlaceConverter; import com.example.mohago_nocar.place.domain.model.Place; import com.example.mohago_nocar.place.domain.repository.PlaceRepository; @@ -32,21 +32,21 @@ public List getFestivalNearPlaces(Long festivalId) { Festival festival = festivalUseCase.getFestival(festivalId); List places = placeRepository.getFestivalAroundPlaces(festivalId); if (places.isEmpty()) { - places = cachePlaces(festivalId, festival.getLocation()); + places = cachePlaces(festivalId, festival.getCoordinate()); } return PlaceConverter.convertToNearPlaceResponseDtos(festivalId, places); } - public List cachePlaces(Long festivalId, Location centerLocation) { - KakaoPlacesResponse placesFromExternalApi = searchPlacesAround(centerLocation); + public List cachePlaces(Long festivalId, Coordinate centerCoordinate) { + KakaoPlacesResponse placesFromExternalApi = searchPlacesAround(centerCoordinate); List places = PlaceConverter.convertToPlaces(placesFromExternalApi); return placeRepository.saveAllToCache(festivalId, places); } - private KakaoPlacesResponse searchPlacesAround(Location centerLocation) { + private KakaoPlacesResponse searchPlacesAround(Coordinate centerCoordinate) { return kakaoApiClient.searchAttractionPlaces( - centerLocation, + centerCoordinate, RADIUS, PAGE_SIZE ); diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java index ab3b233..29107d4 100644 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java @@ -1,6 +1,6 @@ package com.example.mohago_nocar.place.infrastructure.externalApi.kakao; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.place.domain.model.PlaceCategory; import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse; import org.springframework.beans.factory.annotation.Value; @@ -29,10 +29,10 @@ public KakaoApiClient( this.restClient = restClient; } - public KakaoPlacesResponse searchAttractionPlaces(Location centerLocation, int radius, int size) { + public KakaoPlacesResponse searchAttractionPlaces(Coordinate centerCoordinate, int radius, int size) { URI uri = UriComponentsBuilder.fromUriString(baseUrl) - .queryParam("x", centerLocation.getLongitude()) - .queryParam("y", centerLocation.getLatitude()) + .queryParam("x", centerCoordinate.getLongitude()) + .queryParam("y", centerCoordinate.getLatitude()) .queryParam("radius", radius) .queryParam("size", size) .queryParam("category_group_code", PlaceCategory.ATTRACTION.getCode()) diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java index cff2d91..780a5c7 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java @@ -28,11 +28,11 @@ public static BusPathResponseDto of(SubPath subPath) { .busNo(busPath.getBusNo()) .busType(busPath.getBusType()) .startPlaceName(busPath.getStartName()) - .startLongitude(busPath.getStartX()) - .startLatitude(busPath.getStartY()) + .startLongitude(busPath.getStartCoordinate().getLongitude()) + .startLatitude(busPath.getStartCoordinate().getLatitude()) .endPlaceName(busPath.getEndName()) - .endLongitude(busPath.getEndX()) - .endLatitude(busPath.getEndY()) + .endLongitude(busPath.getEndCoordinate().getLongitude()) + .endLatitude(busPath.getEndCoordinate().getLatitude()) .build(); } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java index f32eab4..49f37b7 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java @@ -28,11 +28,11 @@ public static SubwayPathResponseDto of(SubPath subPath) { .sectionTime(subwayPath.getSectionTime()) .subwayLineName(subwayPath.getSubwayLineName()) .startPlaceName(subwayPath.getStartName()) - .startLongitude(subwayPath.getStartX()) - .startLatitude(subwayPath.getStartY()) + .startLongitude(subwayPath.getStartCoordinate().getLongitude()) + .startLatitude(subwayPath.getStartCoordinate().getLatitude()) .endPlaceName(subwayPath.getEndName()) - .endLongitude(subwayPath.getEndX()) - .endLatitude(subwayPath.getEndY()) + .endLongitude(subwayPath.getEndCoordinate().getLongitude()) + .endLatitude(subwayPath.getEndCoordinate().getLatitude()) .build(); } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java index 32e909f..e48119a 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java @@ -1,20 +1,21 @@ package com.example.mohago_nocar.transit.domain.model; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import lombok.Getter; +import lombok.ToString; import static com.example.mohago_nocar.transit.domain.model.PathType.BUS; @Getter +@ToString public class BusPath extends SubPath{ private final String busNo; // 버스 번호 private final int busType; // 버스 타입 private final String startName; // 출발 지점 이름 - private final double startX; // 출발 지점 X 좌표 - private final double startY; // 출발 지점 Y 좌표 + private final Coordinate startCoordinate; private final String endName; // 도착 지점 이름 - private final double endX; // 도착 지점 X 좌표 - private final double endY; // 도착 지점 Y 좌표 + private final Coordinate endCoordinate; public BusPath( double distance, @@ -22,21 +23,17 @@ public BusPath( String busNo, int busType, String startName, - double startX, - double startY, + Coordinate startCoordinate, String endName, - double endX, - double endY + Coordinate endCoordinate ) { super(distance, sectionTime); this.busNo = busNo; this.busType = busType; this.startName = startName; - this.startX = startX; - this.startY = startY; + this.startCoordinate = startCoordinate; this.endName = endName; - this.endX = endX; - this.endY = endY; + this.endCoordinate = endCoordinate; } @Override @@ -44,19 +41,4 @@ public PathType getPathType() { return BUS; } - @Override - public String toString() { - return "BusPath{" + - "busNo='" + busNo + '\'' + - ", busType=" + busType + - ", startName='" + startName + '\'' + - ", startX=" + startX + - ", startY=" + startY + - ", endName='" + endName + '\'' + - ", endX=" + endX + - ", endY=" + endY + - ", distance=" + distance + - ", sectionTime=" + sectionTime + - '}'; - } } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java index fc83082..268b242 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java @@ -1,38 +1,35 @@ package com.example.mohago_nocar.transit.domain.model; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import lombok.Getter; +import lombok.ToString; import static com.example.mohago_nocar.transit.domain.model.PathType.SUBWAY; @Getter +@ToString public class SubwayPath extends SubPath{ - private final String subwayLineName; // 지하철 노선명 - private final String startName; // 출발 지점 이름 - private final double startX; // 출발 지점 X 좌표 - private final double startY; // 출발 지점 Y 좌표 - private final String endName; // 도착 지점 이름 - private final double endX; // 도착 지점 X 좌표 - private final double endY; // 도착 지점 Y 좌표 + private final String subwayLineName; + private final String startName; + private final Coordinate startCoordinate; + private final String endName; + private final Coordinate endCoordinate; public SubwayPath( double distance, int sectionTime, String subwayLineName, String startName, - double startX, - double startY, + Coordinate startCoordinate, String endName, - double endX, - double endY + Coordinate endCoordinate ) { super(distance, sectionTime); this.subwayLineName = subwayLineName; this.startName = startName; - this.startX = startX; - this.startY = startY; + this.startCoordinate = startCoordinate; this.endName = endName; - this.endX = endX; - this.endY = endY; + this.endCoordinate = endCoordinate; } @Override @@ -40,18 +37,4 @@ public PathType getPathType() { return SUBWAY; } - @Override - public String toString() { - return "SubwayPath{" + - "subwayLineName='" + subwayLineName + '\'' + - ", startName='" + startName + '\'' + - ", startX=" + startX + - ", startY=" + startY + - ", endName='" + endName + '\'' + - ", endX=" + endX + - ", endY=" + endY + - ", distance=" + distance + - ", sectionTime=" + sectionTime + - '}'; - } } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/GoogleApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java similarity index 87% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/GoogleApiClient.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java index e050038..933a58a 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/GoogleApiClient.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java @@ -1,7 +1,7 @@ package com.example.mohago_nocar.transit.infrastructure.externalApi.google; -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.GoogleDistanceMatrixResponse; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -60,13 +60,13 @@ public GoogleDistanceMatrixResponse getDistanceMatrix(Location origin, List locations) { + private String formatCoordinates(List coordinates) { return URLEncoder.encode( - locations.stream() + coordinates.stream() .map(location -> location.getLatitude() + "," + location.getLongitude()) .collect(Collectors.joining("|")) ,StandardCharsets.UTF_8 From 9a223d693211a768156c55d8f047c36340ca0b13 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:20:00 +0900 Subject: [PATCH 15/84] =?UTF-8?q?refactor:=20=EA=B1=B0=EB=A6=AC,=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=95=84=EB=93=9C=EC=97=90=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=91=9C=EA=B8=B0=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/presentation/response/BusPathResponseDto.java | 4 ++-- .../presentation/response/SubwayPathResponseDto.java | 6 ++---- .../presentation/response/WalkPathResponseDto.java | 4 ++-- .../mohago_nocar/transit/domain/model/SubPath.java | 10 +++++----- .../mohago_nocar/transit/domain/model/WalkPath.java | 9 ++------- 5 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java index 780a5c7..577127f 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java @@ -23,8 +23,8 @@ public static BusPathResponseDto of(SubPath subPath) { BusPath busPath = (BusPath) subPath; return BusPathResponseDto.builder() - .distance(busPath.getDistance()) - .sectionTime(busPath.getSectionTime()) + .distance(busPath.getDistanceKm()) + .sectionTime(busPath.getSectionTimeMin()) .busNo(busPath.getBusNo()) .busType(busPath.getBusType()) .startPlaceName(busPath.getStartName()) diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java index 49f37b7..1a9a910 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java @@ -1,10 +1,8 @@ package com.example.mohago_nocar.plan.presentation.response; -import com.example.mohago_nocar.transit.domain.model.PathType; import com.example.mohago_nocar.transit.domain.model.SubPath; import com.example.mohago_nocar.transit.domain.model.SubwayPath; import lombok.Builder; -import lombok.Generated; import lombok.Getter; import static com.example.mohago_nocar.transit.domain.model.PathType.SUBWAY; @@ -24,8 +22,8 @@ public static SubwayPathResponseDto of(SubPath subPath) { SubwayPath subwayPath = (SubwayPath) subPath; return SubwayPathResponseDto.builder() - .distance(subwayPath.getDistance()) - .sectionTime(subwayPath.getSectionTime()) + .distance(subwayPath.getDistanceKm()) + .sectionTime(subwayPath.getSectionTimeMin()) .subwayLineName(subwayPath.getSubwayLineName()) .startPlaceName(subwayPath.getStartName()) .startLongitude(subwayPath.getStartCoordinate().getLongitude()) diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java index 9098c1a..399bfd7 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java @@ -15,8 +15,8 @@ public static WalkPathResponseDto of(SubPath subPath) { WalkPath walkPath = (WalkPath) subPath; return WalkPathResponseDto.builder() - .distance(walkPath.getDistance()) - .sectionTime(walkPath.getSectionTime()) + .distance(walkPath.getDistanceKm()) + .sectionTime(walkPath.getSectionTimeMin()) .build(); } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java index 6b78b71..fedd439 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java @@ -4,12 +4,12 @@ @Getter public abstract class SubPath { - protected final double distance; // 구간 거리 - protected final int sectionTime; // 구간 소요 시간 + protected final double distanceKm; // 구간 거리 + protected final int sectionTimeMin; // 구간 소요 시간 - protected SubPath(double distance, int sectionTime) { - this.distance = distance; - this.sectionTime = sectionTime; + protected SubPath(double distanceKm, int sectionTimeMin) { + this.distanceKm = distanceKm; + this.sectionTimeMin = sectionTimeMin; } public abstract PathType getPathType(); diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java index 30aa826..badcae8 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java @@ -1,8 +1,10 @@ package com.example.mohago_nocar.transit.domain.model; import lombok.Getter; +import lombok.ToString; @Getter +@ToString public class WalkPath extends SubPath{ public WalkPath(double distance, int sectionTime) { @@ -14,11 +16,4 @@ public PathType getPathType() { return PathType.WALK; } - @Override - public String toString() { - return "WalkPath{" + - "distance=" + distance + - ", sectionTime=" + sectionTime + - '}'; - } } From 0eb1d00abe8e1963d7f92a8fd6ef42d11f8b5ea3 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:22:27 +0900 Subject: [PATCH 16/84] =?UTF-8?q?feat:=20virtualThreadExecutor=20=EB=B9=88?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D,=20rate=20limiter=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ .../global/config/ExecutorServiceConfig.java | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java diff --git a/build.gradle b/build.gradle index 24a87fb..be5e339 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,9 @@ dependencies { implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework:spring-aspects' + // rate limiter + implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java new file mode 100644 index 0000000..16ebcdc --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java @@ -0,0 +1,20 @@ +package com.example.mohago_nocar.global.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; +import org.springframework.boot.autoconfigure.thread.Threading; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Configuration +public class ExecutorServiceConfig { + + @Bean + @ConditionalOnThreading(Threading.VIRTUAL) + public ExecutorService virtualThreadExecutor(){ + return Executors.newVirtualThreadPerTaskExecutor(); + } + +} From 50f5e661f2976d10e8536150b8d19e571d6ed6a6 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:26:02 +0900 Subject: [PATCH 17/84] =?UTF-8?q?refactor:=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EB=B0=8F=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteOptimizationStrategy.java | 12 ---------- .../strategy/RouteOptimizationStrategy.java | 12 ++++++++++ .../ShortestTimeRouteStrategy.java | 24 +++++++++---------- .../model/RouteMetrics.java} | 9 +++---- 4 files changed, 29 insertions(+), 28 deletions(-) delete mode 100644 src/main/java/com/example/mohago_nocar/plan/application/RouteOptimizationStrategy.java create mode 100644 src/main/java/com/example/mohago_nocar/plan/application/strategy/RouteOptimizationStrategy.java rename src/main/java/com/example/mohago_nocar/plan/application/{ => strategy}/ShortestTimeRouteStrategy.java (78%) rename src/main/java/com/example/mohago_nocar/transit/{infrastructure/externalApi/google/dto/response/RouteSpecification.java => domain/model/RouteMetrics.java} (75%) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/RouteOptimizationStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/RouteOptimizationStrategy.java deleted file mode 100644 index 7244165..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/application/RouteOptimizationStrategy.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.example.mohago_nocar.plan.application; - -import com.example.mohago_nocar.global.common.domain.vo.Coordinate; -import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.RouteSpecification; - -import java.util.List; - -public interface RouteOptimizationStrategy { - - List calculateOptimalRoute(List coordinates, List routeSpecification); - -} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/plan/application/strategy/RouteOptimizationStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/strategy/RouteOptimizationStrategy.java new file mode 100644 index 0000000..7010f6e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/strategy/RouteOptimizationStrategy.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.plan.application.strategy; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; + +import java.util.List; + +public interface RouteOptimizationStrategy { + + List calculateOptimalRoute(List coordinates, List routeMetrics); + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/plan/application/ShortestTimeRouteStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java similarity index 78% rename from src/main/java/com/example/mohago_nocar/plan/application/ShortestTimeRouteStrategy.java rename to src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java index b093626..15f4411 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/ShortestTimeRouteStrategy.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java @@ -1,8 +1,8 @@ -package com.example.mohago_nocar.plan.application; +package com.example.mohago_nocar.plan.application.strategy; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.global.common.exception.InternalServerException; -import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.RouteSpecification; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -10,17 +10,17 @@ @Component @Slf4j -public class ShortestTimeRouteStrategy implements RouteOptimizationStrategy{ +public class ShortestTimeRouteStrategy implements RouteOptimizationStrategy { private static final int FIRST = 0; @Override - public List calculateOptimalRoute(List coordinates, List routeSpecification) { + public List calculateOptimalRoute(List coordinates, List routeMetrics) { int locationCount = coordinates.size(); - Map> fromToTransitInfoMap = new HashMap<>(); + Map> fromToTransitInfoMap = new HashMap<>(); for (int originIndex = FIRST; originIndex < locationCount; originIndex++) { - Map toLocationTransitInfoMap = new HashMap<>(); + Map toLocationTransitInfoMap = new HashMap<>(); for (int destinationIndex = FIRST; destinationIndex < locationCount; destinationIndex++) { @@ -31,7 +31,7 @@ public List calculateOptimalRoute(List coordinates, List Coordinate origin = coordinates.get(originIndex); Coordinate destination = coordinates.get(destinationIndex); - RouteSpecification routeSpec = getMatchedRouteSpec(origin, destination, routeSpecification); + RouteMetrics routeSpec = getMatchedRouteSpec(origin, destination, routeMetrics); toLocationTransitInfoMap.put(destination, routeSpec); } @@ -50,12 +50,12 @@ public List calculateOptimalRoute(List coordinates, List return optimalRoute; } - private RouteSpecification getMatchedRouteSpec( + private RouteMetrics getMatchedRouteSpec( Coordinate origin, Coordinate destination, - List routeSpecificationBetweenLocations + List routeMetricsBetweenLocations ) { - Optional routeSpecification = routeSpecificationBetweenLocations.stream() + Optional routeSpecification = routeMetricsBetweenLocations.stream() .filter(route -> route.isEqualLocation(origin, destination)) .findFirst(); @@ -70,7 +70,7 @@ private RouteSpecification getMatchedRouteSpec( private void routeBacktracking( int k, List coordinates, - Map> transitMaps, + Map> transitMaps, List optimal, List route, List isSelected @@ -103,7 +103,7 @@ private void routeBacktracking( } } - private int calcTravelTime(List route, Map> routeMaps) { + private int calcTravelTime(List route, Map> routeMaps) { int n = route.size(); int travelTime = 0; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/RouteMetrics.java similarity index 75% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java rename to src/main/java/com/example/mohago_nocar/transit/domain/model/RouteMetrics.java index 792675a..a293bea 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/RouteSpecification.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/RouteMetrics.java @@ -1,6 +1,7 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response; +package com.example.mohago_nocar.transit.domain.model; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; import lombok.Builder; /** @@ -11,19 +12,19 @@ * @param destination */ @Builder -public record RouteSpecification( +public record RouteMetrics( Double distanceInKm, Long durationInMinutes, Coordinate origin, Coordinate destination ) { - public static RouteSpecification from( + public static RouteMetrics of( GoogleDistanceMatrixResponse.Element element, Coordinate origin, Coordinate destination ) { - return RouteSpecification.builder() + return RouteMetrics.builder() .distanceInKm(element.distance().value() / 1000.0) .durationInMinutes(element.duration().value() / 60L) .origin(origin) From c7b5f0126ed3a8ee3efb1b27f50dcffa8c489a76 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:29:36 +0900 Subject: [PATCH 18/84] =?UTF-8?q?refactor:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20DTO=EC=9D=98=20Wrapper=20DTO?= =?UTF-8?q?=20class=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/TravelPlanUseCase.java | 2 +- .../presentation/TravelPlanController.java | 2 +- .../response/PlanTravelCourseResponseDto.java | 39 ++------------ .../response/TravelRouteResponseDto.java | 53 +++++++++++++++++++ 4 files changed, 59 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/plan/presentation/response/TravelRouteResponseDto.java diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java index c031756..3c950c7 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java @@ -7,6 +7,6 @@ public interface TravelPlanUseCase { - List planCourse(PlanTravelCourseRequestDto dto); + PlanTravelCourseResponseDto planCourse(PlanTravelCourseRequestDto dto); } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java index 7764aff..4216245 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java @@ -26,7 +26,7 @@ public class TravelPlanController { public ApiResponse planTravelCourse( @RequestBody @Valid PlanTravelCourseRequestDto requestDto ) { - List responseDto = travelPlanUseCase.planCourse(requestDto); + PlanTravelCourseResponseDto responseDto = travelPlanUseCase.planCourse(requestDto); return ApiResponse.ok(responseDto); } } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java index fd29af7..9f86d5d 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java @@ -1,49 +1,18 @@ package com.example.mohago_nocar.plan.presentation.response; -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.transit.domain.model.TransitRoute; import lombok.Builder; import java.util.List; @Builder public record PlanTravelCourseResponseDto( - String startPlaceName, - double startLongitude, - double startLatitude, - String endPlaceName, - double endLongitude, - double endLatitude, - int totalTime, - double totalDistance, - List subPaths + List travelRoutes ) { - public static PlanTravelCourseResponseDto of( - Location fromLocation, - String fromName, - Location toLocation, - String toName, - TransitRoute transitRoute - ) { + public static PlanTravelCourseResponseDto of(List travelRoutes) { return PlanTravelCourseResponseDto.builder() - .startPlaceName(fromName) - .startLongitude(fromLocation.getLongitude()) - .startLatitude(fromLocation.getLatitude()) - .endPlaceName(toName) - .endLongitude(toLocation.getLongitude()) - .endLatitude(toLocation.getLatitude()) - .totalTime(transitRoute.getTotalTime()) - .totalDistance(transitRoute.getTotalDistance()) - .subPaths(transitRoute.getSubPaths().stream() - .map(subPath -> - switch (subPath.getPathType()) { - case BUS -> BusPathResponseDto.of(subPath); - case WALK -> WalkPathResponseDto.of(subPath); - case SUBWAY -> SubwayPathResponseDto.of(subPath); - }) - .toList() - ) + .travelRoutes(travelRoutes) .build(); } + } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/TravelRouteResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/TravelRouteResponseDto.java new file mode 100644 index 0000000..6ecdd26 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/response/TravelRouteResponseDto.java @@ -0,0 +1,53 @@ +package com.example.mohago_nocar.plan.presentation.response; + +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import lombok.Builder; + +import java.util.List; + +@Builder +public record TravelRouteResponseDto( + int totalTime, + double totalDistance, + Location origin, + Location destination, + List subPaths +) { + + public static TravelRouteResponseDto of(TransitRoute transitRoute) { + return TravelRouteResponseDto.builder() + .origin(Location.builder() + .latitude(transitRoute.getOrigin().getCoordinate().getLatitude()) + .longitude(transitRoute.getOrigin().getCoordinate().getLongitude()) + .name(transitRoute.getOrigin().getName()) + .build()) + .destination(Location.builder() + .latitude(transitRoute.getDestination().getCoordinate().getLatitude()) + .longitude(transitRoute.getDestination().getCoordinate().getLongitude()) + .name(transitRoute.getDestination().getName()) + .build()) + .totalTime(transitRoute.getTotalTime()) + .totalDistance(transitRoute.getTotalDistance()) + .subPaths(transitRoute.getSubPaths().stream() + .map(subPath -> + switch (subPath.getPathType()) { + case BUS -> BusPathResponseDto.of(subPath); + case WALK -> WalkPathResponseDto.of(subPath); + case SUBWAY -> SubwayPathResponseDto.of(subPath); + }) + .toList() + ) + .build(); + + } + + @Builder + private record Location( + String name, + Double longitude, + Double latitude + ){ + + } + +} From 97d1a945cb2a6ff0f38b044db7942662ef98e98c Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:30:40 +0900 Subject: [PATCH 19/84] =?UTF-8?q?refactor:=20Location=20class=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EC=9E=91=EC=84=B1=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/mohago_nocar/plan/domain/model/Location.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java b/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java index eed8edc..fa226fc 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java @@ -6,6 +6,9 @@ import lombok.Builder; import lombok.Getter; +/** + * 장소의 이름과 경위도 + */ @Getter public class Location { From 26327f49de708bf6f66bc84c74bfb44edd989e64 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:32:00 +0900 Subject: [PATCH 20/84] =?UTF-8?q?fix:=20=EC=83=9D=EB=9E=B5=EB=90=98?= =?UTF-8?q?=EC=96=B4=20=EC=9E=88=EB=8D=98=20import=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/mohago_nocar/place/presentation/PlaceController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java b/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java index 6ce8d47..1ff3817 100644 --- a/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java +++ b/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import com.example.mohago_nocar.place.presentation.NearPlaceResponseDto; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.*; From e204d992a06b67aab3a31239ba52820926a497e4 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 17:57:25 +0900 Subject: [PATCH 21/84] =?UTF-8?q?refactor:=20Adaptor=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=B4=20google=20API=EB=A5=BC=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구글 API 응답의 status 필드를 매개로 유효한 응답인지 확인하는 로직을 추가하였습니다. - 패키지 이름 및 경로를 변경하였습니다. --- .../DistanceDurationApiAdapter.java | 12 +++++ .../GoogleDistanceMatrixApiAdapter.java | 48 +++++++++++++++++++ .../google/DistanceDurationConverter.java | 26 ++++++++++ .../google/GoogleApiClient.java | 8 ++-- .../google/GoogleResponseValidator.java | 30 ++++++++++++ .../GoogleDistanceMatrixResponse.java | 9 ++-- .../response/GoogleDistanceMatrixStatus.java | 19 ++++++++ .../code/GoogleDistanceMatrixErrorCode.java | 21 ++++++++ ...tion.java => DistanceMatrixException.java} | 7 ++- .../converter/DistanceMatrixConverter.java | 22 --------- 10 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/DistanceDurationConverter.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleResponseValidator.java rename src/main/java/com/example/mohago_nocar/transit/infrastructure/{externalApi => distanceDuration}/google/dto/response/GoogleDistanceMatrixResponse.java (62%) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixStatus.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/GoogleDistanceMatrixErrorCode.java rename src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/{OdsayBadRequestException.java => DistanceMatrixException.java} (51%) delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/converter/DistanceMatrixConverter.java diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java new file mode 100644 index 0000000..6b8dd67 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; + +import java.util.List; + +public interface DistanceDurationApiAdapter { + + List getDistanceAndDuration(Coordinate origin, List destinations); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java new file mode 100644 index 0000000..8d14ba2 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java @@ -0,0 +1,48 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.DistanceDurationConverter; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.GoogleApiClient; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.GoogleResponseValidator; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.error.code.GoogleDistanceMatrixErrorCode; +import com.example.mohago_nocar.transit.infrastructure.error.exception.DistanceMatrixException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class GoogleDistanceMatrixApiAdapter implements DistanceDurationApiAdapter { + + private final GoogleApiClient googleApiClient; + + @Override + public List getDistanceAndDuration(Coordinate origin, List destinations) { + GoogleDistanceMatrixResponse response = googleApiClient.getDistanceMatrix(origin, destinations); + if (GoogleResponseValidator.hasError(response)) { + processInvalidResponse(response); + } + + return processValidResponse(origin, destinations, response); + } + + private void processInvalidResponse(GoogleDistanceMatrixResponse response) { + switch (response.status()) { + case INVALID_REQUEST, MAX_ELEMENTS_EXCEEDED, MAX_DIMENSIONS_EXCEEDED, REQUEST_DENIED -> + throw new DistanceMatrixException(GoogleDistanceMatrixErrorCode.INVALID_REQUEST); + case OVER_DAILY_LIMIT, OVER_QUERY_LIMIT -> + throw new DistanceMatrixException(GoogleDistanceMatrixErrorCode.QUOTA_EXCEEDED); + default -> throw new DistanceMatrixException(GoogleDistanceMatrixErrorCode.SERVER_ERROR); + } + } + + private List processValidResponse(Coordinate origin, List destinations, GoogleDistanceMatrixResponse response) { + return DistanceDurationConverter.convertMatrixToRouteMetrics(response, origin, destinations); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/DistanceDurationConverter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/DistanceDurationConverter.java new file mode 100644 index 0000000..2096f4c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/DistanceDurationConverter.java @@ -0,0 +1,26 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; + +import java.util.List; +import java.util.stream.IntStream; + +public class DistanceDurationConverter { + + public static List convertMatrixToRouteMetrics( + GoogleDistanceMatrixResponse distanceMatrix, + Coordinate origin, + List destinations + ) { + + return IntStream.range(0, destinations.size()) + .mapToObj(visit -> RouteMetrics.of( + distanceMatrix.rows().getFirst().elements().get(visit), + origin, + destinations.get(visit))) + .toList(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java index 933a58a..eccc03d 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.google; +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; @@ -44,10 +44,10 @@ public GoogleApiClient( * * @return 행렬에 기반한 (출발지, 목적지)와 관련된 데이터를 반환합니다. */ - public GoogleDistanceMatrixResponse getDistanceMatrix(Location origin, List destinations) { + public GoogleDistanceMatrixResponse getDistanceMatrix(Coordinate origin, List destinations) { URI requestUri = UriComponentsBuilder.fromUriString(baseUrl) - .queryParam("origins", formatOriginCoordinates(origin)) - .queryParam("destinations", formatLocations(destinations)) + .queryParam("origins", formatCoordinates(origin)) + .queryParam("destinations", formatCoordinates(destinations)) .queryParam("language", "ko") .queryParam("mode", "transit") .queryParam("key", apiKey) diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleResponseValidator.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleResponseValidator.java new file mode 100644 index 0000000..43f807a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleResponseValidator.java @@ -0,0 +1,30 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google; + +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixStatus; + +import java.util.List; + +public class GoogleResponseValidator { + + public static boolean hasError(GoogleDistanceMatrixResponse response) { + return isInvalidStatus(response) || containsNullElements(response.rows()); + } + + private static boolean isInvalidStatus(GoogleDistanceMatrixResponse response) { + return response.status() != GoogleDistanceMatrixStatus.OK; + } + + private static boolean containsNullElements(List elements) { + if (elements == null) { + return true; + } + + return elements.get(0).elements().stream() + .anyMatch(element -> + element.distance() == null || element.distance().text() == null || + element.duration() == null || element.duration().text() == null + ); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/GoogleDistanceMatrixResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java similarity index 62% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/GoogleDistanceMatrixResponse.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java index 984df26..2143a63 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/google/dto/response/GoogleDistanceMatrixResponse.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java @@ -1,11 +1,12 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response; +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response; import java.util.List; public record GoogleDistanceMatrixResponse( - List destinationAddresses, - List originAddresses, - List rows + List destination_addresses, + List origin_addresses, + List rows, + GoogleDistanceMatrixStatus status ) { public record Row( diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixStatus.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixStatus.java new file mode 100644 index 0000000..77d9cd6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixStatus.java @@ -0,0 +1,19 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum GoogleDistanceMatrixStatus { + + OK("valid result"), + INVALID_REQUEST("provided request was invalid"), + MAX_ELEMENTS_EXCEEDED("product of origins and destinations exceeds the per-query limit"), + MAX_DIMENSIONS_EXCEEDED("number of origins or destinations exceeds the per-query limit"), + OVER_DAILY_LIMIT("any of the following: 1. The API key is missing or invalid | 2. Billing has not been enabled on your account | 3. self-imposed usage cap has been exceeded | 4. The provided method of payment is no longer valid"), + OVER_QUERY_LIMIT("service has received too many requests from your application within the allowed time period"), + REQUEST_DENIED("service denied use of the Distance Matrix service by your application"), + UNKNOWN_ERROR("Distance Matrix request could not be processed due to a server error"); + + private final String message; + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/GoogleDistanceMatrixErrorCode.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/GoogleDistanceMatrixErrorCode.java new file mode 100644 index 0000000..acae0d5 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/GoogleDistanceMatrixErrorCode.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.transit.infrastructure.error.code; + +import com.example.mohago_nocar.global.common.exception.Status; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GoogleDistanceMatrixErrorCode implements Status { + + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "GOOGLE400", "잘못된 요청입니다"), + QUOTA_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "GOOGLE429", "할당량이 초과되었습니다"), + SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GOOGLE500", "외부 서버 오류가 발생했습니다"), + API_KEY_INVALID(HttpStatus.UNAUTHORIZED, "GOOGLE401 ", "유효하지 않은 API 키입니다"); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayBadRequestException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/DistanceMatrixException.java similarity index 51% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayBadRequestException.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/DistanceMatrixException.java index eebfbaf..1928d95 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayBadRequestException.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/DistanceMatrixException.java @@ -3,11 +3,10 @@ import com.example.mohago_nocar.global.common.exception.CustomException; import com.example.mohago_nocar.global.common.exception.Status; -import static com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode.POINTS_WITHIN_DISTANCE; +public class DistanceMatrixException extends CustomException { -public class OdsayBadRequestException extends CustomException { - - public OdsayBadRequestException(Status status) { + public DistanceMatrixException(Status status) { super(status); } + } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/converter/DistanceMatrixConverter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/converter/DistanceMatrixConverter.java deleted file mode 100644 index ef657cb..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/converter/DistanceMatrixConverter.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.converter; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.RouteSpecification; -import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.GoogleDistanceMatrixResponse; - -import java.util.List; -import java.util.stream.IntStream; - -public class DistanceMatrixConverter { - - public static List convertMatrixToRouteSpecs( - GoogleDistanceMatrixResponse distanceMatrix, int toVisits, Location origin, List destinations) { - return IntStream.range(0, toVisits) - .mapToObj(visit -> RouteSpecification.from( - distanceMatrix.rows().getFirst().elements().get(visit), - origin, - destinations.get(visit))) - .toList(); - } - -} From eb42224bb451804e64e91a7859cd7a813c3c9a00 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 18:02:55 +0900 Subject: [PATCH 22/84] =?UTF-8?q?feat:=20google=20API=20Client,=20Adaptor?= =?UTF-8?q?=20Test=20=EC=9E=91=EC=84=B1=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GoogleDistanceMatrixApiAdapterTest.java | 89 +++++++++++++++++++ .../google/GoogleApiClientTest.java | 47 ++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java create mode 100644 src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java new file mode 100644 index 0000000..60115da --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java @@ -0,0 +1,89 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.GoogleApiClient; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixStatus; +import com.example.mohago_nocar.transit.infrastructure.error.exception.DistanceMatrixException; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("test") +class GoogleDistanceMatrixApiAdapterTest { + + @Autowired + private GoogleDistanceMatrixApiAdapter googleDistanceMatrixApiAdapter; + + @SpyBean + private GoogleApiClient googleApiClient; + + @DisplayName("유효하지 않은 API 응답임이 확인되면 예외를 throw 한다.") + @Test + public void getDistanceAndDuration_throwException_if_invalid() { + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + List destinations = createDestinations(); + + when(googleApiClient.getDistanceMatrix(origin, destinations)) + .thenReturn(new GoogleDistanceMatrixResponse(null, null, null, GoogleDistanceMatrixStatus.INVALID_REQUEST)); + + //when //then + assertThatThrownBy(() -> googleDistanceMatrixApiAdapter.getDistanceAndDuration(origin, destinations)) + .isInstanceOf(DistanceMatrixException.class) + .hasMessage("잘못된 요청입니다"); + } + + @DisplayName("API 응답에 NULL이 포함되어 있으면 예외를 throw 한다.") + @Test + public void getDistanceAndDuration_catchException_if_null() { + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + List destinations = createDestinations(); + + when(googleApiClient.getDistanceMatrix(origin, destinations)) + .thenReturn(new GoogleDistanceMatrixResponse(null, null, null, GoogleDistanceMatrixStatus.OK)); + + //when //then + assertThatThrownBy(() -> googleDistanceMatrixApiAdapter.getDistanceAndDuration(origin, destinations)) + .isInstanceOf(DistanceMatrixException.class) + .hasMessage("외부 서버 오류가 발생했습니다"); + + } + + @DisplayName("출발지와 도착지 간의 이동 거리와 시간을 알 수 있다.") + @Test + public void getDistanceAndDuration(){ + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + List destinations = createDestinations(); + + //when + List routeMetrics = googleDistanceMatrixApiAdapter.getDistanceAndDuration(origin, destinations); + + //then + Assertions.assertThat(routeMetrics).isNotNull(); + + } + + private List createDestinations() { + Coordinate dest1 = Coordinate.from(126.8834795656736, 37.351812431636645); + Coordinate dest2 = Coordinate.from(126.899445340496, 37.3673238473972); + Coordinate dest3 = Coordinate.from(126.848208105819, 37.3649832880928); + + return List.of(dest1, dest2, dest3); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java new file mode 100644 index 0000000..66e0e4e --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java @@ -0,0 +1,47 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + + +@SpringBootTest +@ActiveProfiles("test") +class GoogleApiClientTest { + + @Autowired + private GoogleApiClient googleApiClient; + + @DisplayName("출발지와 도착지 사이의 거리 및 이동 시간을 조회할 수 있다.") + @Test + public void getDistanceMatrix() { + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + + Coordinate dest1 = Coordinate.from(126.8834795656736, 37.351812431636645); + Coordinate dest2 = Coordinate.from(126.899445340496, 37.3673238473972); + Coordinate dest3 = Coordinate.from(126.848208105819, 37.3649832880928); + + List destinations = List.of(dest1, dest2, dest3); + + //when + GoogleDistanceMatrixResponse response = googleApiClient.getDistanceMatrix(origin, destinations); + + //then + Assertions.assertThat(response).isNotNull(); + Assertions.assertThat(response.rows().get(0).elements().size()).isEqualTo(3); + + for (GoogleDistanceMatrixResponse.Element element : response.rows().get(0).elements()) { + Assertions.assertThat(element.distance().text()).isNotNull(); + Assertions.assertThat(element.duration().text()).isNotNull(); + } + } + +} \ No newline at end of file From 3282a87e54419441184ce0e796fc73a24bc096bb Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 18:15:42 +0900 Subject: [PATCH 23/84] =?UTF-8?q?fix:=20ODsay=20API=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=86=8D=EB=8F=84=20=EC=A1=B0=EC=A0=88=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../externalApi/odsay/ODsayApiClient.java | 124 ------------- .../odsay/ODsayApiResponseDeserializer.java | 121 ------------- .../externalApi/odsay/ResponseValidator.java | 22 --- .../route/odsay/ODsayApiClient.java | 93 ++++++++++ ...ODsayTransitRouteResponseDeserializer.java | 166 ++++++++++++++++++ .../response/ODsayRouteInvalidResponse.java | 21 +++ .../dto/response/ODsayRouteValidResponse.java | 49 ++++++ .../response/ODsayTransitRouteResponse.java | 11 ++ 8 files changed, 340 insertions(+), 267 deletions(-) delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiClient.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiResponseDeserializer.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ResponseValidator.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteResponseDeserializer.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteInvalidResponse.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteValidResponse.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayTransitRouteResponse.java diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiClient.java deleted file mode 100644 index f6794da..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiClient.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.odsay; - -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayServerException; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayTooManyRequestsException; -import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response.OdsayRouteResponse; -import jakarta.annotation.PreDestroy; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.retry.support.RetryTemplate; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.springframework.web.util.UriComponentsBuilder; - -import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URLEncoder; -import java.util.concurrent.*; - -import static com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode.ODSAY_SERVER_ERROR; - -/** - * ODsay API 클라이언트: 대중교통 경로 검색 요청을 처리하고 결과를 반환합니다. - *
  • 요청 빈도를 제어하기 위해 세마포어와 스케줄러 사용
  • - *
  • 재시도를 위해 RetryTemplate 활용
  • - */ -@Component -@Slf4j -public class ODsayApiClient { - - private static final int MAX_ATTEMPTS = 20; - private static final int MIN_INTERVAL_MS = 170; - private static final int PERMIT_THREAD_SIZE = 1; - private static final boolean ENABLE_FIFO_ORDERING = true; - - private final RestClient restClient; - private final String apiKey; - private final String baseUrl; - private final RetryTemplate retryTemplate; - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - private final Semaphore semaphore; - - public ODsayApiClient( - RestClient.Builder restClientBuilder, - @Value("${odsay.api-key}") String apiKey, - @Value("${odsay.url}") String baseUrl) { - this.restClient = restClientBuilder.build(); - this.apiKey = apiKey; - this.baseUrl = baseUrl; - this.retryTemplate = createRetryTemplate(); - this.semaphore = new Semaphore(PERMIT_THREAD_SIZE, ENABLE_FIFO_ORDERING); - } - - public OdsayRouteResponse searchTransitRoute(double startX, double startY, double endX, double endY) { - URI requestURI = buildRequestURI(startX, startY, endX, endY); - return executeWithRetry(requestURI); - } - - private URI buildRequestURI(double startX, double startY, double endX, double endY) { - String encodedApiKey = createEncodedApiKey(); - - return UriComponentsBuilder.fromUriString(baseUrl) - .queryParam("SX", startX) - .queryParam("SY", startY) - .queryParam("EX", endX) - .queryParam("EY", endY) - .queryParam("apiKey", encodedApiKey) - .build(true) - .toUri(); - } - - private String createEncodedApiKey() { - try { - return URLEncoder.encode(apiKey, "UTF-8"); - - } catch (UnsupportedEncodingException e) { - throw new OdsayServerException(e.getMessage(), ODSAY_SERVER_ERROR); - } - } - - private OdsayRouteResponse executeWithRetry(URI requestURI) { - - try { - return retryTemplate.execute(retryContext -> callTransitRouteAPI(requestURI)); - } catch (InterruptedException e) { - log.error("ODsay API 호출 중 인터럽트 발생: {}. 요청 URI: {}", e.getMessage(), requestURI); - throw new RuntimeException(e.getMessage()); - } - } - - private OdsayRouteResponse callTransitRouteAPI(URI requestURI) throws InterruptedException { - semaphore.acquire(); - - try { - return restClient.get() - .uri(requestURI) - .retrieve() - .body(OdsayRouteResponse.class); - } finally { - scheduler.schedule(() -> semaphore.release(), MIN_INTERVAL_MS, TimeUnit.MILLISECONDS); - } - } - - private RetryTemplate createRetryTemplate() { - return RetryTemplate.builder() - .maxAttempts(MAX_ATTEMPTS) - .noBackoff() - .retryOn(OdsayTooManyRequestsException.class) - .build(); - } - - @PreDestroy - public void destroy() { - scheduler.shutdown(); - try { - if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) { - scheduler.shutdownNow(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - scheduler.shutdownNow(); - } - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiResponseDeserializer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiResponseDeserializer.java deleted file mode 100644 index bd1c0c8..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ODsayApiResponseDeserializer.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.odsay; - -import com.example.mohago_nocar.global.common.exception.InternalServerException; -import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayBadRequestException; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayServerException; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayTooManyRequestsException; -import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response.OdsayRouteResponse; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.jackson.JsonComponent; -import java.io.IOException; -import java.util.Arrays; -import java.util.Optional; - -@JsonComponent -@Slf4j -public class ODsayApiResponseDeserializer extends JsonDeserializer { - - private static final boolean TOO_SHORT_DISTANCE = true; - private static final boolean NORMAL_DISTANCE = false; - - @Override - public OdsayRouteResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) { - try { - JsonNode response = parseJsonResponse(jsonParser); - return processResponse(response); - } catch (IOException exception) { - log.error("IOException 발생"); - throw new InternalServerException(exception.getMessage()); - } - } - - private OdsayRouteResponse processResponse(JsonNode response) { - return ResponseValidator.hasError(response) ? - handleErrorResponse(response) : - handleSuccessResponse(response); - } - - private OdsayRouteResponse handleErrorResponse(JsonNode response) { - OdsayErrorCode errorCode = extractErrorCode(response); - - if (errorCode.isServerError()) { - log.error("ODsay API server error :{}", errorCode.getMessage()); - throw new OdsayServerException(); - } - - if (errorCode.isDistanceError()) { - return createTooShortDistanceResponse(); - } - - if (errorCode.isTooManyRequests()) { - throw new OdsayTooManyRequestsException(); - } - - log.error("ODsay API bad request error :{}", errorCode.getMessage()); - throw new OdsayBadRequestException(errorCode); - } - - private OdsayRouteResponse handleSuccessResponse(JsonNode response) { - return createSuccessResponse(response); - } - - /** - * 출, 도착지 사이 거리가 700m 이내여서 발생하는 에러를 성공 응답으로 변환합니다. - */ - private OdsayRouteResponse createTooShortDistanceResponse() { - return OdsayRouteResponse.of(null, TOO_SHORT_DISTANCE); - } - - private OdsayRouteResponse createSuccessResponse(JsonNode node) { - Optional result = find(node, "result"); - - if (result.isEmpty()) { - log.error("[ODsay] result 바인딩을 실패하였습니다."); - log.error("ODsay API response : {}", node.toPrettyString()); - throw new InternalServerException("ODsay API result 바인딩에 실패하였습니다."); - } - - return OdsayRouteResponse.of(result.get(), NORMAL_DISTANCE); - } - - private JsonNode parseJsonResponse(JsonParser jsonParser) throws IOException { - return jsonParser.getCodec().readTree(jsonParser); - } - - private OdsayErrorCode extractErrorCode(JsonNode response) { - JsonNode errorResponse = find(response, "error").get(); - - String errorCode = extractErrorInfo(errorResponse, "code"); - String errorMessage = extractErrorInfo(errorResponse, "message", "msg"); - OdsayErrorCode odsayErrorCode = OdsayErrorCode.from(errorCode); - - log.warn("ODsay errorMessage: {}", errorMessage); - log.warn("ODsay API returns error response : {}", odsayErrorCode); - - return odsayErrorCode; - } - - private String extractErrorInfo(JsonNode errorNode, String... fields) { - return Arrays.stream(fields) - .map(errorNode::findPath) - .filter(node -> !node.isMissingNode()) - .findFirst() - .map(JsonNode::asText) - .orElse(null); - } - - private Optional find(JsonNode node, String fieldName) { - try { - JsonNode result = node.get(fieldName); - return Optional.of(result); - } catch (NullPointerException exception) { - return Optional.empty(); - } - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ResponseValidator.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ResponseValidator.java deleted file mode 100644 index a9635a3..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/ResponseValidator.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.odsay; - -import com.fasterxml.jackson.databind.JsonNode; - -import java.util.Optional; - -public class ResponseValidator { - - public static boolean hasError(JsonNode response) { - return find(response, "error").isPresent(); - } - - private static Optional find(JsonNode node, String fieldName) { - try { - JsonNode result = node.get(fieldName); - return Optional.of(result); - } catch (NullPointerException exception) { - return Optional.empty(); - } - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java new file mode 100644 index 0000000..f5fd3f8 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java @@ -0,0 +1,93 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.global.common.exception.InternalServerException; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayTransitRouteResponse; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLEncoder; +import java.time.Duration; + +@Component +@Slf4j +public class ODsayApiClient { + + private static final int TIMEOUT_DURATION_SEC = 5; + private static final int INTERVAL_MS = 200; + private static final int PERMIT_THREAD_SIZE = 1; + private static final String ODSAY_RATE_LIMITER = "odsay"; + + private final RestClient restClient; + private final String apiKey; + private final String baseUrl; + + private final RateLimiter rateLimiter; + + public ODsayApiClient( + RestClient restClient, + @Value("${odsay.api-key}") String apiKey, + @Value("${odsay.url}") String baseUrl + ) { + RateLimiterRegistry rateLimiterRegistry = initializeRateLimiter(); + this.rateLimiter = rateLimiterRegistry.rateLimiter(ODSAY_RATE_LIMITER); + + this.restClient = restClient; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + public ODsayTransitRouteResponse searchTransitRoute(Coordinate origin, Coordinate destination) { + URI requestURI = buildRequestURI(origin, destination); + + return rateLimiter.executeSupplier(() -> executeApiCall(requestURI)); + } + + private ODsayTransitRouteResponse executeApiCall(URI requestURI) { + return restClient.get() + .uri(requestURI) + .retrieve() + .body(ODsayTransitRouteResponse.class); + } + + private URI buildRequestURI(Coordinate origin, Coordinate destination) { + String encodedApiKey = createEncodedApiKey(); + + return UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("SX", origin.getLongitude()) + .queryParam("SY", origin.getLatitude()) + .queryParam("EX", destination.getLongitude()) + .queryParam("EY", destination.getLatitude()) + .queryParam("apiKey", encodedApiKey) + .build(true) + .toUri(); + } + + private String createEncodedApiKey() { + try { + return URLEncoder.encode(apiKey, "UTF-8"); + + } catch (UnsupportedEncodingException e) { + throw new InternalServerException(e.getMessage()); + } + } + + private RateLimiterRegistry initializeRateLimiter() { + RateLimiterConfig config = RateLimiterConfig.custom() + .limitRefreshPeriod(Duration.ofMillis(INTERVAL_MS)) + .limitForPeriod(PERMIT_THREAD_SIZE) + .timeoutDuration(Duration.ofSeconds(TIMEOUT_DURATION_SEC)) + .build(); + + return RateLimiterRegistry.of(config); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteResponseDeserializer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteResponseDeserializer.java new file mode 100644 index 0000000..5f85d0d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteResponseDeserializer.java @@ -0,0 +1,166 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayTransitRouteResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.jackson.JsonComponent; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +@JsonComponent +@Slf4j +public class ODsayTransitRouteResponseDeserializer extends JsonDeserializer { + + private static final int SUBWAY = 1; + private static final int BUS = 2; + private static final int WALKING = 3; + + @Override + public ODsayTransitRouteResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException { + JsonNode responseJson = parseJsonResponse(jsonParser); + + return isInvalidResponse(responseJson) ? + createInvalidResponse(responseJson) : + createValidResponse(responseJson); + } + + private ODsayTransitRouteResponse createInvalidResponse(JsonNode responseJson) { + var errorInfoJson = find(responseJson, "error").get(); + var errorCode = getFields(errorInfoJson, "code"); + var errorMessage = getFields(errorInfoJson, "message", "msg"); + + return new ODsayRouteInvalidResponse(errorCode, errorMessage); + } + + private ODsayTransitRouteResponse createValidResponse(JsonNode responseJson) { + var pathJson = responseJson.get("result").get("path").get(0); + var infoJson = pathJson.get("info"); + + var totalDistance = infoJson.get("totalDistance").asDouble(); + var totalTime = infoJson.get("totalTime").asInt(); + + var subPathsJson = pathJson.get("subPath"); + var subPaths = streamJsonNodeOrEmpty(subPathsJson) + .map(this::parseByPathType) + .toList(); + + return new ODsayRouteValidResponse(totalTime, totalDistance, subPaths); + } + + private ODsayRouteValidResponse.SubPath parseByPathType(JsonNode pathJson) { + var distance = (pathJson.get("distance").asDouble()); + var sectionTime = pathJson.get("sectionTime").asInt(); + var trafficType = pathJson.get("trafficType").asInt(); + + return switch (trafficType) { + case SUBWAY -> createSubwayPath(distance, sectionTime, pathJson); + case BUS -> createBusPath(distance, sectionTime, pathJson); + case WALKING -> createWalkPath(distance, sectionTime); + default -> throw new IllegalStateException("Unexpected traffic type: " + trafficType); + }; + } + + private ODsayRouteValidResponse.SubPath createSubwayPath(double distance, int sectionTime, JsonNode pathJson) { + String startSubwayStationName = pathJson.get("startName").asText(); + double startSubwayStationLongitude = pathJson.get("startX").asDouble(); + double startSubwayStationLatitude = pathJson.get("startY").asDouble(); + + String endSubWayStationName = pathJson.get("endName").asText(); + double endSubwayStationLongitude = pathJson.get("endX").asDouble(); + double endSubwayStationLatitude = pathJson.get("endY").asDouble(); + + String subwayLineName = pathJson.get("lane").get(0).get("name").asText(); + + return ODsayRouteValidResponse.SubPath.builder() + .trafficType(SUBWAY) + .distanceMeter(distance) + .sectionTimeMin(sectionTime) + .startName(startSubwayStationName) + .startLongitude(startSubwayStationLongitude) + .startLatitude(startSubwayStationLatitude) + .endName(endSubWayStationName) + .endLongitude(endSubwayStationLongitude) + .endLatitude(endSubwayStationLatitude) + .subwayLineName(subwayLineName) + .build(); + } + + private ODsayRouteValidResponse.SubPath createBusPath(double distance, int sectionTime, JsonNode pathJson) { + String startBusStopName = pathJson.get("startName").asText(); + double startBusStopLongitude = pathJson.get("startX").asDouble(); + double startBusStopLatitude = pathJson.get("startY").asDouble(); + + String endBusStopName = pathJson.get("endName").asText(); + double endBusStopLongitude = pathJson.get("endX").asDouble(); + double endBusStopLatitude = pathJson.get("endY").asDouble(); + + String busNo = pathJson.get("lane").get(0).get("busNo").asText(); + int busType = pathJson.get("lane").get(0).get("type").asInt(); + + return ODsayRouteValidResponse.SubPath.builder() + .trafficType(BUS) + .distanceMeter(distance) + .sectionTimeMin(sectionTime) + .startName(startBusStopName) + .startLongitude(startBusStopLongitude) + .startLatitude(startBusStopLatitude) + .endName(endBusStopName) + .endLongitude(endBusStopLongitude) + .endLatitude(endBusStopLatitude) + .busNo(busNo) + .busType(busType) + .build(); + } + + private ODsayRouteValidResponse.SubPath createWalkPath(double distance, int sectionTime) { + return ODsayRouteValidResponse.SubPath.builder() + .trafficType(WALKING) + .distanceMeter(distance) + .sectionTimeMin(sectionTime) + .build(); + } + + private Stream streamJsonNodeOrEmpty(JsonNode node) { + if (node == null || !node.isArray()) { + return Stream.empty(); + } + return StreamSupport.stream(node.spliterator(), false); + } + + private boolean isInvalidResponse(JsonNode responseJson) { + return find(responseJson, "error").isPresent(); + } + + private JsonNode parseJsonResponse(JsonParser jsonParser) throws IOException { + return jsonParser.getCodec().readTree(jsonParser); + } + + private Optional find(JsonNode node, String fieldName) { + try { + JsonNode result = node.get(fieldName); + return Optional.of(result); + } catch (NullPointerException exception) { + return Optional.empty(); + } + } + + private String getFields(JsonNode jsonNode, String... fields) { + return Arrays.stream(fields) + .map(jsonNode::findPath) + .filter(node -> !node.isMissingNode()) + .findFirst() + .map(JsonNode::asText) + .orElse(null); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteInvalidResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteInvalidResponse.java new file mode 100644 index 0000000..2fc86b6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteInvalidResponse.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@RequiredArgsConstructor +@Getter +@ToString +public class ODsayRouteInvalidResponse extends ODsayTransitRouteResponse { + + private final String errorCode; + private final String errorMessage; + + @Override + public Boolean isValid() { + return false; + } + +} + diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteValidResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteValidResponse.java new file mode 100644 index 0000000..10dced1 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteValidResponse.java @@ -0,0 +1,49 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response; + +import lombok.*; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ToString +public class ODsayRouteValidResponse extends ODsayTransitRouteResponse { + + private final int totalTime; + private final double totalDistance; + + @Getter + @RequiredArgsConstructor + @Builder + @ToString + public static class SubPath { + + private final int trafficType; + private final double distanceMeter; + private final int sectionTimeMin; + + // common fields of types bus, subway + private final String startName; + private final Double startLongitude; + private final Double startLatitude; + private final String endName; + private final Double endLongitude; + private final Double endLatitude; + + // fields for bus + private final String busNo; + private final Integer busType; + + // fields for subway + private final String subwayLineName; + + } + + private final List subPaths; + + @Override + public Boolean isValid() { + return true; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayTransitRouteResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayTransitRouteResponse.java new file mode 100644 index 0000000..645068d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayTransitRouteResponse.java @@ -0,0 +1,11 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response; + +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayTransitRouteResponseDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(using = ODsayTransitRouteResponseDeserializer.class) +public abstract class ODsayTransitRouteResponse { + + public abstract Boolean isValid(); + +} From 13346e04087782b2a31a21feb8336259165dd22f Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 18:17:33 +0900 Subject: [PATCH 24/84] =?UTF-8?q?fix:=20TransitRoute=20class=EC=97=90=20?= =?UTF-8?q?=EC=B6=9C=EB=B0=9C=EC=A7=80,=20=EB=8F=84=EC=B0=A9=EC=A7=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transit/domain/model/TransitRoute.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java index 0710e13..f85fb75 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java @@ -1,19 +1,26 @@ package com.example.mohago_nocar.transit.domain.model; +import com.example.mohago_nocar.plan.domain.model.Location; import lombok.Builder; import lombok.Getter; +import lombok.ToString; import java.util.List; @Getter +@ToString public class TransitRoute { private final int totalTime; private final double totalDistance; private final List subPaths; + private final Location origin; + private final Location destination; - public static TransitRoute from(int totalTime, double totalDistance, List subPaths) { + public static TransitRoute from(Location origin, Location destination, int totalTime, double totalDistance, List subPaths) { return TransitRoute.builder() + .origin(origin) + .destination(destination) .totalTime(totalTime) .totalDistance(totalDistance) .subPaths(subPaths) @@ -21,19 +28,12 @@ public static TransitRoute from(int totalTime, double totalDistance, List subPaths) { + private TransitRoute(Location origin, Location destination, int totalTime, double totalDistance, List subPaths) { + this.origin = origin; + this.destination = destination; this.totalTime = totalTime; this.totalDistance = totalDistance; this.subPaths = subPaths; } - @Override - public String toString() { - List collect = subPaths.stream().map(Object::toString).toList(); - - return "TransitInfo{" + - "totalDistance=" + totalDistance + - ", subPaths=" + collect + - '}'; - } } From 496263edbda3f17eab6428f42994e137a5a97f32 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 18:20:34 +0900 Subject: [PATCH 25/84] =?UTF-8?q?refactor:=20Adaptor=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=B4=20ODsay=20API=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유효하지 않은 응답임이 확인되면 예외를 throw 하는 로직을 ODsay 어댑터에 작성하였습니다. --- .../converter/TransitRouteConverter.java | 178 ------------------ .../error/code/OdsayErrorCode.java | 4 +- .../exception/ODsayDistanceException.java | 12 ++ .../error/exception/ODsayRouteException.java | 12 ++ .../error/exception/OdsayServerException.java | 16 -- .../OdsayTooManyRequestsException.java | 13 -- .../dto/response/OdsayRouteResponse.java | 19 -- .../route/OdsayTransitRouteApiAdapter.java | 94 +++++++++ .../route/TransitRouteApiAdapter.java | 10 + .../route/odsay/TransitRouteConverter.java | 60 ++++++ 10 files changed, 190 insertions(+), 228 deletions(-) delete mode 100644 src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayDistanceException.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayServerException.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayTooManyRequestsException.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/dto/response/OdsayRouteResponse.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/OdsayTransitRouteApiAdapter.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java b/src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java deleted file mode 100644 index 3850aee..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/domain/converter/TransitRouteConverter.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.example.mohago_nocar.transit.domain.converter; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.global.common.domain.vo.Coordinate; -import com.example.mohago_nocar.transit.domain.model.*; -import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response.OdsayRouteResponse; -import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; - -import java.util.List; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; - -@Slf4j -public class TransitRouteConverter { - - private static final int SUBWAY = 1; - private static final int BUS = 2; - private static final int WALKING = 3; - private static final int EARTH_RADIUS = 6371; - private static final double METER_TO_KILOMETER = 0.001; - - public static TransitRoute convertRouteResponseDtoToTransitRoute( - OdsayRouteResponse routeResponseDto, Location origin, Location destination) { - if (routeResponseDto.isTooShortDistance()) { - return createWalkingRoute(origin, destination); - } - - JsonNode path = extractPath(routeResponseDto); - double totalDistance = extractTotalDistance(path); - int totalTime = extractTotalTime(path); - List subPaths = extractSubPaths(path); - - return TransitRoute.from(totalTime, totalDistance, subPaths); - } - - private static TransitRoute createWalkingRoute(Location origin, Location destination) { - double walkingDistance = getKmDist(origin, destination); - int walkingTime = (int) Math.round(walkingDistance * 15); - WalkPath walkPath = new WalkPath(walkingDistance, walkingTime); - - return TransitRoute.from(walkingTime, walkingDistance, List.of(walkPath)); - } - - - /** - * 두 위치(Location) 사이의 거리를 킬로미터 단위로 계산합니다. - * @param departure - * @param arrival - * @return 두 위치(Location) 사이의 거리 - */ - private static Double getKmDist(Location departure, Location arrival) { - Double dx = Math.abs(departure.getLongitude() - arrival.getLongitude()); - dx = Math.min(dx, 360 - dx); - - Double dy = Math.abs(departure.getLatitude() - arrival.getLatitude()); - - Double longitudeDist = convertLongitudeToKmDist(dx, departure.getLatitude()); - Double latitudeDist = convertLatitudeToKmDist(dy); - - return Math.sqrt(longitudeDist * longitudeDist + latitudeDist * latitudeDist); - } - - private static Double convertLongitudeToKmDist(Double dx, Double stdLatitude) { - - return EARTH_RADIUS * dx * Math.cos(stdLatitude) * Math.PI / 180; - } - - private static Double convertLatitudeToKmDist(Double dy) { - - return EARTH_RADIUS * dy * Math.PI / 180; - } - - private static JsonNode extractPath(OdsayRouteResponse routeResponseDto) { - return routeResponseDto.result().get("path").get(0); - } - - private static double extractTotalDistance(JsonNode path) { - JsonNode infoNode = path.get("info"); - double distanceInMeters = infoNode.get("totalDistance").asDouble(); - - return distanceInMeters * METER_TO_KILOMETER; - } - - private static int extractTotalTime(JsonNode path) { - JsonNode infoNode = path.get("info"); - - return infoNode.get("totalTime").asInt(); - } - - private static List extractSubPaths(JsonNode path) { - JsonNode subPathNode = path.get("subPath"); - return convertPathesNodeToSubPaths(subPathNode); - } - - private static List convertPathesNodeToSubPaths(JsonNode subPathsNode) { - return streamJsonNodeOrEmpty(subPathsNode) - .map(TransitRouteConverter::convertPathNodeToSubPath) - .toList(); - } - - private static SubPath convertPathNodeToSubPath(JsonNode subPathNode) { - double distance = (subPathNode.get("distance").asDouble()) * 0.001; - int sectionTime = subPathNode.get("sectionTime").asInt(); - int trafficType = subPathNode.get("trafficType").asInt(); - - return switch (trafficType) { - case SUBWAY -> createSubwayPath(distance, sectionTime, subPathNode); - case BUS -> createBusPath(distance, sectionTime, subPathNode); - case WALKING -> createWalkPath(distance, sectionTime); - default -> null; - }; - } - - private static SubPath createSubwayPath(double distance, int sectionTime, JsonNode subPathNode) { - String startSubwayStationName = subPathNode.get("startName").asText(); - String endSubWayStationName = subPathNode.get("endName").asText(); - - double startSubwayStationLongitude = subPathNode.get("startX").asDouble(); - double startSubwayStationLatitude = subPathNode.get("startY").asDouble(); - - double endSubwayStationLongitude = subPathNode.get("endX").asDouble(); - double endSubwayStationLatitude = subPathNode.get("endY").asDouble(); - - String subwayLineName = subPathNode.get("lane").get(0).get("name").asText(); - - return new SubwayPath( - distance, - sectionTime, - subwayLineName, - startSubwayStationName, - startSubwayStationLongitude, - startSubwayStationLatitude, - endSubWayStationName, - endSubwayStationLongitude, - endSubwayStationLatitude - ); - } - - private static SubPath createBusPath(double distance, int sectionTime, JsonNode subPathNode) { - String startBusStopName = subPathNode.get("startName").asText(); - String endBusStopName = subPathNode.get("endName").asText(); - - double startBusStopLongitude = subPathNode.get("startX").asDouble(); - double startBusStopLatitude = subPathNode.get("startY").asDouble(); - - double endBusStopLongitude = subPathNode.get("endX").asDouble(); - double endBusStopLatitude = subPathNode.get("endY").asDouble(); - - String busNo = subPathNode.get("lane").get(0).get("busNo").asText(); - int busType = subPathNode.get("lane").get(0).get("type").asInt(); - - return new BusPath( - distance, - sectionTime, - busNo, - busType, - startBusStopName, - startBusStopLongitude, - startBusStopLatitude, - endBusStopName, - endBusStopLongitude, - endBusStopLatitude - ); - } - - private static SubPath createWalkPath(double distance, int sectionTime) { - return new WalkPath(distance, sectionTime); - } - - private static Stream streamJsonNodeOrEmpty(JsonNode node) { - if (node == null || !node.isArray()) { - return Stream.empty(); - } - return StreamSupport.stream(node.spliterator(), false); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java index 28aa83e..4bb1b42 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java @@ -1,7 +1,7 @@ package com.example.mohago_nocar.transit.infrastructure.error.code; +import com.example.mohago_nocar.global.common.exception.InternalServerException; import com.example.mohago_nocar.global.common.exception.Status; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayServerException; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; @@ -47,7 +47,7 @@ public static OdsayErrorCode from(String code) { case "-99" -> NO_SEARCH_RESULTS; case "-1" -> COMPONENT_ERROR; case "429" -> TOO_MANY_REQUESTS; - default -> throw new OdsayServerException("unknown Error Code 발생 : "+ code, ODSAY_SERVER_ERROR); + default -> throw new InternalServerException("unknown Error Code 발생 : "+ code); }; } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayDistanceException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayDistanceException.java new file mode 100644 index 0000000..af53f17 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayDistanceException.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.transit.infrastructure.error.exception; + +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.Status; + +public class ODsayDistanceException extends CustomException { + + public ODsayDistanceException(Status status) { + super(status); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java new file mode 100644 index 0000000..c2f525b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.transit.infrastructure.error.exception; + +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.Status; + +public class ODsayRouteException extends CustomException { + + public ODsayRouteException(Status status) { + super(status); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayServerException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayServerException.java deleted file mode 100644 index 363bc30..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayServerException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.error.exception; - -import com.example.mohago_nocar.global.common.exception.CustomException; -import com.example.mohago_nocar.global.common.exception.Status; -import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; - -public class OdsayServerException extends CustomException { - - public OdsayServerException() { - super(OdsayErrorCode.ODSAY_SERVER_ERROR); - } - - public OdsayServerException(String message, Status status) { - super(message, status); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayTooManyRequestsException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayTooManyRequestsException.java deleted file mode 100644 index fa6a623..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayTooManyRequestsException.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.error.exception; - -import com.example.mohago_nocar.global.common.exception.CustomException; -import com.example.mohago_nocar.global.common.exception.Status; -import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; - -public class OdsayTooManyRequestsException extends CustomException { - - public OdsayTooManyRequestsException() { - super(OdsayErrorCode.TOO_MANY_REQUESTS); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/dto/response/OdsayRouteResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/dto/response/OdsayRouteResponse.java deleted file mode 100644 index 240763c..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/odsay/dto/response/OdsayRouteResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response; - -import com.fasterxml.jackson.databind.JsonNode; -import lombok.Builder; - -@Builder -public record OdsayRouteResponse( - JsonNode result, - boolean isTooShortDistance -) { - - public static OdsayRouteResponse of(JsonNode result, boolean isTooShortDistance) { - return OdsayRouteResponse.builder() - .result(result) - .isTooShortDistance(isTooShortDistance) - .build(); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/OdsayTransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/OdsayTransitRouteApiAdapter.java new file mode 100644 index 0000000..8a9922b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/OdsayTransitRouteApiAdapter.java @@ -0,0 +1,94 @@ +package com.example.mohago_nocar.transit.infrastructure.route; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.domain.model.WalkPath; +import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; +import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; +import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayDistanceException; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.TransitRouteConverter; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayTransitRouteResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OdsayTransitRouteApiAdapter implements TransitRouteApiAdapter { + + private static final int EARTH_RADIUS = 6371; + + private final ODsayApiClient odsayApiClient; + + @Override + public TransitRoute getTransitRouteBetweenLocations(Location origin, Location destination) { + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute(origin.getCoordinate(), destination.getCoordinate()); + if (!response.isValid()) { + try { + processInvalidResponse((ODsayRouteInvalidResponse)response); + } catch (ODsayDistanceException e) { + return createShortDistanceResponse(origin, destination); + } + } + + return processValidResponse(origin, destination, response); + } + + private void processInvalidResponse(ODsayRouteInvalidResponse response) { + log.warn("ODsay Invalid response: {}", response); + + OdsayErrorCode errorCode = OdsayErrorCode.from(response.getErrorCode()); + if (errorCode.isDistanceError()) { + throw new ODsayDistanceException(errorCode); + } + + throw new ODsayRouteException(errorCode); + } + + private TransitRoute processValidResponse(Location origin, Location destination, ODsayTransitRouteResponse response) { + return TransitRouteConverter.convertToTransitRoute((ODsayRouteValidResponse) response, origin, destination); + } + + private TransitRoute createShortDistanceResponse(Location origin, Location destination) { + double walkingDistance = getKmDist(origin.getCoordinate(), destination.getCoordinate()); + int walkingTime = (int) Math.round(walkingDistance * 15); + WalkPath walkPath = new WalkPath(walkingDistance, walkingTime); + + return TransitRoute.from(origin, destination, walkingTime, walkingDistance, List.of(walkPath)); + } + + + /** + * 두 위치(Location) 사이의 거리를 킬로미터 단위로 계산합니다. + * @param departure + * @param arrival + * @return 두 위치(Location) 사이의 거리 + */ + private Double getKmDist(Coordinate departure, Coordinate arrival) { + Double dx = Math.abs(departure.getLongitude() - arrival.getLongitude()); + dx = Math.min(dx, 360 - dx); + + Double dy = Math.abs(departure.getLatitude() - arrival.getLatitude()); + + Double longitudeDist = convertLongitudeToKmDist(dx, departure.getLatitude()); + Double latitudeDist = convertLatitudeToKmDist(dy); + + return Math.sqrt(longitudeDist * longitudeDist + latitudeDist * latitudeDist); + } + + private Double convertLongitudeToKmDist(Double dx, Double stdLatitude) { + return EARTH_RADIUS * dx * Math.cos(stdLatitude) * Math.PI / 180; + } + + private Double convertLatitudeToKmDist(Double dy) { + return EARTH_RADIUS * dy * Math.PI / 180; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java new file mode 100644 index 0000000..744f640 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java @@ -0,0 +1,10 @@ +package com.example.mohago_nocar.transit.infrastructure.route; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; + +public interface TransitRouteApiAdapter { + + TransitRoute getTransitRouteBetweenLocations(Location origin, Location destination); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java new file mode 100644 index 0000000..c915083 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java @@ -0,0 +1,60 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.*; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; + +import java.util.List; + +public class TransitRouteConverter { + + private static final double METER_TO_KILOMETER = 0.001; + private static final int SUBWAY = 1; + private static final int BUS = 2; + private static final int WALKING = 3; + + public static TransitRoute convertToTransitRoute(ODsayRouteValidResponse response, Location origin, Location destination) { + List subPaths = response.getSubPaths().stream() + .map(path -> + switch (path.getTrafficType()) { + case SUBWAY -> createSubWay(path); + case BUS -> createBus(path); + case WALKING -> createWalking(path); + default -> throw new IllegalStateException("Unexpected value: " + path.getTrafficType()); + } + ).toList(); + + return TransitRoute.from(origin, destination, response.getTotalTime(), response.getTotalDistance(), subPaths); + } + + private static SubPath createSubWay(ODsayRouteValidResponse.SubPath path) { + return new SubwayPath( + path.getDistanceMeter() * METER_TO_KILOMETER, + path.getSectionTimeMin(), + path.getSubwayLineName(), + path.getStartName(), + Coordinate.from(path.getStartLongitude(), path.getStartLatitude()), + path.getEndName(), + Coordinate.from(path.getEndLongitude(), path.getEndLatitude()) + ); + } + + private static SubPath createBus(ODsayRouteValidResponse.SubPath path) { + return new BusPath( + path.getDistanceMeter() * METER_TO_KILOMETER, + path.getSectionTimeMin(), + path.getBusNo(), + path.getBusType(), + path.getStartName(), + Coordinate.from(path.getStartLongitude(), path.getStartLatitude()), + path.getEndName(), + Coordinate.from(path.getEndLongitude(), path.getEndLatitude()) + ); + } + + private static SubPath createWalking(ODsayRouteValidResponse.SubPath path) { + return new WalkPath(path.getDistanceMeter(), path.getSectionTimeMin()); + } + +} From 24e9bbc5df2600bee108546e23e661b729a18eb0 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 18:25:53 +0900 Subject: [PATCH 26/84] =?UTF-8?q?refactor:=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...sitRouteApiAdapter.java => ODsayTransitRouteApiAdapter.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/{OdsayTransitRouteApiAdapter.java => ODsayTransitRouteApiAdapter.java} (98%) diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/OdsayTransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapter.java similarity index 98% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/OdsayTransitRouteApiAdapter.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapter.java index 8a9922b..acc6b05 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/OdsayTransitRouteApiAdapter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapter.java @@ -21,7 +21,7 @@ @Component @RequiredArgsConstructor @Slf4j -public class OdsayTransitRouteApiAdapter implements TransitRouteApiAdapter { +public class ODsayTransitRouteApiAdapter implements TransitRouteApiAdapter { private static final int EARTH_RADIUS = 6371; From d2ae439a29e169701aedb048896553e76d5eb1b7 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 18:27:53 +0900 Subject: [PATCH 27/84] =?UTF-8?q?feat:=20ODsay=20API=20client,=20adaptor?= =?UTF-8?q?=20test=20=EC=9E=91=EC=84=B1=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ODsayTransitRouteApiAdapterTest.java | 100 ++++++++++++++++++ .../route/odsay/ODsayApiClientTest.java | 71 +++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java create mode 100644 src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java new file mode 100644 index 0000000..58cffd5 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java @@ -0,0 +1,100 @@ +package com.example.mohago_nocar.transit.infrastructure.route; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.domain.model.WalkPath; +import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@SpringBootTest +@ActiveProfiles("test") +class ODsayTransitRouteApiAdapterTest { + + @Autowired + ODsayTransitRouteApiAdapter adapter; + + @MockBean + ODsayApiClient odsayApiClient; + + @DisplayName("odsay API가 유효한 응답을 주면 TransitRoute 객체로 변환한다.") + @Test + public void getTransitRouteBetweenLocations_whenODsayReturnValidResponse() { + //given + Location origin = Location.of("출발지", Coordinate.from(126.872939584803, 37.3700357495453)); + Location dest = Location.of("도착지", Coordinate.from(126.8834795656736, 37.351812431636645)); + + ODsayRouteValidResponse mockValidResponse = mock(ODsayRouteValidResponse.class); + when(mockValidResponse.isValid()).thenReturn(true); + when(mockValidResponse.getSubPaths()).thenReturn(List.of()); + when(odsayApiClient.searchTransitRoute(origin.getCoordinate(), dest.getCoordinate()) + ).thenReturn(mockValidResponse); + + //when + TransitRoute transitRoute = adapter.getTransitRouteBetweenLocations(origin, dest); + System.out.println(transitRoute); + + //then + assertThat(transitRoute).isNotNull(); + verify(odsayApiClient).searchTransitRoute(origin.getCoordinate(), dest.getCoordinate()); + } + + @DisplayName("odsay API가 유효하지 않은 응답을 주면 예외를 발생시킨다.") + @Test + public void getTransitRouteBetweenLocations_whenODsayReturnInvalidResponse(){ + //given + Location origin = Location.of("출발지", Coordinate.from(126.872939584803, 37.3700357495453)); + Location dest = Location.of("도착지", Coordinate.from(126.8834795656736, 37.351812431636645)); + + ODsayRouteInvalidResponse mockInvalidResponse = mock(ODsayRouteInvalidResponse.class); + when(mockInvalidResponse.isValid()).thenReturn(false); + when(mockInvalidResponse.getErrorCode()).thenReturn("500"); + + when(odsayApiClient.searchTransitRoute(origin.getCoordinate(), dest.getCoordinate()) + ).thenReturn(mockInvalidResponse); + + //when //then + assertThatThrownBy(()-> adapter.getTransitRouteBetweenLocations(origin, dest)) + .isInstanceOf(ODsayRouteException.class) + .hasMessage("ODsay 서버에 오류가 발생하였습니다. ODsay API를 확인해주세요."); + } + + @DisplayName("ODsay API가 Distance Error 응답을 주면 도보 이동 경로를 생성한다.") + @Test + public void getTransitRouteBetweenLocations_whenODsayReturnShortDistanceError(){ + //given + Location origin = Location.of("출발지", Coordinate.from(126.872939584803, 37.3700357495453)); + Location dest = Location.of("도착지", Coordinate.from(126.872939584803, 37.3700357495453)); + + ODsayRouteInvalidResponse mockInvalidResponse = mock(ODsayRouteInvalidResponse.class); + when(mockInvalidResponse.isValid()).thenReturn(false); + when(mockInvalidResponse.getErrorCode()).thenReturn("-98"); + + when(odsayApiClient.searchTransitRoute(origin.getCoordinate(), dest.getCoordinate()) + ).thenReturn(mockInvalidResponse); + + //when + TransitRoute transitRoute = adapter.getTransitRouteBetweenLocations(origin, dest); + System.out.println(transitRoute); + + //then + Assertions.assertThat(transitRoute.getSubPaths().get(0)) + .isInstanceOf(WalkPath.class); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java new file mode 100644 index 0000000..5832d13 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java @@ -0,0 +1,71 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayTransitRouteResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class ODsayApiClientTest { + + @Autowired + private ODsayApiClient odsayApiClient; + + @DisplayName("출발지와 도착지 간의 대중교통 경로를 조회할 수 있다.") + @Test + public void searchTransitRoute(){ + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate dest = Coordinate.from(126.8834795656736, 37.351812431636645); + + //when + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute(origin, dest); + + //then + assertThat(response).isInstanceOf(ODsayTransitRouteResponse.class); + assertThat(response).isInstanceOf(ODsayRouteValidResponse.class); + } + + @DisplayName("출발지와 도착지 간의 거리가 700m 이내이면 유효하지 않은 응답이 반환된다.") + @Test + public void searchTransitRoute_whenDistanceLessThan700Meter(){ + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate dest = Coordinate.from(126.872939584803, 37.3700357495453); + + //when + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute(origin, dest); + + //then + assertThat(response).isInstanceOf(ODsayTransitRouteResponse.class); + assertThat(response).isInstanceOf(ODsayRouteInvalidResponse.class); + } + +/* @DisplayName("호출 간격을 준수하며 API 요청을 보낸다.") + @Test + public void searchTransitRoute_withRateLimit(){ + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate dest = Coordinate.from(126.8834795656736, 37.351812431636645); + + //when //then + List responses = IntStream.range(0, 10) + .mapToObj(i -> odsayApiClient.searchTransitRoute(origin, dest)) + .filter(response -> response instanceof ODsayRouteValidResponse) + .toList(); + + assertThat(responses).hasSize(10); + }*/ + +} \ No newline at end of file From 67188073d76191e1ee33cdbb9cc6390d07aad51b Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 18:42:28 +0900 Subject: [PATCH 28/84] =?UTF-8?q?refactor:=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9C=A0=EC=A7=80=EB=B3=B4=EC=88=98=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=EC=9D=84=20=EC=9C=84=ED=95=9C=20TravelServic?= =?UTF-8?q?e=20=EC=88=98=EC=A0=95=20=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 비동기 논블로킹 방식으로 외부 API 호출 - 외부 클래스로 분리된 최적 여행 경로 계산 메서드 호출 - 서로 다른 장소가 같은 경위도를 가지는 문제를 해결하기 위해 TravelCatalog class 대신 Location class를 이용 --- .../plan/application/TravelPlanService.java | 344 ++++++------------ .../plan/domain/model/TravelCatalog.java | 63 ---- .../domain/model/TravelLocationWithName.java | 35 -- 3 files changed, 109 insertions(+), 333 deletions(-) delete mode 100644 src/main/java/com/example/mohago_nocar/plan/domain/model/TravelCatalog.java delete mode 100644 src/main/java/com/example/mohago_nocar/plan/domain/model/TravelLocationWithName.java diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java index 6578d6a..98e22a4 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java @@ -2,32 +2,30 @@ import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.festival.domain.repository.FestivalRepository; -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.global.common.exception.InternalServerException; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.global.common.exception.InvalidValueException; import com.example.mohago_nocar.place.application.PlaceService; import com.example.mohago_nocar.place.domain.model.Place; import com.example.mohago_nocar.place.domain.repository.PlaceRepository; -import com.example.mohago_nocar.plan.domain.model.TravelLocationWithName; -import com.example.mohago_nocar.plan.domain.model.TravelCatalog; +import com.example.mohago_nocar.plan.application.strategy.RouteOptimizationStrategy; +import com.example.mohago_nocar.plan.domain.model.Location; import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; import com.example.mohago_nocar.plan.presentation.response.PlanTravelCourseResponseDto; +import com.example.mohago_nocar.plan.presentation.response.TravelRouteResponseDto; import com.example.mohago_nocar.transit.domain.model.TransitRoute; -import com.example.mohago_nocar.transit.domain.model.WalkPath; -import com.example.mohago_nocar.transit.infrastructure.externalApi.google.GoogleApiClient; -import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.GoogleDistanceMatrixResponse; -import com.example.mohago_nocar.transit.infrastructure.externalApi.converter.DistanceMatrixConverter; -import com.example.mohago_nocar.transit.domain.converter.TransitRouteConverter; -import com.example.mohago_nocar.transit.infrastructure.externalApi.google.dto.response.RouteSpecification; -import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.ODsayApiClient; -import com.example.mohago_nocar.transit.infrastructure.externalApi.odsay.dto.response.OdsayRouteResponse; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.LocalDate; import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; import java.util.stream.IntStream; import static com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode.TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD; @@ -37,277 +35,153 @@ @Slf4j public class TravelPlanService implements TravelPlanUseCase { - private static final int FIRST = 0; - private final PlaceRepository placeRepository; private final FestivalRepository festivalRepository; private final PlaceService placeService; - private final GoogleApiClient googleApiClient; - private final ODsayApiClient oDsayApiClient; + private final RouteOptimizationStrategy routeOptimizationStrategy; + private final ExecutorService executor; + private final TransitRouteApiAdapter transitRouteApiAdapter; + private final DistanceDurationApiAdapter distanceDurationApiAdapter; @Override - public List planCourse(PlanTravelCourseRequestDto dto) { + public PlanTravelCourseResponseDto planCourse(PlanTravelCourseRequestDto dto) { Festival festival = validateAndGetFestival(dto); - List places = getTravelPlaces(festival, dto.placeIds()); + List attractions = getAttractions(festival, dto.placeIds()); - TravelCatalog travelCatalog = TravelCatalog.of(festival, places); - List distinctLocations = travelCatalog.getDistinctLocations(); + Map> namesByCoordinate = mergeFestivalAndAttractionName(festival, attractions); - List routeSpecifications = fetchDistanceAndDurationBetween(distinctLocations); - List optimizedRoute = getOptimalRoute(distinctLocations, routeSpecifications); + List optimalRouteCoordinates = findOptimalRoute(namesByCoordinate); + List optimalRouteLocations = mapCoordinatesToLocations(namesByCoordinate, optimalRouteCoordinates); - List travelCourse = createTravelCourse(optimizedRoute, travelCatalog); - log.info(travelCourse.toString()); - return travelCourse; - } + List responses = searchTransitRoutes(optimalRouteLocations).stream() + .map(TravelRouteResponseDto::of) + .toList(); - private Festival validateAndGetFestival(PlanTravelCourseRequestDto dto) { - Festival festival = festivalRepository.getFestivalById(dto.festivalId()); - ensureTravelDateDuringFestival(festival, dto.travelDate()); - return festival; + return PlanTravelCourseResponseDto.of(responses); } - private void ensureTravelDateDuringFestival(Festival festival, LocalDate travelDate) { - if (!festival.isDateDuringFestival(travelDate)) { - throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); - } - } + private List findOptimalRoute(Map> namesByCoordinate) { + var coordinates = collectCoordinate(namesByCoordinate); + var routeMetrics = fetchDistanceAndDurations(coordinates); - private List getTravelPlaces(Festival festival, List placeIds) { - List places = placeRepository.findByIds(festival.getId(), placeIds); - if (places.isEmpty()) { - places = placeService.cachePlaces(festival.getId(), festival.getLocation()).stream() - .filter(place -> placeIds.contains(place.getId())) - .toList(); - } - return places; + return routeOptimizationStrategy.calculateOptimalRoute(coordinates, routeMetrics); } - private List fetchDistanceAndDurationBetween(List distinctLocations) { - List routeSpecs = new ArrayList<>(); - int toVisitSize = distinctLocations.size(); - - for (int originIndex = FIRST; originIndex < toVisitSize; originIndex++) { - Location origin = distinctLocations.get(originIndex); - List destinations = createDestination(distinctLocations, originIndex); - - GoogleDistanceMatrixResponse matrix = googleApiClient.getDistanceMatrix(origin, destinations); - routeSpecs.addAll( - DistanceMatrixConverter.convertMatrixToRouteSpecs(matrix, toVisitSize -1, origin, destinations)); - } - - return routeSpecs; + private List collectCoordinate(Map> namesByCoordinate) { + return namesByCoordinate.keySet().stream().toList(); } - private List createDestination(List distinctLocations, int excludeIndex) { - return IntStream.range(0, distinctLocations.size()) - .filter(index -> index != excludeIndex) - .mapToObj(distinctLocations::get) + /** + * 좌표 간의 거리(km), 이동 시간(minutes)를 가져오는 외부 API를 호출하여 응답을 생성합니다. + * @param coordinates 거리, 이동 시간을 구하는 대상 좌표 + * @return 좌표 간의 거리 및 이동시간 + */ + private List fetchDistanceAndDurations(List coordinates) { + var futures = IntStream.range(0, coordinates.size()) + .mapToObj(index -> asyncGetDistanceDuration(coordinates, index)) .toList(); - } - - private List getOptimalRoute(List distinctLocations, List routeSpecificationBetweenLocations) { - int locationCount = distinctLocations.size(); - Map> fromToTransitInfoMap = new HashMap<>(); - for (int originIndex = FIRST; originIndex < locationCount; originIndex++) { - Map toLocationTransitInfoMap = new HashMap<>(); + CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join(); - for (int destinationIndex = FIRST; destinationIndex < locationCount; destinationIndex++) { - - if (originIndex == destinationIndex) { - continue; - } - - Location origin = distinctLocations.get(originIndex); - Location destination = distinctLocations.get(destinationIndex); - - RouteSpecification routeSpec = getMatchedRouteSpec(origin, destination, routeSpecificationBetweenLocations); - toLocationTransitInfoMap.put(destination, routeSpec); - } - - fromToTransitInfoMap.put(distinctLocations.get(originIndex), toLocationTransitInfoMap); - } - - List route = new ArrayList<>(); - List isSelected = new ArrayList<>(); - List optimalRoute = new ArrayList<>(); - for (int i = 0; i < locationCount; i++) { - isSelected.add(false); - optimalRoute.add(distinctLocations.get(i)); - } - - routeBacktracking(0, distinctLocations, fromToTransitInfoMap , optimalRoute, route, isSelected); - return optimalRoute; + return futures.stream() + .map(CompletableFuture::join) + .flatMap(Collection::stream) + .toList(); } - private RouteSpecification getMatchedRouteSpec( - Location origin, - Location destination, - List routeSpecificationBetweenLocations - ) { - Optional routeSpecification = routeSpecificationBetweenLocations.stream() - .filter(route -> route.isEqualLocation(origin, destination)) - .findFirst(); + private List searchTransitRoutes(List optimalRouteLocations) { + return IntStream.range(0, optimalRouteLocations.size() - 1) + .mapToObj(index -> { + Location origin = optimalRouteLocations.get(index); + Location destination = optimalRouteLocations.get(index + 1); - if (routeSpecification.isEmpty()) { - log.error("origin-{}, destination-{}를 가지는 RouteSpec을 찾을 수 없습니다.", origin, destination); - throw new InternalServerException(); - } - - return routeSpecification.get(); + return transitRouteApiAdapter.getTransitRouteBetweenLocations(origin, destination); + }) + .toList(); } - private void routeBacktracking( - int k, - List locations, - Map> transitMaps, - List optimal, - List route, - List isSelected + private List mapCoordinatesToLocations( + Map> namesByCoordinate, + List coordinates ) { - int n = locations.size(); - - if (k == n) - { - int t1 = calcTravelTime(optimal, transitMaps); - int t2 = calcTravelTime(route, transitMaps); - - if (t1 > t2) { - for (int i = 0; i < n; i++) { - optimal.set(i, route.get(i)); - } - } - return; - } - - for (int i = 0; i < n; i++) { - if (isSelected.get(i)) { - continue; - } - - isSelected.set(i, true); - route.add(locations.get(i)); - routeBacktracking(k + 1, locations, transitMaps, optimal, route, isSelected); - route.removeLast(); - isSelected.set(i, false); - } + return coordinates.stream() + .flatMap(coordinate -> { + List names = namesByCoordinate.get(coordinate); + return names.stream() + .map(name -> Location.of(name, coordinate)); + }).toList(); } - private int calcTravelTime(List route, Map> routeMaps) { - int n = route.size(); - - int travelTime = 0; - for (int i = 0; i < n - 1; i++) { - travelTime += routeMaps.get(route.get(i)).get(route.get(i + 1)).durationInMinutes(); - } - return travelTime; + private CompletableFuture> asyncGetDistanceDuration(List coordinates, int index) { + return CompletableFuture.supplyAsync(() -> distanceDurationApiCall(index, coordinates), executor); } + private List distanceDurationApiCall(int index, List coordinates) { + Coordinate origin = coordinates.get(index); + List destinations = createDestination(coordinates, index); - private List createTravelCourse( - List optimizedRoute, - TravelCatalog travelCatalog - ) { - Map locationNameMap = travelCatalog.createLocationNameMap(); - List travelCourse = new ArrayList<>(); - - for (int index = FIRST; index < optimizedRoute.size() - 1; index++) { - Location origin = optimizedRoute.get(index); - Location destination = optimizedRoute.get(index + 1); - - String originName = handleDuplicateOrigins(origin, travelCourse, locationNameMap, travelCatalog); - String destinationName = locationNameMap.get(destination); - - TransitRoute transitRoute = getTransitRouteBetweenLocations(origin, destination); - travelCourse.add(createResponse(origin, originName, destination, destinationName, transitRoute)); - - handleDuplicateDestinations(travelCatalog, travelCourse, destination, destinationName); - } - - return travelCourse; + return distanceDurationApiAdapter.getDistanceAndDuration(origin, destinations); } - private String handleDuplicateOrigins( - Location location, - List travelCourse, - Map locationNameMap, - TravelCatalog travelCatalog - ) { - if (travelCatalog.checkDuplicateLocation(location)) { - List duplicatedLocations = travelCatalog.getTravelLocations(location); - List responses = createResponses(duplicatedLocations); - travelCourse.addAll(responses); - return responses.getLast().endPlaceName(); // 마지막 장소 이름 반환 - } - - return locationNameMap.get(location); // 중복이 없으면 기존 이름 반환 + private List createDestination(List coordinates, int excludeIndex) { + return IntStream.range(0, coordinates.size()) + .filter(index -> index != excludeIndex) + .mapToObj(coordinates::get) + .toList(); } - private TransitRoute getTransitRouteBetweenLocations(Location origin, Location destination) { - OdsayRouteResponse response = oDsayApiClient.searchTransitRoute( - origin.getLongitude(), origin.getLatitude(), destination.getLongitude(), destination.getLatitude()); - return TransitRouteConverter.convertRouteResponseDtoToTransitRoute(response, origin, destination); + private Festival validateAndGetFestival(PlanTravelCourseRequestDto dto) { + var festival = festivalRepository.getFestivalById(dto.festivalId()); + ensureTravelDateDuringFestival(festival, dto.travelDate()); + return festival; } - private void handleDuplicateDestinations( - TravelCatalog travelCatalog, List travelCourse, - Location destinationLocation, String destinationName - ) { - if (travelCatalog.checkDuplicateLocation(destinationLocation)) { - List duplicatedLocations = travelCatalog.getTravelLocations(destinationLocation); - List responseDtos = createResponsesStartWith(destinationName, duplicatedLocations); - travelCourse.addAll(responseDtos); + private void ensureTravelDateDuringFestival(Festival festival, LocalDate travelDate) { + if (!festival.isDateDuringFestival(travelDate)) { + throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); } } - private List createResponsesStartWith( - String startName, List duplicatedLocations) { - // 시작 지점 탐색 - int startIndex = -1; - for (int i = 0; i < duplicatedLocations.size(); i++) { - if (duplicatedLocations.get(i).getName().equals(startName)) { - startIndex = i; - break; - } - } - - // 시작 지점이 없을 경우 예외 처리 - if (startIndex == -1) { - throw new IllegalArgumentException("Start name not found in locations."); + private List getAttractions(Festival festival, List placeIds) { + List places = placeRepository.findByIds(festival.getId(), placeIds); + if (places.isEmpty()) { + places = placeService.cachePlaces(festival.getId(), festival.getCoordinate()).stream() + .filter(place -> placeIds.contains(place.getId())) + .toList(); } + return places; + } - if (startIndex != 0) { - Collections.swap(duplicatedLocations, 0, startIndex); - } + private Map> mergeFestivalAndAttractionName(Festival festival, List attractions) { + var festivalNameByCoordinate = mapFestivalNameToCoordinate(festival); + var placeNamesByCoordinate = mapPlaceNamesToCoordinate(attractions); - return createResponses(duplicatedLocations); + return mergeNameMaps(festivalNameByCoordinate, placeNamesByCoordinate); } - private List createResponses(List duplicatedLocations) { - List responseBetweenSameLocations = new ArrayList<>(); - - for (int from = 0; from < duplicatedLocations.size() -1; from++) { - PlanTravelCourseResponseDto dto = PlanTravelCourseResponseDto.of( - duplicatedLocations.get(from).getLocation(), - duplicatedLocations.get(from).getName(), - duplicatedLocations.get(from + 1).getLocation(), - duplicatedLocations.get(from + 1).getName(), - TransitRoute.from(0, 0, List.of(new WalkPath((double) 0, 1))) - ); - responseBetweenSameLocations.add(dto); - } + private Map mapFestivalNameToCoordinate(Festival festival) { + return Map.of(festival.getCoordinate(), festival.getName()); + } - return responseBetweenSameLocations; + private Map> mapPlaceNamesToCoordinate(List attractions) { + return attractions.stream() + .collect(Collectors.groupingBy( + Place::getCoordinate, Collectors.mapping(Place::getName, Collectors.toList()) + )); } - private PlanTravelCourseResponseDto createResponse( - Location originLocation, String originName, - Location destinationLocation, String destinationName, - TransitRoute transitRoute - ) { - return PlanTravelCourseResponseDto.of( - originLocation, originName, destinationLocation, destinationName, transitRoute); + private Map> mergeNameMaps(Map festivalNameByCoordinate, Map> placeNamesByCoordinate) { + festivalNameByCoordinate.forEach((coordinate, festivalName) -> { + placeNamesByCoordinate.merge( + coordinate, + List.of(festivalName), + (existingNames, newNames) -> { + existingNames.addAll(newNames); + return existingNames; + }); + }); + + return placeNamesByCoordinate; } } diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelCatalog.java b/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelCatalog.java deleted file mode 100644 index 8a5bb79..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelCatalog.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.example.mohago_nocar.plan.domain.model; - -import com.example.mohago_nocar.festival.domain.model.Festival; -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.place.domain.model.Place; -import lombok.Builder; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class TravelCatalog { - - private final List travelLocations; - - public static TravelCatalog of(List travelLocations) { - return TravelCatalog.builder() - .travelLocations(travelLocations) - .build(); - } - - public static TravelCatalog of(Festival festival, List places) { - List travelLocations = new ArrayList<>(); - travelLocations.add(TravelLocationWithName.of(festival)); - travelLocations.addAll(places.stream() - .map(TravelLocationWithName::of) - .toList()); - - return TravelCatalog.of(travelLocations); - } - - public List getDistinctLocations() { - return travelLocations.stream() - .map(TravelLocationWithName::getLocation) - .distinct() - .collect(Collectors.toList()); - } - - public boolean checkDuplicateLocation(Location location) { - long locationCount = travelLocations.stream() - .filter(place -> place.getLocation().equals(location)) - .count(); - return locationCount > 1; - } - - public List getTravelLocations(Location location) { - return travelLocations.stream() - .filter(place -> place.getLocation().equals(location)) - .toList(); - } - - public Map createLocationNameMap() { - return this.travelLocations.stream() - .collect(Collectors.toMap(TravelLocationWithName::getLocation, TravelLocationWithName::getName)); - } - - @Builder - private TravelCatalog(List travelLocations) { - this.travelLocations = travelLocations; - } - -} diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelLocationWithName.java b/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelLocationWithName.java deleted file mode 100644 index dc3adfd..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/domain/model/TravelLocationWithName.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.mohago_nocar.plan.domain.model; - -import com.example.mohago_nocar.festival.domain.model.Festival; -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.place.domain.model.Place; -import lombok.Builder; -import lombok.Getter; - -@Getter -public class TravelLocationWithName { - - private String name; - private Location location; - - public static TravelLocationWithName of(Festival festival) { - return TravelLocationWithName.builder() - .name(festival.getName()) - .location(festival.getLocation()) - .build(); - } - - public static TravelLocationWithName of(Place place) { - return TravelLocationWithName.builder() - .name(place.getName()) - .location(place.getLocation()) - .build(); - } - - @Builder - private TravelLocationWithName(String name, Location location) { - this.name = name; - this.location = location; - } - -} From 16c7a973b884c5e786b6787548cef33f7e35f8b7 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 24 Feb 2025 21:49:55 +0900 Subject: [PATCH 29/84] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EC=82=AD=EC=A0=9C=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/presentation/response/WalkPathResponseDto.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java index 399bfd7..39e2919 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java @@ -1,6 +1,5 @@ package com.example.mohago_nocar.plan.presentation.response; -import com.example.mohago_nocar.transit.domain.model.PathType; import com.example.mohago_nocar.transit.domain.model.SubPath; import com.example.mohago_nocar.transit.domain.model.WalkPath; import lombok.Builder; From 866ee43eee33c3a3a7c7b48505f0f1aa320ecfd3 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 26 Feb 2025 03:25:11 +0900 Subject: [PATCH 30/84] =?UTF-8?q?fix:=20429=20error=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20odsay=20API?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=EA=B0=84=EA=B2=A9=20=EC=A1=B0=EC=A0=95?= =?UTF-8?q?=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transit/infrastructure/route/odsay/ODsayApiClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java index f5fd3f8..ee1d3d3 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java @@ -21,8 +21,8 @@ @Slf4j public class ODsayApiClient { - private static final int TIMEOUT_DURATION_SEC = 5; - private static final int INTERVAL_MS = 200; + private static final int TIMEOUT_DURATION_SEC = 30; + private static final int INTERVAL_MS = 210; private static final int PERMIT_THREAD_SIZE = 1; private static final String ODSAY_RATE_LIMITER = "odsay"; From d14b8087f908a64f17f3dbb2638abd8e113b51a3 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 26 Feb 2025 03:26:17 +0900 Subject: [PATCH 31/84] =?UTF-8?q?feat:=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EC=8A=A4=EB=A0=88=EB=93=9C=20=EC=82=AC=EC=9A=A9=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20ExecutorService=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mohago_nocar/global/config/ExecutorServiceConfig.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java index 16ebcdc..3f21f31 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java @@ -17,4 +17,10 @@ public ExecutorService virtualThreadExecutor(){ return Executors.newVirtualThreadPerTaskExecutor(); } + @Bean + @ConditionalOnThreading(Threading.PLATFORM) + public ExecutorService platformThreadExecutor(){ + return Executors.newCachedThreadPool(); + } + } From f2b5eb52e58a7e7f021ede5f641c218c58d19703 Mon Sep 17 00:00:00 2001 From: mungsil Date: Thu, 27 Feb 2025 00:36:30 +0900 Subject: [PATCH 32/84] =?UTF-8?q?refactor:=20=EB=B3=80=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/strategy/ShortestTimeRouteStrategy.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java index 15f4411..73609d3 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java @@ -55,16 +55,16 @@ private RouteMetrics getMatchedRouteSpec( Coordinate destination, List routeMetricsBetweenLocations ) { - Optional routeSpecification = routeMetricsBetweenLocations.stream() + Optional routeMetrics = routeMetricsBetweenLocations.stream() .filter(route -> route.isEqualLocation(origin, destination)) .findFirst(); - if (routeSpecification.isEmpty()) { + if (routeMetrics.isEmpty()) { log.error("origin-{}, destination-{}를 가지는 RouteSpec을 찾을 수 없습니다.", origin, destination); throw new InternalServerException(); } - return routeSpecification.get(); + return routeMetrics.get(); } private void routeBacktracking( From 6f64df61c98a05ba000f68a4467d47deeb821c21 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 1 Mar 2025 01:39:43 +0900 Subject: [PATCH 33/84] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A3=BC=EC=84=9D=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mohago_nocar/global/config/ExecutorServiceConfig.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java index 3f21f31..fb33484 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java @@ -17,10 +17,10 @@ public ExecutorService virtualThreadExecutor(){ return Executors.newVirtualThreadPerTaskExecutor(); } - @Bean +/* @Bean @ConditionalOnThreading(Threading.PLATFORM) - public ExecutorService platformThreadExecutor(){ + public ExecutorService cachedThreadExecutor(){ return Executors.newCachedThreadPool(); - } + }*/ } From 6d0e1f38320cfcbe077a87097d4228e9830d5512 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 1 Mar 2025 01:54:01 +0900 Subject: [PATCH 34/84] =?UTF-8?q?feat:=20=EB=B0=B1=ED=8A=B8=EB=9E=98?= =?UTF-8?q?=ED=82=B9=20=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=EC=9D=84=20for?= =?UTF-8?q?kJoinPool=EC=97=90=EC=84=9C=20=EC=8B=A4=ED=96=89=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/application/TravelPlanService.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java index 98e22a4..8b09701 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java @@ -23,8 +23,7 @@ import java.time.LocalDate; import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; +import java.util.concurrent.*; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -35,6 +34,8 @@ @Slf4j public class TravelPlanService implements TravelPlanUseCase { + private final ForkJoinPool forkJoinPool = ForkJoinPool.commonPool(); + private final PlaceRepository placeRepository; private final FestivalRepository festivalRepository; private final PlaceService placeService; @@ -64,7 +65,10 @@ private List findOptimalRoute(Map> namesByC var coordinates = collectCoordinate(namesByCoordinate); var routeMetrics = fetchDistanceAndDurations(coordinates); - return routeOptimizationStrategy.calculateOptimalRoute(coordinates, routeMetrics); + var task = forkJoinPool.submit(() -> + routeOptimizationStrategy.calculateOptimalRoute(coordinates, routeMetrics)); + + return task.join(); } private List collectCoordinate(Map> namesByCoordinate) { From 332f385fa682d8bc8cbe97f87df8f27374d5dfa1 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 1 Mar 2025 01:57:44 +0900 Subject: [PATCH 35/84] =?UTF-8?q?refactor:=20CompletableFuture=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20-=20=EC=99=B8=EB=B6=80=20API=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=A7=81=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?=EC=96=B4=EB=8C=91=ED=84=B0=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/application/TravelPlanService.java | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java index 8b09701..b585df0 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java @@ -40,7 +40,7 @@ public class TravelPlanService implements TravelPlanUseCase { private final FestivalRepository festivalRepository; private final PlaceService placeService; private final RouteOptimizationStrategy routeOptimizationStrategy; - private final ExecutorService executor; + private final ExecutorService virtualThreadExecutor; private final TransitRouteApiAdapter transitRouteApiAdapter; private final DistanceDurationApiAdapter distanceDurationApiAdapter; @@ -85,14 +85,23 @@ private List fetchDistanceAndDurations(List coordinate .mapToObj(index -> asyncGetDistanceDuration(coordinates, index)) .toList(); - CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join(); - return futures.stream() - .map(CompletableFuture::join) + .map(this::awaitFutureResult) .flatMap(Collection::stream) .toList(); } + private Future> asyncGetDistanceDuration(List coordinates, int index) { + return virtualThreadExecutor.submit(() -> distanceDurationApiCall(index, coordinates)); + } + + private List distanceDurationApiCall(int index, List coordinates) { + Coordinate origin = coordinates.get(index); + List destinations = createDestination(coordinates, index); + + return distanceDurationApiAdapter.getDistanceAndDuration(origin, destinations); + } + private List searchTransitRoutes(List optimalRouteLocations) { return IntStream.range(0, optimalRouteLocations.size() - 1) .mapToObj(index -> { @@ -116,17 +125,6 @@ private List mapCoordinatesToLocations( }).toList(); } - private CompletableFuture> asyncGetDistanceDuration(List coordinates, int index) { - return CompletableFuture.supplyAsync(() -> distanceDurationApiCall(index, coordinates), executor); - } - - private List distanceDurationApiCall(int index, List coordinates) { - Coordinate origin = coordinates.get(index); - List destinations = createDestination(coordinates, index); - - return distanceDurationApiAdapter.getDistanceAndDuration(origin, destinations); - } - private List createDestination(List coordinates, int excludeIndex) { return IntStream.range(0, coordinates.size()) .filter(index -> index != excludeIndex) @@ -188,4 +186,12 @@ private Map> mergeNameMaps(Map fest return placeNamesByCoordinate; } + private List awaitFutureResult(Future> future) { + try { + return future.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + } From 699a4883bd94ba337dc63238846c1c6e9d279a37 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sun, 2 Mar 2025 15:35:54 +0900 Subject: [PATCH 36/84] =?UTF-8?q?refactor:=20=EA=B8=80=EB=A1=9C=EB=B2=8C?= =?UTF-8?q?=20=EA=B0=80=EC=83=81=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=97=90=EC=84=9C=20=EB=AA=85=EC=8B=9C=EC=A0=81=20Vir?= =?UTF-8?q?tualThreadExecutor=20=EC=82=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mohago_nocar/global/config/ExecutorServiceConfig.java | 7 ------- .../mohago_nocar/plan/application/TravelPlanService.java | 7 +------ src/main/resources/application.yml | 2 +- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java index fb33484..3886166 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java @@ -12,15 +12,8 @@ public class ExecutorServiceConfig { @Bean - @ConditionalOnThreading(Threading.VIRTUAL) public ExecutorService virtualThreadExecutor(){ return Executors.newVirtualThreadPerTaskExecutor(); } -/* @Bean - @ConditionalOnThreading(Threading.PLATFORM) - public ExecutorService cachedThreadExecutor(){ - return Executors.newCachedThreadPool(); - }*/ - } diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java index b585df0..a19f4ce 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java @@ -34,8 +34,6 @@ @Slf4j public class TravelPlanService implements TravelPlanUseCase { - private final ForkJoinPool forkJoinPool = ForkJoinPool.commonPool(); - private final PlaceRepository placeRepository; private final FestivalRepository festivalRepository; private final PlaceService placeService; @@ -65,10 +63,7 @@ private List findOptimalRoute(Map> namesByC var coordinates = collectCoordinate(namesByCoordinate); var routeMetrics = fetchDistanceAndDurations(coordinates); - var task = forkJoinPool.submit(() -> - routeOptimizationStrategy.calculateOptimalRoute(coordinates, routeMetrics)); - - return task.join(); + return routeOptimizationStrategy.calculateOptimalRoute(coordinates, routeMetrics); } private List collectCoordinate(Map> namesByCoordinate) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c992382..c96b776 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,7 +4,7 @@ server: spring: threads: virtual: - enabled: true + enabled: false application: name: mohago-nocar profiles: From df70820d9955f885e5bef570ac7ff45b894aeccd Mon Sep 17 00:00:00 2001 From: mungsil Date: Thu, 27 Mar 2025 00:41:47 +0900 Subject: [PATCH 37/84] =?UTF-8?q?docs:=20README=EC=97=90=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 7754165..1d2f881 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ No Car 서버는 **자가용 없이**도 **편리하고 즐거운 여행**을 할 수 있도록 지원하여, 더 많은 사람들이 환경을 생각하며 여행을 즐길 수 있도록 하는 것을 목표로 합니다. +--- +## 🛠️ 시스템 아키텍처 +![아키텍처_모하고노카 drawio](https://github.com/user-attachments/assets/d02d63fe-dbd9-4e81-bc85-9e6af9d62282) + + + --- ## 👨🏻‍💻 Developer From 1a35af7394519b9cecd8fa1452bdd4f388beec72 Mon Sep 17 00:00:00 2001 From: mungsil Date: Thu, 27 Mar 2025 17:07:50 +0900 Subject: [PATCH 38/84] =?UTF-8?q?docs:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d2f881..1e89c3e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ No Car 서버는 **자가용 없이**도 **편리하고 즐거운 여행**을 --- ## 🛠️ 시스템 아키텍처 -![아키텍처_모하고노카 drawio](https://github.com/user-attachments/assets/d02d63fe-dbd9-4e81-bc85-9e6af9d62282) +![모하고노카 drawio (1)](https://github.com/user-attachments/assets/f0614b06-9603-49ed-9006-899332ae9547) + From 672131e763676d1afdc17f8d0635a6dff25bbac9 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 31 Mar 2025 01:03:21 +0900 Subject: [PATCH 39/84] =?UTF-8?q?docs:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 1e89c3e..ef145cf 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,7 @@ No Car 서버는 **자가용 없이**도 **편리하고 즐거운 여행**을 --- ## 🛠️ 시스템 아키텍처 -![모하고노카 drawio (1)](https://github.com/user-attachments/assets/f0614b06-9603-49ed-9006-899332ae9547) - +![모하고노카_다이어그램 drawio (3)](https://github.com/user-attachments/assets/dc2ec912-5230-4601-af3e-640c6a575d99) From d9c056d278f2fcd0ab3ed7e3c8fcaf68cad1fe17 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 22 Oct 2025 09:56:38 +0900 Subject: [PATCH 40/84] =?UTF-8?q?feat=20:=20=EA=B0=80=EC=83=81=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c96b776..c992382 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,7 +4,7 @@ server: spring: threads: virtual: - enabled: false + enabled: true application: name: mohago-nocar profiles: From bc8ffbedb7212a008f67660cd446d35f4d6850e8 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 22 Oct 2025 09:56:58 +0900 Subject: [PATCH 41/84] =?UTF-8?q?feat=20:=20=EC=84=B8=EB=A7=88=ED=8F=AC?= =?UTF-8?q?=EC=96=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/application/TravelPlanService.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java index a19f4ce..68a9a60 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java @@ -41,6 +41,7 @@ public class TravelPlanService implements TravelPlanUseCase { private final ExecutorService virtualThreadExecutor; private final TransitRouteApiAdapter transitRouteApiAdapter; private final DistanceDurationApiAdapter distanceDurationApiAdapter; + private final Semaphore semaphore = new Semaphore(1, true); @Override public PlanTravelCourseResponseDto planCourse(PlanTravelCourseRequestDto dto) { @@ -98,7 +99,14 @@ private List distanceDurationApiCall(int index, List c } private List searchTransitRoutes(List optimalRouteLocations) { - return IntStream.range(0, optimalRouteLocations.size() - 1) + try { + semaphore.acquire(); + } catch (InterruptedException e) { + log.error("대중교통 경로 검색 중 스레드가 인터럽트되었습니다.", e); + throw new RuntimeException("대중교통 경로 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", e); + } + + List routes = IntStream.range(0, optimalRouteLocations.size() - 1) .mapToObj(index -> { Location origin = optimalRouteLocations.get(index); Location destination = optimalRouteLocations.get(index + 1); @@ -106,6 +114,9 @@ private List searchTransitRoutes(List optimalRouteLocati return transitRouteApiAdapter.getTransitRouteBetweenLocations(origin, destination); }) .toList(); + semaphore.release(); + + return routes; } private List mapCoordinatesToLocations( From 6f990cacf74261292c7c4857f450a08eb613a5b6 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 22 Oct 2025 09:59:32 +0900 Subject: [PATCH 42/84] =?UTF-8?q?refactor:=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mohago_nocar/plan/application/TravelPlanService.java | 4 ++-- .../response/BusPathResponseDto.java | 2 +- .../response/PlanTravelCourseResponseDto.java | 2 +- .../response/SubPathResponseDto.java | 2 +- .../response/SubwayPathResponseDto.java | 2 +- .../response/TravelRouteResponseDto.java | 2 +- .../response/WalkPathResponseDto.java | 2 +- .../mohago_nocar/plan/domain/service/TravelPlanUseCase.java | 4 +--- .../mohago_nocar/plan/presentation/TravelPlanController.java | 4 +--- 9 files changed, 10 insertions(+), 14 deletions(-) rename src/main/java/com/example/mohago_nocar/plan/{presentation => application}/response/BusPathResponseDto.java (97%) rename src/main/java/com/example/mohago_nocar/plan/{presentation => application}/response/PlanTravelCourseResponseDto.java (86%) rename src/main/java/com/example/mohago_nocar/plan/{presentation => application}/response/SubPathResponseDto.java (88%) rename src/main/java/com/example/mohago_nocar/plan/{presentation => application}/response/SubwayPathResponseDto.java (97%) rename src/main/java/com/example/mohago_nocar/plan/{presentation => application}/response/TravelRouteResponseDto.java (96%) rename src/main/java/com/example/mohago_nocar/plan/{presentation => application}/response/WalkPathResponseDto.java (92%) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java index 68a9a60..320f0c2 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java @@ -11,8 +11,8 @@ import com.example.mohago_nocar.plan.domain.model.Location; import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.presentation.response.PlanTravelCourseResponseDto; -import com.example.mohago_nocar.plan.presentation.response.TravelRouteResponseDto; +import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; +import com.example.mohago_nocar.plan.application.response.TravelRouteResponseDto; import com.example.mohago_nocar.transit.domain.model.TransitRoute; import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/response/BusPathResponseDto.java similarity index 97% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/response/BusPathResponseDto.java index 577127f..af6e16c 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/response/BusPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.response; import com.example.mohago_nocar.transit.domain.model.BusPath; import com.example.mohago_nocar.transit.domain.model.SubPath; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/response/PlanTravelCourseResponseDto.java similarity index 86% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/response/PlanTravelCourseResponseDto.java index 9f86d5d..abeabe8 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/response/PlanTravelCourseResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.response; import lombok.Builder; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/response/SubPathResponseDto.java similarity index 88% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/SubPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/response/SubPathResponseDto.java index a8718d0..bccacab 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/response/SubPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.response; import com.example.mohago_nocar.transit.domain.model.PathType; import lombok.Getter; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/response/SubwayPathResponseDto.java similarity index 97% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/response/SubwayPathResponseDto.java index 1a9a910..7999261 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/response/SubwayPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.response; import com.example.mohago_nocar.transit.domain.model.SubPath; import com.example.mohago_nocar.transit.domain.model.SubwayPath; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/TravelRouteResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/response/TravelRouteResponseDto.java similarity index 96% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/TravelRouteResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/response/TravelRouteResponseDto.java index 6ecdd26..f5666eb 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/TravelRouteResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/response/TravelRouteResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.response; import com.example.mohago_nocar.transit.domain.model.TransitRoute; import lombok.Builder; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/response/WalkPathResponseDto.java similarity index 92% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/response/WalkPathResponseDto.java index 39e2919..30d40ab 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/response/WalkPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.response; import com.example.mohago_nocar.transit.domain.model.SubPath; import com.example.mohago_nocar.transit.domain.model.WalkPath; diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java index 3c950c7..204bcef 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java @@ -1,9 +1,7 @@ package com.example.mohago_nocar.plan.domain.service; import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.presentation.response.PlanTravelCourseResponseDto; - -import java.util.List; +import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; public interface TravelPlanUseCase { diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java index 4216245..f57be4b 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java @@ -3,7 +3,7 @@ import com.example.mohago_nocar.global.common.response.ApiResponse; import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.presentation.response.PlanTravelCourseResponseDto; +import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -12,8 +12,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/travel-plan") From 001d44ba0227de47996d2fb975e47794e819c0b7 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 22 Oct 2025 10:02:15 +0900 Subject: [PATCH 43/84] =?UTF-8?q?refactor:=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mohago_nocar/festival/application/FestivalService.java | 7 +++---- .../response/FestivalActivePeriodResponseDto.java | 2 +- .../response/FestivalLocationResponseDto.java | 2 +- .../response/FestivalResponseDto.java | 2 +- .../festival/domain/service/FestivalUseCase.java | 6 +++--- .../festival/presentation/FestivalController.java | 4 ++-- 6 files changed, 11 insertions(+), 12 deletions(-) rename src/main/java/com/example/mohago_nocar/festival/{presentation => application}/response/FestivalActivePeriodResponseDto.java (88%) rename src/main/java/com/example/mohago_nocar/festival/{presentation => application}/response/FestivalLocationResponseDto.java (85%) rename src/main/java/com/example/mohago_nocar/festival/{presentation => application}/response/FestivalResponseDto.java (93%) diff --git a/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java b/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java index 163faa9..12a40ee 100644 --- a/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java +++ b/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java @@ -5,10 +5,9 @@ import com.example.mohago_nocar.festival.domain.repository.FestivalRepository; import com.example.mohago_nocar.festival.domain.service.FestivalImageUseCase; import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; -import com.example.mohago_nocar.festival.presentation.exception.FestivalNotFoundException; -import com.example.mohago_nocar.festival.presentation.response.FestivalActivePeriodResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalLocationResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalActivePeriodResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalLocationResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalResponseDto; import com.example.mohago_nocar.global.common.dto.PagedResponseDto; import java.util.List; diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalActivePeriodResponseDto.java b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalActivePeriodResponseDto.java similarity index 88% rename from src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalActivePeriodResponseDto.java rename to src/main/java/com/example/mohago_nocar/festival/application/response/FestivalActivePeriodResponseDto.java index ac613f7..8a6f749 100644 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalActivePeriodResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalActivePeriodResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.festival.presentation.response; +package com.example.mohago_nocar.festival.application.response; import com.example.mohago_nocar.festival.domain.model.vo.ActivePeriod; import lombok.Builder; diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalLocationResponseDto.java similarity index 85% rename from src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java rename to src/main/java/com/example/mohago_nocar/festival/application/response/FestivalLocationResponseDto.java index 9bcb1fe..4c78aae 100644 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalLocationResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.festival.presentation.response; +package com.example.mohago_nocar.festival.application.response; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import lombok.Builder; diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalResponseDto.java similarity index 93% rename from src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java rename to src/main/java/com/example/mohago_nocar/festival/application/response/FestivalResponseDto.java index ae7dd7a..8643f47 100644 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.festival.presentation.response; +package com.example.mohago_nocar.festival.application.response; import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.festival.domain.model.vo.ActivePeriod; diff --git a/src/main/java/com/example/mohago_nocar/festival/domain/service/FestivalUseCase.java b/src/main/java/com/example/mohago_nocar/festival/domain/service/FestivalUseCase.java index a76fc7b..c7455e7 100644 --- a/src/main/java/com/example/mohago_nocar/festival/domain/service/FestivalUseCase.java +++ b/src/main/java/com/example/mohago_nocar/festival/domain/service/FestivalUseCase.java @@ -1,9 +1,9 @@ package com.example.mohago_nocar.festival.domain.service; import com.example.mohago_nocar.festival.domain.model.Festival; -import com.example.mohago_nocar.festival.presentation.response.FestivalActivePeriodResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalLocationResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalActivePeriodResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalLocationResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalResponseDto; import com.example.mohago_nocar.global.common.dto.PagedResponseDto; import java.util.List; diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/FestivalController.java b/src/main/java/com/example/mohago_nocar/festival/presentation/FestivalController.java index 78b6bbc..a5b3db2 100644 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/FestivalController.java +++ b/src/main/java/com/example/mohago_nocar/festival/presentation/FestivalController.java @@ -1,8 +1,8 @@ package com.example.mohago_nocar.festival.presentation; import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; -import com.example.mohago_nocar.festival.presentation.response.FestivalActivePeriodResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalActivePeriodResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalResponseDto; import com.example.mohago_nocar.global.common.dto.PagedResponseDto; import com.example.mohago_nocar.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; From 1ae6f2cedc8e0ece639e3aaa8e1bcb19ca4d1b93 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 27 Oct 2025 17:42:09 +0900 Subject: [PATCH 44/84] =?UTF-8?q?refactor:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy.sh | 75 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index e3cc7f1..1c9ac2f 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -2,28 +2,75 @@ cd /home/ubuntu/app +# Redis 컨테이너 실행 여부 확인 +REDIS_CONTAINER_STATUS=$(docker ps | grep redis) +if [ -z "$REDIS_CONTAINER_STATUS" ]; then + echo "Redis 컨테이너가 실행 중이지 않습니다" + echo ">>> Pulling Redis image" + docker compose -f docker-compose.redis.yml pull redis + echo ">>> Starting Redis container" + docker compose -f docker-compose.redis.yml up -d redis +else + echo "Redis 컨테이너가 이미 실행 중입니다." +fi + + DOCKER_APP_NAME=spring -# 실행중인 blue가 있는지 -EXIST_BLUE=$(docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml ps | grep running) +# blue 컨테이너 실행 여부 확인 +BLUE_CONTAINER_RUNNING=$(docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml ps -q | xargs docker inspect -f '{{.State.Running}}') -# green이 실행중이면 blue up -if [ -z "$EXIST_BLUE" ]; then - echo "blue up" +# 컨테이너 스위칭 +if [ "$BLUE_CONTAINER_RUNNING" != "true" ]; then + echo "[blue up]" docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml up -d --build - sleep 30 - - docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml down - docker image prune -af # 사용하지 않는 이미지 삭제 + ACTIVE_ENV="blue" + STANDBY_ENV="green" + ACTIVE_PORT="8081" -# blue가 실행중이면 green up else - echo "green up" + echo "[green up]" docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml up -d --build - sleep 30 + ACTIVE_ENV="green" + STANDBY_ENV="blue" + ACTIVE_PORT="8082" + +fi + + +# 헬스체크 +MAX_RETRIES=30 +RETRY_INTERVAL=2 +HEALTH_CHECK_URL="http://localhost:$ACTIVE_PORT/actuator/health" +ACTIVE_HEALTH_CHECK_PASSED=false + +echo "Waiting for application to be ready..." +for i in $(seq 1 $MAX_RETRIES); do + if curl -s "$HEALTH_CHECK_URL" | grep -q "UP"; then + echo "[Application is ready]" + ACTIVE_HEALTH_CHECK_PASSED=true + break + + fi + echo "Waiting... ($i/$MAX_RETRIES)" + sleep $RETRY_INTERVAL + +done + +# 새로운 컨테이너가 제대로 떴는지 확인 +if [ "$ACTIVE_HEALTH_CHECK_PASSED" = "true" ]; then + # 이전 컨테이너 종료 + docker-compose -p ${DOCKER_APP_NAME}-${STANDBY_ENV} -f docker-compose.${STANDBY_ENV}.yml down + docker image prune -af + echo "[Down] $STANDBY_ENV Down" + +else + echo "Health check failed after $MAX_RETRIES attempts" + # 새 컨테이너 종료 + docker-compose -p ${DOCKER_APP_NAME}-${ACTIVE_ENV} -f docker-compose.${ACTIVE_ENV}.yml down + # 디스코드 알림 + sudo ./discord_${ACTIVE_ENV}.sh - docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml down - docker image prune -af fi \ No newline at end of file From 85eb452717d78f7f65981b73204b43c08e77bd01 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 27 Oct 2025 17:49:23 +0900 Subject: [PATCH 45/84] =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ef145cf..d083911 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ No Car 서버는 **자가용 없이**도 **편리하고 즐거운 여행**을 --- ## 🛠️ 시스템 아키텍처 -![모하고노카_다이어그램 drawio (3)](https://github.com/user-attachments/assets/dc2ec912-5230-4601-af3e-640c6a575d99) +![모하고노카_다이어그램 drawio](https://github.com/user-attachments/assets/34d2823f-b50d-494f-8e00-6c83302895b6) From caf54665c0a549ae5152868cd14d6c1fcc2d4ac0 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 27 Oct 2025 23:08:48 +0900 Subject: [PATCH 46/84] =?UTF-8?q?fix:=20=EC=84=B8=EB=A7=88=ED=8F=AC?= =?UTF-8?q?=EC=96=B4=20=EB=B0=A9=EC=8B=9D=EC=9D=98=20=EB=8B=A8=EC=A0=90?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20CompletableFuture=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세마포어 사용: odsay api 호출 작업을 수행하기 위한 스레드들이 블로킹되었다가 순서가 오면 깨어나는 방식 -> 비효율, 가상 스레드로 전환하는 방법을 고려했으나 pinning 이슈로 불안정. --- .../plan/application/TravelPlanService.java | 37 ++------ .../domain/service/TravelPlanUseCase.java | 4 +- .../presentation/TravelPlanController.java | 9 +- .../route/TransitRouteExecutor.java | 13 +++ .../route/odsay/ODsayApiClient.java | 2 +- .../ODsayTransitRouteApiAdapter.java | 11 ++- .../odsay/ODsayTransitRouteApiExecutor.java | 84 +++++++++++++++++++ .../route/odsay/TransitRouteConverter.java | 2 +- .../response/ODsayRouteInvalidResponse.java | 2 +- .../response/ODsayRouteValidResponse.java | 2 +- .../response/ODsayTransitRouteResponse.java | 3 +- ...ODsayTransitRouteResponseDeserializer.java | 5 +- .../ODsayTransitRouteApiAdapterTest.java | 5 +- .../route/odsay/ODsayApiClientTest.java | 9 +- 14 files changed, 129 insertions(+), 59 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteExecutor.java rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/{ => odsay}/ODsayTransitRouteApiAdapter.java (91%) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/{dto => }/response/ODsayRouteInvalidResponse.java (95%) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/{dto => }/response/ODsayRouteValidResponse.java (98%) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/{dto => }/response/ODsayTransitRouteResponse.java (70%) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/{ => response}/ODsayTransitRouteResponseDeserializer.java (95%) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java index 320f0c2..84bb7b8 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java @@ -13,10 +13,9 @@ import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; import com.example.mohago_nocar.plan.application.response.TravelRouteResponseDto; -import com.example.mohago_nocar.transit.domain.model.TransitRoute; import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -39,12 +38,11 @@ public class TravelPlanService implements TravelPlanUseCase { private final PlaceService placeService; private final RouteOptimizationStrategy routeOptimizationStrategy; private final ExecutorService virtualThreadExecutor; - private final TransitRouteApiAdapter transitRouteApiAdapter; private final DistanceDurationApiAdapter distanceDurationApiAdapter; - private final Semaphore semaphore = new Semaphore(1, true); + private final TransitRouteExecutor transitRouteApiExecutor; @Override - public PlanTravelCourseResponseDto planCourse(PlanTravelCourseRequestDto dto) { + public CompletableFuture planCourse(PlanTravelCourseRequestDto dto) { Festival festival = validateAndGetFestival(dto); List attractions = getAttractions(festival, dto.placeIds()); @@ -53,11 +51,9 @@ public PlanTravelCourseResponseDto planCourse(PlanTravelCourseRequestDto dto) { List optimalRouteCoordinates = findOptimalRoute(namesByCoordinate); List optimalRouteLocations = mapCoordinatesToLocations(namesByCoordinate, optimalRouteCoordinates); - List responses = searchTransitRoutes(optimalRouteLocations).stream() - .map(TravelRouteResponseDto::of) - .toList(); - - return PlanTravelCourseResponseDto.of(responses); + return transitRouteApiExecutor.execute(optimalRouteLocations) + .thenApply(routes -> routes.stream().map(TravelRouteResponseDto::of).toList()) + .thenApply(PlanTravelCourseResponseDto::of); } private List findOptimalRoute(Map> namesByCoordinate) { @@ -98,27 +94,6 @@ private List distanceDurationApiCall(int index, List c return distanceDurationApiAdapter.getDistanceAndDuration(origin, destinations); } - private List searchTransitRoutes(List optimalRouteLocations) { - try { - semaphore.acquire(); - } catch (InterruptedException e) { - log.error("대중교통 경로 검색 중 스레드가 인터럽트되었습니다.", e); - throw new RuntimeException("대중교통 경로 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", e); - } - - List routes = IntStream.range(0, optimalRouteLocations.size() - 1) - .mapToObj(index -> { - Location origin = optimalRouteLocations.get(index); - Location destination = optimalRouteLocations.get(index + 1); - - return transitRouteApiAdapter.getTransitRouteBetweenLocations(origin, destination); - }) - .toList(); - semaphore.release(); - - return routes; - } - private List mapCoordinatesToLocations( Map> namesByCoordinate, List coordinates diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java index 204bcef..85c5dec 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java @@ -3,8 +3,10 @@ import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; +import java.util.concurrent.CompletableFuture; + public interface TravelPlanUseCase { - PlanTravelCourseResponseDto planCourse(PlanTravelCourseRequestDto dto); + CompletableFuture planCourse(PlanTravelCourseRequestDto dto); } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java index f57be4b..15d6420 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java @@ -12,6 +12,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.concurrent.CompletableFuture; + @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/travel-plan") @@ -21,10 +23,11 @@ public class TravelPlanController { private final TravelPlanUseCase travelPlanUseCase; @PostMapping - public ApiResponse planTravelCourse( + public CompletableFuture> planTravelCourse( @RequestBody @Valid PlanTravelCourseRequestDto requestDto ) { - PlanTravelCourseResponseDto responseDto = travelPlanUseCase.planCourse(requestDto); - return ApiResponse.ok(responseDto); + return travelPlanUseCase.planCourse(requestDto) + .thenApply(ApiResponse::ok); } + } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteExecutor.java new file mode 100644 index 0000000..7915640 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteExecutor.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.transit.infrastructure.route; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface TransitRouteExecutor { + + CompletableFuture> execute(final List locations); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java index ee1d3d3..88bff0d 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java @@ -2,7 +2,7 @@ import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.global.common.exception.InternalServerException; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayTransitRouteResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; import io.github.resilience4j.ratelimiter.RateLimiter; import io.github.resilience4j.ratelimiter.RateLimiterConfig; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java similarity index 91% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapter.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java index acc6b05..d46ca2d 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.transit.infrastructure.route; +package com.example.mohago_nocar.transit.infrastructure.route.odsay; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.plan.domain.model.Location; @@ -7,11 +7,10 @@ import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayDistanceException; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiClient; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.TransitRouteConverter; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteInvalidResponse; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayTransitRouteResponse; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java new file mode 100644 index 0000000..0ab3bd0 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java @@ -0,0 +1,84 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteExecutor; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ODsayTransitRouteApiExecutor implements TransitRouteExecutor { + + private final TransitRouteApiAdapter transitRouteApiAdapter; + private ExecutorService singleThreadExecutor; + + @PostConstruct + private void init() { + this.singleThreadExecutor = Executors.newSingleThreadExecutor(); + } + + @Override + public CompletableFuture> execute(final List locations) { + return CompletableFuture.supplyAsync(() -> { + validateLocations(locations); + return fetchTransitRoutes(locations); + }, singleThreadExecutor); + } + + private void validateLocations(List locations) { + if (locations == null || locations.size() < 2) { + throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); + } + } + + private List fetchTransitRoutes(List locations) { + List routes = new ArrayList<>(); + + for (int i = 0; i < locations.size() - 1; i++) { + Location origin = locations.get(i); + Location destination = locations.get(i + 1); + + TransitRoute route = transitRouteApiAdapter.getTransitRouteBetweenLocations(origin, destination); + routes.add(route); + } + + return routes; + } + + @PreDestroy + private void shutdown() { + if (singleThreadExecutor == null) { + return; + } + + log.info("Single thread executor shutdown started"); + singleThreadExecutor.shutdown(); + try { + if (!singleThreadExecutor.awaitTermination(20, TimeUnit.SECONDS)) { + log.warn("정상 종료에 실패했습니다. 강제 종료를 시작합니다."); + singleThreadExecutor.shutdownNow(); + if (!singleThreadExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + log.error("강제 종료에 실패하였습니다."); + } + } + } catch (InterruptedException e) { + log.error("인터럽트 발생", e); + singleThreadExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java index c915083..80f8e48 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java @@ -3,7 +3,7 @@ import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.plan.domain.model.Location; import com.example.mohago_nocar.transit.domain.model.*; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; import java.util.List; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteInvalidResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteInvalidResponse.java similarity index 95% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteInvalidResponse.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteInvalidResponse.java index 2fc86b6..b563792 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteInvalidResponse.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteInvalidResponse.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response; +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteValidResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteValidResponse.java similarity index 98% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteValidResponse.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteValidResponse.java index 10dced1..3dc8f7e 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayRouteValidResponse.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteValidResponse.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response; +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; import lombok.*; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayTransitRouteResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponse.java similarity index 70% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayTransitRouteResponse.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponse.java index 645068d..78531f6 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/dto/response/ODsayTransitRouteResponse.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponse.java @@ -1,6 +1,5 @@ -package com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response; +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayTransitRouteResponseDeserializer; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; @JsonDeserialize(using = ODsayTransitRouteResponseDeserializer.class) diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteResponseDeserializer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java similarity index 95% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteResponseDeserializer.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java index 5f85d0d..c7ef5dd 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteResponseDeserializer.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java @@ -1,8 +1,5 @@ -package com.example.mohago_nocar.transit.infrastructure.route.odsay; +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteInvalidResponse; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayTransitRouteResponse; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java index 58cffd5..938f94d 100644 --- a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java @@ -6,8 +6,9 @@ import com.example.mohago_nocar.transit.domain.model.WalkPath; import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiClient; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteInvalidResponse; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayTransitRouteApiAdapter; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java index 5832d13..3ddfa80 100644 --- a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java @@ -1,18 +1,15 @@ package com.example.mohago_nocar.transit.infrastructure.route.odsay; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteInvalidResponse; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayRouteValidResponse; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.dto.response.ODsayTransitRouteResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import java.util.List; -import java.util.stream.IntStream; - import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest From f620dc987fc9ad5cd3666288e2441a976b2e6984 Mon Sep 17 00:00:00 2001 From: mungsil Date: Tue, 28 Oct 2025 00:13:55 +0900 Subject: [PATCH 47/84] =?UTF-8?q?refactor:=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mohago_nocar/plan/application/TravelPlanService.java | 4 ++-- ...TransitRouteExecutor.java => TransitRouteApiExecutor.java} | 2 +- ...eApiExecutor.java => ODsayTransitRouteApiApiExecutor.java} | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/{TransitRouteExecutor.java => TransitRouteApiExecutor.java} (89%) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/{ODsayTransitRouteApiExecutor.java => ODsayTransitRouteApiApiExecutor.java} (96%) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java index 84bb7b8..fa1cb4f 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java @@ -15,7 +15,7 @@ import com.example.mohago_nocar.plan.application.response.TravelRouteResponseDto; import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; import com.example.mohago_nocar.transit.domain.model.RouteMetrics; -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteExecutor; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -39,7 +39,7 @@ public class TravelPlanService implements TravelPlanUseCase { private final RouteOptimizationStrategy routeOptimizationStrategy; private final ExecutorService virtualThreadExecutor; private final DistanceDurationApiAdapter distanceDurationApiAdapter; - private final TransitRouteExecutor transitRouteApiExecutor; + private final TransitRouteApiExecutor transitRouteApiExecutor; @Override public CompletableFuture planCourse(PlanTravelCourseRequestDto dto) { diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java similarity index 89% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteExecutor.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java index 7915640..dceba4b 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteExecutor.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; -public interface TransitRouteExecutor { +public interface TransitRouteApiExecutor { CompletableFuture> execute(final List locations); diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiApiExecutor.java similarity index 96% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiApiExecutor.java index 0ab3bd0..a06649d 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiApiExecutor.java @@ -3,7 +3,7 @@ import com.example.mohago_nocar.plan.domain.model.Location; import com.example.mohago_nocar.transit.domain.model.TransitRoute; import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteExecutor; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiExecutor; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; @@ -20,7 +20,7 @@ @Component @Slf4j @RequiredArgsConstructor -public class ODsayTransitRouteApiExecutor implements TransitRouteExecutor { +public class ODsayTransitRouteApiApiExecutor implements TransitRouteApiExecutor { private final TransitRouteApiAdapter transitRouteApiAdapter; private ExecutorService singleThreadExecutor; From 7e592a2e5718ce07f0015e726de06e7dcb86ed95 Mon Sep 17 00:00:00 2001 From: mungsil Date: Tue, 28 Oct 2025 00:14:22 +0900 Subject: [PATCH 48/84] =?UTF-8?q?refactor:=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...uteApiApiExecutor.java => ODsayTransitRouteApiExecutor.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/{ODsayTransitRouteApiApiExecutor.java => ODsayTransitRouteApiExecutor.java} (97%) diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiApiExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java similarity index 97% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiApiExecutor.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java index a06649d..376bb87 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiApiExecutor.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java @@ -20,7 +20,7 @@ @Component @Slf4j @RequiredArgsConstructor -public class ODsayTransitRouteApiApiExecutor implements TransitRouteApiExecutor { +public class ODsayTransitRouteApiExecutor implements TransitRouteApiExecutor { private final TransitRouteApiAdapter transitRouteApiAdapter; private ExecutorService singleThreadExecutor; From 941fc43eb51c7a0ae79f80d8248984894bfa1576 Mon Sep 17 00:00:00 2001 From: mungsil Date: Tue, 28 Oct 2025 01:33:25 +0900 Subject: [PATCH 49/84] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20import=EB=AC=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mohago_nocar/plan/presentation/TravelPlanController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java index 15d6420..2357628 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java @@ -3,7 +3,6 @@ import com.example.mohago_nocar.global.common.response.ApiResponse; import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; From 8007f9ebd5f4cafcf0a0e60f9c0bac7e9d3f3172 Mon Sep 17 00:00:00 2001 From: mungsil Date: Tue, 28 Oct 2025 01:37:12 +0900 Subject: [PATCH 50/84] Remove 'mingmingmon' from code owners list --- .github/workflows/require-codeowners-approval.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/require-codeowners-approval.yml b/.github/workflows/require-codeowners-approval.yml index e7e2eff..3035b4d 100644 --- a/.github/workflows/require-codeowners-approval.yml +++ b/.github/workflows/require-codeowners-approval.yml @@ -30,7 +30,7 @@ jobs: } }); - const codeOwners = ['mingmingmon', 'mungsil']; + const codeOwners = ['mungsil']; const isAuthorCodeOwner = codeOwners.includes(prAuthor); let requiredApprovals = []; From 2a2a1db89f13c0e0e3e179aff664b69cdde7ec3f Mon Sep 17 00:00:00 2001 From: mungsil Date: Fri, 31 Oct 2025 22:01:46 +0900 Subject: [PATCH 51/84] =?UTF-8?q?refactor:=20V2=20API=EC=99=80=EC=9D=98=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=EB=AA=85?= =?UTF-8?q?=EC=97=90=20=EB=B2=84=EC=A0=84=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{TravelPlanController.java => TravelPlanControllerV1.java} | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename src/main/java/com/example/mohago_nocar/plan/presentation/{TravelPlanController.java => TravelPlanControllerV1.java} (90%) diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java similarity index 90% rename from src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java rename to src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java index 15d6420..a9aba6e 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java @@ -3,7 +3,6 @@ import com.example.mohago_nocar.global.common.response.ApiResponse; import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -18,7 +17,7 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/travel-plan") @Tag(name = "Plan", description = "여행 코스 설계") -public class TravelPlanController { +public class TravelPlanControllerV1 { private final TravelPlanUseCase travelPlanUseCase; From 550a3432058a8ec61aa124f0ba3073f7012509d0 Mon Sep 17 00:00:00 2001 From: mungsil Date: Fri, 31 Oct 2025 22:52:05 +0900 Subject: [PATCH 52/84] =?UTF-8?q?refactor:=20V2=20API=EC=99=80=EC=9D=98=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=AA=85=EC=97=90=20V1?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{TravelPlanService.java => TravelCourseServiceV1.java} | 4 ++-- .../{TravelPlanUseCase.java => TravelCourseUseCaseV1.java} | 2 +- .../plan/domain/service/TravelCourseUseCaseV2.java | 5 +++++ .../plan/presentation/TravelPlanControllerV1.java | 6 +++--- 4 files changed, 11 insertions(+), 6 deletions(-) rename src/main/java/com/example/mohago_nocar/plan/application/{TravelPlanService.java => TravelCourseServiceV1.java} (98%) rename src/main/java/com/example/mohago_nocar/plan/domain/service/{TravelPlanUseCase.java => TravelCourseUseCaseV1.java} (90%) create mode 100644 src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelCourseServiceV1.java similarity index 98% rename from src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java rename to src/main/java/com/example/mohago_nocar/plan/application/TravelCourseServiceV1.java index fa1cb4f..be9bd1c 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/TravelCourseServiceV1.java @@ -9,7 +9,7 @@ import com.example.mohago_nocar.place.domain.repository.PlaceRepository; import com.example.mohago_nocar.plan.application.strategy.RouteOptimizationStrategy; import com.example.mohago_nocar.plan.domain.model.Location; -import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; +import com.example.mohago_nocar.plan.domain.service.TravelCourseUseCaseV1; import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; import com.example.mohago_nocar.plan.application.response.TravelRouteResponseDto; @@ -31,7 +31,7 @@ @Service @RequiredArgsConstructor @Slf4j -public class TravelPlanService implements TravelPlanUseCase { +public class TravelCourseServiceV1 implements TravelCourseUseCaseV1 { private final PlaceRepository placeRepository; private final FestivalRepository festivalRepository; diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java similarity index 90% rename from src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java rename to src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java index 85c5dec..798bf1c 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java @@ -5,7 +5,7 @@ import java.util.concurrent.CompletableFuture; -public interface TravelPlanUseCase { +public interface TravelCourseUseCaseV1 { CompletableFuture planCourse(PlanTravelCourseRequestDto dto); diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java new file mode 100644 index 0000000..b63afb3 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java @@ -0,0 +1,5 @@ +package com.example.mohago_nocar.plan.domain.service; + +public interface TravelCourseUseCaseV2 { + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java index a9aba6e..d293a21 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java @@ -1,7 +1,7 @@ package com.example.mohago_nocar.plan.presentation; import com.example.mohago_nocar.global.common.response.ApiResponse; -import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; +import com.example.mohago_nocar.plan.domain.service.TravelCourseUseCaseV1; import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -19,13 +19,13 @@ @Tag(name = "Plan", description = "여행 코스 설계") public class TravelPlanControllerV1 { - private final TravelPlanUseCase travelPlanUseCase; + private final TravelCourseUseCaseV1 travelCourseUseCaseV1; @PostMapping public CompletableFuture> planTravelCourse( @RequestBody @Valid PlanTravelCourseRequestDto requestDto ) { - return travelPlanUseCase.planCourse(requestDto) + return travelCourseUseCaseV1.planCourse(requestDto) .thenApply(ApiResponse::ok); } From 681629b17fdda88ab33d2f0e4ee6bc2b905b16f3 Mon Sep 17 00:00:00 2001 From: mungsil Date: Fri, 31 Oct 2025 22:55:12 +0900 Subject: [PATCH 53/84] =?UTF-8?q?feat:=20version=202=20API=20controller=20?= =?UTF-8?q?=EB=BC=88=EB=8C=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/TravelCourseUseCaseV2.java | 5 +++ .../presentation/TravelPlanControllerV2.java | 33 ++++++++++++++++++ .../presentation/TravelPlanResponseDtoV2.java | 6 ++++ .../request/PlanTravelCourseRequestDtoV2.java | 18 ++++++++++ .../TravelPlanControllerV2Test.java | 34 +++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2.java create mode 100644 src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanResponseDtoV2.java create mode 100644 src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDtoV2.java create mode 100644 src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java index b63afb3..ac9752e 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java @@ -1,5 +1,10 @@ package com.example.mohago_nocar.plan.domain.service; +import com.example.mohago_nocar.plan.presentation.TravelPlanResponseDtoV2; +import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDtoV2; + public interface TravelCourseUseCaseV2 { + public TravelPlanResponseDtoV2 plan(PlanTravelCourseRequestDtoV2 request); + } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2.java b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2.java new file mode 100644 index 0000000..2b6ddc8 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.plan.presentation; + +import com.example.mohago_nocar.global.common.response.ApiResponse; +import com.example.mohago_nocar.plan.domain.service.TravelCourseUseCaseV1; +import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDtoV2; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/travel-plan") +@Tag(name = "Plan", description = "여행 코스 설계") +public class TravelPlanControllerV2 { + + private final TravelCourseUseCaseV1 travelPlanUseCase; + + @PostMapping + public ApiResponse planTravelCourse( + @RequestBody @Valid PlanTravelCourseRequestDtoV2 request + ) { + // batch_id 생성 + // 유저 아이디와 batch_id를 매핑해서 저장 + // 유저 아이디와 fcm 토큰 저장 + // 논블로킹으로 여행 api 설계 요청 + // batch_id 반환 + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanResponseDtoV2.java b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanResponseDtoV2.java new file mode 100644 index 0000000..6f6ec06 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanResponseDtoV2.java @@ -0,0 +1,6 @@ +package com.example.mohago_nocar.plan.presentation; + +public record TravelPlanResponseDtoV2( + String batch_id +) { +} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDtoV2.java b/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDtoV2.java new file mode 100644 index 0000000..c20ae2f --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDtoV2.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.plan.presentation.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record PlanTravelCourseRequestDtoV2( + + @Schema(description = "클라이언트가 발급한 uuid로, 회원 아이디 대신 사용합니다.") + String userId, + + @Schema(description = "fcm token") + String fcmToken, + + @Schema(description = "선택된 여행 장소들의 아이디", example = "[1234]") + List placeIds +) { +} diff --git a/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java b/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java new file mode 100644 index 0000000..bfc2cf6 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java @@ -0,0 +1,34 @@ +package com.example.mohago_nocar.plan.presentation; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@WebMvcTest(controllers = TravelPlanControllerV2.class) +class TravelPlanControllerV2Test { + + @Test + @DisplayName("요청 파라미터가 유효하다면 성공 응답을 받는다.") + void shouldReturnSuccessResponseIfRequestIsValid(){ + //given + + //when + + //then + + } + + @Test + @DisplayName("요청 파라미터가 유효하지 않다면 실패 응답을 받는다.") + void shouldReturnFailureResponseIfRequestIsInvalid(){ + //given + + //when + + //then + + } + +} \ No newline at end of file From df26cfa68885b17878e3585f319550b129fe7bce Mon Sep 17 00:00:00 2001 From: mungsil Date: Fri, 31 Oct 2025 22:57:05 +0900 Subject: [PATCH 54/84] =?UTF-8?q?refactor:=20=EB=AA=85=EB=A3=8C=ED=95=9C?= =?UTF-8?q?=20=EA=B5=AC=EB=B6=84=EC=9D=84=20=EC=9C=84=ED=95=B4=20version?= =?UTF-8?q?=201=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/{ => v1}/TravelCourseServiceV1.java | 10 +++++----- .../{ => v1}/response/BusPathResponseDto.java | 2 +- .../{ => v1}/response/PlanTravelCourseResponseDto.java | 2 +- .../{ => v1}/response/SubPathResponseDto.java | 2 +- .../{ => v1}/response/SubwayPathResponseDto.java | 2 +- .../{ => v1}/response/TravelRouteResponseDto.java | 2 +- .../{ => v1}/response/WalkPathResponseDto.java | 2 +- .../{ => v1}/strategy/RouteOptimizationStrategy.java | 2 +- .../{ => v1}/strategy/ShortestTimeRouteStrategy.java | 2 +- .../plan/domain/service/TravelCourseUseCaseV1.java | 4 ++-- .../{request => v1}/PlanTravelCourseRequestDto.java | 2 +- .../presentation/{ => v1}/TravelPlanControllerV1.java | 3 +-- 12 files changed, 17 insertions(+), 18 deletions(-) rename src/main/java/com/example/mohago_nocar/plan/application/{ => v1}/TravelCourseServiceV1.java (95%) rename src/main/java/com/example/mohago_nocar/plan/application/{ => v1}/response/BusPathResponseDto.java (97%) rename src/main/java/com/example/mohago_nocar/plan/application/{ => v1}/response/PlanTravelCourseResponseDto.java (85%) rename src/main/java/com/example/mohago_nocar/plan/application/{ => v1}/response/SubPathResponseDto.java (87%) rename src/main/java/com/example/mohago_nocar/plan/application/{ => v1}/response/SubwayPathResponseDto.java (97%) rename src/main/java/com/example/mohago_nocar/plan/application/{ => v1}/response/TravelRouteResponseDto.java (96%) rename src/main/java/com/example/mohago_nocar/plan/application/{ => v1}/response/WalkPathResponseDto.java (92%) rename src/main/java/com/example/mohago_nocar/plan/application/{ => v1}/strategy/RouteOptimizationStrategy.java (83%) rename src/main/java/com/example/mohago_nocar/plan/application/{ => v1}/strategy/ShortestTimeRouteStrategy.java (98%) rename src/main/java/com/example/mohago_nocar/plan/presentation/{request => v1}/PlanTravelCourseRequestDto.java (90%) rename src/main/java/com/example/mohago_nocar/plan/presentation/{ => v1}/TravelPlanControllerV1.java (88%) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelCourseServiceV1.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCourseServiceV1.java similarity index 95% rename from src/main/java/com/example/mohago_nocar/plan/application/TravelCourseServiceV1.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCourseServiceV1.java index be9bd1c..58dca18 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelCourseServiceV1.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCourseServiceV1.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.application; +package com.example.mohago_nocar.plan.application.v1; import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.festival.domain.repository.FestivalRepository; @@ -7,12 +7,12 @@ import com.example.mohago_nocar.place.application.PlaceService; import com.example.mohago_nocar.place.domain.model.Place; import com.example.mohago_nocar.place.domain.repository.PlaceRepository; -import com.example.mohago_nocar.plan.application.strategy.RouteOptimizationStrategy; +import com.example.mohago_nocar.plan.application.v1.strategy.RouteOptimizationStrategy; import com.example.mohago_nocar.plan.domain.model.Location; import com.example.mohago_nocar.plan.domain.service.TravelCourseUseCaseV1; -import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; -import com.example.mohago_nocar.plan.application.response.TravelRouteResponseDto; +import com.example.mohago_nocar.plan.presentation.v1.PlanTravelCourseRequestDto; +import com.example.mohago_nocar.plan.application.v1.response.PlanTravelCourseResponseDto; +import com.example.mohago_nocar.plan.application.v1.response.TravelRouteResponseDto; import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; import com.example.mohago_nocar.transit.domain.model.RouteMetrics; import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiExecutor; diff --git a/src/main/java/com/example/mohago_nocar/plan/application/response/BusPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java similarity index 97% rename from src/main/java/com/example/mohago_nocar/plan/application/response/BusPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java index af6e16c..87ae636 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/response/BusPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.application.response; +package com.example.mohago_nocar.plan.application.v1.response; import com.example.mohago_nocar.transit.domain.model.BusPath; import com.example.mohago_nocar.transit.domain.model.SubPath; diff --git a/src/main/java/com/example/mohago_nocar/plan/application/response/PlanTravelCourseResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/PlanTravelCourseResponseDto.java similarity index 85% rename from src/main/java/com/example/mohago_nocar/plan/application/response/PlanTravelCourseResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/PlanTravelCourseResponseDto.java index abeabe8..2a5d813 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/response/PlanTravelCourseResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/PlanTravelCourseResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.application.response; +package com.example.mohago_nocar.plan.application.v1.response; import lombok.Builder; diff --git a/src/main/java/com/example/mohago_nocar/plan/application/response/SubPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubPathResponseDto.java similarity index 87% rename from src/main/java/com/example/mohago_nocar/plan/application/response/SubPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubPathResponseDto.java index bccacab..50ae3c9 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/response/SubPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.application.response; +package com.example.mohago_nocar.plan.application.v1.response; import com.example.mohago_nocar.transit.domain.model.PathType; import lombok.Getter; diff --git a/src/main/java/com/example/mohago_nocar/plan/application/response/SubwayPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java similarity index 97% rename from src/main/java/com/example/mohago_nocar/plan/application/response/SubwayPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java index 7999261..ad235bc 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/response/SubwayPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.application.response; +package com.example.mohago_nocar.plan.application.v1.response; import com.example.mohago_nocar.transit.domain.model.SubPath; import com.example.mohago_nocar.transit.domain.model.SubwayPath; diff --git a/src/main/java/com/example/mohago_nocar/plan/application/response/TravelRouteResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/TravelRouteResponseDto.java similarity index 96% rename from src/main/java/com/example/mohago_nocar/plan/application/response/TravelRouteResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/TravelRouteResponseDto.java index f5666eb..1f6f277 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/response/TravelRouteResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/TravelRouteResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.application.response; +package com.example.mohago_nocar.plan.application.v1.response; import com.example.mohago_nocar.transit.domain.model.TransitRoute; import lombok.Builder; diff --git a/src/main/java/com/example/mohago_nocar/plan/application/response/WalkPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java similarity index 92% rename from src/main/java/com/example/mohago_nocar/plan/application/response/WalkPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java index 30d40ab..7a52f0b 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/response/WalkPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.application.response; +package com.example.mohago_nocar.plan.application.v1.response; import com.example.mohago_nocar.transit.domain.model.SubPath; import com.example.mohago_nocar.transit.domain.model.WalkPath; diff --git a/src/main/java/com/example/mohago_nocar/plan/application/strategy/RouteOptimizationStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java similarity index 83% rename from src/main/java/com/example/mohago_nocar/plan/application/strategy/RouteOptimizationStrategy.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java index 7010f6e..4ee36ff 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/strategy/RouteOptimizationStrategy.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.application.strategy; +package com.example.mohago_nocar.plan.application.v1.strategy; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.transit.domain.model.RouteMetrics; diff --git a/src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java similarity index 98% rename from src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java index 73609d3..e20fc13 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/strategy/ShortestTimeRouteStrategy.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.application.strategy; +package com.example.mohago_nocar.plan.application.v1.strategy; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.global.common.exception.InternalServerException; diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java index 798bf1c..a6aaf6b 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java @@ -1,7 +1,7 @@ package com.example.mohago_nocar.plan.domain.service; -import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.application.response.PlanTravelCourseResponseDto; +import com.example.mohago_nocar.plan.presentation.v1.PlanTravelCourseRequestDto; +import com.example.mohago_nocar.plan.application.v1.response.PlanTravelCourseResponseDto; import java.util.concurrent.CompletableFuture; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/PlanTravelCourseRequestDto.java similarity index 90% rename from src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java rename to src/main/java/com/example/mohago_nocar/plan/presentation/v1/PlanTravelCourseRequestDto.java index 056a441..5330fa7 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/PlanTravelCourseRequestDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.request; +package com.example.mohago_nocar.plan.presentation.v1; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java similarity index 88% rename from src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java rename to src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java index d293a21..10713f7 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV1.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java @@ -1,8 +1,7 @@ -package com.example.mohago_nocar.plan.presentation; +package com.example.mohago_nocar.plan.presentation.v1; import com.example.mohago_nocar.global.common.response.ApiResponse; import com.example.mohago_nocar.plan.domain.service.TravelCourseUseCaseV1; -import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; From 2f68ae2d5bd1b61ae00a0c34e3f7ffc382520e3f Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 1 Nov 2025 00:10:43 +0900 Subject: [PATCH 55/84] =?UTF-8?q?feat:=20redis=20hash=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=82=AC=EC=9A=A9=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20config=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/RedisConfig.java | 22 +++++++++++++++++-- .../infrastructure/PlaceRepositoryImpl.java | 6 ++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java b/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java index cc9601b..c1e006b 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java @@ -7,6 +7,7 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -27,13 +28,30 @@ public RedisConnectionFactory redisConnectionFactory() { } @Bean - public RedisTemplate redisTemplate() { + public RedisTemplate redisTemplateForValue() { RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new StringRedisSerializer()); -// redisTemplate.setEnableTransactionSupport(true); + redisTemplate.afterPropertiesSet(); // 설정 후 초기화 작업 수행 + return redisTemplate; } + @Bean + public RedisTemplate redisTemplateForHash() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory()); + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(serializer); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(serializer); + + template.afterPropertiesSet(); + return template; + } + } diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java index a51855a..d1a23b9 100644 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java @@ -21,7 +21,7 @@ public class PlaceRepositoryImpl implements PlaceRepository { private static final String KEY_PREFIX = "festival:places:"; - private final RedisTemplate redisTemplate; + private final RedisTemplate redisTemplateForValue; private final ObjectMapperUtil objectMapperUtil; @Override @@ -54,7 +54,7 @@ public List saveAllToCache(Long festivalId, List toSavePlaces) { private void saveToCache(String key, List places) { String placesJson = objectMapperUtil.writeValue(places); - redisTemplate.opsForValue().set(key, placesJson, 2, TimeUnit.HOURS); + redisTemplateForValue.opsForValue().set(key, placesJson, 2, TimeUnit.HOURS); } private List readFromSavedCache(String key) { @@ -63,7 +63,7 @@ private List readFromSavedCache(String key) { } private String readCache(String redisKey) { - return redisTemplate.opsForValue().get(redisKey); + return redisTemplateForValue.opsForValue().get(redisKey); } private String generateCacheKey(Long festivalId) { From cc81c59557a4108f73c44d86ec56f4ba9d18a28e Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 1 Nov 2025 00:11:13 +0900 Subject: [PATCH 56/84] =?UTF-8?q?refactor:=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/v2/PlanTravelCourseResponseDtoV2.java | 9 +++++++++ .../plan/presentation/TravelPlanResponseDtoV2.java | 6 ------ .../{request => v2}/PlanTravelCourseRequestDtoV2.java | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/PlanTravelCourseResponseDtoV2.java delete mode 100644 src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanResponseDtoV2.java rename src/main/java/com/example/mohago_nocar/plan/presentation/{request => v2}/PlanTravelCourseRequestDtoV2.java (88%) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/PlanTravelCourseResponseDtoV2.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/PlanTravelCourseResponseDtoV2.java new file mode 100644 index 0000000..f28da4c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v2/PlanTravelCourseResponseDtoV2.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.plan.application.v2; + +import lombok.Builder; + +@Builder +public record PlanTravelCourseResponseDtoV2( + String batch_id +) { +} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanResponseDtoV2.java b/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanResponseDtoV2.java deleted file mode 100644 index 6f6ec06..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanResponseDtoV2.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.mohago_nocar.plan.presentation; - -public record TravelPlanResponseDtoV2( - String batch_id -) { -} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDtoV2.java b/src/main/java/com/example/mohago_nocar/plan/presentation/v2/PlanTravelCourseRequestDtoV2.java similarity index 88% rename from src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDtoV2.java rename to src/main/java/com/example/mohago_nocar/plan/presentation/v2/PlanTravelCourseRequestDtoV2.java index c20ae2f..92a0123 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDtoV2.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/v2/PlanTravelCourseRequestDtoV2.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.request; +package com.example.mohago_nocar.plan.presentation.v2; import io.swagger.v3.oas.annotations.media.Schema; From 5ea1676765887d14db7d748784316db244dc776e Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 1 Nov 2025 00:11:26 +0900 Subject: [PATCH 57/84] =?UTF-8?q?refactor:=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/presentation/{ => v2}/TravelPlanControllerV2.java | 6 +++--- .../plan/presentation/TravelPlanControllerV2Test.java | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) rename src/main/java/com/example/mohago_nocar/plan/presentation/{ => v2}/TravelPlanControllerV2.java (83%) diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2.java b/src/main/java/com/example/mohago_nocar/plan/presentation/v2/TravelPlanControllerV2.java similarity index 83% rename from src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2.java rename to src/main/java/com/example/mohago_nocar/plan/presentation/v2/TravelPlanControllerV2.java index 2b6ddc8..3370fe3 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/v2/TravelPlanControllerV2.java @@ -1,8 +1,8 @@ -package com.example.mohago_nocar.plan.presentation; +package com.example.mohago_nocar.plan.presentation.v2; import com.example.mohago_nocar.global.common.response.ApiResponse; +import com.example.mohago_nocar.plan.application.v2.PlanTravelCourseResponseDtoV2; import com.example.mohago_nocar.plan.domain.service.TravelCourseUseCaseV1; -import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDtoV2; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -20,7 +20,7 @@ public class TravelPlanControllerV2 { private final TravelCourseUseCaseV1 travelPlanUseCase; @PostMapping - public ApiResponse planTravelCourse( + public ApiResponse planTravelCourse( @RequestBody @Valid PlanTravelCourseRequestDtoV2 request ) { // batch_id 생성 diff --git a/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java b/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java index bfc2cf6..f0ebc8f 100644 --- a/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java +++ b/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java @@ -1,5 +1,6 @@ package com.example.mohago_nocar.plan.presentation; +import com.example.mohago_nocar.plan.presentation.v2.TravelPlanControllerV2; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; From c1b2b5bf1922a79df64a3f2b07e1627be917a488 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 1 Nov 2025 00:11:43 +0900 Subject: [PATCH 58/84] =?UTF-8?q?refactor:=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/domain/service/TravelCourseUseCaseV2.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java index ac9752e..c58f1b1 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java @@ -1,7 +1,7 @@ package com.example.mohago_nocar.plan.domain.service; -import com.example.mohago_nocar.plan.presentation.TravelPlanResponseDtoV2; -import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDtoV2; +import com.example.mohago_nocar.plan.application.v2.PlanTravelCourseResponseDtoV2; +import com.example.mohago_nocar.plan.presentation.v2.PlanTravelCourseRequestDtoV2; public interface TravelCourseUseCaseV2 { From d1836ed41d434b524577726664a4a9ad718e7fa0 Mon Sep 17 00:00:00 2001 From: mungsil Date: Tue, 4 Nov 2025 18:15:28 +0900 Subject: [PATCH 59/84] =?UTF-8?q?refactor:=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../route/batch/TransitRouteBatchConfig.java | 28 ++++ ...ansitRouteBatchExecutionJpaRepository.java | 7 + .../batch/TransitRouteBatchLauncher.java | 38 +++++ ...ansitRouteBatchProgressRepositoryImpl.java | 26 +++ .../route/batch/TransitRouteItemConsumer.java | 156 ++++++++++++++++++ .../route/batch/TransitRouteItemProducer.java | 61 +++++++ 6 files changed, 316 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchConfig.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchExecutionJpaRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchLauncher.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchProgressRepositoryImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemConsumer.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemProducer.java diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchConfig.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchConfig.java new file mode 100644 index 0000000..294c148 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchConfig.java @@ -0,0 +1,28 @@ +package com.example.mohago_nocar.transit.infrastructure.route.batch; + +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; + +@Configuration +public class TransitRouteBatchConfig { + + + @Bean + TransitRouteBatchLauncher transitRouteBatchLauncher(RedisTemplate redisTemplateWithObj) { + return new TransitRouteBatchLauncher(transitRouteItemProducer(redisTemplateWithObj)); + } + + @Bean + TransitRouteItemProducer transitRouteItemProducer(RedisTemplate redisTemplateWithObj) { + return new TransitRouteItemProducer(redisTemplateWithObj); + } + + @Bean + TransitRouteItemConsumer transitRouteItemConsumer(RedisTemplate redisTemplateWithObj, + TransitRouteApiAdapter transitRouteApiAdapter) { + return new TransitRouteItemConsumer(redisTemplateWithObj, transitRouteApiAdapter); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchExecutionJpaRepository.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchExecutionJpaRepository.java new file mode 100644 index 0000000..9441697 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchExecutionJpaRepository.java @@ -0,0 +1,7 @@ +package com.example.mohago_nocar.transit.infrastructure.route.batch; + +import com.example.mohago_nocar.transit.domain.model.TransitRouteBatchExecution; +import org.springframework.data.repository.CrudRepository; + +public interface TransitRouteBatchExecutionJpaRepository extends CrudRepository { +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchLauncher.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchLauncher.java new file mode 100644 index 0000000..720574d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchLauncher.java @@ -0,0 +1,38 @@ +package com.example.mohago_nocar.transit.infrastructure.route.batch; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRouteBatchExecution; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * 대중교통 경로를 구하는 배치 작업을 시작합니다. + * 진입 순서대로 동일한 배치 아이디를 가지는 아이템을 생성합니다. + */ +@Slf4j +@RequiredArgsConstructor +public class TransitRouteBatchLauncher { + + private final ReentrantLock lock = new ReentrantLock(true); + private final TransitRouteItemProducer itemProducer; + + public void launch(TransitRouteBatchExecution execution, List locations) { + try { + if (lock.tryLock(5, TimeUnit.SECONDS)) { + itemProducer.produce(execution.getId(), locations); + }else { + throw new RuntimeException("Failed to acquire lock within timeout"); + } + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } finally { + lock.unlock(); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchProgressRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchProgressRepositoryImpl.java new file mode 100644 index 0000000..9696397 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchProgressRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.example.mohago_nocar.transit.infrastructure.route.batch; + +import com.example.mohago_nocar.transit.domain.model.TransitRouteBatchExecution; +import com.example.mohago_nocar.transit.domain.repository.TransitRouteBatchProgressRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@Transactional +@RequiredArgsConstructor +public class TransitRouteBatchProgressRepositoryImpl implements TransitRouteBatchProgressRepository { + + private final TransitRouteBatchExecutionJpaRepository jpaRepository; + + @Override + public TransitRouteBatchExecution save(TransitRouteBatchExecution execution) { + return jpaRepository.save(execution); + } + + @Override + public TransitRouteBatchExecution findByExecutionId(String executionId) { + return jpaRepository.findById(executionId).orElse(null); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemConsumer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemConsumer.java new file mode 100644 index 0000000..7724265 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemConsumer.java @@ -0,0 +1,156 @@ +package com.example.mohago_nocar.transit.infrastructure.route.batch; + +import com.example.mohago_nocar.transit.domain.model.OdsayApiRequest; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StreamOperations; +import org.springframework.data.redis.hash.Jackson2HashMapper; +import org.springframework.scheduling.annotation.Scheduled; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.PriorityBlockingQueue; + +/** + * 아이템을 폴링하여 내부적인 큐로 재전송합니다. + */ +@Slf4j +@RequiredArgsConstructor +@Getter +public class TransitRouteItemConsumer { + + private final RedisTemplate redisTemplateWithObj; + private final TransitRouteApiAdapter transitRouteApiAdapter; + + private static final String STREAM_KEY = "odsay-api-request"; + private static final String CONSUMER_GROUP = "odsay-api-consumer"; + private static final String CONSUMER_NAME = "consumer-1"; + + private final PriorityBlockingQueue> queue = new PriorityBlockingQueue<>(); + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + + private RateLimiter rateLimiter; + + @PostConstruct + public void init() { + log.info("Initializing TransitRouteItemConsumer"); + // 소비자 그룹 생성 + try { + redisTemplateWithObj + .opsForStream() + .createGroup(STREAM_KEY, ReadOffset.lastConsumed(), CONSUMER_GROUP); + log.info("Consumer group {} created", CONSUMER_GROUP); + } catch (Exception e) { + log.error("Error initializing TransitRouteItemConsumer", e); + log.info("Consumer group {} already exists or error creating: {}", CONSUMER_GROUP, e.getMessage()); + } + + // 속도 제한 설정 + rateLimiter = initializeRateLimiter().rateLimiter("o"); + +// startWorker(); + } + + /** + * 1.retry = 재시도 큐 확인 > 일정 시간 텀 두기 + * 2.req = 일반 큐 확인 + * + * consume target = retry == null ? req : retry + * consume target.async consume + * : api call + * : if exception occur, put retry queue + * : if retry permit num exceeds, set fail to batch + * + * batchTaskChecker + * : 폴링을 하면서 완료된 배치 잡과 실패한 배치 잡 체크, 알림 전송 + */ + + + /** + * RedisStream에서 메시지를 읽어 큐에 추가합니다. + */ + @Scheduled(fixedDelay = 1000) + public void consume() { + log.info("Consumer group {} consume started", CONSUMER_GROUP); + StreamOperations stringStringObjectStreamOperations = redisTemplateWithObj.opsForStream(new Jackson2HashMapper(true)); + List> records = stringStringObjectStreamOperations + .read( + OdsayApiRequest.class, + Consumer.from(CONSUMER_GROUP, CONSUMER_NAME), + StreamReadOptions.empty() + .count(50) + .block(Duration.ofSeconds(3)), + StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed()) // 해당 라인 필요함? + ); + + if (records != null && !records.isEmpty()) { + log.info("Consumer group {} consumed {} records", CONSUMER_GROUP, records.size()); + records.stream() + .map(obj -> obj.getValue()) + .forEach(System.out::println); + + queue.addAll(records); + } + } + + + public void startWorker() { + Thread thread = new Thread(() -> { + while (!Thread.interrupted()) { + try { + // 재시도 큐에 메시지가 존재하면 그걸 꺼내오면 됨 + ObjectRecord take = queue.take(); + rateLimiter.executeSupplier(() -> + executor.submit(() -> { + // api 요청 + OdsayApiRequest request = take.getValue(); + TransitRoute transitRoute = transitRouteApiAdapter.getTransitRouteBetweenLocations( + request.getOrigin(), request.getDestination()); + // 응답 저장 + // batchId = [응답 1, 응답 2, 응답 3, 응답 4] + // seq: obj를 저장한다음, 배치 작업이 완료되면 seq대로 정렬 후 obj를 꺼내는... + // batch_id_1 : {{seq: 0, content: class}} + // batch 메타 데이터 업데이트: 완료 처리 + // ack 처리 + redisTemplateWithObj.opsForStream().acknowledge(CONSUMER_GROUP, take); + })); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + }); + } + + private RateLimiterRegistry initializeRateLimiter() { + RateLimiterConfig config = RateLimiterConfig.custom() + .limitRefreshPeriod(Duration.ofMillis(200)) + .limitForPeriod(1) + .timeoutDuration(Duration.ofSeconds(20)) + .build(); + + return RateLimiterRegistry.of(config); + } + + public void consume2() { + + } + + @PreDestroy + public void destroy() { + log.info("Destroying TransitRouteItemConsumer"); + // pending list에 있는거 dlq로 보내기? + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemProducer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemProducer.java new file mode 100644 index 0000000..06c4b60 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemProducer.java @@ -0,0 +1,61 @@ +package com.example.mohago_nocar.transit.infrastructure.route.batch; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.OdsayApiRequest; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.hash.Jackson2HashMapper; + +import java.util.ArrayList; +import java.util.List; + +public class TransitRouteItemProducer { + + public static final String ODSAY_API_REQUEST_STREAM_NAME = "odsay-api-request"; + + private final RedisTemplate redisTemplateWithObj; + + public TransitRouteItemProducer(RedisTemplate redisTemplateWithObj) { + this.redisTemplateWithObj = redisTemplateWithObj; + } + + public void produce(String batchId, List locations) { + validateLocations(locations); + List items = createItems(batchId, locations); + submit(items); + } + + private void submit(List items) { + for (OdsayApiRequest item : items) { + ObjectRecord apiReq = StreamRecords.newRecord() + .in(ODSAY_API_REQUEST_STREAM_NAME) + .ofObject(item); + + redisTemplateWithObj + .opsForStream(new Jackson2HashMapper(true)) + .add(apiReq); + } + } + + private List createItems(String batchId, List locations) { + List odsayApiRequests = new ArrayList<>(); + + for (int i = 1; i < locations.size(); i++) { + Location origin = locations.get(i-1); + Location destination = locations.get(i); + + OdsayApiRequest apiRequest = OdsayApiRequest.of(batchId, origin, destination, i); + odsayApiRequests.add(apiRequest); + } + + return odsayApiRequests; + } + + private void validateLocations(List locations) { + if (locations == null || locations.size() < 2) { + throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); + } + } + +} From 8b655233ee553a9e02b34ca143d49fe733d5220f Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 12 Nov 2025 09:27:52 +0900 Subject: [PATCH 60/84] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=8D=BC?= =?UTF-8?q?=ED=8B=B0=20=EC=8A=A4=EC=BA=94=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/mohago_nocar/global/config/PropertyConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/example/mohago_nocar/global/config/PropertyConfig.java b/src/main/java/com/example/mohago_nocar/global/config/PropertyConfig.java index 08fc2b4..44510d0 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/PropertyConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/PropertyConfig.java @@ -1,5 +1,6 @@ package com.example.mohago_nocar.global.config; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; import org.springframework.context.annotation.PropertySources; @@ -8,5 +9,6 @@ @PropertySources({ @PropertySource("classpath:env.properties") }) +@ConfigurationPropertiesScan(basePackages = {"com.example.mohago_nocar"}) public class PropertyConfig { } From 2f70e8f4ebe97df4ebc805018fc22b17d8d2dd83 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 12 Nov 2025 09:28:43 +0900 Subject: [PATCH 61/84] =?UTF-8?q?feat:=20fcm=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.gradle b/build.gradle index be5e339..fbc0e58 100644 --- a/build.gradle +++ b/build.gradle @@ -59,12 +59,17 @@ dependencies { // rate limiter implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0' + // notification + implementation 'com.google.firebase:firebase-admin:9.7.0' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'it.ozimov:embedded-redis:0.7.3' runtimeOnly 'com.h2database:h2' + testImplementation 'org.assertj:assertj-core:3.27.6' + implementation 'net.datafaker:datafaker:2.5.3' } tasks.named('test') { From 0b7ef356c049a5574c61bccd9fb37688691fadce Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 12 Nov 2025 10:37:31 +0900 Subject: [PATCH 62/84] =?UTF-8?q?feat:=20FCM=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=EC=9D=84=20=EC=9C=84=ED=95=9C=20google=20cre?= =?UTF-8?q?dentials=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mohago_nocar/global/config/FcmConfig.java | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/global/config/FcmConfig.java diff --git a/src/main/java/com/example/mohago_nocar/global/config/FcmConfig.java b/src/main/java/com/example/mohago_nocar/global/config/FcmConfig.java new file mode 100644 index 0000000..797e1ed --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/FcmConfig.java @@ -0,0 +1,79 @@ +package com.example.mohago_nocar.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +// todo Prod: 환경변수 GOOGLE_APPLICATION_CREDENTIALS 사용 --> 등록 필요 +@Component +@Slf4j +@RequiredArgsConstructor +public class FcmConfig implements ApplicationRunner { + + @Value("${google.firebase.key.path}") + private String firebaseKeyPath; + + private final Environment env; + + @Override + public void run(ApplicationArguments args) throws Exception { + List profiles = Arrays.asList(env.getActiveProfiles()); + + if (profiles.contains("test")) { + log.warn("테스트 환경에서 FCM 테스트를 지원하지 않습니다."); + return; + } + + GoogleCredentials credentials = profiles.contains("dev") + ? loadFromLocalFile() + : loadFromApplicationDefault(); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(credentials) + .build(); + FirebaseApp.initializeApp(options); + } + + private GoogleCredentials loadFromApplicationDefault() { + try { + return GoogleCredentials.getApplicationDefault(); + } catch (IOException e) { + log.error("GoogleCredentials 획득 중 문제가 발생했습니다."); + log.error("에러: {}", e.getMessage()); + log.error("프로파일: {}", (Object) env.getActiveProfiles()); + throw new RuntimeException(e); + } + } + + private GoogleCredentials loadFromLocalFile() { + try { + FileInputStream serviceAccount = new FileInputStream(firebaseKeyPath); + return GoogleCredentials.fromStream(serviceAccount); + } catch (IOException e) { + log.error("GoogleCredentials 획득 중 문제가 발생했습니다."); + log.error("에러: {}", e.getMessage()); + log.error("프로파일: {}", (Object) env.getActiveProfiles()); + throw new RuntimeException(e); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging() { + return FirebaseMessaging.getInstance(); + } + +} From 9b0c75e0398ec60e0db2152d831967713882f1d9 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 12 Nov 2025 11:29:04 +0900 Subject: [PATCH 63/84] =?UTF-8?q?feat:=20=EC=9D=B5=EB=AA=85=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 여행 계획 완료 시 [알림 전송 -> 유저가 조회]하는 플로우입니다. - 다음과 같은 이유로 유저 기능의 필요성을 느꼈습니다. - 알림을 위해 필요한 FCM 토큰은 유저 단위로 관리하는게 편리함 - 여행 계획 조회 API 구현 방식의 변화(동기 -> 비동기 알림)에 따라, '요청했던 여행 계획 리스트' 기능이 요구됨. 그러나 해당 조회 기능에 FCM 토큰을 식별자로 사용하는 것은 부적절하다고 생각됨. --- .../user/application/UserService.java | 31 +++++++++++++++++ .../user/domain/AnonymousUser.java | 33 +++++++++++++++++++ .../user/domain/UserRepository.java | 11 +++++++ .../mohago_nocar/user/domain/UserUseCase.java | 11 +++++++ .../AnonymousUserJpaRepository.java | 9 +++++ .../infrastructure/UserRepositoryImpl.java | 29 ++++++++++++++++ 6 files changed, 124 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/user/application/UserService.java create mode 100644 src/main/java/com/example/mohago_nocar/user/domain/AnonymousUser.java create mode 100644 src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java create mode 100644 src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java diff --git a/src/main/java/com/example/mohago_nocar/user/application/UserService.java b/src/main/java/com/example/mohago_nocar/user/application/UserService.java new file mode 100644 index 0000000..b6a7278 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/application/UserService.java @@ -0,0 +1,31 @@ +package com.example.mohago_nocar.user.application; + +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserRepository; +import com.example.mohago_nocar.user.domain.UserUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UserService implements UserUseCase { + + private final UserRepository userRepository; + + @Override + public AnonymousUser save(AnonymousUser user) { + return userRepository.save(user); + } + + @Override + public AnonymousUser findById(UUID userId) { + Optional optionalUser = userRepository.findById(userId); + return optionalUser.orElseThrow(() -> new CustomException(GlobalStatus.ENTITY_NOT_FOUND)); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/user/domain/AnonymousUser.java b/src/main/java/com/example/mohago_nocar/user/domain/AnonymousUser.java new file mode 100644 index 0000000..bb31674 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/domain/AnonymousUser.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.user.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.UUID; + + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class AnonymousUser { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + private String fcmToken; + + public static AnonymousUser create(String fcmToken) { + return AnonymousUser.builder() + .fcmToken(fcmToken).build(); + } + + @Builder(access = AccessLevel.PRIVATE) + private AnonymousUser(String uuid, String fcmToken) { + this.fcmToken = fcmToken; + } + +} + + diff --git a/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java b/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java new file mode 100644 index 0000000..aca656d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java @@ -0,0 +1,11 @@ +package com.example.mohago_nocar.user.domain; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository { + + AnonymousUser save(AnonymousUser identifier); + + Optional findById(UUID userId); +} diff --git a/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java b/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java new file mode 100644 index 0000000..bc94a93 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java @@ -0,0 +1,11 @@ +package com.example.mohago_nocar.user.domain; + +import java.util.UUID; + +public interface UserUseCase { + + AnonymousUser save(AnonymousUser user); + + AnonymousUser findById(UUID userId); + +} diff --git a/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java b/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java new file mode 100644 index 0000000..444be0f --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.user.infrastructure; + +import com.example.mohago_nocar.user.domain.AnonymousUser; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface AnonymousUserJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java new file mode 100644 index 0000000..a712eff --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.example.mohago_nocar.user.infrastructure; + +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; + +@Repository +@Transactional +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final AnonymousUserJpaRepository anonymousUserJpaRepository; + + @Override + public AnonymousUser save(AnonymousUser user) { + return anonymousUserJpaRepository.save(user); + } + + @Override + public Optional findById(UUID userId) { + return anonymousUserJpaRepository.findById(userId); + } + +} From 79f0f22babefe3f719fc0eebb01542f431945b5e Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 22 Nov 2025 10:19:21 +0900 Subject: [PATCH 64/84] =?UTF-8?q?log:=20'=EB=B0=A9=EB=AC=B8=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=84=A0=EC=A0=95=20=EC=99=84=EB=A3=8C'=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=86=8C=EB=B9=84=EC=9E=90=20=EA=B5=AC=ED=98=84=20=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EB=B0=8F=20=EC=A4=91=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 변경 전: A -> B 하위 경로 구하기 - 변경 후: 여러 하위 경로를 포함하는 최종 경로 구하기 --- .../consumer/TransitRouteRequestConsumer.java | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java new file mode 100644 index 0000000..89c2de5 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java @@ -0,0 +1,396 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.consumer; + +import com.example.mohago_nocar.global.common.exception.Status; +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.plan.application.v2.TravelCoursePlanNotifyService; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.domain.model.TransitRouteRequest; +import com.example.mohago_nocar.transit.domain.model.BatchStatus; +import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; +import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import io.github.bucket4j.*; +import io.lettuce.core.RedisBusyException; +import io.lettuce.core.RedisConnectionException; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.stream.StreamListener; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +// todo readOffset 설정 확인 +@Component +@RequiredArgsConstructor +@Slf4j +public class TransitRouteRequestConsumer implements StreamListener> { + + private final RedisTemplate objectRedisTemplate; + @Value("${redis.streams.odsay.main}") + private String streamKey; + + private final String consumerGroup = "processors"; + + private StreamMessageListenerContainer> listenerContainer; + + private final StringRedisTemplate stringRedisTemplate; + private final TravelCoursePlanNotifyService travelCoursePlanNotifyService; + private final ObjectMapperUtil objectMapperUtil; + private final TransitRouteApiAdapter transitRouteApiAdapter; + + private final ExecutorService virtualThreadPool = Executors.newVirtualThreadPerTaskExecutor(); + + private Bucket bucket; + + @PostConstruct + public void init() { + bucket = createBucket(); + + // Consumer Group 설정 + createStreamConsumerGroupIfNotExists(streamKey, consumerGroup); + + // StreamMessageListenerContainer 설정 + this.listenerContainer = StreamMessageListenerContainer.create( + stringRedisTemplate.getConnectionFactory(), + StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder() + .targetType(String.class) + .pollTimeout(Duration.ofSeconds(3)) + .batchSize(1) + .errorHandler(t -> log.info(t.getMessage())) // todo 로깅 및 알림 + .build() + ); + + listenerContainer.register(StreamMessageListenerContainer.StreamReadRequest + .builder(StreamOffset.create(streamKey, ReadOffset.lastConsumed())) + .cancelOnError(throwable -> false) + .consumer(Consumer.from(this.consumerGroup, "processors-1")) + .autoAcknowledge(false).build() + , this + ); + +/* int parallelListener = 2; + for (int i = 1; i <= parallelListener; i++) { + String consumerName = "processor-" + i; + + listenerContainer.register(StreamMessageListenerContainer.StreamReadRequest + .builder(StreamOffset.create(streamKey, ReadOffset.lastConsumed())) + .cancelOnError(throwable -> false) + .consumer(Consumer.from(this.consumerGroup, consumerName)) + .autoAcknowledge(false).build() + , this + ); + }*/ + + // Redis listen 시작 + this.listenerContainer.start(); + } + +/* @Override + public void onMessage(ObjectRecord message) { + try { + Thread.sleep(200); + } catch (InterruptedException e) { + // todo 로깅 + 알림 : 현재 스레드를 인터럽트하는 코드를 작성해두지 않음 -> 원인 파악 후 처리 + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + + asyncProcessRequest(message); + }*/ + + + + @Override + public void onMessage(ObjectRecord message) { + try { + bucket.asBlocking().consume(1); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + asyncProcessRequest(message); + } + + private void asyncProcessRequest(ObjectRecord message) { + TransitRouteRequest request = objectMapperUtil.readValue(message.getValue(), TransitRouteRequest.class); + + // todo 이미 실패한 배치의 요청인지 확인 -> 이미 실패한 배치 요청이라면 throw ex + CompletableFuture.supplyAsync(() -> fetchTransitRouteByApiCalling(request), virtualThreadPool) + .handle((route, ex) -> { + if (ex != null) { + // if retryable exception + // todo 재시도 with 지수 백오프 구현 방법 -> pending 리스트에 그대로 두기 + if (isTooManyRequestsEx(ex)) { + // Using retry count of request, consider follower two options + // 1. send to DLQ if <- exceed enable auto retry count + // 2. add to sorted set <- else + return null; + } + + // [로깅, 개발자 알림], 배치 실패 처리 + // 개발자 알림은 why, how? + // why? + // 예상치 못한 예외 (네트웤, 429, external api unexpected ex)에 대한 대응이 필요하기 때문임 + // how? + // 1. 로그 기반 일정 시간 간격: 예외 발생 사실 확인 -> 로깅 -> 정기적 알림 전송 + // 장점: 예상 가능한 알림 전송량 + 예외가 다량 발생해도 통계적으로 확인 가능 + // 단점: 실시간성이 떨어짐. 실시간으로 대처해야하는 에러가 있다면? + // -> '실시간 대처가 필요한' 에러란? + // -> 모르겠음. + // 2. 예외 발생 시 실시간 알림 전송 + // 장점: 실시간으로 예외 지각 가능 + // 단점: 알림이 많이 와서 파악이 어려울 가능성 있음 (근데 현재는 아님) + // + + // todo need a block circuit + if (ex instanceof ODsayRouteException odsayEx) { + Status status = odsayEx.getStatus(); + // 빠르게 응답을 줘야함 -> 배치 실패 처리 후 로깅 + // todo cause로 api 키나 ip 문제 인지 확인 + if (status instanceof OdsayErrorCode errorCode) { + errorCode.isServerError(); + } + // 실패 처리 -> 바로 알림 주는게 조은뎅 + } + + if (ex instanceof RestClientException clientEx) { + // 재시도하는 거 어때욤? 네트워크 에러잖아... + // ah, I was thought I should only add a circuit breaker, + // but now I'm think I need both. + // 아 이거!! 원인이 네트워크 에러만 있는게 아니었음. + } + +// todo lost update 발생 해결 -> 루아스크립트 사용. +// TransitRouteBatchExecution batchExecution = batchExecutionRepository.findByExecutionId(request.getBatchId()); +// batchExecution.fail((Exception) ex); // 배치 상태 실패로 변경 - 가장 먼저 발생한 에러만 기록 +// batchExecutionRepository.save(batchExecution); +// // 실패한 request dlq로 이동 +// onAfterFailure.run(); // entry ack & delete + + // * 실패 알림은 워커 스레드가 수행할거임. <- 배치 상태 fail 인거 확인할거임. + } + + // todo 멱등성 테스트 + else { + BatchStatus batchStatus = processWhenRequestSuccess(request.getBatchId(), route, request.getSequence()); + if (batchStatus == BatchStatus.COMPLETED) { + // 결과 rdb 저장 - notification id + batch id가 기준이 될 것임. + saveCompletedTransitRoute(request.getBatchId()); + + // 알림 전송 + travelCoursePlanNotifyService.sendSuccessNotification(request.getBatchId()); + } + + // 여기서 레디스 서버 크러쉬되면 중복 처리 발생 + ackAndDeleteEntry(message.getId()); + } + + return null; + }).exceptionally(throwable -> { + log.error("Error processing request", throwable); + + + // 2. 네트워크 혼잡 예외 + + // 3. 레디스 인프라 예외 + // 어떤 예외가 있는지 몰라욤. 공부 필요해염. + // 레디스 서버가 내려가는 경우 -> 1. 정상화될때까지 롱폴링 2. RDB + + // 4. 비즈니스 로직 예외. + // 존재하지 않는 배치 아이디 예외 같은거. + // > 예상하지 못한 상황 < 실패 처리하고 디스코드 알림, 로깅하기. + + // etc. 그 외 내가 모르는 예외 + // 일단 DLQ로 전송, 디스코드 알림, 로깅하기. + return null; + }); + } + + private Bucket createBucket() { + int permitAPICallNumPerSec = 10; + Bandwidth limit = BandwidthBuilder.builder().capacity(permitAPICallNumPerSec) + .refillIntervally(permitAPICallNumPerSec, Duration.ofSeconds(1)) + .initialTokens(0) + .build(); + + return Bucket.builder() + .addLimit(limit) + .build(); + } + + private boolean isTooManyRequestsEx(Throwable ex) { + if (ex instanceof ODsayRouteException odsayEx) { + Status status = odsayEx.getStatus(); + if (status instanceof OdsayErrorCode errorCode) { + if (errorCode.isTooManyRequests()) { + return true; + } + + errorCode.isServerError(); // 로깅 with request , DLQ 전송 // 개발자 알림 + + // 아래는 공통적으로 유저 (실패) 알림 전송 // 개발자 알림 + errorCode.isUnExpectedError(); // 로깅 with request, + + // 그 외: 로깅도 안함. + } else { + log.warn("UnExpected Status exists : {}", status); + // 로깅 후 DLQ 전송 // 개발자 알림 + } + } + + // 레디스 크러쉬 + // 서버가 죽으면 '재시도' 하는 것보다 '차단'하는 게 이득이 아닐까? + // 해당 사항은 레디스 서버 크러쉬임 + + // 네트워크 에러 + // 재시도하는게 좋겠지만, 이거 네트워크 혼잡 때문이다! 라고 에러 식별할 수 있는 방법을 모름. + // 이것도 서킷 브레이커 패턴을 쓰면 어떨까 + + if (ex instanceof RedisConnectionException connEX){ + // 재시도 or 실패 + } + + return false; + } + + private void ackAndDeleteEntry(RecordId recordId) { + String lua = """ + local stream = ARGV[1] + local group = ARGV[2] + local id = ARGV[3] + local acked = redis.call('XACK', stream, group, id) + local deleted = redis.call('XDEL', stream, id) + return {acked, deleted} + """; + + DefaultRedisScript script = new DefaultRedisScript<>(lua, List.class); + List executed = stringRedisTemplate.execute( + script, + List.of(), // KEYS 없음 + streamKey, // ARGV[1] + consumerGroup, // ARGV[2] + recordId.getValue() // ARGV[3] + ); + + Long acked = executed.get(0); + Long deleted = executed.get(1); + + if (acked == 0 || deleted == 0) { + log.warn("[Redis] ackAndDeleteEntry: ack={}, del={}, id={}", acked, deleted, recordId.getValue()); + } + + } + + private void saveCompletedTransitRoute(String batchId) { + + } + + // todo sequence 구분자는 batch 도메인에게 물어봐야한다. 필드명도... + private BatchStatus processWhenRequestSuccess(String batchId, TransitRoute route, int sequenceInBatch) { + String lua = + """ + local zsetKey = KEYS[1] -- Sorted Set 키 (경로 결과 저장용) + local hashKey = KEYS[2] -- Hash 키 (배치 메타데이터 저장용) + local score = tonumber(ARGV[1]) -- Sorted Set의 점수값 (경로의 순서(시퀀스) 저장용) + local member = ARGV[2] -- Sorted Set에 저장할 멤버(경로 데이터) + local completedSeqsField = ARGV[3] -- 완료된 시퀀스 목록 필드명 + local totalField = ARGV[4] -- 전체 시퀀스 목록 필드명 + local newCompletedSeq = ARGV[5] -- 완료된 시퀀스 + + -- 1. API 호출 결과(대중교통 경로) 저장 + local added = redis.call('ZADD', zsetKey, score, member) + + local function updateCompletedSeqList(hashKey, completedSeqsField, newCompletedSeq) + local current = redis.call('HGET', hashKey, completedSeqsField) + + -- 시퀀스 목록이 비어있는 경우 + if not current or current == '' then + redis.call('HSET', hashKey, completedSeqsField, newCompletedSeq) + return 1 + end + + local alreadyExists = false + local existedCount = 0 + for seq in string.gmatch(current, '([^,]+)') do + existedCount = existedCount + 1 + if seq == newCompletedSeq then + alreadyExists = true + end + end + + if alreadyExists then + return existedCount + end + + -- 완료 목록에 시퀀스 업데이트 + local updated = current .. ',' .. newCompletedSeq + redis.call('HSET', hashKey, completedSeqsField, updated) + return existedCount + 1 + end + + -- 2. 시퀀스 완료 처리 + local completedCount = updateCompletedSeqList(hashKey, completedSeqsField, newCompletedSeq) + + -- 3. 배치 완료 여부 확인 + local total = redis.call('HGET', hashKey, totalField) + if tostring(completedCount) == tostring(total) then + redis.call('HSET', hashKey, 'status', 'completed') + end + + -- 4. 배치 상태 반환 + local batchStatus = redis.call('HGET', hashKey, 'status') + return batchStatus + """; + DefaultRedisScript script = new DefaultRedisScript<>(lua, String.class); + + // todo 키 별도 관리 + String sortedSetKey = "transit:routes" + batchId; + String batchExecutionKey = "batch:" + batchId; + String result = stringRedisTemplate.execute( + script, + List.of(sortedSetKey, batchExecutionKey), + sequenceInBatch, + route, + "completedSequences", // 필드명 + "totalCount", // 필드명 + 1 + ); + + return BatchStatus.valueOf(result); + } + + // todo 여기서 발생 가능한 예외 정리 + public TransitRoute fetchTransitRouteByApiCalling(TransitRouteRequest request) { + return transitRouteApiAdapter.getTransitRouteBetweenLocations(request.getOrigin(), request.getDestination()); + } + + private void createStreamConsumerGroupIfNotExists(String streamKey, String consumerGroup) { + try { + stringRedisTemplate.opsForStream() + .createGroup(streamKey, ReadOffset.from("0"), consumerGroup); + log.info("Consumer group {} created", consumerGroup); + } catch (RedisSystemException e) { + Throwable cause = e.getCause(); + if (cause instanceof RedisBusyException busyException) { + log.info(busyException.getMessage()); + return; + } + + throw new RuntimeException( + "Unexpected error while creating consumer group: ", cause); // todo convert to 커스텀 ex + } + } + +} From 0fcd6ead8a7ea996cb5aaad23591ba1c45909ab8 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sun, 23 Nov 2025 10:12:10 +0900 Subject: [PATCH 65/84] =?UTF-8?q?feat:=20course=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course/{Course.java => TravelCourse.java} | 16 +++++--- .../domain/model/routeStep/RouteStep.java | 32 ++++++++++------ .../model/travelSpot/AttractionSpot.java | 29 --------------- .../model/travelSpot/RestaurantSpot.java | 27 -------------- .../domain/model/travelSpot/TravelSpot.java | 37 +++++++++++++++---- 5 files changed, 60 insertions(+), 81 deletions(-) rename src/main/java/com/example/mohago_nocar/course/domain/model/course/{Course.java => TravelCourse.java} (60%) delete mode 100644 src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/AttractionSpot.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/RestaurantSpot.java diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/Course.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java similarity index 60% rename from src/main/java/com/example/mohago_nocar/course/domain/model/course/Course.java rename to src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java index 146f361..5f0e506 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/course/Course.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java @@ -1,25 +1,29 @@ package com.example.mohago_nocar.course.domain.model.course; import com.example.mohago_nocar.global.common.domain.BaseEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.UUID; + import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; @Entity @Getter @NoArgsConstructor(access = PROTECTED) -public class Course extends BaseEntity { +public class TravelCourse extends BaseEntity { @Id @GeneratedValue(strategy = IDENTITY) private Long id; - public static Course from() { - return new Course(); + private UUID anonymousUserId; + + public static TravelCourse from() { + return new TravelCourse(); } + } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java index f3d8053..77173c9 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java @@ -3,6 +3,7 @@ import com.example.mohago_nocar.global.common.domain.BaseEntity; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.global.util.DurationToIntervalConverter; +import com.example.mohago_nocar.plan.domain.model.Location; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.Builder; @@ -32,48 +33,55 @@ public class RouteStep extends BaseEntity { @NotNull private Integer stepOrder; + // todo subpath vs jsonB + // JSONB + private String detailPaths; + @NotNull @Embedded @AttributeOverrides({ - @AttributeOverride(name = "longitude", column = @Column(name = "start_longitude")), - @AttributeOverride(name = "latitude", column = @Column(name = "start_latitude")) + @AttributeOverride(name = "name", column = @Column(name = "start_name")), + @AttributeOverride(name = "coordinate.latitude", column = @Column(name = "start_latitude")), + @AttributeOverride(name = "coordinate.longitude", column = @Column(name = "start_longitude")) }) - private Coordinate startCoordinate; + private Location origin; @NotNull @Embedded @AttributeOverrides({ - @AttributeOverride(name = "longitude", column = @Column(name = "end_longitude")), - @AttributeOverride(name = "latitude", column = @Column(name = "end_latitude")) + @AttributeOverride(name = "name", column = @Column(name = "end_name")), + @AttributeOverride(name = "coordinate.latitude", column = @Column(name = "end_latitude")), + @AttributeOverride(name = "coordinate.longitude", column = @Column(name = "end_longitude")) }) - private Coordinate endCoordinate; + private Location destination; @NotNull @Convert(converter = DurationToIntervalConverter.class) private Duration timeTaken; public static RouteStep from(Long courseId, Integer distance, Integer stepOrder, - Coordinate startCoordinate, Coordinate endCoordinate, Duration timeTaken + Location origin, Location destination, Duration timeTaken ) { return RouteStep.builder() .courseId(courseId) .distance(distance) .stepOrder(stepOrder) - .startCoordinate(startCoordinate) - .endCoordinate(endCoordinate) + .origin(origin) + .destination(destination) .timeTaken(timeTaken) .build(); } @Builder private RouteStep(Long courseId, Integer distance, Integer stepOrder, - Coordinate startCoordinate, Coordinate endCoordinate, Duration timeTaken + Location origin, Location destination, Duration timeTaken ) { this.courseId = courseId; this.distance = distance; this.stepOrder = stepOrder; - this.startCoordinate = startCoordinate; - this.endCoordinate = endCoordinate; + this.origin = origin; + this.destination = destination; this.timeTaken = timeTaken; } + } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/AttractionSpot.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/AttractionSpot.java deleted file mode 100644 index 6cc8c33..0000000 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/AttractionSpot.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.mohago_nocar.course.domain.model.travelSpot; - -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import static lombok.AccessLevel.PROTECTED; - -@Entity -@Getter -@NoArgsConstructor(access = PROTECTED) -@DiscriminatorValue("ATTRACTION") -public class AttractionSpot extends TravelSpot { - - public static AttractionSpot from(Long courseId, Integer order) { - return AttractionSpot.builder() - .courseId(courseId) - .order(order) - .build(); - } - - @Builder - private AttractionSpot(Long courseId, Integer order) { - this.courseId = courseId; - this.spotOrder = order; - } -} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/RestaurantSpot.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/RestaurantSpot.java deleted file mode 100644 index 6ff3a70..0000000 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/RestaurantSpot.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.mohago_nocar.course.domain.model.travelSpot; - -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import lombok.*; - -import static lombok.AccessLevel.PROTECTED; - -@Entity -@Getter -@NoArgsConstructor(access = PROTECTED) -@DiscriminatorValue("RESTAURANT") -public class RestaurantSpot extends TravelSpot { - - public static RestaurantSpot from(Long courseId, Integer order) { - return RestaurantSpot.builder() - .courseId(courseId) - .order(order) - .build(); - } - - @Builder - private RestaurantSpot(Long courseId, Integer order) { - this.courseId = courseId; - this.spotOrder = order; - } -} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java index 6dbde77..2cf3737 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java @@ -3,21 +3,44 @@ import com.example.mohago_nocar.global.common.domain.BaseEntity; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import static jakarta.persistence.GenerationType.IDENTITY; @Entity -@Inheritance(strategy = InheritanceType.SINGLE_TABLE) -@DiscriminatorColumn(name = "spot_type") -public abstract class TravelSpot extends BaseEntity { +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelSpot extends BaseEntity { @Id @GeneratedValue(strategy = IDENTITY) - protected Long id; + private Long id; @NotNull - protected Long courseId; + private Long courseId; @NotNull - protected Integer spotOrder; -} + private String placeId; + + @NotNull + private Integer visitOrder; + + public static TravelSpot from(Long courseId, String placeId, Integer visitOrder) { + return TravelSpot.builder() + .courseId(courseId) + .placeId(placeId) + .visitOrder(visitOrder) + .build(); + } + + @Builder + private TravelSpot(Long courseId, String placeId, Integer visitOrder) { + this.courseId = courseId; + this.placeId = placeId; + this.visitOrder = visitOrder; + } + +} \ No newline at end of file From 042a03ebe7fe99a62a060841932d3c1ea8ba7bb8 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sun, 23 Nov 2025 12:00:20 +0900 Subject: [PATCH 66/84] =?UTF-8?q?feat:=20course=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/model/travelSpot/TravelSpot.java | 16 +++++----- .../model/travelSpot/TravelSpotFestival.java | 30 +++++++++++++++++++ .../model/travelSpot/TravelSpotPlace.java | 30 +++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java index 2cf3737..e62fad9 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java @@ -10,10 +10,10 @@ import static jakarta.persistence.GenerationType.IDENTITY; -@Entity +@MappedSuperclass @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class TravelSpot extends BaseEntity { +public abstract class TravelSpot extends BaseEntity { @Id @GeneratedValue(strategy = IDENTITY) @@ -22,13 +22,15 @@ public class TravelSpot extends BaseEntity { @NotNull private Long courseId; - @NotNull - private String placeId; - @NotNull private Integer visitOrder; - public static TravelSpot from(Long courseId, String placeId, Integer visitOrder) { + protected TravelSpot(Long courseId, Integer visitOrder) { + this.courseId = courseId; + this.visitOrder = visitOrder; + } + + /* public static TravelSpot from(Long courseId, String placeId, Integer visitOrder) { return TravelSpot.builder() .courseId(courseId) .placeId(placeId) @@ -41,6 +43,6 @@ private TravelSpot(Long courseId, String placeId, Integer visitOrder) { this.courseId = courseId; this.placeId = placeId; this.visitOrder = visitOrder; - } + }*/ } \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java new file mode 100644 index 0000000..3ade09c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java @@ -0,0 +1,30 @@ +package com.example.mohago_nocar.course.domain.model.travelSpot; + +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelSpotFestival extends TravelSpot{ + + private Long festivalId; + + @Builder + private TravelSpotFestival(Long courseId, Integer visitOrder, Long festivalId) { + super(courseId, visitOrder); + this.festivalId = festivalId; + } + + public TravelSpotFestival from(Long courseId, Integer visitOrder, Long festivalId) { + return TravelSpotFestival.builder() + .courseId(courseId) + .visitOrder(visitOrder) + .festivalId(festivalId) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java new file mode 100644 index 0000000..7c552c8 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java @@ -0,0 +1,30 @@ +package com.example.mohago_nocar.course.domain.model.travelSpot; + +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelSpotPlace extends TravelSpot{ + + private String placeId; + + @Builder + private TravelSpotPlace(Long courseId, Integer visitOrder, String placeId) { + super(courseId, visitOrder); + this.placeId = placeId; + } + + public TravelSpotPlace from(Long courseId, Integer visitOrder, String placeId) { + return TravelSpotPlace.builder() + .courseId(courseId) + .visitOrder(visitOrder) + .placeId(placeId) + .build(); + } + +} From 49ca9a8cce5fcd58362581123c5830f529302788 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 29 Nov 2025 15:25:38 +0900 Subject: [PATCH 67/84] =?UTF-8?q?archive:=20FCM=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=97=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/FcmMsgTemplate.java | 32 ++++++++ .../notification/TravelCourseMsgTemplate.java | 74 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/notification/FcmMsgTemplate.java create mode 100644 src/main/java/com/example/mohago_nocar/notification/TravelCourseMsgTemplate.java diff --git a/src/main/java/com/example/mohago_nocar/notification/FcmMsgTemplate.java b/src/main/java/com/example/mohago_nocar/notification/FcmMsgTemplate.java new file mode 100644 index 0000000..8fadf2a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/notification/FcmMsgTemplate.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.notification; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.Map; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.SIMPLE_NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TravelCourseMsgTemplate.class,name = "travelCourseFcmMsg") +}) +@AllArgsConstructor +@ToString +@Getter +@NoArgsConstructor +public abstract class FcmMsgTemplate { + + String title; + + String body; + + abstract Map getAllCustomData(); + +} diff --git a/src/main/java/com/example/mohago_nocar/notification/TravelCourseMsgTemplate.java b/src/main/java/com/example/mohago_nocar/notification/TravelCourseMsgTemplate.java new file mode 100644 index 0000000..e6e620d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/notification/TravelCourseMsgTemplate.java @@ -0,0 +1,74 @@ +package com.example.mohago_nocar.notification; + +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +// 이건...... course 도메인에 둬도 되지 않을까 +@ToString(callSuper = true) +@Getter +@NoArgsConstructor +public class TravelCourseMsgTemplate extends FcmMsgTemplate { + + public static class SuccessTemplate { + private static final String TITLE = "여행 계획 완성! ✈️"; + private static final String BODY = "여행 계획 설계가 완료되었어요. 탭해서 확인해보세요!"; + } + + public static class FailureTemplate { + private static final String TITLE = "여행 계획 수립 실패..."; + private static final String BODY = "예상치 못한 에러로 계획 수립에 실패했어요."; + } + + Long travelCourseId; + + UUID userId; + + Boolean isSuccess; + + public static TravelCourseMsgTemplate create( + boolean isSuccess, long travelCourseId, @NotNull UUID userId) { + String title = isSuccess ? SuccessTemplate.TITLE : FailureTemplate.TITLE; + String body = isSuccess ? SuccessTemplate.BODY : FailureTemplate.BODY; + + return TravelCourseMsgTemplate.builder() + .title(title) + .body(body) + .isSuccess(isSuccess) + .travelCourseId(travelCourseId) + .userId(userId) + .build(); + } + + @Builder(access = AccessLevel.PRIVATE) + private TravelCourseMsgTemplate( + String title, String body, Long travelCourseId, UUID userId, Boolean isSuccess) { + super(title, body); + this.travelCourseId = travelCourseId; + this.userId = userId; + this.isSuccess = isSuccess; + } + + /** + * Returns custom data as a map of field names to string values. + * + *

    Notice: This implementation uses hard‑coded field names. + * If any field names change in the class, this method must be updated manually. + * + * @return map containing travelCourseId, userId, and isSuccess values + */ + @Override + Map getAllCustomData() { + Objects.requireNonNull(userId); + + return Map.of( + "travelCourseId", String.valueOf(travelCourseId), + "userId", userId.toString(), + "isSuccess", String.valueOf(isSuccess) + ); + } + +} From 298b6a10606550eb50c767956518324dcb9b37d5 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 1 Dec 2025 10:59:04 +0900 Subject: [PATCH 68/84] =?UTF-8?q?config:=20lombock=EC=9D=84=20=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20DI=EC=97=90=EC=84=9C=20Qualifier=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20lombock=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lombock.config | 1 + 1 file changed, 1 insertion(+) create mode 100644 lombock.config diff --git a/lombock.config b/lombock.config new file mode 100644 index 0000000..eb6db90 --- /dev/null +++ b/lombock.config @@ -0,0 +1 @@ +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier \ No newline at end of file From 2ac44cefa425b07f3f5452f7c5fa12d898c0986e Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 3 Dec 2025 10:30:10 +0900 Subject: [PATCH 69/84] =?UTF-8?q?feat:=20API=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EC=A0=95=EC=B1=85=EC=9D=B4=20=EC=9C=A0?= =?UTF-8?q?=EB=B0=9C=ED=95=98=EB=8A=94=20=EB=B3=91=EB=AA=A9=20=ED=98=84?= =?UTF-8?q?=EC=83=81=EC=9D=98=20=EA=B0=9C=EC=84=A0=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20API=20key=20pooling=20=EC=A0=84=EB=9E=B5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84,=20API=20=EC=9D=91=EB=8B=B5=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/route/RateLimitKey.java | 43 +++++++++ .../route/RateLimitedApiKeyPool.java | 42 +++++++++ .../route/RateLimitedApiKeyPoolConfig.java | 31 +++++++ .../route/TransitRouteApiAdapter.java | 4 + .../odsay/ODsayApiRateLimitedClient.java | 87 +++++++++++++++++++ .../odsay/ODsayTransitRouteApiAdapter.java | 23 ++++- .../odsay/ODsayTransitRouteApiExecutor.java | 1 + .../route/odsay/OdsayApiKeyProperties.java | 23 +++++ 8 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitKey.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPool.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPoolConfig.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiKeyProperties.java diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitKey.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitKey.java new file mode 100644 index 0000000..82956bf --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitKey.java @@ -0,0 +1,43 @@ +package com.example.mohago_nocar.transit.infrastructure.route; + +import io.github.bucket4j.Bucket; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +public class RateLimitKey { + + private final Bucket bucket; + private final String key; + private final String encodedKey; + + public RateLimitKey(Bucket bucket, String key) { + this.bucket = bucket; + this.key = key; + this.encodedKey = createEncodedApiKey(key); + } + + private String createEncodedApiKey(String key) { + return URLEncoder.encode(key, StandardCharsets.UTF_8); + } + + /** + * API 호출 제한 속도에 맞추어 사용 가능한 Key를 반환합니다. + * 호출 제한 속도를 초과하면 호출 스레드는 Blocking 됩니다. + * @return API key + */ + public String acquireEncodedKey() { + try { + bucket.asBlocking().consume(1); + } catch (InterruptedException e) { // todo 인터럽트 발생 시 고민 + throw new RuntimeException(e); + } + + return encodedKey; + } + + public String getKey() { + return key; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPool.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPool.java new file mode 100644 index 0000000..5d12f29 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPool.java @@ -0,0 +1,42 @@ +package com.example.mohago_nocar.transit.infrastructure.route; + +import com.example.mohago_nocar.transit.infrastructure.route.odsay.OdsayApiKeyProperties; +import io.github.bucket4j.Bucket; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +public class RateLimitedApiKeyPool { + + private final List keys; + private AtomicInteger index; + + public RateLimitedApiKeyPool( + OdsayApiKeyProperties keyProperties, Supplier bucketCreator) { + keys = new ArrayList<>(); + index = new AtomicInteger(0); + for (String key : keyProperties.getKeys()) { + RateLimitKey rateLimitKey = new RateLimitKey(bucketCreator.get(), key); + keys.add(rateLimitKey); + } + } + + /** + * 인코딩된 API key를 획득합니다. + * API 호출 시 429 Too Many Request Error가 발생하지 않도록 key 획득 속도를 조절합니다. + * @return + */ + public String acquireEncodedKey() { + // 원자적 + 라운드로빈 방식으로 인덱스 획득 + int idx = index.getAndUpdate(current -> (current + 1) % keys.size()); + RateLimitKey rateLimitKey = keys.get(idx); + return rateLimitKey.acquireEncodedKey(); + } + + public int getNextOrder() { + return index.get(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPoolConfig.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPoolConfig.java new file mode 100644 index 0000000..e473e12 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPoolConfig.java @@ -0,0 +1,31 @@ +package com.example.mohago_nocar.transit.infrastructure.route; + +import com.example.mohago_nocar.transit.infrastructure.route.odsay.OdsayApiKeyProperties; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BandwidthBuilder; +import io.github.bucket4j.Bucket; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +public class RateLimitedApiKeyPoolConfig { + + @Bean + public RateLimitedApiKeyPool rateLimitedOdsayApiKeyPool(OdsayApiKeyProperties keyProperties) { + return new RateLimitedApiKeyPool(keyProperties, this::createBucket); + } + + private Bucket createBucket() { + Bandwidth limit = BandwidthBuilder.builder().capacity(5) + .refillIntervally(5, Duration.ofSeconds(1)) + .initialTokens(0) + .build(); + + return Bucket.builder() + .addLimit(limit) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java index 744f640..7e23317 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java @@ -3,8 +3,12 @@ import com.example.mohago_nocar.plan.domain.model.Location; import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import java.util.concurrent.CompletableFuture; + public interface TransitRouteApiAdapter { TransitRoute getTransitRouteBetweenLocations(Location origin, Location destination); + CompletableFuture getTransitRoute(Location origin, Location destination); + } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java new file mode 100644 index 0000000..ef3212f --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java @@ -0,0 +1,87 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.route.RateLimitedApiKeyPool; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +// todo 클래스명 수정 +@Component +@Slf4j +public class ODsayApiRateLimitedClient { + + private final RestClient restClient; + private final String baseUrl; + private final RateLimitedApiKeyPool rateLimitedApiKeyPool; + + public ODsayApiRateLimitedClient( + RestClient restClient, + @Value("${odsay.url}") String baseUrl, + RateLimitedApiKeyPool rateLimitedOdsayApiKeyPool + ) { + this.restClient = restClient; + this.baseUrl = baseUrl; + this.rateLimitedApiKeyPool = rateLimitedOdsayApiKeyPool; + } + + /** + * 대중교통 경로를 검색합니다. + * + *

    Rate Limit 제어: API 키 획득 시 rate limit 준수를 위해 + * 의도적으로 블로킹하여 소비 속도를 조절합니다.

    + * + * @param origin 출발지 좌표 + * @param destination 도착지 좌표 + * @return 경로 검색 결과의 CompletableFuture + */ + public ODsayTransitRouteResponse searchTransitRoute(Coordinate origin, Coordinate destination) { + String encodedKey = rateLimitedApiKeyPool.acquireEncodedKey(); + URI requestURI = buildRequestURI(origin, destination, encodedKey); + return executeApiCall(requestURI); + } + + /** + * 대중교통 경로를 검색합니다. API 응답을 비동기로 처리합니다. + * + *

    Rate Limit 제어: API 키 획득 시 rate limit 준수를 위해 + * 의도적으로 블로킹하여 소비 속도를 조절합니다.

    + * + * @param origin 출발지 좌표 + * @param destination 도착지 좌표 + * @return 경로 검색 결과의 CompletableFuture + */ + public CompletableFuture searchTransitRouteAsync( + Coordinate origin, Coordinate destination) { + String encodedKey = rateLimitedApiKeyPool.acquireEncodedKey(); + URI requestURI = buildRequestURI(origin, destination, encodedKey); + return CompletableFuture.supplyAsync(() -> executeApiCall(requestURI)); + } + + private ODsayTransitRouteResponse executeApiCall(URI requestURI) { + return restClient.get() + .uri(requestURI) + .retrieve() + .body(ODsayTransitRouteResponse.class); + } + + private URI buildRequestURI(Coordinate origin, Coordinate destination, String encodedKey) { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("SX", origin.getLongitude()) + .queryParam("SY", origin.getLatitude()) + .queryParam("EX", destination.getLongitude()) + .queryParam("EY", destination.getLatitude()); + + return uriComponentsBuilder + .queryParam("apiKey", encodedKey) + .build(true) + .toUri(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java index d46ca2d..a1067e9 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.CompletableFuture; @Component @RequiredArgsConstructor @@ -24,11 +25,11 @@ public class ODsayTransitRouteApiAdapter implements TransitRouteApiAdapter { private static final int EARTH_RADIUS = 6371; - private final ODsayApiClient odsayApiClient; + private final ODsayApiRateLimitedClient rateLimitedClient; @Override public TransitRoute getTransitRouteBetweenLocations(Location origin, Location destination) { - ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute(origin.getCoordinate(), destination.getCoordinate()); + ODsayTransitRouteResponse response = rateLimitedClient.searchTransitRoute(origin.getCoordinate(), destination.getCoordinate()); if (!response.isValid()) { try { processInvalidResponse((ODsayRouteInvalidResponse)response); @@ -40,6 +41,24 @@ public TransitRoute getTransitRouteBetweenLocations(Location origin, Location de return processValidResponse(origin, destination, response); } + @Override + public CompletableFuture getTransitRoute(Location origin, Location destination) { + CompletableFuture future = + rateLimitedClient.searchTransitRouteAsync(origin.getCoordinate(), destination.getCoordinate()); + + return future.thenApply(response -> { + if (!response.isValid()) { + try { + processInvalidResponse((ODsayRouteInvalidResponse)response); + } catch (ODsayDistanceException e) { + return createShortDistanceResponse(origin, destination); + } + } + + return processValidResponse(origin, destination, response); + }); + } + private void processInvalidResponse(ODsayRouteInvalidResponse response) { log.warn("ODsay Invalid response: {}", response); diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java index 376bb87..4b262b3 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java @@ -17,6 +17,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +@Deprecated @Component @Slf4j @RequiredArgsConstructor diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiKeyProperties.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiKeyProperties.java new file mode 100644 index 0000000..80d93a7 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiKeyProperties.java @@ -0,0 +1,23 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; +import java.util.Set; + +@Getter +@ConfigurationProperties("odsay") +public class OdsayApiKeyProperties { + + private final List keys; + + public OdsayApiKeyProperties(List apiKeys) { + if (Set.of(apiKeys.toArray()).size() != apiKeys.size()) { + throw new RuntimeException("API 키는 중복일 수 없습니다."); + } + + this.keys = apiKeys; + } + +} From 01e8486a0180fc3b64ad9c1ce0ba2d966b7a98d8 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 3 Dec 2025 10:31:35 +0900 Subject: [PATCH 70/84] =?UTF-8?q?feat:=20=EC=84=A4=EA=B3=84=20=EC=9D=98?= =?UTF-8?q?=EB=8F=84=EC=99=80=20=EB=8B=A4=EB=A5=B4=EA=B2=8C=20=EB=B3=91?= =?UTF-8?q?=EB=AA=A9=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0=20=EB=B6=88?= =?UTF-8?q?=EA=B0=80=20->=20Deprecated=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transit/infrastructure/route/TransitRouteApiExecutor.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java index dceba4b..528ab91 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; +@Deprecated public interface TransitRouteApiExecutor { CompletableFuture> execute(final List locations); From d5493480328ba944a8d7f3aba65598c532feb6ee Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 3 Dec 2025 10:33:51 +0900 Subject: [PATCH 71/84] =?UTF-8?q?feat:=20logback=EC=9D=84=20=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=97=AC=20=EB=A1=9C=EA=B7=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/logback-spring.xml | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/main/resources/logback-spring.xml diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3b43467 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + logs/app/error.log + + + ERROR + ACCEPT + DENY + + + + originalEvent + throwableClass + throwableMessage + throwableType + rootCause + stackTrace + + + + logs/app/%d{yyyy-MM-dd}-error.log + 90 + 5GB + + + + + + logs/app/warn.log + + + WARN + ACCEPT + DENY + + + + originalEvent + throwableClass + throwableMessage + throwableType + + + + logs/app/%d{yyyy-MM-dd}-warn.log + 90 + 5GB + + + + + + + + + + + \ No newline at end of file From 24a453fc823b818ebd67982cc2b9aaacc1f22341 Mon Sep 17 00:00:00 2001 From: mungsil Date: Wed, 3 Dec 2025 12:13:49 +0900 Subject: [PATCH 72/84] =?UTF-8?q?feat:=20travelSpot=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=9D=EC=B2=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/model/travelSpot/TravelSpot.java | 41 +++++++++++-------- .../model/travelSpot/TravelSpotFestival.java | 21 ++++++++-- .../model/travelSpot/TravelSpotPlace.java | 20 +++++++-- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java index e62fad9..6767ddd 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java @@ -1,19 +1,24 @@ package com.example.mohago_nocar.course.domain.model.travelSpot; import com.example.mohago_nocar.global.common.domain.BaseEntity; +import com.example.mohago_nocar.plan.domain.model.Location; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Comparator; +import java.util.Objects; + import static jakarta.persistence.GenerationType.IDENTITY; -@MappedSuperclass +@Entity @Getter +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "spot_type") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public abstract class TravelSpot extends BaseEntity { +public abstract class TravelSpot extends BaseEntity implements Comparable { @Id @GeneratedValue(strategy = IDENTITY) @@ -22,27 +27,29 @@ public abstract class TravelSpot extends BaseEntity { @NotNull private Long courseId; - @NotNull private Integer visitOrder; - protected TravelSpot(Long courseId, Integer visitOrder) { + @NotNull + @Embedded + private Location location; // snapshot + + protected TravelSpot(Long courseId, Integer visitOrder, Location location) { this.courseId = courseId; this.visitOrder = visitOrder; + this.location = location; } - /* public static TravelSpot from(Long courseId, String placeId, Integer visitOrder) { - return TravelSpot.builder() - .courseId(courseId) - .placeId(placeId) - .visitOrder(visitOrder) - .build(); + @Override + public int compareTo(TravelSpot other) { + Objects.requireNonNull(this.getVisitOrder(), "방문 순서가 정해지지 않은 장소입니다."); + Objects.requireNonNull(other.getVisitOrder(), "방문 순서가 정해지지 않은 장소입니다."); + + return Comparator.comparing(TravelSpot::getVisitOrder) + .compare(this, other); } - @Builder - private TravelSpot(Long courseId, String placeId, Integer visitOrder) { - this.courseId = courseId; - this.placeId = placeId; - this.visitOrder = visitOrder; - }*/ + public void setOrder(int i) { + visitOrder = i; + } } \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java index 3ade09c..d3e0c05 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java @@ -1,5 +1,9 @@ package com.example.mohago_nocar.course.domain.model.travelSpot; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.plan.domain.model.Location; +import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; import lombok.AccessLevel; import lombok.Builder; @@ -8,18 +12,19 @@ @Entity @Getter +@DiscriminatorValue("festival") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TravelSpotFestival extends TravelSpot{ private Long festivalId; @Builder - private TravelSpotFestival(Long courseId, Integer visitOrder, Long festivalId) { - super(courseId, visitOrder); + private TravelSpotFestival(Long courseId, Integer visitOrder, Long festivalId, Location location){ + super(courseId, visitOrder, location); this.festivalId = festivalId; } - public TravelSpotFestival from(Long courseId, Integer visitOrder, Long festivalId) { + public TravelSpotFestival create(Long courseId, Integer visitOrder, Long festivalId) { return TravelSpotFestival.builder() .courseId(courseId) .visitOrder(visitOrder) @@ -27,4 +32,14 @@ public TravelSpotFestival from(Long courseId, Integer visitOrder, Long festivalI .build(); } + public static TravelSpotFestival createUnOrderedSpot( + TravelCourse course, Festival festival) { + return TravelSpotFestival.builder() + .courseId(course.getId()) + .visitOrder(null) + .festivalId(festival.getId()) + .location(Location.of(festival)) + .build(); + } + } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java index 7c552c8..63c4901 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java @@ -1,5 +1,9 @@ package com.example.mohago_nocar.course.domain.model.travelSpot; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.plan.domain.model.Location; +import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; import lombok.AccessLevel; import lombok.Builder; @@ -8,18 +12,19 @@ @Entity @Getter +@DiscriminatorValue("place") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TravelSpotPlace extends TravelSpot{ private String placeId; @Builder - private TravelSpotPlace(Long courseId, Integer visitOrder, String placeId) { - super(courseId, visitOrder); + private TravelSpotPlace(Long courseId, Integer visitOrder, String placeId, Location location) { + super(courseId, visitOrder, location); this.placeId = placeId; } - public TravelSpotPlace from(Long courseId, Integer visitOrder, String placeId) { + public TravelSpotPlace create(Long courseId, Integer visitOrder, String placeId) { return TravelSpotPlace.builder() .courseId(courseId) .visitOrder(visitOrder) @@ -27,4 +32,13 @@ public TravelSpotPlace from(Long courseId, Integer visitOrder, String placeId) { .build(); } + public static TravelSpotPlace createUnOrderedSpot(TravelCourse course, Place place) { + return TravelSpotPlace.builder() + .courseId(course.getId()) + .visitOrder(null) + .placeId(place.getKakaoId()) + .location(Location.of(place)) + .build(); + } + } From a09ad2da8de9817e37c6da27ac219b7c7c7cbf67 Mon Sep 17 00:00:00 2001 From: mungsil Date: Thu, 4 Dec 2025 10:11:03 +0900 Subject: [PATCH 73/84] =?UTF-8?q?archive:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드 내용: TransitRoute 이벤트 발생 시 장소 간 대중 교통 경로를 구함. 이때 '장소'는 '여행 코스'에 소속된 여행 장소임. 해당 소속 관계를 나타내기 위해 여행 코스를 배치 단위로 정의하고, 이벤트 소비 완료 시 배치 상태를 업데이트하는 코드를 작성함. --- .../queue/batch/BatchStatus.java | 16 ++ .../batch/TransitRouteBatchExecution.java | 89 +++++++ ...ansitRouteBatchExecutionJpaRepository.java | 3 +- .../TransitRouteBatchExecutionRepository.java | 9 + ...sitRouteBatchExecutionRepositoryImpl.java} | 6 +- .../batch/TransitRouteBatchLauncher.java | 11 +- .../queue/batch/TransitRouteBatchService.java | 25 ++ .../queue/batch/TransitRouteBatchUseCase.java | 11 + .../queue/batch/TransitRouteRequest.java | 68 +++++ .../queue/batch/TransitRouteService.java | 56 +++++ .../PrioritizedTransitRouteRequest.java | 32 +++ .../consumer/TransitRouteRequestConsumer.java | 6 +- .../TransitRouteRequestForwarder.java | 146 +++++++++++ .../TransitRouteRequestProcessor.java | 236 ++++++++++++++++++ .../TransitRouteDeadRequestProducer.java | 38 +++ .../producer/TransitRouteRequestProducer.java | 62 +++++ 16 files changed, 799 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/BatchStatus.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecution.java rename src/main/java/com/example/mohago_nocar/transit/infrastructure/{route => queue}/batch/TransitRouteBatchExecutionJpaRepository.java (56%) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepository.java rename src/main/java/com/example/mohago_nocar/transit/infrastructure/{route/batch/TransitRouteBatchProgressRepositoryImpl.java => queue/batch/TransitRouteBatchExecutionRepositoryImpl.java} (63%) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/{route => queue}/batch/TransitRouteBatchLauncher.java (71%) create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchService.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchUseCase.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteRequest.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteService.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/PrioritizedTransitRouteRequest.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestForwarder.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestProcessor.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteDeadRequestProducer.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteRequestProducer.java diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/BatchStatus.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/BatchStatus.java new file mode 100644 index 0000000..0b88c14 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/BatchStatus.java @@ -0,0 +1,16 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.batch; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum BatchStatus { + + PENDING("실행 대기 중"), + RUNNING("실행 중"), + COMPLETED("완료"), + FAILED("실패"); + + private final String description; + +} + diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecution.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecution.java new file mode 100644 index 0000000..aebc22e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecution.java @@ -0,0 +1,89 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.batch; + +import jakarta.persistence.Id; +import lombok.*; +import org.springframework.data.redis.core.RedisHash; + +import java.time.Instant; +import java.util.*; + +/** + * 배치 작업의 상태를 기록합니다. + */ +// todo plan id 기록 +@RedisHash(value = "batch") +@Builder(access = AccessLevel.PRIVATE) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@ToString +public class TransitRouteBatchExecution { + + @Id + private String id; + + private UUID userId; + + private Long planId; + + private BatchStatus status; + + private Integer totalCount; + + private String completedSequences; // todo + + private Boolean isDelivered; + + private Long createdAt; + + private Long completedAt; + + // todo 삭제 + private String errorMessage; + + // todo synch가 필요한가? -> 루아스크립트로 변경 + public synchronized void fail(Exception exception) { + status = BatchStatus.FAILED; + errorMessage = exception.getMessage(); + } + + public static TransitRouteBatchExecution createInitial(int totalCount, UUID userId, Long planId) { + String batchId = UUID.randomUUID().toString(); + + return TransitRouteBatchExecution.builder() + .id(batchId) + .userId(userId) + .planId(planId) + .status(BatchStatus.PENDING) + .totalCount(totalCount) + .completedSequences(null) + .isDelivered(false) + .createdAt(Instant.now().toEpochMilli()) + .build(); + } + + public void completeDeliver() { + this.isDelivered = true; + } + + public boolean isAlreadyDelivered() { + return isDelivered; + } + + public int getCompletedCount() { + return this.getCompletedSequences().split(",").length; + } + +/* public String addCompletedSequence(String sequence) { + if (completedSequences == null) { + this.completedSequences = sequence; + } else { + this.completedSequences += "," + sequence; + } + + return this.completedSequences; + }*/ + +} + + diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchExecutionJpaRepository.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionJpaRepository.java similarity index 56% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchExecutionJpaRepository.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionJpaRepository.java index 9441697..73efdf8 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchExecutionJpaRepository.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionJpaRepository.java @@ -1,6 +1,5 @@ -package com.example.mohago_nocar.transit.infrastructure.route.batch; +package com.example.mohago_nocar.transit.infrastructure.queue.batch; -import com.example.mohago_nocar.transit.domain.model.TransitRouteBatchExecution; import org.springframework.data.repository.CrudRepository; public interface TransitRouteBatchExecutionJpaRepository extends CrudRepository { diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepository.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepository.java new file mode 100644 index 0000000..e589edd --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepository.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.batch; + +public interface TransitRouteBatchExecutionRepository { + + TransitRouteBatchExecution save(TransitRouteBatchExecution execution); + + TransitRouteBatchExecution findByExecutionId(String executionId); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchProgressRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepositoryImpl.java similarity index 63% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchProgressRepositoryImpl.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepositoryImpl.java index 9696397..202b221 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchProgressRepositoryImpl.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepositoryImpl.java @@ -1,7 +1,5 @@ -package com.example.mohago_nocar.transit.infrastructure.route.batch; +package com.example.mohago_nocar.transit.infrastructure.queue.batch; -import com.example.mohago_nocar.transit.domain.model.TransitRouteBatchExecution; -import com.example.mohago_nocar.transit.domain.repository.TransitRouteBatchProgressRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -9,7 +7,7 @@ @Repository @Transactional @RequiredArgsConstructor -public class TransitRouteBatchProgressRepositoryImpl implements TransitRouteBatchProgressRepository { +public class TransitRouteBatchExecutionRepositoryImpl implements TransitRouteBatchExecutionRepository { private final TransitRouteBatchExecutionJpaRepository jpaRepository; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchLauncher.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchLauncher.java similarity index 71% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchLauncher.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchLauncher.java index 720574d..0860f51 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchLauncher.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchLauncher.java @@ -1,24 +1,25 @@ -package com.example.mohago_nocar.transit.infrastructure.route.batch; +package com.example.mohago_nocar.transit.infrastructure.queue.batch; import com.example.mohago_nocar.plan.domain.model.Location; -import com.example.mohago_nocar.transit.domain.model.TransitRouteBatchExecution; +import com.example.mohago_nocar.transit.infrastructure.queue.producer.TransitRouteRequestProducer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; /** - * 대중교통 경로를 구하는 배치 작업을 시작합니다. - * 진입 순서대로 동일한 배치 아이디를 가지는 아이템을 생성합니다. + * 대중교통 경로를 구하는 배치 작업을 진입 순서대로 시작합니다. */ +@Component @Slf4j @RequiredArgsConstructor public class TransitRouteBatchLauncher { private final ReentrantLock lock = new ReentrantLock(true); - private final TransitRouteItemProducer itemProducer; + private final TransitRouteRequestProducer itemProducer; public void launch(TransitRouteBatchExecution execution, List locations) { try { diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchService.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchService.java new file mode 100644 index 0000000..a7da76c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchService.java @@ -0,0 +1,25 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.batch; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class TransitRouteBatchService implements TransitRouteBatchUseCase { + + private final TransitRouteBatchExecutionRepository repository; + + @Override + public TransitRouteBatchExecution createAndSaveExecution(int totalPlaceCount, UUID userId, Long planId) { + TransitRouteBatchExecution entity = TransitRouteBatchExecution.createInitial(totalPlaceCount, userId, planId); + return repository.save(entity); + } + + @Override + public TransitRouteBatchExecution getById(String executionId) { + return repository.findByExecutionId(executionId); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchUseCase.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchUseCase.java new file mode 100644 index 0000000..4287b99 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchUseCase.java @@ -0,0 +1,11 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.batch; + +import java.util.UUID; + +public interface TransitRouteBatchUseCase { + + TransitRouteBatchExecution createAndSaveExecution(int totalPlaceCount, UUID userId, Long planId); + + TransitRouteBatchExecution getById(String executionId); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteRequest.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteRequest.java new file mode 100644 index 0000000..0dd4f02 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteRequest.java @@ -0,0 +1,68 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.batch; + +import com.example.mohago_nocar.plan.domain.model.Location; +import lombok.*; + +import java.util.UUID; + +// todo Comparable 제거 +@Data +@AllArgsConstructor +@NoArgsConstructor +@Getter +@ToString +public class TransitRouteRequest implements Comparable { + + String id; + String batchId; + Location origin; + Location destination; + Integer sequence; + Integer retry; + Boolean needRetry; + + public Integer plusRetry() { + return ++retry; + } + + public void markNeedRetry() { + needRetry = true; + } + + public static TransitRouteRequest of(String batchId, Location origin, Location destination, Integer sequence) { + String id = UUID.randomUUID().toString(); + + return TransitRouteRequest.builder() + .id(id) + .batchId(batchId) + .origin(origin) + .destination(destination) + .sequence(sequence) + .build(); + } + + @Builder + private TransitRouteRequest(String id, String batchId, Location origin, Location destination, Integer sequence) { + this.id = id; + this.batchId = batchId; + this.origin = origin; + this.destination = destination; + this.sequence = sequence; + this.retry = 0; + this.needRetry = false; + } + + @Override + public int compareTo(TransitRouteRequest o) { + if (this.retry > o.getRetry()) { + return -1; + } + + if (this.retry < o.getRetry()) { + return 1; + } + + return 0; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteService.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteService.java new file mode 100644 index 0000000..f053985 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteService.java @@ -0,0 +1,56 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.batch; + +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class TransitRouteService { + + private final TransitRouteApiAdapter transitRouteApiAdapter; + + public List> searchTransitRoutesInOrder(List travelSpotsInOrder) { + validateMinSize(travelSpotsInOrder, 2); + + List> transitRouteApiCallResults = new ArrayList<>(); + int spotNum = travelSpotsInOrder.size(); + + for (int i = 0; i < spotNum - 1; i++) { + Location origin = travelSpotsInOrder.get(i).getLocation(); + Location destination = travelSpotsInOrder.get(i + 1).getLocation(); + + CompletableFuture apiCalled = transitRouteApiAdapter.getTransitRoute(origin, destination); + transitRouteApiCallResults.add(apiCalled); + } + + return transitRouteApiCallResults; + } + + private void validateMinSize(List travelSpotsInOrder, int minSize) { + if (travelSpotsInOrder == null || travelSpotsInOrder.size() < minSize) { + throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); + } + } + + private List>fetchTransitRoutes(List travelSpotsInOrder) { + List> futures = new ArrayList<>(); + + for (int i = 0; i < travelSpotsInOrder.size() - 1; i++) { + Location origin = travelSpotsInOrder.get(i).getLocation(); + Location destination = travelSpotsInOrder.get(i + 1).getLocation(); + + futures.add(transitRouteApiAdapter.getTransitRoute(origin, destination)); + } + + return futures; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/PrioritizedTransitRouteRequest.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/PrioritizedTransitRouteRequest.java new file mode 100644 index 0000000..fba29d6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/PrioritizedTransitRouteRequest.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.consumer; + +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class PrioritizedTransitRouteRequest implements Comparable { + + private Long insertionOrder; // tie-breaker: 동일 우선순위 시, 먼저 들어온 요청이 우선됨 + private TransitRouteRequest value; + + @Override + public int compareTo(PrioritizedTransitRouteRequest o) { + int compared = this.value.compareTo(o.getValue()); + if (compared != 0) { + return compared; + } + + // 기존 값의 우선 순위가 동일한 경우 진입 순서 활용 + if (this.insertionOrder < o.insertionOrder) { + return -1; + } + if (this.insertionOrder > o.insertionOrder) { + return 1; + } + + return 0; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java index 89c2de5..5d76531 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java @@ -4,8 +4,8 @@ import com.example.mohago_nocar.global.util.ObjectMapperUtil; import com.example.mohago_nocar.plan.application.v2.TravelCoursePlanNotifyService; import com.example.mohago_nocar.transit.domain.model.TransitRoute; -import com.example.mohago_nocar.transit.domain.model.TransitRouteRequest; -import com.example.mohago_nocar.transit.domain.model.BatchStatus; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.BatchStatus; import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; @@ -18,7 +18,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.RedisSystemException; import org.springframework.data.redis.connection.stream.*; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.stream.StreamListener; @@ -38,7 +37,6 @@ @Slf4j public class TransitRouteRequestConsumer implements StreamListener> { - private final RedisTemplate objectRedisTemplate; @Value("${redis.streams.odsay.main}") private String streamKey; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestForwarder.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestForwarder.java new file mode 100644 index 0000000..f188cd6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestForwarder.java @@ -0,0 +1,146 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.consumer; + +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.stream.StreamListener; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; +import org.springframework.data.redis.stream.Subscription; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; + +// transit route req outer Consumer +// bridge consumer, + +// inner, +// TransitRouteRequestForwarder -> TransitRouteReqForwarder +// TransitRouteRequestProcessor -> PrioritizedTransitRouteProcessor +@Component +@RequiredArgsConstructor +@Slf4j +public class TransitRouteRequestForwarder implements StreamListener> { + + @Value("${redis.streams.odsay.main}") + private String streamKey; + + private final String consumerGroup = "processors"; + private final String consumer = "processor-1"; + + private StreamMessageListenerContainer> listenerContainer; + private Subscription subscription; + private RateLimiter rateLimiter; + + private final RedisTemplate stringRedisTemplate; + private final TransitRouteRequestProcessor transitRouteRequestProcessor; + + @PostConstruct + public void init() { + rateLimiter = configureRateLimiter().rateLimiter("transit-api-producer"); + + // Consumer Group 설정 + createStreamConsumerGroupIfNotExists(streamKey, consumerGroup); + + // StreamMessageListenerContainer 설정 + this.listenerContainer = StreamMessageListenerContainer.create( + stringRedisTemplate.getConnectionFactory(), + StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder() + .targetType(String.class) + .pollTimeout(Duration.ofSeconds(3)) +// .errorHandler() + .build() + ); + + // Subscription 설정 + this.subscription = this.listenerContainer.receive( + Consumer.from(this.consumerGroup, consumer), + StreamOffset.create(streamKey, ReadOffset.lastConsumed()), + this + ); + + // Redis listen 시작 + this.listenerContainer.start(); + } + + private void createStreamConsumerGroupIfNotExists(String streamKey, String consumerGroup) { + try { + stringRedisTemplate.opsForStream() + .createGroup(streamKey, ReadOffset.from("0"), consumerGroup); + log.info("Consumer group {} created", consumerGroup); + } catch (Exception e) { + log.error("Exception occurs during creating consumer group : {}", e.getMessage()); + } + } + + @Override + public void onMessage(ObjectRecord message) { + + rateLimiter.executeRunnable(() -> { + transitRouteRequestProcessor.receive(message); + transitRouteRequestProcessor.onAfterSuccess(() -> ackAndDeleteEntry(message)); + transitRouteRequestProcessor.onAfterFailure(() -> ackAndDeleteEntry(message)); + }); + + } + + private void ackAndDeleteEntry(ObjectRecord message) { + String lua = """ + local stream = ARGV[1] + local group = ARGV[2] + local id = ARGV[3] + local acked = redis.call('XACK', stream, group, id) + local deleted = redis.call('XDEL', stream, id) + return {acked, deleted} + """; + + DefaultRedisScript script = new DefaultRedisScript<>(lua, List.class); + List executed = stringRedisTemplate.execute( + script, + List.of(), // KEYS 없음 + streamKey, // ARGV[1] + consumerGroup, // ARGV[2] + message.getId().getValue() // ARGV[3] + ); + + Long acked = executed.get(0); + Long deleted = executed.get(1); + + if (acked == 0 || deleted == 0) { + log.warn("[Redis] ackAndDeleteEntry: ack={}, del={}, id={}", acked, deleted, message.getId()); + } + + } + + @PreDestroy + public void stopListenerContainer() { + if (listenerContainer != null) { + listenerContainer.stop(); + log.info("Listener container stopped"); + } + } + + private RateLimiterRegistry configureRateLimiter() { + RateLimiterConfig config = RateLimiterConfig.custom() + .limitRefreshPeriod(Duration.ofSeconds(1)) + .limitForPeriod(5) + .timeoutDuration(Duration.ofMinutes(5)) + .build(); + + return RateLimiterRegistry.of(config); + } + +} + + diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestProcessor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestProcessor.java new file mode 100644 index 0000000..1497675 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestProcessor.java @@ -0,0 +1,236 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.consumer; + +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.plan.application.v2.TravelCoursePlanNotifyService; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.BatchStatus; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecution; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecutionRepository; +import com.example.mohago_nocar.transit.domain.repository.TransitRouteRepository; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.atomic.AtomicLong; + +/** + * QueueRelay: send item from A to B queue + * RateLimitedConsumer: consume item by limited rate + * + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TransitRouteRequestProcessor { + + private final TransitRouteApiAdapter transitRouteApiAdapter; + private final TransitRouteBatchExecutionRepository batchExecutionRepository; + private final ObjectMapperUtil objectMapperUtil; + private final StringRedisTemplate stringRedisTemplate; + private final TransitRouteRepository transitRouteRepository; + private final TravelCoursePlanNotifyService travelCoursePlanNotifyService; + + private final PriorityBlockingQueue queue = new PriorityBlockingQueue<>(); + + // 우선 순위를 위해 진입 순서 부여 + private final AtomicLong insertionOrderCounter = new AtomicLong(0); + + private final ExecutorService virtualThreadPool = Executors.newVirtualThreadPerTaskExecutor(); + + private RateLimiter rateLimiter; + private Runnable onAfterSuccess; + private Runnable onAfterFailure; + + public void receive(ObjectRecord msg) { + this.queue.add(PrioritizedTransitRouteRequest.builder() + .value(objectMapperUtil.readValue(msg.getValue(), TransitRouteRequest.class)) + .insertionOrder(insertionOrderCounter.addAndGet(1)).build()); + } + + /** + * 워커 스레드를 시작합니다. + */ + public void startWorker() { + Thread thread = new Thread(() -> { + while (!Thread.interrupted()) { + newDoWork(); + } + // Q: 자원 정리 로직 추가해야할까? + // 정리해야될 자원은 뭐가 있을까 + }); + thread.start(); + } + + // todo executor service를 이용한 수행. -> 남은 작업을 완료할 수 있도록 대기하고 싶음, 미래에 소비자가 늘면 관리가 복잡해짐 + // rate limiting만 수행하고 그 외 작업은 비동기 처리. + // 1. api call with "rate limit" + // 2. process result + public void newDoWork() { + // 다음 과정을 executorservice에 submit + // (동기) rate limit 대기 + // (비동기) CompletableFuture 사용하여 api call 및 handle + TransitRouteRequest request; + try { + request = queue.take().getValue(); // 블로킹 + } catch (InterruptedException e) { + // todo deadMsgProducer.produce + onAfterFailure.run(); + return; + } +// rateLimiter.acquirePermission(); +// thenAccept: api Call +// handle: process // 여기서 블로킹 발생함. + /* + 문제: + */ + rateLimiter.executeRunnable(() -> asyncProcessRequest(request)); + } + + private void asyncProcessRequest(TransitRouteRequest request) { + CompletableFuture.supplyAsync(() -> fetchTransitRouteByApiCalling(request), virtualThreadPool) + .handle((route, ex) -> { + if (ex != null) { // 여기서 발생하는 예외가 다인가? + // todo lost update 발생 해결 -> 루아스크립트 사용. + TransitRouteBatchExecution batchExecution = batchExecutionRepository.findByExecutionId(request.getBatchId()); + batchExecution.fail((Exception) ex); // 배치 상태 실패로 변경 - 가장 먼저 발생한 에러만 기록 + batchExecutionRepository.save(batchExecution); + // 실패한 request dlq로 이동 + onAfterFailure.run(); // entry ack & delete + }else { + BatchStatus batchStatus = processWhenRequestSuccess(request.getBatchId(), route, request.getSequence()); + if (batchStatus == BatchStatus.COMPLETED) { + // 결과 rdb 저장 - notification id + batch id가 기준이 될 것임. + saveCompletedTransitRoute(request.getBatchId()); + + // 알림 전송 + travelCoursePlanNotifyService.sendSuccessNotification(request.getBatchId()); + } + onAfterSuccess.run(); + } + + return null; + }); + } + + private void saveCompletedTransitRoute(String batchId) { + + } + + private BatchStatus processWhenRequestSuccess(String batchId, TransitRoute route, int sequenceInBatch) { + String lua = + """ + local zsetKey = KEYS[1] -- Sorted Set 키 (경로 결과 저장용) + local hashKey = KEYS[2] -- Hash 키 (배치 메타데이터 저장용) + local score = tonumber(ARGV[1]) -- Sorted Set의 점수값 (경로의 순서(시퀀스) 저장용) + local member = ARGV[2] -- Sorted Set에 저장할 멤버(경로 데이터) + local completedSeqsField = ARGV[3] -- 완료된 시퀀스 목록 필드명 + local totalField = ARGV[4] -- 전체 시퀀스 목록 필드명 + local newCompletedSeq = ARGV[5] -- 완료된 시퀀스 + + -- 1. API 호출 결과(대중교통 경로) 저장 + local added = redis.call('ZADD', zsetKey, score, member) + + local function updateCompletedSeqList(hashKey, completedSeqsField, newCompletedSeq) + local current = redis.call('HGET', hashKey, completedSeqsField) + + -- 시퀀스 목록이 비어있는 경우 + if not current or current == '' then + redis.call('HSET', hashKey, completedSeqsField, newCompletedSeq) + return 1 + end + + local alreadyExists = false + local existedCount = 0 + for seq in string.gmatch(current, '([^,]+)') do + existedCount = existedCount + 1 + if seq == newCompletedSeq then + alreadyExists = true + end + end + + if alreadyExists then + return existedCount + end + + -- 완료 목록에 시퀀스 업데이트 + local updated = current .. ',' .. newCompletedSeq + redis.call('HSET', hashKey, completedSeqsField, updated) + return existedCount + 1 + end + + -- 2. 시퀀스 완료 처리 + local completedCount = updateCompletedSeqList(hashKey, completedSeqsField, newCompletedSeq) + + -- 3. 배치 완료 여부 확인 + local total = redis.call('HGET', hashKey, totalField) + if tostring(completedCount) == tostring(total) then + redis.call('HSET', hashKey, 'status', 'completed') + end + + -- 4. 배치 상태 반환 + local batchStatus = redis.call('HGET', hashKey, 'status') + return batchStatus + """; + DefaultRedisScript script = new DefaultRedisScript<>(lua, String.class); + + // todo 키 별도 관리 + String sortedSetKey = "transit:routes" + batchId; + String batchExecutionKey = "batch:" + batchId; + String result = stringRedisTemplate.execute( + script, + List.of(sortedSetKey, batchExecutionKey), + sequenceInBatch, + route, + "completedCount", // 필드명 + "totalCount", // 필드명 + 1 + ); + + return BatchStatus.valueOf(result); + } + + // todo 여기서 발생 가능한 예외 정리 + public TransitRoute fetchTransitRouteByApiCalling(TransitRouteRequest request) { + return transitRouteApiAdapter.getTransitRouteBetweenLocations(request.getOrigin(), request.getDestination()); + } + + @PostConstruct + public void init() { + rateLimiter = initializeRateLimiter().rateLimiter("odsay api rate limiter"); + startWorker(); + } + + private RateLimiterRegistry initializeRateLimiter() { + RateLimiterConfig config = RateLimiterConfig.custom() + .limitRefreshPeriod(Duration.ofMillis(200)) + .limitForPeriod(1) + .timeoutDuration(Duration.ofSeconds(20)) + .build(); + + return RateLimiterRegistry.of(config); + } + + public void onAfterSuccess(Runnable action) { + onAfterSuccess = action; + } + + public void onAfterFailure(Runnable action) { + onAfterFailure = action; + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteDeadRequestProducer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteDeadRequestProducer.java new file mode 100644 index 0000000..e09205e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteDeadRequestProducer.java @@ -0,0 +1,38 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.producer; + +import com.example.mohago_nocar.global.common.DeadMessageCreator; +import com.example.mohago_nocar.global.common.DeadSummaryMessage; +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TransitRouteDeadRequestProducer { + + @Value("${redis.streams.odsay.dlq}") + private String streamKey; + + private final ObjectMapperUtil objectMapperUtil; + private final DeadMessageCreator deadMessageCreator; + private final StringRedisTemplate stringRedisTemplate; + + public void produce(TransitRouteRequest request, Exception ex) { + // 아래는...서비스로...? + DeadSummaryMessage deadMessage = deadMessageCreator.createSummaryMessage( + objectMapperUtil.writeValue(request), ex, 0, 10); + + ObjectRecord entry = StreamRecords.newRecord() + .in(streamKey) + .ofObject(objectMapperUtil.writeValue(deadMessage)); + + stringRedisTemplate.opsForStream().add(entry); + } + + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteRequestProducer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteRequestProducer.java new file mode 100644 index 0000000..b02a194 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteRequestProducer.java @@ -0,0 +1,62 @@ +package com.example.mohago_nocar.transit.infrastructure.queue.producer; + +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class TransitRouteRequestProducer { + + @Value("${redis.streams.odsay.main}") + private String streamKey; + + private final RedisTemplate stringRedisTemplate; + private final ObjectMapperUtil objectMapperUtil; + + public void produce(String batchId, List locations) { + validateLocations(locations); + List items = createItems(batchId, locations); + submit(items); + } + + private void submit(List items) { + for (TransitRouteRequest item : items) { + ObjectRecord apiReq = StreamRecords.newRecord() + .in(streamKey) + .ofObject(objectMapperUtil.writeValue(item)); + + stringRedisTemplate.opsForStream().add(apiReq); + } + } + + private List createItems(String batchId, List locations) { + List transitRouteRequests = new ArrayList<>(); + + for (int i = 1; i < locations.size(); i++) { + Location origin = locations.get(i-1); + Location destination = locations.get(i); + + TransitRouteRequest apiRequest = TransitRouteRequest.of(batchId, origin, destination, i); + transitRouteRequests.add(apiRequest); + } + + return transitRouteRequests; + } + + private void validateLocations(List locations) { + if (locations == null || locations.size() < 2) { + throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); + } + } + +} From abcd4dfe8cd691f3a336c5c6c369c571764c4a46 Mon Sep 17 00:00:00 2001 From: mungsil Date: Thu, 4 Dec 2025 14:26:15 +0900 Subject: [PATCH 74/84] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course/application/CourseService.java | 8 - .../course/domain/service/CourseUseCase.java | 4 - .../course/presentation/CourseController.java | 9 - .../notification/FcmMsgTemplate.java | 32 -- .../notification/TravelCourseMsgTemplate.java | 74 ---- .../v2/PlanTravelCourseResponseDtoV2.java | 9 - .../domain/service/TravelCourseUseCaseV2.java | 10 - .../v2/PlanTravelCourseRequestDtoV2.java | 18 - .../queue/batch/BatchStatus.java | 16 - .../batch/TransitRouteBatchExecution.java | 89 ---- ...ansitRouteBatchExecutionJpaRepository.java | 6 - .../TransitRouteBatchExecutionRepository.java | 9 - ...nsitRouteBatchExecutionRepositoryImpl.java | 24 -- .../batch/TransitRouteBatchLauncher.java | 39 -- .../queue/batch/TransitRouteBatchService.java | 25 -- .../queue/batch/TransitRouteBatchUseCase.java | 11 - .../queue/batch/TransitRouteRequest.java | 68 --- .../queue/batch/TransitRouteService.java | 56 --- .../PrioritizedTransitRouteRequest.java | 32 -- .../consumer/TransitRouteRequestConsumer.java | 394 ------------------ .../TransitRouteRequestForwarder.java | 146 ------- .../TransitRouteRequestProcessor.java | 236 ----------- .../producer/TransitRouteRequestProducer.java | 62 --- .../route/batch/TransitRouteBatchConfig.java | 28 -- .../route/odsay/ODsayApiClient.java | 93 ----- 25 files changed, 1498 deletions(-) delete mode 100644 src/main/java/com/example/mohago_nocar/course/application/CourseService.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/domain/service/CourseUseCase.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/presentation/CourseController.java delete mode 100644 src/main/java/com/example/mohago_nocar/notification/FcmMsgTemplate.java delete mode 100644 src/main/java/com/example/mohago_nocar/notification/TravelCourseMsgTemplate.java delete mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/PlanTravelCourseResponseDtoV2.java delete mode 100644 src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java delete mode 100644 src/main/java/com/example/mohago_nocar/plan/presentation/v2/PlanTravelCourseRequestDtoV2.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/BatchStatus.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecution.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionJpaRepository.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepository.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepositoryImpl.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchLauncher.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchService.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchUseCase.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteRequest.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteService.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/PrioritizedTransitRouteRequest.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestForwarder.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestProcessor.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteRequestProducer.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchConfig.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java diff --git a/src/main/java/com/example/mohago_nocar/course/application/CourseService.java b/src/main/java/com/example/mohago_nocar/course/application/CourseService.java deleted file mode 100644 index 0d91390..0000000 --- a/src/main/java/com/example/mohago_nocar/course/application/CourseService.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.mohago_nocar.course.application; - -import com.example.mohago_nocar.course.domain.service.CourseUseCase; -import org.springframework.stereotype.Service; - -@Service -public class CourseService implements CourseUseCase { -} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/service/CourseUseCase.java b/src/main/java/com/example/mohago_nocar/course/domain/service/CourseUseCase.java deleted file mode 100644 index ce64044..0000000 --- a/src/main/java/com/example/mohago_nocar/course/domain/service/CourseUseCase.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.mohago_nocar.course.domain.service; - -public interface CourseUseCase { -} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/CourseController.java b/src/main/java/com/example/mohago_nocar/course/presentation/CourseController.java deleted file mode 100644 index fe68e51..0000000 --- a/src/main/java/com/example/mohago_nocar/course/presentation/CourseController.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.mohago_nocar.course.presentation; - -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class CourseController { -} diff --git a/src/main/java/com/example/mohago_nocar/notification/FcmMsgTemplate.java b/src/main/java/com/example/mohago_nocar/notification/FcmMsgTemplate.java deleted file mode 100644 index 8fadf2a..0000000 --- a/src/main/java/com/example/mohago_nocar/notification/FcmMsgTemplate.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.mohago_nocar.notification; - -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; - -import java.util.Map; - -@JsonTypeInfo( - use = JsonTypeInfo.Id.SIMPLE_NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "type" -) -@JsonSubTypes({ - @JsonSubTypes.Type(value = TravelCourseMsgTemplate.class,name = "travelCourseFcmMsg") -}) -@AllArgsConstructor -@ToString -@Getter -@NoArgsConstructor -public abstract class FcmMsgTemplate { - - String title; - - String body; - - abstract Map getAllCustomData(); - -} diff --git a/src/main/java/com/example/mohago_nocar/notification/TravelCourseMsgTemplate.java b/src/main/java/com/example/mohago_nocar/notification/TravelCourseMsgTemplate.java deleted file mode 100644 index e6e620d..0000000 --- a/src/main/java/com/example/mohago_nocar/notification/TravelCourseMsgTemplate.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.example.mohago_nocar.notification; - -import jakarta.validation.constraints.NotNull; -import lombok.*; - -import java.util.Map; -import java.util.Objects; -import java.util.UUID; - -// 이건...... course 도메인에 둬도 되지 않을까 -@ToString(callSuper = true) -@Getter -@NoArgsConstructor -public class TravelCourseMsgTemplate extends FcmMsgTemplate { - - public static class SuccessTemplate { - private static final String TITLE = "여행 계획 완성! ✈️"; - private static final String BODY = "여행 계획 설계가 완료되었어요. 탭해서 확인해보세요!"; - } - - public static class FailureTemplate { - private static final String TITLE = "여행 계획 수립 실패..."; - private static final String BODY = "예상치 못한 에러로 계획 수립에 실패했어요."; - } - - Long travelCourseId; - - UUID userId; - - Boolean isSuccess; - - public static TravelCourseMsgTemplate create( - boolean isSuccess, long travelCourseId, @NotNull UUID userId) { - String title = isSuccess ? SuccessTemplate.TITLE : FailureTemplate.TITLE; - String body = isSuccess ? SuccessTemplate.BODY : FailureTemplate.BODY; - - return TravelCourseMsgTemplate.builder() - .title(title) - .body(body) - .isSuccess(isSuccess) - .travelCourseId(travelCourseId) - .userId(userId) - .build(); - } - - @Builder(access = AccessLevel.PRIVATE) - private TravelCourseMsgTemplate( - String title, String body, Long travelCourseId, UUID userId, Boolean isSuccess) { - super(title, body); - this.travelCourseId = travelCourseId; - this.userId = userId; - this.isSuccess = isSuccess; - } - - /** - * Returns custom data as a map of field names to string values. - * - *

    Notice: This implementation uses hard‑coded field names. - * If any field names change in the class, this method must be updated manually. - * - * @return map containing travelCourseId, userId, and isSuccess values - */ - @Override - Map getAllCustomData() { - Objects.requireNonNull(userId); - - return Map.of( - "travelCourseId", String.valueOf(travelCourseId), - "userId", userId.toString(), - "isSuccess", String.valueOf(isSuccess) - ); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/PlanTravelCourseResponseDtoV2.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/PlanTravelCourseResponseDtoV2.java deleted file mode 100644 index f28da4c..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/application/v2/PlanTravelCourseResponseDtoV2.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.mohago_nocar.plan.application.v2; - -import lombok.Builder; - -@Builder -public record PlanTravelCourseResponseDtoV2( - String batch_id -) { -} diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java deleted file mode 100644 index c58f1b1..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV2.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.example.mohago_nocar.plan.domain.service; - -import com.example.mohago_nocar.plan.application.v2.PlanTravelCourseResponseDtoV2; -import com.example.mohago_nocar.plan.presentation.v2.PlanTravelCourseRequestDtoV2; - -public interface TravelCourseUseCaseV2 { - - public TravelPlanResponseDtoV2 plan(PlanTravelCourseRequestDtoV2 request); - -} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/v2/PlanTravelCourseRequestDtoV2.java b/src/main/java/com/example/mohago_nocar/plan/presentation/v2/PlanTravelCourseRequestDtoV2.java deleted file mode 100644 index 92a0123..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/v2/PlanTravelCourseRequestDtoV2.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.example.mohago_nocar.plan.presentation.v2; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.util.List; - -public record PlanTravelCourseRequestDtoV2( - - @Schema(description = "클라이언트가 발급한 uuid로, 회원 아이디 대신 사용합니다.") - String userId, - - @Schema(description = "fcm token") - String fcmToken, - - @Schema(description = "선택된 여행 장소들의 아이디", example = "[1234]") - List placeIds -) { -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/BatchStatus.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/BatchStatus.java deleted file mode 100644 index 0b88c14..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/BatchStatus.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public enum BatchStatus { - - PENDING("실행 대기 중"), - RUNNING("실행 중"), - COMPLETED("완료"), - FAILED("실패"); - - private final String description; - -} - diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecution.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecution.java deleted file mode 100644 index aebc22e..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecution.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -import jakarta.persistence.Id; -import lombok.*; -import org.springframework.data.redis.core.RedisHash; - -import java.time.Instant; -import java.util.*; - -/** - * 배치 작업의 상태를 기록합니다. - */ -// todo plan id 기록 -@RedisHash(value = "batch") -@Builder(access = AccessLevel.PRIVATE) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@ToString -public class TransitRouteBatchExecution { - - @Id - private String id; - - private UUID userId; - - private Long planId; - - private BatchStatus status; - - private Integer totalCount; - - private String completedSequences; // todo - - private Boolean isDelivered; - - private Long createdAt; - - private Long completedAt; - - // todo 삭제 - private String errorMessage; - - // todo synch가 필요한가? -> 루아스크립트로 변경 - public synchronized void fail(Exception exception) { - status = BatchStatus.FAILED; - errorMessage = exception.getMessage(); - } - - public static TransitRouteBatchExecution createInitial(int totalCount, UUID userId, Long planId) { - String batchId = UUID.randomUUID().toString(); - - return TransitRouteBatchExecution.builder() - .id(batchId) - .userId(userId) - .planId(planId) - .status(BatchStatus.PENDING) - .totalCount(totalCount) - .completedSequences(null) - .isDelivered(false) - .createdAt(Instant.now().toEpochMilli()) - .build(); - } - - public void completeDeliver() { - this.isDelivered = true; - } - - public boolean isAlreadyDelivered() { - return isDelivered; - } - - public int getCompletedCount() { - return this.getCompletedSequences().split(",").length; - } - -/* public String addCompletedSequence(String sequence) { - if (completedSequences == null) { - this.completedSequences = sequence; - } else { - this.completedSequences += "," + sequence; - } - - return this.completedSequences; - }*/ - -} - - diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionJpaRepository.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionJpaRepository.java deleted file mode 100644 index 73efdf8..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -import org.springframework.data.repository.CrudRepository; - -public interface TransitRouteBatchExecutionJpaRepository extends CrudRepository { -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepository.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepository.java deleted file mode 100644 index e589edd..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -public interface TransitRouteBatchExecutionRepository { - - TransitRouteBatchExecution save(TransitRouteBatchExecution execution); - - TransitRouteBatchExecution findByExecutionId(String executionId); - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepositoryImpl.java deleted file mode 100644 index 202b221..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchExecutionRepositoryImpl.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; - -@Repository -@Transactional -@RequiredArgsConstructor -public class TransitRouteBatchExecutionRepositoryImpl implements TransitRouteBatchExecutionRepository { - - private final TransitRouteBatchExecutionJpaRepository jpaRepository; - - @Override - public TransitRouteBatchExecution save(TransitRouteBatchExecution execution) { - return jpaRepository.save(execution); - } - - @Override - public TransitRouteBatchExecution findByExecutionId(String executionId) { - return jpaRepository.findById(executionId).orElse(null); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchLauncher.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchLauncher.java deleted file mode 100644 index 0860f51..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchLauncher.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -import com.example.mohago_nocar.plan.domain.model.Location; -import com.example.mohago_nocar.transit.infrastructure.queue.producer.TransitRouteRequestProducer; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.*; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; - -/** - * 대중교통 경로를 구하는 배치 작업을 진입 순서대로 시작합니다. - */ -@Component -@Slf4j -@RequiredArgsConstructor -public class TransitRouteBatchLauncher { - - private final ReentrantLock lock = new ReentrantLock(true); - private final TransitRouteRequestProducer itemProducer; - - public void launch(TransitRouteBatchExecution execution, List locations) { - try { - if (lock.tryLock(5, TimeUnit.SECONDS)) { - itemProducer.produce(execution.getId(), locations); - }else { - throw new RuntimeException("Failed to acquire lock within timeout"); - } - } catch (Exception e) { - log.error(e.getMessage(), e); - throw new RuntimeException(e); - } finally { - lock.unlock(); - } - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchService.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchService.java deleted file mode 100644 index a7da76c..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchService.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.UUID; - -@Service -@RequiredArgsConstructor -public class TransitRouteBatchService implements TransitRouteBatchUseCase { - - private final TransitRouteBatchExecutionRepository repository; - - @Override - public TransitRouteBatchExecution createAndSaveExecution(int totalPlaceCount, UUID userId, Long planId) { - TransitRouteBatchExecution entity = TransitRouteBatchExecution.createInitial(totalPlaceCount, userId, planId); - return repository.save(entity); - } - - @Override - public TransitRouteBatchExecution getById(String executionId) { - return repository.findByExecutionId(executionId); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchUseCase.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchUseCase.java deleted file mode 100644 index 4287b99..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteBatchUseCase.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -import java.util.UUID; - -public interface TransitRouteBatchUseCase { - - TransitRouteBatchExecution createAndSaveExecution(int totalPlaceCount, UUID userId, Long planId); - - TransitRouteBatchExecution getById(String executionId); - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteRequest.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteRequest.java deleted file mode 100644 index 0dd4f02..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteRequest.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -import com.example.mohago_nocar.plan.domain.model.Location; -import lombok.*; - -import java.util.UUID; - -// todo Comparable 제거 -@Data -@AllArgsConstructor -@NoArgsConstructor -@Getter -@ToString -public class TransitRouteRequest implements Comparable { - - String id; - String batchId; - Location origin; - Location destination; - Integer sequence; - Integer retry; - Boolean needRetry; - - public Integer plusRetry() { - return ++retry; - } - - public void markNeedRetry() { - needRetry = true; - } - - public static TransitRouteRequest of(String batchId, Location origin, Location destination, Integer sequence) { - String id = UUID.randomUUID().toString(); - - return TransitRouteRequest.builder() - .id(id) - .batchId(batchId) - .origin(origin) - .destination(destination) - .sequence(sequence) - .build(); - } - - @Builder - private TransitRouteRequest(String id, String batchId, Location origin, Location destination, Integer sequence) { - this.id = id; - this.batchId = batchId; - this.origin = origin; - this.destination = destination; - this.sequence = sequence; - this.retry = 0; - this.needRetry = false; - } - - @Override - public int compareTo(TransitRouteRequest o) { - if (this.retry > o.getRetry()) { - return -1; - } - - if (this.retry < o.getRetry()) { - return 1; - } - - return 0; - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteService.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteService.java deleted file mode 100644 index f053985..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/batch/TransitRouteService.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.batch; - -import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; -import com.example.mohago_nocar.plan.domain.model.Location; -import com.example.mohago_nocar.transit.domain.model.TransitRoute; -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; - -@Component -@RequiredArgsConstructor -public class TransitRouteService { - - private final TransitRouteApiAdapter transitRouteApiAdapter; - - public List> searchTransitRoutesInOrder(List travelSpotsInOrder) { - validateMinSize(travelSpotsInOrder, 2); - - List> transitRouteApiCallResults = new ArrayList<>(); - int spotNum = travelSpotsInOrder.size(); - - for (int i = 0; i < spotNum - 1; i++) { - Location origin = travelSpotsInOrder.get(i).getLocation(); - Location destination = travelSpotsInOrder.get(i + 1).getLocation(); - - CompletableFuture apiCalled = transitRouteApiAdapter.getTransitRoute(origin, destination); - transitRouteApiCallResults.add(apiCalled); - } - - return transitRouteApiCallResults; - } - - private void validateMinSize(List travelSpotsInOrder, int minSize) { - if (travelSpotsInOrder == null || travelSpotsInOrder.size() < minSize) { - throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); - } - } - - private List>fetchTransitRoutes(List travelSpotsInOrder) { - List> futures = new ArrayList<>(); - - for (int i = 0; i < travelSpotsInOrder.size() - 1; i++) { - Location origin = travelSpotsInOrder.get(i).getLocation(); - Location destination = travelSpotsInOrder.get(i + 1).getLocation(); - - futures.add(transitRouteApiAdapter.getTransitRoute(origin, destination)); - } - - return futures; - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/PrioritizedTransitRouteRequest.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/PrioritizedTransitRouteRequest.java deleted file mode 100644 index fba29d6..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/PrioritizedTransitRouteRequest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.consumer; - -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class PrioritizedTransitRouteRequest implements Comparable { - - private Long insertionOrder; // tie-breaker: 동일 우선순위 시, 먼저 들어온 요청이 우선됨 - private TransitRouteRequest value; - - @Override - public int compareTo(PrioritizedTransitRouteRequest o) { - int compared = this.value.compareTo(o.getValue()); - if (compared != 0) { - return compared; - } - - // 기존 값의 우선 순위가 동일한 경우 진입 순서 활용 - if (this.insertionOrder < o.insertionOrder) { - return -1; - } - if (this.insertionOrder > o.insertionOrder) { - return 1; - } - - return 0; - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java deleted file mode 100644 index 5d76531..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestConsumer.java +++ /dev/null @@ -1,394 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.consumer; - -import com.example.mohago_nocar.global.common.exception.Status; -import com.example.mohago_nocar.global.util.ObjectMapperUtil; -import com.example.mohago_nocar.plan.application.v2.TravelCoursePlanNotifyService; -import com.example.mohago_nocar.transit.domain.model.TransitRoute; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.BatchStatus; -import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; -import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; -import io.github.bucket4j.*; -import io.lettuce.core.RedisBusyException; -import io.lettuce.core.RedisConnectionException; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.connection.stream.*; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; -import org.springframework.data.redis.stream.StreamListener; -import org.springframework.data.redis.stream.StreamMessageListenerContainer; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClientException; - -import java.time.Duration; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -// todo readOffset 설정 확인 -@Component -@RequiredArgsConstructor -@Slf4j -public class TransitRouteRequestConsumer implements StreamListener> { - - @Value("${redis.streams.odsay.main}") - private String streamKey; - - private final String consumerGroup = "processors"; - - private StreamMessageListenerContainer> listenerContainer; - - private final StringRedisTemplate stringRedisTemplate; - private final TravelCoursePlanNotifyService travelCoursePlanNotifyService; - private final ObjectMapperUtil objectMapperUtil; - private final TransitRouteApiAdapter transitRouteApiAdapter; - - private final ExecutorService virtualThreadPool = Executors.newVirtualThreadPerTaskExecutor(); - - private Bucket bucket; - - @PostConstruct - public void init() { - bucket = createBucket(); - - // Consumer Group 설정 - createStreamConsumerGroupIfNotExists(streamKey, consumerGroup); - - // StreamMessageListenerContainer 설정 - this.listenerContainer = StreamMessageListenerContainer.create( - stringRedisTemplate.getConnectionFactory(), - StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder() - .targetType(String.class) - .pollTimeout(Duration.ofSeconds(3)) - .batchSize(1) - .errorHandler(t -> log.info(t.getMessage())) // todo 로깅 및 알림 - .build() - ); - - listenerContainer.register(StreamMessageListenerContainer.StreamReadRequest - .builder(StreamOffset.create(streamKey, ReadOffset.lastConsumed())) - .cancelOnError(throwable -> false) - .consumer(Consumer.from(this.consumerGroup, "processors-1")) - .autoAcknowledge(false).build() - , this - ); - -/* int parallelListener = 2; - for (int i = 1; i <= parallelListener; i++) { - String consumerName = "processor-" + i; - - listenerContainer.register(StreamMessageListenerContainer.StreamReadRequest - .builder(StreamOffset.create(streamKey, ReadOffset.lastConsumed())) - .cancelOnError(throwable -> false) - .consumer(Consumer.from(this.consumerGroup, consumerName)) - .autoAcknowledge(false).build() - , this - ); - }*/ - - // Redis listen 시작 - this.listenerContainer.start(); - } - -/* @Override - public void onMessage(ObjectRecord message) { - try { - Thread.sleep(200); - } catch (InterruptedException e) { - // todo 로깅 + 알림 : 현재 스레드를 인터럽트하는 코드를 작성해두지 않음 -> 원인 파악 후 처리 - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - - asyncProcessRequest(message); - }*/ - - - - @Override - public void onMessage(ObjectRecord message) { - try { - bucket.asBlocking().consume(1); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - asyncProcessRequest(message); - } - - private void asyncProcessRequest(ObjectRecord message) { - TransitRouteRequest request = objectMapperUtil.readValue(message.getValue(), TransitRouteRequest.class); - - // todo 이미 실패한 배치의 요청인지 확인 -> 이미 실패한 배치 요청이라면 throw ex - CompletableFuture.supplyAsync(() -> fetchTransitRouteByApiCalling(request), virtualThreadPool) - .handle((route, ex) -> { - if (ex != null) { - // if retryable exception - // todo 재시도 with 지수 백오프 구현 방법 -> pending 리스트에 그대로 두기 - if (isTooManyRequestsEx(ex)) { - // Using retry count of request, consider follower two options - // 1. send to DLQ if <- exceed enable auto retry count - // 2. add to sorted set <- else - return null; - } - - // [로깅, 개발자 알림], 배치 실패 처리 - // 개발자 알림은 why, how? - // why? - // 예상치 못한 예외 (네트웤, 429, external api unexpected ex)에 대한 대응이 필요하기 때문임 - // how? - // 1. 로그 기반 일정 시간 간격: 예외 발생 사실 확인 -> 로깅 -> 정기적 알림 전송 - // 장점: 예상 가능한 알림 전송량 + 예외가 다량 발생해도 통계적으로 확인 가능 - // 단점: 실시간성이 떨어짐. 실시간으로 대처해야하는 에러가 있다면? - // -> '실시간 대처가 필요한' 에러란? - // -> 모르겠음. - // 2. 예외 발생 시 실시간 알림 전송 - // 장점: 실시간으로 예외 지각 가능 - // 단점: 알림이 많이 와서 파악이 어려울 가능성 있음 (근데 현재는 아님) - // - - // todo need a block circuit - if (ex instanceof ODsayRouteException odsayEx) { - Status status = odsayEx.getStatus(); - // 빠르게 응답을 줘야함 -> 배치 실패 처리 후 로깅 - // todo cause로 api 키나 ip 문제 인지 확인 - if (status instanceof OdsayErrorCode errorCode) { - errorCode.isServerError(); - } - // 실패 처리 -> 바로 알림 주는게 조은뎅 - } - - if (ex instanceof RestClientException clientEx) { - // 재시도하는 거 어때욤? 네트워크 에러잖아... - // ah, I was thought I should only add a circuit breaker, - // but now I'm think I need both. - // 아 이거!! 원인이 네트워크 에러만 있는게 아니었음. - } - -// todo lost update 발생 해결 -> 루아스크립트 사용. -// TransitRouteBatchExecution batchExecution = batchExecutionRepository.findByExecutionId(request.getBatchId()); -// batchExecution.fail((Exception) ex); // 배치 상태 실패로 변경 - 가장 먼저 발생한 에러만 기록 -// batchExecutionRepository.save(batchExecution); -// // 실패한 request dlq로 이동 -// onAfterFailure.run(); // entry ack & delete - - // * 실패 알림은 워커 스레드가 수행할거임. <- 배치 상태 fail 인거 확인할거임. - } - - // todo 멱등성 테스트 - else { - BatchStatus batchStatus = processWhenRequestSuccess(request.getBatchId(), route, request.getSequence()); - if (batchStatus == BatchStatus.COMPLETED) { - // 결과 rdb 저장 - notification id + batch id가 기준이 될 것임. - saveCompletedTransitRoute(request.getBatchId()); - - // 알림 전송 - travelCoursePlanNotifyService.sendSuccessNotification(request.getBatchId()); - } - - // 여기서 레디스 서버 크러쉬되면 중복 처리 발생 - ackAndDeleteEntry(message.getId()); - } - - return null; - }).exceptionally(throwable -> { - log.error("Error processing request", throwable); - - - // 2. 네트워크 혼잡 예외 - - // 3. 레디스 인프라 예외 - // 어떤 예외가 있는지 몰라욤. 공부 필요해염. - // 레디스 서버가 내려가는 경우 -> 1. 정상화될때까지 롱폴링 2. RDB - - // 4. 비즈니스 로직 예외. - // 존재하지 않는 배치 아이디 예외 같은거. - // > 예상하지 못한 상황 < 실패 처리하고 디스코드 알림, 로깅하기. - - // etc. 그 외 내가 모르는 예외 - // 일단 DLQ로 전송, 디스코드 알림, 로깅하기. - return null; - }); - } - - private Bucket createBucket() { - int permitAPICallNumPerSec = 10; - Bandwidth limit = BandwidthBuilder.builder().capacity(permitAPICallNumPerSec) - .refillIntervally(permitAPICallNumPerSec, Duration.ofSeconds(1)) - .initialTokens(0) - .build(); - - return Bucket.builder() - .addLimit(limit) - .build(); - } - - private boolean isTooManyRequestsEx(Throwable ex) { - if (ex instanceof ODsayRouteException odsayEx) { - Status status = odsayEx.getStatus(); - if (status instanceof OdsayErrorCode errorCode) { - if (errorCode.isTooManyRequests()) { - return true; - } - - errorCode.isServerError(); // 로깅 with request , DLQ 전송 // 개발자 알림 - - // 아래는 공통적으로 유저 (실패) 알림 전송 // 개발자 알림 - errorCode.isUnExpectedError(); // 로깅 with request, - - // 그 외: 로깅도 안함. - } else { - log.warn("UnExpected Status exists : {}", status); - // 로깅 후 DLQ 전송 // 개발자 알림 - } - } - - // 레디스 크러쉬 - // 서버가 죽으면 '재시도' 하는 것보다 '차단'하는 게 이득이 아닐까? - // 해당 사항은 레디스 서버 크러쉬임 - - // 네트워크 에러 - // 재시도하는게 좋겠지만, 이거 네트워크 혼잡 때문이다! 라고 에러 식별할 수 있는 방법을 모름. - // 이것도 서킷 브레이커 패턴을 쓰면 어떨까 - - if (ex instanceof RedisConnectionException connEX){ - // 재시도 or 실패 - } - - return false; - } - - private void ackAndDeleteEntry(RecordId recordId) { - String lua = """ - local stream = ARGV[1] - local group = ARGV[2] - local id = ARGV[3] - local acked = redis.call('XACK', stream, group, id) - local deleted = redis.call('XDEL', stream, id) - return {acked, deleted} - """; - - DefaultRedisScript script = new DefaultRedisScript<>(lua, List.class); - List executed = stringRedisTemplate.execute( - script, - List.of(), // KEYS 없음 - streamKey, // ARGV[1] - consumerGroup, // ARGV[2] - recordId.getValue() // ARGV[3] - ); - - Long acked = executed.get(0); - Long deleted = executed.get(1); - - if (acked == 0 || deleted == 0) { - log.warn("[Redis] ackAndDeleteEntry: ack={}, del={}, id={}", acked, deleted, recordId.getValue()); - } - - } - - private void saveCompletedTransitRoute(String batchId) { - - } - - // todo sequence 구분자는 batch 도메인에게 물어봐야한다. 필드명도... - private BatchStatus processWhenRequestSuccess(String batchId, TransitRoute route, int sequenceInBatch) { - String lua = - """ - local zsetKey = KEYS[1] -- Sorted Set 키 (경로 결과 저장용) - local hashKey = KEYS[2] -- Hash 키 (배치 메타데이터 저장용) - local score = tonumber(ARGV[1]) -- Sorted Set의 점수값 (경로의 순서(시퀀스) 저장용) - local member = ARGV[2] -- Sorted Set에 저장할 멤버(경로 데이터) - local completedSeqsField = ARGV[3] -- 완료된 시퀀스 목록 필드명 - local totalField = ARGV[4] -- 전체 시퀀스 목록 필드명 - local newCompletedSeq = ARGV[5] -- 완료된 시퀀스 - - -- 1. API 호출 결과(대중교통 경로) 저장 - local added = redis.call('ZADD', zsetKey, score, member) - - local function updateCompletedSeqList(hashKey, completedSeqsField, newCompletedSeq) - local current = redis.call('HGET', hashKey, completedSeqsField) - - -- 시퀀스 목록이 비어있는 경우 - if not current or current == '' then - redis.call('HSET', hashKey, completedSeqsField, newCompletedSeq) - return 1 - end - - local alreadyExists = false - local existedCount = 0 - for seq in string.gmatch(current, '([^,]+)') do - existedCount = existedCount + 1 - if seq == newCompletedSeq then - alreadyExists = true - end - end - - if alreadyExists then - return existedCount - end - - -- 완료 목록에 시퀀스 업데이트 - local updated = current .. ',' .. newCompletedSeq - redis.call('HSET', hashKey, completedSeqsField, updated) - return existedCount + 1 - end - - -- 2. 시퀀스 완료 처리 - local completedCount = updateCompletedSeqList(hashKey, completedSeqsField, newCompletedSeq) - - -- 3. 배치 완료 여부 확인 - local total = redis.call('HGET', hashKey, totalField) - if tostring(completedCount) == tostring(total) then - redis.call('HSET', hashKey, 'status', 'completed') - end - - -- 4. 배치 상태 반환 - local batchStatus = redis.call('HGET', hashKey, 'status') - return batchStatus - """; - DefaultRedisScript script = new DefaultRedisScript<>(lua, String.class); - - // todo 키 별도 관리 - String sortedSetKey = "transit:routes" + batchId; - String batchExecutionKey = "batch:" + batchId; - String result = stringRedisTemplate.execute( - script, - List.of(sortedSetKey, batchExecutionKey), - sequenceInBatch, - route, - "completedSequences", // 필드명 - "totalCount", // 필드명 - 1 - ); - - return BatchStatus.valueOf(result); - } - - // todo 여기서 발생 가능한 예외 정리 - public TransitRoute fetchTransitRouteByApiCalling(TransitRouteRequest request) { - return transitRouteApiAdapter.getTransitRouteBetweenLocations(request.getOrigin(), request.getDestination()); - } - - private void createStreamConsumerGroupIfNotExists(String streamKey, String consumerGroup) { - try { - stringRedisTemplate.opsForStream() - .createGroup(streamKey, ReadOffset.from("0"), consumerGroup); - log.info("Consumer group {} created", consumerGroup); - } catch (RedisSystemException e) { - Throwable cause = e.getCause(); - if (cause instanceof RedisBusyException busyException) { - log.info(busyException.getMessage()); - return; - } - - throw new RuntimeException( - "Unexpected error while creating consumer group: ", cause); // todo convert to 커스텀 ex - } - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestForwarder.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestForwarder.java deleted file mode 100644 index f188cd6..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestForwarder.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.consumer; - -import io.github.resilience4j.ratelimiter.RateLimiter; -import io.github.resilience4j.ratelimiter.RateLimiterConfig; -import io.github.resilience4j.ratelimiter.RateLimiterRegistry; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.connection.stream.Consumer; -import org.springframework.data.redis.connection.stream.ObjectRecord; -import org.springframework.data.redis.connection.stream.ReadOffset; -import org.springframework.data.redis.connection.stream.StreamOffset; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; -import org.springframework.data.redis.stream.StreamListener; -import org.springframework.data.redis.stream.StreamMessageListenerContainer; -import org.springframework.data.redis.stream.Subscription; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.util.List; - -// transit route req outer Consumer -// bridge consumer, - -// inner, -// TransitRouteRequestForwarder -> TransitRouteReqForwarder -// TransitRouteRequestProcessor -> PrioritizedTransitRouteProcessor -@Component -@RequiredArgsConstructor -@Slf4j -public class TransitRouteRequestForwarder implements StreamListener> { - - @Value("${redis.streams.odsay.main}") - private String streamKey; - - private final String consumerGroup = "processors"; - private final String consumer = "processor-1"; - - private StreamMessageListenerContainer> listenerContainer; - private Subscription subscription; - private RateLimiter rateLimiter; - - private final RedisTemplate stringRedisTemplate; - private final TransitRouteRequestProcessor transitRouteRequestProcessor; - - @PostConstruct - public void init() { - rateLimiter = configureRateLimiter().rateLimiter("transit-api-producer"); - - // Consumer Group 설정 - createStreamConsumerGroupIfNotExists(streamKey, consumerGroup); - - // StreamMessageListenerContainer 설정 - this.listenerContainer = StreamMessageListenerContainer.create( - stringRedisTemplate.getConnectionFactory(), - StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder() - .targetType(String.class) - .pollTimeout(Duration.ofSeconds(3)) -// .errorHandler() - .build() - ); - - // Subscription 설정 - this.subscription = this.listenerContainer.receive( - Consumer.from(this.consumerGroup, consumer), - StreamOffset.create(streamKey, ReadOffset.lastConsumed()), - this - ); - - // Redis listen 시작 - this.listenerContainer.start(); - } - - private void createStreamConsumerGroupIfNotExists(String streamKey, String consumerGroup) { - try { - stringRedisTemplate.opsForStream() - .createGroup(streamKey, ReadOffset.from("0"), consumerGroup); - log.info("Consumer group {} created", consumerGroup); - } catch (Exception e) { - log.error("Exception occurs during creating consumer group : {}", e.getMessage()); - } - } - - @Override - public void onMessage(ObjectRecord message) { - - rateLimiter.executeRunnable(() -> { - transitRouteRequestProcessor.receive(message); - transitRouteRequestProcessor.onAfterSuccess(() -> ackAndDeleteEntry(message)); - transitRouteRequestProcessor.onAfterFailure(() -> ackAndDeleteEntry(message)); - }); - - } - - private void ackAndDeleteEntry(ObjectRecord message) { - String lua = """ - local stream = ARGV[1] - local group = ARGV[2] - local id = ARGV[3] - local acked = redis.call('XACK', stream, group, id) - local deleted = redis.call('XDEL', stream, id) - return {acked, deleted} - """; - - DefaultRedisScript script = new DefaultRedisScript<>(lua, List.class); - List executed = stringRedisTemplate.execute( - script, - List.of(), // KEYS 없음 - streamKey, // ARGV[1] - consumerGroup, // ARGV[2] - message.getId().getValue() // ARGV[3] - ); - - Long acked = executed.get(0); - Long deleted = executed.get(1); - - if (acked == 0 || deleted == 0) { - log.warn("[Redis] ackAndDeleteEntry: ack={}, del={}, id={}", acked, deleted, message.getId()); - } - - } - - @PreDestroy - public void stopListenerContainer() { - if (listenerContainer != null) { - listenerContainer.stop(); - log.info("Listener container stopped"); - } - } - - private RateLimiterRegistry configureRateLimiter() { - RateLimiterConfig config = RateLimiterConfig.custom() - .limitRefreshPeriod(Duration.ofSeconds(1)) - .limitForPeriod(5) - .timeoutDuration(Duration.ofMinutes(5)) - .build(); - - return RateLimiterRegistry.of(config); - } - -} - - diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestProcessor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestProcessor.java deleted file mode 100644 index 1497675..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/consumer/TransitRouteRequestProcessor.java +++ /dev/null @@ -1,236 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.consumer; - -import com.example.mohago_nocar.global.util.ObjectMapperUtil; -import com.example.mohago_nocar.plan.application.v2.TravelCoursePlanNotifyService; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.BatchStatus; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; -import com.example.mohago_nocar.transit.domain.model.TransitRoute; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecution; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecutionRepository; -import com.example.mohago_nocar.transit.domain.repository.TransitRouteRepository; -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; -import io.github.resilience4j.ratelimiter.RateLimiter; -import io.github.resilience4j.ratelimiter.RateLimiterConfig; -import io.github.resilience4j.ratelimiter.RateLimiterRegistry; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.connection.stream.*; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.PriorityBlockingQueue; -import java.util.concurrent.atomic.AtomicLong; - -/** - * QueueRelay: send item from A to B queue - * RateLimitedConsumer: consume item by limited rate - * - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class TransitRouteRequestProcessor { - - private final TransitRouteApiAdapter transitRouteApiAdapter; - private final TransitRouteBatchExecutionRepository batchExecutionRepository; - private final ObjectMapperUtil objectMapperUtil; - private final StringRedisTemplate stringRedisTemplate; - private final TransitRouteRepository transitRouteRepository; - private final TravelCoursePlanNotifyService travelCoursePlanNotifyService; - - private final PriorityBlockingQueue queue = new PriorityBlockingQueue<>(); - - // 우선 순위를 위해 진입 순서 부여 - private final AtomicLong insertionOrderCounter = new AtomicLong(0); - - private final ExecutorService virtualThreadPool = Executors.newVirtualThreadPerTaskExecutor(); - - private RateLimiter rateLimiter; - private Runnable onAfterSuccess; - private Runnable onAfterFailure; - - public void receive(ObjectRecord msg) { - this.queue.add(PrioritizedTransitRouteRequest.builder() - .value(objectMapperUtil.readValue(msg.getValue(), TransitRouteRequest.class)) - .insertionOrder(insertionOrderCounter.addAndGet(1)).build()); - } - - /** - * 워커 스레드를 시작합니다. - */ - public void startWorker() { - Thread thread = new Thread(() -> { - while (!Thread.interrupted()) { - newDoWork(); - } - // Q: 자원 정리 로직 추가해야할까? - // 정리해야될 자원은 뭐가 있을까 - }); - thread.start(); - } - - // todo executor service를 이용한 수행. -> 남은 작업을 완료할 수 있도록 대기하고 싶음, 미래에 소비자가 늘면 관리가 복잡해짐 - // rate limiting만 수행하고 그 외 작업은 비동기 처리. - // 1. api call with "rate limit" - // 2. process result - public void newDoWork() { - // 다음 과정을 executorservice에 submit - // (동기) rate limit 대기 - // (비동기) CompletableFuture 사용하여 api call 및 handle - TransitRouteRequest request; - try { - request = queue.take().getValue(); // 블로킹 - } catch (InterruptedException e) { - // todo deadMsgProducer.produce - onAfterFailure.run(); - return; - } -// rateLimiter.acquirePermission(); -// thenAccept: api Call -// handle: process // 여기서 블로킹 발생함. - /* - 문제: - */ - rateLimiter.executeRunnable(() -> asyncProcessRequest(request)); - } - - private void asyncProcessRequest(TransitRouteRequest request) { - CompletableFuture.supplyAsync(() -> fetchTransitRouteByApiCalling(request), virtualThreadPool) - .handle((route, ex) -> { - if (ex != null) { // 여기서 발생하는 예외가 다인가? - // todo lost update 발생 해결 -> 루아스크립트 사용. - TransitRouteBatchExecution batchExecution = batchExecutionRepository.findByExecutionId(request.getBatchId()); - batchExecution.fail((Exception) ex); // 배치 상태 실패로 변경 - 가장 먼저 발생한 에러만 기록 - batchExecutionRepository.save(batchExecution); - // 실패한 request dlq로 이동 - onAfterFailure.run(); // entry ack & delete - }else { - BatchStatus batchStatus = processWhenRequestSuccess(request.getBatchId(), route, request.getSequence()); - if (batchStatus == BatchStatus.COMPLETED) { - // 결과 rdb 저장 - notification id + batch id가 기준이 될 것임. - saveCompletedTransitRoute(request.getBatchId()); - - // 알림 전송 - travelCoursePlanNotifyService.sendSuccessNotification(request.getBatchId()); - } - onAfterSuccess.run(); - } - - return null; - }); - } - - private void saveCompletedTransitRoute(String batchId) { - - } - - private BatchStatus processWhenRequestSuccess(String batchId, TransitRoute route, int sequenceInBatch) { - String lua = - """ - local zsetKey = KEYS[1] -- Sorted Set 키 (경로 결과 저장용) - local hashKey = KEYS[2] -- Hash 키 (배치 메타데이터 저장용) - local score = tonumber(ARGV[1]) -- Sorted Set의 점수값 (경로의 순서(시퀀스) 저장용) - local member = ARGV[2] -- Sorted Set에 저장할 멤버(경로 데이터) - local completedSeqsField = ARGV[3] -- 완료된 시퀀스 목록 필드명 - local totalField = ARGV[4] -- 전체 시퀀스 목록 필드명 - local newCompletedSeq = ARGV[5] -- 완료된 시퀀스 - - -- 1. API 호출 결과(대중교통 경로) 저장 - local added = redis.call('ZADD', zsetKey, score, member) - - local function updateCompletedSeqList(hashKey, completedSeqsField, newCompletedSeq) - local current = redis.call('HGET', hashKey, completedSeqsField) - - -- 시퀀스 목록이 비어있는 경우 - if not current or current == '' then - redis.call('HSET', hashKey, completedSeqsField, newCompletedSeq) - return 1 - end - - local alreadyExists = false - local existedCount = 0 - for seq in string.gmatch(current, '([^,]+)') do - existedCount = existedCount + 1 - if seq == newCompletedSeq then - alreadyExists = true - end - end - - if alreadyExists then - return existedCount - end - - -- 완료 목록에 시퀀스 업데이트 - local updated = current .. ',' .. newCompletedSeq - redis.call('HSET', hashKey, completedSeqsField, updated) - return existedCount + 1 - end - - -- 2. 시퀀스 완료 처리 - local completedCount = updateCompletedSeqList(hashKey, completedSeqsField, newCompletedSeq) - - -- 3. 배치 완료 여부 확인 - local total = redis.call('HGET', hashKey, totalField) - if tostring(completedCount) == tostring(total) then - redis.call('HSET', hashKey, 'status', 'completed') - end - - -- 4. 배치 상태 반환 - local batchStatus = redis.call('HGET', hashKey, 'status') - return batchStatus - """; - DefaultRedisScript script = new DefaultRedisScript<>(lua, String.class); - - // todo 키 별도 관리 - String sortedSetKey = "transit:routes" + batchId; - String batchExecutionKey = "batch:" + batchId; - String result = stringRedisTemplate.execute( - script, - List.of(sortedSetKey, batchExecutionKey), - sequenceInBatch, - route, - "completedCount", // 필드명 - "totalCount", // 필드명 - 1 - ); - - return BatchStatus.valueOf(result); - } - - // todo 여기서 발생 가능한 예외 정리 - public TransitRoute fetchTransitRouteByApiCalling(TransitRouteRequest request) { - return transitRouteApiAdapter.getTransitRouteBetweenLocations(request.getOrigin(), request.getDestination()); - } - - @PostConstruct - public void init() { - rateLimiter = initializeRateLimiter().rateLimiter("odsay api rate limiter"); - startWorker(); - } - - private RateLimiterRegistry initializeRateLimiter() { - RateLimiterConfig config = RateLimiterConfig.custom() - .limitRefreshPeriod(Duration.ofMillis(200)) - .limitForPeriod(1) - .timeoutDuration(Duration.ofSeconds(20)) - .build(); - - return RateLimiterRegistry.of(config); - } - - public void onAfterSuccess(Runnable action) { - onAfterSuccess = action; - } - - public void onAfterFailure(Runnable action) { - onAfterFailure = action; - } - -} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteRequestProducer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteRequestProducer.java deleted file mode 100644 index b02a194..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteRequestProducer.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.producer; - -import com.example.mohago_nocar.global.util.ObjectMapperUtil; -import com.example.mohago_nocar.plan.domain.model.Location; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.connection.stream.ObjectRecord; -import org.springframework.data.redis.connection.stream.StreamRecords; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.List; - -@Component -@RequiredArgsConstructor -public class TransitRouteRequestProducer { - - @Value("${redis.streams.odsay.main}") - private String streamKey; - - private final RedisTemplate stringRedisTemplate; - private final ObjectMapperUtil objectMapperUtil; - - public void produce(String batchId, List locations) { - validateLocations(locations); - List items = createItems(batchId, locations); - submit(items); - } - - private void submit(List items) { - for (TransitRouteRequest item : items) { - ObjectRecord apiReq = StreamRecords.newRecord() - .in(streamKey) - .ofObject(objectMapperUtil.writeValue(item)); - - stringRedisTemplate.opsForStream().add(apiReq); - } - } - - private List createItems(String batchId, List locations) { - List transitRouteRequests = new ArrayList<>(); - - for (int i = 1; i < locations.size(); i++) { - Location origin = locations.get(i-1); - Location destination = locations.get(i); - - TransitRouteRequest apiRequest = TransitRouteRequest.of(batchId, origin, destination, i); - transitRouteRequests.add(apiRequest); - } - - return transitRouteRequests; - } - - private void validateLocations(List locations) { - if (locations == null || locations.size() < 2) { - throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); - } - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchConfig.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchConfig.java deleted file mode 100644 index 294c148..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteBatchConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.route.batch; - -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.core.RedisTemplate; - -@Configuration -public class TransitRouteBatchConfig { - - - @Bean - TransitRouteBatchLauncher transitRouteBatchLauncher(RedisTemplate redisTemplateWithObj) { - return new TransitRouteBatchLauncher(transitRouteItemProducer(redisTemplateWithObj)); - } - - @Bean - TransitRouteItemProducer transitRouteItemProducer(RedisTemplate redisTemplateWithObj) { - return new TransitRouteItemProducer(redisTemplateWithObj); - } - - @Bean - TransitRouteItemConsumer transitRouteItemConsumer(RedisTemplate redisTemplateWithObj, - TransitRouteApiAdapter transitRouteApiAdapter) { - return new TransitRouteItemConsumer(redisTemplateWithObj, transitRouteApiAdapter); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java deleted file mode 100644 index 88bff0d..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClient.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.route.odsay; - -import com.example.mohago_nocar.global.common.domain.vo.Coordinate; -import com.example.mohago_nocar.global.common.exception.InternalServerException; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; -import io.github.resilience4j.ratelimiter.RateLimiter; -import io.github.resilience4j.ratelimiter.RateLimiterConfig; -import io.github.resilience4j.ratelimiter.RateLimiterRegistry; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.springframework.web.util.UriComponentsBuilder; - -import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URLEncoder; -import java.time.Duration; - -@Component -@Slf4j -public class ODsayApiClient { - - private static final int TIMEOUT_DURATION_SEC = 30; - private static final int INTERVAL_MS = 210; - private static final int PERMIT_THREAD_SIZE = 1; - private static final String ODSAY_RATE_LIMITER = "odsay"; - - private final RestClient restClient; - private final String apiKey; - private final String baseUrl; - - private final RateLimiter rateLimiter; - - public ODsayApiClient( - RestClient restClient, - @Value("${odsay.api-key}") String apiKey, - @Value("${odsay.url}") String baseUrl - ) { - RateLimiterRegistry rateLimiterRegistry = initializeRateLimiter(); - this.rateLimiter = rateLimiterRegistry.rateLimiter(ODSAY_RATE_LIMITER); - - this.restClient = restClient; - this.apiKey = apiKey; - this.baseUrl = baseUrl; - } - - public ODsayTransitRouteResponse searchTransitRoute(Coordinate origin, Coordinate destination) { - URI requestURI = buildRequestURI(origin, destination); - - return rateLimiter.executeSupplier(() -> executeApiCall(requestURI)); - } - - private ODsayTransitRouteResponse executeApiCall(URI requestURI) { - return restClient.get() - .uri(requestURI) - .retrieve() - .body(ODsayTransitRouteResponse.class); - } - - private URI buildRequestURI(Coordinate origin, Coordinate destination) { - String encodedApiKey = createEncodedApiKey(); - - return UriComponentsBuilder.fromUriString(baseUrl) - .queryParam("SX", origin.getLongitude()) - .queryParam("SY", origin.getLatitude()) - .queryParam("EX", destination.getLongitude()) - .queryParam("EY", destination.getLatitude()) - .queryParam("apiKey", encodedApiKey) - .build(true) - .toUri(); - } - - private String createEncodedApiKey() { - try { - return URLEncoder.encode(apiKey, "UTF-8"); - - } catch (UnsupportedEncodingException e) { - throw new InternalServerException(e.getMessage()); - } - } - - private RateLimiterRegistry initializeRateLimiter() { - RateLimiterConfig config = RateLimiterConfig.custom() - .limitRefreshPeriod(Duration.ofMillis(INTERVAL_MS)) - .limitForPeriod(PERMIT_THREAD_SIZE) - .timeoutDuration(Duration.ofSeconds(TIMEOUT_DURATION_SEC)) - .build(); - - return RateLimiterRegistry.of(config); - } - -} From f4ef8cd17b469f4e1a334dab8f4aaaf9175f65ee Mon Sep 17 00:00:00 2001 From: mungsil Date: Thu, 4 Dec 2025 14:29:12 +0900 Subject: [PATCH 75/84] =?UTF-8?q?archive:=20=EB=B0=B0=EC=B9=98=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EC=A0=84?= =?UTF-8?q?=20=EB=B3=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/TravelCoursePlanNotifyService.java | 108 ++++++++ .../v2/TravelCoursePlanServiceV2.java | 233 ++++++++++++++++++ .../application/v2/response/BatchInfoDto.java | 31 +++ .../GetTravelCoursePlanResponseDto.java | 19 ++ .../PlanTravelCourseResponseDtoV2.java | 10 + 5 files changed, 401 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanNotifyService.java create mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanServiceV2.java create mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/response/BatchInfoDto.java create mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/response/GetTravelCoursePlanResponseDto.java create mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/response/PlanTravelCourseResponseDtoV2.java diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanNotifyService.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanNotifyService.java new file mode 100644 index 0000000..9af9801 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanNotifyService.java @@ -0,0 +1,108 @@ +package com.example.mohago_nocar.plan.application.v2; + +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchUseCase; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecution; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserUseCase; +import com.google.firebase.messaging.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * [알림 전송 상황] + * - plan 실패 시 + * - 대중교통 경로 조회 실패 시 + * - 대중교통 경로 조회 완료 시 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TravelCoursePlanNotifyService { + + private final FirebaseMessaging firebaseMessaging; + private final UserUseCase userUseCase; + private final TransitRouteBatchUseCase transitRouteBatchUseCase; + + public void sendNotification( + UUID userId, + String title, + String body, + Long planId + ) { + try { + AnonymousUser user = userUseCase.findByIdOrThrow(userId); + + Message message = Message.builder() + .putData("planId", String.valueOf(planId)) + .setToken(user.getFcmToken()) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body).build()) + .build(); + + log.info("메시지 전송을 시작합니다."); + String response = firebaseMessaging.send(message); + log.info("메시지 전송이 끝났습니다."); + System.out.println("Successfully sent message: " + response); + } catch (Exception e) { + log.error("FCM 메시지 전송 중 에러 발생: {}", e.getMessage()); + // todo 디스코드 알림 전송 + } + } + + public void sendSuccessNotification(String batchId) { + // 배치 조회 + TransitRouteBatchExecution execution = transitRouteBatchUseCase.getById(batchId); + if (execution.isAlreadyDelivered()) { + return; + } + + Long planId = execution.getPlanId(); + UUID userId = execution.getUserId(); + String title = "오매~ 성공했어유"; + String body = "보러 오세요^^"; + + try { + AnonymousUser user = userUseCase.findByIdOrThrow(userId); + + Message message = Message.builder() + .putData("planId", String.valueOf(planId)) + .setToken(user.getFcmToken()) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body).build()) + .build(); + + String response = firebaseMessaging.send(message); + execution.completeDeliver(); + + System.out.println("Successfully sent message: " + response); + } catch (Exception e) { + log.error("FCM 메시지 전송 중 에러 발생: {}", e.getMessage()); + // todo 디스코드 알림 전송 + } + } + + public void send(String token) { + + Message message = Message.builder() + .setToken(token) + .setNotification(Notification.builder() + .setTitle("안녕하세유") + .setBody("음음...").build()) + .build(); + + try { + log.info("메시지 전송을 시작합니다."); + String response = firebaseMessaging.send(message); + log.info("메시지 전송이 끝났습니다: {}", response); + + } catch (FirebaseMessagingException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanServiceV2.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanServiceV2.java new file mode 100644 index 0000000..eda9905 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanServiceV2.java @@ -0,0 +1,233 @@ +package com.example.mohago_nocar.plan.application.v2; + +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import com.example.mohago_nocar.global.common.exception.InvalidValueException; +import com.example.mohago_nocar.plan.application.v2.response.BatchInfoDto; +import com.example.mohago_nocar.plan.application.v2.response.GetTravelCoursePlanResponseDto; +import com.example.mohago_nocar.plan.application.v2.response.PlanTravelCourseResponseDtoV2; +import com.example.mohago_nocar.plan.domain.service.PlanTravelCourseUsecaseRequestDto; +import com.example.mohago_nocar.plan.infrastructure.queue.PlanEvent; +import com.example.mohago_nocar.plan.infrastructure.queue.producer.PlanEventProducer; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchLauncher; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserUseCase; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.service.PlaceUseCase; +import com.example.mohago_nocar.plan.application.v1.response.TravelRouteResponseDto; +import com.example.mohago_nocar.plan.application.v1.strategy.RouteOptimizationStrategy; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.plan.domain.model.TravelCourseInPlan; +import com.example.mohago_nocar.plan.domain.repository.TravelCoursePlanRepository; +import com.example.mohago_nocar.plan.domain.service.TravelCoursePlanUseCaseV2; +import com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode; +import com.example.mohago_nocar.plan.presentation.v2.GetTravelPlanRequestDto; +import com.example.mohago_nocar.plan.presentation.v2.AsyncPlanTravelCourseRequestDto; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecution; +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchUseCase; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode.TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD; + +@Service +@Slf4j +@RequiredArgsConstructor +public class TravelCoursePlanServiceV2 implements TravelCoursePlanUseCaseV2 { + + private final PlaceUseCase placeUseCase; + private final FestivalUseCase festivalUseCase; + private final TransitRouteBatchUseCase transitRouteBatchUseCase; + private final UserUseCase userUseCase; + + private final ExecutorService virtualThreadExecutor; + private final DistanceDurationApiAdapter distanceDurationApiAdapter; + private final RouteOptimizationStrategy routeOptimizationStrategy; + private final PlanEventProducer planEventProducer; + + private final TransitRouteBatchLauncher transitRouteBatchLauncher; + private final TravelCoursePlanRepository travelCoursePlanRepository; + + @Override + public PlanTravelCourseResponseDtoV2 doAsyncPlan(AsyncPlanTravelCourseRequestDto request) { + // 플랜 객체 생성 + AnonymousUser user = userUseCase.getOrCreate(request.fcmToken()); + TravelCourseInPlan coursePlan = TravelCourseInPlan.create(user); + TravelCourseInPlan plan = travelCoursePlanRepository.save(coursePlan); + + // 플랜 이벤트 큐에 발행 + PlanEvent planEvent = PlanEvent.of(plan.getId(), user.getId(), request.festivalId(), request.placeIds(), request.travelDate()); + planEventProducer.produce(planEvent); + + // 반환 + return PlanTravelCourseResponseDtoV2.builder() + .userId(user.getId().toString()) + .planId(plan.getId()).build(); + } + + @Override + public void doPlan(PlanTravelCourseUsecaseRequestDto request) { + Festival festival = validateAndGetFestival(request.festivalId(), request.travelDate()); + List attractions = getAttractions(festival, request.placeIds()); + + Map> namesByCoordinate = mergeFestivalAndAttractionName(festival, attractions); + + List optimalRouteCoordinates = findOptimalRoute(namesByCoordinate); + List optimalRouteLocations = mapCoordinatesToLocations(namesByCoordinate, optimalRouteCoordinates); + + TransitRouteBatchExecution batchExecution = transitRouteBatchUseCase.createAndSaveExecution( + optimalRouteLocations.size(), request.userId(), request.planId()); + + transitRouteBatchLauncher.launch(batchExecution, optimalRouteLocations); + } + + @Override + public GetTravelCoursePlanResponseDto get(GetTravelPlanRequestDto request) { + // fetch plan from database + Optional optionalPlan = travelCoursePlanRepository.findById(request.planId()); + TravelCourseInPlan plan = optionalPlan.orElseThrow(() -> new CustomException(GlobalStatus.ENTITY_NOT_FOUND)); + + // dto mapping + List routes = plan.getTransitRoutes().stream() + .map(TravelRouteResponseDto::of) // todo dto 이름 변경 + .toList(); + + return GetTravelCoursePlanResponseDto.of(routes); + } + + + public BatchInfoDto getBatchInfo(String batchId) { + TransitRouteBatchExecution batchExecution = transitRouteBatchUseCase.getById(batchId); + if (batchExecution == null) throw new CustomException(PlanErrorCode.BATCH_TASK_NOT_FOUND); + return BatchInfoDto.from(batchExecution); + } + + private List mapCoordinatesToLocations( + Map> namesByCoordinate, + List coordinates + ) { + return coordinates.stream() + .flatMap(coordinate -> { + List names = namesByCoordinate.get(coordinate); + return names.stream() + .map(name -> Location.of(name, coordinate)); + }).toList(); + } + + private List collectCoordinate(Map> namesByCoordinate) { + return namesByCoordinate.keySet().stream().toList(); + } + + private List createDestination(List coordinates, int excludeIndex) { + return IntStream.range(0, coordinates.size()) + .filter(index -> index != excludeIndex) + .mapToObj(coordinates::get) + .toList(); + } + + private Future> distanceDurationApiCall(List coordinates, int index) { + return virtualThreadExecutor.submit(() -> { + Coordinate origin = coordinates.get(index); + List destinations = createDestination(coordinates, index); + + return distanceDurationApiAdapter.getDistanceAndDuration(origin, destinations); + }); + } + + /** + * 좌표 간의 거리(km), 이동 시간(minutes)를 가져오는 외부 API를 호출하여 응답을 생성합니다. + * @param coordinates 거리, 이동 시간을 구하는 대상 좌표 + * @return 좌표 간의 거리 및 이동시간 + */ + private List fetchDistanceAndDurations(List coordinates) { + var futures = IntStream.range(0, coordinates.size()) + .mapToObj(index -> distanceDurationApiCall(coordinates, index)) + .toList(); + + return futures.stream() + .map(this::awaitFutureResult) + .flatMap(Collection::stream) + .toList(); + } + + private List findOptimalRoute(Map> namesByCoordinate) { + var coordinates = collectCoordinate(namesByCoordinate); + var routeMetrics = fetchDistanceAndDurations(coordinates); + + return routeOptimizationStrategy.calculateOptimalRoute(coordinates, routeMetrics); + } + + + private Festival validateAndGetFestival(Long festivalId, LocalDate travelDate) { + var festival = festivalUseCase.getFestival(festivalId); + if (!festival.isDateDuringFestival(travelDate)) { + throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); + } + return festival; + } + + private List getAttractions(Festival festival, List placeIds) { + List places = placeUseCase.getFestivalNearPlacesById(festival.getId(), placeIds); + if (places.isEmpty()) { + places = placeUseCase.cachePlaces(festival.getId(), festival.getCoordinate()).stream() + .filter(place -> placeIds.contains(place.getKakaoId())) + .toList(); + } + return places; + } + + private Map> mergeFestivalAndAttractionName(Festival festival, List attractions) { + var festivalNameByCoordinate = mapFestivalNameToCoordinate(festival); + var placeNamesByCoordinate = mapPlaceNamesToCoordinate(attractions); + + return mergeNameMaps(festivalNameByCoordinate, placeNamesByCoordinate); + } + + private Map mapFestivalNameToCoordinate(Festival festival) { + return Map.of(festival.getCoordinate(), festival.getName()); + } + + private Map> mapPlaceNamesToCoordinate(List attractions) { + return attractions.stream() + .collect(Collectors.groupingBy( + Place::getCoordinate, Collectors.mapping(Place::getName, Collectors.toList()) + )); + } + + private Map> mergeNameMaps(Map festivalNameByCoordinate, Map> placeNamesByCoordinate) { + festivalNameByCoordinate.forEach((coordinate, festivalName) -> { + placeNamesByCoordinate.merge( + coordinate, + List.of(festivalName), + (existingNames, newNames) -> { + existingNames.addAll(newNames); + return existingNames; + }); + }); + + return placeNamesByCoordinate; + } + + private List awaitFutureResult(Future> future) { + try { + return future.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/response/BatchInfoDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/response/BatchInfoDto.java new file mode 100644 index 0000000..e54943a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v2/response/BatchInfoDto.java @@ -0,0 +1,31 @@ +package com.example.mohago_nocar.plan.application.v2.response; + +import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecution; +import lombok.Builder; + +@Builder +public record BatchInfoDto( + String batchId, + String userId, + String status, + Integer totalCount, + Integer completedCount, + Long createdAt, + Long completedAt, + String errorMessage +) { + + public static BatchInfoDto from(TransitRouteBatchExecution batch) { + return BatchInfoDto.builder() + .batchId(batch.getId()) + .userId(batch.getUserId().toString()) + .status(batch.getStatus().name()) + .totalCount(batch.getTotalCount()) + .completedCount(batch.getCompletedCount()) + .createdAt(batch.getCreatedAt()) + .completedAt(batch.getCompletedAt()) + .errorMessage(batch.getErrorMessage()) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/response/GetTravelCoursePlanResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/response/GetTravelCoursePlanResponseDto.java new file mode 100644 index 0000000..05eaa0f --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v2/response/GetTravelCoursePlanResponseDto.java @@ -0,0 +1,19 @@ +package com.example.mohago_nocar.plan.application.v2.response; + +import com.example.mohago_nocar.plan.application.v1.response.TravelRouteResponseDto; +import lombok.Builder; + +import java.util.List; + +@Builder +public record GetTravelCoursePlanResponseDto( + List travelRoutes +) { + + public static GetTravelCoursePlanResponseDto of(List travelRoutes) { + return GetTravelCoursePlanResponseDto.builder() + .travelRoutes(travelRoutes) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/response/PlanTravelCourseResponseDtoV2.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/response/PlanTravelCourseResponseDtoV2.java new file mode 100644 index 0000000..18a77c7 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v2/response/PlanTravelCourseResponseDtoV2.java @@ -0,0 +1,10 @@ +package com.example.mohago_nocar.plan.application.v2.response; + +import lombok.Builder; + +@Builder +public record PlanTravelCourseResponseDtoV2( + String userId, + Long planId +) { +} From 77e520b1f28092afd7c811a8297d721c96abb232 Mon Sep 17 00:00:00 2001 From: mungsil Date: Thu, 4 Dec 2025 14:30:24 +0900 Subject: [PATCH 76/84] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/TravelCoursePlanNotifyService.java | 108 -------- .../v2/TravelCoursePlanServiceV2.java | 233 ------------------ .../application/v2/response/BatchInfoDto.java | 31 --- .../GetTravelCoursePlanResponseDto.java | 19 -- .../PlanTravelCourseResponseDtoV2.java | 10 - .../route/batch/TransitRouteItemConsumer.java | 156 ------------ .../route/batch/TransitRouteItemProducer.java | 61 ----- 7 files changed, 618 deletions(-) delete mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanNotifyService.java delete mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanServiceV2.java delete mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/response/BatchInfoDto.java delete mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/response/GetTravelCoursePlanResponseDto.java delete mode 100644 src/main/java/com/example/mohago_nocar/plan/application/v2/response/PlanTravelCourseResponseDtoV2.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemConsumer.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemProducer.java diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanNotifyService.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanNotifyService.java deleted file mode 100644 index 9af9801..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanNotifyService.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.example.mohago_nocar.plan.application.v2; - -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchUseCase; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecution; -import com.example.mohago_nocar.user.domain.AnonymousUser; -import com.example.mohago_nocar.user.domain.UserUseCase; -import com.google.firebase.messaging.*; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.UUID; - -/** - * [알림 전송 상황] - * - plan 실패 시 - * - 대중교통 경로 조회 실패 시 - * - 대중교통 경로 조회 완료 시 - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class TravelCoursePlanNotifyService { - - private final FirebaseMessaging firebaseMessaging; - private final UserUseCase userUseCase; - private final TransitRouteBatchUseCase transitRouteBatchUseCase; - - public void sendNotification( - UUID userId, - String title, - String body, - Long planId - ) { - try { - AnonymousUser user = userUseCase.findByIdOrThrow(userId); - - Message message = Message.builder() - .putData("planId", String.valueOf(planId)) - .setToken(user.getFcmToken()) - .setNotification(Notification.builder() - .setTitle(title) - .setBody(body).build()) - .build(); - - log.info("메시지 전송을 시작합니다."); - String response = firebaseMessaging.send(message); - log.info("메시지 전송이 끝났습니다."); - System.out.println("Successfully sent message: " + response); - } catch (Exception e) { - log.error("FCM 메시지 전송 중 에러 발생: {}", e.getMessage()); - // todo 디스코드 알림 전송 - } - } - - public void sendSuccessNotification(String batchId) { - // 배치 조회 - TransitRouteBatchExecution execution = transitRouteBatchUseCase.getById(batchId); - if (execution.isAlreadyDelivered()) { - return; - } - - Long planId = execution.getPlanId(); - UUID userId = execution.getUserId(); - String title = "오매~ 성공했어유"; - String body = "보러 오세요^^"; - - try { - AnonymousUser user = userUseCase.findByIdOrThrow(userId); - - Message message = Message.builder() - .putData("planId", String.valueOf(planId)) - .setToken(user.getFcmToken()) - .setNotification(Notification.builder() - .setTitle(title) - .setBody(body).build()) - .build(); - - String response = firebaseMessaging.send(message); - execution.completeDeliver(); - - System.out.println("Successfully sent message: " + response); - } catch (Exception e) { - log.error("FCM 메시지 전송 중 에러 발생: {}", e.getMessage()); - // todo 디스코드 알림 전송 - } - } - - public void send(String token) { - - Message message = Message.builder() - .setToken(token) - .setNotification(Notification.builder() - .setTitle("안녕하세유") - .setBody("음음...").build()) - .build(); - - try { - log.info("메시지 전송을 시작합니다."); - String response = firebaseMessaging.send(message); - log.info("메시지 전송이 끝났습니다: {}", response); - - } catch (FirebaseMessagingException e) { - throw new RuntimeException(e); - } - } - -} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanServiceV2.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanServiceV2.java deleted file mode 100644 index eda9905..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/application/v2/TravelCoursePlanServiceV2.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.example.mohago_nocar.plan.application.v2; - -import com.example.mohago_nocar.festival.domain.model.Festival; -import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; -import com.example.mohago_nocar.global.common.domain.vo.Coordinate; -import com.example.mohago_nocar.global.common.exception.CustomException; -import com.example.mohago_nocar.global.common.exception.GlobalStatus; -import com.example.mohago_nocar.global.common.exception.InvalidValueException; -import com.example.mohago_nocar.plan.application.v2.response.BatchInfoDto; -import com.example.mohago_nocar.plan.application.v2.response.GetTravelCoursePlanResponseDto; -import com.example.mohago_nocar.plan.application.v2.response.PlanTravelCourseResponseDtoV2; -import com.example.mohago_nocar.plan.domain.service.PlanTravelCourseUsecaseRequestDto; -import com.example.mohago_nocar.plan.infrastructure.queue.PlanEvent; -import com.example.mohago_nocar.plan.infrastructure.queue.producer.PlanEventProducer; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchLauncher; -import com.example.mohago_nocar.user.domain.AnonymousUser; -import com.example.mohago_nocar.user.domain.UserUseCase; -import com.example.mohago_nocar.place.domain.model.Place; -import com.example.mohago_nocar.place.domain.service.PlaceUseCase; -import com.example.mohago_nocar.plan.application.v1.response.TravelRouteResponseDto; -import com.example.mohago_nocar.plan.application.v1.strategy.RouteOptimizationStrategy; -import com.example.mohago_nocar.plan.domain.model.Location; -import com.example.mohago_nocar.plan.domain.model.TravelCourseInPlan; -import com.example.mohago_nocar.plan.domain.repository.TravelCoursePlanRepository; -import com.example.mohago_nocar.plan.domain.service.TravelCoursePlanUseCaseV2; -import com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode; -import com.example.mohago_nocar.plan.presentation.v2.GetTravelPlanRequestDto; -import com.example.mohago_nocar.plan.presentation.v2.AsyncPlanTravelCourseRequestDto; -import com.example.mohago_nocar.transit.domain.model.RouteMetrics; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecution; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchUseCase; -import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.time.LocalDate; -import java.util.*; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -import static com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode.TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD; - -@Service -@Slf4j -@RequiredArgsConstructor -public class TravelCoursePlanServiceV2 implements TravelCoursePlanUseCaseV2 { - - private final PlaceUseCase placeUseCase; - private final FestivalUseCase festivalUseCase; - private final TransitRouteBatchUseCase transitRouteBatchUseCase; - private final UserUseCase userUseCase; - - private final ExecutorService virtualThreadExecutor; - private final DistanceDurationApiAdapter distanceDurationApiAdapter; - private final RouteOptimizationStrategy routeOptimizationStrategy; - private final PlanEventProducer planEventProducer; - - private final TransitRouteBatchLauncher transitRouteBatchLauncher; - private final TravelCoursePlanRepository travelCoursePlanRepository; - - @Override - public PlanTravelCourseResponseDtoV2 doAsyncPlan(AsyncPlanTravelCourseRequestDto request) { - // 플랜 객체 생성 - AnonymousUser user = userUseCase.getOrCreate(request.fcmToken()); - TravelCourseInPlan coursePlan = TravelCourseInPlan.create(user); - TravelCourseInPlan plan = travelCoursePlanRepository.save(coursePlan); - - // 플랜 이벤트 큐에 발행 - PlanEvent planEvent = PlanEvent.of(plan.getId(), user.getId(), request.festivalId(), request.placeIds(), request.travelDate()); - planEventProducer.produce(planEvent); - - // 반환 - return PlanTravelCourseResponseDtoV2.builder() - .userId(user.getId().toString()) - .planId(plan.getId()).build(); - } - - @Override - public void doPlan(PlanTravelCourseUsecaseRequestDto request) { - Festival festival = validateAndGetFestival(request.festivalId(), request.travelDate()); - List attractions = getAttractions(festival, request.placeIds()); - - Map> namesByCoordinate = mergeFestivalAndAttractionName(festival, attractions); - - List optimalRouteCoordinates = findOptimalRoute(namesByCoordinate); - List optimalRouteLocations = mapCoordinatesToLocations(namesByCoordinate, optimalRouteCoordinates); - - TransitRouteBatchExecution batchExecution = transitRouteBatchUseCase.createAndSaveExecution( - optimalRouteLocations.size(), request.userId(), request.planId()); - - transitRouteBatchLauncher.launch(batchExecution, optimalRouteLocations); - } - - @Override - public GetTravelCoursePlanResponseDto get(GetTravelPlanRequestDto request) { - // fetch plan from database - Optional optionalPlan = travelCoursePlanRepository.findById(request.planId()); - TravelCourseInPlan plan = optionalPlan.orElseThrow(() -> new CustomException(GlobalStatus.ENTITY_NOT_FOUND)); - - // dto mapping - List routes = plan.getTransitRoutes().stream() - .map(TravelRouteResponseDto::of) // todo dto 이름 변경 - .toList(); - - return GetTravelCoursePlanResponseDto.of(routes); - } - - - public BatchInfoDto getBatchInfo(String batchId) { - TransitRouteBatchExecution batchExecution = transitRouteBatchUseCase.getById(batchId); - if (batchExecution == null) throw new CustomException(PlanErrorCode.BATCH_TASK_NOT_FOUND); - return BatchInfoDto.from(batchExecution); - } - - private List mapCoordinatesToLocations( - Map> namesByCoordinate, - List coordinates - ) { - return coordinates.stream() - .flatMap(coordinate -> { - List names = namesByCoordinate.get(coordinate); - return names.stream() - .map(name -> Location.of(name, coordinate)); - }).toList(); - } - - private List collectCoordinate(Map> namesByCoordinate) { - return namesByCoordinate.keySet().stream().toList(); - } - - private List createDestination(List coordinates, int excludeIndex) { - return IntStream.range(0, coordinates.size()) - .filter(index -> index != excludeIndex) - .mapToObj(coordinates::get) - .toList(); - } - - private Future> distanceDurationApiCall(List coordinates, int index) { - return virtualThreadExecutor.submit(() -> { - Coordinate origin = coordinates.get(index); - List destinations = createDestination(coordinates, index); - - return distanceDurationApiAdapter.getDistanceAndDuration(origin, destinations); - }); - } - - /** - * 좌표 간의 거리(km), 이동 시간(minutes)를 가져오는 외부 API를 호출하여 응답을 생성합니다. - * @param coordinates 거리, 이동 시간을 구하는 대상 좌표 - * @return 좌표 간의 거리 및 이동시간 - */ - private List fetchDistanceAndDurations(List coordinates) { - var futures = IntStream.range(0, coordinates.size()) - .mapToObj(index -> distanceDurationApiCall(coordinates, index)) - .toList(); - - return futures.stream() - .map(this::awaitFutureResult) - .flatMap(Collection::stream) - .toList(); - } - - private List findOptimalRoute(Map> namesByCoordinate) { - var coordinates = collectCoordinate(namesByCoordinate); - var routeMetrics = fetchDistanceAndDurations(coordinates); - - return routeOptimizationStrategy.calculateOptimalRoute(coordinates, routeMetrics); - } - - - private Festival validateAndGetFestival(Long festivalId, LocalDate travelDate) { - var festival = festivalUseCase.getFestival(festivalId); - if (!festival.isDateDuringFestival(travelDate)) { - throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); - } - return festival; - } - - private List getAttractions(Festival festival, List placeIds) { - List places = placeUseCase.getFestivalNearPlacesById(festival.getId(), placeIds); - if (places.isEmpty()) { - places = placeUseCase.cachePlaces(festival.getId(), festival.getCoordinate()).stream() - .filter(place -> placeIds.contains(place.getKakaoId())) - .toList(); - } - return places; - } - - private Map> mergeFestivalAndAttractionName(Festival festival, List attractions) { - var festivalNameByCoordinate = mapFestivalNameToCoordinate(festival); - var placeNamesByCoordinate = mapPlaceNamesToCoordinate(attractions); - - return mergeNameMaps(festivalNameByCoordinate, placeNamesByCoordinate); - } - - private Map mapFestivalNameToCoordinate(Festival festival) { - return Map.of(festival.getCoordinate(), festival.getName()); - } - - private Map> mapPlaceNamesToCoordinate(List attractions) { - return attractions.stream() - .collect(Collectors.groupingBy( - Place::getCoordinate, Collectors.mapping(Place::getName, Collectors.toList()) - )); - } - - private Map> mergeNameMaps(Map festivalNameByCoordinate, Map> placeNamesByCoordinate) { - festivalNameByCoordinate.forEach((coordinate, festivalName) -> { - placeNamesByCoordinate.merge( - coordinate, - List.of(festivalName), - (existingNames, newNames) -> { - existingNames.addAll(newNames); - return existingNames; - }); - }); - - return placeNamesByCoordinate; - } - - private List awaitFutureResult(Future> future) { - try { - return future.get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - } - -} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/response/BatchInfoDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/response/BatchInfoDto.java deleted file mode 100644 index e54943a..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/application/v2/response/BatchInfoDto.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.mohago_nocar.plan.application.v2.response; - -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteBatchExecution; -import lombok.Builder; - -@Builder -public record BatchInfoDto( - String batchId, - String userId, - String status, - Integer totalCount, - Integer completedCount, - Long createdAt, - Long completedAt, - String errorMessage -) { - - public static BatchInfoDto from(TransitRouteBatchExecution batch) { - return BatchInfoDto.builder() - .batchId(batch.getId()) - .userId(batch.getUserId().toString()) - .status(batch.getStatus().name()) - .totalCount(batch.getTotalCount()) - .completedCount(batch.getCompletedCount()) - .createdAt(batch.getCreatedAt()) - .completedAt(batch.getCompletedAt()) - .errorMessage(batch.getErrorMessage()) - .build(); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/response/GetTravelCoursePlanResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/response/GetTravelCoursePlanResponseDto.java deleted file mode 100644 index 05eaa0f..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/application/v2/response/GetTravelCoursePlanResponseDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.mohago_nocar.plan.application.v2.response; - -import com.example.mohago_nocar.plan.application.v1.response.TravelRouteResponseDto; -import lombok.Builder; - -import java.util.List; - -@Builder -public record GetTravelCoursePlanResponseDto( - List travelRoutes -) { - - public static GetTravelCoursePlanResponseDto of(List travelRoutes) { - return GetTravelCoursePlanResponseDto.builder() - .travelRoutes(travelRoutes) - .build(); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v2/response/PlanTravelCourseResponseDtoV2.java b/src/main/java/com/example/mohago_nocar/plan/application/v2/response/PlanTravelCourseResponseDtoV2.java deleted file mode 100644 index 18a77c7..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/application/v2/response/PlanTravelCourseResponseDtoV2.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.example.mohago_nocar.plan.application.v2.response; - -import lombok.Builder; - -@Builder -public record PlanTravelCourseResponseDtoV2( - String userId, - Long planId -) { -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemConsumer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemConsumer.java deleted file mode 100644 index 7724265..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemConsumer.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.route.batch; - -import com.example.mohago_nocar.transit.domain.model.OdsayApiRequest; -import com.example.mohago_nocar.transit.domain.model.TransitRoute; -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; -import io.github.resilience4j.ratelimiter.RateLimiter; -import io.github.resilience4j.ratelimiter.RateLimiterConfig; -import io.github.resilience4j.ratelimiter.RateLimiterRegistry; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.connection.stream.*; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.StreamOperations; -import org.springframework.data.redis.hash.Jackson2HashMapper; -import org.springframework.scheduling.annotation.Scheduled; - -import java.time.Duration; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.PriorityBlockingQueue; - -/** - * 아이템을 폴링하여 내부적인 큐로 재전송합니다. - */ -@Slf4j -@RequiredArgsConstructor -@Getter -public class TransitRouteItemConsumer { - - private final RedisTemplate redisTemplateWithObj; - private final TransitRouteApiAdapter transitRouteApiAdapter; - - private static final String STREAM_KEY = "odsay-api-request"; - private static final String CONSUMER_GROUP = "odsay-api-consumer"; - private static final String CONSUMER_NAME = "consumer-1"; - - private final PriorityBlockingQueue> queue = new PriorityBlockingQueue<>(); - private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); - - private RateLimiter rateLimiter; - - @PostConstruct - public void init() { - log.info("Initializing TransitRouteItemConsumer"); - // 소비자 그룹 생성 - try { - redisTemplateWithObj - .opsForStream() - .createGroup(STREAM_KEY, ReadOffset.lastConsumed(), CONSUMER_GROUP); - log.info("Consumer group {} created", CONSUMER_GROUP); - } catch (Exception e) { - log.error("Error initializing TransitRouteItemConsumer", e); - log.info("Consumer group {} already exists or error creating: {}", CONSUMER_GROUP, e.getMessage()); - } - - // 속도 제한 설정 - rateLimiter = initializeRateLimiter().rateLimiter("o"); - -// startWorker(); - } - - /** - * 1.retry = 재시도 큐 확인 > 일정 시간 텀 두기 - * 2.req = 일반 큐 확인 - * - * consume target = retry == null ? req : retry - * consume target.async consume - * : api call - * : if exception occur, put retry queue - * : if retry permit num exceeds, set fail to batch - * - * batchTaskChecker - * : 폴링을 하면서 완료된 배치 잡과 실패한 배치 잡 체크, 알림 전송 - */ - - - /** - * RedisStream에서 메시지를 읽어 큐에 추가합니다. - */ - @Scheduled(fixedDelay = 1000) - public void consume() { - log.info("Consumer group {} consume started", CONSUMER_GROUP); - StreamOperations stringStringObjectStreamOperations = redisTemplateWithObj.opsForStream(new Jackson2HashMapper(true)); - List> records = stringStringObjectStreamOperations - .read( - OdsayApiRequest.class, - Consumer.from(CONSUMER_GROUP, CONSUMER_NAME), - StreamReadOptions.empty() - .count(50) - .block(Duration.ofSeconds(3)), - StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed()) // 해당 라인 필요함? - ); - - if (records != null && !records.isEmpty()) { - log.info("Consumer group {} consumed {} records", CONSUMER_GROUP, records.size()); - records.stream() - .map(obj -> obj.getValue()) - .forEach(System.out::println); - - queue.addAll(records); - } - } - - - public void startWorker() { - Thread thread = new Thread(() -> { - while (!Thread.interrupted()) { - try { - // 재시도 큐에 메시지가 존재하면 그걸 꺼내오면 됨 - ObjectRecord take = queue.take(); - rateLimiter.executeSupplier(() -> - executor.submit(() -> { - // api 요청 - OdsayApiRequest request = take.getValue(); - TransitRoute transitRoute = transitRouteApiAdapter.getTransitRouteBetweenLocations( - request.getOrigin(), request.getDestination()); - // 응답 저장 - // batchId = [응답 1, 응답 2, 응답 3, 응답 4] - // seq: obj를 저장한다음, 배치 작업이 완료되면 seq대로 정렬 후 obj를 꺼내는... - // batch_id_1 : {{seq: 0, content: class}} - // batch 메타 데이터 업데이트: 완료 처리 - // ack 처리 - redisTemplateWithObj.opsForStream().acknowledge(CONSUMER_GROUP, take); - })); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } - }); - } - - private RateLimiterRegistry initializeRateLimiter() { - RateLimiterConfig config = RateLimiterConfig.custom() - .limitRefreshPeriod(Duration.ofMillis(200)) - .limitForPeriod(1) - .timeoutDuration(Duration.ofSeconds(20)) - .build(); - - return RateLimiterRegistry.of(config); - } - - public void consume2() { - - } - - @PreDestroy - public void destroy() { - log.info("Destroying TransitRouteItemConsumer"); - // pending list에 있는거 dlq로 보내기? - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemProducer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemProducer.java deleted file mode 100644 index 06c4b60..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/batch/TransitRouteItemProducer.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.route.batch; - -import com.example.mohago_nocar.plan.domain.model.Location; -import com.example.mohago_nocar.transit.domain.model.OdsayApiRequest; -import org.springframework.data.redis.connection.stream.ObjectRecord; -import org.springframework.data.redis.connection.stream.StreamRecords; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.hash.Jackson2HashMapper; - -import java.util.ArrayList; -import java.util.List; - -public class TransitRouteItemProducer { - - public static final String ODSAY_API_REQUEST_STREAM_NAME = "odsay-api-request"; - - private final RedisTemplate redisTemplateWithObj; - - public TransitRouteItemProducer(RedisTemplate redisTemplateWithObj) { - this.redisTemplateWithObj = redisTemplateWithObj; - } - - public void produce(String batchId, List locations) { - validateLocations(locations); - List items = createItems(batchId, locations); - submit(items); - } - - private void submit(List items) { - for (OdsayApiRequest item : items) { - ObjectRecord apiReq = StreamRecords.newRecord() - .in(ODSAY_API_REQUEST_STREAM_NAME) - .ofObject(item); - - redisTemplateWithObj - .opsForStream(new Jackson2HashMapper(true)) - .add(apiReq); - } - } - - private List createItems(String batchId, List locations) { - List odsayApiRequests = new ArrayList<>(); - - for (int i = 1; i < locations.size(); i++) { - Location origin = locations.get(i-1); - Location destination = locations.get(i); - - OdsayApiRequest apiRequest = OdsayApiRequest.of(batchId, origin, destination, i); - odsayApiRequests.add(apiRequest); - } - - return odsayApiRequests; - } - - private void validateLocations(List locations) { - if (locations == null || locations.size() < 2) { - throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); - } - } - -} From 54cadb3da3fa2953afeb894dff30d10351c0afe9 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 8 Dec 2025 23:06:41 +0900 Subject: [PATCH 77/84] =?UTF-8?q?archive:=20dead=20letter=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/DeadMessageProcessor.java | 56 +++++++++ .../stream/LongPendingMessageHandler.java | 32 +++++ .../stream/LongPendingMessageReader.java | 39 ++++++ .../global/messaging/DLQStatus.java | 18 +++ .../messaging/DeadLetterQueueEntry.java | 119 ++++++++++++++++++ .../DeadLetterQueueEntryRepository.java | 47 +++++++ .../messaging/DeadLetterQueueService.java | 91 ++++++++++++++ .../global/messaging/RedisStreamHelper.java | 52 ++++++++ 8 files changed, 454 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessor.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageHandler.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageReader.java create mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/DLQStatus.java create mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java create mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java create mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/RedisStreamHelper.java diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessor.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessor.java new file mode 100644 index 0000000..eaedbea --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessor.java @@ -0,0 +1,56 @@ +package com.example.mohago_nocar.course.infrastructure.stream; + +import com.example.mohago_nocar.global.messaging.DeadLetterQueueService; +import com.example.mohago_nocar.global.messaging.RedisStreamHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.PendingMessage; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Slf4j +public class DeadMessageProcessor { + + private final RedisStreamHelper redisStreamHelper; + private final DeadLetterQueueService deadLetterQueueService; + + private final String streamKey; + private final String consumerGroup; + + @Transactional + public void process(List deadMessages) { + log.warn("Processing dead messages: {}", deadMessages); + List dtos = deadMessages.stream() + .map(pm -> { + MapRecord readMessage = redisStreamHelper + .readMessage(streamKey, pm.getId()); + + return DeadLetterQueueEntryDto.from(streamKey, pm, readMessage.getValue().toString()); + }) + .toList(); + + deadLetterQueueService.saveAll(dtos); + + RecordId[] recordIds = deadMessages.stream() + .map(PendingMessage::getId) + .toArray(RecordId[]::new); + + try { + redisStreamHelper.acknowledgeAndDelete(streamKey, consumerGroup, recordIds); + } catch (RedisSystemException e) { + log.error("Failed to acknowledge messages By RedisSystemException : {}", e.getMessage(), e); + throw e; + } catch (Exception e) { + log.error("Failed to acknowledge messages By Exception : {}", e.getMessage(), e); + throw e; + } + + log.debug("Successfully processed dead letter messages, size: {}", deadMessages.size()); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageHandler.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageHandler.java new file mode 100644 index 0000000..4327f35 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageHandler.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.course.infrastructure.stream; + +import com.example.mohago_nocar.global.notification.application.developer.DeveloperNotificationUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.scheduling.annotation.Scheduled; + +import java.util.*; + +@RequiredArgsConstructor +@Slf4j +public class LongPendingMessageHandler { + + private final LongPendingMessageReader longPendingMessageReader; + private final DeadMessageProcessor deadMessageProcessor; + private final DeveloperNotificationUseCase developerNotificationUseCase; + + private static final int FIXED_RATE_IN_MILS = 60_000; // 1분 + + @Scheduled(fixedRate = FIXED_RATE_IN_MILS) + public void process() { + try { + List pendingMessages = longPendingMessageReader.read(); + deadMessageProcessor.process(pendingMessages); // Long pending 시 dead Message 로 간주 + } catch (Exception e) { + developerNotificationUseCase.sendNotification( + "Exception occurred while processing long pending messages", e); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageReader.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageReader.java new file mode 100644 index 0000000..1bd5aa7 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageReader.java @@ -0,0 +1,39 @@ +package com.example.mohago_nocar.course.infrastructure.stream; + +import com.example.mohago_nocar.global.messaging.RedisStreamHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.stream.PendingMessage; +import org.springframework.data.redis.connection.stream.PendingMessages; + +import java.util.List; + +@RequiredArgsConstructor +@Slf4j +public class LongPendingMessageReader { + + private final String streamKeyName; + private final String consumerGroupName; + private final String consumerName; + private final int maxScanNum; + private final long idleTimeThresholdMs; + + private final RedisStreamHelper redisStreamHelper; + + public List read() { + log.debug("Starting long pending message scan..."); + PendingMessages pendingMessages = redisStreamHelper.getPendingMessages( + streamKeyName, consumerGroupName, consumerName, maxScanNum, Range.unbounded()); + + if (pendingMessages.isEmpty()) { + log.debug("No pending messages found"); + return List.of(); + } + + return pendingMessages.stream() + .filter(msg -> msg.getElapsedTimeSinceLastDelivery().toMillis() > idleTimeThresholdMs) + .toList(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DLQStatus.java b/src/main/java/com/example/mohago_nocar/global/messaging/DLQStatus.java new file mode 100644 index 0000000..c4e27fb --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DLQStatus.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.global.messaging; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DLQStatus { + + NEW("새로운 엔트리", "즉시 확인 필요"), + POSSESSING("처리 중", "원인 분석 및 해결 중"), + RESOLVED("해결 완료", "정상 처리 완료"), + IGNORED("무시", "처리 불필요로 판단"); + + private final String displayName; + private final String description; + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java new file mode 100644 index 0000000..975174e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java @@ -0,0 +1,119 @@ +package com.example.mohago_nocar.global.messaging; + +import com.example.mohago_nocar.course.infrastructure.stream.DeadLetterQueueEntryDto; +import com.example.mohago_nocar.global.common.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.Comment; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "dead_letter_queue") +@Getter +@EqualsAndHashCode +@ToString +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DeadLetterQueueEntry extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Comment("DLQ 엔트리 ID") + private Long id; + + @Column(nullable = false, length = 100) + @Comment("Redis Stream 키") + private String streamKey; + + @Column(nullable = false, length = 100) + @Comment("컨슈머 그룹 이름") + private String consumerGroup; + + @Column(nullable = false, length = 100) + @Comment("Stream entry ID") + private String entryId; + + @Column(columnDefinition = "TEXT") + @Comment("메시지 페이로드 (JSON)") + private String payload; + + @Comment("예외 타입") + private String exceptionType; + + @Column(columnDefinition = "TEXT") + @Comment("에러 메시지") + private String errorMessage; + + @Column(columnDefinition = "TEXT") + @Comment("스택 트레이스") + private String stackTrace; + + @Comment("전달 시도 횟수") + private Long deliveryCount; + + @Column(length = 100) + @Comment("마지막 처리 컨슈머") + private String lastConsumer; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 200) + @Comment("처리 상태") + private DLQStatus status; + + @Column + @Comment("해결 완료 시간") + private LocalDateTime resolvedAt; + + @Builder(access = AccessLevel.PRIVATE) + private DeadLetterQueueEntry(String streamKey, String consumerGroup, String entryId, + String payload, String exceptionType, String errorMessage, + String stackTrace, Long deliveryCount, String lastConsumer, + DLQStatus status, LocalDateTime resolvedAt) { + this.streamKey = streamKey; + this.consumerGroup = consumerGroup; + this.entryId = entryId; + this.payload = payload; + this.exceptionType = exceptionType; + this.errorMessage = errorMessage; + this.stackTrace = stackTrace; + this.deliveryCount = deliveryCount; + this.lastConsumer = lastConsumer; + this.status = status; + this.resolvedAt = resolvedAt; + } + + public static DeadLetterQueueEntry create(DeadLetterQueueEntryDto dto) { + DeadLetterQueueEntryBuilder builder = DeadLetterQueueEntry.builder() + .streamKey(dto.getStreamName()) + .consumerGroup(dto.getGroupName()) + .entryId(dto.getId()) + .payload(dto.getPayload()) + .deliveryCount(dto.getDeliveryCount()) + .lastConsumer(dto.getConsumerName()) + .status(DLQStatus.NEW); + + if (dto.getThrowable() != null) { + Throwable throwable = dto.getThrowable(); + return builder.errorMessage(throwable.getMessage()) + .exceptionType(throwable.getClass().getSimpleName()) + .stackTrace(throwable.getStackTrace().toString()) + .build(); + } else { + return builder.build(); + } + + } + + public void changeStatus(DLQStatus newStatus) { + this.status = newStatus; + + if (newStatus == DLQStatus.RESOLVED) { + this.resolvedAt = LocalDateTime.now(); + } + } + + public void syncUpdateTime(LocalDateTime now) { + this.setUpdatedAt(now); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryRepository.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryRepository.java new file mode 100644 index 0000000..bf691eb --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryRepository.java @@ -0,0 +1,47 @@ +package com.example.mohago_nocar.global.messaging; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface DeadLetterQueueEntryRepository extends JpaRepository { + + List findByStatus(DLQStatus status); + + long countByStatus(DLQStatus status); + + /** + * 예외 타입별 통계 + */ + @Query("SELECT d.exceptionType, COUNT(d) FROM DeadLetterQueueEntry d " + + "GROUP BY d.exceptionType ORDER BY COUNT(d) DESC") + List countByExceptionType(); + + /** + * 특정 Stream의 엔트리 조회 + */ + List findByStreamKeyAndStatus( + String streamKey, + DLQStatus status + ); + + /** + * 오래된 NEW 상태 엔트리 (알림용) + */ + @Query("SELECT d FROM DeadLetterQueueEntry d " + + "WHERE d.status = 'NEW' " + + "AND d.createdAt < :threshold " + + "ORDER BY d.createdAt ASC") + List findOldNewEntries(@Param("threshold") LocalDateTime threshold); + + Optional findByEntryId(String entryId); + + List findAllByEntryIdIn(List entryIds); + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java new file mode 100644 index 0000000..ba69752 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java @@ -0,0 +1,91 @@ +package com.example.mohago_nocar.global.messaging; + +import com.example.mohago_nocar.course.infrastructure.stream.DeadLetterQueueEntryDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +@Slf4j +public class DeadLetterQueueService { + + private final DeadLetterQueueEntryRepository dlqRepository; + + @Transactional + public void save(DeadLetterQueueEntryDto dto) { + dlqRepository.save(DeadLetterQueueEntry.create(dto)); + } + + /** + * 주어진 DTO 리스트를 기반으로 {@link DeadLetterQueueEntry} 목록을 저장합니다. + * + *

    저장 동작은 다음 기준에 따라 처리됩니다:

    + *
      + *
    • 동일한 {@code recordId}의 엔트리가 이미 존재하는 경우 + * → 내용이 동일하면 업데이트 시각만 갱신하고, 다르면 새로운 엔트리를 추가로 저장합니다. + *
    • + *
    • {@code recordId}가 존재하지 않는 경우 + * → 새로운 엔트리를 생성하여 저장합니다. + *
    • + *
    + * + * @param newDeadEntries 재시도 불가능한 예외로 인해 처리되지 못한 엔트리를 나타내는 DTO 리스트 + * @return 저장된 {@link DeadLetterQueueEntry} 리스트 + */ + @Transactional + public List saveAll(List newDeadEntries) { + List entryIds = newDeadEntries.stream() + .map(DeadLetterQueueEntryDto::getId) + .toList(); + + Map existedMap = dlqRepository.findAllByEntryIdIn(entryIds).stream() + .collect(Collectors.toMap( + DeadLetterQueueEntry::getEntryId, + Function.identity() + )); + + List entries = newDeadEntries.stream() + .map(DeadLetterQueueEntry::create) + .map(newEntry -> { + String entryId = newEntry.getEntryId(); + DeadLetterQueueEntry existingEntry = existedMap.get(entryId); + if (existingEntry != null) { + existingEntry.syncUpdateTime(LocalDateTime.now()); + return existingEntry; + }else { + return newEntry; + } + }).toList(); + + log.info("Saving dead letter queue entries"); + log.info("Dead letter queue entries: {}", entries); + + return dlqRepository.saveAll(entries); + } + + @Transactional(readOnly = true) + public List findByStatus(DLQStatus status) { + return dlqRepository.findByStatus(status); + } + + @Transactional + public void changeStatus(Long entryId, DLQStatus newStatus) { + DeadLetterQueueEntry entry = dlqRepository.findById(entryId) + .orElseThrow(() -> new IllegalArgumentException("DLQ entry not found: " + entryId)); + + entry.changeStatus(newStatus); + dlqRepository.save(entry); + + log.info("DLQ entry status changed: id={}, status={}", + entryId, newStatus); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/RedisStreamHelper.java b/src/main/java/com/example/mohago_nocar/global/messaging/RedisStreamHelper.java new file mode 100644 index 0000000..8e81976 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/RedisStreamHelper.java @@ -0,0 +1,52 @@ +package com.example.mohago_nocar.global.messaging; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class RedisStreamHelper { + + private final StringRedisTemplate redisTemplate; + + public RecordId addNewObjectRecord(String streamKey, String payload) { + ObjectRecord record = StreamRecords.newRecord().in(streamKey).ofObject(payload); + return redisTemplate.opsForStream().add(record); + } + + public PendingMessages getPendingMessages( + String streamKeyName, String consumerGroupName, String consumerName, int maxScanNum, Range range) { + return redisTemplate.opsForStream() + .pending( + streamKeyName, + Consumer.from(consumerGroupName, consumerName), + range, + maxScanNum); + } + + public void acknowledgeAndDelete(String streamKeyName, String consumerGroupName, RecordId[] recordIds) { + redisTemplate.opsForStream().acknowledge(streamKeyName, consumerGroupName, recordIds); + redisTemplate.opsForStream().delete(streamKeyName, recordIds); + } + + public void acknowledgeAndDelete(String streamKeyName, String consumerGroupName, RecordId recordId) { + redisTemplate.opsForStream().acknowledge(streamKeyName, consumerGroupName, recordId); + redisTemplate.opsForStream().delete(streamKeyName, recordId); + } + + public MapRecord readMessage(String streamKeyName, RecordId recordId) { + // 결과: MapBackedRecord{recordId=1761874798218-0, kvMap={user_name=k}} + List> range = redisTemplate.opsForStream() + .range(streamKeyName, Range.just(recordId.getValue())); + + return range.stream() + .findFirst() + .orElse(null); + } + +} \ No newline at end of file From e8eb947d4ff4ce80fd358ed11fdd8d39057cec26 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 8 Dec 2025 23:07:09 +0900 Subject: [PATCH 78/84] =?UTF-8?q?archive:=20dead=20letter=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EC=98=88=EC=99=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messaging/DeadLetterIdleTimeoutException.java | 10 ++++++++++ .../messaging/DeadLetterProcessingException.java | 9 +++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterIdleTimeoutException.java create mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterProcessingException.java diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterIdleTimeoutException.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterIdleTimeoutException.java new file mode 100644 index 0000000..2397832 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterIdleTimeoutException.java @@ -0,0 +1,10 @@ + +package com.example.mohago_nocar.global.messaging; + +public class DeadLetterIdleTimeoutException extends RuntimeException { + + public DeadLetterIdleTimeoutException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterProcessingException.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterProcessingException.java new file mode 100644 index 0000000..c54607f --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterProcessingException.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.global.messaging; + +public class DeadLetterProcessingException extends RuntimeException { + + public DeadLetterProcessingException(String message) { + super(message); + } + +} From d8500ef6cc2777889d420c16110ce06dd296eb3f Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 22 Dec 2025 15:20:57 +0900 Subject: [PATCH 79/84] =?UTF-8?q?feat:=20push=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EB=A1=9C=EC=9D=98=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20API=20=ED=98=B8=EC=B6=9C=20=EC=86=8D=EB=8F=84=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=EC=84=B1=20=ED=96=A5=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 낮은 처리량의 외부 API를 호출하는 여행 코스 이동 경로 안내 API의 처리량 및 응답 속도 저하 문제 발생 - push 모델로 전환하여 UX 저하 방지 - 외부 API 호출 속도를 제한하여 429 에러 발생 방지 --- build.gradle | 16 +- .../mohago_nocar/MohagoNocarApplication.java | 2 + .../course/application/CourseErrorCode.java | 20 ++ .../TravelCourseCompletionNotifier.java | 64 +++++ .../course/TravelCourseConverter.java | 68 +++++ .../TravelCourseOptimizedEventHandler.java | 50 ++++ .../course/TravelCourseService.java | 168 ++++++++++++ .../course/application/dto/BusSubPathDto.java | 17 ++ .../application/dto/EventHandleFailure.java | 18 ++ .../application/dto/EventHandleResult.java | 7 + .../application/dto/EventHandleSuccess.java | 21 ++ .../application/dto/GetRequesterInfoDto.java | 10 + .../course/application/dto/RouteStepDto.java | 33 +++ .../course/application/dto/SubPathDto.java | 4 + .../application/dto/SubwaySubPathDto.java | 16 ++ .../TravelCourseResultNotificationDto.java | 20 ++ .../application/dto/WalkSubPathDto.java | 12 + .../course/application/route/RouteFinder.java | 99 +++++++ .../application/route/RouteStepService.java | 55 ++++ .../application/spot/TravelSpotService.java | 180 +++++++++++++ .../event/ThrottlingCompletedEvent.java | 18 ++ .../event/TravelCourseOptimizedEvent.java | 27 ++ .../domain/model/course/CourseStatus.java | 16 ++ .../domain/model/course/TravelCourse.java | 35 ++- .../course/TravelCourseCompletionMessage.java | 28 ++ .../domain/model/routeStep/RouteStep.java | 83 +++--- .../domain/model/travelSpot/TravelSpot.java | 9 +- .../model/travelSpot/TravelSpotFestival.java | 2 +- .../model/travelSpot/TravelSpotPlace.java | 2 +- .../domain/repository/CourseRepository.java | 4 - .../repository/RouteStepRepository.java | 11 + .../repository/TravelCourseRepository.java | 21 ++ .../repository/TravelSpotRepository.java | 12 + .../domain/service/TravelCourseUseCase.java | 40 +++ .../domain/service/TravelSpotUseCase.java | 23 ++ .../course/CourseJpaRepository.java | 7 - .../course/CourseRepositoryImpl.java | 11 - .../messaging}/LongPendingMessageHandler.java | 13 +- .../messaging}/LongPendingMessageReader.java | 8 +- .../TravelCourseOptimizedEventPublisher.java | 9 + .../TravelCourseOptimizedMessageConsumer.java | 142 ++++++++++ .../TravelCourseOptimizedMessageProducer.java | 34 +++ ...avelCourseOptimizedMessageRetryPolicy.java | 47 ++++ .../TravelCourseOptimizedStreamConfig.java | 110 ++++++++ .../TravelCourseOptimizedStreamContainer.java | 115 +++++++++ ...lCourseOptimizedStreamRecoveryManager.java | 59 +++++ .../repository/TravelCourseJpaRepository.java | 33 +++ .../TravelCourseRepositoryImpl.java | 39 +++ .../RouteStepJpaRepository.java | 7 +- .../route/RouteStepRepositoryImpl.java | 32 +++ .../routeStep/RouteStepRepositoryImpl.java | 11 - .../spot/TravelSpotJpaRepository.java | 11 + .../spot/TravelSpotRepositoryImpl.java | 33 +++ .../stream/DeadMessageProcessor.java | 56 ---- .../travelSpot/TravelSpotJpaRepository.java | 7 - .../travelSpot/TravelSpotRepositoryImpl.java | 11 - .../presentation/TravelCourseController.java | 40 +++ ...imizedTravelCourseAcceptedResponseDto.java | 14 + .../dto/CreateTravelCourseRequestDto.java | 26 ++ .../GetOptimizedTravelCourseRequestDto.java | 13 + .../GetOptimizedTravelCourseResponseDto.java | 13 + .../festival/domain/model/Festival.java | 6 + .../festival/domain/model/FestivalImage.java | 2 + .../global/common/RetryPolicy.java | 7 + .../global/common/domain/BaseEntity.java | 5 + .../common/exception/CustomException.java | 1 + .../global/config/EmbeddedRedisConfig.java | 4 +- .../global/config/RedisConfig.java | 24 +- .../global/config/RestClientConfig.java | 39 --- .../global/config/TimeZoneConfig.java | 16 ++ .../DeadLetterIdleTimeoutException.java | 10 - .../DeadLetterProcessingException.java | 9 - .../messaging/DeadLetterQueueEntry.java | 11 +- .../messaging/DeadLetterQueueEntryDto.java | 48 ++++ .../messaging/DeadLetterQueueService.java | 6 +- .../NotificationMessagingException.java | 13 + .../DeveloperNotificationUseCase.java | 9 + .../DeveloperNotificationUseCaseImpl.java | 50 ++++ .../application/user/UserNotificationDto.java | 24 ++ .../user/UserNotificationService.java | 46 ++++ .../user/UserNotificationServiceImpl.java | 30 +++ .../discord/DiscordMessage.java | 20 ++ .../discord/DiscordMessageSender.java | 41 +++ .../infrastructure/fcm}/FcmConfig.java | 56 ++-- .../infrastructure/fcm/FcmMessage.java | 40 +++ .../infrastructure/fcm/FcmMessageSender.java | 33 +++ .../global/rateLimit/IntervalRateLimiter.java | 50 ++++ .../global/rateLimit/RateLimiter.java | 13 + .../global/util/ObjectMapperUtil.java | 8 + .../RedisStreamHelper.java | 13 +- .../mohago_nocar/global/util/Result.java | 15 ++ .../place/application/PlaceService.java | 8 + .../place/domain/model/Place.java | 11 +- .../place/domain/service/PlaceUseCase.java | 6 + .../infrastructure/PlaceRepositoryImpl.java | 9 +- .../externalApi/kakao/KakaoApiClient.java | 14 +- .../response/NearPlaceResponseDto.java | 2 +- ...V1.java => TravelCoursePlanServiceV1.java} | 23 +- .../v1/response/BusPathResponseDto.java | 2 +- .../v1/response/SubwayPathResponseDto.java | 2 +- .../v1/response/WalkPathResponseDto.java | 2 +- .../strategy/RouteOptimizationStrategy.java | 2 +- .../strategy/ShortestTimeRouteStrategy.java | 2 +- .../plan/domain/model/Location.java | 12 + ...V1.java => TravelCoursePlanUseCaseV1.java} | 2 +- .../presentation/exception/PlanErrorCode.java | 1 + .../v1/TravelPlanControllerV1.java | 7 +- .../v2/TravelPlanControllerV2.java | 33 --- .../TransitRouteSummaryUseCaseImpl.java | 32 +++ .../transit/domain/model/BusPath.java | 39 ++- .../transit/domain/model/SubPath.java | 34 ++- .../transit/domain/model/SubwayPath.java | 35 ++- .../transit/domain/model/TransitRoute.java | 45 +++- .../transit/domain/model/WalkPath.java | 17 +- .../service/TransitRouteSummaryUseCase.java | 21 ++ .../DistanceDurationApiAdapter.java | 4 + .../GoogleDistanceMatrixApiAdapter.java | 22 +- .../google/GoogleApiClient.java | 61 +++-- .../response/DistanceMatrixElementStatus.java | 8 + .../GoogleDistanceMatrixResponse.java | 3 +- .../error/code/OdsayErrorCode.java | 5 + .../error/exception/ODsayRouteException.java | 12 +- .../TransitRouteDeadRequestProducer.java | 38 --- .../infrastructure/route/RateLimitKey.java | 43 ---- .../route/RateLimitedApiKeyPool.java | 42 --- .../route/RateLimitedApiKeyPoolConfig.java | 31 --- .../route/TransitRouteApiAdapter.java | 2 +- .../odsay/ODsayApiRateLimitedClient.java | 66 ++--- .../odsay/ODsayTransitRouteApiAdapter.java | 6 +- .../route/odsay/OdsayApiKeyProperties.java | 23 -- .../route/odsay/OdsayApiProperties.java | 25 ++ .../odsay/deprecated/ODsayApiClient.java | 73 ++++++ .../ODsayTransitRouteApiExecutor.java | 3 +- .../deprecated}/TransitRouteApiExecutor.java | 2 +- ...ODsayTransitRouteResponseDeserializer.java | 1 + .../{ => response}/TransitRouteConverter.java | 5 +- .../user/application/UserService.java | 25 +- .../user/domain/UserRepository.java | 2 + .../mohago_nocar/user/domain/UserUseCase.java | 8 +- .../AnonymousUserJpaRepository.java | 2 + .../infrastructure/UserRepositoryImpl.java | 5 + src/main/resources/application.yml | 78 +++++- src/main/resources/logback-spring.xml | 45 ++-- .../resources/scripts/save_and_publish.lua | 16 ++ ...ompletableFutureExceptionHandlingTest.java | 191 ++++++++++++++ .../mohago_nocar/OdsayThrottleTest.java | 208 +++++++++++++++ .../com/example/mohago_nocar/RetryTest.java | 111 ++++++++ .../example/mohago_nocar/RouteStepTest.java | 160 ++++++++++++ .../example/mohago_nocar/SentrySendTest.java | 26 ++ ...velSpotOrderAssignmentPerformanceTest.java | 242 ++++++++++++++++++ .../route/RouteStepFinderTest.java | 111 ++++++++ .../spot/TravelSpotServiceTest.java | 21 ++ ...avelCourseOptimizedEventPublisherTest.java | 31 +++ .../stream/DeadMessageProcessorTest.java | 68 +++++ ...rseOptimizedStreamRecoveryManagerTest.java | 36 +++ .../messaging/DeadLetterQueueEntryTest.java | 26 ++ .../messaging/DeadLetterQueueServiceTest.java | 27 ++ .../messaging/RedisStreamHelperTest.java | 68 +++++ .../TravelPlanControllerV2Test.java | 35 --- .../rateLimiter/RateLimiterTest.java | 49 ++++ .../mohago_nocar/redis/TemplateStudy.java | 46 ++++ .../support/FakeDataFactoryConfig.java | 18 ++ .../mohago_nocar/support/Fixtures.java | 171 +++++++++++++ .../support/IntegrationTestSupport.java | 9 + .../support/LocalIntegrationTestSupport.java | 9 + .../mohago_nocar/test/DistinctTimeTest.java | 72 ++++++ .../test/ForCustomLoggerTest.java | 24 ++ .../mohago_nocar/test/v2/ESSubmitterTest.java | 33 +++ .../v3/LockSupportBasedRateLimiterTest.java | 25 ++ .../test/v3/RateLimiterMethodTest.java | 46 ++++ .../test/v3/StrictRateLimiterTest.java | 29 +++ .../mohago_nocar/transit/LuaScriptTest.java | 80 ++++++ .../GoogleDistanceMatrixApiAdapterTest.java | 27 +- .../google/GoogleApiClientTest.java | 18 +- .../ODsayTransitRouteApiAdapterTest.java | 4 +- ...ava => ODsayApiRateLimitedClientTest.java} | 4 +- .../route/odsay/ODsayApiRateLimitingTest.java | 75 ++++++ .../process/StreamPendingHandlerTest.java | 58 +++++ .../route/test/TestConsumerTest.java | 170 ++++++++++++ .../route/v2/RateLimitedApiKeyPoolTest.java | 125 +++++++++ ...eLimitedODsayApiRateLimitedClientTest.java | 57 +++++ src/test/resources/logback-test.xml | 70 +++++ 182 files changed, 5612 insertions(+), 746 deletions(-) create mode 100644 src/main/java/com/example/mohago_nocar/course/application/CourseErrorCode.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseCompletionNotifier.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseConverter.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseOptimizedEventHandler.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/BusSubPathDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleFailure.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleResult.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleSuccess.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/GetRequesterInfoDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/RouteStepDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/SubPathDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/SubwaySubPathDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/TravelCourseResultNotificationDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/dto/WalkSubPathDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/route/RouteFinder.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/spot/TravelSpotService.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/event/ThrottlingCompletedEvent.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/model/course/CourseStatus.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseCompletionMessage.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/domain/repository/CourseRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/service/TravelSpotUseCase.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseJpaRepository.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseRepositoryImpl.java rename src/main/java/com/example/mohago_nocar/course/infrastructure/{stream => course/messaging}/LongPendingMessageHandler.java (57%) rename src/main/java/com/example/mohago_nocar/course/infrastructure/{stream => course/messaging}/LongPendingMessageReader.java (83%) create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisher.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageProducer.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageRetryPolicy.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamContainer.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamRecoveryManager.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java rename src/main/java/com/example/mohago_nocar/course/infrastructure/{routeStep => route}/RouteStepJpaRepository.java (52%) create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepRepositoryImpl.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepRepositoryImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotJpaRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotRepositoryImpl.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessor.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotJpaRepository.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotRepositoryImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/course/presentation/TravelCourseController.java create mode 100644 src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateOptimizedTravelCourseAcceptedResponseDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateTravelCourseRequestDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseRequestDto.java create mode 100644 src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseResponseDto.java create mode 100644 src/main/java/com/example/mohago_nocar/global/common/RetryPolicy.java delete mode 100644 src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java create mode 100644 src/main/java/com/example/mohago_nocar/global/config/TimeZoneConfig.java delete mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterIdleTimeoutException.java delete mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterProcessingException.java create mode 100644 src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryDto.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/NotificationMessagingException.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCase.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCaseImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationDto.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationService.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationServiceImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessage.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessageSender.java rename src/main/java/com/example/mohago_nocar/global/{config => notification/infrastructure/fcm}/FcmConfig.java (55%) create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessage.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessageSender.java create mode 100644 src/main/java/com/example/mohago_nocar/global/rateLimit/IntervalRateLimiter.java create mode 100644 src/main/java/com/example/mohago_nocar/global/rateLimit/RateLimiter.java rename src/main/java/com/example/mohago_nocar/global/{messaging => util}/RedisStreamHelper.java (82%) create mode 100644 src/main/java/com/example/mohago_nocar/global/util/Result.java rename src/main/java/com/example/mohago_nocar/plan/application/v1/{TravelCourseServiceV1.java => TravelCoursePlanServiceV1.java} (91%) rename src/main/java/com/example/mohago_nocar/plan/domain/service/{TravelCourseUseCaseV1.java => TravelCoursePlanUseCaseV1.java} (89%) delete mode 100644 src/main/java/com/example/mohago_nocar/plan/presentation/v2/TravelPlanControllerV2.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/applicatoin/TransitRouteSummaryUseCaseImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/domain/service/TransitRouteSummaryUseCase.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/DistanceMatrixElementStatus.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteDeadRequestProducer.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitKey.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPool.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPoolConfig.java delete mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiKeyProperties.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiProperties.java create mode 100644 src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayApiClient.java rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/{ => deprecated}/ODsayTransitRouteApiExecutor.java (97%) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/{ => odsay/deprecated}/TransitRouteApiExecutor.java (81%) rename src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/{ => response}/TransitRouteConverter.java (92%) create mode 100644 src/main/resources/scripts/save_and_publish.lua create mode 100644 src/test/java/com/example/mohago_nocar/CompletableFutureExceptionHandlingTest.java create mode 100644 src/test/java/com/example/mohago_nocar/OdsayThrottleTest.java create mode 100644 src/test/java/com/example/mohago_nocar/RetryTest.java create mode 100644 src/test/java/com/example/mohago_nocar/RouteStepTest.java create mode 100644 src/test/java/com/example/mohago_nocar/SentrySendTest.java create mode 100644 src/test/java/com/example/mohago_nocar/TravelSpotOrderAssignmentPerformanceTest.java create mode 100644 src/test/java/com/example/mohago_nocar/course/application/route/RouteStepFinderTest.java create mode 100644 src/test/java/com/example/mohago_nocar/course/application/spot/TravelSpotServiceTest.java create mode 100644 src/test/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisherTest.java create mode 100644 src/test/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessorTest.java create mode 100644 src/test/java/com/example/mohago_nocar/course/infrastructure/stream/TravelCourseOptimizedStreamRecoveryManagerTest.java create mode 100644 src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryTest.java create mode 100644 src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueServiceTest.java create mode 100644 src/test/java/com/example/mohago_nocar/global/messaging/RedisStreamHelperTest.java delete mode 100644 src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java create mode 100644 src/test/java/com/example/mohago_nocar/rateLimiter/RateLimiterTest.java create mode 100644 src/test/java/com/example/mohago_nocar/redis/TemplateStudy.java create mode 100644 src/test/java/com/example/mohago_nocar/support/FakeDataFactoryConfig.java create mode 100644 src/test/java/com/example/mohago_nocar/support/Fixtures.java create mode 100644 src/test/java/com/example/mohago_nocar/support/IntegrationTestSupport.java create mode 100644 src/test/java/com/example/mohago_nocar/support/LocalIntegrationTestSupport.java create mode 100644 src/test/java/com/example/mohago_nocar/test/DistinctTimeTest.java create mode 100644 src/test/java/com/example/mohago_nocar/test/ForCustomLoggerTest.java create mode 100644 src/test/java/com/example/mohago_nocar/test/v2/ESSubmitterTest.java create mode 100644 src/test/java/com/example/mohago_nocar/test/v3/LockSupportBasedRateLimiterTest.java create mode 100644 src/test/java/com/example/mohago_nocar/test/v3/RateLimiterMethodTest.java create mode 100644 src/test/java/com/example/mohago_nocar/test/v3/StrictRateLimiterTest.java create mode 100644 src/test/java/com/example/mohago_nocar/transit/LuaScriptTest.java rename src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/{ODsayApiClientTest.java => ODsayApiRateLimitedClientTest.java} (96%) create mode 100644 src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitingTest.java create mode 100644 src/test/java/com/example/mohago_nocar/transit/infrastructure/route/process/StreamPendingHandlerTest.java create mode 100644 src/test/java/com/example/mohago_nocar/transit/infrastructure/route/test/TestConsumerTest.java create mode 100644 src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedApiKeyPoolTest.java create mode 100644 src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedODsayApiRateLimitedClientTest.java create mode 100644 src/test/resources/logback-test.xml diff --git a/build.gradle b/build.gradle index fbc0e58..2115e0b 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.4' id 'io.spring.dependency-management' version '1.1.6' + id "io.sentry.jvm.gradle" version "5.12.2" } group = 'com.example' @@ -31,20 +32,24 @@ dependencies { // Web implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web-services' + implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' // HTTP Client implementation group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.3.1' + // logging + implementation 'io.sentry:sentry-logback:8.22.0' + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' // DB - implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'org.postgresql:postgresql' + implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.12.0' // Actuator implementation 'org.springframework.boot:spring-boot-starter-actuator' @@ -56,8 +61,7 @@ dependencies { implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework:spring-aspects' - // rate limiter - implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0' + implementation 'net.logstash.logback:logstash-logback-encoder:9.0' // notification implementation 'com.google.firebase:firebase-admin:9.7.0' @@ -75,3 +79,9 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +test { + testLogging { + showStandardStreams = true + } +} diff --git a/src/main/java/com/example/mohago_nocar/MohagoNocarApplication.java b/src/main/java/com/example/mohago_nocar/MohagoNocarApplication.java index f3671bc..a7849dc 100644 --- a/src/main/java/com/example/mohago_nocar/MohagoNocarApplication.java +++ b/src/main/java/com/example/mohago_nocar/MohagoNocarApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class MohagoNocarApplication { diff --git a/src/main/java/com/example/mohago_nocar/course/application/CourseErrorCode.java b/src/main/java/com/example/mohago_nocar/course/application/CourseErrorCode.java new file mode 100644 index 0000000..a711c18 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/CourseErrorCode.java @@ -0,0 +1,20 @@ +package com.example.mohago_nocar.course.application; + +import com.example.mohago_nocar.global.common.exception.Status; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum CourseErrorCode implements Status { + TRAVEL_COURSE_OPTIMIZATION_INCOMPLETE(HttpStatus.BAD_REQUEST, "TRAVEL_COURSE_OPTIMIZATION_INCOMPLETE", "여행 코스 최적화가 완료되지 않았습니다.") + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseCompletionNotifier.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseCompletionNotifier.java new file mode 100644 index 0000000..c686801 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseCompletionNotifier.java @@ -0,0 +1,64 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseCompletionMessage; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationDto; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationService; +import com.example.mohago_nocar.global.util.Result; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@Slf4j +@RequiredArgsConstructor +public class TravelCourseCompletionNotifier { + + private final UserNotificationService userNotificationService; + private final TravelCourseUseCase travelCourseUseCase; + + /** + * 알림을 전송합니다. 만일 알림 전송 이력이 있다면 알림을 전송하지 않습니다. + * @param travelCourseId 알림을 전송할 코스 아이디 + * @param result 코스 처리 결과 + * @param exceptionHandler 알림 전송 중 예외 발생 시 사용되는 핸들러 + */ + public void sendNotificationOnce( + Long travelCourseId, + Result result, + TravelCourseNotifyExceptionHandler exceptionHandler + ) { + try { + TravelCourse course = travelCourseUseCase.findById(travelCourseId).orElseThrow(); + if (course.getNotificationSent()) { + return; + } + + TravelCourseCompletionMessage message = result.isSuccess() ? + TravelCourseCompletionMessage.SUCCESS : TravelCourseCompletionMessage.FAILURE; + + userNotificationService.send(new UserNotificationDto( + message.getTitle(), + message.getBody(), + course.getAnonymousUserId(), + Map.of("travelCourseId", String.valueOf(course.getId())) + )); + + travelCourseUseCase.markNotificationSent(travelCourseId); + } catch (Exception e) { + log.info("알림 전송에 실패했습니다. ", e); + exceptionHandler.handle(e); + } + } + + @FunctionalInterface + public interface TravelCourseNotifyExceptionHandler { + + void handle(Exception e); + + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseConverter.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseConverter.java new file mode 100644 index 0000000..af0f918 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseConverter.java @@ -0,0 +1,68 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.application.dto.*; +import com.example.mohago_nocar.course.application.dto.RouteStepDto.LocationDto; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.BusPath; +import com.example.mohago_nocar.transit.domain.model.SubwayPath; +import com.example.mohago_nocar.transit.domain.model.WalkPath; + +import java.util.List; +import java.util.stream.Collectors; + +public class TravelCourseConverter { + + public static RouteStepDto convertToRouteStepDto( + final TravelSpot origin, + final TravelSpot destination, + final RouteStep step + ){ + return new RouteStepDto( + step.getTimeTakenMin(), + step.getDistanceKm(), + convertToLocationDto(origin), + convertToLocationDto(destination), + convertToSubPathDtos(step)); + } + + private static List convertToSubPathDtos(RouteStep step) { + return step.getDetailPaths().stream() + .map(path ->{ + if (path instanceof WalkPath walk) { + return new WalkSubPathDto(walk.getDistanceKm(), walk.getTimeTakenMin(), + walk.getPathType().name()); + } + + if (path instanceof BusPath bus) { + return new BusSubPathDto(bus.getDistanceKm(), bus.getTimeTakenMin(), + bus.getPathType().name(), bus.getBusNo(), bus.getBusType(), + bus.getStartName(), bus.getStartCoordinate().getLongitude(), bus.getStartCoordinate().getLatitude(), + bus.getEndName(), bus.getEndCoordinate().getLongitude(), bus.getEndCoordinate().getLatitude()); + } + + if (path instanceof SubwayPath subway) { + return new SubwaySubPathDto( + subway.getDistanceKm(), subway.getTimeTakenMin(), + subway.getPathType().name(), subway.getSubwayLineName(), + subway.getStartName(), subway.getStartCoordinate().getLongitude(), subway.getStartCoordinate().getLatitude(), + subway.getEndName(), subway.getEndCoordinate().getLongitude(), subway.getEndCoordinate().getLatitude() + ); + } + + throw new IllegalArgumentException("지원하지 않는 경로 유형입니다."); + }) + .collect(Collectors.toList()); + } + + private static LocationDto convertToLocationDto(TravelSpot origin) { + Location location = origin.getLocation(); + return LocationDto.builder() + .name(location.getName()) + .latitude(location.getCoordinate().getLatitude()) + .longitude(location.getCoordinate().getLongitude()) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseOptimizedEventHandler.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseOptimizedEventHandler.java new file mode 100644 index 0000000..f5caf80 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseOptimizedEventHandler.java @@ -0,0 +1,50 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.course.domain.model.course.CourseStatus; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.course.infrastructure.course.messaging.TravelCourseOptimizedEventPublisher; +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@Slf4j +@RequiredArgsConstructor +public class TravelCourseOptimizedEventHandler { + + private final TravelCourseOptimizedEventPublisher eventPublisher; + private final TravelCourseUseCase travelCourseUseCase; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void publishEvent(TravelCourseOptimizedEvent event) { + try { + eventPublisher.publish(event); + } catch (Exception e) { + log.error("이벤트 발송에 실패했습니다. 이벤트 = {}", event); + throw new CustomException(GlobalStatus.INTERNAL_SERVER_ERROR); + } + } + + public void handleEvent(TravelCourseOptimizedEvent event) { + travelCourseUseCase.generateTransitRoute(event.getTravelCourseId()); + markAsSucceeded(event.getTravelCourseId()); + } + + public void markAsSucceeded(Long travelCourseId) { + travelCourseUseCase.updateCourseStatus(travelCourseId, CourseStatus.SUCCEEDED); + } + + public void markAsWaitingReprocessing(Long travelCourseId) { + travelCourseUseCase.updateCourseStatus(travelCourseId, CourseStatus.WAITING_REPROCESSING); + } + + public void markAsFailed(Long travelCourseId) { + travelCourseUseCase.updateCourseStatus(travelCourseId, CourseStatus.FAILED); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java new file mode 100644 index 0000000..a1dc732 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java @@ -0,0 +1,168 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.application.CourseErrorCode; +import com.example.mohago_nocar.course.application.dto.RouteStepDto; +import com.example.mohago_nocar.course.application.route.RouteFinder; +import com.example.mohago_nocar.course.application.route.RouteStepService; +import com.example.mohago_nocar.course.application.spot.TravelSpotService; +import com.example.mohago_nocar.course.domain.event.ThrottlingCompletedEvent; +import com.example.mohago_nocar.course.domain.model.course.CourseStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.course.domain.repository.TravelCourseRepository; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseCompletionMessage; +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.course.infrastructure.course.messaging.TravelCourseOptimizedMessageConsumer; +import com.example.mohago_nocar.course.presentation.dto.CreateTravelCourseRequestDto; +import com.example.mohago_nocar.course.presentation.dto.CreateOptimizedTravelCourseAcceptedResponseDto; +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationDto; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationService; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TravelCourseService implements TravelCourseUseCase { + + private final UserUseCase userUseCase; + private final TravelCourseRepository travelCourseRepository; + private final TravelSpotService travelSpotService; + private final RouteStepService routeStepService; + private final ApplicationEventPublisher eventPublisher; + private final RouteFinder routeFinder; + + @Override + @Transactional + public CreateOptimizedTravelCourseAcceptedResponseDto createOptimizedTravelCourse(CreateTravelCourseRequestDto request) { + AnonymousUser user = userUseCase.getOrCreate(request.fcmToken()); + TravelCourse course = TravelCourse.create(user, CourseStatus.ENQUEUED); + travelCourseRepository.save(course); + + generateSpotsWithOptimizedOrder(request, course); + + eventPublisher.publishEvent( + TravelCourseOptimizedEvent.of(course.getId(), course.getAnonymousUserId()) + ); + + return CreateOptimizedTravelCourseAcceptedResponseDto.of(course.getId(), user.getId()); + } + + private void generateSpotsWithOptimizedOrder(CreateTravelCourseRequestDto request, TravelCourse course) { + Set spotsWithoutVisitOrder = + travelSpotService.makeSpotsWithoutOrder(course, request.festivalId(), request.travelStartDate(), request.placeIds()); + + Set spotsWithOrder = + travelSpotService.determineOptimizedTravelOrder(spotsWithoutVisitOrder); + + travelSpotService.saveAll(spotsWithOrder); + } + + @Override + @Transactional + public void generateTransitRoute(Long travelCourseId) { + List> routeFutures = findRoutesInTravelCourse(travelCourseId); + eventPublisher.publishEvent(ThrottlingCompletedEvent.of(travelCourseId)); + List routeSteps = CompletableFuture.allOf(routeFutures.toArray(new CompletableFuture[0])) + .orTimeout(8, TimeUnit.SECONDS) + .thenApply(completed -> routeFutures.stream().map(CompletableFuture::join).toList()) + .join(); + + routeStepService.saveAll(routeSteps); + } + + private List> findRoutesInTravelCourse(Long travelCourseId) { + List travelSpots = travelSpotService.getByCourseId(travelCourseId); + + validateMinSize(travelSpots, 2); + sortByVisitOrder(travelSpots); + + return routeFinder.findRouteWithThrottling(travelSpots); + } + + private void sortByVisitOrder(List travelSpots) { + Collections.sort(travelSpots); + } + + private void validateMinSize(List travelSpotsInOrder, int minSize) { + if (travelSpotsInOrder == null || travelSpotsInOrder.size() < minSize) { + System.out.println("travelSpotInOrder: " + travelSpotsInOrder); + throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); + } + } + + @Override + public List getOptimizedTravelCourseRoutes(Long courseId, UUID ownerUserId) { + Objects.requireNonNull(courseId); + Objects.requireNonNull(ownerUserId); + + TravelCourse course = travelCourseRepository.findById(courseId) + .orElseThrow(() -> new CustomException(GlobalStatus.ENTITY_NOT_FOUND)); + + if (!course.getAnonymousUserId().equals(ownerUserId)) { + throw new CustomException(GlobalStatus.FORBIDDEN); + } + + if (course.getCourseStatus() != CourseStatus.SUCCEEDED) { + throw new CustomException(CourseErrorCode.TRAVEL_COURSE_OPTIMIZATION_INCOMPLETE); + } + + List travelSpots = travelSpotService.getByCourseId(course.getId()); + Collections.sort(travelSpots); + + List routesBetweenSpots = new ArrayList<>(); + for (int i = 0; i < travelSpots.size() - 1; i++) { + TravelSpot origin = travelSpots.get(i); + TravelSpot destination = travelSpots.get(i + 1); + RouteStep route = routeStepService.getByOriginAndDestination(origin, destination); + RouteStepDto dto = TravelCourseConverter.convertToRouteStepDto(origin, destination, route); + routesBetweenSpots.add(dto); + } + + return routesBetweenSpots; + } + + @Override + public List getOutdatedCourseNeedingNotification(int cutOffTimeInMin, Boolean notificationSent) { + LocalDateTime thresholdTime = LocalDateTime.now().minusMinutes(cutOffTimeInMin); + return travelCourseRepository.findOutdatedCoursesNeedingNotification(thresholdTime, notificationSent); + } + + @Override + public Optional findById(Long travelCourseId) { + return travelCourseRepository.findById(travelCourseId); + } + + @Override + @Transactional + public void markNotificationSent(Long travelCourseId) { + TravelCourse course = findById(travelCourseId).orElseThrow(); + course.markNotificationSent(); + } + + @Override + @Transactional + public void updateCourseStatus(Long travelCourseId, CourseStatus courseStatus) { + TravelCourse course = findById(travelCourseId).orElseThrow(); + course.updateStatus(courseStatus); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/BusSubPathDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/BusSubPathDto.java new file mode 100644 index 0000000..53331fa --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/BusSubPathDto.java @@ -0,0 +1,17 @@ +package com.example.mohago_nocar.course.application.dto; + +public record BusSubPathDto( + double distanceKm, + int timeTakenMin, + String pathType, + String busNo, + int busType, + String startPlaceName, + double startLongitude, + double startLatitude, + String endPlaceName, + double endLongitude, + double endLatitude +) implements SubPathDto{ + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleFailure.java b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleFailure.java new file mode 100644 index 0000000..be0bb48 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleFailure.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.course.application.dto; + +public final class EventHandleFailure implements EventHandleResult { + + private Throwable throwable; + + public static EventHandleFailure create(Throwable throwable) { + return new EventHandleFailure(throwable); + } + + private EventHandleFailure(Throwable throwable) { + this.throwable = throwable; + } + + public Throwable get() { + return throwable; + } +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleResult.java b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleResult.java new file mode 100644 index 0000000..af3d107 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleResult.java @@ -0,0 +1,7 @@ +package com.example.mohago_nocar.course.application.dto; + +public sealed interface EventHandleResult permits + EventHandleSuccess, + EventHandleFailure { + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleSuccess.java b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleSuccess.java new file mode 100644 index 0000000..e383e62 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleSuccess.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.course.application.dto; + +import java.util.concurrent.CompletableFuture; + +public final class EventHandleSuccess implements EventHandleResult { + + private CompletableFuture futureResult; + + public static EventHandleSuccess create(CompletableFuture futureResult) { + return new EventHandleSuccess(futureResult); + } + + private EventHandleSuccess(CompletableFuture futureResult) { + this.futureResult = futureResult; + } + + public CompletableFuture get() { + return futureResult; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/GetRequesterInfoDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/GetRequesterInfoDto.java new file mode 100644 index 0000000..12dba86 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/GetRequesterInfoDto.java @@ -0,0 +1,10 @@ +package com.example.mohago_nocar.course.application.dto; + +import java.util.UUID; + +// todo 삭제 +public record GetRequesterInfoDto( + UUID anonymousUserId, + String fcmToken +) { +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/RouteStepDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/RouteStepDto.java new file mode 100644 index 0000000..44fa884 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/RouteStepDto.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.course.application.dto; + +import lombok.Builder; + +import java.util.List; + +public record RouteStepDto ( + int sectionTimeMin, + double sectionDistanceKm, + LocationDto origin, + LocationDto destination, + List subPaths +){ + + public static RouteStepDto of( + int sectionTimeMin, + double sectionDistanceKm, + LocationDto origin, + LocationDto destination, + List subPaths + ) { + return new RouteStepDto(sectionTimeMin, sectionDistanceKm, origin, destination, subPaths); + } + + @Builder + public record LocationDto( + String name, + Double longitude, + Double latitude + ){ + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/SubPathDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/SubPathDto.java new file mode 100644 index 0000000..d76ddd6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/SubPathDto.java @@ -0,0 +1,4 @@ +package com.example.mohago_nocar.course.application.dto; + +public interface SubPathDto { +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/SubwaySubPathDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/SubwaySubPathDto.java new file mode 100644 index 0000000..0666a60 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/SubwaySubPathDto.java @@ -0,0 +1,16 @@ +package com.example.mohago_nocar.course.application.dto; + +public record SubwaySubPathDto( + double distanceKm, + int timeTakenMin, + String pathType, + String subwayLineName, + String startPlaceName, + double startLongitude, + double startLatitude, + String endPlaceName, + double endLongitude, + double endLatitude +) implements SubPathDto{ + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/TravelCourseResultNotificationDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/TravelCourseResultNotificationDto.java new file mode 100644 index 0000000..728a195 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/TravelCourseResultNotificationDto.java @@ -0,0 +1,20 @@ +package com.example.mohago_nocar.course.application.dto; + +import lombok.Builder; + +import java.util.UUID; + +@Builder +public record TravelCourseResultNotificationDto( + Long travelCourseId, + UUID anonymousUserId +) { + + public static TravelCourseResultNotificationDto of( + Long travelCourseId, + UUID anonymousUserId + ) { + return new TravelCourseResultNotificationDto(travelCourseId, anonymousUserId); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/WalkSubPathDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/WalkSubPathDto.java new file mode 100644 index 0000000..ac43363 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/WalkSubPathDto.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.course.application.dto; + +import com.example.mohago_nocar.transit.domain.model.PathType; + +public record WalkSubPathDto( + double distanceKm, + int timeTakenMin, + String pathType +) implements SubPathDto { + + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/route/RouteFinder.java b/src/main/java/com/example/mohago_nocar/course/application/route/RouteFinder.java new file mode 100644 index 0000000..3af3264 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/route/RouteFinder.java @@ -0,0 +1,99 @@ +package com.example.mohago_nocar.course.application.route; + +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.locks.ReentrantLock; + +@Component +@Slf4j +@RequiredArgsConstructor +public class RouteFinder { + + private final TransitRouteApiAdapter transitRouteApiAdapter; + + private ReentrantLock lock = new ReentrantLock(true); + + public List> findRouteWithThrottling(List travelSpots) { + List> steps = new ArrayList<>(); + + for (int i = 0; i < travelSpots.size()-1; i++) { + TravelSpot originSpot = travelSpots.get(i); + TravelSpot destinationSpot = travelSpots.get(i + 1); + + CompletableFuture routeFuture = + transitRouteApiAdapter.getTransitRouteWithThrottling(originSpot.getLocation(), destinationSpot.getLocation()) + .thenApply(transitRoute -> RouteStep.from(originSpot, destinationSpot, transitRoute)); + + steps.add(routeFuture); + } + + return steps; + } + + /** + * 여행 코스 내 존재하는 장소 사이의 이동 경로를 찾습니다. + * @param travelSpots 같은 여행 코스 내 방문 순서가 정해진 여행 장소들 + * @return 출발 장소, 도착 장소 간의 이동 경로들 + * @implNote 이동 경로 조회 API가 허용하는 초당 호출량은 최대 5회입니다. + * 따라서 병렬적으로 여러 여행 코스 내의 이동 경로를 구할 경우, 전체적인 처리 시간이 증가합니다. + * 이를 방지하기 위해 한 여행 코스 내의 이동 경로 조회 API를 호출한 후에 다음 여행 코스의 이동 경로를 구하도록 제한합니다. + */ + public List> findRouteInTravelCourse(Long travelCourseId, List travelSpots) { + validateMinSize(travelSpots, 2); + validateSpotBelongsToSameCourse(travelCourseId, travelSpots); + sortByVisitOrder(travelSpots); + + List> steps = new ArrayList<>(); + + try{ + lock.lock(); + log.info("get Lock in findRouteInTravelCourse"); + + for (int i = 0; i < travelSpots.size()-1; i++) { + TravelSpot originSpot = travelSpots.get(i); + TravelSpot destinationSpot = travelSpots.get(i + 1); + + CompletableFuture routeFuture = + transitRouteApiAdapter.getTransitRouteWithThrottling(originSpot.getLocation(), destinationSpot.getLocation()) + .thenApply(transitRoute -> RouteStep.from(originSpot, destinationSpot, transitRoute)); + + steps.add(routeFuture); + } + + } finally { + lock.unlock(); + log.info("release Lock in findRouteInTravelCourse"); + } + + return steps; + } + + private void validateSpotBelongsToSameCourse(Long travelCourseId, List travelSpots) { + for (TravelSpot travelSpot : travelSpots) { + if (!Objects.equals(travelSpot.getCourseId(), travelCourseId)) { + log.error("여행 장소가 속한 코스의 아이디가 {}입니다. 그러나 여행 코스 아이디는 {} 이어야 합니다.", + travelSpot.getCourseId(), travelCourseId); + throw new RuntimeException("여행 장소들이 모두 같은 여행 코스에 소속되지 않았습니다."); + } + } + } + + private void sortByVisitOrder(List travelSpots) { + Collections.sort(travelSpots); + } + + private void validateMinSize(List travelSpotsInOrder, int minSize) { + if (travelSpotsInOrder == null || travelSpotsInOrder.size() < minSize) { + System.out.println("travelSpotInOrder: " + travelSpotsInOrder); + throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java b/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java new file mode 100644 index 0000000..73d95a8 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java @@ -0,0 +1,55 @@ +package com.example.mohago_nocar.course.application.route; + +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.course.domain.repository.RouteStepRepository; +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Service +@Slf4j +@RequiredArgsConstructor +public class RouteStepService { + + private final RouteStepRepository routeStepRepository; + private final RouteFinder routeStepFinder; + + @Transactional + public List saveAll(List routeSteps) { + return routeStepRepository.saveAll(routeSteps); + } + + // todo 예외 처리, 메서드 이름이 올바른가 점검 + private CompletableFuture saveWhenAllCompleteWithTimeout(List> routeStepFutures, int timeoutInSec) { + return waitAllCompleteWithTimeout(routeStepFutures, timeoutInSec) + .thenRun(() -> { + List routeSteps = routeStepFutures.stream().map(CompletableFuture::join).toList(); + routeStepRepository.saveAll(routeSteps); + }); + } + + private CompletableFuture waitAllCompleteWithTimeout(List> routeStepFutures, int timeoutInSec) { + return CompletableFuture.allOf(routeStepFutures.toArray(new CompletableFuture[routeStepFutures.size()])) + .orTimeout(timeoutInSec, TimeUnit.SECONDS); + } + + public RouteStep getByOriginAndDestination(TravelSpot origin, TravelSpot destination) { + return routeStepRepository.findByOriginAndDestination(origin.getId(), destination.getId()) + .orElseThrow(() -> { + log.error("주어진 출발지와 목적지 사이에 경로가 존재하지 않습니다. origin={}, destination={}", origin, destination); + return new CustomException(GlobalStatus.ENTITY_NOT_FOUND); + }); + } + + public List findAll(List ids) { + return routeStepRepository.findByIds(ids); + } +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/spot/TravelSpotService.java b/src/main/java/com/example/mohago_nocar/course/application/spot/TravelSpotService.java new file mode 100644 index 0000000..a145b37 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/spot/TravelSpotService.java @@ -0,0 +1,180 @@ +package com.example.mohago_nocar.course.application.spot; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpotFestival; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpotPlace; +import com.example.mohago_nocar.course.domain.repository.TravelSpotRepository; +import com.example.mohago_nocar.course.domain.service.TravelSpotUseCase; +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import com.example.mohago_nocar.global.common.exception.InvalidValueException; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.service.PlaceUseCase; +import com.example.mohago_nocar.plan.application.v1.strategy.RouteOptimizationStrategy; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.domain.service.TransitRouteSummaryUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + + +import static com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode.TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TravelSpotService implements TravelSpotUseCase { + + private final TravelSpotRepository travelSpotRepository; + private final TransitRouteSummaryUseCase transitRouteSummaryUseCase; + private final RouteOptimizationStrategy routeOptimizationStrategy; + private final FestivalUseCase festivalUseCase; + private final PlaceUseCase placeUseCase; + + @Override + public Set determineOptimizedTravelOrder(Set unorderedSpots) { + if (unorderedSpots == null || unorderedSpots.isEmpty()) { + return Collections.emptySet(); + } + + // 중복 좌표를 갖는 장소 존재 가능 -> 임시 중복 좌표 제거 + Map> unorderedSpotsByCoordinate = mapByCoordinateFrom(unorderedSpots); + Set uniqueCoordinates = unorderedSpotsByCoordinate.keySet(); + + // 고유한 좌표들을 방문할 순서 결정 + List routeMetrics = fetchRouteMetrics(uniqueCoordinates); + List determinedOrder = routeOptimizationStrategy.calculateOptimalRoute( + uniqueCoordinates.stream().toList(), routeMetrics); + + // 중복된 좌표를 가졌던 장소를 포함한 모든 장소에게 방문 순서 부여 + return assignOrderToSpot(determinedOrder, unorderedSpotsByCoordinate); + } + + private Map> mapByCoordinateFrom(Set travelSpots) { + return travelSpots.stream() + .collect(Collectors.groupingBy( + travelSpot -> travelSpot.getLocation().getCoordinate(), + Collectors.toCollection(HashSet::new) + )); + } + + /** + * 모든 좌표 간의 이동 요약 정보(거리(km), 이동 시간(minutes))을 구합니다. + * + * @param coordinates 거리, 이동 시간을 구하는 대상 좌표 + * @return 좌표 간의 거리 및 이동시간

    예시: A, B 좌표가 주어지면 A->B, B->A 간의 거리 및 이동 시간을 구합니다.

    + */ + private List fetchRouteMetrics(Set coordinates) { + List>> futureRouteMetricsNested = new ArrayList<>(); + + for (Coordinate origin : coordinates) { + Set destinations = new HashSet<>(coordinates); + destinations.remove(origin); + + futureRouteMetricsNested.add( + transitRouteSummaryUseCase.getRouteSummary(origin, destinations)); + } + + CompletableFuture allOf = CompletableFuture.allOf( + futureRouteMetricsNested.toArray(new CompletableFuture[0])) + .orTimeout(8, TimeUnit.SECONDS); + + try { + allOf.join(); + } catch (Exception e) { + log.error("이동 경로 요약 조회 실패", e); + throw new RuntimeException(e); + } + + return futureRouteMetricsNested.stream() + .map(CompletableFuture::join) + .flatMap(Collection::stream) + .toList(); + } + + private Set assignOrderToSpot( + List visitOrder, Map> spotsByCoordinate + ) { + if (visitOrder.size() != spotsByCoordinate.keySet().size()) { // todo map.size 동작 + log.error("경위도 개수가 일치하지 않습니다. visitOrder- {}개, spotsByCoordinate- {}개", visitOrder.size(), spotsByCoordinate.size()); + throw new CustomException(GlobalStatus.INTERNAL_SERVER_ERROR); + } + + int order = 0; + Set orderedSpot = new HashSet<>(); + for (Coordinate nxt : visitOrder) { + Set nxtSpots = spotsByCoordinate.get(nxt); + for (TravelSpot spot : nxtSpots) { + spot.setOrder(order); + order = order + 1; + } + orderedSpot.addAll(nxtSpots); + } + + return orderedSpot; + } + + @Override + public List saveAll(Set spotsWithOrder) { + return travelSpotRepository.saveAll(spotsWithOrder); + } + + @Override + public List getByCourseId(Long travelCourseId) { + return travelSpotRepository.findByTravelCourseId(travelCourseId); + } + + @Override + public Set makeSpotsWithoutOrder(TravelCourse ownerCourse, Long festivalId, LocalDate travelDate, List placeIds) { + Festival festival = getFestivalOrThrow(festivalId, travelDate); + List places = getNearPlacesOfFestival(festival, placeIds); + + return createUnOrderedTravelSpots(ownerCourse, festival, places); + } + + private Festival getFestivalOrThrow(Long festivalId, LocalDate travelDate) { + var festival = festivalUseCase.getFestival(festivalId); +/* if (!festival.isOpen(travelDate)) { + throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); + }*/ + return festival; + } + + private List getNearPlacesOfFestival(Festival festival, List placeIds) { + List places = placeUseCase.getFestivalNearPlacesById(festival.getId(), placeIds); + log.info("카카오로부터 조회해온 장소:{}", places); + // todo refactoring : 캐싱 정책 (all or nothing)을 담는 객체 생성 + if (places.isEmpty()) { + log.info("Place cache miss가 발생했습니다. 원본 데이터를 요청하여 재캐싱합니다."); + places = placeUseCase.cachePlaces(festival.getId(), festival.getCoordinate()).stream() + .filter(place -> placeIds.contains(place.getKakaoId())) + .toList(); + } + return places; + } + + @Override + public Set createUnOrderedTravelSpots(TravelCourse course, Festival festival, List places) { + log.info("인자로 전달된 place 사이즈: {}", places.size()); + log.info("places:{}", places); + Set travelSpots = new HashSet<>(); + travelSpots.add(TravelSpotFestival.createWithNoOrder(course, festival)); + for (Place place : places) { + travelSpots.add(TravelSpotPlace.createWithNoOrder(course, place)); + } + log.info("여행 장소: {}", travelSpots); + + return travelSpots; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/event/ThrottlingCompletedEvent.java b/src/main/java/com/example/mohago_nocar/course/domain/event/ThrottlingCompletedEvent.java new file mode 100644 index 0000000..77cec84 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/event/ThrottlingCompletedEvent.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.course.domain.event; + +import lombok.Getter; + +@Getter +public class ThrottlingCompletedEvent { + + private final Long travelCourseId; + + public static ThrottlingCompletedEvent of(Long travelCourseId) { + return new ThrottlingCompletedEvent(travelCourseId); + } + + private ThrottlingCompletedEvent(Long travelCourseId) { + this.travelCourseId = travelCourseId; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java b/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java new file mode 100644 index 0000000..e88dee0 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java @@ -0,0 +1,27 @@ +package com.example.mohago_nocar.course.domain.event; + +import lombok.Getter; +import lombok.ToString; + +import java.util.UUID; + +/** + * 여행 코스에 포함된 여행 장소들을 방문할 순서의 최적화 완료 이벤트 + */ +@Getter +@ToString +public class TravelCourseOptimizedEvent { + + private Long travelCourseId; + private UUID anonymousUserId; + + public static TravelCourseOptimizedEvent of(Long travelCourseId, UUID anonymousUserId) { + return new TravelCourseOptimizedEvent(travelCourseId, anonymousUserId); + } + + private TravelCourseOptimizedEvent(Long travelCourseId, UUID anonymousUserId) { + this.travelCourseId = travelCourseId; + this.anonymousUserId = anonymousUserId; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/CourseStatus.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/CourseStatus.java new file mode 100644 index 0000000..0410d59 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/CourseStatus.java @@ -0,0 +1,16 @@ +package com.example.mohago_nocar.course.domain.model.course; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum CourseStatus { + + ENQUEUED("처리 대기 중"), + SUCCEEDED("처리 성공"), + FAILED("처리 실패"), + WAITING_REPROCESSING("재처리 대기 중"); + + private final String description; + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java index 5f0e506..d3dc2b0 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java @@ -3,6 +3,8 @@ import com.example.mohago_nocar.global.common.domain.BaseEntity; import com.example.mohago_nocar.user.domain.AnonymousUser; import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -14,16 +16,45 @@ @Entity @Getter @NoArgsConstructor(access = PROTECTED) +@Table(name = "travel_course") public class TravelCourse extends BaseEntity { @Id @GeneratedValue(strategy = IDENTITY) private Long id; + @Column(nullable = false) private UUID anonymousUserId; - public static TravelCourse from() { - return new TravelCourse(); + @NotNull + @Column(nullable = false) + private Boolean notificationSent; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CourseStatus courseStatus; + + public static TravelCourse create(AnonymousUser user, CourseStatus courseStatus) { + return TravelCourse.builder() + .anonymousUserId(user.getId()) + .courseStatus(courseStatus) + .notificationSent(false) + .build(); + } + + @Builder + private TravelCourse(UUID anonymousUserId, CourseStatus courseStatus, Boolean notificationSent) { + this.anonymousUserId = anonymousUserId; + this.courseStatus = courseStatus; + this.notificationSent = notificationSent; + } + + public void markNotificationSent() { + this.notificationSent = true; + } + + public void updateStatus(CourseStatus courseStatus) { + this.courseStatus = courseStatus; } } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseCompletionMessage.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseCompletionMessage.java new file mode 100644 index 0000000..9796aaa --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseCompletionMessage.java @@ -0,0 +1,28 @@ +package com.example.mohago_nocar.course.domain.model.course; + +public enum TravelCourseCompletionMessage { + + SUCCESS("여행 계획 완성! ✈️", "여행 계획 설계가 완료되었어요. 탭해서 확인해보세요!"), + FAILURE("여행 계획 수립 실패...", "예상치 못한 에러로 계획 수립에 실패했어요."); + + private final String title; + private final String body; + + TravelCourseCompletionMessage(String title, String body) { + this.title = title; + this.body = body; + } + + public String getTitle() { + return title; + } + + public String getBody() { + return body; + } + + public static TravelCourseCompletionMessage fromResult(boolean result) { + return result ? SUCCESS : FAILURE; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java index 77173c9..21d869f 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java @@ -1,22 +1,27 @@ package com.example.mohago_nocar.course.domain.model.routeStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; import com.example.mohago_nocar.global.common.domain.BaseEntity; -import com.example.mohago_nocar.global.common.domain.vo.Coordinate; -import com.example.mohago_nocar.global.util.DurationToIntervalConverter; -import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.SubPath; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.ToString; +import org.hibernate.annotations.Type; -import java.time.Duration; +import java.util.List; import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; @Entity @Getter +@Table(name = "route_step") +@ToString @NoArgsConstructor(access = PROTECTED) public class RouteStep extends BaseEntity { @@ -24,64 +29,40 @@ public class RouteStep extends BaseEntity { @GeneratedValue(strategy = IDENTITY) private Long id; - @NotNull - private Long courseId; - - @NotNull - private Integer distance; - - @NotNull - private Integer stepOrder; + private Long originSpotId; - // todo subpath vs jsonB - // JSONB - private String detailPaths; + private Long destinationSpotId; - @NotNull - @Embedded - @AttributeOverrides({ - @AttributeOverride(name = "name", column = @Column(name = "start_name")), - @AttributeOverride(name = "coordinate.latitude", column = @Column(name = "start_latitude")), - @AttributeOverride(name = "coordinate.longitude", column = @Column(name = "start_longitude")) - }) - private Location origin; + @Type(JsonBinaryType.class) + @Column(columnDefinition = "jsonb") + private List detailPaths; // subpath는 transit 건데, 그럼 dto도 transit에 있어야하는게 적절하지 않나 @NotNull - @Embedded - @AttributeOverrides({ - @AttributeOverride(name = "name", column = @Column(name = "end_name")), - @AttributeOverride(name = "coordinate.latitude", column = @Column(name = "end_latitude")), - @AttributeOverride(name = "coordinate.longitude", column = @Column(name = "end_longitude")) - }) - private Location destination; + private Double distanceKm; - @NotNull - @Convert(converter = DurationToIntervalConverter.class) - private Duration timeTaken; + private Integer timeTakenMin; - public static RouteStep from(Long courseId, Integer distance, Integer stepOrder, - Location origin, Location destination, Duration timeTaken + public static RouteStep from( + TravelSpot origin, TravelSpot destination, TransitRoute transitRoute ) { + List subPaths = transitRoute.getSubPaths(); return RouteStep.builder() - .courseId(courseId) - .distance(distance) - .stepOrder(stepOrder) - .origin(origin) - .destination(destination) - .timeTaken(timeTaken) + .originSpotId(origin.getId()) + .destinationSpotId(destination.getId()) + .timeTakenMin(transitRoute.getTotalTime()) + .distanceKm(transitRoute.getTotalDistance()) + .detailPaths(subPaths) .build(); } @Builder - private RouteStep(Long courseId, Integer distance, Integer stepOrder, - Location origin, Location destination, Duration timeTaken - ) { - this.courseId = courseId; - this.distance = distance; - this.stepOrder = stepOrder; - this.origin = origin; - this.destination = destination; - this.timeTaken = timeTaken; + private RouteStep(Long originSpotId, Long destinationSpotId, List detailPaths, + Double distanceKm, Integer timeTakenMin) { + this.originSpotId = originSpotId; + this.destinationSpotId = destinationSpotId; + this.detailPaths = detailPaths; + this.distanceKm = distanceKm; + this.timeTakenMin = timeTakenMin; } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java index 6767ddd..39bd29b 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java @@ -1,15 +1,18 @@ package com.example.mohago_nocar.course.domain.model.travelSpot; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.global.common.domain.BaseEntity; +import com.example.mohago_nocar.place.domain.model.Place; import com.example.mohago_nocar.plan.domain.model.Location; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.ToString; -import java.util.Comparator; -import java.util.Objects; +import java.util.*; import static jakarta.persistence.GenerationType.IDENTITY; @@ -17,6 +20,8 @@ @Getter @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "spot_type") +@Table(name = "travel_spot") +@ToString @NoArgsConstructor(access = AccessLevel.PROTECTED) public abstract class TravelSpot extends BaseEntity implements Comparable { diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java index d3e0c05..c341adc 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java @@ -32,7 +32,7 @@ public TravelSpotFestival create(Long courseId, Integer visitOrder, Long festiva .build(); } - public static TravelSpotFestival createUnOrderedSpot( + public static TravelSpotFestival createWithNoOrder( TravelCourse course, Festival festival) { return TravelSpotFestival.builder() .courseId(course.getId()) diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java index 63c4901..4e81161 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java @@ -32,7 +32,7 @@ public TravelSpotPlace create(Long courseId, Integer visitOrder, String placeId) .build(); } - public static TravelSpotPlace createUnOrderedSpot(TravelCourse course, Place place) { + public static TravelSpotPlace createWithNoOrder(TravelCourse course, Place place) { return TravelSpotPlace.builder() .courseId(course.getId()) .visitOrder(null) diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/CourseRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/CourseRepository.java deleted file mode 100644 index e39ca83..0000000 --- a/src/main/java/com/example/mohago_nocar/course/domain/repository/CourseRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.mohago_nocar.course.domain.repository; - -public interface CourseRepository { -} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/RouteStepRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/RouteStepRepository.java index 4680784..2b52a69 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/repository/RouteStepRepository.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/RouteStepRepository.java @@ -1,4 +1,15 @@ package com.example.mohago_nocar.course.domain.repository; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + public interface RouteStepRepository { + List saveAll(List routeSteps); + + Optional findByOriginAndDestination(Long originSpotId, Long destinationSpotId); + + List findByIds(List ids); } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java new file mode 100644 index 0000000..74c3325 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.course.domain.repository; + +import com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface TravelCourseRepository { + + TravelCourse save(TravelCourse course); + + Optional findById(Long travelCourseId); + + Optional getRequestrInfo(Long travelCourseId); + + List findOutdatedCoursesNeedingNotification(LocalDateTime thresholdTime, Boolean notificationSent); + +} + diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelSpotRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelSpotRepository.java index f6128f9..a9b607d 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelSpotRepository.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelSpotRepository.java @@ -1,4 +1,16 @@ package com.example.mohago_nocar.course.domain.repository; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; + +import java.util.Collection; +import java.util.List; + public interface TravelSpotRepository { + TravelSpot save(TravelSpot travelSpot); + + // todo 배치 insert 지원 시 Jpa 기본 saveAll 사용 + List saveAll(Collection travelSpots); + + List findByTravelCourseId(Long travelCourseId); + } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java new file mode 100644 index 0000000..f96acee --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java @@ -0,0 +1,40 @@ +package com.example.mohago_nocar.course.domain.service; + +import com.example.mohago_nocar.course.application.dto.RouteStepDto; +import com.example.mohago_nocar.course.domain.model.course.CourseStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.presentation.dto.CreateTravelCourseRequestDto; +import com.example.mohago_nocar.course.presentation.dto.CreateOptimizedTravelCourseAcceptedResponseDto; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface TravelCourseUseCase { + + /** + * 여행 코스 내 장소 간 대중교통 이동 경로를 생성합니다. + * @param travelCourseId 장소 방문 순서가 결정된 여행 코스의 아이디 + */ + void generateTransitRoute(Long travelCourseId); + + /** + * 여행 장소들을 방문할 순서를 결정합니다. + * 모든 장소를 가장 빠르게 거칠 수 있는 최적화된 방문 순서를 제공합니다. + */ + CreateOptimizedTravelCourseAcceptedResponseDto createOptimizedTravelCourse(CreateTravelCourseRequestDto request); + + List getOptimizedTravelCourseRoutes(Long courseId, UUID ownerUserId); + + List getOutdatedCourseNeedingNotification(int cutOffTimeInMin, Boolean notificationSent); + + Optional findById(Long travelCourseId); + + void markNotificationSent(Long travelCourseId); + + void updateCourseStatus(Long travelCourseId, CourseStatus courseStatus); + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/service/TravelSpotUseCase.java b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelSpotUseCase.java new file mode 100644 index 0000000..25a35a3 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelSpotUseCase.java @@ -0,0 +1,23 @@ +package com.example.mohago_nocar.course.domain.service; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.place.domain.model.Place; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +public interface TravelSpotUseCase { + + Set createUnOrderedTravelSpots(TravelCourse course, Festival festival, List places); + + Set determineOptimizedTravelOrder(Set unorderedSpots); + + List saveAll(Set spotsWithOrder); + + List getByCourseId(Long travelCourseId); + + Set makeSpotsWithoutOrder(TravelCourse ownerCourse, Long festivalId, LocalDate travelDate, List placeIds); +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseJpaRepository.java deleted file mode 100644 index ac8be15..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseJpaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.course; - -import com.example.mohago_nocar.course.domain.model.course.Course; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CourseJpaRepository extends JpaRepository { -} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseRepositoryImpl.java deleted file mode 100644 index 7813709..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseRepositoryImpl.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.course; - -import com.example.mohago_nocar.course.domain.repository.CourseRepository; -import com.example.mohago_nocar.course.infrastructure.course.CourseJpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public class CourseRepositoryImpl implements CourseRepository { - - private CourseJpaRepository courseJpaRepository; -} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageHandler.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageHandler.java similarity index 57% rename from src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageHandler.java rename to src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageHandler.java index 4327f35..0cac52a 100644 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageHandler.java +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageHandler.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.course.infrastructure.stream; +package com.example.mohago_nocar.course.infrastructure.course.messaging; import com.example.mohago_nocar.global.notification.application.developer.DeveloperNotificationUseCase; import lombok.RequiredArgsConstructor; @@ -13,20 +13,15 @@ public class LongPendingMessageHandler { private final LongPendingMessageReader longPendingMessageReader; - private final DeadMessageProcessor deadMessageProcessor; private final DeveloperNotificationUseCase developerNotificationUseCase; private static final int FIXED_RATE_IN_MILS = 60_000; // 1분 @Scheduled(fixedRate = FIXED_RATE_IN_MILS) public void process() { - try { - List pendingMessages = longPendingMessageReader.read(); - deadMessageProcessor.process(pendingMessages); // Long pending 시 dead Message 로 간주 - } catch (Exception e) { - developerNotificationUseCase.sendNotification( - "Exception occurred while processing long pending messages", e); - } + List pendingMessages = longPendingMessageReader.read(); + developerNotificationUseCase.sendNotification( + "Long Pending 메시지가 " + pendingMessages.size() + "개 있습니다."); } } diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageReader.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageReader.java similarity index 83% rename from src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageReader.java rename to src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageReader.java index 1bd5aa7..cec7612 100644 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/LongPendingMessageReader.java +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageReader.java @@ -1,6 +1,6 @@ -package com.example.mohago_nocar.course.infrastructure.stream; +package com.example.mohago_nocar.course.infrastructure.course.messaging; -import com.example.mohago_nocar.global.messaging.RedisStreamHelper; +import com.example.mohago_nocar.global.util.RedisStreamHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Range; @@ -17,7 +17,7 @@ public class LongPendingMessageReader { private final String consumerGroupName; private final String consumerName; private final int maxScanNum; - private final long idleTimeThresholdMs; + private final long idleTimeThresholdMills; private final RedisStreamHelper redisStreamHelper; @@ -32,7 +32,7 @@ public List read() { } return pendingMessages.stream() - .filter(msg -> msg.getElapsedTimeSinceLastDelivery().toMillis() > idleTimeThresholdMs) + .filter(msg -> msg.getElapsedTimeSinceLastDelivery().toMillis() > idleTimeThresholdMills) .toList(); } diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisher.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisher.java new file mode 100644 index 0000000..b41d3e0 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisher.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; + +public interface TravelCourseOptimizedEventPublisher { + + void publish(TravelCourseOptimizedEvent event); + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java new file mode 100644 index 0000000..fec7c0a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java @@ -0,0 +1,142 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.application.course.TravelCourseCompletionNotifier; +import com.example.mohago_nocar.course.application.course.TravelCourseOptimizedEventHandler; +import com.example.mohago_nocar.course.domain.event.ThrottlingCompletedEvent; +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.messaging.DeadLetterQueueEntryDto; +import com.example.mohago_nocar.global.messaging.DeadLetterQueueService; +import com.example.mohago_nocar.global.util.RedisStreamHelper; +import com.example.mohago_nocar.global.common.RetryPolicy; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationService; +import com.example.mohago_nocar.global.util.Result; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.stream.StreamListener; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Getter +@RequiredArgsConstructor +public class TravelCourseOptimizedMessageConsumer + implements StreamListener> { + + private final String streamName; + private final String consumerGroupName; + private final String consumerName; + + private final RedisStreamHelper redisStreamHelper; + private final DeadLetterQueueService dlqService; + private final ObjectMapper objectMapper; + private final UserNotificationService userNotificationService; + private final RetryPolicy retryPolicy; + private final TravelCourseOptimizedEventHandler eventHandler; + private final TravelCourseCompletionNotifier travelCourseNotifier; + + private ExecutorService executorService; + private Semaphore semaphore; + + @PostConstruct + public void init() { + executorService = Executors.newVirtualThreadPerTaskExecutor(); + semaphore = new Semaphore(0); + } + + @Override + public void onMessage(ObjectRecord message) { + log.info("Received message {}", message); + + executorService.submit(() -> { + processEvent(message); + }); + + try { + if (!semaphore.tryAcquire(1, TimeUnit.SECONDS)) { + log.info("idle 대기 시간을 넘겼습니다. 다음 메시지를 처리할 수 있도록 컨슈머 스레드의 블로킹을 해제합니다."); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private void processEvent(ObjectRecord message) { + TravelCourseOptimizedEvent event = null; + try { + event = objectMapper.readValue(message.getValue(), TravelCourseOptimizedEvent.class); + } catch (JsonProcessingException e) { + log.error("메시지 역직렬화 중 에러가 발생했습니다."); + saveToDeadLetterQueue(message, e); + ackAndDel(message); + return; + } + + try { + eventHandler.handleEvent(event); + travelCourseNotifier.sendNotificationOnce( + event.getTravelCourseId(), Result.SUCCESS, exception -> saveToDeadLetterQueue(message, exception)); + } catch (Exception ex) { + handleException(event, message, ex); + } finally { + ackAndDel(message); + } + } + + private void handleException(TravelCourseOptimizedEvent event, ObjectRecord message, Exception e) { + if (retryPolicy.isRetryable(e)) { + saveToDeadLetterQueue(message, e); + eventHandler.markAsWaitingReprocessing(event.getTravelCourseId()); + return; + } + + eventHandler.markAsFailed(event.getTravelCourseId()); + travelCourseNotifier.sendNotificationOnce( + event.getTravelCourseId(), Result.FAILURE, exception -> saveToDeadLetterQueue(message, exception)); + } + + private void saveToDeadLetterQueue(ObjectRecord message, Exception ex) { + log.info("재시도 가능한 예외입니다. 재처리를 위해 메시지를 저장합니다."); + DeadLetterQueueEntryDto dto = DeadLetterQueueEntryDto.of( + message.getId().getValue(), streamName, getConsumerGroupName(), getConsumerName(), message.getValue(), ex); + dlqService.save(dto); + } + + private void ackAndDel(ObjectRecord message) { + redisStreamHelper.acknowledgeAndDelete(streamName, consumerGroupName, message.getId()); + } + + @EventListener + public void allowNextConsume(ThrottlingCompletedEvent event) { + log.info("travelCourse ID:{} throttling completed", event.getTravelCourseId()); + semaphore.release(); + } + + @PreDestroy + private void destroy() { + log.info("ExecutorService in TravelCourseOptimizedMessageConsumer shutdown 시작"); + executorService.shutdown(); + try { + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + log.warn("정상 종료에 실패했습니다. 강제 종료를 시작합니다."); + executorService.shutdownNow(); + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + log.error("강제 종료에 실패하였습니다."); + } + } + } catch (InterruptedException e) { + log.error("인터럽트 발생", e); + executorService.shutdownNow(); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageProducer.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageProducer.java new file mode 100644 index 0000000..63deb13 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageProducer.java @@ -0,0 +1,34 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +public class TravelCourseOptimizedMessageProducer implements TravelCourseOptimizedEventPublisher { + + private final String streamKey; + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapperUtil objectMapperUtil; + + @Override + public void publish(TravelCourseOptimizedEvent event) { + String eventStr = objectMapperUtil.writeValue(event); + + ObjectRecord record = StreamRecords + .newRecord() + .ofObject(eventStr) + .withStreamKey(streamKey); + + stringRedisTemplate.opsForStream().add(record); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageRetryPolicy.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageRetryPolicy.java new file mode 100644 index 0000000..2b06b36 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageRetryPolicy.java @@ -0,0 +1,47 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.global.common.RetryPolicy; +import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.TransientDataAccessException; + +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.http.HttpTimeoutException; +import java.util.concurrent.CompletionException; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelCourseOptimizedMessageRetryPolicy implements RetryPolicy { + + @Override + public boolean isRetryable(Throwable throwable) { + if (throwable instanceof SocketTimeoutException || + throwable instanceof ConnectException || + throwable instanceof HttpTimeoutException){ + return true; + } + + if (throwable instanceof TransientDataAccessException || + throwable instanceof DataAccessResourceFailureException){ + return true; + } + + if (throwable instanceof CompletionException exception) { + if (exception.getCause() instanceof ODsayRouteException odsayException) { + return odsayException.getErrorCode().isTooManyRequests() || + odsayException.getErrorCode().isServerError(); + } + } + + // todo Completion Exception 안에 OdsayRouteException이 있을 때 아래로 안잡히는지 테스트 + if (throwable instanceof ODsayRouteException exception){ + return exception.getErrorCode().isTooManyRequests() || + exception.getErrorCode().isServerError(); + } + + return false; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java new file mode 100644 index 0000000..1d17bce --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java @@ -0,0 +1,110 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.application.course.TravelCourseCompletionNotifier; +import com.example.mohago_nocar.course.application.course.TravelCourseOptimizedEventHandler; +import com.example.mohago_nocar.global.messaging.DeadLetterQueueService; +import com.example.mohago_nocar.global.util.RedisStreamHelper; +import com.example.mohago_nocar.global.common.RetryPolicy; +import com.example.mohago_nocar.global.notification.application.developer.DeveloperNotificationUseCase; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationService; +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class TravelCourseOptimizedStreamConfig { + + @Value("${redis.streams.course.optimized.main}") + private String streamKey; + + private static final String CONSUMER_GROUP = "processors"; + private static final String CONSUMER_1 = "processor-1"; + private int messagesPerPolling = 3; + + @Bean + public TravelCourseOptimizedStreamContainer travelCourseOptimizedStreamContainer( + TravelCourseOptimizedMessageConsumer consumer, + TravelCourseOptimizedStreamRecoveryManager recoveryManager, + StringRedisTemplate stringRedisTemplate + ) { + return new TravelCourseOptimizedStreamContainer( + CONSUMER_GROUP, + CONSUMER_1, + messagesPerPolling, + consumer, + recoveryManager, + stringRedisTemplate + ); + } + + @Bean + public TravelCourseOptimizedStreamRecoveryManager travelCourseOptimizedStreamRecoveryManager( + StringRedisTemplate stringRedisTemplate, + TravelCourseOptimizedMessageConsumer consumer + ){ + return new TravelCourseOptimizedStreamRecoveryManager( + stringRedisTemplate, consumer + ); + } + + @Bean + public TravelCourseOptimizedMessageProducer travelSpotOrderEventProducer( + StringRedisTemplate stringRedisTemplate, + ObjectMapperUtil objectMapperUtil + ) { + return new TravelCourseOptimizedMessageProducer( + streamKey, stringRedisTemplate, objectMapperUtil); + } + + @Bean + public TravelCourseOptimizedMessageConsumer travelCourseOptimizedMessageConsumer( + RedisStreamHelper redisStreamHelper, + ObjectMapper objectMapper, + DeadLetterQueueService deadLetterQueueService, + UserNotificationService userNotificationService, + RetryPolicy travelSpotOptimizedStreamMsgRetryPolicy, + TravelCourseOptimizedEventHandler travelCourseOptimizedEventHandler, + TravelCourseCompletionNotifier travelCourseNotifier) { + return new TravelCourseOptimizedMessageConsumer( + streamKey, + CONSUMER_GROUP, + CONSUMER_1, + redisStreamHelper, + deadLetterQueueService, + objectMapper, + userNotificationService, + travelSpotOptimizedStreamMsgRetryPolicy, + travelCourseOptimizedEventHandler, + travelCourseNotifier + ); + } + + @Bean + public TravelCourseOptimizedMessageRetryPolicy travelSpotOptimizedStreamMsgRetryPolicy() { + return new TravelCourseOptimizedMessageRetryPolicy(); + } + + @Bean + public LongPendingMessageReader travelSpotOptimizedLongPendingMessageReader(RedisStreamHelper redisStreamHelper) { + return new LongPendingMessageReader( + streamKey, CONSUMER_GROUP, CONSUMER_1, 200, 30_000, redisStreamHelper); + } + + @Bean + public LongPendingMessageHandler travelSpotOptimizedLongPendingMessageHandler( + LongPendingMessageReader travelSpotOptimizedLongPendingMessageReader, + DeveloperNotificationUseCase developerNotificationUseCase + ) { + return new LongPendingMessageHandler( + travelSpotOptimizedLongPendingMessageReader, + developerNotificationUseCase); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamContainer.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamContainer.java new file mode 100644 index 0000000..de8d394 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamContainer.java @@ -0,0 +1,115 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import io.lettuce.core.RedisBusyException; +import io.sentry.Sentry; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.SmartLifecycle; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; + +import java.time.Duration; +import java.util.Objects; + +@Slf4j +@RequiredArgsConstructor +public class TravelCourseOptimizedStreamContainer implements SmartLifecycle { + + @Value("${redis.streams.course.optimized.main}") + private String streamKey; + + private final String consumerGroupName; + private final String consumerName; + public final int messagesPerPolling; + + private final TravelCourseOptimizedMessageConsumer messageConsumer; + private final TravelCourseOptimizedStreamRecoveryManager recoveryManager; + private final StringRedisTemplate stringRedisTemplate; + + private StreamMessageListenerContainer> streamMessageListenerContainer; + + @PostConstruct + public void init() { + createStreamConsumerGroupIfNotExists(streamKey, consumerGroupName); + this.streamMessageListenerContainer = createContainer(); + } + + private void createStreamConsumerGroupIfNotExists(String streamKey, String consumerGroup) { + try { + stringRedisTemplate.opsForStream() + .createGroup(streamKey, ReadOffset.from("0"), consumerGroup); + log.info("Consumer group {} created", consumerGroup); + + } catch (RedisSystemException e) { + Throwable cause = e.getCause(); + + if (cause instanceof RedisBusyException busyException) { + log.info(busyException.getMessage()); + return; + } + + throw new RuntimeException( + "Unexpected error while creating consumer group: ", cause); + } + } + + private StreamMessageListenerContainer> createContainer() { + StreamMessageListenerContainer> listenerContainer = + StreamMessageListenerContainer.create( + Objects.requireNonNull(stringRedisTemplate.getConnectionFactory()), + StreamMessageListenerContainer + .StreamMessageListenerContainerOptions.builder() + .targetType(String.class) + .pollTimeout(Duration.ofSeconds(3)) + .batchSize(messagesPerPolling) + .errorHandler(throwable -> { + log.error("Error occurs during Redis stream message consuming", throwable); + Sentry.captureException(throwable); + }) + .build() + ); + + listenerContainer.register( + StreamMessageListenerContainer.StreamReadRequest + .builder(StreamOffset.create(streamKey, ReadOffset.lastConsumed())) + //turn off auto shutdown of stream consumer if an error occurs. + .cancelOnError(throwable -> false) + .consumer(Consumer.from( + messageConsumer.getConsumerGroupName(), + messageConsumer.getConsumerName())) + .autoAcknowledge(false).build() + , messageConsumer); + + return listenerContainer; + } + + @Override + public void start() { +// recoveryManager.recovery(streamKey, consumerGroupName, consumerName); + streamMessageListenerContainer.start(); + } + + @Override + public void stop() { + streamMessageListenerContainer.stop(); + } + + @Override + public boolean isRunning() { + return streamMessageListenerContainer.isRunning(); + } + + @Override + public int getPhase() { + return Integer.MAX_VALUE; + } + +} + + + + diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamRecoveryManager.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamRecoveryManager.java new file mode 100644 index 0000000..6dd12c9 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamRecoveryManager.java @@ -0,0 +1,59 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.StreamOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +public class TravelCourseOptimizedStreamRecoveryManager { + + private final StringRedisTemplate stringRedisTemplate; + private final TravelCourseOptimizedMessageConsumer travelCourseOptimizedMessageConsumer; + + public void recovery(String streamKey, String consumerGroupName, String consumerName) { + StreamOperations streamOps = stringRedisTemplate.opsForStream(); + + // 아래 코드 실행 시 라이브러리 버그로 인한 오류 발생 +// PendingMessages pendingMessages = streamOps.pending(streamKey, Consumer.from(consumerGroupName, consumerName)); +// long totalPendingMessages = pendingMessages.size(); + + // 현재 단일 컨슈머이므로 그룹 내 모든 Pending Message 조회 + PendingMessagesSummary summary = streamOps.pending(streamKey, consumerGroupName); + long totalPendingMessages = summary.getTotalPendingMessages(); + log.info("Pending messages in consumer group [{}]: {}", consumerGroupName, totalPendingMessages); + + StreamReadOptions options = StreamReadOptions.empty() + .count(totalPendingMessages); + + Consumer consumer = Consumer.from(consumerGroupName, consumerName); + + List> records = streamOps.read( + consumer, + options, + StreamOffset.fromStart(streamKey)).stream() + .map(record -> { + RecordId id = record.getId(); + Map recordValue = record.getValue(); + return ObjectRecord.create(streamKey, recordValue.get("payload").toString()).withId(id); + }) + .toList(); + + log.info("Starting recovery process for pending messages"); + for (ObjectRecord record : records) { + try { + travelCourseOptimizedMessageConsumer.onMessage(record); + } catch (Exception e) { + log.error("Failed to reprocess pending message. RecordId={}", record.getId(), e); + } + + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java new file mode 100644 index 0000000..261dc7e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface TravelCourseJpaRepository extends JpaRepository { + + @Query(""" + SELECT new com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto( + au.id, + au.fcmToken + ) + FROM TravelCourse tc + JOIN AnonymousUser au ON tc.anonymousUserId = au.id + WHERE tc.id = :travelCourseId + """) + Optional findRequesterInfoByTravelCourseId(@Param("travelCourseId") Long travelCourseId); + + @Query("SELECT tc FROM TravelCourse tc " + + "WHERE tc.createdAt <= :thresholdTime " + + "AND tc.notificationSent = :notificationSent") + List findOutdatedCoursesNeedingNotification( + @Param("thresholdTime") LocalDateTime thresholdTime, + @Param("notificationSent") Boolean notificationSent); + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java new file mode 100644 index 0000000..ad492d5 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.repository.TravelCourseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class TravelCourseRepositoryImpl implements TravelCourseRepository { + + private final TravelCourseJpaRepository travelCourseJpaRepository; + + @Override + public TravelCourse save(TravelCourse course) { + return travelCourseJpaRepository.save(course); + } + + @Override + public Optional findById(Long travelCourseId) { + return travelCourseJpaRepository.findById(travelCourseId); + } + + @Override + public Optional getRequestrInfo(Long travelCourseId) { + return travelCourseJpaRepository.findRequesterInfoByTravelCourseId(travelCourseId); + } + + @Override + public List findOutdatedCoursesNeedingNotification(LocalDateTime thresholdTime, Boolean notificationSent) { + return travelCourseJpaRepository.findOutdatedCoursesNeedingNotification(thresholdTime, Boolean.FALSE); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepJpaRepository.java similarity index 52% rename from src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepJpaRepository.java rename to src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepJpaRepository.java index 7a6190e..cac562a 100644 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepJpaRepository.java +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepJpaRepository.java @@ -1,7 +1,12 @@ -package com.example.mohago_nocar.course.infrastructure.routeStep; +package com.example.mohago_nocar.course.infrastructure.route; import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface RouteStepJpaRepository extends JpaRepository { + + Optional findByOriginSpotIdAndDestinationSpotId(Long originSpotId, Long destinationSpotId); + } diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepRepositoryImpl.java new file mode 100644 index 0000000..f1a2f07 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.course.infrastructure.route; + +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.repository.RouteStepRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class RouteStepRepositoryImpl implements RouteStepRepository { + + private final RouteStepJpaRepository routeStepJpaRepository; + + @Override + public List saveAll(List routeSteps) { + return routeStepJpaRepository.saveAll(routeSteps); + } + + @Override + public Optional findByOriginAndDestination(Long originSpotId, Long destinationSpotId) { + return routeStepJpaRepository.findByOriginSpotIdAndDestinationSpotId(originSpotId, destinationSpotId); + } + + @Override + public List findByIds(List ids) { + return routeStepJpaRepository.findAllById(ids); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepRepositoryImpl.java deleted file mode 100644 index 7810897..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepRepositoryImpl.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.routeStep; - -import com.example.mohago_nocar.course.domain.repository.RouteStepRepository; -import com.example.mohago_nocar.course.infrastructure.routeStep.RouteStepJpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public class RouteStepRepositoryImpl implements RouteStepRepository { - - private RouteStepJpaRepository routeStepJpaRepository; -} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotJpaRepository.java new file mode 100644 index 0000000..cae6ce1 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotJpaRepository.java @@ -0,0 +1,11 @@ +package com.example.mohago_nocar.course.infrastructure.spot; + +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TravelSpotJpaRepository extends JpaRepository { + List findByCourseId(@NotNull Long courseId); +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotRepositoryImpl.java new file mode 100644 index 0000000..cde8c78 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotRepositoryImpl.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.course.infrastructure.spot; + +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.course.domain.repository.TravelSpotRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class TravelSpotRepositoryImpl implements TravelSpotRepository { + + private final TravelSpotJpaRepository travelSpotJpaRepository; + + @Override + public TravelSpot save(TravelSpot travelSpot) { + return travelSpotJpaRepository.save(travelSpot); + } + + // todo 배치 insert 지원 시 Jpa 기본 saveAll 사용 + @Override + public List saveAll(Collection travelSpots) { + return travelSpotJpaRepository.saveAll(travelSpots); + } + + @Override + public List findByTravelCourseId(Long travelCourseId) { + return travelSpotJpaRepository.findByCourseId(travelCourseId); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessor.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessor.java deleted file mode 100644 index eaedbea..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessor.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.stream; - -import com.example.mohago_nocar.global.messaging.DeadLetterQueueService; -import com.example.mohago_nocar.global.messaging.RedisStreamHelper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.connection.stream.MapRecord; -import org.springframework.data.redis.connection.stream.PendingMessage; -import org.springframework.data.redis.connection.stream.RecordId; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@RequiredArgsConstructor -@Slf4j -public class DeadMessageProcessor { - - private final RedisStreamHelper redisStreamHelper; - private final DeadLetterQueueService deadLetterQueueService; - - private final String streamKey; - private final String consumerGroup; - - @Transactional - public void process(List deadMessages) { - log.warn("Processing dead messages: {}", deadMessages); - List dtos = deadMessages.stream() - .map(pm -> { - MapRecord readMessage = redisStreamHelper - .readMessage(streamKey, pm.getId()); - - return DeadLetterQueueEntryDto.from(streamKey, pm, readMessage.getValue().toString()); - }) - .toList(); - - deadLetterQueueService.saveAll(dtos); - - RecordId[] recordIds = deadMessages.stream() - .map(PendingMessage::getId) - .toArray(RecordId[]::new); - - try { - redisStreamHelper.acknowledgeAndDelete(streamKey, consumerGroup, recordIds); - } catch (RedisSystemException e) { - log.error("Failed to acknowledge messages By RedisSystemException : {}", e.getMessage(), e); - throw e; - } catch (Exception e) { - log.error("Failed to acknowledge messages By Exception : {}", e.getMessage(), e); - throw e; - } - - log.debug("Successfully processed dead letter messages, size: {}", deadMessages.size()); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotJpaRepository.java deleted file mode 100644 index 5f440c0..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotJpaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.travelSpot; - -import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface TravelSpotJpaRepository extends JpaRepository { -} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotRepositoryImpl.java deleted file mode 100644 index 78272c8..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotRepositoryImpl.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.travelSpot; - -import com.example.mohago_nocar.course.domain.repository.TravelSpotRepository; -import com.example.mohago_nocar.course.infrastructure.travelSpot.TravelSpotJpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public class TravelSpotRepositoryImpl implements TravelSpotRepository { - - private TravelSpotJpaRepository travelSpotJpaRepository; -} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/TravelCourseController.java b/src/main/java/com/example/mohago_nocar/course/presentation/TravelCourseController.java new file mode 100644 index 0000000..153af4c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/TravelCourseController.java @@ -0,0 +1,40 @@ +package com.example.mohago_nocar.course.presentation; + +import com.example.mohago_nocar.course.application.dto.RouteStepDto; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.course.presentation.dto.CreateTravelCourseRequestDto; +import com.example.mohago_nocar.course.presentation.dto.CreateOptimizedTravelCourseAcceptedResponseDto; +import com.example.mohago_nocar.course.presentation.dto.GetOptimizedTravelCourseResponseDto; +import com.example.mohago_nocar.global.common.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/travel/courses") +public class TravelCourseController { + + private final TravelCourseUseCase travelCourseUseCase; + + @PostMapping("/") + public ApiResponse createOptimizedTravelCourseWithRoute( + @RequestBody @Valid CreateTravelCourseRequestDto request + ) { + CreateOptimizedTravelCourseAcceptedResponseDto response = travelCourseUseCase.createOptimizedTravelCourse(request); + return ApiResponse.ok(response); + } + + @GetMapping("/{courseId}") + public ApiResponse getOptimizedTravelCourseWithRoutes( + @PathVariable Long courseId, + @RequestParam UUID ownerUserId + ) { + List course = travelCourseUseCase.getOptimizedTravelCourseRoutes(courseId, ownerUserId); + return ApiResponse.ok(GetOptimizedTravelCourseResponseDto.of(course)); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateOptimizedTravelCourseAcceptedResponseDto.java b/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateOptimizedTravelCourseAcceptedResponseDto.java new file mode 100644 index 0000000..08cf341 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateOptimizedTravelCourseAcceptedResponseDto.java @@ -0,0 +1,14 @@ +package com.example.mohago_nocar.course.presentation.dto; + +import java.util.UUID; + +public record CreateOptimizedTravelCourseAcceptedResponseDto( + Long courseId, + UUID anonymousUserId +) { + + public static CreateOptimizedTravelCourseAcceptedResponseDto of(Long courseId, UUID anonymousUserId){ + return new CreateOptimizedTravelCourseAcceptedResponseDto(courseId, anonymousUserId); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateTravelCourseRequestDto.java b/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateTravelCourseRequestDto.java new file mode 100644 index 0000000..1205d05 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateTravelCourseRequestDto.java @@ -0,0 +1,26 @@ +package com.example.mohago_nocar.course.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; +import java.util.List; + +// todo 검증 추가 +public record CreateTravelCourseRequestDto( + + @Schema(description = "FCM 토큰") + String fcmToken, + + @Schema(description = "축제 아이디") + Long festivalId, + + // todo 검증 어노테이션 추가 + @Size(max = 4) + @Schema(description = "선택된 여행 장소들의 아이디로, 최소 2개-최대 4개 선택 가능", example = "[1234]") + List placeIds, + + @Schema(description = "여행 시작 날짜") + LocalDate travelStartDate +) { +} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseRequestDto.java b/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseRequestDto.java new file mode 100644 index 0000000..206f6d4 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseRequestDto.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.course.presentation.dto; + +import jakarta.validation.constraints.NotBlank; + +import java.util.UUID; + +public record GetOptimizedTravelCourseRequestDto( + @NotBlank + UUID anonymousUserId, + @NotBlank + Long travelCourseId +) { +} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseResponseDto.java b/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseResponseDto.java new file mode 100644 index 0000000..dd4f35c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseResponseDto.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.course.presentation.dto; + +import com.example.mohago_nocar.course.application.dto.RouteStepDto; + +import java.util.List; + +public record GetOptimizedTravelCourseResponseDto( + List routeSteps +) { + public static GetOptimizedTravelCourseResponseDto of(List stepDtos) { + return new GetOptimizedTravelCourseResponseDto(stepDtos); + } +} diff --git a/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java b/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java index b5aa311..672e4e3 100644 --- a/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java +++ b/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java @@ -14,6 +14,7 @@ @Entity @Getter +@Table(name = "festival") @NoArgsConstructor(access = PROTECTED) public class Festival extends BaseEntity { @@ -62,4 +63,9 @@ private Festival(String name, ActivePeriod activePeriod, String description, Str public boolean isDateDuringFestival(LocalDate travelDate) { return activePeriod.containsDate(travelDate); } + + public boolean isOpen(LocalDate travelDate) { + return activePeriod.containsDate(travelDate); + } + } diff --git a/src/main/java/com/example/mohago_nocar/festival/domain/model/FestivalImage.java b/src/main/java/com/example/mohago_nocar/festival/domain/model/FestivalImage.java index 3acd6bb..e153520 100644 --- a/src/main/java/com/example/mohago_nocar/festival/domain/model/FestivalImage.java +++ b/src/main/java/com/example/mohago_nocar/festival/domain/model/FestivalImage.java @@ -4,6 +4,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; +import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; @@ -17,6 +18,7 @@ @Entity @Getter @Builder +@Table(name = "festival_image") @AllArgsConstructor(access = PRIVATE) @NoArgsConstructor(access = PROTECTED) public class FestivalImage extends BaseEntity { diff --git a/src/main/java/com/example/mohago_nocar/global/common/RetryPolicy.java b/src/main/java/com/example/mohago_nocar/global/common/RetryPolicy.java new file mode 100644 index 0000000..9ab657c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/common/RetryPolicy.java @@ -0,0 +1,7 @@ +package com.example.mohago_nocar.global.common; + +public interface RetryPolicy { + + boolean isRetryable(Throwable throwable); + +} diff --git a/src/main/java/com/example/mohago_nocar/global/common/domain/BaseEntity.java b/src/main/java/com/example/mohago_nocar/global/common/domain/BaseEntity.java index d7eced5..f9d3302 100644 --- a/src/main/java/com/example/mohago_nocar/global/common/domain/BaseEntity.java +++ b/src/main/java/com/example/mohago_nocar/global/common/domain/BaseEntity.java @@ -21,4 +21,9 @@ public abstract class BaseEntity { @LastModifiedDate private LocalDateTime updatedAt; + + public void setUpdatedAt(LocalDateTime newUpdatedAt) { + this.updatedAt = newUpdatedAt; + } + } diff --git a/src/main/java/com/example/mohago_nocar/global/common/exception/CustomException.java b/src/main/java/com/example/mohago_nocar/global/common/exception/CustomException.java index e7db234..0a8d3b7 100644 --- a/src/main/java/com/example/mohago_nocar/global/common/exception/CustomException.java +++ b/src/main/java/com/example/mohago_nocar/global/common/exception/CustomException.java @@ -2,6 +2,7 @@ import lombok.Getter; +// todo: businessException으로 이름 변경 @Getter public class CustomException extends RuntimeException { diff --git a/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java b/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java index eda478b..4c06c64 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java @@ -11,7 +11,7 @@ @Profile("test") public class EmbeddedRedisConfig { - @Value("${spring.data.redis.port}") +/* @Value("${spring.data.redis.port") private int port; private RedisServer redisServer; @@ -30,6 +30,6 @@ public void stopEmbeddedRedis() { if (redisServer != null) { redisServer.stop(); } - } + }*/ } diff --git a/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java b/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java index c1e006b..64d6bfa 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java @@ -7,6 +7,7 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -28,29 +29,22 @@ public RedisConnectionFactory redisConnectionFactory() { } @Bean - public RedisTemplate redisTemplateForValue() { - RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(redisConnectionFactory()); - - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); - redisTemplate.afterPropertiesSet(); // 설정 후 초기화 작업 수행 - - return redisTemplate; - } - - @Bean - public RedisTemplate redisTemplateForHash() { + public RedisTemplate objectRedisTemplate() { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(redisConnectionFactory()); - GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); + + // key (string) template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); + + // value (object) + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); + template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); + return template; } diff --git a/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java b/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java deleted file mode 100644 index 2561cbd..0000000 --- a/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.example.mohago_nocar.global.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.JdkClientHttpRequestFactory; -import org.springframework.web.client.RestClient; - -import java.net.http.HttpClient; -import java.time.Duration; -import java.util.concurrent.Executors; - -@Configuration -public class RestClientConfig { - - @Value("${spring.threads.virtual.enabled}") - private boolean isVirtualThreadEnabled; - - @Bean - public RestClient restClient() { - var httpRequestFactory = createHttpRequestFactory(); - httpRequestFactory.setReadTimeout(Duration.ofSeconds(10)); - - return RestClient.builder() - .requestFactory(httpRequestFactory) - .build(); - } - - private JdkClientHttpRequestFactory createHttpRequestFactory() { - if (isVirtualThreadEnabled) { - return new JdkClientHttpRequestFactory(HttpClient.newBuilder() - .executor(Executors.newVirtualThreadPerTaskExecutor()) - .build()); - } - - return new JdkClientHttpRequestFactory(); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/global/config/TimeZoneConfig.java b/src/main/java/com/example/mohago_nocar/global/config/TimeZoneConfig.java new file mode 100644 index 0000000..0064d52 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/TimeZoneConfig.java @@ -0,0 +1,16 @@ +package com.example.mohago_nocar.global.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; + +import java.util.TimeZone; + +@Component +public class TimeZoneConfig { + + @PostConstruct + public void configTimeZone() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterIdleTimeoutException.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterIdleTimeoutException.java deleted file mode 100644 index 2397832..0000000 --- a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterIdleTimeoutException.java +++ /dev/null @@ -1,10 +0,0 @@ - -package com.example.mohago_nocar.global.messaging; - -public class DeadLetterIdleTimeoutException extends RuntimeException { - - public DeadLetterIdleTimeoutException(String message) { - super(message); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterProcessingException.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterProcessingException.java deleted file mode 100644 index c54607f..0000000 --- a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterProcessingException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.mohago_nocar.global.messaging; - -public class DeadLetterProcessingException extends RuntimeException { - - public DeadLetterProcessingException(String message) { - super(message); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java index 975174e..eae10ac 100644 --- a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java @@ -1,9 +1,9 @@ package com.example.mohago_nocar.global.messaging; -import com.example.mohago_nocar.course.infrastructure.stream.DeadLetterQueueEntryDto; import com.example.mohago_nocar.global.common.domain.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.hibernate.annotations.Comment; import java.time.LocalDateTime; @@ -48,9 +48,6 @@ public class DeadLetterQueueEntry extends BaseEntity { @Comment("스택 트레이스") private String stackTrace; - @Comment("전달 시도 횟수") - private Long deliveryCount; - @Column(length = 100) @Comment("마지막 처리 컨슈머") private String lastConsumer; @@ -67,7 +64,7 @@ public class DeadLetterQueueEntry extends BaseEntity { @Builder(access = AccessLevel.PRIVATE) private DeadLetterQueueEntry(String streamKey, String consumerGroup, String entryId, String payload, String exceptionType, String errorMessage, - String stackTrace, Long deliveryCount, String lastConsumer, + String stackTrace, String lastConsumer, DLQStatus status, LocalDateTime resolvedAt) { this.streamKey = streamKey; this.consumerGroup = consumerGroup; @@ -76,7 +73,6 @@ private DeadLetterQueueEntry(String streamKey, String consumerGroup, String entr this.exceptionType = exceptionType; this.errorMessage = errorMessage; this.stackTrace = stackTrace; - this.deliveryCount = deliveryCount; this.lastConsumer = lastConsumer; this.status = status; this.resolvedAt = resolvedAt; @@ -88,7 +84,6 @@ public static DeadLetterQueueEntry create(DeadLetterQueueEntryDto dto) { .consumerGroup(dto.getGroupName()) .entryId(dto.getId()) .payload(dto.getPayload()) - .deliveryCount(dto.getDeliveryCount()) .lastConsumer(dto.getConsumerName()) .status(DLQStatus.NEW); @@ -96,7 +91,7 @@ public static DeadLetterQueueEntry create(DeadLetterQueueEntryDto dto) { Throwable throwable = dto.getThrowable(); return builder.errorMessage(throwable.getMessage()) .exceptionType(throwable.getClass().getSimpleName()) - .stackTrace(throwable.getStackTrace().toString()) + .stackTrace(ExceptionUtils.getStackTrace(throwable)) .build(); } else { return builder.build(); diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryDto.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryDto.java new file mode 100644 index 0000000..b1958bc --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryDto.java @@ -0,0 +1,48 @@ +package com.example.mohago_nocar.global.messaging; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.data.redis.connection.stream.PendingMessage; + +@Getter +@ToString +public class DeadLetterQueueEntryDto { + + private String id; + private String streamName; + private String groupName; + private String consumerName; + private String payload; + private Throwable throwable; + + public static DeadLetterQueueEntryDto of( + String id, String streamName, String groupName, String consumerName, String payload, Throwable throwable) { + return new DeadLetterQueueEntryDto(id, streamName, groupName, consumerName, payload, throwable); + } + + public static DeadLetterQueueEntryDto from(String streamName, PendingMessage pendingMessage, String payload) { + return new DeadLetterQueueEntryDto( + pendingMessage.getId().toString(), + streamName, + pendingMessage.getGroupName(), + pendingMessage.getConsumerName(), + payload + ,null + ); + } + + @Builder(access = AccessLevel.PRIVATE) + private DeadLetterQueueEntryDto( + String id, String streamName, String groupName, + String consumerName, String payload, Throwable throwable) { + this.id = id; + this.streamName = streamName; + this.groupName = groupName; + this.consumerName = consumerName; + this.payload = payload; + this.throwable = throwable; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java index ba69752..02bf18f 100644 --- a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java @@ -1,9 +1,8 @@ package com.example.mohago_nocar.global.messaging; -import com.example.mohago_nocar.course.infrastructure.stream.DeadLetterQueueEntryDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -12,7 +11,7 @@ import java.util.function.Function; import java.util.stream.Collectors; -@Component +@Service @RequiredArgsConstructor @Slf4j public class DeadLetterQueueService { @@ -21,6 +20,7 @@ public class DeadLetterQueueService { @Transactional public void save(DeadLetterQueueEntryDto dto) { + log.debug("Dead Letter를 저장합니다. DeadLetterQueueEntryDto: {}", dto); dlqRepository.save(DeadLetterQueueEntry.create(dto)); } diff --git a/src/main/java/com/example/mohago_nocar/global/notification/NotificationMessagingException.java b/src/main/java/com/example/mohago_nocar/global/notification/NotificationMessagingException.java new file mode 100644 index 0000000..8e95aa8 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/NotificationMessagingException.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.global.notification; + +public class NotificationMessagingException extends RuntimeException { + + public NotificationMessagingException(String message) { + super(message); + } + + public NotificationMessagingException(String message, Exception exception) { + super(message, exception); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCase.java b/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCase.java new file mode 100644 index 0000000..6677d8b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCase.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.global.notification.application.developer; + +public interface DeveloperNotificationUseCase { + + void sendNotification(String content); + + void sendNotification(String contentTitle, Throwable throwable); + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCaseImpl.java b/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCaseImpl.java new file mode 100644 index 0000000..a806e8a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCaseImpl.java @@ -0,0 +1,50 @@ +package com.example.mohago_nocar.global.notification.application.developer; + +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.global.notification.infrastructure.discord.DiscordMessage; +import com.example.mohago_nocar.global.notification.infrastructure.discord.DiscordMessageSender; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class DeveloperNotificationUseCaseImpl implements DeveloperNotificationUseCase { + + private final DiscordMessageSender discordMessageSender; + private final ObjectMapperUtil objectMapperUtil; + + @Override + public void sendNotification(String content) { + discordMessageSender.send(DiscordMessage.of(content)); + } + + @Override + public void sendNotification(String contentTitle, Throwable throwable) { + String content = makeSummaryContentInJson(contentTitle, throwable); + DiscordMessage discordMessage = DiscordMessage.of(content); + discordMessageSender.send(discordMessage); + } + + private String makeSummaryContentInJson(String msgTitle, Throwable throwable) { + String errorMessage = throwable.getMessage(); + + // 스택 트레이스 상위 5줄 추출 + StackTraceElement[] stackTrace = throwable.getStackTrace(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < Math.min(5, stackTrace.length); i++) { + sb.append(stackTrace[i].toString()).append("\n"); + } + + Map payloadMap = new HashMap<>(); + payloadMap.put("title", msgTitle); + payloadMap.put("errorMessage", errorMessage); + payloadMap.put("stackTrace", sb.toString()); + + String content = objectMapperUtil.writeValue(payloadMap); + return content; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationDto.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationDto.java new file mode 100644 index 0000000..bd678b7 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationDto.java @@ -0,0 +1,24 @@ +package com.example.mohago_nocar.global.notification.application.user; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; +import java.util.UUID; + +@Getter +@AllArgsConstructor +@Builder +public class UserNotificationDto { + + private String title; + private String body; + private UUID userId; + private Map customData; + + public static UserNotificationDto of(String title, String body, UUID userId, Map customData) { + return new UserNotificationDto(title, body, userId, customData); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationService.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationService.java new file mode 100644 index 0000000..402e65d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationService.java @@ -0,0 +1,46 @@ +package com.example.mohago_nocar.global.notification.application.user; + +public interface UserNotificationService { + + String send(UserNotificationDto userNotificationDto); + +} + +/** + * + * <타 도메인에서 사용 시> + * - NotifyAdapter.sendToFcm, sendToDiscord = NotifyService.send + * - 어댑터에서 Fcm, discord 메시지 빌드 + * - 타 도메인은 '안내 메시지'를 정의하는 책임 + * + * NotifyService [interface] --> make easy testing + * NotifyServiceImpl + * send(fcmMsgDto) + * send(discordMsgDto) + * - 알림 도메인은 '전송하는 방법' 책임 + * + * [단점] + * - fcm, discord 등 구현체를 알아야함 + * - 근데 구현체를 알아야하지 않나? 이게 단점인가? + */ + +/** + * [타 도메인] + * - Adapter interface + * send + * - TravelCourseAdapter + * send: + * 1. converter.convertToFcm + * 2. service.send + * - RetryableTravelCourseAdapter + * Retry retry; + * send: + * retryDecorate(() -> TravelCourseAdapter.send()) + * + * + * [알람 도메인] + * - service.sendFcm(FcmMsg) + * - service.sendFcm(fcmMsg, retry) + * - service.sendDiscord(DiscorMsg) + * + */ \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationServiceImpl.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationServiceImpl.java new file mode 100644 index 0000000..3221a5e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationServiceImpl.java @@ -0,0 +1,30 @@ +package com.example.mohago_nocar.global.notification.application.user; + +import com.example.mohago_nocar.global.notification.infrastructure.fcm.FcmMessage; +import com.example.mohago_nocar.global.notification.infrastructure.fcm.FcmMessageSender; +import com.example.mohago_nocar.user.domain.UserUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +@Component +@Slf4j +@RequiredArgsConstructor +public class UserNotificationServiceImpl implements UserNotificationService { + + private final FcmMessageSender fcmMessageSender; + private final UserUseCase userUseCase; + + @Override + public String send(UserNotificationDto dto) { // todo notnull validation + Objects.requireNonNull(dto); + + String fcmToken = userUseCase.getFcmToken(dto.getUserId()); + FcmMessage fcmMessage = FcmMessage.create(dto.getTitle(), dto.getBody(), fcmToken, dto.getCustomData()); + + return fcmMessageSender.send(fcmMessage); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessage.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessage.java new file mode 100644 index 0000000..51a9ebf --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessage.java @@ -0,0 +1,20 @@ +package com.example.mohago_nocar.global.notification.infrastructure.discord; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DiscordMessage { + + private final String content; + + // 필요 시 embeds 필드 추가... + + public static DiscordMessage of(String content) { + return new DiscordMessage(content); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessageSender.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessageSender.java new file mode 100644 index 0000000..fea3337 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessageSender.java @@ -0,0 +1,41 @@ +package com.example.mohago_nocar.global.notification.infrastructure.discord; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Component +public class DiscordMessageSender { + + @Value("${discord.webhook-url}") + private String webhookUrl; + private WebClient webClient; + + @PostConstruct + public void init() { + webClient = WebClient.builder().build(); + } + + public void send(DiscordMessage message) { + try { + webClient.post() + .uri(webhookUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(message) + .retrieve() + .toBodilessEntity() + .block(); + + log.info("Discord 메시지 전송 성공: content={}", message.getContent()); + } catch (Exception e) { + log.error("Discord 메시지 전송 실패: {}", e.getMessage(), e); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/config/FcmConfig.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmConfig.java similarity index 55% rename from src/main/java/com/example/mohago_nocar/global/config/FcmConfig.java rename to src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmConfig.java index 797e1ed..3712b36 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/FcmConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmConfig.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.global.config; +package com.example.mohago_nocar.global.notification.infrastructure.fcm; import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; @@ -7,63 +7,52 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; import org.springframework.context.annotation.Bean; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; import java.io.FileInputStream; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; +import java.util.concurrent.Callable; // todo Prod: 환경변수 GOOGLE_APPLICATION_CREDENTIALS 사용 --> 등록 필요 @Component @Slf4j @RequiredArgsConstructor -public class FcmConfig implements ApplicationRunner { +public class FcmConfig { @Value("${google.firebase.key.path}") private String firebaseKeyPath; private final Environment env; - @Override - public void run(ApplicationArguments args) throws Exception { - List profiles = Arrays.asList(env.getActiveProfiles()); - - if (profiles.contains("test")) { - log.warn("테스트 환경에서 FCM 테스트를 지원하지 않습니다."); - return; - } - - GoogleCredentials credentials = profiles.contains("dev") - ? loadFromLocalFile() - : loadFromApplicationDefault(); + @Bean + public FirebaseMessaging firebaseMessaging() { + GoogleCredentials credentials = loadFromLocalFile(); FirebaseOptions options = FirebaseOptions.builder() .setCredentials(credentials) + .setConnectTimeout(10_000) + .setReadTimeout(30_000) .build(); + FirebaseApp.initializeApp(options); - } - private GoogleCredentials loadFromApplicationDefault() { - try { - return GoogleCredentials.getApplicationDefault(); - } catch (IOException e) { - log.error("GoogleCredentials 획득 중 문제가 발생했습니다."); - log.error("에러: {}", e.getMessage()); - log.error("프로파일: {}", (Object) env.getActiveProfiles()); - throw new RuntimeException(e); - } + return FirebaseMessaging.getInstance(); } private GoogleCredentials loadFromLocalFile() { - try { + return getCredentials(() -> { FileInputStream serviceAccount = new FileInputStream(firebaseKeyPath); return GoogleCredentials.fromStream(serviceAccount); - } catch (IOException e) { + }); + } + + private GoogleCredentials getCredentials( + Callable callable + ) { + try { + return callable.call(); + } catch (Exception e) { log.error("GoogleCredentials 획득 중 문제가 발생했습니다."); log.error("에러: {}", e.getMessage()); log.error("프로파일: {}", (Object) env.getActiveProfiles()); @@ -71,9 +60,4 @@ private GoogleCredentials loadFromLocalFile() { } } - @Bean - public FirebaseMessaging firebaseMessaging() { - return FirebaseMessaging.getInstance(); - } - } diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessage.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessage.java new file mode 100644 index 0000000..a165ed5 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessage.java @@ -0,0 +1,40 @@ +package com.example.mohago_nocar.global.notification.infrastructure.fcm; + +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +@Builder +@RequiredArgsConstructor +@Getter +public class FcmMessage { + + private final String fcmToken; + private final Map customData; + private final Notification notification; + + public static FcmMessage create(String title, String body, String fcmToken, Map customData) { + return FcmMessage.builder() + .fcmToken(fcmToken) + .customData(customData) + .notification(Notification.builder().setTitle(title).setBody(body).build()) + .build(); + } + + public Message toMessage(FcmMessage fcmMessage) { + Message.Builder builder = Message.builder() + .setToken(fcmMessage.getFcmToken()) + .setNotification(fcmMessage.getNotification()); + + for (Map.Entry entry : fcmMessage.getCustomData().entrySet()) { + builder.putData(entry.getKey(), entry.getValue()); + } + + return builder.build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessageSender.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessageSender.java new file mode 100644 index 0000000..3824da5 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessageSender.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.global.notification.infrastructure.fcm; + +import com.example.mohago_nocar.global.notification.NotificationMessagingException; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + + +@Slf4j +@Component +@RequiredArgsConstructor +public class FcmMessageSender { + + private final FirebaseMessaging firebaseMessaging; + + public String send(FcmMessage fcmMessage) { + Message message = fcmMessage.toMessage(fcmMessage); + return sendMessage(message); + } + + private String sendMessage(Message message) { + try { + return firebaseMessaging.send(message); + } catch (FirebaseMessagingException e) { + log.error("FCM 알림 전송에 실패했습니다. 에러 메시지={}", e.getMessage()); + throw new NotificationMessagingException("알림 전송에 실패했습니다.", e); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/rateLimit/IntervalRateLimiter.java b/src/main/java/com/example/mohago_nocar/global/rateLimit/IntervalRateLimiter.java new file mode 100644 index 0000000..d31ae23 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/rateLimit/IntervalRateLimiter.java @@ -0,0 +1,50 @@ +package com.example.mohago_nocar.global.rateLimit; + +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import java.util.concurrent.locks.ReentrantLock; + +/** + * 연속된 작업 실행 사이의 최소 시간 간격을 보장합니다. + * 여러 스레드가 동시에 접근해도 thread-safe하게 rate limiting을 수행합니다. + */ +@Slf4j +public class IntervalRateLimiter implements RateLimiter { + + private long nextExecutionTimeNanos; + + private final long minIntervalNanos; + private final ReentrantLock entryLock; + + public IntervalRateLimiter(long minIntervalMillis) { + this.minIntervalNanos = TimeUnit.MILLISECONDS.toNanos(minIntervalMillis); + this.nextExecutionTimeNanos = System.nanoTime(); + this.entryLock = new ReentrantLock(); + } + + /** + * 다음 실행이 허용될 때까지 현재 스레드를 대기시킵니다. + * Rate limit을 초과하지 않도록 필요한 시간만큼 blocking합니다. + */ + @Override + public void throttle() { + entryLock.lock(); + try { + long now = System.nanoTime(); + long waitTimeNanos = Math.max(0, nextExecutionTimeNanos - now); + + if (waitTimeNanos > 0) { + LockSupport.parkNanos(waitTimeNanos); + } + + this.nextExecutionTimeNanos = System.nanoTime() + minIntervalNanos; + + } finally { + entryLock.unlock(); + } + + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/rateLimit/RateLimiter.java b/src/main/java/com/example/mohago_nocar/global/rateLimit/RateLimiter.java new file mode 100644 index 0000000..530aed2 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/rateLimit/RateLimiter.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.global.rateLimit; + +import java.util.concurrent.TimeUnit; + +public interface RateLimiter { + + void throttle(); + + static RateLimiter create(long minIntervalMillis) { + return new IntervalRateLimiter(minIntervalMillis); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java b/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java index 6e39aac..ddee971 100644 --- a/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java +++ b/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java @@ -21,6 +21,14 @@ public T readValue(String value, TypeReference typeReference) { } } + public T readValue(String value, Class clazz) { + try { + return objectMapper.readValue(value, clazz); + } catch (JsonProcessingException e) { + throw new InternalServerException(e.getMessage()); + } + } + public String writeValue(Object value) { try { return objectMapper.writeValueAsString(value); diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/RedisStreamHelper.java b/src/main/java/com/example/mohago_nocar/global/util/RedisStreamHelper.java similarity index 82% rename from src/main/java/com/example/mohago_nocar/global/messaging/RedisStreamHelper.java rename to src/main/java/com/example/mohago_nocar/global/util/RedisStreamHelper.java index 8e81976..c7abda8 100644 --- a/src/main/java/com/example/mohago_nocar/global/messaging/RedisStreamHelper.java +++ b/src/main/java/com/example/mohago_nocar/global/util/RedisStreamHelper.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.global.messaging; +package com.example.mohago_nocar.global.util; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Range; @@ -29,6 +29,16 @@ public PendingMessages getPendingMessages( maxScanNum); } + public PendingMessages getPendingMessages( + String streamKeyName, String consumerGroupName, int maxScanNum, Range range) { + return redisTemplate.opsForStream() + .pending( + streamKeyName, + consumerGroupName, + range, + maxScanNum); + } + public void acknowledgeAndDelete(String streamKeyName, String consumerGroupName, RecordId[] recordIds) { redisTemplate.opsForStream().acknowledge(streamKeyName, consumerGroupName, recordIds); redisTemplate.opsForStream().delete(streamKeyName, recordIds); @@ -40,7 +50,6 @@ public void acknowledgeAndDelete(String streamKeyName, String consumerGroupName, } public MapRecord readMessage(String streamKeyName, RecordId recordId) { - // 결과: MapBackedRecord{recordId=1761874798218-0, kvMap={user_name=k}} List> range = redisTemplate.opsForStream() .range(streamKeyName, Range.just(recordId.getValue())); diff --git a/src/main/java/com/example/mohago_nocar/global/util/Result.java b/src/main/java/com/example/mohago_nocar/global/util/Result.java new file mode 100644 index 0000000..bf2a22b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/util/Result.java @@ -0,0 +1,15 @@ +package com.example.mohago_nocar.global.util; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Result { + SUCCESS(true), FAILURE(false); + + private final boolean bool; + + public boolean isSuccess() { + return bool; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java index 32afc10..0d7ee89 100644 --- a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java +++ b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java @@ -38,6 +38,14 @@ public List getFestivalNearPlaces(Long festivalId) { return PlaceConverter.convertToNearPlaceResponseDtos(festivalId, places); } + // 아래 메서드 결과는 0 or all이어야함. 캐싱은 festival 주변 장소 단위거든요. + // todo 이를 반영하는 객체 만들기 + @Override + public List getFestivalNearPlacesById(Long festivalId, List placeIds) { + return placeRepository.findByIds(festivalId, placeIds); + } + + @Override public List cachePlaces(Long festivalId, Coordinate centerCoordinate) { KakaoPlacesResponse placesFromExternalApi = searchPlacesAround(centerCoordinate); List places = PlaceConverter.convertToPlaces(placesFromExternalApi); diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java b/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java index 44a5919..23f66ce 100644 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java +++ b/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java @@ -9,12 +9,13 @@ import static jakarta.persistence.EnumType.STRING; +// 이거 레디스 해시예욤... @Getter @NoArgsConstructor public class Place { @NotNull - private String id; + private String kakaoId; @NotNull private String name; @@ -33,7 +34,7 @@ public class Place { private PlaceCategory category; public static Place from( - String id, + String kakaoId, String name, Coordinate coordinate, String address, @@ -41,7 +42,7 @@ public static Place from( PlaceCategory category ) { return Place.builder() - .id(id) + .kakaoId(kakaoId) .name(name) .coordinate(coordinate) .address(address) @@ -52,14 +53,14 @@ public static Place from( @Builder private Place( - String id, + String kakaoId, String name, Coordinate coordinate, String address, String placeUrl, PlaceCategory category ) { - this.id = id; + this.kakaoId = kakaoId; this.name = name; this.coordinate = coordinate; this.address = address; diff --git a/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java b/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java index f32ca01..4d1d005 100644 --- a/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java +++ b/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java @@ -1,5 +1,7 @@ package com.example.mohago_nocar.place.domain.service; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.model.Place; import com.example.mohago_nocar.place.presentation.NearPlaceResponseDto; import java.util.List; @@ -8,4 +10,8 @@ public interface PlaceUseCase { List getFestivalNearPlaces(Long festivalId); + List getFestivalNearPlacesById(Long festivalId, List placeIds); + + List cachePlaces(Long festivalId, Coordinate centerCoordinate); + } diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java index d1a23b9..1d4c16a 100644 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java @@ -21,15 +21,16 @@ public class PlaceRepositoryImpl implements PlaceRepository { private static final String KEY_PREFIX = "festival:places:"; - private final RedisTemplate redisTemplateForValue; + private final RedisTemplate stringRedisTemplate; private final ObjectMapperUtil objectMapperUtil; + // 특정 아이디를 가지는 장소만 조회하도록 바꾸면되자나요. 근데 뭐가 더 효율적일지는 모르겠네. @Override public List findByIds(Long festivalId, List placeIds) { List places = getFestivalAroundPlaces(festivalId); return places.stream() - .filter(place -> placeIds.contains(place.getId())) + .filter(place -> placeIds.contains(place.getKakaoId())) .toList(); } @@ -54,7 +55,7 @@ public List saveAllToCache(Long festivalId, List toSavePlaces) { private void saveToCache(String key, List places) { String placesJson = objectMapperUtil.writeValue(places); - redisTemplateForValue.opsForValue().set(key, placesJson, 2, TimeUnit.HOURS); + stringRedisTemplate.opsForValue().set(key, placesJson, 2, TimeUnit.HOURS); } private List readFromSavedCache(String key) { @@ -63,7 +64,7 @@ private List readFromSavedCache(String key) { } private String readCache(String redisKey) { - return redisTemplateForValue.opsForValue().get(redisKey); + return stringRedisTemplate.opsForValue().get(redisKey); } private String generateCacheKey(Long festivalId) { diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java index 29107d4..876775b 100644 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java @@ -7,6 +7,7 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; @@ -18,15 +19,14 @@ public class KakaoApiClient { private final String baseUrl; private final String apiKey; - private final RestClient restClient; + private final WebClient webClient; public KakaoApiClient( @Value("${kakao.local.category}") String baseUrl, - @Value("${kakao.api-key}") String apiKey, - RestClient restClient) { + @Value("${kakao.api-key}") String apiKey) { this.baseUrl = baseUrl; this.apiKey = apiKey; - this.restClient = restClient; + this.webClient = WebClient.builder().build(); } public KakaoPlacesResponse searchAttractionPlaces(Coordinate centerCoordinate, int radius, int size) { @@ -39,12 +39,12 @@ public KakaoPlacesResponse searchAttractionPlaces(Coordinate centerCoordinate, i .build(true) .toUri(); - return restClient.get() + return webClient.get() .uri(uri) .header("Authorization", AUTHORIZATION_PREFIX + apiKey) .retrieve() - .body(new ParameterizedTypeReference<>() { } - ); + .bodyToMono(KakaoPlacesResponse.class) + .block(); } } diff --git a/src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java b/src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java index 8875cd6..d8d26ee 100644 --- a/src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java @@ -17,7 +17,7 @@ public record NearPlaceResponseDto( ) { public static NearPlaceResponseDto of(Long festivalId, Place place) { return new NearPlaceResponseDtoBuilder() - .id(place.getId()) + .id(place.getKakaoId()) .name(place.getName()) .festivalId(festivalId) .coordinate(place.getCoordinate()) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCourseServiceV1.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCoursePlanServiceV1.java similarity index 91% rename from src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCourseServiceV1.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCoursePlanServiceV1.java index 58dca18..7a54ffb 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCourseServiceV1.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCoursePlanServiceV1.java @@ -4,18 +4,17 @@ import com.example.mohago_nocar.festival.domain.repository.FestivalRepository; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.global.common.exception.InvalidValueException; -import com.example.mohago_nocar.place.application.PlaceService; import com.example.mohago_nocar.place.domain.model.Place; -import com.example.mohago_nocar.place.domain.repository.PlaceRepository; +import com.example.mohago_nocar.place.domain.service.PlaceUseCase; import com.example.mohago_nocar.plan.application.v1.strategy.RouteOptimizationStrategy; import com.example.mohago_nocar.plan.domain.model.Location; -import com.example.mohago_nocar.plan.domain.service.TravelCourseUseCaseV1; +import com.example.mohago_nocar.plan.domain.service.TravelCoursePlanUseCaseV1; import com.example.mohago_nocar.plan.presentation.v1.PlanTravelCourseRequestDto; import com.example.mohago_nocar.plan.application.v1.response.PlanTravelCourseResponseDto; import com.example.mohago_nocar.plan.application.v1.response.TravelRouteResponseDto; import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; import com.example.mohago_nocar.transit.domain.model.RouteMetrics; -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiExecutor; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated.TransitRouteApiExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -28,14 +27,14 @@ import static com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode.TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD; +@Deprecated @Service @RequiredArgsConstructor @Slf4j -public class TravelCourseServiceV1 implements TravelCourseUseCaseV1 { +public class TravelCoursePlanServiceV1 implements TravelCoursePlanUseCaseV1 { - private final PlaceRepository placeRepository; private final FestivalRepository festivalRepository; - private final PlaceService placeService; + private final PlaceUseCase placeService; private final RouteOptimizationStrategy routeOptimizationStrategy; private final ExecutorService virtualThreadExecutor; private final DistanceDurationApiAdapter distanceDurationApiAdapter; @@ -48,7 +47,7 @@ public CompletableFuture planCourse(PlanTravelCours Map> namesByCoordinate = mergeFestivalAndAttractionName(festival, attractions); - List optimalRouteCoordinates = findOptimalRoute(namesByCoordinate); + List optimalRouteCoordinates = findOptimalRoute(namesByCoordinate); List optimalRouteLocations = mapCoordinatesToLocations(namesByCoordinate, optimalRouteCoordinates); return transitRouteApiExecutor.execute(optimalRouteLocations) @@ -56,7 +55,7 @@ public CompletableFuture planCourse(PlanTravelCours .thenApply(PlanTravelCourseResponseDto::of); } - private List findOptimalRoute(Map> namesByCoordinate) { + private List findOptimalRoute(Map> namesByCoordinate) { var coordinates = collectCoordinate(namesByCoordinate); var routeMetrics = fetchDistanceAndDurations(coordinates); @@ -96,7 +95,7 @@ private List distanceDurationApiCall(int index, List c private List mapCoordinatesToLocations( Map> namesByCoordinate, - List coordinates + List coordinates ) { return coordinates.stream() .flatMap(coordinate -> { @@ -126,10 +125,10 @@ private void ensureTravelDateDuringFestival(Festival festival, LocalDate travelD } private List getAttractions(Festival festival, List placeIds) { - List places = placeRepository.findByIds(festival.getId(), placeIds); + List places = placeService.getFestivalNearPlacesById(festival.getId(), placeIds); if (places.isEmpty()) { places = placeService.cachePlaces(festival.getId(), festival.getCoordinate()).stream() - .filter(place -> placeIds.contains(place.getId())) + .filter(place -> placeIds.contains(place.getKakaoId())) .toList(); } return places; diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java index 87ae636..e8bca2b 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java @@ -24,7 +24,7 @@ public static BusPathResponseDto of(SubPath subPath) { return BusPathResponseDto.builder() .distance(busPath.getDistanceKm()) - .sectionTime(busPath.getSectionTimeMin()) + .sectionTime(busPath.getTimeTakenMin()) .busNo(busPath.getBusNo()) .busType(busPath.getBusType()) .startPlaceName(busPath.getStartName()) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java index ad235bc..453da08 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java @@ -23,7 +23,7 @@ public static SubwayPathResponseDto of(SubPath subPath) { return SubwayPathResponseDto.builder() .distance(subwayPath.getDistanceKm()) - .sectionTime(subwayPath.getSectionTimeMin()) + .sectionTime(subwayPath.getTimeTakenMin()) .subwayLineName(subwayPath.getSubwayLineName()) .startPlaceName(subwayPath.getStartName()) .startLongitude(subwayPath.getStartCoordinate().getLongitude()) diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java index 7a52f0b..cfc72fd 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java @@ -15,7 +15,7 @@ public static WalkPathResponseDto of(SubPath subPath) { return WalkPathResponseDto.builder() .distance(walkPath.getDistanceKm()) - .sectionTime(walkPath.getSectionTimeMin()) + .sectionTime(walkPath.getTimeTakenMin()) .build(); } diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java index 4ee36ff..02e9f34 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java @@ -7,6 +7,6 @@ public interface RouteOptimizationStrategy { - List calculateOptimalRoute(List coordinates, List routeMetrics); + List calculateOptimalRoute(List coordinates, List routeMetrics); } \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java index e20fc13..3835d79 100644 --- a/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java @@ -15,7 +15,7 @@ public class ShortestTimeRouteStrategy implements RouteOptimizationStrategy { private static final int FIRST = 0; @Override - public List calculateOptimalRoute(List coordinates, List routeMetrics) { + public List calculateOptimalRoute(List coordinates, List routeMetrics) { int locationCount = coordinates.size(); Map> fromToTransitInfoMap = new HashMap<>(); diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java b/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java index fa226fc..5f2522e 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java @@ -3,16 +3,24 @@ import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.place.domain.model.Place; +import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; /** * 장소의 이름과 경위도 */ @Getter +@NoArgsConstructor +@ToString +@Embeddable public class Location { private String name; + + @Embedded private Coordinate coordinate; public static Location of(Festival festival) { @@ -42,4 +50,8 @@ private Location(String name, Coordinate coordinate) { this.coordinate = coordinate; } + public boolean hasSameCoordinate(Location destination) { + return this.coordinate.equals(destination.coordinate); + } + } diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCoursePlanUseCaseV1.java similarity index 89% rename from src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java rename to src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCoursePlanUseCaseV1.java index a6aaf6b..8b85663 100644 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCourseUseCaseV1.java +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCoursePlanUseCaseV1.java @@ -5,7 +5,7 @@ import java.util.concurrent.CompletableFuture; -public interface TravelCourseUseCaseV1 { +public interface TravelCoursePlanUseCaseV1 { CompletableFuture planCourse(PlanTravelCourseRequestDto dto); diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/exception/PlanErrorCode.java b/src/main/java/com/example/mohago_nocar/plan/presentation/exception/PlanErrorCode.java index fbd71f5..114ee0c 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/exception/PlanErrorCode.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/exception/PlanErrorCode.java @@ -10,6 +10,7 @@ public enum PlanErrorCode implements Status { TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD(HttpStatus.BAD_REQUEST, "PLAN400", "여행 날짜가 축제 기간을 벗어났습니다"), + BATCH_TASK_NOT_FOUND(HttpStatus.BAD_REQUEST, "BATCH_TASK_NOT_FOUND", "존재하지 않는 배치 작업입니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java index 10713f7..350fbf6 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java @@ -1,7 +1,7 @@ package com.example.mohago_nocar.plan.presentation.v1; import com.example.mohago_nocar.global.common.response.ApiResponse; -import com.example.mohago_nocar.plan.domain.service.TravelCourseUseCaseV1; +import com.example.mohago_nocar.plan.domain.service.TravelCoursePlanUseCaseV1; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -12,19 +12,20 @@ import java.util.concurrent.CompletableFuture; +@Deprecated @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/travel-plan") @Tag(name = "Plan", description = "여행 코스 설계") public class TravelPlanControllerV1 { - private final TravelCourseUseCaseV1 travelCourseUseCaseV1; + private final TravelCoursePlanUseCaseV1 travelCoursePlanUseCaseV1; @PostMapping public CompletableFuture> planTravelCourse( @RequestBody @Valid PlanTravelCourseRequestDto requestDto ) { - return travelCourseUseCaseV1.planCourse(requestDto) + return travelCoursePlanUseCaseV1.planCourse(requestDto) .thenApply(ApiResponse::ok); } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/v2/TravelPlanControllerV2.java b/src/main/java/com/example/mohago_nocar/plan/presentation/v2/TravelPlanControllerV2.java deleted file mode 100644 index 3370fe3..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/v2/TravelPlanControllerV2.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.mohago_nocar.plan.presentation.v2; - -import com.example.mohago_nocar.global.common.response.ApiResponse; -import com.example.mohago_nocar.plan.application.v2.PlanTravelCourseResponseDtoV2; -import com.example.mohago_nocar.plan.domain.service.TravelCourseUseCaseV1; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v2/travel-plan") -@Tag(name = "Plan", description = "여행 코스 설계") -public class TravelPlanControllerV2 { - - private final TravelCourseUseCaseV1 travelPlanUseCase; - - @PostMapping - public ApiResponse planTravelCourse( - @RequestBody @Valid PlanTravelCourseRequestDtoV2 request - ) { - // batch_id 생성 - // 유저 아이디와 batch_id를 매핑해서 저장 - // 유저 아이디와 fcm 토큰 저장 - // 논블로킹으로 여행 api 설계 요청 - // batch_id 반환 - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/applicatoin/TransitRouteSummaryUseCaseImpl.java b/src/main/java/com/example/mohago_nocar/transit/applicatoin/TransitRouteSummaryUseCaseImpl.java new file mode 100644 index 0000000..3f0d7b6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/applicatoin/TransitRouteSummaryUseCaseImpl.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.transit.applicatoin; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.domain.service.TransitRouteSummaryUseCase; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +public class TransitRouteSummaryUseCaseImpl implements TransitRouteSummaryUseCase { + + private final DistanceDurationApiAdapter apiAdapter; + + @Override + public CompletableFuture> getRouteSummary(Coordinate origin, Set destinations) { + for (Coordinate destination : destinations) { + if (origin.equals(destination)) { + throw new IllegalArgumentException("Origin and destination are equal"); + } + } + + return apiAdapter.getDistanceAndDurationAsync(origin, destinations); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java index e48119a..e5eb722 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java @@ -1,25 +1,36 @@ package com.example.mohago_nocar.transit.domain.model; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.ToString; import static com.example.mohago_nocar.transit.domain.model.PathType.BUS; +import static jakarta.persistence.GenerationType.IDENTITY; @Getter -@ToString +@NoArgsConstructor +@EqualsAndHashCode +@ToString(callSuper = true) public class BusPath extends SubPath{ - private final String busNo; // 버스 번호 - private final int busType; // 버스 타입 - private final String startName; // 출발 지점 이름 - private final Coordinate startCoordinate; - private final String endName; // 도착 지점 이름 - private final Coordinate endCoordinate; + private String busNo; // 버스 번호 + + private int busType; // 버스 타입 + + private String startName; // 출발 지점 이름 + + private Coordinate startCoordinate; + + private String endName; // 도착 지점 이름 + + private Coordinate endCoordinate; public BusPath( - double distance, - int sectionTime, + double distanceKm, + int timeTakenMin, String busNo, int busType, String startName, @@ -27,7 +38,7 @@ public BusPath( String endName, Coordinate endCoordinate ) { - super(distance, sectionTime); + super(distanceKm, timeTakenMin, BUS); this.busNo = busNo; this.busType = busType; this.startName = startName; @@ -36,9 +47,9 @@ public BusPath( this.endCoordinate = endCoordinate; } - @Override - public PathType getPathType() { - return BUS; - } +// @Override +// public PathType getPathType() { +// return getPathType(); +// } } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java index fedd439..b7dcf49 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java @@ -1,16 +1,40 @@ package com.example.mohago_nocar.transit.domain.model; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "pathType", + visible = true +) +@JsonSubTypes({ + @Type(value = BusPath.class, name = "BUS"), + @Type(value = SubwayPath.class, name = "SUBWAY"), + @Type(value = WalkPath.class, name = "WALK"), +}) @Getter +@NoArgsConstructor +@EqualsAndHashCode +@ToString public abstract class SubPath { - protected final double distanceKm; // 구간 거리 - protected final int sectionTimeMin; // 구간 소요 시간 - protected SubPath(double distanceKm, int sectionTimeMin) { + private double distanceKm; // 구간 거리 + + private int timeTakenMin; // 구간 소요 시간 + + private PathType pathType; + + protected SubPath(double distanceKm, int timeTakenMin, PathType pathType) { this.distanceKm = distanceKm; - this.sectionTimeMin = sectionTimeMin; + this.timeTakenMin = timeTakenMin; + this.pathType = pathType; } - public abstract PathType getPathType(); } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java index 268b242..daf2092 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java @@ -1,30 +1,41 @@ package com.example.mohago_nocar.transit.domain.model; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.ToString; import static com.example.mohago_nocar.transit.domain.model.PathType.SUBWAY; +import static jakarta.persistence.GenerationType.IDENTITY; @Getter -@ToString +@NoArgsConstructor +@EqualsAndHashCode +@ToString(callSuper = true) public class SubwayPath extends SubPath{ - private final String subwayLineName; - private final String startName; - private final Coordinate startCoordinate; - private final String endName; - private final Coordinate endCoordinate; + + private String subwayLineName; + + private String startName; + + private Coordinate startCoordinate; + + private String endName; + + private Coordinate endCoordinate; public SubwayPath( - double distance, - int sectionTime, + double distanceKm, + int timeTakenMin, String subwayLineName, String startName, Coordinate startCoordinate, String endName, Coordinate endCoordinate ) { - super(distance, sectionTime); + super(distanceKm, timeTakenMin, SUBWAY); this.subwayLineName = subwayLineName; this.startName = startName; this.startCoordinate = startCoordinate; @@ -32,9 +43,5 @@ public SubwayPath( this.endCoordinate = endCoordinate; } - @Override - public PathType getPathType() { - return SUBWAY; - } - } + diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java index f85fb75..3b24481 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java @@ -1,33 +1,56 @@ package com.example.mohago_nocar.transit.domain.model; import com.example.mohago_nocar.plan.domain.model.Location; -import lombok.Builder; -import lombok.Getter; -import lombok.ToString; +import com.example.mohago_nocar.plan.domain.model.TravelCourseInPlan; +import jakarta.persistence.*; +import lombok.*; +import java.util.ArrayList; import java.util.List; +import static jakarta.persistence.GenerationType.IDENTITY; + +@NoArgsConstructor @Getter @ToString public class TransitRoute { - private final int totalTime; - private final double totalDistance; - private final List subPaths; - private final Location origin; - private final Location destination; + private int totalTime; + + private double totalDistance; + + private List subPaths; - public static TransitRoute from(Location origin, Location destination, int totalTime, double totalDistance, List subPaths) { - return TransitRoute.builder() + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "name", column = @Column(name = "start_name")), + @AttributeOverride(name = "coordinate.latitude", column = @Column(name = "start_latitude")), + @AttributeOverride(name = "coordinate.longitude", column = @Column(name = "start_longitude")) + }) + private Location origin; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "name", column = @Column(name = "end_name")), + @AttributeOverride(name = "coordinate.latitude", column = @Column(name = "end_latitude")), + @AttributeOverride(name = "coordinate.longitude", column = @Column(name = "end_longitude")) + }) + private Location destination; + + public static TransitRoute from(Location origin, Location destination, + int totalTime, double totalDistance, List subPaths) { + TransitRoute route = TransitRoute.builder() .origin(origin) .destination(destination) .totalTime(totalTime) .totalDistance(totalDistance) .subPaths(subPaths) .build(); + + return route; } - @Builder + @Builder(access = AccessLevel.PRIVATE) private TransitRoute(Location origin, Location destination, int totalTime, double totalDistance, List subPaths) { this.origin = origin; this.destination = destination; diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java index badcae8..8947ff9 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java @@ -1,19 +1,20 @@ package com.example.mohago_nocar.transit.domain.model; +import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.ToString; +import static jakarta.persistence.GenerationType.IDENTITY; + @Getter -@ToString +@NoArgsConstructor +@EqualsAndHashCode +@ToString(callSuper = true) public class WalkPath extends SubPath{ - public WalkPath(double distance, int sectionTime) { - super(distance, sectionTime); - } - - @Override - public PathType getPathType() { - return PathType.WALK; + public WalkPath(double distanceKm, int timeTakenMin) { + super(distanceKm, timeTakenMin, PathType.WALK); } } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitRouteSummaryUseCase.java b/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitRouteSummaryUseCase.java new file mode 100644 index 0000000..05d80e3 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitRouteSummaryUseCase.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.transit.domain.service; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import org.springframework.util.RouteMatcher; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +public interface TransitRouteSummaryUseCase { + + /** + * 출발지와 목적지 리스트 내에 있는 각 목적지 사이의 이동 요약 정보를 구합니다. + * @param origin 출발지의 경위도 + * @param destinations 목적지 경위도 집합 + * @return 이동 시간 및 거리 + */ + CompletableFuture> getRouteSummary(Coordinate origin, Set destinations); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java index 6b8dd67..89f60c9 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java @@ -4,9 +4,13 @@ import com.example.mohago_nocar.transit.domain.model.RouteMetrics; import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +// todo 이름 변경해도 될듯 public interface DistanceDurationApiAdapter { List getDistanceAndDuration(Coordinate origin, List destinations); + CompletableFuture> getDistanceAndDurationAsync(Coordinate origin, Set destinations); } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java index 8d14ba2..51eedee 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java @@ -13,6 +13,9 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; @Component @RequiredArgsConstructor @@ -24,6 +27,8 @@ public class GoogleDistanceMatrixApiAdapter implements DistanceDurationApiAdapte @Override public List getDistanceAndDuration(Coordinate origin, List destinations) { GoogleDistanceMatrixResponse response = googleApiClient.getDistanceMatrix(origin, destinations); + + log.info("Distance matrix response: {}", response); if (GoogleResponseValidator.hasError(response)) { processInvalidResponse(response); } @@ -31,6 +36,20 @@ public List getDistanceAndDuration(Coordinate origin, List> getDistanceAndDurationAsync(Coordinate origin, Set destinations) { + CompletableFuture distanceMatrixFuture = + googleApiClient.getDistanceMatrixAsync(origin, destinations); + + return distanceMatrixFuture.thenApply(response -> { + if (GoogleResponseValidator.hasError(response)) { + processInvalidResponse(response); + } + + return processValidResponse(origin, destinations.stream().toList(), response); + }); + } + private void processInvalidResponse(GoogleDistanceMatrixResponse response) { switch (response.status()) { case INVALID_REQUEST, MAX_ELEMENTS_EXCEEDED, MAX_DIMENSIONS_EXCEEDED, REQUEST_DENIED -> @@ -41,7 +60,8 @@ private void processInvalidResponse(GoogleDistanceMatrixResponse response) { } } - private List processValidResponse(Coordinate origin, List destinations, GoogleDistanceMatrixResponse response) { + private List processValidResponse( + Coordinate origin, List destinations, GoogleDistanceMatrixResponse response) { return DistanceDurationConverter.convertMatrixToRouteMetrics(response, origin, destinations); } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java index eccc03d..f5d391e 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java @@ -5,27 +5,34 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; +import java.util.stream.Stream; @Component public class GoogleApiClient { - private final RestClient restClient; + private final WebClient webClient; private final String apiKey; private final String baseUrl; + private static final String DELIMITER_COMMA = "," ; + private static final String PIPE = "|" ; + public GoogleApiClient( - RestClient restClient, @Value("${google.api-key}") String apiKey, @Value("${google.maps.distance}") String baseUrl ) { - this.restClient = restClient; + this.webClient = WebClient.builder().build(); this.apiKey = apiKey; this.baseUrl = baseUrl; } @@ -45,31 +52,55 @@ public GoogleApiClient( * @return 행렬에 기반한 (출발지, 목적지)와 관련된 데이터를 반환합니다. */ public GoogleDistanceMatrixResponse getDistanceMatrix(Coordinate origin, List destinations) { - URI requestUri = UriComponentsBuilder.fromUriString(baseUrl) - .queryParam("origins", formatCoordinates(origin)) - .queryParam("destinations", formatCoordinates(destinations)) + URI requestUri = buildUri(origin, destinations.stream()); + return executeApiCall(requestUri); + } + + public CompletableFuture getDistanceMatrixAsync(Coordinate origin, Set destinations) { + URI requestUri = buildUri(origin, destinations.stream()); + return executeApiCallAsync(requestUri); + } + + private URI buildUri(Coordinate origin, Stream destinations) { + return UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("origins", encodeCoordinate(origin)) + .queryParam("destinations", encodeCoordinates(destinations)) .queryParam("language", "ko") .queryParam("mode", "transit") .queryParam("key", apiKey) .build(true) .toUri(); + } - return restClient.get() + private CompletableFuture executeApiCallAsync(URI requestUri) { + return webClient.get() .uri(requestUri) .retrieve() - .body(GoogleDistanceMatrixResponse.class); + .bodyToMono(GoogleDistanceMatrixResponse.class) + .toFuture(); } - private String formatCoordinates(Coordinate origin) { - return origin.getLatitude() + "," + origin.getLongitude(); + private GoogleDistanceMatrixResponse executeApiCall(URI requestUri) { + return webClient.get() + .uri(requestUri) + .retrieve() + .bodyToMono(GoogleDistanceMatrixResponse.class) + .block(); + } + + private String encodeCoordinate(Coordinate origin) { + return origin.getLatitude() + DELIMITER_COMMA + origin.getLongitude(); } - private String formatCoordinates(List coordinates) { + /** + * Coordinate 스트림을 "위도,경도|위도,경도|..." 형태로 변환 후 URL 인코딩합니다. + */ + private String encodeCoordinates(Stream coordinates) { return URLEncoder.encode( - coordinates.stream() - .map(location -> location.getLatitude() + "," + location.getLongitude()) - .collect(Collectors.joining("|")) - ,StandardCharsets.UTF_8 + coordinates + .map(location -> location.getLatitude() + DELIMITER_COMMA + location.getLongitude()) + .collect(Collectors.joining(PIPE)), + StandardCharsets.UTF_8 ); } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/DistanceMatrixElementStatus.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/DistanceMatrixElementStatus.java new file mode 100644 index 0000000..e4a82b4 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/DistanceMatrixElementStatus.java @@ -0,0 +1,8 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response; + +public enum DistanceMatrixElementStatus { + OK, + NOT_FOUND, + ZERO_RESULTS, + MAX_ROUTE_LENGTH_EXCEEDED +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java index 2143a63..57d7d51 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java @@ -15,7 +15,8 @@ public record Row( public record Element( Distance distance, - Duration duration + Duration duration, + DistanceMatrixElementStatus status ){} public record Distance( diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java index 4bb1b42..797626c 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java @@ -1,5 +1,6 @@ package com.example.mohago_nocar.transit.infrastructure.error.code; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; import com.example.mohago_nocar.global.common.exception.InternalServerException; import com.example.mohago_nocar.global.common.exception.Status; import lombok.AllArgsConstructor; @@ -63,4 +64,8 @@ public boolean isTooManyRequests() { return this == TOO_MANY_REQUESTS; } + public boolean isUnExpectedError() { + return this == REQUIRED_INPUT_FORMAT_ERROR || this == REQUIRED_INPUT_MISSING || this == COMPONENT_ERROR; + } + } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java index c2f525b..294c86a 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java @@ -1,12 +1,16 @@ package com.example.mohago_nocar.transit.infrastructure.error.exception; -import com.example.mohago_nocar.global.common.exception.CustomException; import com.example.mohago_nocar.global.common.exception.Status; +import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; +import lombok.Getter; -public class ODsayRouteException extends CustomException { +@Getter +public class ODsayRouteException extends RuntimeException { - public ODsayRouteException(Status status) { - super(status); + private final OdsayErrorCode errorCode; + + public ODsayRouteException(OdsayErrorCode errorCode) { + this.errorCode = errorCode; } } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteDeadRequestProducer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteDeadRequestProducer.java deleted file mode 100644 index e09205e..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/queue/producer/TransitRouteDeadRequestProducer.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.queue.producer; - -import com.example.mohago_nocar.global.common.DeadMessageCreator; -import com.example.mohago_nocar.global.common.DeadSummaryMessage; -import com.example.mohago_nocar.global.util.ObjectMapperUtil; -import com.example.mohago_nocar.transit.infrastructure.queue.batch.TransitRouteRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.connection.stream.ObjectRecord; -import org.springframework.data.redis.connection.stream.StreamRecords; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class TransitRouteDeadRequestProducer { - - @Value("${redis.streams.odsay.dlq}") - private String streamKey; - - private final ObjectMapperUtil objectMapperUtil; - private final DeadMessageCreator deadMessageCreator; - private final StringRedisTemplate stringRedisTemplate; - - public void produce(TransitRouteRequest request, Exception ex) { - // 아래는...서비스로...? - DeadSummaryMessage deadMessage = deadMessageCreator.createSummaryMessage( - objectMapperUtil.writeValue(request), ex, 0, 10); - - ObjectRecord entry = StreamRecords.newRecord() - .in(streamKey) - .ofObject(objectMapperUtil.writeValue(deadMessage)); - - stringRedisTemplate.opsForStream().add(entry); - } - - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitKey.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitKey.java deleted file mode 100644 index 82956bf..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitKey.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.route; - -import io.github.bucket4j.Bucket; - -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - -public class RateLimitKey { - - private final Bucket bucket; - private final String key; - private final String encodedKey; - - public RateLimitKey(Bucket bucket, String key) { - this.bucket = bucket; - this.key = key; - this.encodedKey = createEncodedApiKey(key); - } - - private String createEncodedApiKey(String key) { - return URLEncoder.encode(key, StandardCharsets.UTF_8); - } - - /** - * API 호출 제한 속도에 맞추어 사용 가능한 Key를 반환합니다. - * 호출 제한 속도를 초과하면 호출 스레드는 Blocking 됩니다. - * @return API key - */ - public String acquireEncodedKey() { - try { - bucket.asBlocking().consume(1); - } catch (InterruptedException e) { // todo 인터럽트 발생 시 고민 - throw new RuntimeException(e); - } - - return encodedKey; - } - - public String getKey() { - return key; - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPool.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPool.java deleted file mode 100644 index 5d12f29..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPool.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.route; - -import com.example.mohago_nocar.transit.infrastructure.route.odsay.OdsayApiKeyProperties; -import io.github.bucket4j.Bucket; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Supplier; - -public class RateLimitedApiKeyPool { - - private final List keys; - private AtomicInteger index; - - public RateLimitedApiKeyPool( - OdsayApiKeyProperties keyProperties, Supplier bucketCreator) { - keys = new ArrayList<>(); - index = new AtomicInteger(0); - for (String key : keyProperties.getKeys()) { - RateLimitKey rateLimitKey = new RateLimitKey(bucketCreator.get(), key); - keys.add(rateLimitKey); - } - } - - /** - * 인코딩된 API key를 획득합니다. - * API 호출 시 429 Too Many Request Error가 발생하지 않도록 key 획득 속도를 조절합니다. - * @return - */ - public String acquireEncodedKey() { - // 원자적 + 라운드로빈 방식으로 인덱스 획득 - int idx = index.getAndUpdate(current -> (current + 1) % keys.size()); - RateLimitKey rateLimitKey = keys.get(idx); - return rateLimitKey.acquireEncodedKey(); - } - - public int getNextOrder() { - return index.get(); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPoolConfig.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPoolConfig.java deleted file mode 100644 index e473e12..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/RateLimitedApiKeyPoolConfig.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.route; - -import com.example.mohago_nocar.transit.infrastructure.route.odsay.OdsayApiKeyProperties; -import io.github.bucket4j.Bandwidth; -import io.github.bucket4j.BandwidthBuilder; -import io.github.bucket4j.Bucket; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.time.Duration; - -@Configuration -public class RateLimitedApiKeyPoolConfig { - - @Bean - public RateLimitedApiKeyPool rateLimitedOdsayApiKeyPool(OdsayApiKeyProperties keyProperties) { - return new RateLimitedApiKeyPool(keyProperties, this::createBucket); - } - - private Bucket createBucket() { - Bandwidth limit = BandwidthBuilder.builder().capacity(5) - .refillIntervally(5, Duration.ofSeconds(1)) - .initialTokens(0) - .build(); - - return Bucket.builder() - .addLimit(limit) - .build(); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java index 7e23317..5fcf3c7 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java @@ -9,6 +9,6 @@ public interface TransitRouteApiAdapter { TransitRoute getTransitRouteBetweenLocations(Location origin, Location destination); - CompletableFuture getTransitRoute(Location origin, Location destination); + CompletableFuture getTransitRouteWithThrottling(Location origin, Location destination); } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java index ef3212f..314fc19 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java @@ -1,12 +1,12 @@ package com.example.mohago_nocar.transit.infrastructure.route.odsay; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; -import com.example.mohago_nocar.transit.infrastructure.route.RateLimitedApiKeyPool; +import com.example.mohago_nocar.global.rateLimit.RateLimiter; import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; @@ -17,18 +17,16 @@ @Slf4j public class ODsayApiRateLimitedClient { - private final RestClient restClient; - private final String baseUrl; - private final RateLimitedApiKeyPool rateLimitedApiKeyPool; + private final OdsayApiProperties apiProperties; + private final RateLimiter rateLimiter; + private final WebClient webClient; public ODsayApiRateLimitedClient( - RestClient restClient, - @Value("${odsay.url}") String baseUrl, - RateLimitedApiKeyPool rateLimitedOdsayApiKeyPool + OdsayApiProperties apiProperties ) { - this.restClient = restClient; - this.baseUrl = baseUrl; - this.rateLimitedApiKeyPool = rateLimitedOdsayApiKeyPool; + this.apiProperties = apiProperties; + this.rateLimiter = RateLimiter.create(apiProperties.getExecutionIntervalMills()); + this.webClient = WebClient.builder().build(); } /** @@ -42,8 +40,8 @@ public ODsayApiRateLimitedClient( * @return 경로 검색 결과의 CompletableFuture */ public ODsayTransitRouteResponse searchTransitRoute(Coordinate origin, Coordinate destination) { - String encodedKey = rateLimitedApiKeyPool.acquireEncodedKey(); - URI requestURI = buildRequestURI(origin, destination, encodedKey); + URI requestURI = buildRequestURI(origin, destination); + rateLimiter.throttle(); return executeApiCall(requestURI); } @@ -59,29 +57,39 @@ public ODsayTransitRouteResponse searchTransitRoute(Coordinate origin, Coordinat */ public CompletableFuture searchTransitRouteAsync( Coordinate origin, Coordinate destination) { - String encodedKey = rateLimitedApiKeyPool.acquireEncodedKey(); - URI requestURI = buildRequestURI(origin, destination, encodedKey); - return CompletableFuture.supplyAsync(() -> executeApiCall(requestURI)); + URI requestURI = buildRequestURI(origin, destination); + rateLimiter.throttle(); + return executeApiCallAsync(requestURI); + } + + private URI buildRequestURI(Coordinate origin, Coordinate destination) { + UriComponentsBuilder uriComponentsBuilder = + UriComponentsBuilder.fromUriString(apiProperties.getRequestUrl()) + .queryParam("SX", origin.getLongitude()) + .queryParam("SY", origin.getLatitude()) + .queryParam("EX", destination.getLongitude()) + .queryParam("EY", destination.getLatitude()); + + return uriComponentsBuilder + .queryParam("apiKey", apiProperties.getEncodedKey()) + .build(true) + .toUri(); } private ODsayTransitRouteResponse executeApiCall(URI requestURI) { - return restClient.get() + return webClient.get() .uri(requestURI) .retrieve() - .body(ODsayTransitRouteResponse.class); + .bodyToMono(ODsayTransitRouteResponse.class) + .block(); } - private URI buildRequestURI(Coordinate origin, Coordinate destination, String encodedKey) { - UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUrl) - .queryParam("SX", origin.getLongitude()) - .queryParam("SY", origin.getLatitude()) - .queryParam("EX", destination.getLongitude()) - .queryParam("EY", destination.getLatitude()); - - return uriComponentsBuilder - .queryParam("apiKey", encodedKey) - .build(true) - .toUri(); + private CompletableFuture executeApiCallAsync(URI requestURI) { + return webClient.get() + .uri(requestURI) + .retrieve() + .bodyToMono(ODsayTransitRouteResponse.class) + .toFuture(); } } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java index a1067e9..7e35f30 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java @@ -11,6 +11,7 @@ import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.TransitRouteConverter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -42,7 +43,7 @@ public TransitRoute getTransitRouteBetweenLocations(Location origin, Location de } @Override - public CompletableFuture getTransitRoute(Location origin, Location destination) { + public CompletableFuture getTransitRouteWithThrottling(Location origin, Location destination) { CompletableFuture future = rateLimitedClient.searchTransitRouteAsync(origin.getCoordinate(), destination.getCoordinate()); @@ -71,6 +72,7 @@ private void processInvalidResponse(ODsayRouteInvalidResponse response) { } private TransitRoute processValidResponse(Location origin, Location destination, ODsayTransitRouteResponse response) { + log.info("Odsay API response: {}", response); return TransitRouteConverter.convertToTransitRoute((ODsayRouteValidResponse) response, origin, destination); } @@ -102,7 +104,7 @@ private Double getKmDist(Coordinate departure, Coordinate arrival) { } private Double convertLongitudeToKmDist(Double dx, Double stdLatitude) { - return EARTH_RADIUS * dx * Math.cos(stdLatitude) * Math.PI / 180; + return EARTH_RADIUS * dx * Math.cos(Math.toRadians(stdLatitude)) * Math.PI / 180; } private Double convertLatitudeToKmDist(Double dy) { diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiKeyProperties.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiKeyProperties.java deleted file mode 100644 index 80d93a7..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiKeyProperties.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.route.odsay; - -import lombok.Getter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.util.List; -import java.util.Set; - -@Getter -@ConfigurationProperties("odsay") -public class OdsayApiKeyProperties { - - private final List keys; - - public OdsayApiKeyProperties(List apiKeys) { - if (Set.of(apiKeys.toArray()).size() != apiKeys.size()) { - throw new RuntimeException("API 키는 중복일 수 없습니다."); - } - - this.keys = apiKeys; - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiProperties.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiProperties.java new file mode 100644 index 0000000..fa81e30 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiProperties.java @@ -0,0 +1,25 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Getter +@ConfigurationProperties("odsay") +public class OdsayApiProperties { + + private final String requestUrl; + private final String key; + private final String encodedKey; + private final int executionIntervalMills; + + public OdsayApiProperties(String requestUrl, String key, int executionIntervalMills) { + this.requestUrl = requestUrl; + this.key = key; + this.encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8); + this.executionIntervalMills = executionIntervalMills; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayApiClient.java new file mode 100644 index 0000000..b32d90d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayApiClient.java @@ -0,0 +1,73 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +// todo 클래스명 수정 +@Deprecated +@Component +@Slf4j +public class ODsayApiClient { + + private final WebClient webClient; + private final String baseUrl; + private final String apiKey; + + public ODsayApiClient( + @Value("${odsay.request-url}") String baseUrl, + @Value("${odsay.key}") String apiKey + ) { + this.webClient = WebClient.builder().build(); + this.baseUrl = baseUrl; + this.apiKey = apiKey; + } + + /** + * 대중교통 경로를 검색합니다. + * + *

    Rate Limit 제어: API 키 획득 시 rate limit 준수를 위해 + * 의도적으로 블로킹하여 소비 속도를 조절합니다.

    + * + * @param origin 출발지 좌표 + * @param destination 도착지 좌표 + * @return 경로 검색 결과의 CompletableFuture + */ + public ODsayTransitRouteResponse searchTransitRoute(Coordinate origin, Coordinate destination) { + String encodedKey = URLEncoder.encode(apiKey, StandardCharsets.UTF_8); + URI requestURI = buildRequestURI(origin, destination, encodedKey); + ODsayTransitRouteResponse response = executeApiCall(requestURI); + return response; + } + + private ODsayTransitRouteResponse executeApiCall(URI requestURI) { + return webClient.get() + .uri(requestURI) + .retrieve() + .bodyToMono(ODsayTransitRouteResponse.class) + .block(); + } + + private URI buildRequestURI(Coordinate origin, Coordinate destination, String encodedKey) { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("SX", origin.getLongitude()) + .queryParam("SY", origin.getLatitude()) + .queryParam("EX", destination.getLongitude()) + .queryParam("EY", destination.getLatitude()); + + return uriComponentsBuilder + .queryParam("apiKey", encodedKey) + .build(true) + .toUri(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayTransitRouteApiExecutor.java similarity index 97% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayTransitRouteApiExecutor.java index 4b262b3..ecb3234 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiExecutor.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayTransitRouteApiExecutor.java @@ -1,9 +1,8 @@ -package com.example.mohago_nocar.transit.infrastructure.route.odsay; +package com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated; import com.example.mohago_nocar.plan.domain.model.Location; import com.example.mohago_nocar.transit.domain.model.TransitRoute; import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; -import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiExecutor; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/TransitRouteApiExecutor.java similarity index 81% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/TransitRouteApiExecutor.java index 528ab91..84bcb06 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiExecutor.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/TransitRouteApiExecutor.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.transit.infrastructure.route; +package com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated; import com.example.mohago_nocar.plan.domain.model.Location; import com.example.mohago_nocar.transit.domain.model.TransitRoute; diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java index c7ef5dd..6feb882 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java @@ -32,6 +32,7 @@ public ODsayTransitRouteResponse deserialize(JsonParser jsonParser, Deserializat } private ODsayTransitRouteResponse createInvalidResponse(JsonNode responseJson) { + log.info(responseJson.asText()); var errorInfoJson = find(responseJson, "error").get(); var errorCode = getFields(errorInfoJson, "code"); var errorMessage = getFields(errorInfoJson, "message", "msg"); diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/TransitRouteConverter.java similarity index 92% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/TransitRouteConverter.java index 80f8e48..192569b 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/TransitRouteConverter.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/TransitRouteConverter.java @@ -1,9 +1,8 @@ -package com.example.mohago_nocar.transit.infrastructure.route.odsay; +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import com.example.mohago_nocar.plan.domain.model.Location; import com.example.mohago_nocar.transit.domain.model.*; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; import java.util.List; @@ -54,7 +53,7 @@ private static SubPath createBus(ODsayRouteValidResponse.SubPath path) { } private static SubPath createWalking(ODsayRouteValidResponse.SubPath path) { - return new WalkPath(path.getDistanceMeter(), path.getSectionTimeMin()); + return new WalkPath(path.getDistanceMeter() * METER_TO_KILOMETER, path.getSectionTimeMin()); } } diff --git a/src/main/java/com/example/mohago_nocar/user/application/UserService.java b/src/main/java/com/example/mohago_nocar/user/application/UserService.java index b6a7278..cd0e944 100644 --- a/src/main/java/com/example/mohago_nocar/user/application/UserService.java +++ b/src/main/java/com/example/mohago_nocar/user/application/UserService.java @@ -23,9 +23,28 @@ public AnonymousUser save(AnonymousUser user) { } @Override - public AnonymousUser findById(UUID userId) { - Optional optionalUser = userRepository.findById(userId); - return optionalUser.orElseThrow(() -> new CustomException(GlobalStatus.ENTITY_NOT_FOUND)); + public String getFcmToken(UUID userId) { + AnonymousUser user = findByIdOrThrow(userId); + return user.getFcmToken(); + } + + @Override + public AnonymousUser findByIdOrThrow(UUID userId) { + return findById(userId).orElseThrow(() -> new CustomException(GlobalStatus.ENTITY_NOT_FOUND)); + } + + @Override + public Optional findById(UUID userId) { + return userRepository.findById(userId); + } + + @Override + public AnonymousUser getOrCreate(String fcmToken) { + return userRepository.findByFcm(fcmToken) + .orElseGet(()-> { + AnonymousUser created = AnonymousUser.create(fcmToken); + return save(created); + }); } } diff --git a/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java b/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java index aca656d..f8426b0 100644 --- a/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java +++ b/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java @@ -8,4 +8,6 @@ public interface UserRepository { AnonymousUser save(AnonymousUser identifier); Optional findById(UUID userId); + + Optional findByFcm(String fcmToken); } diff --git a/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java b/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java index bc94a93..628c99d 100644 --- a/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java +++ b/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java @@ -1,11 +1,17 @@ package com.example.mohago_nocar.user.domain; +import java.util.Optional; import java.util.UUID; public interface UserUseCase { AnonymousUser save(AnonymousUser user); - AnonymousUser findById(UUID userId); + String getFcmToken(UUID userId); + AnonymousUser findByIdOrThrow(UUID userId); + + Optional findById(UUID userId); + + AnonymousUser getOrCreate(String fcmToken); } diff --git a/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java b/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java index 444be0f..48b3f41 100644 --- a/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java +++ b/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java @@ -3,7 +3,9 @@ import com.example.mohago_nocar.user.domain.AnonymousUser; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; import java.util.UUID; public interface AnonymousUserJpaRepository extends JpaRepository { + Optional findByFcmToken(String fcmToken); } diff --git a/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java index a712eff..1badca4 100644 --- a/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java +++ b/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java @@ -26,4 +26,9 @@ public Optional findById(UUID userId) { return anonymousUserJpaRepository.findById(userId); } + @Override + public Optional findByFcm(String fcmToken) { + return anonymousUserJpaRepository.findByFcmToken(fcmToken); + } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c992382..214c770 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,10 +1,23 @@ server: port: ${SERVER_PORT:8080} +sentry: + dsn: https://b7b02a49b4031327059e36430753f700@o4510496632930304.ingest.us.sentry.io/4510496642695168 + # Add data like request headers and IP for users, + # see https://docs.sentry.io/platforms/java/guides/spring-boot/data-management/data-collected/ for more info + send-default-pii: true + exception-resolver-order: -2147483647 + logging: + minimum-event-level: warn + minimum-breadcrumb-level: info + minimum-level: info + logs: + enabled: true + spring: threads: virtual: - enabled: true + enabled: false application: name: mohago-nocar profiles: @@ -27,7 +40,7 @@ spring: data: redis: host: localhost - port: 6379 + port: 6378 springdoc: default-consumes-media-type: application/json @@ -37,21 +50,68 @@ springdoc: tags-sorter: alpha odsay: - url: https://api.odsay.com/v1/api/searchPubTransPathT - api-key: ${ODSAY_API_KEY} + request-url: https://api.odsay.com/v1/api/searchPubTransPathT + key: ${ODSAY_API_KEY} + execution-interval-mills: 230 + +redis: + streams: + odsay: + main: odsay-transit-api-requests + dlq: odsay-api-failures + plan: + main: plan-events + dlq: plan-events-failures + course: + optimized: + main: optimized-course + dlq: travel-spot-optimized-failures #삭제 google: maps: distance : https://maps.googleapis.com/maps/api/distancematrix/json api-key : ${GOOGLE_API_KEY} + firebase: + key: + path: ${FIREBASE_KEY_PATH} kakao: local: category: https://dapi.kakao.com/v2/local/search/category api-key: ${KAKAO_API_KEY} +discord: + webhook-url: sdfdsfdsf + +logging: + level: + org.springframework.orm.jpa: DEBUG + org.springframework.transaction: DEBUG + org.hibernate.engine.transaction.internal.TransactionImpl: DEBUG + root: info +# org.springframework.jdbc.datasource.DataSourceUtils: DEBUG +# com.zaxxer.hikari: DEBUG +# org.springframework.data.redis.stream: TRACE +# org.springframework.data.redis: TRACE +# org.hibernate.SQL: debug +# org.hibernate.orm.jdbc.bind: trace +# com.fasterxml.jackson.databind: DEBUG +# org.springframework.http.converter.json: trace +# io.sentry: trace + #logging: # level: +# root: info +# com: +# google: +# firebase: debug +# +# io.lettuce.core.protocol: TRACE +# io.lettuce.core.RedisClient: TRACE +# org.springframework.data.redis: trace +# io: +# lettuce: trace + # root: debug # jdk.virtualThread: debug @@ -70,10 +130,18 @@ spring: # url: jdbc:h2:mem:~/mohagoNoCar # username: sa # password: + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/mohagoNoCarTest + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: create # jpa: # database-platform: org.hibernate.dialect.H2Dialect # hibernate: -# ddl-auto: create +# ddl-auto: update # properties: # hibernate: # dialect: org.hibernate.dialect.H2Dialect diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 3b43467..b24212c 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -5,10 +5,8 @@ - - - - + + @@ -20,20 +18,15 @@ DENY - - originalEvent - throwableClass - throwableMessage - throwableType - rootCause - stackTrace - - logs/app/%d{yyyy-MM-dd}-error.log - 90 + 30 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%level] [%thread] [%logger{36}] - %msg%n + @@ -46,25 +39,23 @@ DENY - - originalEvent - throwableClass - throwableMessage - throwableType - - logs/app/%d{yyyy-MM-dd}-warn.log - 90 + 30 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%level] [%thread] [%logger{36}] - %msg%n + - - + + - - - + + + + \ No newline at end of file diff --git a/src/main/resources/scripts/save_and_publish.lua b/src/main/resources/scripts/save_and_publish.lua new file mode 100644 index 0000000..9b58366 --- /dev/null +++ b/src/main/resources/scripts/save_and_publish.lua @@ -0,0 +1,16 @@ +-- KEYS[1] : 상태 저장용 Redis 키 (예: travelCourse:spotOptimized:entryStatus:{travelCourseId}) +-- KEYS[2] : 스트림 키 (예: travelCourse:spotOptimized:stream) +-- ARGV[1] : travelCourseId +-- ARGV[2] : status 값 (예: CREATED) +-- ARGV[3] : 이벤트 JSON 문자열 + +-- 1. 상태 저장 +redis.call('HSET', KEYS[1], 'travelCourseId', ARGV[1], 'status', ARGV[2]) + +-- 2. 스트림 발행 +local streamId = redis.call('XADD', KEYS[2], '*', + 'travelCourseId', ARGV[1], + 'event', ARGV[3] +) + +return streamId \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/CompletableFutureExceptionHandlingTest.java b/src/test/java/com/example/mohago_nocar/CompletableFutureExceptionHandlingTest.java new file mode 100644 index 0000000..b05475f --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/CompletableFutureExceptionHandlingTest.java @@ -0,0 +1,191 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.*; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class CompletableFutureExceptionHandlingTest extends IntegrationTestSupport { + + private static ExecutorService executor; + + @BeforeAll + static void setup() { + // 테스트용 고정 스레드풀(동시성 제어를 위해) + executor = Executors.newFixedThreadPool(4); + } + + @AfterAll + static void tearDown() { + executor.shutdownNow(); + } + + @Test + @DisplayName("타임아웃의 적용 범위를 관찰한다") + void play_timeout(){ + //given + + //when + // 전체 타임 아웃 + CompletableFuture future = CompletableFuture.runAsync(() -> { + System.out.println("API call"); + try { + Thread.sleep(Duration.ofSeconds(5)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }, executor) + .orTimeout(3, TimeUnit.SECONDS) // 여기서 안걸림 + .exceptionally(throwable -> { + System.out.println("API call이 오래 걸림"); + return null; + }) + .thenRun(() -> { + System.out.println("Sending notify..."); + try { + Thread.sleep(Duration.ofSeconds(8)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }) + .orTimeout(10, TimeUnit.SECONDS); + + //then + future.join(); + } + + @Test + @DisplayName("체이닝된 CF 중 중간 CF가 예외를 던질 시 동작을 관찰한다") + void play_when_the_middle_future_throw_ex(){ + //given + CompletableFuture future = CompletableFuture.runAsync(() -> System.out.println("첫 번째 CF입니다. 1억 정도?! 받았어요.")) + .thenRun(() -> System.out.println("두 번째 CF입니다. 2억 정도?! 받았어요.")) + .thenRun(() -> { + System.out.println("BOOOMMM!!!"); + throw new RuntimeException("BBBBOOOOOOMMMMM~"); + }) + .thenRun(() -> { + System.out.println("BOOOMMM!!! BOOOMMM!!!"); + throw new RuntimeException("BBBBOOOOOOMMMMM~ BBBBOOOOOOMMMMM~"); + }) + .thenRun(() -> System.out.println("마지막 CF입니다. 100억 정도 받았어요.")) + .exceptionally(throwable -> { + System.out.println("CF 촬영 중 이슈가 발생했어요. 위약금 1000억입니다."); + return null; + }); + //when + try { + future.get(); + } catch (InterruptedException e) { + System.out.println("InterruptedException"); + throw new RuntimeException(e); + } catch (ExecutionException e) { + System.out.println("ExecutionException"); + throw new RuntimeException(e); + } + + //then + + } + + // 1) exceptionally()로 예외 복구 + @Test + void exceptionally_recoversFromException_and_chainContinues() { + CompletableFuture cf = CompletableFuture.supplyAsync(() -> { + throw new IllegalStateException("boom"); + }, executor) + // 예외 발생시 대체값 반환 + .exceptionally(ex -> { + // 확인용: ex는 CompletionException 또는 직접 던진 예외의 래핑일 수 있음 + return "default"; + }) + .thenApply(s -> s + "-continued"); + + assertThat(cf.join()).isEqualTo("default-continued"); + } + + // 2) handle()은 성공/실패 둘 다 처리 가능 + @Test + void handle_receivesResultOrException_and_canReturnDifferentValue() { + CompletableFuture cf = CompletableFuture.supplyAsync(() -> "ok", executor) + // 다음 단계에서 고의로 예외 발생 + .thenApply(s -> { + throw new RuntimeException("fail-in-thenApply"); + }) + // handle은 성공/실패 모두 실행됨 + .handle((res, ex) -> { + if (ex != null) { + // 예외가 발생한 경우 복구값 반환 + return "handled-recovery"; + } else { + return res; + } + }); + + assertThat(cf.join()).isEqualTo("handled-recovery"); + } + + // 3) whenComplete는 결과를 관찰만 하고 원래 결과를 넘겨준다 + @Test + void whenComplete_observesOutcome_but_doesNotChangeResult_unlessThrows() { + CompletableFuture cf = CompletableFuture.supplyAsync(() -> "good", executor) + .whenComplete((res, ex) -> { + // 관찰용 작업(로깅 등). 원래 결과를 변경하지 않음. + assertThat(ex).isNull(); + assertThat(res).isEqualTo("good"); + }); + + // whenComplete는 원본 결과를 그대로 전달 + assertThat(cf.join()).isEqualTo("good"); + + // 만약 whenComplete 안에서 예외를 던지면 그 예외가 전파된다. + CompletableFuture cf2 = CompletableFuture.supplyAsync(() -> "x", executor) + .whenComplete((res, ex) -> { + throw new IllegalArgumentException("observer-threw"); + }); + + assertThatThrownBy(cf2::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(IllegalArgumentException.class) + .hasRootCauseMessage("observer-threw"); + } + + // 4) 조합(thenCompose)된 단계에서 예외가 발생했을 때의 처리와 복구 + @Test + void thenCompose_exceptionInComposedStage_canBeRecoveredWithExceptionally() { + CompletableFuture initial = CompletableFuture.supplyAsync(() -> "start", executor); + + CompletableFuture pipeline = initial.thenCompose(s -> + // 내부에서 exception 발생 + CompletableFuture.supplyAsync(() -> { + throw new IllegalStateException("compose-boom"); + }, executor) + ) + // compose에서 발생한 예외를 여기서 복구 + .exceptionally(ex -> "composed-default") + .thenApply(s -> s + "-after"); + + assertThat(pipeline.join()).isEqualTo("composed-default-after"); + } + + // 5) join()/get() 시 예외 래핑(CompletionException/ExecutionException) 확인 + @Test + void join_wrapsCauseInCompletionException_whenStageFailed() { + CompletableFuture cf = CompletableFuture.supplyAsync(() -> { + throw new UnsupportedOperationException("underlying"); + }, executor); + + assertThatThrownBy(cf::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(UnsupportedOperationException.class) + .hasRootCauseMessage("underlying"); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/OdsayThrottleTest.java b/src/test/java/com/example/mohago_nocar/OdsayThrottleTest.java new file mode 100644 index 0000000..a94bff7 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/OdsayThrottleTest.java @@ -0,0 +1,208 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiRateLimitedClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +public class OdsayThrottleTest extends IntegrationTestSupport { + +/* @Autowired + private ODsayApiRateLimitedClient odsayApiClient; + + private RateLimiter rateLimiter = initializeRateLimiter().rateLimiter("테스트 속도 조절기"); + + + @Test + @DisplayName("1초에 몇 번 형식인가?") + void test() throws InterruptedException { + //given + + //when + for (int i = 0; i < 20; i++) { + CompletableFuture.runAsync(() -> { + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute( + Coordinate.from(126.98708591399983, 37.56127528907461), + Coordinate.from(126.99023335682591, 37.55377929365595) + ); + + System.out.println(response); + }); + } + + Thread.sleep(Duration.ofSeconds(10)); + } + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + @Test + @DisplayName("속도 제한 로직이 문제인지 확인한다") + void testRateLimiter() { + //given + String currentThreadName = Thread.currentThread().getName(); + + //when + for (int i = 0; i < 15; i++) { + if (rateLimiter.acquirePermission()) { + System.out.println(formatter.format(LocalDateTime.now()) + ":" + currentThreadName); + } + } + + //then + + } + + ExecutorService executorService = Executors.newFixedThreadPool(10); + AtomicInteger index = new AtomicInteger(0); + + @Test + @DisplayName("토큰 버킷 알고리즘 동작을 확인한다.") + void playWithTokenBucket() throws InterruptedException { + //given + Bandwidth limit = BandwidthBuilder.builder().capacity(1) + .refillGreedy(2, Duration.ofSeconds(1)) + .build(); + + LocalBucket bucket = Bucket.builder() + .addLimit(limit) + .build(); + + Thread.sleep(100); + //when + for (int i = 0; i < 20; i++) { + int requestOrder = i; + CompletableFuture.runAsync(() -> { + + try { + bucket.asBlocking().consume(1); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + System.out.println( + "[획득] order=" + requestOrder + + ", thread=" + Thread.currentThread().getName() + + ", time=" + formatter.format(LocalDateTime.now()) + ); + + }); + } + Thread.sleep(Duration.ofSeconds(10)); + + } + + @Test + @DisplayName("blocking consume는 공평한 대기(FIFO)를 보장하지 않는다") + void playWithTokenBucket_unfairBlocking() throws InterruptedException { + // given + Bandwidth limit = BandwidthBuilder.builder() + .capacity(1) // 의도적으로 1로 설정 + .refillIntervally(1, Duration.ofSeconds(1)) + .initialTokens(1) + .build(); + + LocalBucket bucket = Bucket.builder() + .addLimit(limit) + .build(); + + int threadCount = 10; + + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + + for (int i = 0; i < threadCount; i++) { + int requestOrder = i; + + CompletableFuture.runAsync(() -> { + try { + // 모든 스레드가 준비될 때까지 대기 + readyLatch.countDown(); + startLatch.await(); + + System.out.println( + "[요청] order=" + requestOrder + + ", thread=" + Thread.currentThread().getName() + + ", time=" + formatter.format(LocalDateTime.now()) + ); + + bucket.asBlocking().consume(1); + + System.out.println( + "[획득] order=" + requestOrder + + ", thread=" + Thread.currentThread().getName() + + ", time=" + formatter.format(LocalDateTime.now()) + ); + + } catch (InterruptedException e) { + System.out.println("인터럽트 발생: " + requestOrder); + } + }, executorService); + } + + // 모든 스레드가 준비될 때까지 대기 + readyLatch.await(); + + System.out.println("=== 모든 요청 스레드 준비 완료, 동시에 시작 ==="); + startLatch.countDown(); + + Thread.sleep(Duration.ofSeconds(12)); + } + + + private RateLimiterRegistry initializeRateLimiter() { + RateLimiterConfig config = RateLimiterConfig.custom() + .limitRefreshPeriod(Duration.ofSeconds(1)) + .limitForPeriod(5) + .timeoutDuration(Duration.ofSeconds(30)) + .build(); + + return RateLimiterRegistry.of(config); + } + + private static Bucket bucket; + + @BeforeAll + public static void init() { + // 200ms마다 1개의 토큰을 엄격하게 추가 + Bandwidth limit = BandwidthBuilder.builder().capacity(1) + .refillGreedy(4, Duration.ofSeconds(1)) + .initialTokens(1) + .build(); + + bucket = Bucket.builder() + .addLimit(limit) + .build(); + } + + @Test + @DisplayName("넷플리그 속도 제한 로직 대신 구글을 사용해본다") + public void executeWithRateLimit() throws InterruptedException { + String currentThreadName = Thread.currentThread().getName(); + + Thread.sleep(Duration.ofSeconds(2)); + try { + for (int i = 0; i < 10; i++) { + bucket.asBlocking().consume(1); // 토큰을 얻을 때까지 블로킹 + System.out.println(formatter.format(LocalDateTime.now()) + ":" + currentThreadName); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }*/ + +} diff --git a/src/test/java/com/example/mohago_nocar/RetryTest.java b/src/test/java/com/example/mohago_nocar/RetryTest.java new file mode 100644 index 0000000..0936c06 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/RetryTest.java @@ -0,0 +1,111 @@ +package com.example.mohago_nocar; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.*; +import java.util.function.Supplier; + +public class RetryTest { + +/* ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + Logger log = LoggerFactory.getLogger(RetryTest.class); + + public Retry init() { + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(3) + .retryOnException(e -> !(e instanceof RuntimeException)) + .intervalFunction(IntervalFunction.ofExponentialBackoff(1000, 2.0)) // 1초, 2배씩 증가 + .build(); + + Retry retry = Retry.of("transitApiRetry", retryConfig); + retry.getEventPublisher() + .onRetry(event -> log.warn("재시도 발생: {}번째 시도, 원인={}", + event.getNumberOfRetryAttempts(), + event.getLastThrowable().toString())) + .onError(throwable -> log.error("재시도 실패: 원인={}", + throwable.getLastThrowable().toString())); + + return retry; + } + + @Test + @DisplayName("DecorateCallable을 사용해봐요") + void testDecorateCallable(){ + //given + Retry retry = init(); + + //when + Callable callable = Retry.decorateCallable(retry, + () -> { + blockAndPrintAndThrowCheckedException("API 응답 대기 중..."); + return null; + }); + + //then + try { + callable.call(); + } catch (Exception e) { + System.out.println("API 호출에 실패했어요 ㅜㅜ"); + throw new RuntimeException(e); + } + + } + + private void blockAndPrintAndThrowCheckedException(String message) throws IOException { + try { + System.out.println(Thread.currentThread().getName() + ": " + "키 획득 대기를 시작합니다"); + Thread.sleep(Duration.ofSeconds(1)); // 여기까지 호출자 스레드가 수행(처음에만), equal acquire key + System.out.println(Thread.currentThread().getName() + ":" + message); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + throw new IOException("아이쿠! 실패!"); + } + + @Test + @DisplayName("CompletableFuture를 재시도 해요") + void testRetry() { + //given + Retry retry = init(); + + //when + Supplier> stageSupplier = Retry.decorateCompletionStage( + retry, + scheduler, + () -> blockAndPrint("API 응답 대기 중...") + ); + + CompletionStage stage = stageSupplier.get(); + CompletableFuture future = stage.thenRun(() -> System.out.println("API 호출 완료")) + .toCompletableFuture(); + try { + future.join(); + } catch (Exception e) { + System.out.println("API 호출에 실패했어요 ㅜㅜ"); + } + + //then + + } + + CompletableFuture blockAndPrint(String printMsg) { + try { + System.out.println(Thread.currentThread().getName() + ": " + "키 획득 대기를 시작합니다"); + Thread.sleep(Duration.ofSeconds(3)); // 여기까지 호출자 스레드가 수행(처음에만), equal acquire key + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return CompletableFuture.runAsync( + () -> { + System.out.println(Thread.currentThread().getName() + ":" + printMsg); + throw new RuntimeException("API 호출 실패"); + }); // equal api call + }*/ + +} diff --git a/src/test/java/com/example/mohago_nocar/RouteStepTest.java b/src/test/java/com/example/mohago_nocar/RouteStepTest.java new file mode 100644 index 0000000..fd912d3 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/RouteStepTest.java @@ -0,0 +1,160 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.course.application.route.RouteStepService; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import com.example.mohago_nocar.transit.domain.model.*; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class RouteStepTest extends LocalIntegrationTestSupport { + + @Autowired + private TravelCourseUseCase travelCourseUseCase; + + @Autowired + private RouteStepService routeStepService; + + @Test + @DisplayName("RouteStep을 저장하고 조회할 때 SubPath 리스트가 JSONB로 정상 직렬화/역직렬화된다") + void saveAndRetrieveRouteStepWithSubPaths() { + // given: SubPath 리스트를 포함한 RouteStep 생성 + WalkPath walkPath = new WalkPath(1.5, 15); + + Coordinate busStartCoord = Coordinate.from(37.5547, 126.9707); + Coordinate busEndCoord = Coordinate.from(37.5600, 126.9800); + BusPath busPath = new BusPath( + 5.2, 20, "402", 1, + "서울역", busStartCoord, + "시청역", busEndCoord + ); + + Coordinate subwayStartCoord = Coordinate.from(37.5600, 126.9800); + Coordinate subwayEndCoord = Coordinate.from(37.4979, 127.0276); + SubwayPath subwayPath = new SubwayPath( + 10.3, 25, "2호선", + "시청역", subwayStartCoord, + "강남역", subwayEndCoord + ); + + List subPaths = List.of(walkPath, busPath, subwayPath); + System.out.println("저장 전 ============="); + subPaths.forEach(System.out::println); + System.out.println("===================="); + + RouteStep routeStep = RouteStep.builder() + .originSpotId(1L) + .destinationSpotId(2L) + .timeTakenMin(60) + .distanceKm(17.1) + .detailPaths(subPaths) + .build(); + + // finalizeRoute로 확인 + // when: 저장 + List routeSteps = routeStepService.saveAll(List.of(routeStep)); + System.out.println("routeSteps returned saveAll method " + routeSteps); + + List ids = routeSteps.stream().map(route -> route.getId()).toList(); + List found = routeStepService.findAll(ids); + System.out.println("routeSteps returned findAll method " + routeSteps); + + // then: 조회 및 검증 +// RouteStep found = entityManager.find(RouteStep.class, routeStep.getId()); +// +// assertThat(found).isNotNull(); +// assertThat(found.getOriginSpotId()).isEqualTo(1L); +// assertThat(found.getDestinationSpotId()).isEqualTo(2L); +// assertThat(found.getTimeTakenMin()).isEqualTo(60); +// assertThat(found.getDistanceKm()).isEqualTo(17.1); +// +// // SubPath 리스트 검증 +// List foundPaths = found.getDetailPaths(); +// assertThat(foundPaths).hasSize(3); +// System.out.println("조회 완료 ================="); +// foundPaths.forEach(System.out::println); +// System.out.println("========================="); +// +// // 첫 번째 경로 - WalkSubPath +// SubPath firstPath = foundPaths.get(0); +// System.out.println(firstPath); +// assertThat(firstPath).isInstanceOf(WalkPath.class); +// assertThat(firstPath.getDistanceKm()).isEqualTo(1.5); +// assertThat(firstPath.getTimeTakenMin()).isEqualTo(15); +// assertThat(firstPath.getPathType()).isEqualTo(PathType.WALK); +// +// // 두 번째 경로 - BusPath +// SubPath secondPath = foundPaths.get(1); +// assertThat(secondPath).isInstanceOf(BusPath.class); +// assertThat(secondPath.getDistanceKm()).isEqualTo(5.2); +// assertThat(secondPath.getTimeTakenMin()).isEqualTo(20); +// BusPath busPathResult = (BusPath) secondPath; +// assertThat(busPathResult.getBusNo()).isEqualTo("402"); +// assertThat(busPathResult.getBusType()).isEqualTo(1); +// assertThat(busPathResult.getStartName()).isEqualTo("서울역"); +// assertThat(busPathResult.getEndName()).isEqualTo("시청역"); +// assertThat(secondPath.getPathType()).isEqualTo(PathType.BUS); +// +// // 세 번째 경로 - SubwayPath +// SubPath thirdPath = foundPaths.get(2); +// assertThat(thirdPath).isInstanceOf(SubwayPath.class); +// assertThat(thirdPath.getDistanceKm()).isEqualTo(10.3); +// assertThat(thirdPath.getTimeTakenMin()).isEqualTo(25); +// SubwayPath subwayPathResult = (SubwayPath) thirdPath; +// assertThat(subwayPathResult.getSubwayLineName()).isEqualTo("2호선"); +// assertThat(subwayPathResult.getStartName()).isEqualTo("시청역"); +// assertThat(subwayPathResult.getEndName()).isEqualTo("강남역"); +// assertThat(thirdPath.getPathType()).isEqualTo(PathType.SUBWAY); + } + + @Test + @DisplayName("추상 클래스를 jsonb type으로 저장할 수 있다") + void shouldSaved(){ + // given: SubPath 리스트를 포함한 RouteStep 생성 + WalkPath walkPath = new WalkPath(1.5, 15); + + Coordinate busStartCoord = Coordinate.from(37.5547, 126.9707); + Coordinate busEndCoord = Coordinate.from(37.5600, 126.9800); + BusPath busPath = new BusPath( + 5.2, 20, "402", 1, + "서울역", busStartCoord, + "시청역", busEndCoord + ); + + Coordinate subwayStartCoord = Coordinate.from(37.5600, 126.9800); + Coordinate subwayEndCoord = Coordinate.from(37.4979, 127.0276); + SubwayPath subwayPath = new SubwayPath( + 10.3, 25, "2호선", + "시청역", subwayStartCoord, + "강남역", subwayEndCoord + ); + + List subPaths = List.of(walkPath, busPath, subwayPath); + + RouteStep routeStep = RouteStep.builder() + .originSpotId(1L) + .destinationSpotId(2L) + .timeTakenMin(60) + .distanceKm(17.1) + .detailPaths(subPaths) + .build(); + + // when: 저장 +// entityManager.persist(routeStep); + } + + // 1. 엔티티 관련 문제 + // 2. pojo 직렬화/역직렬화 문제 -> subpath pojo 역직렬화/직렬화해보기 + +} diff --git a/src/test/java/com/example/mohago_nocar/SentrySendTest.java b/src/test/java/com/example/mohago_nocar/SentrySendTest.java new file mode 100644 index 0000000..e7647e6 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/SentrySendTest.java @@ -0,0 +1,26 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import io.sentry.Sentry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class SentrySendTest extends IntegrationTestSupport { + + @Test + @DisplayName("") + void shouldSendAlert(){ + //given + + //when + try { + throw new Exception("This is a test."); + } catch (Exception e) { + Sentry.captureException(e); + } + + //then + + } + +} diff --git a/src/test/java/com/example/mohago_nocar/TravelSpotOrderAssignmentPerformanceTest.java b/src/test/java/com/example/mohago_nocar/TravelSpotOrderAssignmentPerformanceTest.java new file mode 100644 index 0000000..7dd336a --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/TravelSpotOrderAssignmentPerformanceTest.java @@ -0,0 +1,242 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.plan.domain.model.Location; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.stream.Collectors; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +public class TravelSpotOrderAssignmentPerformanceTest { + @Test + @DisplayName("방식1(Map 인덱싱)과 방식2(중첩 루프) 성능 비교 - 5개 장소") + void comparePerformanceWith5Spots() { + // Given: 5개의 TravelSpot (일부 중복 좌표 포함) + Set spots1 = createTestSpots(100); + Set spots2 = new HashSet<>(spots1); + + List optimalRoute = spots1.stream() + .map(s -> s.getLocation().getCoordinate()) + .collect(Collectors.toList()); + + // When & Then: 방식1 (Map 인덱싱) + long startTime1 = System.nanoTime(); + assignOrderWithMap(spots1, optimalRoute); + long endTime1 = System.nanoTime(); + long duration1 = endTime1 - startTime1; + + // When & Then: 방식2 (중첩 루프) + long startTime2 = System.nanoTime(); + assignOrderWithNestedLoop(spots2, optimalRoute); + long endTime2 = System.nanoTime(); + long duration2 = endTime2 - startTime2; + + // 결과 검증 + assertThat(spots1).allMatch(s -> s.getVisitOrder() != null); + assertThat(spots2).allMatch(s -> s.getVisitOrder() != null); + + // 성능 비교 출력 + System.out.println("=== 100개 장소 성능 비교 ==="); + System.out.println("방식1 (Map 인덱싱): " + duration1 + " ns (" + duration1/1000.0 + " μs)"); + System.out.println("방식2 (중첩 루프): " + duration2 + " ns (" + duration2/1000.0 + " μs)"); + System.out.println("속도 차이: " + (duration2 > duration1 ? + "방식1이 " + (duration2 - duration1) + " ns 더 빠름" : + "방식2가 " + (duration1 - duration2) + " ns 더 빠름")); + } + + @RepeatedTest(10) + @DisplayName("반복 테스트로 평균 성능 측정 - 5개 장소") + void repeatedPerformanceTest() { + Set spots1 = createTestSpots(5); + Set spots2 = new HashSet<>(spots1); + + List optimalRoute = spots1.stream() + .map(s -> s.getLocation().getCoordinate()) + .collect(Collectors.toList()); + + long startTime1 = System.nanoTime(); + assignOrderWithMap(spots1, optimalRoute); + long duration1 = System.nanoTime() - startTime1; + + long startTime2 = System.nanoTime(); + assignOrderWithNestedLoop(spots2, optimalRoute); + long duration2 = System.nanoTime() - startTime2; + + System.out.printf("방식1: %d ns | 방식2: %d ns | 차이: %d ns%n", + duration1, duration2, Math.abs(duration1 - duration2)); + } + + @Test + @DisplayName("중복 좌표가 있는 경우 테스트") + void testWithDuplicateCoordinates() { + // Given: 중복 좌표 포함 + Coordinate dupCoord = Coordinate.from(127.0, 37.5); + Set spots = new HashSet<>(); + spots.add(new TestTravelSpot(1L, null, new Location(dupCoord, "장소1"))); + spots.add(new TestTravelSpot(1L, null, new Location(dupCoord, "장소2"))); + spots.add(new TestTravelSpot(1L, null, new Location(Coordinate.from(127.1, 37.6), "장소3"))); + +// List route = Arrays.asList( +// Coordinate.from(127.1, 37.6), +// dupCoord +// ); + + List route = Arrays.asList( + dupCoord + ); + + // When + Set spots1 = new HashSet<>(spots); + Set spots2 = new HashSet<>(spots); + + assignOrderWithMap(spots1, route); + + // Then: 중복 좌표의 경우 Map 방식은 마지막 것만, 중첩 루프는 모두 처리 + System.out.println("\n=== 중복 좌표 처리 결과 ==="); + System.out.println("방식1 (Map): " ); + spots1.stream() + .forEach(s -> System.out.println(s.getLocation().name + ": "+ s.getVisitOrder())); + + assignOrderWithNestedLoop(spots2, route); + System.out.println("방식2 (중첩 루프): " ); + spots2.stream() + .forEach(s -> System.out.println(s.getLocation().name + ": "+ s.getVisitOrder())); + } + + @Test + @DisplayName("정확성 검증 - 순서가 올바르게 할당되는지 확인") + void verifyCorrectness() { + // Given + Set spots = createTestSpots(5); + List route = spots.stream() + .map(s -> s.getLocation().getCoordinate()) + .collect(Collectors.toList()); + + Set spots1 = new HashSet<>(spots); + Set spots2 = new HashSet<>(spots); + + // When + assignOrderWithMap(spots1, route); + assignOrderWithNestedLoop(spots2, route); + + // Then + List orders1 = spots1.stream() + .map(TestTravelSpot::getVisitOrder) + .sorted() + .collect(Collectors.toList()); + + List orders2 = spots2.stream() + .map(TestTravelSpot::getVisitOrder) + .sorted() + .collect(Collectors.toList()); + + assertThat(orders1).containsExactly(0, 1, 2, 3, 4); + assertThat(orders2).containsExactly(0, 1, 2, 3, 4); + } + + // 방식1: Map 인덱싱 + private void assignOrderWithMap(Set unOrderedTravelSpots, List optimalRoute) { + Map> index = unOrderedTravelSpots.stream() + .collect(Collectors.groupingBy(s -> s.getLocation().getCoordinate())); + + int order = 0; + for (Coordinate next : optimalRoute) { + List spots = index.get(next); + if (spots != null) { + for (TestTravelSpot spot : spots) { + spot.setOrder(order++); + } + } + } + } + + + // 방식2: 중첩 루프 + private void assignOrderWithNestedLoop(Set unOrderedTravelSpots, List optimalRoute) { + int order = 0; + for (Coordinate nxt : optimalRoute) { + for (TestTravelSpot travelSpot : unOrderedTravelSpots) { + Coordinate spotCoordi = travelSpot.getLocation().getCoordinate(); + if (spotCoordi.equals(nxt)) { + travelSpot.setOrder(order++); + } + } + } + if (order != optimalRoute.size()) { + throw new RuntimeException("모든 장소에 순서가 부여되지 못했거나 여행 장소의 경위도 외의 추가 경위도가 존재합니다."); + } + } + + // 테스트용 데이터 생성 + private Set createTestSpots(int count) { + Set spots = new HashSet<>(); + for (int i = 0; i < count; i++) { + Coordinate coord = Coordinate.from(127.0 + i * 0.1, 37.5 + i * 0.1); + spots.add(new TestTravelSpot(1L, null, new Location(coord, "장소" + i))); + } + return spots; + } + + // 테스트용 TravelSpot 구현 + static class TestTravelSpot { + private Long id; + private Long courseId; + private Integer visitOrder; + private Location location; + + public TestTravelSpot(Long courseId, Integer visitOrder, Location location) { + this.courseId = courseId; + this.visitOrder = visitOrder; + this.location = location; + } + + public void setOrder(int i) { + visitOrder = i; + } + + public Integer getVisitOrder() { + return visitOrder; + } + + public Location getLocation() { + return location; + } + + @Override + public String toString() { + return "TestTravelSpot{" + + "id=" + id + + ", courseId=" + courseId + + ", visitOrder=" + visitOrder + + ", location=" + location + + '}'; + } + } + + static class Location { + private Coordinate coordinate; + private String name; + + public Location(Coordinate coordinate, String name) { + this.coordinate = coordinate; + this.name = name; + } + + public Coordinate getCoordinate() { + return coordinate; + } + + @Override + public String toString() { + return "Location{" + + "coordinate=" + coordinate + + ", name='" + name + '\'' + + '}'; + } + } + +} diff --git a/src/test/java/com/example/mohago_nocar/course/application/route/RouteStepFinderTest.java b/src/test/java/com/example/mohago_nocar/course/application/route/RouteStepFinderTest.java new file mode 100644 index 0000000..8b2bb12 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/application/route/RouteStepFinderTest.java @@ -0,0 +1,111 @@ +package com.example.mohago_nocar.course.application.route; + +import com.example.mohago_nocar.course.application.spot.TravelSpotService; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.*; + +import static org.assertj.core.api.Assertions.assertThat; + +class RouteStepFinderTest extends LocalIntegrationTestSupport { + + @Autowired + private RouteFinder routeStepFinder; + + @Autowired + private TravelSpotService travelSpotService; + +/* @Test + @DisplayName("호출 순서대로 API 요청을 완료된다.") + void shouldCompleteOneByOne() throws InterruptedException { + //given + long travelCourseId = 52L; + List spots = travelSpotService.getByCourseId(travelCourseId); + + CountDownLatch countDownLatch = new CountDownLatch(1); + ExecutorService executorService = Executors.newFixedThreadPool(10); + + for (int i = 0; i < 10; i++) { + int finalI = i; + countDownLatch.await(); + executorService.submit(() -> { + List> futures = routeStepFinder.findRouteInTravelCourse(travelCourseId, spots); + List routeSteps = futures.stream().map(CompletableFuture::join).toList(); + System.out.println(finalI +"번째 요청 완료: " + routeSteps); + }); + } + + countDownLatch.countDown(); + + //when + + //then + + }*/ + + @Test + @DisplayName("호출 순서대로 API 요청을 완료한다.") + void shouldCompleteOneByOne() throws InterruptedException { + // given + long travelCourseId = 25L; + List spots = travelSpotService.getByCourseId(travelCourseId); + System.out.println("spots size: " + spots.size()); + + int requestCount = 1; + System.out.println("total request count: " + requestCount * (spots.size() - 1)); + + CountDownLatch latch = new CountDownLatch(requestCount); + ExecutorService executorService = Executors.newFixedThreadPool(requestCount); + + // 예외 스택트레이스를 모아둘 리스트 + List exceptions = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < requestCount; i++) { + executorService.submit(() -> { + try { + List> futures = + routeStepFinder.findRouteInTravelCourse(travelCourseId, new ArrayList<>(spots)); + List routeSteps = new ArrayList<>(); + for (CompletableFuture future : futures) { + RouteStep join = future.join(); + routeSteps.add(join); + } + System.out.println("응답: " + routeSteps); + System.out.println(Thread.currentThread() + "의 임무 완료 시각 :" + LocalDateTime.now()); + + } catch (Exception e) { + // 예외를 리스트에 저장 + exceptions.add(e); + } finally { + latch.countDown(); + } + }); + } + + boolean completed = latch.await(120, TimeUnit.SECONDS); + Thread.sleep(Duration.ofSeconds(50)); + executorService.shutdown(); + + // 모든 예외 출력 + if (!exceptions.isEmpty()) { + System.out.println("=== 발생한 예외 스택트레이스 ==="); + for (Throwable t : exceptions) { + t.printStackTrace(System.out); + } + } + + assertThat(completed).isTrue(); + } + + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/course/application/spot/TravelSpotServiceTest.java b/src/test/java/com/example/mohago_nocar/course/application/spot/TravelSpotServiceTest.java new file mode 100644 index 0000000..d74fc0f --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/application/spot/TravelSpotServiceTest.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.course.application.spot; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class TravelSpotServiceTest { + + @Test + @DisplayName("") + void shouldDetermineTravelOrder(){ + //given + + //when + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisherTest.java b/src/test/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisherTest.java new file mode 100644 index 0000000..e492165 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisherTest.java @@ -0,0 +1,31 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class TravelCourseOptimizedEventPublisherTest extends LocalIntegrationTestSupport { + + @Autowired + private TravelCourseOptimizedEventPublisher publisher; + + @Test + @DisplayName("") + void shouldPublish() throws InterruptedException { + //given //when + for (int i = 0; i < 5; i++) { + publisher.publish(TravelCourseOptimizedEvent.of(61L, UUID.fromString("96ed22d6-ffd9-41c5-931a-d4a662e5054a"))); + } + + //then + Thread.sleep(Duration.ofSeconds(10)); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessorTest.java b/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessorTest.java new file mode 100644 index 0000000..c65bfcc --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessorTest.java @@ -0,0 +1,68 @@ +package com.example.mohago_nocar.course.infrastructure.stream; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.messaging.DeadLetterQueueEntryRepository; +import com.example.mohago_nocar.global.util.RedisStreamHelper; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.stream.*; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.BDDMockito.*; + +class DeadMessageProcessorTest extends LocalIntegrationTestSupport { + + @Autowired + DeadLetterQueueEntryRepository deadLetterQueueEntryRepository; + + @MockBean + RedisStreamHelper redisStreamHelper; + + @Test + @DisplayName("예외 발생 시 트랜잭션에 속한 작업들은 모두 롤백된다.") + void shouldRollbackThrownException(){ + // given: ack 단계에서 예외 발생하도록 설정 + willThrow(new RuntimeException("Redis Acknowledge 실패")) + .given(redisStreamHelper) + .acknowledgeAndDelete(anyString(), anyString(), any(RecordId[].class)); + + // given: readMessage는 정상 반환 + RecordId recordId = RecordId.of("1670000000-0"); + PendingMessage pendingMessage = new PendingMessage( + recordId, + Consumer.from("consumerGroup", "consumer"), // Consumer 객체 + Duration.ofMillis(1000), // 마지막 전달 이후 경과 시간 + 1L // 총 전달 횟수 + ); + + MapRecord mapRecord = + StreamRecords.mapBacked(Map.of( + (Object) "제발 끝내줘", (Object) TravelCourseOptimizedEvent.of(1L, UUID.randomUUID()))) + .withStreamKey("myStream") + .withId(recordId); + + given(redisStreamHelper.readMessage(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .willReturn(mapRecord); + +/* // when: DeadMessageProcessor 실행 → 예외 발생 + assertThatThrownBy(() -> + deadMessageProcessor.processLongPendingMessages(List.of(pendingMessage)) + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Redis Acknowledge 실패"); + + // then: DeadLetterQueueEntryRepository에는 데이터가 남아있지 않아야 함 + assertThat(deadLetterQueueEntryRepository.findByEntryId(recordId.getValue())).isEmpty();*/ + } + + // todo: making pending messages. half already has stored as DeadLetterQueueEntryDto to test + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/TravelCourseOptimizedStreamRecoveryManagerTest.java b/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/TravelCourseOptimizedStreamRecoveryManagerTest.java new file mode 100644 index 0000000..bc2face --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/TravelCourseOptimizedStreamRecoveryManagerTest.java @@ -0,0 +1,36 @@ +package com.example.mohago_nocar.course.infrastructure.stream; + +import com.example.mohago_nocar.course.infrastructure.course.messaging.TravelCourseOptimizedStreamRecoveryManager; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +class TravelCourseOptimizedStreamRecoveryManagerTest extends IntegrationTestSupport { + + @Value("${redis.streams.travel-spot.main}") + private String streamKey; + + private static final String CONSUMER_GROUP = "processors"; + private static final String CONSUMER_1 = "processor-1"; + + @Autowired + private TravelCourseOptimizedStreamRecoveryManager recoveryManager; + + @Test + @DisplayName("동작 테스트") + void test() { + //given + // pel에 엔트리 생성 + // recovery + // delivery count, idle time check + + //when + recoveryManager.recovery(streamKey, CONSUMER_GROUP, CONSUMER_1); + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryTest.java b/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryTest.java new file mode 100644 index 0000000..c97a03a --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryTest.java @@ -0,0 +1,26 @@ +package com.example.mohago_nocar.global.messaging; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.PendingMessage; +import org.springframework.data.redis.connection.stream.RecordId; + +import java.time.Duration; + +class DeadLetterQueueEntryTest { + + @Test + void create() { + PendingMessage pendingMessage = new PendingMessage( + RecordId.of("1670000000-0"), + Consumer.from("consumerGroup", "consumer"), // Consumer 객체 + Duration.ofMillis(1000), // 마지막 전달 이후 경과 시간 + 1L // 총 전달 횟수 + ); + + DeadLetterQueueEntry entry = DeadLetterQueueEntry.create( + DeadLetterQueueEntryDto.from("Sdfdsf", pendingMessage, "ㄹㄴㅇㄹㅇ")); + System.out.println(entry); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueServiceTest.java b/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueServiceTest.java new file mode 100644 index 0000000..d0cd7a8 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueServiceTest.java @@ -0,0 +1,27 @@ +package com.example.mohago_nocar.global.messaging; + +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class DeadLetterQueueServiceTest extends LocalIntegrationTestSupport { + + @Autowired + DeadLetterQueueService deadLetterQueueService; + + @Test + @DisplayName("Dead Letter를 저장한다.") + void shouldSaveDeadLetter(){ + //given + DeadLetterQueueEntryDto dto = DeadLetterQueueEntryDto.of("id", "streamName", "groupName", "consumerName", + "payload", new Throwable("테스트 중!")); + + //when + deadLetterQueueService.save(dto); + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/global/messaging/RedisStreamHelperTest.java b/src/test/java/com/example/mohago_nocar/global/messaging/RedisStreamHelperTest.java new file mode 100644 index 0000000..2833213 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/global/messaging/RedisStreamHelperTest.java @@ -0,0 +1,68 @@ +package com.example.mohago_nocar.global.messaging; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.global.util.RedisStreamHelper; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Map; +import java.util.UUID; + +class RedisStreamHelperTest extends LocalIntegrationTestSupport { + + @Autowired + private ObjectMapperUtil objectMapper; + + @Autowired + private RedisStreamHelper redisStreamHelper; + + @Autowired + private StringRedisTemplate redisTemplate; + + @Test + @DisplayName("") + void shouldAddStream(){ + //given + String eventJson = objectMapper.writeValue( + TravelCourseOptimizedEvent.of(1L, UUID.randomUUID()) + ); + + //when + ObjectRecord record = StreamRecords.newRecord().in("mystream").ofObject(eventJson); + RecordId recordId = redisTemplate.opsForStream().add(record); + + //then + System.out.println("recordId: " + recordId); + MapRecord entries = redisStreamHelper.readMessage("mystream", RecordId.of(recordId.getValue())); + System.out.println(entries.toString()); + System.out.println(entries.getValue()); + } + + @Test + @DisplayName("레코드(엔트리)를 읽는다") + void shouldReadRecord(){ + //given + //when + MapRecord record = redisStreamHelper.readMessage("mystream", RecordId.of("1766322817723-0")); + RecordId id = record.getId(); + Map recordValue = record.getValue(); + ObjectRecord objRecord = ObjectRecord.create("mystream", recordValue.toString()).withId(id); + System.out.println(objRecord); + String stream = objRecord.getStream(); + System.out.println(stream); + + //then + System.out.println(record.toString()); + System.out.println(record.getValue()); + // 결과: MapBackedRecord{recordId=1761874798218-0, kvMap={user_name=k}} + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java b/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java deleted file mode 100644 index f0ebc8f..0000000 --- a/src/test/java/com/example/mohago_nocar/plan/presentation/TravelPlanControllerV2Test.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.mohago_nocar.plan.presentation; - -import com.example.mohago_nocar.plan.presentation.v2.TravelPlanControllerV2; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -@WebMvcTest(controllers = TravelPlanControllerV2.class) -class TravelPlanControllerV2Test { - - @Test - @DisplayName("요청 파라미터가 유효하다면 성공 응답을 받는다.") - void shouldReturnSuccessResponseIfRequestIsValid(){ - //given - - //when - - //then - - } - - @Test - @DisplayName("요청 파라미터가 유효하지 않다면 실패 응답을 받는다.") - void shouldReturnFailureResponseIfRequestIsInvalid(){ - //given - - //when - - //then - - } - -} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/rateLimiter/RateLimiterTest.java b/src/test/java/com/example/mohago_nocar/rateLimiter/RateLimiterTest.java new file mode 100644 index 0000000..c009686 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/rateLimiter/RateLimiterTest.java @@ -0,0 +1,49 @@ +package com.example.mohago_nocar.rateLimiter; + +import com.example.mohago_nocar.global.rateLimit.IntervalRateLimiter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +class RateLimiterTest { + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + @Test + @DisplayName("rate limit test") + void shouldRateLimit() throws InterruptedException { + //given + IntervalRateLimiter rateLimiter = new IntervalRateLimiter(200); + + //when + for (int i = 0; i < 5; i++) { + final int order = i; + CompletableFuture.runAsync(() -> { + rateLimiter.throttle(); + System.out.println(order + "요청 받았습니다! : " + LocalDateTime.now().format(formatter)); + }); + + } + + Thread.sleep(Duration.ofMillis(300)); + + for (int i = 6; i < 10; i++) { + final int order = i; + CompletableFuture.runAsync(() -> { + rateLimiter.throttle(); + System.out.println(order + "요청 받았습니다! : " + LocalDateTime.now().format(formatter)); + }); + + } + + //then + Thread.sleep(Duration.ofSeconds(3)); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/redis/TemplateStudy.java b/src/test/java/com/example/mohago_nocar/redis/TemplateStudy.java new file mode 100644 index 0000000..d3246cd --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/redis/TemplateStudy.java @@ -0,0 +1,46 @@ +package com.example.mohago_nocar.redis; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.support.Fixtures; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +public class TemplateStudy extends IntegrationTestSupport { + + @Autowired + private RedisTemplate stringRedisTemplate; + + @Autowired + private RedisTemplate objectRedisTemplate; + + @Test + @DisplayName("이 뭐야") + void whatIsStringString(){ + //given + + //when + stringRedisTemplate.opsForValue().set("hello", "world"); + stringRedisTemplate.opsForList().leftPush("hello2", "world"); + stringRedisTemplate.opsForList().leftPush("hello2", "hello"); + + //then + System.out.println(stringRedisTemplate.opsForValue().get("hello")); + System.out.println(stringRedisTemplate.opsForList().range("hello2", 0, 2)); + } + + @Test + @DisplayName("가 뭐야") + void whatIsStringObject(){ + //given + //when + Location location = Fixtures.location(); + objectRedisTemplate.opsForValue().set("hello", location); + + //then + System.out.println(objectRedisTemplate.opsForValue().get("hello")); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/support/FakeDataFactoryConfig.java b/src/test/java/com/example/mohago_nocar/support/FakeDataFactoryConfig.java new file mode 100644 index 0000000..72b0496 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/support/FakeDataFactoryConfig.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.support; + +import net.datafaker.Faker; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Locale; + +@TestConfiguration +public class FakeDataFactoryConfig { + + @Bean + public Faker faker() { + return new Faker(new Locale("ko")); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/support/Fixtures.java b/src/test/java/com/example/mohago_nocar/support/Fixtures.java new file mode 100644 index 0000000..90c929c --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/support/Fixtures.java @@ -0,0 +1,171 @@ +package com.example.mohago_nocar.support; + +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.festival.domain.model.vo.ActivePeriod; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.model.PlaceCategory; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import net.datafaker.Faker; + +import java.time.LocalDate; +import java.util.List; +import java.util.Locale; +import java.util.stream.IntStream; + +public class Fixtures { + + static Faker faker = new Faker(Locale.KOREA); + + public static List places(int numberOfPlaces) { + return IntStream.range(0, numberOfPlaces).mapToObj(i -> place()).toList(); + } + + private static Place place() { + return Place.from( + faker.number().digits(4), // 랜덤 ID + faker.company().name() + " " + faker.options().option("해수욕장", "공원", "시장", "온천", "미술관"), // 장소 이름 + Coordinate.from( + faker.number().randomDouble(8, 126, 130), // 한국 경도 범위 + faker.number().randomDouble(8, 34, 38) // 한국 위도 범위 + ), + faker.address().fullAddress(), + faker.internet().url(), + faker.options().option(PlaceCategory.values()) + ); + } + + public static Festival festival() { + LocalDate startDate = LocalDate.now().minusDays(faker.number().numberBetween(1, 14)); + LocalDate endDate = LocalDate.now().plusDays(faker.number().numberBetween(1, 7)); + + return Festival.from( + faker.expression(faker.name() + "축제"), // 자연스러운 축제 이름 + ActivePeriod.from(startDate, endDate), + faker.lorem().sentence(8) + " 🎉", // 랜덤 설명 + faker.address().state(), // 지역 (예: 강원도, 전라남도) + Coordinate.from( + faker.number().randomDouble(8, 126, 130), + faker.number().randomDouble(8, 34, 38) + ) + ); + } + +/* public static List googleApiResponse() { + Coordinate c1 = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate c2 = Coordinate.from(126.8834795656736, 37.351812431636645); + Coordinate c3 = Coordinate.from(126.903150731652, 37.366183537311); + Coordinate c4 = Coordinate.from(126.848208105819, 37.3649832880928); + Coordinate c5 = Coordinate.from(126.897131767179, 37.3693104219685); + + return List.of( + // origin: c1 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("2.769 km", 2769L), + new GoogleDistanceMatrixResponse.Duration("24분", 1440L)), + c1, c2), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("18.793 km", 18793L), + new GoogleDistanceMatrixResponse.Duration("122분", 7320L)), + c1, c3), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("5.39 km", 5390L), + new GoogleDistanceMatrixResponse.Duration("55분", 3300L)), + c1, c4), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("2.555 km", 2555L), + new GoogleDistanceMatrixResponse.Duration("38분", 2280L)), + c1, c5), + + // origin: c2 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("2.762 km", 2762L), + new GoogleDistanceMatrixResponse.Duration("24분", 1440L)), + c2, c1), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("12.709 km", 12709L), + new GoogleDistanceMatrixResponse.Duration("104분", 6240L)), + c2, c3), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("7.724 km", 7724L), + new GoogleDistanceMatrixResponse.Duration("73분", 4380L)), + c2, c4), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("4.563 km", 4563L), + new GoogleDistanceMatrixResponse.Duration("53분", 3180L)), + c2, c5), + + // origin: c3 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("20.308 km", 20308L), + new GoogleDistanceMatrixResponse.Duration("101분", 6060L)), + c3, c1), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("19.463 km", 19463L), + new GoogleDistanceMatrixResponse.Duration("109분", 6540L)), + c3, c2), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("22.867 km", 22867L), + new GoogleDistanceMatrixResponse.Duration("107분", 6420L)), + c3, c4), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("13.475 km", 13475L), + new GoogleDistanceMatrixResponse.Duration("99분", 5940L)), + c3, c5), + + // origin: c4 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("5.152 km", 5152L), + new GoogleDistanceMatrixResponse.Duration("54분", 3240L)), + c4, c1), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("7.827 km", 7827L), + new GoogleDistanceMatrixResponse.Duration("73분", 4380L)), + c4, c2), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("23.228 km", 23228L), + new GoogleDistanceMatrixResponse.Duration("109분", 6540L)), + c4, c3), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("7.253 km", 7253L), + new GoogleDistanceMatrixResponse.Duration("85분", 5100L)), + c4, c5), + + // origin: c5 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("2.831 km", 2831L), + new GoogleDistanceMatrixResponse.Duration("37분", 2220L)), + c5, c1), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("4.568 km", 4568L), + new GoogleDistanceMatrixResponse.Duration("54분", 3240L)), + c5, c2), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("12.748 km", 12748L), + new GoogleDistanceMatrixResponse.Duration("96분", 5760L)), + c5, c3), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("8.514 km", 8514L), + new GoogleDistanceMatrixResponse.Duration("69분", 4140L)), + c5, c4) + ); + }*/ + + public static List locations(int numOfLocations) { + return IntStream.range(0, numOfLocations) + .mapToObj(i -> location()) + .toList(); + } + + public static Location location() { + return Location.of( + faker.funnyName().name(), + Coordinate.from( + faker.number().randomDouble(8, 126, 130), + faker.number().randomDouble(8, 34, 38) + )); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/support/IntegrationTestSupport.java b/src/test/java/com/example/mohago_nocar/support/IntegrationTestSupport.java new file mode 100644 index 0000000..d8d7aa9 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/support/IntegrationTestSupport.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.support; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class IntegrationTestSupport { +} diff --git a/src/test/java/com/example/mohago_nocar/support/LocalIntegrationTestSupport.java b/src/test/java/com/example/mohago_nocar/support/LocalIntegrationTestSupport.java new file mode 100644 index 0000000..3ce7e5a --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/support/LocalIntegrationTestSupport.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.support; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("dev") +public class LocalIntegrationTestSupport { +} diff --git a/src/test/java/com/example/mohago_nocar/test/DistinctTimeTest.java b/src/test/java/com/example/mohago_nocar/test/DistinctTimeTest.java new file mode 100644 index 0000000..1c0b6dc --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/DistinctTimeTest.java @@ -0,0 +1,72 @@ +package com.example.mohago_nocar.test; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +public class DistinctTimeTest { + + @Test + @DisplayName("나노초 단위로 when 절 수행 시간 측정") + void testMakingDistinctTime(){ + //given + // Coordinate 클래스와 createDestinations() 메서드는 필요합니다. + List destinations = createDestinations(); + + //when + long startNanoTime = System.nanoTime(); // when 절 시작 시간 기록 (나노초) + Map> map = new HashMap<>(); + + for (Coordinate coordinate : destinations) { + map.compute(coordinate, (k, v) -> { + if (v == null) { + // 첫 번째 요소는 식별자 "coordinate1"로 유지 + return new HashSet<>(Set.of("coordinate1")); + } else { + // Key가 있으면 기존 Set에 현재 나노초 시간 추가 + v.add(System.nanoTime()); + return v; + } + }); + } + + long endNanoTime = System.nanoTime(); // when 절 종료 시간 기록 (나노초) + long totalDurationNanos = endNanoTime - startNanoTime; // 총 수행 시간 (나노초) + + // 나노초 단위의 시간을 보기 좋은 포맷으로 변환 및 출력 + long seconds = totalDurationNanos / 1_000_000_000; // 초 (10^9 나노초) + long nanoseconds = totalDurationNanos % 1_000_000_000; // 나머지 나노초 + + System.out.println("---"); + // 초와 나머지 나노초를 함께 출력합니다. (%09d는 나노초를 9자리로 채워서 출력) + System.out.printf("when 절 수행 시간: %d.%09d초 (%d ns)%n", + seconds, nanoseconds, totalDurationNanos); + System.out.println("---"); + + //then + for (Map.Entry> entry : map.entrySet()) { + Coordinate coordinate = entry.getKey(); + Set objects = entry.getValue(); + System.out.println(coordinate); + System.out.println(objects); + } + } + + private Coordinate createFixedCoordinate() { + return Coordinate.from(126.872939584803, 37.3700357495453); + } + + private List createDestinations() { + Coordinate dest1 = Coordinate.from(126.8834795656736, 37.351812431636645); + Coordinate dest2 = Coordinate.from(126.899445340496, 37.3673238473972); + Coordinate dest3 = Coordinate.from(126.848208105819, 37.3649832880928); + Coordinate dest4 = createFixedCoordinate(); + Coordinate dest5 = createFixedCoordinate(); + + return List.of(dest1, dest2, dest3, dest4, dest5); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/test/ForCustomLoggerTest.java b/src/test/java/com/example/mohago_nocar/test/ForCustomLoggerTest.java new file mode 100644 index 0000000..4445d4d --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/ForCustomLoggerTest.java @@ -0,0 +1,24 @@ +package com.example.mohago_nocar.test; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ForCustomLoggerTest { + + ForCustomLogger logger = new ForCustomLogger(); + + @Test + @DisplayName("로그를 찍어봐요") + void test(){ + //given + + //when + logger.log(); + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/test/v2/ESSubmitterTest.java b/src/test/java/com/example/mohago_nocar/test/v2/ESSubmitterTest.java new file mode 100644 index 0000000..1ecd644 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/v2/ESSubmitterTest.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.test.v2; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; + +class ESSubmitterTest extends IntegrationTestSupport { + + @Autowired + ESSubmitter esSubmitter; + + @Test + @DisplayName("ExecutorService Task에서 예외를 던지면 ?된다!") + void test() { + esSubmitter.submit(); + esSubmitter.submit(); + esSubmitter.submit(); + + sleep(3); + } + + private void sleep(int seconds) { + try { + Thread.sleep(Duration.ofSeconds(seconds)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/test/v3/LockSupportBasedRateLimiterTest.java b/src/test/java/com/example/mohago_nocar/test/v3/LockSupportBasedRateLimiterTest.java new file mode 100644 index 0000000..f7f3b86 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/v3/LockSupportBasedRateLimiterTest.java @@ -0,0 +1,25 @@ +package com.example.mohago_nocar.test.v3; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LockSupportBasedRateLimiterTest { + + LockSupportBasedRateLimiter rateLimiter = new LockSupportBasedRateLimiter(); + + @Test + @DisplayName("") + void test() { + //given + + //when + for (int i = 0; i < 5; i++) { + rateLimiter.execute(); + } + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/test/v3/RateLimiterMethodTest.java b/src/test/java/com/example/mohago_nocar/test/v3/RateLimiterMethodTest.java new file mode 100644 index 0000000..2c3692f --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/v3/RateLimiterMethodTest.java @@ -0,0 +1,46 @@ +package com.example.mohago_nocar.test.v3; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +class RateLimiterMethodTest extends IntegrationTestSupport { + + @Autowired + private RateLimiterMethod rateLimiterMethod; + + @Test + @DisplayName("두 메서드 동작이 동일함을 확인한다.") + void test() throws InterruptedException { + //given + + //when + for (int i = 0; i < 5; i++) { + rateLimiterMethod.experimentalRateLimitMethod(i); + } + + //then + Thread.sleep(Duration.ofSeconds(5)); + } + + @Test + @DisplayName("") + void test2() throws InterruptedException { + //given + + //when + for (int i = 0; i < 5; i++) { + rateLimiterMethod.print(); + } + + //then + Thread.sleep(Duration.ofSeconds(5)); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/test/v3/StrictRateLimiterTest.java b/src/test/java/com/example/mohago_nocar/test/v3/StrictRateLimiterTest.java new file mode 100644 index 0000000..17d91af --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/v3/StrictRateLimiterTest.java @@ -0,0 +1,29 @@ +package com.example.mohago_nocar.test.v3; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.junit.jupiter.api.Assertions.*; + +class StrictRateLimiterTest extends IntegrationTestSupport { + + @Autowired + private StrictRateLimiter strictRateLimiter; + + @Test + @DisplayName("") + void test(){ + //given + + //when + for (int i = 0; i < 5; i++) { + strictRateLimiter.executeWithRateLimit(); + } + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/LuaScriptTest.java b/src/test/java/com/example/mohago_nocar/transit/LuaScriptTest.java new file mode 100644 index 0000000..68600c2 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/LuaScriptTest.java @@ -0,0 +1,80 @@ +package com.example.mohago_nocar.transit; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; + +// key:batchId, value: route 저장 +// batch의 completedTaskCount+1 update +// batch가 완료되었다면 batch status 변경 +// batch entry를 ack + delete +// batch status 반환 +public class LuaScriptTest extends IntegrationTestSupport { + + @Autowired + private StringRedisTemplate redisTemplate; + + private static final String ZSET_KEY = "lua:zset:test"; + + @Test + @DisplayName("응답 정보를 저장할 sorted set을 사용해본다") + void shouldUseSortedSet(){ + //given + String lua = + "local key = KEYS[1]\n" + + "local score = tonumber(ARGV[1])\n" + + "local member = ARGV[2]\n" + + "return redis.call('ZADD', key, score, member)"; + + DefaultRedisScript script = new DefaultRedisScript<>(lua, Long.class); + Long result = redisTemplate.execute(script, List.of(ZSET_KEY), "10", "coffee"); + + assertThat(result).isEqualTo(1L); + assertThat(redisTemplate.opsForZSet().range(ZSET_KEY, 0, -1)).isEqualTo(Set.of("coffee")); + + //when + + //then + + } + + @Test + @DisplayName("배치 정보가 저장된 해시를 사용해본다") + void shouldUseHash(){ + //given + String lua = + "local key = KEYS[1]\n" + + "local count = tonumber(ARGV[1])\n" + + "local lastId = ARGV[2]\n" + + "return redis.call('XREAD', 'COUNT', count, 'STREAMS', key, lastId)"; + + DefaultRedisScript script = new DefaultRedisScript<>(lua, List.class); + + //when + List mystream = redisTemplate.execute(script, List.of("mystream"), "2", "0"); + + //then + System.out.println(mystream); + } + + @Test + @DisplayName("스트림 엔트리에 ack과 delete를 사용해본다") + void shouldUseStream(){ + //given + + //when + + //then + + } + +} diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java index 60115da..8c977c0 100644 --- a/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java @@ -11,11 +11,12 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.ActiveProfiles; import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; @@ -30,11 +31,29 @@ class GoogleDistanceMatrixApiAdapterTest { @SpyBean private GoogleApiClient googleApiClient; + @Test + @DisplayName("출발지와 도착지의 경위도가 동일해도 정상적으로 응답한다.") + void shouldReturnSuccessResponseWhenSameCoordinateInputs(){ + //given + Coordinate origin = createFixedCoordinate(); + List destinations = IntStream.range(0, 3) + .mapToObj(i -> createFixedCoordinate()) + .toList(); + + //when & then + List routeMetrics = googleDistanceMatrixApiAdapter.getDistanceAndDuration(origin, destinations); + System.out.println(routeMetrics); + } + + private Coordinate createFixedCoordinate() { + return Coordinate.from(126.872939584803, 37.3700357495453); + } + @DisplayName("유효하지 않은 API 응답임이 확인되면 예외를 throw 한다.") @Test public void getDistanceAndDuration_throwException_if_invalid() { //given - Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate origin = createFixedCoordinate(); List destinations = createDestinations(); when(googleApiClient.getDistanceMatrix(origin, destinations)) @@ -50,7 +69,7 @@ public void getDistanceAndDuration_throwException_if_invalid() { @Test public void getDistanceAndDuration_catchException_if_null() { //given - Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate origin = createFixedCoordinate(); List destinations = createDestinations(); when(googleApiClient.getDistanceMatrix(origin, destinations)) @@ -67,7 +86,7 @@ public void getDistanceAndDuration_catchException_if_null() { @Test public void getDistanceAndDuration(){ //given - Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate origin = createFixedCoordinate(); List destinations = createDestinations(); //when diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java index 66e0e4e..b5180ec 100644 --- a/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java @@ -9,7 +9,9 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; @SpringBootTest @@ -19,7 +21,7 @@ class GoogleApiClientTest { @Autowired private GoogleApiClient googleApiClient; - @DisplayName("출발지와 도착지 사이의 거리 및 이동 시간을 조회할 수 있다.") + @DisplayName("출발지와 도착지 사이의 거리 및 이동 시간을 조회한다.") @Test public void getDistanceMatrix() { //given @@ -32,7 +34,17 @@ public void getDistanceMatrix() { List destinations = List.of(dest1, dest2, dest3); //when - GoogleDistanceMatrixResponse response = googleApiClient.getDistanceMatrix(origin, destinations); + List> futures = new ArrayList<>(); + + for (int i = 0; i < 4; i++) { + CompletableFuture future = CompletableFuture.supplyAsync( + () -> googleApiClient.getDistanceMatrix(origin, destinations)); + futures.add(future); + } + + + +/* GoogleDistanceMatrixResponse response = googleApiClient.getDistanceMatrix(origin, destinations); //then Assertions.assertThat(response).isNotNull(); @@ -41,7 +53,7 @@ public void getDistanceMatrix() { for (GoogleDistanceMatrixResponse.Element element : response.rows().get(0).elements()) { Assertions.assertThat(element.distance().text()).isNotNull(); Assertions.assertThat(element.duration().text()).isNotNull(); - } + }*/ } } \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java index 938f94d..8b9a5c6 100644 --- a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java @@ -5,7 +5,7 @@ import com.example.mohago_nocar.transit.domain.model.TransitRoute; import com.example.mohago_nocar.transit.domain.model.WalkPath; import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; -import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiRateLimitedClient; import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayTransitRouteApiAdapter; import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; @@ -31,7 +31,7 @@ class ODsayTransitRouteApiAdapterTest { ODsayTransitRouteApiAdapter adapter; @MockBean - ODsayApiClient odsayApiClient; + ODsayApiRateLimitedClient odsayApiClient; @DisplayName("odsay API가 유효한 응답을 주면 TransitRoute 객체로 변환한다.") @Test diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClientTest.java similarity index 96% rename from src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java rename to src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClientTest.java index 3ddfa80..def2dcf 100644 --- a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiClientTest.java +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClientTest.java @@ -14,10 +14,10 @@ @SpringBootTest @ActiveProfiles("test") -class ODsayApiClientTest { +class ODsayApiRateLimitedClientTest { @Autowired - private ODsayApiClient odsayApiClient; + private ODsayApiRateLimitedClient odsayApiClient; @DisplayName("출발지와 도착지 간의 대중교통 경로를 조회할 수 있다.") @Test diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitingTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitingTest.java new file mode 100644 index 0000000..16a3529 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitingTest.java @@ -0,0 +1,75 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated.ODsayApiClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +class ODsayApiRateLimitingTest extends LocalIntegrationTestSupport { + + @Autowired + private ODsayApiClient odsayApiClient; + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + @Test + @DisplayName("속도를 측정한다.") + void testLimit() throws InterruptedException { + + AtomicInteger count = new AtomicInteger(0); +/* for (int i = 0; i < 20; i++) { + int reqOrder = i; + CompletableFuture.runAsync(() -> { + System.out.println(reqOrder + ": 요청, 시각: " + formatter.format(LocalDateTime.now())); + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute( + Coordinate.from(126.98708591399983, 37.56127528907461), + Coordinate.from(126.99023335682591, 37.55377929365595) + ); + if (response instanceof ODsayRouteInvalidResponse) { + System.out.println(reqOrder + ": 실패"); + } else { + System.out.println(reqOrder + ": 성공"); + } + }); + }*/ + scheduler.scheduleAtFixedRate(() -> { + int reqOrder = count.incrementAndGet(); + + CompletableFuture.runAsync(() -> { + System.out.println(reqOrder + ": 요청, 시각: " + formatter.format(LocalDateTime.now())); + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute( + Coordinate.from(126.98708591399983, 37.56127528907461), + Coordinate.from(126.99023335682591, 37.55377929365595) + ); + if (response instanceof ODsayRouteInvalidResponse) { + System.out.println(reqOrder + ": 실패"); + } else { + System.out.println(reqOrder + ": 성공"); + } + }); + + }, 0, 200, TimeUnit.MILLISECONDS); + + Thread.sleep(Duration.ofSeconds(2)); + + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/process/StreamPendingHandlerTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/process/StreamPendingHandlerTest.java new file mode 100644 index 0000000..75b15e6 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/process/StreamPendingHandlerTest.java @@ -0,0 +1,58 @@ +package com.example.mohago_nocar.transit.infrastructure.route.process; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class StreamPendingHandlerTest { + + @Test + @DisplayName("비정상적으로 오래 걸리는 경우 서버 크러쉬로 간주하고 재처리를 시도한다") + void t1(){ + //given + // 요청 전송 + + //when + // 파트 1인데 10초 걸려 + // 파트 2인데 타임아웃 시간보다 더 걸려 + // -> 결과 저장되어 있으면 알림 전송 후 ack처리할지도? -> 알림이 이미 전송되어있으면 어카냐고요 + + //then + // 재시도큐로 전송됨 + } + + @Test + @DisplayName("API 응답 타임아웃 에러가 발생한 경우 실패 처리한다") + void t2(){ + //given + + //when + + //then + + } + + @Test + @DisplayName("실패 표기된 배치 발견 시 실패 알림을 전송한다") + void t3(){ + //given + + //when + + //then + + } + + @Test + @DisplayName("외부 API 서버 내부 원인인 경우 에러 메시지를 로깅한다") + void t4(){ + //given + + //when + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/test/TestConsumerTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/test/TestConsumerTest.java new file mode 100644 index 0000000..d5feea7 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/test/TestConsumerTest.java @@ -0,0 +1,170 @@ +package com.example.mohago_nocar.transit.infrastructure.route.test; + +import com.example.mohago_nocar.support.IntegrationTestSupport; + +class TestConsumerTest extends IntegrationTestSupport { + +/* + @Autowired + TestProducer testProducer; + + @Autowired + TestConsumer testConsumer; + + @Test + @DisplayName("기본 설정 스레드 동작을 확인한다") + void test() throws InterruptedException { + //given + int requestThreads = 3; + ExecutorService executor = Executors.newFixedThreadPool(requestThreads); + CountDownLatch countDownLatch = new CountDownLatch(requestThreads); + + // when + for (int i = 1; i <= requestThreads; i++) { + final int index = i; + + executor.submit(() -> { + try { + testProducer.produce(index + "번째 메시지임둥"); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + countDownLatch.countDown(); + } + }); + } + + countDownLatch.await(); + + //then + Thread.sleep(Duration.ofSeconds(10)); + } + + @Test + @DisplayName("스트림 리스너 컨테이너에 executor를 지정하는 경우 동작을 확인한다") + void test2() throws InterruptedException { + //given + + //when + + //then + + } + + @Test + @DisplayName("예외 객체 메서드를 사용해봐요") + void test3(){ + //given + try { + throw new CustomException(GlobalStatus.ENTITY_NOT_FOUND); + } catch (CustomException e) { + System.out.println("getMessage:" + e.getMessage()); + System.out.println("getCause:" + e.getCause()); + List topStackTrace = getTopStackTrace(e, 10); + for (String s : topStackTrace) { + System.out.println(s); + } + } + + //when + + //then + + } + + @Test + @DisplayName("") + void test4() throws InterruptedException { + //given + + //when + for (int i = 0; i < 10; i++) { + testProducer.produce(i + "번째 메시지임둥"); + } + + //then + Thread.sleep(Duration.ofSeconds(1)); + testConsumer.destroy(); + Thread.sleep(Duration.ofSeconds(10)); + } + + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.KOREAN); + + @Test +// @DisplayName("New가 있어도 PEL부터 읽는다는게 사실인지 확인한다 -> 구라임") + @DisplayName("Completable Future timeout 기능 확인해봐요") + void test6(){ + //given + CompletableFuture future1 = CompletableFuture.supplyAsync( + () -> { + try { + Thread.sleep(Duration.ofSeconds(3)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + System.out.println("안녕? "); + return "안녕? "; + }); + + CompletableFuture future2 = future1 + .thenApply(s -> { + try { + Thread.sleep(Duration.ofSeconds(1)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + System.out.println("응 ~ 안녕"); + return s + "응 ~ 안녕"; + }) + .thenApply(s -> { + try { + Thread.sleep(Duration.ofSeconds(3)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return s; + }); + + //when + + try { + String result = future2.orTimeout(2, SECONDS).get(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + + + //then + + } + + @Test + @DisplayName("") + void test5(){ + //given + + //when + for (int i = 0; i < 5; i++) { + System.out.println(formatter.format(LocalDateTime.now())); + LockSupport.parkNanos(SECONDS.toNanos(1)); + System.out.println(formatter.format(LocalDateTime.now())); + + } + + //then + + } + + private static List getTopStackTrace(Throwable ex, int limit) { + return Arrays.stream(ex.getStackTrace()) + .limit(limit) + .map(StackTraceElement::toString) + .collect(Collectors.toList()); + } +*/ + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedApiKeyPoolTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedApiKeyPoolTest.java new file mode 100644 index 0000000..278c837 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedApiKeyPoolTest.java @@ -0,0 +1,125 @@ +package com.example.mohago_nocar.transit.infrastructure.route.v2; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BandwidthBuilder; +import io.github.bucket4j.Bucket; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RateLimitedApiKeyPoolTest { + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + + @Test + @DisplayName("") + void shouldNotEnsureFirstInputFirstOut() throws InterruptedException { + // given + Bucket bucket = createBucket(); + ExecutorService executor = Executors.newFixedThreadPool(15); + List executionOrder = Collections.synchronizedList(new ArrayList<>()); + + // when: 동시에 여러 스레드가 토큰 요청 + CountDownLatch latch = new CountDownLatch(1); + for (int i = 1; i <= 15; i++) { + final int id = i; + executor.submit(() -> { + try { + latch.await(); // 동시에 시작 + bucket.asBlocking().consume(1); // 토큰 획득 (대기 가능) + executionOrder.add(id); // 획득한 순서 기록 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + latch.countDown(); // 모든 스레드 동시에 시작 + executor.shutdown(); + executor.awaitTermination(15, TimeUnit.SECONDS); + + // then: 요청 순서(1,2,3,4,5)와 실제 획득 순서가 다를 수 있음 + System.out.println("Execution order: " + executionOrder); + + assertThat(executionOrder).isNotEqualTo(List.of(1, 2, 3, 4, 5)); + } + + private Bucket createBucket() { + Bandwidth limit = BandwidthBuilder.builder().capacity(5) + .refillIntervally(5, Duration.ofSeconds(1)) + .initialTokens(0) + .build(); + + return Bucket.builder() + .addLimit(limit) + .build(); + } + +/* @Test + @DisplayName("요청 순서를 올바르게 카운팅한다") + void shouldHaveRaceConditionWhenPlainInteger() throws InterruptedException, NoSuchFieldException, IllegalAccessException { + //given +// RateLimitedApiKeyPool pool = new RateLimitedApiKeyPool(fakeProperties(), () -> createBucket()); + + RateLimitedApiKeyPool pool = new RateLimitedApiKeyPoolConfig().rateLimitedOdsayApiKeyPool(fakeProperties()); + int threadCount = 20; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(); + + //when + for (int i = 0; i < threadCount; i++) { + futures.add(executor.submit(() -> { + String key = pool.acquireEncodedKey(); + System.out.println(formatter.format(LocalDateTime.now()) + + ":" + key); + return key; + })); + } + + executor.shutdown(); + executor.awaitTermination(5, TimeUnit.SECONDS); + + //then + int nextOrder = pool.getNextOrder(); + System.out.println("final order = " + nextOrder); + }*/ + + @Test + @DisplayName("") + void shouldGetAndIncrement(){ + //given + AtomicInteger atomicInteger = new AtomicInteger(); + + //when + for (int i = 0; i < 20; i++) { + int result = atomicInteger.getAndIncrement() % 2; + System.out.println(i + "번째 요청의 결과:" + result); + } + + //then + } + + private Bucket mockBucket() { + Bandwidth limit = BandwidthBuilder.builder() + .capacity(Long.MAX_VALUE) + .refillIntervally(1, Duration.ofNanos(1)) + .build(); + + return Bucket.builder() + .addLimit(limit) + .build(); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedODsayApiRateLimitedClientTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedODsayApiRateLimitedClientTest.java new file mode 100644 index 0000000..89ab093 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedODsayApiRateLimitedClientTest.java @@ -0,0 +1,57 @@ +package com.example.mohago_nocar.transit.infrastructure.route.v2; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiRateLimitedClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; + +class RateLimitedODsayApiRateLimitedClientTest extends IntegrationTestSupport { + + @Autowired + private ODsayApiRateLimitedClient odsayApiClient; + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + @DisplayName("출발지와 도착지 간의 대중교통 경로를 429에러 없이 조회한다.") + @Test + public void safeSearchTransitRoute() throws InterruptedException { + System.out.println("아!아! 마이크테스트! 시작합니다- : " + formatter.format(LocalDateTime.now())); + + List> futures = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + CompletableFuture future = + CompletableFuture.runAsync(() -> callAPI()); + futures.add(future); + } + + // 모든 작업이 완료될 때까지 대기 + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + System.out.println("끝났어요!: " + formatter.format(LocalDateTime.now())); + } + + private void callAPI() { + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate dest = Coordinate.from(126.8834795656736, 37.351812431636645); + + //when + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute(origin, dest); + + //then + System.out.println(response); + } + +} \ No newline at end of file diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..43e6b5b --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + logs/app/error.log + + + ERROR + ACCEPT + DENY + + + + originalEvent + throwableClass + throwableMessage + throwableType + rootCause + stackTrace + + + + logs/app/%d{yyyy-MM-dd}-error.log + 90 + 5GB + + + + + + logs/app/warn.log + + + WARN + ACCEPT + DENY + + + + originalEvent + throwableClass + throwableMessage + throwableType + + + + logs/app/%d{yyyy-MM-dd}-warn.log + 90 + 5GB + + + + + + + + + + + \ No newline at end of file From d1ff189d096ff2e2e7c550bd1022510b55d5f2b4 Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 27 Dec 2025 09:58:24 +0900 Subject: [PATCH 80/84] =?UTF-8?q?feat:=20=EB=A9=B1=EB=93=B1=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=95=84=EC=9D=B4=EB=94=94=EB=A5=BC=20ent?= =?UTF-8?q?ry=20ID=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/messaging/DeadLetterQueueEntry.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java index eae10ac..fa72026 100644 --- a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java @@ -17,9 +17,9 @@ public class DeadLetterQueueEntry extends BaseEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Comment("DLQ 엔트리 ID") - private Long id; + @Column(nullable = false, length = 100) + @Comment("Stream entry ID") + private String entryId; @Column(nullable = false, length = 100) @Comment("Redis Stream 키") @@ -29,10 +29,6 @@ public class DeadLetterQueueEntry extends BaseEntity { @Comment("컨슈머 그룹 이름") private String consumerGroup; - @Column(nullable = false, length = 100) - @Comment("Stream entry ID") - private String entryId; - @Column(columnDefinition = "TEXT") @Comment("메시지 페이로드 (JSON)") private String payload; From 334bfc9ab992ae2093c840b7c0b8d725cf5d571c Mon Sep 17 00:00:00 2001 From: mungsil Date: Sat, 27 Dec 2025 10:09:01 +0900 Subject: [PATCH 81/84] =?UTF-8?q?feat:=20=EC=9C=A0=EB=8B=88=ED=81=AC=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course/domain/model/routeStep/RouteStep.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java index 21d869f..e9ef6f0 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java @@ -20,7 +20,10 @@ @Entity @Getter -@Table(name = "route_step") +@Table(name = "route_step", + uniqueConstraints = + @UniqueConstraint(columnNames = {"originSpotId", "destinationSpotId"}) +) @ToString @NoArgsConstructor(access = PROTECTED) public class RouteStep extends BaseEntity { @@ -35,7 +38,7 @@ public class RouteStep extends BaseEntity { @Type(JsonBinaryType.class) @Column(columnDefinition = "jsonb") - private List detailPaths; // subpath는 transit 건데, 그럼 dto도 transit에 있어야하는게 적절하지 않나 + private List detailPaths; @NotNull private Double distanceKm; From 8fae4fa8422a81647956cf40882e29660f798a5a Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 29 Dec 2025 16:01:27 +0900 Subject: [PATCH 82/84] =?UTF-8?q?feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=20=EB=B0=8F=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=88=84=EB=9D=BD=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Outbox=20=ED=8C=A8=ED=84=B4=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TravelCourseCompletionNotifier.java | 64 ---------- .../course/TravelCourseEventHandler.java | 71 +++++++++++ .../TravelCourseEventOutboxHandler.java | 38 ++++++ .../TravelCourseEventOutboxService.java | 61 ++++++++++ .../TravelCourseOptimizedEventHandler.java | 50 -------- .../course/TravelCourseService.java | 37 ++---- .../application/route/RouteStepService.java | 1 - .../event/TravelCourseOptimizedEvent.java | 5 + .../domain/model/course/TravelCourse.java | 18 +-- ...avelCourseOptimizedEventHandleHistory.java | 38 ++++++ .../TravelCourseOptimizedEventOutbox.java | 65 ++++++++++ ...rseStatus.java => TravelCourseStatus.java} | 14 ++- .../TravelCourseEventOutboxRepository.java | 15 +++ ...OptimizedEventHandleHistoryRepository.java | 8 ++ .../repository/TravelCourseRepository.java | 5 +- .../domain/service/TravelCourseUseCase.java | 9 +- .../TravelCourseOptimizedMessageConsumer.java | 29 ++--- .../TravelCourseOptimizedStreamConfig.java | 18 ++- ...TravelCourseEventOutboxRepositoryImpl.java | 30 +++++ .../repository/TravelCourseJpaRepository.java | 26 +--- ...imizedEventHandleHistoryJpaRepository.java | 9 ++ ...mizedEventHandleHistoryRepositoryImpl.java | 22 ++++ ...urseOptimizedEventOutboxJpaRepository.java | 32 +++++ .../TravelCourseRepositoryImpl.java | 11 +- .../global/common/domain/OutboxStatus.java | 7 ++ .../user/UserNotificationOutboxHandler.java | 49 ++++++++ .../user/UserNotificationOutboxService.java | 93 ++++++++++++++ .../user/UserNotificationService.java | 46 ------- .../user/UserNotificationServiceImpl.java | 30 ----- .../domain/UserNotificationMessageOutbox.java | 114 ++++++++++++++++++ ...otificationMessageOutboxJpaRepository.java | 20 +++ .../infrastructure/fcm/FcmMessage.java | 40 ------ .../infrastructure/fcm/FcmMessageSender.java | 33 ----- .../transit/domain/model/TransitRoute.java | 1 - .../test/ForCustomLoggerTest.java | 24 ---- 35 files changed, 725 insertions(+), 408 deletions(-) delete mode 100644 src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseCompletionNotifier.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventHandler.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxHandler.java create mode 100644 src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxService.java delete mode 100644 src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseOptimizedEventHandler.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventHandleHistory.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventOutbox.java rename src/main/java/com/example/mohago_nocar/course/domain/model/course/{CourseStatus.java => TravelCourseStatus.java} (56%) create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseEventOutboxRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseOptimizedEventHandleHistoryRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseEventOutboxRepositoryImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryJpaRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryRepositoryImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventOutboxJpaRepository.java create mode 100644 src/main/java/com/example/mohago_nocar/global/common/domain/OutboxStatus.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxHandler.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxService.java delete mode 100644 src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationService.java delete mode 100644 src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationServiceImpl.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/domain/UserNotificationMessageOutbox.java create mode 100644 src/main/java/com/example/mohago_nocar/global/notification/infrastructure/UserNotificationMessageOutboxJpaRepository.java delete mode 100644 src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessage.java delete mode 100644 src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessageSender.java delete mode 100644 src/test/java/com/example/mohago_nocar/test/ForCustomLoggerTest.java diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseCompletionNotifier.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseCompletionNotifier.java deleted file mode 100644 index c686801..0000000 --- a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseCompletionNotifier.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.example.mohago_nocar.course.application.course; - -import com.example.mohago_nocar.course.domain.model.course.TravelCourse; -import com.example.mohago_nocar.course.domain.model.course.TravelCourseCompletionMessage; -import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; -import com.example.mohago_nocar.global.notification.application.user.UserNotificationDto; -import com.example.mohago_nocar.global.notification.application.user.UserNotificationService; -import com.example.mohago_nocar.global.util.Result; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.Map; - -@Component -@Slf4j -@RequiredArgsConstructor -public class TravelCourseCompletionNotifier { - - private final UserNotificationService userNotificationService; - private final TravelCourseUseCase travelCourseUseCase; - - /** - * 알림을 전송합니다. 만일 알림 전송 이력이 있다면 알림을 전송하지 않습니다. - * @param travelCourseId 알림을 전송할 코스 아이디 - * @param result 코스 처리 결과 - * @param exceptionHandler 알림 전송 중 예외 발생 시 사용되는 핸들러 - */ - public void sendNotificationOnce( - Long travelCourseId, - Result result, - TravelCourseNotifyExceptionHandler exceptionHandler - ) { - try { - TravelCourse course = travelCourseUseCase.findById(travelCourseId).orElseThrow(); - if (course.getNotificationSent()) { - return; - } - - TravelCourseCompletionMessage message = result.isSuccess() ? - TravelCourseCompletionMessage.SUCCESS : TravelCourseCompletionMessage.FAILURE; - - userNotificationService.send(new UserNotificationDto( - message.getTitle(), - message.getBody(), - course.getAnonymousUserId(), - Map.of("travelCourseId", String.valueOf(course.getId())) - )); - - travelCourseUseCase.markNotificationSent(travelCourseId); - } catch (Exception e) { - log.info("알림 전송에 실패했습니다. ", e); - exceptionHandler.handle(e); - } - } - - @FunctionalInterface - public interface TravelCourseNotifyExceptionHandler { - - void handle(Exception e); - - } - -} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventHandler.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventHandler.java new file mode 100644 index 0000000..5e01562 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventHandler.java @@ -0,0 +1,71 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseCompletionMessage; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventHandleHistory; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; +import com.example.mohago_nocar.course.domain.repository.TravelCourseOptimizedEventHandleHistoryRepository; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationDto; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationOutboxService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Component +@Slf4j +@RequiredArgsConstructor +public class TravelCourseEventHandler { + + private final TravelCourseUseCase travelCourseUseCase; + private final TravelCourseOptimizedEventHandleHistoryRepository handleHistoryRepository; + private final UserNotificationOutboxService notificationOutboxService; + + @Transactional + public void handleEvent(TravelCourseOptimizedEvent event) { + travelCourseUseCase.generateTransitRoute(event.getTravelCourseId()); + processSuccessEvent(event); + } + + private void processSuccessEvent(TravelCourseOptimizedEvent event) { + travelCourseUseCase.updateUncompletedCourseStatus(event.getTravelCourseId(), TravelCourseStatus.SUCCEEDED); + UserNotificationDto notificationDto = createNotificationMessage(event, true); + notificationOutboxService.save(notificationDto); + } + + @Transactional + public void processFailEvent(TravelCourseOptimizedEvent event) { + travelCourseUseCase.updateUncompletedCourseStatus(event.getTravelCourseId(), TravelCourseStatus.FAILED); + UserNotificationDto notificationDto = createNotificationMessage(event, false); + notificationOutboxService.save(notificationDto); + } + + private UserNotificationDto createNotificationMessage(TravelCourseOptimizedEvent event, boolean isSuccess) { + TravelCourseCompletionMessage message = isSuccess ? + TravelCourseCompletionMessage.SUCCESS : TravelCourseCompletionMessage.FAILURE; + + return new UserNotificationDto( + message.getTitle(), + message.getBody(), + event.getAnonymousUserId(), + Map.of("travelCourseId", String.valueOf(event.getTravelCourseId())) + ); + } + + public boolean hasHandleHistory(Long travelCourseId) { + TravelCourseOptimizedEventHandleHistory history = TravelCourseOptimizedEventHandleHistory.of(travelCourseId); + + try { + handleHistoryRepository.save(history); + } catch (DataIntegrityViolationException e) { + return true; + } + + return false; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxHandler.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxHandler.java new file mode 100644 index 0000000..7cdc745 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxHandler.java @@ -0,0 +1,38 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Service +@Slf4j +@RequiredArgsConstructor +public class TravelCourseEventOutboxHandler { + + private final TravelCourseEventOutboxService outboxService; + + @Scheduled(fixedDelay = 1000) + public void handle() { + long handleStartTime = System.nanoTime(); + + List unpublishedList = outboxService.findUnpublished(10); + for (TravelCourseOptimizedEventOutbox unpublished : unpublishedList) { + try { + outboxService.publish(unpublished); + outboxService.markAsPublished(unpublished); + } catch (Exception e) { + outboxService.processFailure(unpublished, e); + } + } + + long handleEndTime = System.nanoTime(); + log.info("Handle completed in {} ms", TimeUnit.NANOSECONDS.toMillis(handleEndTime - handleStartTime)); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxService.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxService.java new file mode 100644 index 0000000..3760e43 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxService.java @@ -0,0 +1,61 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; +import com.example.mohago_nocar.course.domain.repository.TravelCourseEventOutboxRepository; +import com.example.mohago_nocar.course.infrastructure.course.messaging.TravelCourseOptimizedEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class TravelCourseEventOutboxService { + + private final TravelCourseEventOutboxRepository travelCourseEventOutboxRepository; + private final TravelCourseOptimizedEventPublisher eventPublisher; + + @Transactional + public TravelCourseOptimizedEventOutbox generate(TravelCourse course) { + TravelCourseOptimizedEventOutbox courseOutbox = createCourseOutbox(course); + return travelCourseEventOutboxRepository.save(courseOutbox); + } + + private TravelCourseOptimizedEventOutbox createCourseOutbox(TravelCourse course) { + TravelCourseOptimizedEvent event = TravelCourseOptimizedEvent.of(course.getId(), course.getAnonymousUserId()); + return TravelCourseOptimizedEventOutbox.create(event); + } + + public List findUnpublished(int size) { + return travelCourseEventOutboxRepository.findByStatusInOrderByCreatedDateAsc( + List.of(OutboxStatus.PENDING), size); + } + + public void publish(TravelCourseOptimizedEventOutbox travelCourseOptimizedEventOutbox) { + eventPublisher.publish(travelCourseOptimizedEventOutbox.getPayload()); + } + + @Transactional + public void markAsPublished(TravelCourseOptimizedEventOutbox outbox) { + outbox.markAsPublished(); + travelCourseEventOutboxRepository.save(outbox); + } + + @Transactional + public void processFailure(TravelCourseOptimizedEventOutbox outbox, Throwable throwable) { + if (outbox.isFinalRetry()) { + outbox.markFailWithReason(OutboxStatus.FAIL, throwable); + log.error("최대 재시도 횟수에 도달했습니다. outbox id: {}", outbox.getId()); + } else { + outbox.incrementRetryCount(); + } + travelCourseEventOutboxRepository.save(outbox); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseOptimizedEventHandler.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseOptimizedEventHandler.java deleted file mode 100644 index f5caf80..0000000 --- a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseOptimizedEventHandler.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.mohago_nocar.course.application.course; - -import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; -import com.example.mohago_nocar.course.domain.model.course.CourseStatus; -import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; -import com.example.mohago_nocar.course.infrastructure.course.messaging.TravelCourseOptimizedEventPublisher; -import com.example.mohago_nocar.global.common.exception.CustomException; -import com.example.mohago_nocar.global.common.exception.GlobalStatus; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Component -@Slf4j -@RequiredArgsConstructor -public class TravelCourseOptimizedEventHandler { - - private final TravelCourseOptimizedEventPublisher eventPublisher; - private final TravelCourseUseCase travelCourseUseCase; - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void publishEvent(TravelCourseOptimizedEvent event) { - try { - eventPublisher.publish(event); - } catch (Exception e) { - log.error("이벤트 발송에 실패했습니다. 이벤트 = {}", event); - throw new CustomException(GlobalStatus.INTERNAL_SERVER_ERROR); - } - } - - public void handleEvent(TravelCourseOptimizedEvent event) { - travelCourseUseCase.generateTransitRoute(event.getTravelCourseId()); - markAsSucceeded(event.getTravelCourseId()); - } - - public void markAsSucceeded(Long travelCourseId) { - travelCourseUseCase.updateCourseStatus(travelCourseId, CourseStatus.SUCCEEDED); - } - - public void markAsWaitingReprocessing(Long travelCourseId) { - travelCourseUseCase.updateCourseStatus(travelCourseId, CourseStatus.WAITING_REPROCESSING); - } - - public void markAsFailed(Long travelCourseId) { - travelCourseUseCase.updateCourseStatus(travelCourseId, CourseStatus.FAILED); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java index a1dc732..9366492 100644 --- a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java @@ -6,21 +6,16 @@ import com.example.mohago_nocar.course.application.route.RouteStepService; import com.example.mohago_nocar.course.application.spot.TravelSpotService; import com.example.mohago_nocar.course.domain.event.ThrottlingCompletedEvent; -import com.example.mohago_nocar.course.domain.model.course.CourseStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; import com.example.mohago_nocar.course.domain.model.course.TravelCourse; import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; import com.example.mohago_nocar.course.domain.repository.TravelCourseRepository; import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; -import com.example.mohago_nocar.course.domain.model.course.TravelCourseCompletionMessage; -import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; -import com.example.mohago_nocar.course.infrastructure.course.messaging.TravelCourseOptimizedMessageConsumer; import com.example.mohago_nocar.course.presentation.dto.CreateTravelCourseRequestDto; import com.example.mohago_nocar.course.presentation.dto.CreateOptimizedTravelCourseAcceptedResponseDto; import com.example.mohago_nocar.global.common.exception.CustomException; import com.example.mohago_nocar.global.common.exception.GlobalStatus; -import com.example.mohago_nocar.global.notification.application.user.UserNotificationDto; -import com.example.mohago_nocar.global.notification.application.user.UserNotificationService; import com.example.mohago_nocar.user.domain.AnonymousUser; import com.example.mohago_nocar.user.domain.UserUseCase; import lombok.RequiredArgsConstructor; @@ -28,13 +23,10 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.Assert; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @Service @@ -45,6 +37,7 @@ public class TravelCourseService implements TravelCourseUseCase { private final UserUseCase userUseCase; private final TravelCourseRepository travelCourseRepository; + private final TravelCourseEventOutboxService travelCourseEventOutboxService; private final TravelSpotService travelSpotService; private final RouteStepService routeStepService; private final ApplicationEventPublisher eventPublisher; @@ -54,15 +47,12 @@ public class TravelCourseService implements TravelCourseUseCase { @Transactional public CreateOptimizedTravelCourseAcceptedResponseDto createOptimizedTravelCourse(CreateTravelCourseRequestDto request) { AnonymousUser user = userUseCase.getOrCreate(request.fcmToken()); - TravelCourse course = TravelCourse.create(user, CourseStatus.ENQUEUED); + TravelCourse course = TravelCourse.create(user, TravelCourseStatus.PENDING); travelCourseRepository.save(course); generateSpotsWithOptimizedOrder(request, course); - eventPublisher.publishEvent( - TravelCourseOptimizedEvent.of(course.getId(), course.getAnonymousUserId()) - ); - + travelCourseEventOutboxService.generate(course); return CreateOptimizedTravelCourseAcceptedResponseDto.of(course.getId(), user.getId()); } @@ -121,7 +111,7 @@ public List getOptimizedTravelCourseRoutes(Long courseId throw new CustomException(GlobalStatus.FORBIDDEN); } - if (course.getCourseStatus() != CourseStatus.SUCCEEDED) { + if (course.getCourseStatus() != TravelCourseStatus.SUCCEEDED) { throw new CustomException(CourseErrorCode.TRAVEL_COURSE_OPTIMIZATION_INCOMPLETE); } @@ -140,12 +130,6 @@ public List getOptimizedTravelCourseRoutes(Long courseId return routesBetweenSpots; } - @Override - public List getOutdatedCourseNeedingNotification(int cutOffTimeInMin, Boolean notificationSent) { - LocalDateTime thresholdTime = LocalDateTime.now().minusMinutes(cutOffTimeInMin); - return travelCourseRepository.findOutdatedCoursesNeedingNotification(thresholdTime, notificationSent); - } - @Override public Optional findById(Long travelCourseId) { return travelCourseRepository.findById(travelCourseId); @@ -153,15 +137,12 @@ public Optional findById(Long travelCourseId) { @Override @Transactional - public void markNotificationSent(Long travelCourseId) { + public void updateUncompletedCourseStatus(Long travelCourseId, TravelCourseStatus courseStatus) { TravelCourse course = findById(travelCourseId).orElseThrow(); - course.markNotificationSent(); - } + if (course.getCourseStatus().isComplete()) { + throw new RuntimeException("이미 처리 완료된 여행 코스입니다. " + course.toString()); + } - @Override - @Transactional - public void updateCourseStatus(Long travelCourseId, CourseStatus courseStatus) { - TravelCourse course = findById(travelCourseId).orElseThrow(); course.updateStatus(courseStatus); } diff --git a/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java b/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java index 73d95a8..80e7e49 100644 --- a/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java +++ b/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java @@ -20,7 +20,6 @@ public class RouteStepService { private final RouteStepRepository routeStepRepository; - private final RouteFinder routeStepFinder; @Transactional public List saveAll(List routeSteps) { diff --git a/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java b/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java index e88dee0..8045a1b 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java @@ -1,6 +1,9 @@ package com.example.mohago_nocar.course.domain.event; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.ToString; import java.util.UUID; @@ -10,6 +13,8 @@ */ @Getter @ToString +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class TravelCourseOptimizedEvent { private Long travelCourseId; diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java index d3dc2b0..6a9c600 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java @@ -26,34 +26,24 @@ public class TravelCourse extends BaseEntity { @Column(nullable = false) private UUID anonymousUserId; - @NotNull - @Column(nullable = false) - private Boolean notificationSent; - @Enumerated(EnumType.STRING) @Column(nullable = false) - private CourseStatus courseStatus; + private TravelCourseStatus courseStatus; - public static TravelCourse create(AnonymousUser user, CourseStatus courseStatus) { + public static TravelCourse create(AnonymousUser user, TravelCourseStatus courseStatus) { return TravelCourse.builder() .anonymousUserId(user.getId()) .courseStatus(courseStatus) - .notificationSent(false) .build(); } @Builder - private TravelCourse(UUID anonymousUserId, CourseStatus courseStatus, Boolean notificationSent) { + private TravelCourse(UUID anonymousUserId, TravelCourseStatus courseStatus) { this.anonymousUserId = anonymousUserId; this.courseStatus = courseStatus; - this.notificationSent = notificationSent; - } - - public void markNotificationSent() { - this.notificationSent = true; } - public void updateStatus(CourseStatus courseStatus) { + public void updateStatus(TravelCourseStatus courseStatus) { this.courseStatus = courseStatus; } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventHandleHistory.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventHandleHistory.java new file mode 100644 index 0000000..b3b07b9 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventHandleHistory.java @@ -0,0 +1,38 @@ +package com.example.mohago_nocar.course.domain.model.course; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Persistable; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelCourseOptimizedEventHandleHistory implements Persistable { + + @Id + @Column(unique = true) + private Long travelCourseId; + + public static TravelCourseOptimizedEventHandleHistory of(Long travelCourseId) { + return new TravelCourseOptimizedEventHandleHistory(travelCourseId); + } + + @Builder + private TravelCourseOptimizedEventHandleHistory(Long travelCourseId) { + this.travelCourseId = travelCourseId; + } + + @Override + public Long getId() { + return travelCourseId; + } + + @Override + public boolean isNew() { + return true; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventOutbox.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventOutbox.java new file mode 100644 index 0000000..2f5f33b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventOutbox.java @@ -0,0 +1,65 @@ +package com.example.mohago_nocar.course.domain.model.course; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.common.domain.BaseEntity; +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelCourseOptimizedEventOutbox extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private TravelCourseOptimizedEvent payload; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxStatus status; + + @Column(nullable = false) + private Integer retryCount; + + @Column(nullable = true) + private String failReason; + + public static TravelCourseOptimizedEventOutbox create(TravelCourseOptimizedEvent event) { + return TravelCourseOptimizedEventOutbox.builder() + .payload(event) + .retryCount(0) + .status(OutboxStatus.PENDING) + .build(); + } + + @Builder + private TravelCourseOptimizedEventOutbox(TravelCourseOptimizedEvent payload, OutboxStatus status, Integer retryCount) { + this.payload = payload; + this.status = status; + this.retryCount = retryCount; + } + + public boolean isFinalRetry() { + return 3 <= retryCount; + } + + public int incrementRetryCount() { + return ++retryCount; + } + + public void markAsPublished() { + this.status = OutboxStatus.SENT; + } + + public void markFailWithReason(OutboxStatus outboxStatus, Throwable throwable) { + this.status = outboxStatus; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/CourseStatus.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseStatus.java similarity index 56% rename from src/main/java/com/example/mohago_nocar/course/domain/model/course/CourseStatus.java rename to src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseStatus.java index 0410d59..a554246 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/course/CourseStatus.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseStatus.java @@ -4,13 +4,15 @@ import lombok.AllArgsConstructor; @AllArgsConstructor(access = AccessLevel.PRIVATE) -public enum CourseStatus { - - ENQUEUED("처리 대기 중"), +public enum TravelCourseStatus { + PENDING("처리 대기 중"), SUCCEEDED("처리 성공"), - FAILED("처리 실패"), - WAITING_REPROCESSING("재처리 대기 중"); + FAILED("처리 실패"); private final String description; -} + public boolean isComplete() { + return this == SUCCEEDED || this == FAILED; + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseEventOutboxRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseEventOutboxRepository.java new file mode 100644 index 0000000..78409be --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseEventOutboxRepository.java @@ -0,0 +1,15 @@ +package com.example.mohago_nocar.course.domain.repository; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; + +import java.util.List; + +public interface TravelCourseEventOutboxRepository { + + TravelCourseOptimizedEventOutbox save(TravelCourseOptimizedEventOutbox travelCourseOptimizedEventOutbox); + + List findByStatusInOrderByCreatedDateAsc( + List statuses, int size); + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseOptimizedEventHandleHistoryRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseOptimizedEventHandleHistoryRepository.java new file mode 100644 index 0000000..f076940 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseOptimizedEventHandleHistoryRepository.java @@ -0,0 +1,8 @@ +package com.example.mohago_nocar.course.domain.repository; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventHandleHistory; +import org.springframework.stereotype.Repository; + +public interface TravelCourseOptimizedEventHandleHistoryRepository { + TravelCourseOptimizedEventHandleHistory save(TravelCourseOptimizedEventHandleHistory handleHistory); +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java index 74c3325..480626a 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java @@ -1,6 +1,7 @@ package com.example.mohago_nocar.course.domain.repository; import com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; import com.example.mohago_nocar.course.domain.model.course.TravelCourse; import java.time.LocalDateTime; @@ -13,9 +14,5 @@ public interface TravelCourseRepository { Optional findById(Long travelCourseId); - Optional getRequestrInfo(Long travelCourseId); - - List findOutdatedCoursesNeedingNotification(LocalDateTime thresholdTime, Boolean notificationSent); - } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java index f96acee..9402dc4 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java @@ -1,17 +1,14 @@ package com.example.mohago_nocar.course.domain.service; import com.example.mohago_nocar.course.application.dto.RouteStepDto; -import com.example.mohago_nocar.course.domain.model.course.CourseStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; import com.example.mohago_nocar.course.domain.model.course.TravelCourse; -import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; -import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; import com.example.mohago_nocar.course.presentation.dto.CreateTravelCourseRequestDto; import com.example.mohago_nocar.course.presentation.dto.CreateOptimizedTravelCourseAcceptedResponseDto; import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.concurrent.CompletableFuture; public interface TravelCourseUseCase { @@ -33,8 +30,6 @@ public interface TravelCourseUseCase { Optional findById(Long travelCourseId); - void markNotificationSent(Long travelCourseId); - - void updateCourseStatus(Long travelCourseId, CourseStatus courseStatus); + void updateUncompletedCourseStatus(Long travelCourseId, TravelCourseStatus courseStatus); } diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java index fec7c0a..a9f0b13 100644 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java @@ -1,14 +1,13 @@ package com.example.mohago_nocar.course.infrastructure.course.messaging; -import com.example.mohago_nocar.course.application.course.TravelCourseCompletionNotifier; -import com.example.mohago_nocar.course.application.course.TravelCourseOptimizedEventHandler; +import com.example.mohago_nocar.course.application.course.TravelCourseEventHandler; import com.example.mohago_nocar.course.domain.event.ThrottlingCompletedEvent; import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; import com.example.mohago_nocar.global.messaging.DeadLetterQueueEntryDto; import com.example.mohago_nocar.global.messaging.DeadLetterQueueService; import com.example.mohago_nocar.global.util.RedisStreamHelper; import com.example.mohago_nocar.global.common.RetryPolicy; -import com.example.mohago_nocar.global.notification.application.user.UserNotificationService; import com.example.mohago_nocar.global.util.Result; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -21,10 +20,7 @@ import org.springframework.data.redis.connection.stream.ObjectRecord; import org.springframework.data.redis.stream.StreamListener; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; @Slf4j @Getter @@ -39,10 +35,9 @@ public class TravelCourseOptimizedMessageConsumer private final RedisStreamHelper redisStreamHelper; private final DeadLetterQueueService dlqService; private final ObjectMapper objectMapper; - private final UserNotificationService userNotificationService; private final RetryPolicy retryPolicy; - private final TravelCourseOptimizedEventHandler eventHandler; - private final TravelCourseCompletionNotifier travelCourseNotifier; + private final TravelCourseEventHandler eventHandler; + private final TravelCourseUseCase travelCourseUseCase; private ExecutorService executorService; private Semaphore semaphore; @@ -81,10 +76,14 @@ private void processEvent(ObjectRecord message) { return; } + if (eventHandler.hasHandleHistory(event.getTravelCourseId())) { + log.info("메시지 중복 처리 시도가 발생했습니다. 메시지 처리를 중단합니다."); + ackAndDel(message); + return; + } + try { eventHandler.handleEvent(event); - travelCourseNotifier.sendNotificationOnce( - event.getTravelCourseId(), Result.SUCCESS, exception -> saveToDeadLetterQueue(message, exception)); } catch (Exception ex) { handleException(event, message, ex); } finally { @@ -93,15 +92,13 @@ private void processEvent(ObjectRecord message) { } private void handleException(TravelCourseOptimizedEvent event, ObjectRecord message, Exception e) { + log.error(e.getMessage(), e); if (retryPolicy.isRetryable(e)) { saveToDeadLetterQueue(message, e); - eventHandler.markAsWaitingReprocessing(event.getTravelCourseId()); return; } - eventHandler.markAsFailed(event.getTravelCourseId()); - travelCourseNotifier.sendNotificationOnce( - event.getTravelCourseId(), Result.FAILURE, exception -> saveToDeadLetterQueue(message, exception)); + eventHandler.processFailEvent(event); } private void saveToDeadLetterQueue(ObjectRecord message, Exception ex) { diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java index 1d17bce..49ea91f 100644 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java @@ -1,12 +1,11 @@ package com.example.mohago_nocar.course.infrastructure.course.messaging; -import com.example.mohago_nocar.course.application.course.TravelCourseCompletionNotifier; -import com.example.mohago_nocar.course.application.course.TravelCourseOptimizedEventHandler; +import com.example.mohago_nocar.course.application.course.TravelCourseEventHandler; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; import com.example.mohago_nocar.global.messaging.DeadLetterQueueService; import com.example.mohago_nocar.global.util.RedisStreamHelper; import com.example.mohago_nocar.global.common.RetryPolicy; import com.example.mohago_nocar.global.notification.application.developer.DeveloperNotificationUseCase; -import com.example.mohago_nocar.global.notification.application.user.UserNotificationService; import com.example.mohago_nocar.global.util.ObjectMapperUtil; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -55,7 +54,7 @@ public TravelCourseOptimizedStreamRecoveryManager travelCourseOptimizedStreamRec } @Bean - public TravelCourseOptimizedMessageProducer travelSpotOrderEventProducer( + public TravelCourseOptimizedMessageProducer travelCourseOptimizedEventPublisher( StringRedisTemplate stringRedisTemplate, ObjectMapperUtil objectMapperUtil ) { @@ -68,10 +67,10 @@ public TravelCourseOptimizedMessageConsumer travelCourseOptimizedMessageConsumer RedisStreamHelper redisStreamHelper, ObjectMapper objectMapper, DeadLetterQueueService deadLetterQueueService, - UserNotificationService userNotificationService, RetryPolicy travelSpotOptimizedStreamMsgRetryPolicy, - TravelCourseOptimizedEventHandler travelCourseOptimizedEventHandler, - TravelCourseCompletionNotifier travelCourseNotifier) { + TravelCourseEventHandler travelCourseEventHandler, + TravelCourseUseCase travelCourseUseCase + ) { return new TravelCourseOptimizedMessageConsumer( streamKey, CONSUMER_GROUP, @@ -79,10 +78,9 @@ public TravelCourseOptimizedMessageConsumer travelCourseOptimizedMessageConsumer redisStreamHelper, deadLetterQueueService, objectMapper, - userNotificationService, travelSpotOptimizedStreamMsgRetryPolicy, - travelCourseOptimizedEventHandler, - travelCourseNotifier + travelCourseEventHandler, + travelCourseUseCase ); } diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseEventOutboxRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseEventOutboxRepositoryImpl.java new file mode 100644 index 0000000..3de0a85 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseEventOutboxRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; +import com.example.mohago_nocar.course.domain.repository.TravelCourseEventOutboxRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class TravelCourseEventOutboxRepositoryImpl implements TravelCourseEventOutboxRepository { + + private final TravelCourseOptimizedEventOutboxJpaRepository jpaRepository; + + @Override + @Transactional + public TravelCourseOptimizedEventOutbox save(TravelCourseOptimizedEventOutbox travelCourseOptimizedEventOutbox) { + return jpaRepository.save(travelCourseOptimizedEventOutbox); + } + + @Override + public List findByStatusInOrderByCreatedDateAsc( + List statuses, int size) { + return jpaRepository.findTop10ByStatusInOrderByCreatedDateAsc(statuses, size); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java index 261dc7e..044e121 100644 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java @@ -1,33 +1,11 @@ package com.example.mohago_nocar.course.infrastructure.course.repository; import com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; import com.example.mohago_nocar.course.domain.model.course.TravelCourse; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; public interface TravelCourseJpaRepository extends JpaRepository { - @Query(""" - SELECT new com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto( - au.id, - au.fcmToken - ) - FROM TravelCourse tc - JOIN AnonymousUser au ON tc.anonymousUserId = au.id - WHERE tc.id = :travelCourseId - """) - Optional findRequesterInfoByTravelCourseId(@Param("travelCourseId") Long travelCourseId); - - @Query("SELECT tc FROM TravelCourse tc " + - "WHERE tc.createdAt <= :thresholdTime " + - "AND tc.notificationSent = :notificationSent") - List findOutdatedCoursesNeedingNotification( - @Param("thresholdTime") LocalDateTime thresholdTime, - @Param("notificationSent") Boolean notificationSent); - } diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryJpaRepository.java new file mode 100644 index 0000000..09c66e6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryJpaRepository.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventHandleHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TravelCourseOptimizedEventHandleHistoryJpaRepository extends + JpaRepository { + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryRepositoryImpl.java new file mode 100644 index 0000000..3209030 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventHandleHistory; +import com.example.mohago_nocar.course.domain.repository.TravelCourseOptimizedEventHandleHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class TravelCourseOptimizedEventHandleHistoryRepositoryImpl + implements TravelCourseOptimizedEventHandleHistoryRepository { + + private final TravelCourseOptimizedEventHandleHistoryJpaRepository jpaRepository; + + @Override + @Transactional + public TravelCourseOptimizedEventHandleHistory save(TravelCourseOptimizedEventHandleHistory handleHistory) { + return jpaRepository.save(handleHistory); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventOutboxJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventOutboxJpaRepository.java new file mode 100644 index 0000000..0b681d9 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventOutboxJpaRepository.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface TravelCourseOptimizedEventOutboxJpaRepository extends JpaRepository { + + @Query("SELECT o FROM TravelCourseOptimizedEventOutbox o " + + "WHERE o.status IN :statuses " + + "ORDER BY o.createdAt ASC " + + "LIMIT :size") + List findTop10ByStatusInOrderByCreatedDateAsc( + List statuses, int size); + + @Modifying(clearAutomatically = true) + @Query("update TravelCourseOptimizedEventOutbox o " + + "set o.status = :targetStatus " + + "where o.id in :ids") + int updateStatuses(List ids, OutboxStatus targetStatus); + + @Modifying(clearAutomatically = true) + @Query("update TravelCourseOptimizedEventOutbox o " + + "set o.status = :targetStatus " + + "where o.id = :id") + int updateStatus(Long id, OutboxStatus targetStatus); + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java index ad492d5..0b461fe 100644 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java @@ -1,6 +1,7 @@ package com.example.mohago_nocar.course.infrastructure.course.repository; import com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; import com.example.mohago_nocar.course.domain.model.course.TravelCourse; import com.example.mohago_nocar.course.domain.repository.TravelCourseRepository; import lombok.RequiredArgsConstructor; @@ -26,14 +27,4 @@ public Optional findById(Long travelCourseId) { return travelCourseJpaRepository.findById(travelCourseId); } - @Override - public Optional getRequestrInfo(Long travelCourseId) { - return travelCourseJpaRepository.findRequesterInfoByTravelCourseId(travelCourseId); - } - - @Override - public List findOutdatedCoursesNeedingNotification(LocalDateTime thresholdTime, Boolean notificationSent) { - return travelCourseJpaRepository.findOutdatedCoursesNeedingNotification(thresholdTime, Boolean.FALSE); - } - } diff --git a/src/main/java/com/example/mohago_nocar/global/common/domain/OutboxStatus.java b/src/main/java/com/example/mohago_nocar/global/common/domain/OutboxStatus.java new file mode 100644 index 0000000..76ce404 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/common/domain/OutboxStatus.java @@ -0,0 +1,7 @@ +package com.example.mohago_nocar.global.common.domain; + +public enum OutboxStatus { + PENDING, // 아직 처리 안됨 + FAIL, // 처리 실패 + SENT // 처리 완료 +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxHandler.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxHandler.java new file mode 100644 index 0000000..08d1877 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxHandler.java @@ -0,0 +1,49 @@ +package com.example.mohago_nocar.global.notification.application.user; + +import com.example.mohago_nocar.global.notification.domain.UserNotificationMessageOutbox; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@Component +@Slf4j +@RequiredArgsConstructor +public class UserNotificationOutboxHandler { + + private final UserNotificationOutboxService outboxService; + private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); + + @Scheduled(fixedDelay = 1000) + public void handle() { + long handleStartTime = System.nanoTime(); + + List unpublishedList = outboxService.findUnpublished(10); + + List> futures = new ArrayList<>(); + for (UserNotificationMessageOutbox unpublished : unpublishedList) { + CompletableFuture future = + CompletableFuture.runAsync(() -> { + outboxService.publish(unpublished); + outboxService.markAsPublished(unpublished); + }, executorService) + .exceptionally(throwable -> { + outboxService.processFailure(unpublished, throwable); + return null; + }); + futures.add(future); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + long handleEndTime = System.nanoTime(); + log.info("Handle completed in {} ms", TimeUnit.NANOSECONDS.toMillis(handleEndTime - handleStartTime)); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxService.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxService.java new file mode 100644 index 0000000..4f3816f --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxService.java @@ -0,0 +1,93 @@ +package com.example.mohago_nocar.global.notification.application.user; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.global.notification.NotificationMessagingException; +import com.example.mohago_nocar.global.notification.domain.UserNotificationMessageOutbox; +import com.example.mohago_nocar.global.notification.infrastructure.UserNotificationMessageOutboxJpaRepository; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserUseCase; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@Slf4j +@RequiredArgsConstructor +public class UserNotificationOutboxService { + + private final UserNotificationMessageOutboxJpaRepository outboxRepository; + private final UserUseCase userUseCase; + private final FirebaseMessaging firebaseMessaging; + + @Transactional + public void save(UserNotificationDto dto) { + UserNotificationMessageOutbox msg = UserNotificationMessageOutbox.from(dto); + outboxRepository.save(msg); + } + + @Transactional + public List findUnpublished(int size) { + return outboxRepository.findByStatusInOrderByCreatedDateAsc( + List.of(OutboxStatus.PENDING), size); + } + + public void publish(UserNotificationMessageOutbox messageOutbox) { + UUID userId = messageOutbox.getUserId(); + AnonymousUser user = userUseCase.findByIdOrThrow(userId); + Message message = convertToFcmMessage(messageOutbox, user); + try { + firebaseMessaging.send(message); + } catch (FirebaseMessagingException e) { + throw new NotificationMessagingException("FCM 메시지 전송 중 오류가 발생했습니다", e); + } + } + + private Message convertToFcmMessage(UserNotificationMessageOutbox target, AnonymousUser user) { + Notification notification = Notification.builder() + .setTitle(target.getTitle()) + .setBody(target.getBody()) + .build(); + + Message.Builder builder = Message.builder() + .setToken(user.getFcmToken()) + .setNotification(notification); + + for (Map.Entry entry : target.getCustomData().entrySet()) { + builder.putData(entry.getKey(), entry.getValue()); + } + + return builder.build(); + } + + @Transactional + public void processFailure(UserNotificationMessageOutbox messageOutbox, Throwable throwable) { + log.error("메시지 전송 시도 중 오류가 발생했습니다. ", throwable); + if (throwable instanceof NotificationMessagingException) { + if (messageOutbox.isFinalRetry()) { + messageOutbox.markFailWithReason(throwable); + log.warn("최대 재시도 횟수에 도달했습니다. outbox id: {}", messageOutbox.getId()); + } else { + messageOutbox.incrementRetryCount(); + } + } else { + messageOutbox.markFailWithReason(throwable); + } + outboxRepository.save(messageOutbox); + } + + @Transactional + public void markAsPublished(UserNotificationMessageOutbox messageOutbox) { + messageOutbox.markAsPublished(); + outboxRepository.save(messageOutbox); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationService.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationService.java deleted file mode 100644 index 402e65d..0000000 --- a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationService.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.example.mohago_nocar.global.notification.application.user; - -public interface UserNotificationService { - - String send(UserNotificationDto userNotificationDto); - -} - -/** - * - * <타 도메인에서 사용 시> - * - NotifyAdapter.sendToFcm, sendToDiscord = NotifyService.send - * - 어댑터에서 Fcm, discord 메시지 빌드 - * - 타 도메인은 '안내 메시지'를 정의하는 책임 - * - * NotifyService [interface] --> make easy testing - * NotifyServiceImpl - * send(fcmMsgDto) - * send(discordMsgDto) - * - 알림 도메인은 '전송하는 방법' 책임 - * - * [단점] - * - fcm, discord 등 구현체를 알아야함 - * - 근데 구현체를 알아야하지 않나? 이게 단점인가? - */ - -/** - * [타 도메인] - * - Adapter interface - * send - * - TravelCourseAdapter - * send: - * 1. converter.convertToFcm - * 2. service.send - * - RetryableTravelCourseAdapter - * Retry retry; - * send: - * retryDecorate(() -> TravelCourseAdapter.send()) - * - * - * [알람 도메인] - * - service.sendFcm(FcmMsg) - * - service.sendFcm(fcmMsg, retry) - * - service.sendDiscord(DiscorMsg) - * - */ \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationServiceImpl.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationServiceImpl.java deleted file mode 100644 index 3221a5e..0000000 --- a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationServiceImpl.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.example.mohago_nocar.global.notification.application.user; - -import com.example.mohago_nocar.global.notification.infrastructure.fcm.FcmMessage; -import com.example.mohago_nocar.global.notification.infrastructure.fcm.FcmMessageSender; -import com.example.mohago_nocar.user.domain.UserUseCase; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.Objects; - -@Component -@Slf4j -@RequiredArgsConstructor -public class UserNotificationServiceImpl implements UserNotificationService { - - private final FcmMessageSender fcmMessageSender; - private final UserUseCase userUseCase; - - @Override - public String send(UserNotificationDto dto) { // todo notnull validation - Objects.requireNonNull(dto); - - String fcmToken = userUseCase.getFcmToken(dto.getUserId()); - FcmMessage fcmMessage = FcmMessage.create(dto.getTitle(), dto.getBody(), fcmToken, dto.getCustomData()); - - return fcmMessageSender.send(fcmMessage); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/domain/UserNotificationMessageOutbox.java b/src/main/java/com/example/mohago_nocar/global/notification/domain/UserNotificationMessageOutbox.java new file mode 100644 index 0000000..2584bf6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/domain/UserNotificationMessageOutbox.java @@ -0,0 +1,114 @@ +package com.example.mohago_nocar.global.notification.domain; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.global.common.domain.BaseEntity; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationDto; +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; + +import java.util.Map; +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserNotificationMessageOutbox extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String body; + + @Column(nullable = false) + private UUID userId; + + @Type(JsonBinaryType.class) + @Column(columnDefinition = "jsonb", nullable = true) + private Map customData; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxStatus status; + + @Column(nullable = false) + private Integer retryCount; + + @Column(nullable = true) + private String failReason; + + public static UserNotificationMessageOutbox of( + OutboxStatus status, + Integer retryCount, + String title, + String body, + UUID userId, + Map customData + ) { + return UserNotificationMessageOutbox.builder() + .status(status) + .retryCount(retryCount) + .title(title) + .body(body) + .userId(userId) + .customData(customData) + .build(); + } + + public static UserNotificationMessageOutbox from(UserNotificationDto dto) { + return UserNotificationMessageOutbox.of( + OutboxStatus.PENDING, + 0, + dto.getTitle(), + dto.getBody(), + dto.getUserId(), + dto.getCustomData() + ); + } + + @Builder + private UserNotificationMessageOutbox( + OutboxStatus status, + Integer retryCount, + String title, + String body, + UUID userId, + Map customData, + String failReason + ) { + this.status = status; + this.retryCount = retryCount; + this.title = title; + this.body = body; + this.userId = userId; + this.customData = customData; + this.failReason = failReason; + } + + public boolean isFinalRetry() { + return 3 <= retryCount; + } + + public void markAsPublished() { + this.status = OutboxStatus.SENT; + } + + public void markFailWithReason(Throwable throwable) { + this.status = OutboxStatus.FAIL; + this.failReason = throwable.getMessage(); + } + + public void incrementRetryCount() { + retryCount++; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/UserNotificationMessageOutboxJpaRepository.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/UserNotificationMessageOutboxJpaRepository.java new file mode 100644 index 0000000..db17df4 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/UserNotificationMessageOutboxJpaRepository.java @@ -0,0 +1,20 @@ +package com.example.mohago_nocar.global.notification.infrastructure; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.global.notification.domain.UserNotificationMessageOutbox; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface UserNotificationMessageOutboxJpaRepository extends JpaRepository { + + @Query(value = "select o from UserNotificationMessageOutbox o " + + "WHERE o.status IN :statuses " + + "ORDER BY o.createdAt ASC " + + "LIMIT :size ") + List findByStatusInOrderByCreatedDateAsc( + List statuses, int size + ); + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessage.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessage.java deleted file mode 100644 index a165ed5..0000000 --- a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessage.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.example.mohago_nocar.global.notification.infrastructure.fcm; - -import com.google.firebase.messaging.Message; -import com.google.firebase.messaging.Notification; -import lombok.Builder; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -import java.util.Map; - -@Builder -@RequiredArgsConstructor -@Getter -public class FcmMessage { - - private final String fcmToken; - private final Map customData; - private final Notification notification; - - public static FcmMessage create(String title, String body, String fcmToken, Map customData) { - return FcmMessage.builder() - .fcmToken(fcmToken) - .customData(customData) - .notification(Notification.builder().setTitle(title).setBody(body).build()) - .build(); - } - - public Message toMessage(FcmMessage fcmMessage) { - Message.Builder builder = Message.builder() - .setToken(fcmMessage.getFcmToken()) - .setNotification(fcmMessage.getNotification()); - - for (Map.Entry entry : fcmMessage.getCustomData().entrySet()) { - builder.putData(entry.getKey(), entry.getValue()); - } - - return builder.build(); - } - -} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessageSender.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessageSender.java deleted file mode 100644 index 3824da5..0000000 --- a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmMessageSender.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.mohago_nocar.global.notification.infrastructure.fcm; - -import com.example.mohago_nocar.global.notification.NotificationMessagingException; -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.Message; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - - -@Slf4j -@Component -@RequiredArgsConstructor -public class FcmMessageSender { - - private final FirebaseMessaging firebaseMessaging; - - public String send(FcmMessage fcmMessage) { - Message message = fcmMessage.toMessage(fcmMessage); - return sendMessage(message); - } - - private String sendMessage(Message message) { - try { - return firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - log.error("FCM 알림 전송에 실패했습니다. 에러 메시지={}", e.getMessage()); - throw new NotificationMessagingException("알림 전송에 실패했습니다.", e); - } - } - -} diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java index 3b24481..c073883 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java @@ -1,7 +1,6 @@ package com.example.mohago_nocar.transit.domain.model; import com.example.mohago_nocar.plan.domain.model.Location; -import com.example.mohago_nocar.plan.domain.model.TravelCourseInPlan; import jakarta.persistence.*; import lombok.*; diff --git a/src/test/java/com/example/mohago_nocar/test/ForCustomLoggerTest.java b/src/test/java/com/example/mohago_nocar/test/ForCustomLoggerTest.java deleted file mode 100644 index 4445d4d..0000000 --- a/src/test/java/com/example/mohago_nocar/test/ForCustomLoggerTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.mohago_nocar.test; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class ForCustomLoggerTest { - - ForCustomLogger logger = new ForCustomLogger(); - - @Test - @DisplayName("로그를 찍어봐요") - void test(){ - //given - - //when - logger.log(); - - //then - - } - -} \ No newline at end of file From afd68b315765290f76ff276028a1ac4c6af841d2 Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 29 Dec 2025 16:02:28 +0900 Subject: [PATCH 83/84] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mohago_nocar/course/domain/service/TravelCourseUseCase.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java index 9402dc4..b09b98d 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java @@ -26,8 +26,6 @@ public interface TravelCourseUseCase { List getOptimizedTravelCourseRoutes(Long courseId, UUID ownerUserId); - List getOutdatedCourseNeedingNotification(int cutOffTimeInMin, Boolean notificationSent); - Optional findById(Long travelCourseId); void updateUncompletedCourseStatus(Long travelCourseId, TravelCourseStatus courseStatus); From fbdf6abacb826686a2cc94d0c7e3df488a7a0c4d Mon Sep 17 00:00:00 2001 From: mungsil Date: Mon, 29 Dec 2025 16:21:57 +0900 Subject: [PATCH 84/84] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A5=B4?= =?UTF-8?q?=EB=9F=AC=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/EnableSchedulingConfig.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/java/com/example/mohago_nocar/global/config/EnableSchedulingConfig.java diff --git a/src/main/java/com/example/mohago_nocar/global/config/EnableSchedulingConfig.java b/src/main/java/com/example/mohago_nocar/global/config/EnableSchedulingConfig.java new file mode 100644 index 0000000..2c6a37e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/EnableSchedulingConfig.java @@ -0,0 +1,10 @@ +package com.example.mohago_nocar.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class EnableSchedulingConfig { + +}