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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 51 additions & 121 deletions src/main/java/com/spots/domain/ai/service/RecommendService.java
Original file line number Diff line number Diff line change
@@ -1,30 +1,26 @@
package com.spots.domain.ai.service;

import static java.math.BigDecimal.valueOf;
import static java.math.RoundingMode.HALF_UP;

import com.spots.domain.ai.dto.request.RecommendLLMRequest;
import com.spots.domain.ai.dto.request.RecommendLLMRequest.RecommendProgramData;
import com.spots.domain.ai.dto.request.UserInfoServiceRequest;
import com.spots.domain.ai.dto.response.WeeklyRecommendResponse;
import com.spots.domain.program.dto.response.ProgramDetailInfoResponse;
import com.spots.domain.program.dto.response.ProgramInfoResponse;
import com.spots.domain.program.dto.response.TransportData;
import com.spots.domain.program.dto.response.TransportDataRawWithFacility;
import com.spots.domain.program.entity.Program;
import com.spots.domain.program.repository.ProgramRepository;
import com.spots.domain.program.service.ProgramService;
import com.spots.domain.transport.repository.TransitRepository;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StopWatch;

@Service
@RequiredArgsConstructor
Expand All @@ -33,6 +29,7 @@ public class RecommendService {

private final ProgramService programService;
private final ProgramRepository programRepository;
private final TransitRepository transitRepository;
private final RecommendLLMService recommendLLMService;
Comment on lines 30 to 33
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

ProgramService is no longer referenced in this class after the refactor, but it is still injected as a field. This adds an unnecessary dependency and can mislead future readers; remove the field (and corresponding constructor injection) if it’s not needed anymore.

Copilot uses AI. Check for mistakes.

private static final double EARTH_RADIUS_KM = 6371.0;
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

EARTH_RADIUS_KM is now unused (distance calculation was removed). Please remove the constant to avoid dead code and potential static-analysis failures.

Copilot uses AI. Check for mistakes.
Expand All @@ -43,127 +40,60 @@ public CompletableFuture<WeeklyRecommendResponse> recommendWeeklyRoutine(
UserInfoServiceRequest request
) {

List<ProgramInfoResponse> programs =
programRepository
.searchPrograms(
request.toProgramInfoServiceRequest(),
50L,
null,
null
)
.getContent();
log.info("programs size: {}", programs.size());
List<RecommendProgramData> recommendProgramDataList = convertToRecommendProgramData(programs, request);
List<ProgramInfoResponse> programs = programRepository
.searchPrograms(
request.toProgramInfoServiceRequest(),
50L,
null,
null
)
.getContent();

List<RecommendProgramData> recommendProgramDataList = convertToRecommendProgramData(programs);

RecommendLLMRequest llmRequest = RecommendLLMRequest.from(request, recommendProgramDataList);
WeeklyRecommendResponse response = recommendLLMService.createWeeklyPlan(llmRequest);

return CompletableFuture.completedFuture(response);
}

private List<RecommendProgramData> convertToRecommendProgramData(
List<ProgramInfoResponse> programs,
UserInfoServiceRequest request
) {
// 성능 측정을 위한 스톱워치 생성
StopWatch stopWatch = new StopWatch("DataConversionTask");

// 1. ID 추출
List<Long> programIds = programs.stream()
.map(ProgramInfoResponse::programId)
.toList();

// 2. 배치 조회 측정
stopWatch.start("Bulk DB Fetch (findAllById)");
List<Program> programEntities = programRepository.findAllById(programIds);
stopWatch.stop();

// Map 변환
Map<Long, Program> programMap = programEntities.stream()
.collect(Collectors.toMap(Program::getId, Function.identity()));

// 3. 루프 처리 시간 측정 준비
// stream 내부에서 시간을 누적하기 위해 AtomicLong 사용
AtomicLong totalGetProgramTime = new AtomicLong(0);
AtomicLong totalCalcDistanceTime = new AtomicLong(0);

stopWatch.start("Stream Processing (Loop)");

List<RecommendProgramData> result = programs.stream()
.map(programInfoResponse -> {

// A. 상세 정보 조회 시간 측정 (가장 의심되는 구간)
long startService = System.nanoTime();
ProgramDetailInfoResponse programDetailInfoResponse =
programService.getProgram(programInfoResponse.programId());
long endService = System.nanoTime();
totalGetProgramTime.addAndGet(endService - startService); // 누적

List<TransportData> transports = programDetailInfoResponse.transportData();

Program program = programMap.get(programInfoResponse.programId());

// B. 거리 계산 시간 측정
double distance = 0.0;
if (program != null && program.getFacility() != null) {
long startCalc = System.nanoTime();
distance = calculateDistance(
request.latitude(),
request.longitude(),
program.getFacility().getFcltyLa(),
program.getFacility().getFcltyLo()
);
long endCalc = System.nanoTime();
totalCalcDistanceTime.addAndGet(endCalc - startCalc);
}

return new RecommendProgramData(
programInfoResponse,
transports,
distance
);
})
.toList();

stopWatch.stop();

// 4. 결과 로그 출력
log.info("================ 성능 측정 결과 ================");
log.info(stopWatch.prettyPrint()); // 전체적인 요약 출력
log.info(">> [상세] programService.getProgram() 총 소요 시간: {} ms", totalGetProgramTime.get() / 1_000_000);
log.info(">> [상세] 거리 계산 총 소요 시간: {} ms", totalCalcDistanceTime.get() / 1_000_000);
log.info("==============================================");

return result;
}

private Double calculateDistance(
Double userLa,
Double userLo,
Double fcltyLa,
Double fcltyLo
private List<RecommendProgramData> convertToRecommendProgramData(
List<ProgramInfoResponse> programs
) {
final double R = EARTH_RADIUS_KM;

double lat1 = Math.toRadians(userLa);
double lon1 = Math.toRadians(userLo);
double lat2 = Math.toRadians(fcltyLa);
double lon2 = Math.toRadians(fcltyLo);

double diffLat = lat2 - lat1;
double diffLon = lon2 - lon1;

double a =
Math.pow(Math.sin(diffLat / 2), 2)
+ Math.cos(lat1)
* Math.cos(lat2)
* Math.pow(Math.sin(diffLon / 2), 2);

double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

return valueOf(R * c)
.setScale(2, HALF_UP)
.doubleValue();
List<Long> programIds = programs.stream()
.map(ProgramInfoResponse::programId)
.toList();
List<Program> programEntities = programRepository.findAllWithFacility(programIds);
Map<Long, Program> programMap = programEntities.stream()
.collect(Collectors.toMap(Program::getId, Function.identity()));

List<Long> facilityIds = programEntities.stream()
.map(program -> program.getFacility().getId())
.distinct()
.toList();

Map<Long, List<TransportData>> transitMap = transitRepository
Comment on lines +71 to +75
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

If programEntities is empty (e.g., no programs matched), facilityIds becomes empty and the subsequent native query using IN (:facilityIds) can fail (often expands to IN ()). Add an early return for empty programs/facilityIds, or skip the transit query when there are no facility IDs.

Copilot uses AI. Check for mistakes.
.findTop2TransitByFacilityIds(facilityIds)
.stream()
.collect(Collectors.groupingBy(
TransportDataRawWithFacility::facilityId,
Collectors.mapping(
raw -> new TransportData(
raw.transportType(),
raw.transportName(),
raw.transportTime().longValue()
),
Collectors.toList()
)
));

return programs.stream()
.map(info -> {
Program program = programMap.get(info.programId());
Long facilityId = program.getFacility().getId();
List<TransportData> transports = transitMap.getOrDefault(facilityId, List.of());
return new RecommendProgramData(info, transports, info.distance());
})
.toList();
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.spots.domain.program.dto.response;

public record TransportDataRawWithFacility(
Long facilityId,
String transportType,
String transportName,
Double transportTime
) {

}
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package com.spots.domain.program.repository;

import com.spots.domain.program.entity.Program;
import java.util.List;
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;

@Repository
public interface ProgramRepository extends JpaRepository<Program, Long>, ProgramRepositoryCustom {

@Query("SELECT p FROM Program p JOIN FETCH p.facility WHERE p.id IN :ids")
List<Program> findAllWithFacility(@Param("ids") List<Long> ids);

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.spots.domain.transport.repository;

import com.spots.domain.program.dto.response.TransportDataRaw;
import com.spots.domain.program.dto.response.TransportDataRawWithFacility;
import com.spots.domain.transport.entity.FacilityTransit;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -12,7 +13,7 @@
public interface TransitRepository extends JpaRepository<FacilityTransit, Long> {

@Query(value = """
SELECT DISTINCT ON (rank)
SELECT DISTINCT ON (rank)
pbtrnsp_fclty_sdiv_nm AS transportType,
bstp_subwayst_nm AS transportName,
(wlkg_mvmn_time / 60) + 1 AS transportTime
Expand All @@ -22,4 +23,17 @@ SELECT DISTINCT ON (rank)
""",
nativeQuery = true)
List<TransportDataRaw> findTop2Transit(@Param("facilityId") Long facilityId);

@Query(value = """
SELECT DISTINCT ON (facility_id, rank)
facility_id AS facilityId,
pbtrnsp_fclty_sdiv_nm AS transportType,
bstp_subwayst_nm AS transportName,
(wlkg_mvmn_time / 60) + 1 AS transportTime
FROM facility_transit
WHERE facility_id IN (:facilityIds) AND rank IN (1, 2)
ORDER BY facility_id, rank, wlkg_mvmn_time ASC
""",
Comment on lines +32 to +36
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

This native query uses facility_id IN (:facilityIds). When facilityIds is empty, many JPA providers will generate invalid SQL (IN ()) and throw at runtime. Consider guarding against empty input (e.g., via a default method that returns an empty list) or ensure all call sites short-circuit before invoking this method with an empty collection.

Copilot uses AI. Check for mistakes.
nativeQuery = true)
List<TransportDataRawWithFacility> findTop2TransitByFacilityIds(@Param("facilityIds") List<Long> facilityIds);
}
Loading