Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@
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;
import org.springframework.transaction.annotation.Transactional;

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;
Expand Down Expand Up @@ -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<SavedPlaceInfo> search(String keyword) {
//네이버에서 특정 장소 기본정보 받아오기
NaverSearchResponse naverSearchResponse = naverMapClient.searchPlaces(keyword, 5);

return naverSearchResponse.items().stream()
List<SearchCandidateResponse> candidates = naverSearchResponse.items().stream()
.map(item -> {

String name = stripHtml(item.title());
String address = item.roadAddress();
String placeKey = PlaceKeyGenerator.generate(name, address);
List<String> 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<String> placeKeys = candidates.stream()
.map(SearchCandidateResponse::placeKey)
.distinct()
.toList();

Map<String, List<String>> images = placeImagesRepository.findAllByPlaceKeyIn(placeKeys).stream()
.collect(Collectors.groupingBy(
PlaceImages::getPlaceKey,
Collectors.mapping(PlaceImages::getImageUrl, Collectors.toList())
));

List<CompletableFuture<SavedPlaceInfo>> 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();
Comment on lines +84 to +92
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "GooglePlaceClient.java" 2>/dev/null

Repository: our-campUS/our-campUS-BE

Length of output: 159


🏁 Script executed:

# Check PlaceService.java structure and imports
head -50 src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java

Repository: our-campUS/our-campUS-BE

Length of output: 2583


🏁 Script executed:

# Search for Semaphore usage in the project
rg -n "Semaphore" --type java

Repository: our-campUS/our-campUS-BE

Length of output: 608


🏁 Script executed:

# View GooglePlaceClient implementation
cat -n src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java

Repository: our-campUS/our-campUS-BE

Length of output: 5211


🏁 Script executed:

# View PlaceService.java lines 75-100 for full context
sed -n '75,100p' src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java | cat -n

Repository: our-campUS/our-campUS-BE

Length of output: 1174


🏁 Script executed:

# Check if there's another GoogleClient file
find . -type f -name "*GoogleClient.java" 2>/dev/null

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

# Find convertToSavedPlaceInfo method
rg -n "convertToSavedPlaceInfo" src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java -A 15

Repository: our-campUS/our-campUS-BE

Length of output: 1476


🏁 Script executed:

# Find ExecutorService configuration in PlaceService
sed -n '50,85p' src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java | cat -n

Repository: our-campUS/our-campUS-BE

Length of output: 1833


🏁 Script executed:

# Check if there are any other timeout issues with CompletableFuture
rg -n "completeOnTimeout|orTimeout" src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java -B 2 -A 2

Repository: our-campUS/our-campUS-BE

Length of output: 327


타임아웃된 작업이 ExecutorService에서 계속 실행되는 문제가 있습니다.

completeOnTimeout은 4초 후 CompletableFuture를 완료하지만 기존 작업을 취소하지 않습니다. convertToSavedPlaceInfo 내에서 호출되는 googleClient.fetchImages()가 4초 이상 걸리면, ExecutorService의 스레드에서 계속 실행되어 다음과 같은 문제가 발생합니다:

  • HTTP 커넥션 및 네트워크 리소스 낭비
  • 빈번한 호출 시 ExecutorService 스레드 누적
  • 불필요한 Google API 호출로 할당량 소비

또한 타임아웃 및 에러 발생 시 로깅이 없어 모니터링 및 디버깅이 어렵습니다.

다만 PR 설명의 Semaphore(동시 실행 수 20개 제한)는 GooglePlaceClient에 정상적으로 구현되어 있으며, try-finally 블록에서 적절히 해제되고 있습니다. 따라서 Semaphore 자체는 문제가 아니며, ExecutorService 스레드의 지속 실행이 주요 이슈입니다.

🔎 로깅 추가 및 태스크 취소 개선 제안
 List<CompletableFuture<SavedPlaceInfo>> futures = candidates.stream()
-	.map(response -> CompletableFuture.supplyAsync(() -> convertToSavedPlaceInfo(response, images),
+	.map(response -> {
+		CompletableFuture<SavedPlaceInfo> future = CompletableFuture.supplyAsync(
+			() -> convertToSavedPlaceInfo(response, images),
 			executorService)
-		.completeOnTimeout(fallback(response), 4, TimeUnit.SECONDS)
-		.exceptionally(ex -> fallback(response)))
+			.completeOnTimeout(fallback(response), 4, TimeUnit.SECONDS)
+			.exceptionally(ex -> {
+				if (ex instanceof TimeoutException) {
+					log.warn("장소 정보 조회 타임아웃: placeKey={}, name={}", 
+						response.placeKey(), response.name());
+				} else {
+					log.error("장소 정보 조회 실패: placeKey={}, name={}", 
+						response.placeKey(), response.name(), ex);
+				}
+				return fallback(response);
+			});
+		return future;
+	})
 	.toList();

참고: 완전한 태스크 취소를 위해서는 googleClient.fetchImages 내부에서 Thread.interrupted() 확인 또는 CompletableFuture 취소 신호를 전파하는 메커니즘이 필요합니다.

}

//장소 저장
Expand Down Expand Up @@ -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<String> getPlaceImgs(String placeKey, String name, String address) {
// DB 확인
List<String> images = getImages(placeKey);
if (!images.isEmpty()) {
return images;
}
private SavedPlaceInfo convertToSavedPlaceInfo(SearchCandidateResponse response, Map<String, List<String>> images) {
List<String> cached = images.getOrDefault(response.placeKey(), List.of());
List<String> placeImages = !cached.isEmpty()
? cached : googleClient.fetchImages(response.name(), response.address(), 3);

//최초 검색 시 google에서 이미지 url 가져오기
List<String> 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<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,4 +10,6 @@
public interface PlaceImagesRepository extends JpaRepository<PlaceImages, Long> {

List<PlaceImages> findByPlaceKey(String placeKey);

List<PlaceImages> findAllByPlaceKeyIn(Collection<String> placeKeys);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.time.Duration;
import java.util.List;
import java.util.concurrent.Semaphore;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
Expand All @@ -21,6 +22,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
) {
Expand All @@ -34,26 +37,35 @@ public GooglePlaceClient(
* 장소 이름 + 주소를 기준으로 google places에서 이미지 URL 목록을 가져옴
*/
public List<String> fetchImages(String name, String address, int limit) {

String placeId = findPlaceId(name, address);
if (placeId == null) {
return List.of();
try{
googleApiSemaphore.acquire();
} catch (InterruptedException e){
throw new RuntimeException("Google API 대기 중 인터럽트 발생", e);
}

//placee details -> photo reference
List<String> photoRefs = getPhotoReferences(placeId);
if (photoRefs.isEmpty()) {
return List.of();
try{
String placeId = findPlaceId(name, address);
if (placeId == null) {
return List.of();
}

//placee details -> photo reference
List<String> photoRefs = getPhotoReferences(placeId);
if (photoRefs.isEmpty()) {
return List.of();
}

//imageURL 생성
List<String> imageUrls = photoRefs.stream()
.limit(3)
.map(this::buildPhotoUrl)
.toList();
Comment on lines +60 to +64
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

메서드 파라미터 limit가 사용되지 않고 있습니다.

Line 62에서 .limit(3)으로 하드코딩되어 있어 fetchImages 메서드의 limit 파라미터가 무시됩니다. 파라미터를 활용하거나 불필요하다면 시그니처에서 제거하는 것이 좋습니다.

🔎 제안하는 수정 방안
	List<String> imageUrls = photoRefs.stream()
-		.limit(3)
+		.limit(limit)
		.map(this::buildPhotoUrl)
		.toList();
🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java
around lines 60-64, The fetchImages method currently ignores its limit parameter
because photoRefs.stream().limit(3) is hardcoded; update the stream call to use
the method parameter (replace .limit(3) with .limit(limit)) so the provided
limit is honored, or if the parameter is truly unused remove the limit parameter
from the fetchImages signature and related callers; ensure buildPhotoUrl is
still used to map refs to URLs and adjust any tests or callers accordingly.


log.info("imageUrls: {}", imageUrls);
return imageUrls;
} finally {
googleApiSemaphore.release();
}

//imageURL 생성
List<String> imageUrls = photoRefs.stream()
.limit(3)
.map(this::buildPhotoUrl)
.toList();

log.info("imageUrls: {}", imageUrls);
return imageUrls;
}

/*
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
20 changes: 0 additions & 20 deletions src/main/java/com/campus/campus/test/TestController.java

This file was deleted.

18 changes: 0 additions & 18 deletions src/main/java/com/campus/campus/test/TestResponseCode.java

This file was deleted.