diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapNodeResponse.java b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapNodeResponse.java index 1e925d82..cb6143d9 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapNodeResponse.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapNodeResponse.java @@ -17,10 +17,23 @@ public record JobRoadmapNodeResponse( boolean isLinkedToTask, Double weight, // 이 노드의 가중치 (JobRoadmapNodeStat에서) + // 멘토 커버리지 통계 + Integer mentorCount, // 이 노드를 선택한 멘토 수 + Integer totalMentorCount, // 해당 직업의 전체 멘토 수 + Double mentorCoverageRatio, // mentorCount / totalMentorCount (0.0 ~ 1.0) + + // UI 강조 표시용 (동적 계산) + Boolean isEssential, // 필수 노드 여부 (50% 이상) + String essentialLevel, // "CORE" (80%+) | "COMMON" (50%+) | "OPTIONAL" (<50%) + @JsonInclude(JsonInclude.Include.NON_EMPTY) List children ) { + // 필수 경로 판정 기준 (상수) + private static final double ESSENTIAL_THRESHOLD = 0.5; // 50% + private static final double CORE_THRESHOLD = 0.8; // 80% + // 정적 팩토리 메서드 - RoadmapNode로부터 Response DTO 생성 (자식 노드 정보 포함) public static JobRoadmapNodeResponse from(RoadmapNode node, List children) { List childIds = children != null ? @@ -38,6 +51,11 @@ public static JobRoadmapNodeResponse from(RoadmapNode node, List= ESSENTIAL_THRESHOLD; + String essentialLevel = calculateEssentialLevel(mentorCoverageRatio); + + return new JobRoadmapNodeResponse( + this.id, this.parentId, this.childIds, this.taskId, this.taskName, this.description, + this.stepOrder, this.level, this.isLinkedToTask, this.weight, + mentorCount, totalMentorCount, mentorCoverageRatio, + isEssential, essentialLevel, + this.children + ); + } + + // 필수 경로 레벨 계산 (동적) + private static String calculateEssentialLevel(Double ratio) { + if (ratio == null) return "UNKNOWN"; + if (ratio >= CORE_THRESHOLD) return "CORE"; // 80%+ : 핵심 필수 + if (ratio >= ESSENTIAL_THRESHOLD) return "COMMON"; // 50%+ : 일반 필수 + return "OPTIONAL"; // 50%- : 선택 + } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapResponse.java b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapResponse.java index d33a5849..802c255d 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapResponse.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapResponse.java @@ -1,6 +1,7 @@ package com.back.domain.roadmap.roadmap.dto.response; import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat; import com.back.domain.roadmap.roadmap.entity.RoadmapNode; import java.time.LocalDateTime; @@ -19,8 +20,8 @@ public record JobRoadmapResponse( LocalDateTime modifiedDate ) { - // 정적 팩터리 메서드 - JobRoadmap과 Job 정보로부터 Response DTO 생성 - public static JobRoadmapResponse from(JobRoadmap jobRoadmap, String jobName) { + // 정적 팩터리 메서드 - JobRoadmap과 Job 정보, 통계 정보로부터 Response DTO 생성 + public static JobRoadmapResponse from(JobRoadmap jobRoadmap, String jobName, Map statMap) { // 부모-자식 관계 맵 생성 Map> childrenMap = jobRoadmap.getNodes().stream() .filter(node -> node.getParent() != null) @@ -29,8 +30,8 @@ public static JobRoadmapResponse from(JobRoadmap jobRoadmap, String jobName) { // 노드를 재귀적으로 변환하는 함수 Map nodeResponseMap = new HashMap<>(); - // 모든 노드를 bottom-up 방식으로 변환 (자식부터 부모 순서) - buildNodeResponses(jobRoadmap.getNodes(), childrenMap, nodeResponseMap); + // 모든 노드를 bottom-up 방식으로 변환 (자식부터 부모 순서, 통계 정보 포함) + buildNodeResponses(jobRoadmap.getNodes(), childrenMap, nodeResponseMap, statMap); // 루트 노드들만 반환 (자식 노드들은 children 필드에 포함되어 전체 트리 구조 제공) List nodes = jobRoadmap.getNodes().stream() @@ -53,11 +54,12 @@ public static JobRoadmapResponse from(JobRoadmap jobRoadmap, String jobName) { ); } - // 노드 응답 객체들을 재귀적으로 구성하는 헬퍼 메서드 + // 노드 응답 객체들을 재귀적으로 구성하는 헬퍼 메서드 (통계 정보 포함) private static void buildNodeResponses( List allNodes, Map> childrenMap, - Map nodeResponseMap) { + Map nodeResponseMap, + Map statMap) { // 노드들을 level 역순으로 정렬 (깊은 노드부터 처리) List sortedNodes = allNodes.stream() @@ -76,6 +78,15 @@ private static void buildNodeResponses( // 현재 노드의 응답 객체 생성 JobRoadmapNodeResponse nodeResponse = JobRoadmapNodeResponse.from(node, childResponses); + + // 통계 정보가 있으면 추가 + JobRoadmapNodeStat stat = statMap.get(node.getId()); + if (stat != null) { + nodeResponse = nodeResponse + .withWeight(stat.getWeight()) + .withStats(stat.getMentorCount(), stat.getTotalMentorCount(), stat.getMentorCoverageRatio()); + } + nodeResponseMap.put(node.getId(), nodeResponse); } } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java index cbdbd117..99b2198d 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java @@ -28,6 +28,12 @@ public class JobRoadmapNodeStat extends BaseEntity { @Column(name = "mentor_count") private Integer mentorCount; // 몇 명의 멘토 로드맵에 등장했는지 (unique mentor count) + @Column(name = "total_mentor_count") + private Integer totalMentorCount; // 해당 직업의 전체 멘토 수 + + @Column(name = "mentor_coverage_ratio") + private Double mentorCoverageRatio; // mentorCount / totalMentorCount (0.0 ~ 1.0) + @Column(name = "outgoing_transitions") private Integer outgoingTransitions; // 이 노드에서 다른 노드로 이동한 총 전이수 @@ -37,10 +43,57 @@ public class JobRoadmapNodeStat extends BaseEntity { @Column(name = "transition_counts", columnDefinition = "TEXT") private String transitionCounts; // (선택) JSON 직렬화: { "T:5":3, "T:7":1 } 형태로 보관 가능 + @Column(name = "alternative_parents", columnDefinition = "TEXT") + private String alternativeParents; // 대안 부모 후보들: JSON 형태 { "T:1": 8, "N:kotlin": 7 } + @Builder public JobRoadmapNodeStat(Integer stepOrder, Double weight, RoadmapNode node) { this.stepOrder = stepOrder; this.weight = weight != null ? weight : 0.0; this.node = node; } + + public void setStepOrder(Integer stepOrder) { + this.stepOrder = stepOrder; + } + + public void setWeight(Double weight) { + this.weight = weight; + } + + public void setNode(RoadmapNode node) { + this.node = node; + } + + public void setAveragePosition(Double averagePosition) { + this.averagePosition = averagePosition; + } + + public void setMentorCount(Integer mentorCount) { + this.mentorCount = mentorCount; + } + + public void setTotalMentorCount(Integer totalMentorCount) { + this.totalMentorCount = totalMentorCount; + } + + public void setMentorCoverageRatio(Double mentorCoverageRatio) { + this.mentorCoverageRatio = mentorCoverageRatio; + } + + public void setOutgoingTransitions(Integer outgoingTransitions) { + this.outgoingTransitions = outgoingTransitions; + } + + public void setIncomingTransitions(Integer incomingTransitions) { + this.incomingTransitions = incomingTransitions; + } + + public void setTransitionCounts(String transitionCounts) { + this.transitionCounts = transitionCounts; + } + + public void setAlternativeParents(String alternativeParents) { + this.alternativeParents = alternativeParents; + } } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java index d9ef1b20..4d3d5c5c 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java @@ -78,11 +78,35 @@ public void addChild(RoadmapNode child) { child.setLevel(this.level + 1); // 부모 level + 1로 자동 설정 } - private void setParent(RoadmapNode parent) { + public void setParent(RoadmapNode parent) { this.parent = parent; } - private void setLevel(int level) { + public void setLevel(int level) { this.level = level; } + + public void setStepOrder(int stepOrder) { + this.stepOrder = stepOrder; + } + + public void setRoadmapId(Long roadmapId) { + this.roadmapId = roadmapId; + } + + public void setRoadmapType(RoadmapType roadmapType) { + this.roadmapType = roadmapType; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setTask(Task task) { + this.task = task; + } + + public void setTaskName(String taskName) { + this.taskName = taskName; + } } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java new file mode 100644 index 00000000..62b67616 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java @@ -0,0 +1,18 @@ +package com.back.domain.roadmap.roadmap.repository; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface JobRoadmapNodeStatRepository extends JpaRepository { + + @Query(""" + SELECT s FROM JobRoadmapNodeStat s + JOIN FETCH s.node n + WHERE n.roadmapId = :roadmapId + """) + List findByNode_RoadmapIdWithNode(@Param("roadmapId") Long roadmapId); +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapRepository.java index 36d23518..c247e1b0 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapRepository.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapRepository.java @@ -1,5 +1,6 @@ package com.back.domain.roadmap.roadmap.repository; +import com.back.domain.job.job.entity.Job; import com.back.domain.roadmap.roadmap.entity.JobRoadmap; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -35,4 +36,5 @@ public interface JobRoadmapRepository extends JpaRepository { ORDER BY n.level, n.stepOrder""") Optional findByIdWithJobAndNodes(@Param("id") Long id); + Optional findByJob(Job job); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java new file mode 100644 index 00000000..ba62ca47 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java @@ -0,0 +1,593 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.job.job.entity.Job; +import com.back.domain.job.job.repository.JobRepository; +import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat; +import com.back.domain.roadmap.roadmap.entity.MentorRoadmap; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode.RoadmapType; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapNodeStatRepository; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; +import com.back.domain.roadmap.roadmap.repository.MentorRoadmapRepository; +import com.back.domain.roadmap.task.entity.Task; +import com.back.domain.roadmap.task.repository.TaskRepository; +import com.back.global.exception.ServiceException; +import com.back.standard.util.Ut; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class JobRoadmapIntegrationService { + private final MentorRoadmapRepository mentorRoadmapRepository; + private final JobRepository jobRepository; + private final JobRoadmapRepository jobRoadmapRepository; + private final TaskRepository taskRepository; + private final JobRoadmapNodeStatRepository jobRoadmapNodeStatRepository; + + private final double BRANCH_THRESHOLD = 0.25; + private final int MAX_DEPTH = 10; + private final int MAX_CHILDREN = 4; + + // 부모 우선순위 점수 가중치 + private static final double TRANSITION_WEIGHT = 0.7; // 전이 빈도 가중치 + private static final double POSITION_WEIGHT = 0.2; // 위치 가중치 + private static final double MENTOR_COVERAGE_WEIGHT = 0.1; // 멘토 커버리지 가중치 + private static final int MAX_DEFERRED_RETRY = 3; // 재시도 최대 횟수 + + @Transactional + public JobRoadmap integrateJobRoadmap(Long jobId) { + // 해당 Job 존재 확인 + Job job = jobRepository.findById(jobId) + .orElseThrow(() -> new ServiceException("404", "직업을 찾을 수 없습니다. id=" + jobId)); + + // 옵션: 기존 JobRoadmap 삭제 + jobRoadmapRepository.findByJob(job).ifPresent(existing -> { + jobRoadmapRepository.delete(existing); + log.info("기존 JobRoadmap 삭제: id={}", existing.getId()); + }); + + List mentorRoadmaps = mentorRoadmapRepository.findAllByMentorJobIdWithNodes(jobId); + + // 저품질 로드맵 필터링 (노드 3개 이하 제외) + List qualityFiltered = mentorRoadmaps.stream() + .filter(mr -> mr.getNodes() != null && mr.getNodes().size() >= 3) + .toList(); + + if (qualityFiltered.isEmpty()) { + throw new ServiceException("404", "해당 직업에 대한 유효한 멘토 로드맵이 존재하지 않습니다. (최소 3개 노드 필요)"); + } + + log.info("멘토 로드맵 필터링: 전체 {}개 → 유효 {}개", mentorRoadmaps.size(), qualityFiltered.size()); + mentorRoadmaps = qualityFiltered; + final int totalMentorCount = mentorRoadmaps.size(); // 통합에 사용될 멘토 로드맵 개수 + + // 집계 자료구조 + Map agg = new HashMap<>(); + Map> transitions = new HashMap<>(); + Map rootCount = new HashMap<>(); + Map> mentorAppearSet = new HashMap<>(); // unique mentors per key + Map> positions = new HashMap<>(); // to compute average position + Map> descs = new HashMap<>(); + + // 집계 루프(모든 멘토 로드맵의 모든 노드 순회하며 필요한 통계 자료 수집) + for (MentorRoadmap mr : mentorRoadmaps) { + // 멘토 로드맵의 노드를 순서대로 정렬 (stepOrder 기준) + List nodes = mr.getNodes().stream() + .sorted(Comparator.comparingInt(RoadmapNode::getStepOrder)) + .toList(); + + if (nodes.isEmpty()) continue; + + // 첫번째 노드를 root 후보로 등록 + RoadmapNode first = nodes.get(0); + String firstKey = generateKey(first); + rootCount.merge(firstKey, 1, Integer::sum); + + // 멘토 ID를 저장 (노드별 mentorAppearSet에 활용) + Long mentorId = mr.getMentor().getId(); + + // 각 노드 집계 + for (int i = 0; i < nodes.size(); i++) { + RoadmapNode rn = nodes.get(i); + String k = generateKey(rn); + + // agg: 노드별 출현 횟수(빈도) + AggregatedNode an = agg.computeIfAbsent(k, kk -> new AggregatedNode(rn.getTask(), rn.getTask() != null ? rn.getTask().getName() : rn.getTaskName())); + an.count += 1; + + // positions: 현재 위치 기록(1부터 시작) + positions.computeIfAbsent(k, kk -> new ArrayList<>()).add(i + 1); + + // metorAppearSet: 고유 멘토 수 + mentorAppearSet.computeIfAbsent(k, kk -> new HashSet<>()).add(mentorId); + + // descs: description 모으기 + if (rn.getDescription() != null && !rn.getDescription().isBlank()) { + descs.computeIfAbsent(k, kk -> new ArrayList<>()).add(rn.getDescription()); + } + + // transitions: 다음 노드로의 이동 기록 (A->B가 몇 번 나왔는지) + if (i < nodes.size() - 1) { + String nextKey = generateKey(nodes.get(i + 1)); + transitions.computeIfAbsent(k, kk -> new HashMap<>()).merge(nextKey, 1, Integer::sum); + } + } + } + + // Task prefetch (통합된 결과를 만들 때 필요한 task들 한번에 로드) + Set taskIds = agg.values().stream() + .map(a -> a.task != null ? a.task.getId() : null) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map taskMap = new HashMap<>(); + if (!taskIds.isEmpty()) taskRepository.findAllById(taskIds).forEach(t -> taskMap.put(t.getId(), t)); + + // RootKey 선택: 가장 많이 루트로 등장한 키를 root로 설정 (동점 시 사전순 tie-breaker) + String rootKey = rootCount.entrySet().stream() + .sorted((e1, e2) -> { + int cmp = Integer.compare(e2.getValue(), e1.getValue()); + if (cmp != 0) return cmp; + return e1.getKey().compareTo(e2.getKey()); // tie-breaker + }) + .map(Map.Entry::getKey) + .findFirst() + .orElseGet(() -> agg.entrySet().stream() + .sorted((e1, e2) -> { + int cmp = Integer.compare(e2.getValue().count, e1.getValue().count); + if (cmp != 0) return cmp; + return e1.getKey().compareTo(e2.getKey()); + }) + .map(Map.Entry::getKey) + .findFirst() + .orElseThrow()); + + log.info("선택된 rootKey={} (빈도={})", rootKey, rootCount.getOrDefault(rootKey, 0)); + + // 노드 생성 (집계된 데이터를 기반으로 RoadmapNode 인스턴스 생성) + Map keyToNode = new HashMap<>(); + for (Map.Entry e : agg.entrySet()) { + AggregatedNode a = e.getValue(); + RoadmapNode node = RoadmapNode.builder() + .taskName(a.displayName) + .description(mergeTopDescriptions(descs.get(e.getKey()))) + .task(a.task != null ? taskMap.get(a.task.getId()) : null) + .stepOrder(0) // assign later + .level(0) // assign later via addChild/setLevel + .roadmapId(0L) + .roadmapType(RoadmapType.JOB) + .build(); + keyToNode.put(e.getKey(), node); + } + + // Transition 빈도 기반 자식 선택 + 부모 우선순위 계산 + // 주의: chosenChildren은 BRANCH_THRESHOLD로 필터링되어 일부 저빈도 전이는 제외됨 + // childToParentCandidates는 모든 전이를 포함 (부모 우선순위 분석용) + // 결과적으로 chosenChildren에 포함되지 않은 노드는 메인 트리에서 제외되며, + // topLevelNodes 처리에서 별도 루트로 추가됨 (의도된 설계: 저빈도 경로는 분리된 트리로 표현) + Map> chosenChildren = new HashMap<>(); + Map> childToParentCandidates = new HashMap<>(); // 자식별 부모 후보 목록 + + for (Map.Entry> e : transitions.entrySet()) { + String parent = e.getKey(); + Map cmap = e.getValue(); + int parentCount = agg.getOrDefault(parent, new AggregatedNode(null, parent)).count; + List> sorted = cmap.entrySet().stream() + .sorted((x, y) -> y.getValue().compareTo(x.getValue())) + .limit(MAX_CHILDREN) + .toList(); + + List chosen = new ArrayList<>(); + for (int i = 0; i < sorted.size(); i++) { + Map.Entry ce = sorted.get(i); + String child = ce.getKey(); + int transitionCount = ce.getValue(); + + if (i == 0) { + chosen.add(child); + } else { + double ratio = parentCount == 0 ? 0.0 : (double) transitionCount / parentCount; + // 현재: 부모 대비 비율만 체크 (샘플 데이터가 적을 때 유용) + if (ratio >= BRANCH_THRESHOLD) chosen.add(child); + + // TODO: 데이터 충분히 확보 후 활성화 - 전체 멘토 대비 비율로 저빈도 노이즈 필터링 + // double globalRatio = (double) transitionCount / totalMentorCount; + // if (ratio >= BRANCH_THRESHOLD && globalRatio >= 0.2) { // 전체 멘토의 20% 이상 + // chosen.add(child); + // } + } + + // 부모 후보 점수 계산 (정규화된 복합 점수) + double transitionScore = (double) transitionCount / totalMentorCount; // 0~1 + + List parentPosList = positions.getOrDefault(parent, Collections.emptyList()); + double avgParentPos = parentPosList.isEmpty() ? 99.0 : parentPosList.stream().mapToInt(Integer::intValue).average().orElse(99.0); + double positionScore = 1.0 / (avgParentPos + 1); // 0~1, 위치가 앞일수록 높은 점수 + + int parentMentorCount = mentorAppearSet.getOrDefault(parent, Collections.emptySet()).size(); + double mentorCoverageScore = (double) parentMentorCount / totalMentorCount; // 0~1 + + double priorityScore = + TRANSITION_WEIGHT * transitionScore + + POSITION_WEIGHT * positionScore + + MENTOR_COVERAGE_WEIGHT * mentorCoverageScore; + + childToParentCandidates.computeIfAbsent(child, k -> new ArrayList<>()) + .add(new ParentCandidate(parent, transitionCount, priorityScore)); + } + if (!chosen.isEmpty()) chosenChildren.put(parent, chosen); + } + + // 각 자식에 대해 부모 후보를 점수순으로 정렬 (최고 점수 부모가 먼저 선택되도록) + Map childToBestParent = new HashMap<>(); + for (Map.Entry> entry : childToParentCandidates.entrySet()) { + String child = entry.getKey(); + List candidates = entry.getValue(); + if (!candidates.isEmpty()) { + // tie-breaker: 동일 점수일 때 parentKey 사전순으로 정렬 (결정성 보장) + candidates.sort((a, b) -> { + int scoreDiff = Double.compare(b.priorityScore, a.priorityScore); + if (scoreDiff == 0) { + return a.parentKey.compareTo(b.parentKey); // tie-breaker + } + return scoreDiff; + }); + childToBestParent.put(child, candidates.get(0).parentKey); + } + } + + log.debug("부모 후보 샘플 (상위 10개): {}", + childToBestParent.entrySet().stream().limit(10).collect(Collectors.toList())); + + // BFS 탐색을 통해 parent-child 연결 생성 -> 메인 트리 구성 + if (!keyToNode.containsKey(rootKey)) { + // fallback root 생성 + AggregatedNode a = agg.get(rootKey); + RoadmapNode rootNode = RoadmapNode.builder() + .taskName(a != null ? a.displayName : "root") + .description(mergeTopDescriptions(descs.get(rootKey))) + .task(a != null ? a.task : null) + .stepOrder(0) + .level(0) + .roadmapId(0L) + .roadmapType(RoadmapType.JOB) + .build(); + keyToNode.put(rootKey, rootNode); + } + RoadmapNode rootNode = keyToNode.get(rootKey); + + // BFS queue + visited 집합 + 대안 부모 추적 + 재시도 큐 + Queue q = new ArrayDeque<>(); + Set visited = new HashSet<>(); + Map> skippedParents = new HashMap<>(); + Queue deferredQueue = new ArrayDeque<>(); + + visited.add(rootKey); + q.add(rootKey); + + // 1차 BFS: 형제 순서대로 stepOrder 지정 (부모 우선순위 고려) + while (!q.isEmpty()) { + String pk = q.poll(); + RoadmapNode parentNode = keyToNode.get(pk); + List childs = chosenChildren.getOrDefault(pk, Collections.emptyList()); + int order = 1; + for (String ck : childs) { + if (visited.contains(ck)) { + // 이미 다른 부모와 연결된 노드 -> 대안 부모로 기록 + recordAsAlternative(pk, ck, transitions, mentorAppearSet, positions, totalMentorCount, skippedParents); + continue; + } + + RoadmapNode childNode = keyToNode.get(ck); + if (childNode == null) continue; + + // 부모 우선순위 체크: 현재 부모가 최적의 부모인지 확인 + String bestParent = childToBestParent.get(ck); + if (bestParent != null && !bestParent.equals(pk)) { + if (!visited.contains(bestParent)) { + // bestParent가 아직 미방문 -> 나중에 재시도 + deferredQueue.add(new DeferredChild(pk, ck, MAX_DEFERRED_RETRY)); + continue; + } + // bestParent가 이미 방문됨 -> 대안으로 기록 + recordAsAlternative(pk, ck, transitions, mentorAppearSet, positions, totalMentorCount, skippedParents); + continue; + } + + // MAX_DEPTH 제한 체크 + if (parentNode.getLevel() + 1 > MAX_DEPTH) { + log.warn("MAX_DEPTH({}) 초과로 노드 추가 중단: parent={}, child={}", MAX_DEPTH, pk, ck); + recordAsAlternative(pk, ck, transitions, mentorAppearSet, positions, totalMentorCount, skippedParents); + continue; + } + + // 연결 수행 + visited.add(ck); + parentNode.addChild(childNode); + childNode.setStepOrder(order++); + q.add(ck); + } + } + + // 2차: Deferred 재시도 (bestParent가 이제 방문 가능한지 확인) + int deferredProcessed = 0; + while (!deferredQueue.isEmpty()) { + DeferredChild dc = deferredQueue.poll(); + if (visited.contains(dc.childKey)) continue; // 이미 연결됨 + + String bestParent = childToBestParent.get(dc.childKey); + if (bestParent != null && visited.contains(bestParent)) { + // bestParent가 이제 방문됨 -> 연결 가능 + RoadmapNode bestParentNode = keyToNode.get(bestParent); + RoadmapNode childNode = keyToNode.get(dc.childKey); + + if (bestParentNode != null && childNode != null && bestParentNode.getLevel() + 1 <= MAX_DEPTH) { + visited.add(dc.childKey); + bestParentNode.addChild(childNode); + int childCount = bestParentNode.getChildren().size(); + childNode.setStepOrder(childCount); + deferredProcessed++; + log.debug("Deferred 연결 성공: {} -> {}", bestParent, dc.childKey); + } else { + // Depth 초과 등 -> 대안 기록 + recordAsAlternative(dc.parentKey, dc.childKey, transitions, mentorAppearSet, positions, totalMentorCount, skippedParents); + } + } else { + // 여전히 미방문 -> 재시도 또는 fallback + if (dc.retryCount > 0) { + deferredQueue.add(new DeferredChild(dc.parentKey, dc.childKey, dc.retryCount - 1)); + } else { + // 최종 fallback: 원래 부모와 연결 시도 + RoadmapNode fallbackParentNode = keyToNode.get(dc.parentKey); + RoadmapNode childNode = keyToNode.get(dc.childKey); + + if (fallbackParentNode != null && childNode != null && + fallbackParentNode.getLevel() + 1 <= MAX_DEPTH && !visited.contains(dc.childKey)) { + visited.add(dc.childKey); + fallbackParentNode.addChild(childNode); + int childCount = fallbackParentNode.getChildren().size(); + childNode.setStepOrder(childCount); + log.debug("Fallback 연결: {} -> {}", dc.parentKey, dc.childKey); + } else { + // 최종 실패 -> 대안 기록 + recordAsAlternative(dc.parentKey, dc.childKey, transitions, mentorAppearSet, positions, totalMentorCount, skippedParents); + } + } + } + } + + log.info("Deferred 재시도 완료: {}개 노드 연결", deferredProcessed); + + // 메인 루트 설정 (단일 루트만 허용) + RoadmapNode mainRoot = keyToNode.get(rootKey); + if (mainRoot != null) { + mainRoot.setStepOrder(1); + mainRoot.setLevel(0); + } + + // 고아 노드(visited되지 않은 노드) 로그 기록 + long orphanCount = keyToNode.values().stream() + .filter(n -> !visited.contains(generateKey(n))) + .count(); + + if (orphanCount > 0) { + log.info("=== 제외된 고아 노드 ==="); + keyToNode.entrySet().stream() + .filter(e -> !visited.contains(e.getKey())) + .forEach(e -> { + String key = e.getKey(); + AggregatedNode aggNode = agg.get(key); + int count = aggNode != null ? aggNode.count : 0; + int mentorCount = mentorAppearSet.getOrDefault(key, Collections.emptySet()).size(); + log.info(" - 키: {}, 이름: {}, 출현빈도: {}회, 멘토수: {}명", + key, e.getValue().getTaskName(), count, mentorCount); + }); + log.info("총 {}개의 저빈도 노드가 메인 트리에서 제외되었습니다.", orphanCount); + } + + // JobRoadmap 생성 + JobRoadmap jobRoadmap = JobRoadmap.builder().job(job).build(); + + // visited된 노드만 포함 (고아 노드 제외) + List allNodes = keyToNode.values().stream() + .filter(n -> visited.contains(generateKey(n))) // 메인 트리에 연결된 노드만 + .sorted(Comparator.comparingInt(RoadmapNode::getLevel).thenComparingInt(RoadmapNode::getStepOrder)) + .toList(); + + // jobRoadmap 저장해 ID 받아옴 + jobRoadmap = jobRoadmapRepository.save(jobRoadmap); + Long roadmapId = jobRoadmap.getId(); + + // 모든 노드에 roadmapId, roadmapType 설정 후 JobRoadmap의 노드로 추가 + for (RoadmapNode n : allNodes) { + n.setRoadmapId(roadmapId); + n.setRoadmapType(RoadmapType.JOB); + jobRoadmap.getNodes().add(n); + } + + JobRoadmap saved = jobRoadmapRepository.save(jobRoadmap); + + // 노드별 통계값 저장 + List stats = new ArrayList<>(); + for (RoadmapNode persisted : saved.getNodes()) { + String k = generateKey(persisted); + AggregatedNode a = agg.get(k); + JobRoadmapNodeStat stat = new JobRoadmapNodeStat(); + stat.setNode(persisted); + stat.setStepOrder(persisted.getStepOrder()); + + // 해당 키를 선택한 멘토 수 + int mentorCount = mentorAppearSet.getOrDefault(k, Collections.emptySet()).size(); + stat.setMentorCount(mentorCount); + + // 평균 단계 위치 + List posList = positions.getOrDefault(k, Collections.emptyList()); + Double avgPos = posList.isEmpty() ? null : posList.stream().mapToInt(Integer::intValue).average().orElse(0.0); + stat.setAveragePosition(avgPos); + + // Weight 계산: priorityScore와 동일한 복합 가중치 사용 + // (등장빈도, 멘토커버리지, 평균위치, 연결성) + double frequencyScore = a == null ? 0.0 : (double) a.count / (double) totalMentorCount; // 0~1 + double mentorCoverageScore = (double) mentorCount / (double) totalMentorCount; // 0~1 + double positionScore = avgPos != null ? 1.0 / (avgPos + 1) : 0.0; // 0~1, 위치가 앞일수록 높은 점수 + + // 연결성 점수: outgoing + incoming transitions (정규화) + int outgoing = transitions.getOrDefault(k, Collections.emptyMap()).values().stream().mapToInt(Integer::intValue).sum(); + int incoming = transitions.entrySet().stream() + .mapToInt(e -> e.getValue().getOrDefault(k, 0)).sum(); + int totalTransitions = outgoing + incoming; + double connectivityScore = totalTransitions > 0 ? Math.min(1.0, (double) totalTransitions / (totalMentorCount * 2)) : 0.0; // 0~1 + + double weight = + 0.4 * frequencyScore + + 0.3 * mentorCoverageScore + + 0.2 * positionScore + + 0.1 * connectivityScore; + + // 방어적 코딩: 0~1 범위로 클램프 + weight = Math.max(0.0, Math.min(1.0, weight)); + + stat.setWeight(weight); + stat.setTotalMentorCount(totalMentorCount); + stat.setMentorCoverageRatio(mentorCoverageScore); // 0.0 ~ 1.0 + stat.setOutgoingTransitions(outgoing); + stat.setIncomingTransitions(incoming); + + // 다음으로 이어지는 노드들의 분포 (Ut.json 사용) + Map outMap = transitions.getOrDefault(k, Collections.emptyMap()); + if (!outMap.isEmpty()) { + String json = Ut.json.toString(outMap); + if (json != null) { + stat.setTransitionCounts(json); + } + } + + // 대안 부모 정보 저장 (메타정보 포함, Ut.json 사용) + List altParents = skippedParents.get(k); + if (altParents != null && !altParents.isEmpty()) { + String json = Ut.json.toString(altParents); + if (json != null) { + stat.setAlternativeParents(json); + } + } + + stats.add(stat); + } + + jobRoadmapNodeStatRepository.saveAll(stats); + log.info("JobRoadmap 생성 완료: id={}, 노드={}개, 통계={}개, 대안 부모 기록={}개", + saved.getId(), saved.getNodes().size(), stats.size(), skippedParents.size()); + + return saved; + } + + // ---------------- 헬퍼 클래스&메서드 ---------------- + + private static class AggregatedNode { + Task task; + String displayName; + int count = 0; + AggregatedNode(Task task, String displayName) { this.task = task; this.displayName = displayName; } + } + + private static class AlternativeParentInfo { + public String parentKey; // 부모 노드 키 (T:1, N:kotlin 등) + public int transitionCount; // 전이 빈도 + public int mentorCount; // 해당 부모를 사용한 멘토 수 + public double score; // 가중치 점수 + + public AlternativeParentInfo(String parentKey, int transitionCount, int mentorCount, double score) { + this.parentKey = parentKey; + this.transitionCount = transitionCount; + this.mentorCount = mentorCount; + this.score = score; + } + } + + private static class ParentCandidate { + String parentKey; + int transitionCount; + double priorityScore; + + public ParentCandidate(String parentKey, int transitionCount, double priorityScore) { + this.parentKey = parentKey; + this.transitionCount = transitionCount; + this.priorityScore = priorityScore; + } + } + + private static class DeferredChild { + String parentKey; + String childKey; + int retryCount; + + public DeferredChild(String parentKey, String childKey, int retryCount) { + this.parentKey = parentKey; + this.childKey = childKey; + this.retryCount = retryCount; + } + } + + private void recordAsAlternative(String parentKey, String childKey, + Map> transitions, + Map> mentorAppearSet, + Map> positions, + int totalMentorCount, + Map> skippedParents) { + int transitionCount = transitions.getOrDefault(parentKey, Collections.emptyMap()).getOrDefault(childKey, 0); + int parentMentorCount = mentorAppearSet.getOrDefault(parentKey, Collections.emptySet()).size(); + + // priorityScore와 동일한 복합 점수 계산 (일관성 보장) + double transitionScore = (double) transitionCount / totalMentorCount; + + List parentPosList = positions.getOrDefault(parentKey, Collections.emptyList()); + double avgParentPos = parentPosList.isEmpty() ? 99.0 + : parentPosList.stream().mapToInt(Integer::intValue).average().orElse(99.0); + double positionScore = 1.0 / (avgParentPos + 1); + + double mentorCoverageScore = (double) parentMentorCount / totalMentorCount; + + double score = + TRANSITION_WEIGHT * transitionScore + + POSITION_WEIGHT * positionScore + + MENTOR_COVERAGE_WEIGHT * mentorCoverageScore; + + AlternativeParentInfo info = new AlternativeParentInfo(parentKey, transitionCount, parentMentorCount, score); + skippedParents.computeIfAbsent(childKey, k -> new ArrayList<>()).add(info); + } + + private String mergeTopDescriptions(List list) { + if (list == null || list.isEmpty()) return null; + LinkedHashSet set = new LinkedHashSet<>(); + for (String s : list) { + if (s == null) continue; + String c = s.trim(); + if (!c.isEmpty()) set.add(c); + if (set.size() >= 3) break; + } + return String.join("\n\n", set); + } + + // RoadmapNode를 고유한 키로 변환 + private String generateKey(RoadmapNode rn) { + // Task가 연결된 경우: "T:{taskId}" + if (rn.getTask() != null) { + return "T:" + rn.getTask().getId(); + } + + //Task 없는 경우: "N:{taskName}" (소문자, 공백 정규화) + String name = rn.getTaskName(); + + // 빈 taskName 방어: "N:__unknown__" + if (name == null) return "N:__unknown__"; + name = name.trim().toLowerCase(); + if (name.isEmpty()) return "N:__unknown__"; + return "N:" + name.replaceAll("\\s+", " "); + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationWithAiService.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationWithAiService.java new file mode 100644 index 00000000..cc30815d --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationWithAiService.java @@ -0,0 +1,4 @@ +package com.back.domain.roadmap.roadmap.service; + +public class JobRoadmapIntegrationWithAiService { +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapService.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapService.java index 6dfe4e08..cf7fbc35 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapService.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapService.java @@ -3,6 +3,8 @@ import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapListResponse; import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapResponse; import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapNodeStatRepository; import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; import com.back.global.exception.ServiceException; import lombok.RequiredArgsConstructor; @@ -14,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -21,6 +25,7 @@ @Transactional(readOnly = true) public class JobRoadmapService { private final JobRoadmapRepository jobRoadmapRepository; + private final JobRoadmapNodeStatRepository jobRoadmapNodeStatRepository; public List getAllJobRoadmaps() { return jobRoadmapRepository.findAllWithJob() @@ -40,7 +45,12 @@ public JobRoadmapResponse getJobRoadmapById(Long id) { JobRoadmap jobRoadmap = jobRoadmapRepository.findByIdWithJobAndNodes(id) .orElseThrow(() -> new ServiceException("404", "직업 로드맵을 찾을 수 없습니다.")); - return JobRoadmapResponse.from(jobRoadmap, jobRoadmap.getJob().getName()); + // 통계 정보 조회 및 Map 생성 (nodeId -> JobRoadmapNodeStat) + List stats = jobRoadmapNodeStatRepository.findByNode_RoadmapIdWithNode(id); + Map statMap = stats.stream() + .collect(Collectors.toMap(stat -> stat.getNode().getId(), stat -> stat)); + + return JobRoadmapResponse.from(jobRoadmap, jobRoadmap.getJob().getName(), statMap); } private JobRoadmapListResponse toListResponse(JobRoadmap jobRoadmap) { diff --git a/back/src/main/java/com/back/global/initData/RoadmapInitData.java b/back/src/main/java/com/back/global/initData/RoadmapInitData.java index 97bc52af..bd96af19 100644 --- a/back/src/main/java/com/back/global/initData/RoadmapInitData.java +++ b/back/src/main/java/com/back/global/initData/RoadmapInitData.java @@ -13,18 +13,22 @@ import com.back.domain.roadmap.roadmap.entity.RoadmapNode; import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; import com.back.domain.roadmap.roadmap.repository.MentorRoadmapRepository; +import com.back.domain.roadmap.roadmap.service.JobRoadmapIntegrationService; import com.back.domain.roadmap.roadmap.service.MentorRoadmapService; import com.back.domain.roadmap.task.entity.Task; import com.back.domain.roadmap.task.repository.TaskRepository; import com.back.domain.roadmap.task.service.TaskService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.ApplicationRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.transaction.annotation.Transactional; +import java.util.Comparator; import java.util.List; +@Slf4j @Configuration @RequiredArgsConstructor @Transactional @@ -38,6 +42,7 @@ public class RoadmapInitData { private final MentorRoadmapService mentorRoadmapService; private final MentorRoadmapRepository mentorRoadmapRepository; private final JobRoadmapRepository jobRoadmapRepository; + private final JobRoadmapIntegrationService jobRoadmapIntegrationService; @Bean ApplicationRunner baseInitDataApplicationRunner2() { @@ -50,6 +55,10 @@ public void runInitData() { initTaskData(); // 보강된 Task 목록 //initSampleMentorRoadmaps(); // 활성화: 다양한 멘토 로드맵 생성 //initSampleJobRoadmap(); // 직업 로드맵 조회 API 테스트용 샘플 데이터 + + // 통합 로직 테스트 + //initTestMentorRoadmaps(); // 테스트용 멘토 로드맵 10개 생성 + //testJobRoadmapIntegration(); // 통합 로직 실행 및 트리 구조 출력 } // --- Job 초기화 --- @@ -669,4 +678,366 @@ public void initSampleJobRoadmap() { jobRoadmapRepository.save(jobRoadmap); jobRoadmapRepository.save(frontendRoadmap); // 빈 로드맵 저장 } + + // --- 통합 로직 테스트용 멘토 로드맵 10개 생성 --- + public void initTestMentorRoadmaps() { + if (mentorRoadmapRepository.count() > 0) { + log.info("멘토 로드맵이 이미 존재합니다. 테스트 로드맵 생성을 건너뜁니다."); + return; + } + + Job backendJob = jobRepository.findByName("백엔드 개발자") + .orElseThrow(() -> new RuntimeException("백엔드 개발자 직업을 찾을 수 없습니다.")); + + log.info("=== 통합 테스트용 멘토 로드맵 10개 생성 시작 ==="); + + // Java 경로 5개 + createTestJavaRoadmap1(backendJob); + createTestJavaRoadmap2(backendJob); + createTestJavaRoadmap3(backendJob); + createTestJavaRoadmap4(backendJob); + createTestJavaRoadmap5(backendJob); + + // JavaScript 경로 3개 + createTestJavaScriptRoadmap1(backendJob); + createTestJavaScriptRoadmap2(backendJob); + createTestJavaScriptRoadmap3(backendJob); + + // Python 경로 2개 + createTestPythonRoadmap1(backendJob); + createTestPythonRoadmap2(backendJob); + + log.info("=== 통합 테스트용 멘토 로드맵 10개 생성 완료 ==="); + } + + // Java 경로 로드맵들 + private void createTestJavaRoadmap1(Job backendJob) { + Member member = memberService.joinMentor("test.java1@test.com", "자바멘토1", "javamentor1", "1234", "백엔드 개발자", 3); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("Java", 0, 4, "자바 언어 기초"), + createNodeRequest("Spring", 0, 5, "스프링 프레임워크"), + createNodeRequest("Spring Boot", 0, 6, "스프링 부트"), + createNodeRequest("MySQL", 0, 7, "MySQL 데이터베이스"), + createNodeRequest("JPA", 0, 8, "JPA ORM"), + createNodeRequest("Docker", 0, 9, "도커 컨테이너"), + createNodeRequest("AWS", 0, 10, "AWS 클라우드") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 자바 로드맵 1", + "Java -> Spring -> Spring Boot -> MySQL -> JPA -> Docker -> AWS", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("자바 로드맵 1 생성 완료"); + } + + private void createTestJavaRoadmap2(Job backendJob) { + Member member = memberService.joinMentor("test.java2@test.com", "자바멘토2", "javamentor2", "1234", "백엔드 개발자", 4); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("Java", 0, 4, "자바 언어 기초"), + createNodeRequest("Spring", 0, 5, "스프링 프레임워크"), + createNodeRequest("Spring Boot", 0, 6, "스프링 부트"), + createNodeRequest("PostgreSQL", 0, 7, "PostgreSQL 데이터베이스"), + createNodeRequest("Redis", 0, 8, "Redis 캐싱"), + createNodeRequest("Kubernetes", 0, 9, "쿠버네티스"), + createNodeRequest("Jenkins", 0, 10, "젠킨스 CI/CD") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 자바 로드맵 2", + "Java -> Spring -> Spring Boot -> PostgreSQL -> Redis -> Kubernetes -> Jenkins", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("자바 로드맵 2 생성 완료"); + } + + private void createTestJavaRoadmap3(Job backendJob) { + Member member = memberService.joinMentor("test.java3@test.com", "자바멘토3", "javamentor3", "1234", "백엔드 개발자", 5); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("Java", 0, 4, "자바 언어 기초"), + createNodeRequest("Spring", 0, 5, "스프링 프레임워크"), + createNodeRequest("Spring Boot", 0, 6, "스프링 부트"), + createNodeRequest("MySQL", 0, 7, "MySQL 데이터베이스"), + createNodeRequest("Docker", 0, 8, "도커 컨테이너"), + createNodeRequest("Monitoring", 0, 9, "모니터링"), + createNodeRequest("Logging", 0, 10, "로깅") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 자바 로드맵 3", + "Java -> Spring -> Spring Boot -> MySQL -> Docker -> Monitoring -> Logging", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("자바 로드맵 3 생성 완료"); + } + + private void createTestJavaRoadmap4(Job backendJob) { + Member member = memberService.joinMentor("test.java4@test.com", "자바멘토4", "javamentor4", "1234", "백엔드 개발자", 6); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("Java", 0, 4, "자바 언어 기초"), + createNodeRequest("Spring", 0, 5, "스프링 프레임워크"), + createNodeRequest("Spring Boot", 0, 6, "스프링 부트"), + createNodeRequest("PostgreSQL", 0, 7, "PostgreSQL 데이터베이스"), + createNodeRequest("JPA", 0, 8, "JPA ORM"), + createNodeRequest("Spring Security", 0, 9, "스프링 시큐리티"), + createNodeRequest("Testing", 0, 10, "테스팅") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 자바 로드맵 4", + "Java -> Spring -> Spring Boot -> PostgreSQL -> JPA -> Spring Security -> Testing", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("자바 로드맵 4 생성 완료"); + } + + private void createTestJavaRoadmap5(Job backendJob) { + Member member = memberService.joinMentor("test.java5@test.com", "자바멘토5", "javamentor5", "1234", "백엔드 개발자", 4); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("Java", 0, 4, "자바 언어 기초"), + createNodeRequest("Spring", 0, 5, "스프링 프레임워크"), + createNodeRequest("Spring Boot", 0, 6, "스프링 부트"), + createNodeRequest("MySQL", 0, 7, "MySQL 데이터베이스"), + createNodeRequest("Redis", 0, 8, "Redis 캐싱"), + createNodeRequest("Docker", 0, 9, "도커 컨테이너"), + createNodeRequest("CI/CD", 0, 10, "CI/CD 파이프라인") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 자바 로드맵 5", + "Java -> Spring -> Spring Boot -> MySQL -> Redis -> Docker -> CI/CD", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("자바 로드맵 5 생성 완료"); + } + + // JavaScript 경로 로드맵들 + private void createTestJavaScriptRoadmap1(Job backendJob) { + Member member = memberService.joinMentor("test.js1@test.com", "JS멘토1", "jsmentor1", "1234", "백엔드 개발자", 3); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("JavaScript", 0, 4, "자바스크립트 언어"), + createNodeRequest("Node.js", 0, 5, "Node.js 런타임"), + createNodeRequest("Express.js", 0, 6, "Express.js 프레임워크"), + createNodeRequest("MongoDB", 0, 7, "MongoDB 데이터베이스"), + createNodeRequest("Docker", 0, 8, "도커 컨테이너"), + createNodeRequest("AWS", 0, 9, "AWS 클라우드"), + createNodeRequest("Nginx", 0, 10, "Nginx 웹서버") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 JS 로드맵 1", + "JavaScript -> Node.js -> Express -> MongoDB -> Docker -> AWS -> Nginx", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("JavaScript 로드맵 1 생성 완료"); + } + + private void createTestJavaScriptRoadmap2(Job backendJob) { + Member member = memberService.joinMentor("test.js2@test.com", "JS멘토2", "jsmentor2", "1234", "백엔드 개발자", 4); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("JavaScript", 0, 4, "자바스크립트 언어"), + createNodeRequest("Node.js", 0, 5, "Node.js 런타임"), + createNodeRequest("Express.js", 0, 6, "Express.js 프레임워크"), + createNodeRequest("MongoDB", 0, 7, "MongoDB 데이터베이스"), + createNodeRequest("Redis", 0, 8, "Redis 캐싱"), + createNodeRequest("Docker", 0, 9, "도커 컨테이너"), + createNodeRequest("Kubernetes", 0, 10, "쿠버네티스") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 JS 로드맵 2", + "JavaScript -> Node.js -> Express -> MongoDB -> Redis -> Docker -> Kubernetes", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("JavaScript 로드맵 2 생성 완료"); + } + + private void createTestJavaScriptRoadmap3(Job backendJob) { + Member member = memberService.joinMentor("test.js3@test.com", "JS멘토3", "jsmentor3", "1234", "백엔드 개발자", 2); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("JavaScript", 0, 4, "자바스크립트 언어"), + createNodeRequest("Node.js", 0, 5, "Node.js 런타임"), + createNodeRequest("Express.js", 0, 6, "Express.js 프레임워크"), + createNodeRequest("PostgreSQL", 0, 7, "PostgreSQL 데이터베이스"), + createNodeRequest("Testing", 0, 8, "테스팅"), + createNodeRequest("Monitoring", 0, 9, "모니터링"), + createNodeRequest("Logging", 0, 10, "로깅") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 JS 로드맵 3", + "JavaScript -> Node.js -> Express -> PostgreSQL -> Testing -> Monitoring -> Logging", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("JavaScript 로드맵 3 생성 완료"); + } + + // Python 경로 로드맵들 + private void createTestPythonRoadmap1(Job backendJob) { + Member member = memberService.joinMentor("test.py1@test.com", "파이썬멘토1", "pymentor1", "1234", "백엔드 개발자", 3); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("Python", 0, 4, "파이썬 언어"), + createNodeRequest("Django", 0, 5, "Django 프레임워크"), + createNodeRequest("PostgreSQL", 0, 6, "PostgreSQL 데이터베이스"), + createNodeRequest("Redis", 0, 7, "Redis 캐싱"), + createNodeRequest("Docker", 0, 8, "도커 컨테이너"), + createNodeRequest("AWS", 0, 9, "AWS 클라우드"), + createNodeRequest("Monitoring", 0, 10, "모니터링") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 파이썬 로드맵 1", + "Python -> Django -> PostgreSQL -> Redis -> Docker -> AWS -> Monitoring", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("Python 로드맵 1 생성 완료"); + } + + private void createTestPythonRoadmap2(Job backendJob) { + Member member = memberService.joinMentor("test.py2@test.com", "파이썬멘토2", "pymentor2", "1234", "백엔드 개발자", 4); + Mentor mentor = updateMentorJob(member, backendJob); + + List nodes = List.of( + createNodeRequest("Programming Fundamentals", 0, 1, "프로그래밍 기초"), + createNodeRequest("Git", 0, 2, "버전 관리"), + createNodeRequest("HTTP", 0, 3, "HTTP 프로토콜"), + createNodeRequest("Python", 0, 4, "파이썬 언어"), + createNodeRequest("Django", 0, 5, "Django 프레임워크"), + createNodeRequest("MySQL", 0, 6, "MySQL 데이터베이스"), + createNodeRequest("Docker", 0, 7, "도커 컨테이너"), + createNodeRequest("Kubernetes", 0, 8, "쿠버네티스"), + createNodeRequest("Jenkins", 0, 9, "젠킨스 CI/CD"), + createNodeRequest("Logging", 0, 10, "로깅") + ); + + MentorRoadmapSaveRequest request = new MentorRoadmapSaveRequest( + "테스트 파이썬 로드맵 2", + "Python -> Django -> MySQL -> Docker -> Kubernetes -> Jenkins -> Logging", + nodes + ); + + mentorRoadmapService.create(mentor.getId(), request); + log.info("Python 로드맵 2 생성 완료"); + } + + // --- 통합 로직 테스트 및 트리 구조 출력 --- + public void testJobRoadmapIntegration() { + Job backendJob = jobRepository.findByName("백엔드 개발자") + .orElseThrow(() -> new RuntimeException("백엔드 개발자 직업을 찾을 수 없습니다.")); + + log.info("\n\n=== 직업 로드맵 통합 시작 ==="); + JobRoadmap integratedRoadmap = jobRoadmapIntegrationService.integrateJobRoadmap(backendJob.getId()); + log.info("=== 직업 로드맵 통합 완료 ===\n"); + + log.info("\n\n=== 통합된 직업 로드맵 트리 구조 출력 ==="); + printJobRoadmapTree(integratedRoadmap); + log.info("=== 트리 구조 출력 완료 ===\n\n"); + } + + // --- 트리 구조 출력 헬퍼 메서드 --- + private void printJobRoadmapTree(JobRoadmap jobRoadmap) { + log.info("직업 로드맵 ID: {}", jobRoadmap.getId()); + log.info("직업: {}", jobRoadmap.getJob().getName()); + log.info("총 노드 수: {}", jobRoadmap.getNodes().size()); + log.info("\n트리 구조:"); + + // 루트 노드들 찾기 (parent가 null인 노드들) + List rootNodes = jobRoadmap.getNodes().stream() + .filter(node -> node.getParent() == null) + .sorted(Comparator.comparingInt(RoadmapNode::getStepOrder)) + .toList(); + + log.info("루트 노드 수: {}", rootNodes.size()); + + for (RoadmapNode rootNode : rootNodes) { + printNodeRecursive(rootNode, "", true); + } + } + + private void printNodeRecursive(RoadmapNode node, String prefix, boolean isLast) { + // 현재 노드 출력 + String connector = isLast ? "└── " : "├── "; + String nodeInfo = String.format("%s (level=%d, stepOrder=%d, id=%d)", + node.getTaskName(), + node.getLevel(), + node.getStepOrder(), + node.getId()); + + log.info("{}{}{}", prefix, connector, nodeInfo); + + // 자식 노드들 정렬 (stepOrder 기준) + List children = node.getChildren().stream() + .sorted(Comparator.comparingInt(RoadmapNode::getStepOrder)) + .toList(); + + // 자식 노드들 재귀 출력 + String childPrefix = prefix + (isLast ? " " : "│ "); + for (int i = 0; i < children.size(); i++) { + boolean isLastChild = (i == children.size() - 1); + printNodeRecursive(children.get(i), childPrefix, isLastChild); + } + } }