Skip to content

Commit 7f6ff8d

Browse files
authored
Feat: 로드맵 통합 로직 보강 (#167)
* refactor: 엔티티 public setter 제거 * feat: 로드맵 통합 로직 1차 보강 * feat: 통합 로직 V2 1차 보강 * feat: 통합 로직 2차 보강: 단일 루트만 허용 * refactor: 통합 로직 단계별 메서드 분리 * feat: 통합 실행 로직 추가 * feat: 통합 실행 로직 수정 * feat: 통합 실행 로직 트랜잭션 분리 * faet: 통합 실행 로직에 낙관적 락 적용 * chore: initData 비활성화 * fix: 노드 삭제시 FK 제약조건 위반하는 로직 수정
1 parent 6908a89 commit 7f6ff8d

15 files changed

Lines changed: 1189 additions & 84 deletions

back/src/main/java/com/back/BackApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.scheduling.annotation.EnableScheduling;
67

78
@SpringBootApplication
89
@EnableJpaAuditing
10+
@EnableScheduling
911
public class BackApplication {
1012

1113
public static void main(String[] args) {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.back.domain.roadmap.roadmap.entity;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.Entity;
5+
import jakarta.persistence.Id;
6+
import jakarta.persistence.Table;
7+
import jakarta.persistence.Version;
8+
import lombok.NoArgsConstructor;
9+
10+
import java.time.LocalDateTime;
11+
12+
@Entity
13+
@Table(name= "job_roadmap_integration_queue")
14+
@NoArgsConstructor
15+
public class JobRoadmapIntegrationQueue {
16+
@Id
17+
@Column(name = "job_id")
18+
private Long jobId;
19+
20+
@Column(name = "requested_at", nullable = false)
21+
private LocalDateTime requestedAt;
22+
23+
@Column(name = "retry_count", nullable = false)
24+
private Integer retryCount = 0;
25+
26+
@Version
27+
@Column(name = "version")
28+
private Long version = 0L;
29+
30+
public JobRoadmapIntegrationQueue(Long jobId) {
31+
this.jobId = jobId;
32+
this.requestedAt = LocalDateTime.now();
33+
this.retryCount = 0;
34+
}
35+
36+
public void updateRequestedAt() {
37+
this.requestedAt = LocalDateTime.now();
38+
}
39+
40+
public void incrementRetryCount() {
41+
this.retryCount += 1;
42+
}
43+
44+
public boolean isMaxRetryExceeded(int maxRetry) {
45+
return this.retryCount >= maxRetry;
46+
}
47+
48+
public Long getJobId() {
49+
return jobId;
50+
}
51+
52+
public Integer getRetryCount() {
53+
return retryCount;
54+
}
55+
}

back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -47,53 +47,29 @@ public class JobRoadmapNodeStat extends BaseEntity {
4747
private String alternativeParents; // 대안 부모 후보들: JSON 형태 { "T:1": 8, "N:kotlin": 7 }
4848

4949
@Builder
50-
public JobRoadmapNodeStat(Integer stepOrder, Double weight, RoadmapNode node) {
51-
this.stepOrder = stepOrder;
52-
this.weight = weight != null ? weight : 0.0;
50+
public JobRoadmapNodeStat(
51+
RoadmapNode node,
52+
Integer stepOrder,
53+
Double weight,
54+
Double averagePosition,
55+
Integer mentorCount,
56+
Integer totalMentorCount,
57+
Double mentorCoverageRatio,
58+
Integer outgoingTransitions,
59+
Integer incomingTransitions,
60+
String transitionCounts,
61+
String alternativeParents
62+
) {
5363
this.node = node;
54-
}
55-
56-
public void setStepOrder(Integer stepOrder) {
5764
this.stepOrder = stepOrder;
58-
}
59-
60-
public void setWeight(Double weight) {
61-
this.weight = weight;
62-
}
63-
64-
public void setNode(RoadmapNode node) {
65-
this.node = node;
66-
}
67-
68-
public void setAveragePosition(Double averagePosition) {
65+
this.weight = weight != null ? weight : 0.0;
6966
this.averagePosition = averagePosition;
70-
}
71-
72-
public void setMentorCount(Integer mentorCount) {
7367
this.mentorCount = mentorCount;
74-
}
75-
76-
public void setTotalMentorCount(Integer totalMentorCount) {
7768
this.totalMentorCount = totalMentorCount;
78-
}
79-
80-
public void setMentorCoverageRatio(Double mentorCoverageRatio) {
8169
this.mentorCoverageRatio = mentorCoverageRatio;
82-
}
83-
84-
public void setOutgoingTransitions(Integer outgoingTransitions) {
8570
this.outgoingTransitions = outgoingTransitions;
86-
}
87-
88-
public void setIncomingTransitions(Integer incomingTransitions) {
8971
this.incomingTransitions = incomingTransitions;
90-
}
91-
92-
public void setTransitionCounts(String transitionCounts) {
9372
this.transitionCounts = transitionCounts;
94-
}
95-
96-
public void setAlternativeParents(String alternativeParents) {
9773
this.alternativeParents = alternativeParents;
9874
}
9975
}

back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ public RoadmapNode(String taskName, String learningAdvice, String recommendedRes
9797
}
9898

9999

100+
// ========== 도메인 메서드 (Public) ==========
101+
102+
/**
103+
* 자식 노드를 추가하고 부모-자식 관계를 설정합니다.
104+
* 자식의 level은 부모 level + 1로 자동 설정됩니다.
105+
*/
100106
public void addChild(RoadmapNode child) {
101107
if (child == null) {
102108
throw new IllegalArgumentException("자식 노드는 null일 수 없습니다.");
@@ -106,34 +112,54 @@ public void addChild(RoadmapNode child) {
106112
}
107113
this.children.add(child);
108114
child.setParent(this);
109-
child.setLevel(this.level + 1); // 부모 level + 1로 자동 설정
115+
child.setLevel(this.level + 1);
110116
}
111117

112-
public void setParent(RoadmapNode parent) {
113-
this.parent = parent;
118+
/**
119+
* 이 노드를 특정 로드맵에 할당합니다.
120+
* JobRoadmap 또는 MentorRoadmap 저장 후 ID를 받은 시점에 호출됩니다.
121+
*
122+
* @param roadmapId 로드맵 ID
123+
* @param roadmapType 로드맵 타입 (JOB 또는 MENTOR)
124+
*/
125+
public void assignToRoadmap(Long roadmapId, RoadmapType roadmapType) {
126+
if (roadmapId == null) {
127+
throw new IllegalArgumentException("roadmapId는 null일 수 없습니다.");
128+
}
129+
if (roadmapType == null) {
130+
throw new IllegalArgumentException("roadmapType은 null일 수 없습니다.");
131+
}
132+
this.roadmapId = roadmapId;
133+
this.roadmapType = roadmapType;
114134
}
115135

116-
public void setLevel(int level) {
117-
this.level = level;
136+
/**
137+
* 이 노드를 루트 노드로 초기화합니다.
138+
* level=0, stepOrder=1로 설정됩니다.
139+
* 직업 로드맵 통합 알고리즘에서 메인 루트 설정 시 사용됩니다.
140+
*/
141+
public void initializeAsRoot() {
142+
this.level = 0;
143+
this.stepOrder = 1;
118144
}
119145

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

124-
public void setRoadmapId(Long roadmapId) {
125-
this.roadmapId = roadmapId;
126-
}
127-
128-
public void setRoadmapType(RoadmapType roadmapType) {
129-
this.roadmapType = roadmapType;
130-
}
156+
// ========== Package-private 메서드 (같은 패키지에서만 접근) ==========
131157

132-
public void setTask(Task task) {
133-
this.task = task;
158+
void setParent(RoadmapNode parent) {
159+
this.parent = parent;
134160
}
135161

136-
public void setTaskName(String taskName) {
137-
this.taskName = taskName;
162+
void setLevel(int level) {
163+
this.level = level;
138164
}
139165
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.back.domain.roadmap.roadmap.event;
2+
3+
public class MentorRoadmapChangeEvent {
4+
private final Long jobId;
5+
6+
public MentorRoadmapChangeEvent(Long jobId) {
7+
this.jobId = jobId;
8+
}
9+
10+
public Long getJobId() {
11+
return jobId;
12+
}
13+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.back.domain.roadmap.roadmap.repository;
2+
3+
import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Query;
6+
7+
import java.util.List;
8+
9+
public interface JobRoadmapIntegrationQueueRepository extends JpaRepository<JobRoadmapIntegrationQueue, Long> {
10+
@Query("SELECT q FROM JobRoadmapIntegrationQueue q ORDER BY q.requestedAt ASC")
11+
List<JobRoadmapIntegrationQueue> findAllOrderByRequestedAt();
12+
}

back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat;
44
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Modifying;
56
import org.springframework.data.jpa.repository.Query;
67
import org.springframework.data.repository.query.Param;
78

@@ -15,4 +16,8 @@ public interface JobRoadmapNodeStatRepository extends JpaRepository<JobRoadmapNo
1516
WHERE n.roadmapId = :roadmapId
1617
""")
1718
List<JobRoadmapNodeStat> findByNode_RoadmapIdWithNode(@Param("roadmapId") Long roadmapId);
19+
20+
@Modifying
21+
@Query("DELETE FROM JobRoadmapNodeStat s WHERE s.node.id IN :nodeIds")
22+
void deleteByNodeIdIn(@Param("nodeIds") List<Long> nodeIds);
1823
}

back/src/main/java/com/back/domain/roadmap/roadmap/repository/RoadmapNodeRepository.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,36 @@ void deleteByRoadmapIdAndRoadmapType(
2020
@Param("roadmapType") RoadmapNode.RoadmapType roadmapType
2121
);
2222

23+
@Query("SELECT MAX(n.level) FROM RoadmapNode n " +
24+
"WHERE n.roadmapId = :roadmapId AND n.roadmapType = :roadmapType")
25+
Integer findMaxLevelByRoadmapIdAndRoadmapType(
26+
@Param("roadmapId") Long roadmapId,
27+
@Param("roadmapType") RoadmapNode.RoadmapType roadmapType);
28+
29+
@Modifying
30+
@Query("DELETE FROM RoadmapNode n " +
31+
"WHERE n.roadmapId = :roadmapId " +
32+
"AND n.roadmapType = :roadmapType " +
33+
"AND n.level = :level")
34+
void deleteByRoadmapIdAndRoadmapTypeAndLevel(
35+
@Param("roadmapId") Long roadmapId,
36+
@Param("roadmapType") RoadmapNode.RoadmapType roadmapType,
37+
@Param("level") int level);
38+
39+
// 부모-자식 구조를 가진 엔티티를 삭제하기 위해 자식부터 순서대로 삭제(PQL 2단계 방식)
40+
@Modifying
41+
@Query("DELETE FROM RoadmapNode r WHERE r.parent IS NOT NULL AND r.roadmapId = :roadmapId AND r.roadmapType = :roadmapType")
42+
void deleteChildren(@Param("roadmapId") Long roadmapId, @Param("roadmapType") RoadmapNode.RoadmapType roadmapType);
43+
44+
@Modifying
45+
@Query("DELETE FROM RoadmapNode r WHERE r.parent IS NULL AND r.roadmapId = :roadmapId AND r.roadmapType = :roadmapType")
46+
void deleteParents(@Param("roadmapId") Long roadmapId, @Param("roadmapType") RoadmapNode.RoadmapType roadmapType);
47+
48+
49+
@Query("SELECT n.id FROM RoadmapNode n WHERE n.roadmapId = :roadmapId AND n.roadmapType = :roadmapType")
50+
List<Long> findIdsByRoadmapIdAndRoadmapType(@Param("roadmapId") Long roadmapId,
51+
@Param("roadmapType") RoadmapNode.RoadmapType roadmapType);
52+
2353
// 조회용 메서드 (성능 최적화용)
2454
List<RoadmapNode> findByRoadmapIdAndRoadmapTypeOrderByStepOrder(
2555
Long roadmapId,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.back.domain.roadmap.roadmap.service;
2+
3+
import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue;
4+
import com.back.domain.roadmap.roadmap.repository.JobRoadmapIntegrationQueueRepository;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Component;
10+
11+
import java.util.List;
12+
13+
@Component
14+
@RequiredArgsConstructor
15+
@Slf4j
16+
public class JobRoadmapBatchIntegrator {
17+
private final JobRoadmapIntegrationQueueRepository queueRepository;
18+
private final JobRoadmapIntegrationProcessor processor;
19+
private static final int MAX_RETRY = 3;
20+
21+
@Scheduled(fixedDelay = 120000) // 2분
22+
public void integrate() {
23+
List<JobRoadmapIntegrationQueue> pendingQueues = queueRepository.findAllOrderByRequestedAt();
24+
25+
if(pendingQueues.isEmpty()) {
26+
log.debug("처리할 큐가 없습니다.");
27+
return;
28+
}
29+
30+
log.info("직업 로드맵 배치 통합 시작: {}개 직업", pendingQueues.size());
31+
32+
int successCount = 0;
33+
int conflictCount = 0;
34+
35+
for(JobRoadmapIntegrationQueue queue : pendingQueues) {
36+
try {
37+
processor.processQueue(queue);
38+
successCount++;
39+
40+
} catch (ObjectOptimisticLockingFailureException e) {
41+
// 낙관적 락 충돌: 다른 트랜잭션이 큐를 수정함 (정상 동작)
42+
conflictCount++;
43+
log.info("버전 충돌 발생 (정상): jobId={}, 다음 주기에 재처리",
44+
queue.getJobId());
45+
46+
} catch (Exception e) {
47+
// 실제 에러: 통합 로직 실패 등
48+
log.error("직업 로드맵 통합 실패: jobId={}, error={}",
49+
queue.getJobId(), e.getMessage());
50+
51+
try {
52+
processor.handleRetry(queue, MAX_RETRY);
53+
} catch (Exception retryError) {
54+
log.error("재시도 처리 실패: jobId={}, error={}",
55+
queue.getJobId(), retryError.getMessage());
56+
}
57+
}
58+
}
59+
60+
int failureCount = pendingQueues.size() - successCount - conflictCount;
61+
log.info("직업 로드맵 배치 통합 완료: 성공 {}, 충돌 {}, 실패 {}, 총 {}개",
62+
successCount, conflictCount, failureCount, pendingQueues.size());
63+
}
64+
}

0 commit comments

Comments
 (0)