diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/SearchCandidateResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/SearchCandidateResponse.java new file mode 100644 index 00000000..032d645b --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/SearchCandidateResponse.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.place.application.dto.response; + +import com.campus.campus.domain.place.application.dto.response.naver.NaverSearchResponse; + +public record SearchCandidateResponse( + NaverSearchResponse.Item item, + String name, + String address, + String placeKey, + String naverPlaceUrl +) { +} diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java index be49b208..81a05fe1 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java @@ -3,7 +3,12 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -11,6 +16,7 @@ import com.campus.campus.domain.place.application.dto.response.LikeResponse; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; +import com.campus.campus.domain.place.application.dto.response.SearchCandidateResponse; import com.campus.campus.domain.place.application.dto.response.naver.NaverSearchResponse; import com.campus.campus.domain.place.application.exception.NaverMapAPIException; import com.campus.campus.domain.place.application.exception.PlaceCreationException; @@ -48,23 +54,42 @@ public class PlaceService { private final PresignedUrlService presignedUrlService; private final LikedPlacesRepository likedPlacesRepository; private final UserRepository userRepository; + private final ExecutorService executorService; public List search(String keyword) { //네이버에서 특정 장소 기본정보 받아오기 NaverSearchResponse naverSearchResponse = naverMapClient.searchPlaces(keyword, 5); - return naverSearchResponse.items().stream() + List candidates = naverSearchResponse.items().stream() .map(item -> { - String name = stripHtml(item.title()); String address = item.roadAddress(); String placeKey = PlaceKeyGenerator.generate(name, address); - List placeImages = getPlaceImgs(placeKey, name, address); String naverPlaceUrl = buildNaverPlaceUrl(item); - - return placeMapper.toSavedPlaceInfo(item, name, placeKey, naverPlaceUrl, placeImages); + return new SearchCandidateResponse(item, name, address, placeKey, naverPlaceUrl); }) .toList(); + + List placeKeys = candidates.stream() + .map(SearchCandidateResponse::placeKey) + .distinct() + .toList(); + + Map> images = placeImagesRepository.findAllByPlaceKeyIn(placeKeys).stream() + .collect(Collectors.groupingBy( + PlaceImages::getPlaceKey, + Collectors.mapping(PlaceImages::getImageUrl, Collectors.toList()) + )); + + List> futures = candidates.stream() + .map(response -> CompletableFuture.supplyAsync(() -> convertToSavedPlaceInfo(response, images), + executorService) + .completeOnTimeout(fallback(response), 4, TimeUnit.SECONDS) + .exceptionally(ex -> fallback(response))) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + return futures.stream().map(CompletableFuture::join).toList(); } //장소 저장 @@ -110,39 +135,26 @@ public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { return placeMapper.toLikeResponse(place); } - /** - * 태그 제거용 - */ - private String stripHtml(String text) { - return text.replaceAll("<[^>]*>", ""); - } - - /* - * 장소 검색 시 google places로부터 이미지 불러오기 - */ - private List getPlaceImgs(String placeKey, String name, String address) { - // DB 확인 - List images = getImages(placeKey); - if (!images.isEmpty()) { - return images; - } + private SavedPlaceInfo convertToSavedPlaceInfo(SearchCandidateResponse response, Map> images) { + List cached = images.getOrDefault(response.placeKey(), List.of()); + List placeImages = !cached.isEmpty() + ? cached : googleClient.fetchImages(response.name(), response.address(), 3); - //최초 검색 시 google에서 이미지 url 가져오기 - List googleImageUrls = googleClient.fetchImages(name, address, 3); - if (googleImageUrls.isEmpty()) { - return List.of(); - } + return placeMapper.toSavedPlaceInfo(response.item(), response.name(), response.placeKey(), + response.naverPlaceUrl(), placeImages == null ? List.of() : placeImages + ); + } - return googleImageUrls; + private SavedPlaceInfo fallback(SearchCandidateResponse response) { + return placeMapper.toSavedPlaceInfo(response.item(), response.name(), response.placeKey(), + response.naverPlaceUrl(), List.of()); } - /* - * DB 조회 + /** + * 태그 제거용 */ - private List getImages(String placeKey) { - return placeImagesRepository.findByPlaceKey(placeKey).stream() - .map(PlaceImages::getImageUrl) - .toList(); + private String stripHtml(String text) { + return text.replaceAll("<[^>]*>", ""); } private String buildNaverPlaceUrl(NaverSearchResponse.Item item) { diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java index c52672e0..aa763a30 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java @@ -1,5 +1,6 @@ package com.campus.campus.domain.place.domain.repository; +import java.util.Collection; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +10,6 @@ public interface PlaceImagesRepository extends JpaRepository { List findByPlaceKey(String placeKey); + + List findAllByPlaceKeyIn(Collection placeKeys); } diff --git a/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java b/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java index f61c2090..6eaf07d5 100644 --- a/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java +++ b/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java @@ -2,6 +2,8 @@ import java.time.Duration; import java.util.List; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -21,6 +23,8 @@ public class GooglePlaceClient { private final WebClient webClient; private final String apiKey; + private final Semaphore googleApiSemaphore = new Semaphore(20); + public GooglePlaceClient( @Value("${map.google.places.api-key}") String apiKey ) { @@ -34,26 +38,47 @@ public GooglePlaceClient( * 장소 이름 + 주소를 기준으로 google places에서 이미지 URL 목록을 가져옴 */ public List fetchImages(String name, String address, int limit) { + boolean acquired = false; + try { + acquired = googleApiSemaphore.tryAcquire(5, TimeUnit.SECONDS); + if (!acquired) { + log.warn("Google API 요청 폭주로 인한 대기 시간 초과 (Timeout): {}, {}", name, address); + return List.of(); + } + + String placeId = findPlaceId(name, address); + if (placeId == null) { + return List.of(); + } + + //place details -> photo reference + List photoRefs = getPhotoReferences(placeId); + if (photoRefs.isEmpty()) { + return List.of(); + } + + //imageURL 생성 + List imageUrls = photoRefs.stream() + .limit(3) + .map(this::buildPhotoUrl) + .toList(); + + log.info("imageUrls: {}", imageUrls); + return imageUrls; + } catch (InterruptedException e) { + log.warn("Google API 대기 중 인터럽트 발생", e); + Thread.currentThread().interrupt(); - String placeId = findPlaceId(name, address); - if (placeId == null) { return List.of(); - } + } catch (Exception e) { + log.error("Google API 호출 중 예상치 못한 에러 발생", e); - //placee details -> photo reference - List photoRefs = getPhotoReferences(placeId); - if (photoRefs.isEmpty()) { return List.of(); + } finally { + if (acquired) { + googleApiSemaphore.release(); + } } - - //imageURL 생성 - List imageUrls = photoRefs.stream() - .limit(3) - .map(this::buildPhotoUrl) - .toList(); - - log.info("imageUrls: {}", imageUrls); - return imageUrls; } /* @@ -77,6 +102,11 @@ private String findPlaceId(String name, String address) { return null; } + if (response.results() == null || response.results().isEmpty()) { + log.info("Google Place 검색 결과 없음: query={}", query); + return null; + } + String placeId = response.results().get(0).placeId(); if (placeId == null || placeId.isBlank()) { return null; diff --git a/src/main/java/com/campus/campus/global/annotation/stopwatch/ExecutionTimeAspect.java b/src/main/java/com/campus/campus/global/annotation/stopwatch/ExecutionTimeAspect.java new file mode 100644 index 00000000..a6cb7545 --- /dev/null +++ b/src/main/java/com/campus/campus/global/annotation/stopwatch/ExecutionTimeAspect.java @@ -0,0 +1,30 @@ +package com.campus.campus.global.annotation.stopwatch; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.util.StopWatch; + +import lombok.extern.slf4j.Slf4j; + +@Aspect +@Component +@Slf4j +public class ExecutionTimeAspect { + + @Around("@annotation(LogExecutionTime)") + public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + Object proceed = joinPoint.proceed(); // 실제 메서드 실행 + + stopWatch.stop(); + log.info("⏱️ [Performance] Method: {} | Execution Time: {} ms", + joinPoint.getSignature().toShortString(), + stopWatch.getTotalTimeMillis()); + + return proceed; + } +} diff --git a/src/main/java/com/campus/campus/global/annotation/stopwatch/LogExecutionTime.java b/src/main/java/com/campus/campus/global/annotation/stopwatch/LogExecutionTime.java new file mode 100644 index 00000000..025fe9af --- /dev/null +++ b/src/main/java/com/campus/campus/global/annotation/stopwatch/LogExecutionTime.java @@ -0,0 +1,11 @@ +package com.campus.campus.global.annotation.stopwatch; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) // 메서드에 붙이는 어노테이션 +@Retention(RetentionPolicy.RUNTIME) // 런타임까지 유지 +public @interface LogExecutionTime { +} diff --git a/src/main/java/com/campus/campus/global/config/executor/PlaceSearchExecutorConfig.java b/src/main/java/com/campus/campus/global/config/executor/PlaceSearchExecutorConfig.java new file mode 100644 index 00000000..3379c995 --- /dev/null +++ b/src/main/java/com/campus/campus/global/config/executor/PlaceSearchExecutorConfig.java @@ -0,0 +1,15 @@ +package com.campus.campus.global.config.executor; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PlaceSearchExecutorConfig { + @Bean(destroyMethod = "close") + public ExecutorService placeSearchExecutor() { + return Executors.newVirtualThreadPerTaskExecutor(); + } +} diff --git a/src/main/java/com/campus/campus/test/TestController.java b/src/main/java/com/campus/campus/test/TestController.java deleted file mode 100644 index 0a7c0159..00000000 --- a/src/main/java/com/campus/campus/test/TestController.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.campus.campus.test; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.campus.campus.global.common.response.CommonResponse; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/test") -@RequiredArgsConstructor -public class TestController { - @GetMapping - public CommonResponse test() { - System.out.println("test"); - return CommonResponse.success(TestResponseCode.TEST_SUCCESS); - } -} diff --git a/src/main/java/com/campus/campus/test/TestResponseCode.java b/src/main/java/com/campus/campus/test/TestResponseCode.java deleted file mode 100644 index 4e85faa6..00000000 --- a/src/main/java/com/campus/campus/test/TestResponseCode.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.campus.campus.test; - -import org.springframework.http.HttpStatus; - -import com.campus.campus.global.common.response.ResponseCodeInterface; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum TestResponseCode implements ResponseCodeInterface { - TEST_SUCCESS(201, HttpStatus.OK, "테스트에 성공했습니다."); - - private final int code; - private final HttpStatus status; - private final String message; -}