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
2 changes: 2 additions & 0 deletions back/src/main/java/com/back/BackApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
public class BackApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.back.domain.roadmap.roadmap.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Table(name= "job_roadmap_integration_queue")
@NoArgsConstructor
public class JobRoadmapIntegrationQueue {
@Id
@Column(name = "job_id")
private Long jobId;

@Column(name = "requested_at", nullable = false)
private LocalDateTime requestedAt;

@Column(name = "retry_count", nullable = false)
private Integer retryCount = 0;

@Version
@Column(name = "version")
private Long version = 0L;

public JobRoadmapIntegrationQueue(Long jobId) {
this.jobId = jobId;
this.requestedAt = LocalDateTime.now();
this.retryCount = 0;
}

public void updateRequestedAt() {
this.requestedAt = LocalDateTime.now();
}

public void incrementRetryCount() {
this.retryCount += 1;
}

public boolean isMaxRetryExceeded(int maxRetry) {
return this.retryCount >= maxRetry;
}

public Long getJobId() {
return jobId;
}

public Integer getRetryCount() {
return retryCount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,53 +47,29 @@ public class JobRoadmapNodeStat extends BaseEntity {
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;
public JobRoadmapNodeStat(
RoadmapNode node,
Integer stepOrder,
Double weight,
Double averagePosition,
Integer mentorCount,
Integer totalMentorCount,
Double mentorCoverageRatio,
Integer outgoingTransitions,
Integer incomingTransitions,
String transitionCounts,
String alternativeParents
) {
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.weight = weight != null ? weight : 0.0;
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ public RoadmapNode(String taskName, String learningAdvice, String recommendedRes
}


// ========== 도메인 메서드 (Public) ==========

/**
* 자식 노드를 추가하고 부모-자식 관계를 설정합니다.
* 자식의 level은 부모 level + 1로 자동 설정됩니다.
*/
public void addChild(RoadmapNode child) {
if (child == null) {
throw new IllegalArgumentException("자식 노드는 null일 수 없습니다.");
Expand All @@ -106,34 +112,54 @@ public void addChild(RoadmapNode child) {
}
this.children.add(child);
child.setParent(this);
child.setLevel(this.level + 1); // 부모 level + 1로 자동 설정
child.setLevel(this.level + 1);
}

public void setParent(RoadmapNode parent) {
this.parent = parent;
/**
* 이 노드를 특정 로드맵에 할당합니다.
* JobRoadmap 또는 MentorRoadmap 저장 후 ID를 받은 시점에 호출됩니다.
*
* @param roadmapId 로드맵 ID
* @param roadmapType 로드맵 타입 (JOB 또는 MENTOR)
*/
public void assignToRoadmap(Long roadmapId, RoadmapType roadmapType) {
if (roadmapId == null) {
throw new IllegalArgumentException("roadmapId는 null일 수 없습니다.");
}
if (roadmapType == null) {
throw new IllegalArgumentException("roadmapType은 null일 수 없습니다.");
}
this.roadmapId = roadmapId;
this.roadmapType = roadmapType;
}

public void setLevel(int level) {
this.level = level;
/**
* 이 노드를 루트 노드로 초기화합니다.
* level=0, stepOrder=1로 설정됩니다.
* 직업 로드맵 통합 알고리즘에서 메인 루트 설정 시 사용됩니다.
*/
public void initializeAsRoot() {
this.level = 0;
this.stepOrder = 1;
}

public void setStepOrder(int stepOrder) {
this.stepOrder = stepOrder;
/**
* 형제 노드들 사이에서의 순서를 할당합니다.
* BFS 트리 구성 시 부모의 자식들 중 몇 번째인지 설정하는 데 사용됩니다.
*
* @param order 형제 노드 중 순서 (1부터 시작)
*/
public void assignOrderInSiblings(int order) {
this.stepOrder = order;
}

public void setRoadmapId(Long roadmapId) {
this.roadmapId = roadmapId;
}

public void setRoadmapType(RoadmapType roadmapType) {
this.roadmapType = roadmapType;
}
// ========== Package-private 메서드 (같은 패키지에서만 접근) ==========

public void setTask(Task task) {
this.task = task;
void setParent(RoadmapNode parent) {
this.parent = parent;
}

public void setTaskName(String taskName) {
this.taskName = taskName;
void setLevel(int level) {
this.level = level;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.back.domain.roadmap.roadmap.event;

public class MentorRoadmapChangeEvent {
private final Long jobId;

public MentorRoadmapChangeEvent(Long jobId) {
this.jobId = jobId;
}

public Long getJobId() {
return jobId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.back.domain.roadmap.roadmap.repository;

import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface JobRoadmapIntegrationQueueRepository extends JpaRepository<JobRoadmapIntegrationQueue, Long> {
@Query("SELECT q FROM JobRoadmapIntegrationQueue q ORDER BY q.requestedAt ASC")
List<JobRoadmapIntegrationQueue> findAllOrderByRequestedAt();
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat;
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;

Expand All @@ -15,4 +16,8 @@ public interface JobRoadmapNodeStatRepository extends JpaRepository<JobRoadmapNo
WHERE n.roadmapId = :roadmapId
""")
List<JobRoadmapNodeStat> findByNode_RoadmapIdWithNode(@Param("roadmapId") Long roadmapId);

@Modifying
@Query("DELETE FROM JobRoadmapNodeStat s WHERE s.node.id IN :nodeIds")
void deleteByNodeIdIn(@Param("nodeIds") List<Long> nodeIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,36 @@ void deleteByRoadmapIdAndRoadmapType(
@Param("roadmapType") RoadmapNode.RoadmapType roadmapType
);

@Query("SELECT MAX(n.level) FROM RoadmapNode n " +
"WHERE n.roadmapId = :roadmapId AND n.roadmapType = :roadmapType")
Integer findMaxLevelByRoadmapIdAndRoadmapType(
@Param("roadmapId") Long roadmapId,
@Param("roadmapType") RoadmapNode.RoadmapType roadmapType);

@Modifying
@Query("DELETE FROM RoadmapNode n " +
"WHERE n.roadmapId = :roadmapId " +
"AND n.roadmapType = :roadmapType " +
"AND n.level = :level")
void deleteByRoadmapIdAndRoadmapTypeAndLevel(
@Param("roadmapId") Long roadmapId,
@Param("roadmapType") RoadmapNode.RoadmapType roadmapType,
@Param("level") int level);

// 부모-자식 구조를 가진 엔티티를 삭제하기 위해 자식부터 순서대로 삭제(PQL 2단계 방식)
@Modifying
@Query("DELETE FROM RoadmapNode r WHERE r.parent IS NOT NULL AND r.roadmapId = :roadmapId AND r.roadmapType = :roadmapType")
void deleteChildren(@Param("roadmapId") Long roadmapId, @Param("roadmapType") RoadmapNode.RoadmapType roadmapType);

@Modifying
@Query("DELETE FROM RoadmapNode r WHERE r.parent IS NULL AND r.roadmapId = :roadmapId AND r.roadmapType = :roadmapType")
void deleteParents(@Param("roadmapId") Long roadmapId, @Param("roadmapType") RoadmapNode.RoadmapType roadmapType);


@Query("SELECT n.id FROM RoadmapNode n WHERE n.roadmapId = :roadmapId AND n.roadmapType = :roadmapType")
List<Long> findIdsByRoadmapIdAndRoadmapType(@Param("roadmapId") Long roadmapId,
@Param("roadmapType") RoadmapNode.RoadmapType roadmapType);

// 조회용 메서드 (성능 최적화용)
List<RoadmapNode> findByRoadmapIdAndRoadmapTypeOrderByStepOrder(
Long roadmapId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.back.domain.roadmap.roadmap.service;

import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue;
import com.back.domain.roadmap.roadmap.repository.JobRoadmapIntegrationQueueRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@RequiredArgsConstructor
@Slf4j
public class JobRoadmapBatchIntegrator {
private final JobRoadmapIntegrationQueueRepository queueRepository;
private final JobRoadmapIntegrationProcessor processor;
private static final int MAX_RETRY = 3;

@Scheduled(fixedDelay = 120000) // 2분
public void integrate() {
List<JobRoadmapIntegrationQueue> pendingQueues = queueRepository.findAllOrderByRequestedAt();

if(pendingQueues.isEmpty()) {
log.debug("처리할 큐가 없습니다.");
return;
}

log.info("직업 로드맵 배치 통합 시작: {}개 직업", pendingQueues.size());

int successCount = 0;
int conflictCount = 0;

for(JobRoadmapIntegrationQueue queue : pendingQueues) {
try {
processor.processQueue(queue);
successCount++;

} catch (ObjectOptimisticLockingFailureException e) {
// 낙관적 락 충돌: 다른 트랜잭션이 큐를 수정함 (정상 동작)
conflictCount++;
log.info("버전 충돌 발생 (정상): jobId={}, 다음 주기에 재처리",
queue.getJobId());

} catch (Exception e) {
// 실제 에러: 통합 로직 실패 등
log.error("직업 로드맵 통합 실패: jobId={}, error={}",
queue.getJobId(), e.getMessage());

try {
processor.handleRetry(queue, MAX_RETRY);
} catch (Exception retryError) {
log.error("재시도 처리 실패: jobId={}, error={}",
queue.getJobId(), retryError.getMessage());
}
}
}

int failureCount = pendingQueues.size() - successCount - conflictCount;
log.info("직업 로드맵 배치 통합 완료: 성공 {}, 충돌 {}, 실패 {}, 총 {}개",
successCount, conflictCount, failureCount, pendingQueues.size());
}
}
Loading