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
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.capstone.pickIt.api.course.service;

import com.capstone.pickIt.domain.course.entity.ImportanceLevel;
import com.capstone.pickIt.domain.course.entity.RecruitmentStatus;
import com.capstone.pickIt.domain.course.entity.UserCourseProfile;
import com.capstone.pickIt.domain.course.entity.UserCourseTrait;
import com.capstone.pickIt.domain.course.repository.UserCourseProfileRepository;
import com.capstone.pickIt.domain.matching.entity.MatchScore;
import com.capstone.pickIt.domain.matching.repository.MatchScoreRepository;
import com.capstone.pickIt.domain.teamlevel.entity.TeamLevel;
import com.capstone.pickIt.domain.teamlevel.repository.TeamLevelRepository;
import com.capstone.pickIt.domain.trait.entity.TraitSide;
import com.capstone.pickIt.domain.user.entity.User;
import com.capstone.pickIt.domain.user.entity.UserDefaultTrait;
import com.capstone.pickIt.domain.user.repository.UserDefaultTraitRepository;
import com.capstone.pickIt.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class MatchScoreService {

private final MatchScoreRepository matchScoreRepository;
private final UserCourseProfileRepository userCourseProfileRepository;
private final UserDefaultTraitRepository userDefaultTraitRepository;
private final TeamLevelRepository teamLevelRepository;
private final UserRepository userRepository;

// 카드 생성/수정 시 → 해당 카드에 대해 모든 유저의 점수 계산
@Transactional
public void recalculateForProfile(UserCourseProfile targetProfile) {
// 기존 점수 삭제
matchScoreRepository.deleteByProfileId(targetProfile.getId());

// 해당 과목의 다른 유저 목록 조회
List<UserCourseProfile> otherProfiles = userCourseProfileRepository
.findAllByUserIdAndDeletedAtIsNull(targetProfile.getUser().getId());

// 같은 과목의 모든 유저 조회 (본인 제외)
List<User> allUsers = userRepository.findAll().stream()
.filter(u -> !u.getId().equals(targetProfile.getUser().getId()))
.toList();

// 각 유저와의 점수 계산
List<MatchScore> scores = allUsers.stream()
.map(user -> calculateScore(user, targetProfile))
.toList();
Comment on lines +47 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

재계산 경로가 전체 스캔 + N+1 조회 구조라 트래픽 시 급격히 느려질 수 있습니다.

Line 45-52, 66-74에서 전체 로딩 후 스트림 필터링을 하고, 각 항목 계산마다 추가 조회가 발생해(성향/과목 프로필/팀레벨) 재계산 1회당 쿼리 수가 크게 증가합니다. 후보군을 DB에서 먼저 좁히고, 필요한 데이터는 배치 조회로 한 번에 가져오도록 바꾸는 게 안전합니다.

개선 방향 예시
- List<User> allUsers = userRepository.findAll().stream()
-         .filter(u -> !u.getId().equals(targetProfile.getUser().getId()))
-         .toList();
+ List<Long> candidateUserIds = userCourseProfileRepository
+         .findDistinctUserIdsByCourseIdAndDeletedAtIsNullAndRecruitmentStatusNot(
+                 targetProfile.getCourse().getId(),
+                 RecruitmentStatus.RECRUITMENT_COMPLETED
+         );
+ // 이후 candidateUserIds 기준으로 default traits / team levels / my profile을 일괄 조회 후 메모리 계산

관련 개념: Spring Data JPA에서 projection/배치 조회로 N+1 회피 패턴을 권장합니다.

Also applies to: 66-74, 79-83, 89-90, 112-114, 127-130

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/capstone/pickIt/api/course/service/MatchScoreService.java`
around lines 45 - 52, The current MatchScoreService scanning all users via
userRepository.findAll() and calling calculateScore(user, targetProfile)
triggers N+1 queries (fetching profiles/preferences/team level inside
calculateScore); change to narrow candidates in the DB first (e.g., add
repository query methods or Criteria/JPQL to filter by basic criteria) and load
all required related data in bulk using JOIN FETCH or batch queries (load User
-> Profile, SubjectProfile, Preference, TeamLevel in one query or via repository
methods returning projections/DTOs), then map those pre-fetched entities into
calculateScoreBatch(List<User> candidates, Profile targetProfile) or adapt
calculateScore to accept preloaded data so per-user work is in-memory and avoids
per-entity repository calls in MatchScoreService.


matchScoreRepository.saveAll(scores);
}

// 기본 팀플 성향 수정 시 → 해당 유저가 조회하는 모든 점수 재계산
@Transactional
public void recalculateForUser(Long userId) {
User user = userRepository.findById(userId).orElseThrow();

// 기존 점수 삭제
matchScoreRepository.deleteByUserId(userId);

// 활성화된 모든 카드 조회 (본인 카드 제외)
List<UserCourseProfile> allProfiles = userCourseProfileRepository.findAll().stream()
.filter(p -> !p.getUser().getId().equals(userId))
.filter(p -> p.getDeletedAt() == null)
.filter(p -> p.getRecruitmentStatus() != RecruitmentStatus.RECRUITMENT_COMPLETED)
.toList();

List<MatchScore> scores = allProfiles.stream()
.map(profile -> calculateScore(user, profile))
.toList();

matchScoreRepository.saveAll(scores);
}

private MatchScore calculateScore(User user, UserCourseProfile targetProfile) {
int traitScore = calculateTraitScore(user.getId(), targetProfile);
int importanceScore = calculateImportanceScore(user.getId(), targetProfile);
int levelScore = calculateLevelScore(user.getId(), targetProfile.getUser().getId());

return MatchScore.of(user, targetProfile, traitScore, importanceScore, levelScore);
}

// 성향 일치도 (최대 5점)
private int calculateTraitScore(Long userId, UserCourseProfile targetProfile) {
List<UserDefaultTrait> myTraits = userDefaultTraitRepository.findByUserId(userId);
List<UserCourseTrait> targetTraits = targetProfile.getTraits();

Map<Long, TraitSide> myTraitMap = myTraits.stream()
.collect(Collectors.toMap(
t -> t.getTraitItem().getTraitItemsId(),
UserDefaultTrait::getSelectedSide
));

int score = 0;
for (UserCourseTrait targetTrait : targetTraits) {
Long traitItemId = targetTrait.getTraitItem().getTraitItemsId();
TraitSide mySide = myTraitMap.get(traitItemId);
if (mySide != null && mySide == targetTrait.getSelectedSide()) {
score++;
}
}
return score;
}

// 중요도 일치도 (최대 3점)
private int calculateImportanceScore(Long userId, UserCourseProfile targetProfile) {
// 내 카드 중 같은 과목 카드 조회
UserCourseProfile myProfile = userCourseProfileRepository
.findByUserIdAndCourseIdAndDeletedAtIsNull(userId, targetProfile.getCourse().getId())
.orElse(null);

if (myProfile == null) return 0;

int myLevel = importanceToInt(myProfile.getImportanceLevel());
int targetLevel = importanceToInt(targetProfile.getImportanceLevel());
int diff = Math.abs(myLevel - targetLevel);

return Math.max(3 - diff, 0);
}

// 팀플 레벨 (최대 3점)
private int calculateLevelScore(Long myUserId, Long targetUserId) {
Optional<TeamLevel> myTeamLevel = teamLevelRepository.findByUserId(myUserId);
Optional<TeamLevel> targetTeamLevel = teamLevelRepository.findByUserId(targetUserId);

// 한쪽이라도 레벨 정보 없으면 0점
if (myTeamLevel.isEmpty() || targetTeamLevel.isEmpty()) {
return 0;
}

int myLevel = myTeamLevel.get().getLevel();
int targetLevel = targetTeamLevel.get().getLevel();
int diff = Math.abs(myLevel - targetLevel);
return Math.max(3 - diff, 0);
}

private int importanceToInt(ImportanceLevel level) {
return switch (level) {
case HIGH -> 3;
case MEDIUM -> 2;
case LOW -> 1;
};
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.capstone.pickIt.api.user.service;

import com.capstone.pickIt.api.course.service.MatchScoreService;
import com.capstone.pickIt.api.user.dto.request.AddCourseRequestDTO;
import com.capstone.pickIt.api.user.dto.request.UpdateCourseRequestDTO;
import com.capstone.pickIt.api.user.dto.response.CourseCardResponseDTO;
Expand All @@ -15,6 +16,7 @@
import com.capstone.pickIt.domain.course.repository.UserCourseProfileRepository;
import com.capstone.pickIt.domain.course.repository.UserCourseRepository;
import com.capstone.pickIt.domain.course.repository.UserCourseTraitRepository;
import com.capstone.pickIt.domain.matching.repository.MatchScoreRepository;
import com.capstone.pickIt.domain.trait.entity.TraitItem;
import com.capstone.pickIt.domain.trait.exception.TraitErrorCode;
import com.capstone.pickIt.domain.trait.exception.TraitException;
Expand Down Expand Up @@ -44,6 +46,8 @@ public class MypageCourseService {
private final TraitItemRepository traitItemRepository;
private final UserRepository userRepository;
private final ProjectTeamMemberRepository projectTeamMemberRepository;
private final MatchScoreService matchScoreService;
private final MatchScoreRepository matchScoreRepository;

@Transactional(readOnly = true)
public List<CourseListResponseDTO> getCourseList(Long userId) {
Expand Down Expand Up @@ -108,6 +112,7 @@ public void updateCourse(Long userId, Long courseId, UpdateCourseRequestDTO requ
.build()
);
}
matchScoreService.recalculateForProfile(profile);
}

@Transactional
Expand All @@ -126,6 +131,9 @@ public void deleteCourse(Long userId, Long courseId) {

userCourseTraitRepository.deleteByUserCourseProfileId(profile.getId());
userCourseRepository.deleteByUserIdAndCourseId(userId, courseId);

matchScoreRepository.deleteByProfileId(profile.getId());

profile.softDelete();
}

Expand Down Expand Up @@ -177,5 +185,7 @@ public void addCourse(Long userId, AddCourseRequestDTO request) {
.build()
);
}

matchScoreService.recalculateForProfile(profile);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.capstone.pickIt.api.user.service;

import com.capstone.pickIt.api.course.service.MatchScoreService;
import com.capstone.pickIt.api.user.dto.request.UserDefaultTraitRequestDTO;
import com.capstone.pickIt.api.user.dto.response.UserDefaultTraitResponseDTO;
import com.capstone.pickIt.domain.trait.entity.TraitItem;
Expand All @@ -25,6 +26,7 @@ public class UserDefaultTraitService {
private final UserDefaultTraitRepository userDefaultTraitRepository;
private final UserRepository userRepository;
private final TraitItemRepository traitItemRepository;
private final MatchScoreService matchScoreService;

// 기본 팀플 성향 조회
@Transactional(readOnly = true)
Expand Down Expand Up @@ -70,6 +72,7 @@ public void saveDefaultTraits(Long userId, List<UserDefaultTraitRequestDTO> requ
public List<UserDefaultTraitResponseDTO> updateDefaultTraits(Long userId, List<UserDefaultTraitRequestDTO> requests) {
userDefaultTraitRepository.deleteByUserId(userId);
saveDefaultTraits(userId, requests);
matchScoreService.recalculateForUser(userId);
return getDefaultTraits(userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.time.ZoneOffset;

@Entity
@Table(name = "match_scores")
Expand Down Expand Up @@ -42,4 +43,17 @@ public class MatchScore extends CreatedBaseEntity {

@Column(name = "calculated_at", nullable = false)
private LocalDateTime calculatedAt;

public static MatchScore of(User user, UserCourseProfile profile,
int traitScore, int importanceScore, int levelScore) {
return MatchScore.builder()
.user(user)
.userCourseProfile(profile)
.traitScore(traitScore)
.importanceScore(importanceScore)
.levelScore(levelScore)
.totalScore(traitScore + importanceScore + levelScore)
.calculatedAt(LocalDateTime.now())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

import com.capstone.pickIt.domain.matching.entity.MatchScore;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface MatchScoreRepository extends JpaRepository<MatchScore, Long> {

// 특정 유저가 특정 카드에 대해 계산한 점수 삭제
@Modifying
@Query("DELETE FROM MatchScore ms WHERE ms.user.id = :userId AND ms.userCourseProfile.id = :profileId")
void deleteByUserIdAndProfileId(@Param("userId") Long userId, @Param("profileId") Long profileId);

// 특정 카드에 대한 모든 점수 삭제 (카드 삭제 시)
@Modifying
@Query("DELETE FROM MatchScore ms WHERE ms.userCourseProfile.id = :profileId")
void deleteByProfileId(@Param("profileId") Long profileId);

// 특정 유저가 계산한 모든 점수 삭제 (성향 수정 시)
@Modifying
@Query("DELETE FROM MatchScore ms WHERE ms.user.id = :userId")
void deleteByUserId(@Param("userId") Long userId);
}
Loading