예시: "매일 오전 8시", "매주 월요일 오후 6시"
+ */
+ private String scheduleText;
+
+ /**
+ * 스케줄 활성화 여부
+ *
+ *
true: 활성화 (실행됨), false: 비활성화 (실행 안 됨)
+ */
+ @NotNull(message = "활성화 상태는 필수입니다")
+ private Boolean isActive;
+}
diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java
index 07ac19ea..939781cb 100644
--- a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java
+++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java
@@ -93,4 +93,12 @@ Schedule findByWorkflowIdAndCronExpression(
* @return 영향받은 행 수
*/
int deactivateAllByWorkflowId(@Param("workflowId") Long workflowId);
+
+ /**
+ * 스케줄 ID로 단건 조회
+ *
+ * @param id 스케줄 ID
+ * @return 스케줄 정보, 없으면 null
+ */
+ Schedule findById(@Param("id") Long id);
}
diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java
index 667637b1..4c8d6196 100644
--- a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java
+++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java
@@ -1,25 +1,34 @@
package site.icebang.domain.schedule.service;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
+import java.util.List;
+import java.util.Set;
+
import org.quartz.*;
+import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
import site.icebang.domain.schedule.model.Schedule;
import site.icebang.domain.workflow.scheduler.WorkflowTriggerJob;
/**
* Spring Quartz 스케줄러의 Job과 Trigger를 동적으로 관리하는 서비스 클래스입니다.
*
- *
이 서비스는 데이터베이스에 정의된 {@code Schedule} 정보를 바탕으로,
- * Quartz 엔진에 실제 실행 가능한 작업을 등록, 수정, 삭제하는 역할을 담당합니다.
+ *
이 서비스는 데이터베이스에 정의된 {@code Schedule} 정보를 바탕으로, Quartz 엔진에 실제 실행 가능한 작업을 등록, 수정, 삭제하는
+ * 역할을 담당합니다.
*
*
주요 기능:
+ *
*
- *
DB의 스케줄 정보를 바탕으로 Quartz Job 및 Trigger 생성 또는 업데이트
- *
기존에 등록된 Quartz 스케줄 삭제
+ *
DB의 스케줄 정보를 바탕으로 Quartz Job 및 Trigger 생성 또는 업데이트
+ *
기존에 등록된 Quartz 스케줄 삭제
+ *
워크플로우의 모든 스케줄 일괄 삭제
+ *
Quartz 클러스터 환경에서 안전한 동작 보장
*
*
- * @author jihu0210@naver.com
+ * @author bwnfo0702@gmail.com
* @since v0.1.0
*/
@Slf4j
@@ -33,9 +42,8 @@ public class QuartzScheduleService {
/**
* DB에 정의된 Schedule 객체를 기반으로 Quartz에 스케줄을 등록하거나 업데이트합니다.
*
- *
지정된 워크플로우 ID에 해당하는 Job이 이미 존재할 경우, 기존 Job과 Trigger를 삭제하고
- * 새로운 정보로 다시 생성하여 스케줄을 업데이트합니다. {@code JobDataMap}을 통해
- * 실행될 Job에게 어떤 워크플로우를 실행해야 하는지 ID를 전달합니다.
+ *
지정된 워크플로우 ID에 해당하는 Job이 이미 존재할 경우, 기존 Job과 Trigger를 삭제하고 새로운 정보로 다시 생성하여 스케줄을
+ * 업데이트합니다. {@code JobDataMap}을 통해 실행될 Job에게 어떤 워크플로우를 실행해야 하는지 ID를 전달합니다.
*
* @param schedule Quartz에 등록할 스케줄 정보를 담은 도메인 모델 객체
* @since v0.1.0
@@ -43,19 +51,21 @@ public class QuartzScheduleService {
public void addOrUpdateSchedule(Schedule schedule) {
try {
JobKey jobKey = JobKey.jobKey("workflow-" + schedule.getWorkflowId());
- JobDetail jobDetail = JobBuilder.newJob(WorkflowTriggerJob.class)
- .withIdentity(jobKey)
- .withDescription("Workflow " + schedule.getWorkflowId() + " Trigger Job")
- .usingJobData("workflowId", schedule.getWorkflowId())
- .storeDurably()
- .build();
+ JobDetail jobDetail =
+ JobBuilder.newJob(WorkflowTriggerJob.class)
+ .withIdentity(jobKey)
+ .withDescription("Workflow " + schedule.getWorkflowId() + " Trigger Job")
+ .usingJobData("workflowId", schedule.getWorkflowId())
+ .storeDurably()
+ .build();
TriggerKey triggerKey = TriggerKey.triggerKey("trigger-for-workflow-" + schedule.getWorkflowId());
- Trigger trigger = TriggerBuilder.newTrigger()
- .forJob(jobDetail)
- .withIdentity(triggerKey)
- .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCronExpression()))
- .build();
+ Trigger trigger =
+ TriggerBuilder.newTrigger()
+ .forJob(jobDetail)
+ .withIdentity(triggerKey)
+ .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCronExpression()))
+ .build();
if (scheduler.checkExists(jobKey)) {
scheduler.deleteJob(jobKey); // 기존 Job 삭제 후 재생성 (업데이트)
@@ -64,6 +74,7 @@ public void addOrUpdateSchedule(Schedule schedule) {
log.info("Quartz 스케줄 등록/업데이트 완료: Workflow ID {}", schedule.getWorkflowId());
} catch (SchedulerException e) {
log.error("Quartz 스케줄 등록 실패: Workflow ID " + schedule.getWorkflowId(), e);
+ throw new RuntimeException("Quartz 스케줄 등록 중 오류가 발생했습니다", e);
}
}
@@ -77,11 +88,85 @@ public void deleteSchedule(Long workflowId) {
try {
JobKey jobKey = JobKey.jobKey("workflow-" + workflowId);
if (scheduler.checkExists(jobKey)) {
- scheduler.deleteJob(jobKey);
- log.info("Quartz 스케줄 삭제 완료: Workflow ID {}", workflowId);
+ boolean deleted = scheduler.deleteJob(jobKey);
+ if (deleted) {
+ log.info("Quartz 스케줄 삭제 완료: Workflow ID {}", workflowId);
+ } else {
+ log.warn("Quartz 스케줄 삭제 실패: Workflow ID {}", workflowId);
+ }
+ } else {
+ log.debug("삭제할 Quartz 스케줄이 존재하지 않음: Workflow ID {}", workflowId);
}
} catch (SchedulerException e) {
log.error("Quartz 스케줄 삭제 실패: Workflow ID " + workflowId, e);
+ throw new RuntimeException("Quartz 스케줄 삭제 중 오류가 발생했습니다", e);
+ }
+ }
+
+ /**
+ * 워크플로우와 연결된 모든 Quartz 스케줄을 일괄 삭제합니다.
+ *
+ *
하나의 워크플로우에 여러 스케줄이 있을 수 있으므로, 관련된 모든 Job을 제거합니다.
+ *
+ * @param workflowId 워크플로우 ID
+ * @return 삭제된 스케줄 개수
+ */
+ public int deleteAllSchedulesForWorkflow(Long workflowId) {
+ try {
+ int deletedCount = 0;
+
+ // 워크플로우 관련 모든 Job 키 조회
+ Set jobKeys = scheduler.getJobKeys(GroupMatcher.anyJobGroup());
+
+ for (JobKey jobKey : jobKeys) {
+ // "workflow-{workflowId}" 형식의 Job 찾기
+ if (jobKey.getName().equals("workflow-" + workflowId)) {
+ boolean deleted = scheduler.deleteJob(jobKey);
+ if (deleted) {
+ deletedCount++;
+ log.debug("Quartz Job 삭제: {}", jobKey);
+ }
+ }
+ }
+
+ log.info("Quartz 스케줄 일괄 삭제 완료: Workflow ID {} - {}개 삭제", workflowId, deletedCount);
+ return deletedCount;
+
+ } catch (SchedulerException e) {
+ log.error("Quartz 스케줄 일괄 삭제 실패: Workflow ID " + workflowId, e);
+ throw new RuntimeException("Quartz 스케줄 일괄 삭제 중 오류가 발생했습니다", e);
+ }
+ }
+
+ /**
+ * Quartz 스케줄러에 등록된 모든 Job 목록을 조회합니다.
+ *
+ *
디버깅 및 모니터링 용도로 사용됩니다.
+ *
+ * @return 등록된 Job 키 목록
+ */
+ public Set getAllScheduledJobs() {
+ try {
+ return scheduler.getJobKeys(GroupMatcher.anyJobGroup());
+ } catch (SchedulerException e) {
+ log.error("Quartz Job 목록 조회 실패", e);
+ throw new RuntimeException("Quartz Job 목록 조회 중 오류가 발생했습니다", e);
+ }
+ }
+
+ /**
+ * 특정 워크플로우의 Quartz 스케줄이 등록되어 있는지 확인합니다.
+ *
+ * @param workflowId 워크플로우 ID
+ * @return 등록되어 있으면 true
+ */
+ public boolean isScheduleRegistered(Long workflowId) {
+ try {
+ JobKey jobKey = JobKey.jobKey("workflow-" + workflowId);
+ return scheduler.checkExists(jobKey);
+ } catch (SchedulerException e) {
+ log.error("Quartz 스케줄 존재 확인 실패: Workflow ID " + workflowId, e);
+ return false;
}
}
}
\ No newline at end of file
diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java
new file mode 100644
index 00000000..95670f82
--- /dev/null
+++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java
@@ -0,0 +1,308 @@
+package site.icebang.domain.schedule.service;
+
+import java.util.*;
+
+import org.quartz.CronExpression;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import site.icebang.common.exception.DuplicateDataException;
+import site.icebang.domain.schedule.dto.ScheduleCreateDto;
+import site.icebang.domain.schedule.dto.ScheduleUpdateDto;
+import site.icebang.domain.schedule.mapper.ScheduleMapper;
+import site.icebang.domain.schedule.model.Schedule;
+
+/**
+ * 스케줄 관리를 위한 비즈니스 로직을 처리하는 서비스 클래스입니다.
+ *
+ *
이 서비스는 스케줄의 CRUD 작업과 Quartz 스케줄러와의 동기화를 담당합니다.
+ *
+ *
주요 기능:
+ *
+ *
+ *
스케줄 조회 (단건, 목록)
+ *
스케줄 수정 (크론식, 활성화 상태)
+ *
스케줄 삭제 (논리 삭제)
+ *
스케줄 활성화/비활성화 토글
+ *
DB 변경 시 Quartz 실시간 동기화
+ *
+ *
+ * @author bwnfo0702@gmail.com
+ * @since v0.1.0
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ScheduleService {
+
+ private final ScheduleMapper scheduleMapper;
+ private final QuartzScheduleService quartzScheduleService;
+
+ @Transactional
+ public Schedule createSchedule(Long workflowId, ScheduleCreateDto dto, Long userId) {
+ // 1. Schedule 엔티티 생성
+ Schedule schedule = ScheduleCreateDto.toEntity(dto, workflowId, userId);
+
+ // 2. DB에 저장
+ scheduleMapper.insertSchedule(schedule);
+
+ // 3. 활성화 상태면 Quartz에 등록
+ if (schedule.isActive()) {
+ quartzScheduleService.addOrUpdateSchedule(schedule);
+ }
+
+ return schedule;
+ }
+
+ /**
+ * 특정 워크플로우의 모든 활성 스케줄을 조회합니다.
+ *
+ * @param workflowId 워크플로우 ID
+ * @return 활성 스케줄 목록
+ */
+ @Transactional(readOnly = true)
+ public List getSchedulesByWorkflowId(Long workflowId) {
+ log.debug("워크플로우 스케줄 조회: Workflow ID {}", workflowId);
+ return scheduleMapper.findAllByWorkflowId(workflowId);
+ }
+
+ /**
+ * 스케줄 ID로 단건 조회합니다.
+ *
+ * @param scheduleId 스케줄 ID
+ * @return 스케줄 정보
+ * @throws IllegalArgumentException 스케줄이 존재하지 않을 경우
+ */
+ @Transactional(readOnly = true)
+ public Schedule getScheduleById(Long scheduleId) {
+ Schedule schedule = scheduleMapper.findById(scheduleId);
+ if (schedule == null) {
+ throw new IllegalArgumentException("스케줄을 찾을 수 없습니다: " + scheduleId);
+ }
+ return schedule;
+ }
+
+ /**
+ * 스케줄을 수정하고 Quartz에 실시간 반영합니다.
+ *
+ *
수정 프로세스:
+ *
+ *
+ *
크론 표현식 유효성 검증
+ *
DB 업데이트
+ *
Quartz 스케줄러에 변경사항 반영 (재등록)
+ *
비활성화된 경우 Quartz에서 제거
+ *
+ *
+ * @param scheduleId 수정할 스케줄 ID
+ * @param dto 수정 정보
+ * @param updatedBy 수정자 ID
+ * @throws IllegalArgumentException 스케줄이 존재하지 않거나 크론식이 유효하지 않을 경우
+ */
+ @Transactional
+ public void updateSchedule(Long scheduleId, ScheduleUpdateDto dto, Long updatedBy) {
+ log.info("스케줄 수정 시작: Schedule ID {}", scheduleId);
+
+ // 1. 기존 스케줄 조회
+ Schedule schedule = getScheduleById(scheduleId);
+
+ // 2. 크론 표현식 유효성 검증
+ if (!isValidCronExpression(dto.getCronExpression())) {
+ throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + dto.getCronExpression());
+ }
+
+ // 3. 스케줄 정보 업데이트
+ schedule.setCronExpression(dto.getCronExpression());
+ schedule.setScheduleText(dto.getScheduleText());
+ schedule.setActive(dto.getIsActive());
+ schedule.setUpdatedBy(updatedBy);
+
+ // 4. DB 업데이트
+ int result = scheduleMapper.updateSchedule(schedule);
+ if (result != 1) {
+ throw new RuntimeException("스케줄 수정에 실패했습니다: Schedule ID " + scheduleId);
+ }
+
+ // 5. Quartz 실시간 동기화
+ syncScheduleToQuartz(schedule);
+
+ log.info(
+ "스케줄 수정 완료: Schedule ID {} - {} (활성화: {})",
+ scheduleId,
+ dto.getCronExpression(),
+ dto.getIsActive());
+ }
+
+ /**
+ * 스케줄 활성화 상태를 토글합니다.
+ *
+ *
활성화 → 비활성화 또는 비활성화 → 활성화로 전환하고 Quartz에 반영합니다.
+ *
+ * @param scheduleId 스케줄 ID
+ * @param isActive 변경할 활성화 상태
+ * @throws IllegalArgumentException 스케줄이 존재하지 않을 경우
+ */
+ @Transactional
+ public void toggleScheduleActive(Long scheduleId, Boolean isActive) {
+ log.info("스케줄 활성화 상태 변경: Schedule ID {} - {}", scheduleId, isActive);
+
+ // 1. 기존 스케줄 조회
+ Schedule schedule = getScheduleById(scheduleId);
+
+ // 2. DB 업데이트
+ int result = scheduleMapper.updateActiveStatus(scheduleId, isActive);
+ if (result != 1) {
+ throw new RuntimeException("스케줄 활성화 상태 변경 실패: Schedule ID " + scheduleId);
+ }
+
+ // 3. 스케줄 객체 상태 업데이트
+ schedule.setActive(isActive);
+
+ // 4. Quartz 실시간 동기화
+ syncScheduleToQuartz(schedule);
+
+ log.info("스케줄 활성화 상태 변경 완료: Schedule ID {} - {}", scheduleId, isActive);
+ }
+
+ /**
+ * 스케줄을 삭제합니다 (논리 삭제).
+ *
+ *
DB에서 is_active를 false로 설정하고 Quartz에서도 제거합니다.
+ *
+ * @param scheduleId 삭제할 스케줄 ID
+ * @throws IllegalArgumentException 스케줄이 존재하지 않을 경우
+ */
+ @Transactional
+ public void deleteSchedule(Long scheduleId) {
+ log.info("스케줄 삭제 시작: Schedule ID {}", scheduleId);
+
+ // 1. 기존 스케줄 조회
+ Schedule schedule = getScheduleById(scheduleId);
+
+ // 2. DB에서 논리 삭제
+ int result = scheduleMapper.deleteSchedule(scheduleId);
+ if (result != 1) {
+ throw new RuntimeException("스케줄 삭제에 실패했습니다: Schedule ID " + scheduleId);
+ }
+
+ // 3. Quartz에서 제거
+ quartzScheduleService.deleteSchedule(schedule.getWorkflowId());
+
+ log.info("스케줄 삭제 완료: Schedule ID {}", scheduleId);
+ }
+
+ /**
+ * 스케줄 변경사항을 Quartz 스케줄러에 동기화합니다.
+ *
+ *
활성화된 스케줄: Quartz에 등록/업데이트 비활성화된 스케줄: Quartz에서 제거
+ *
+ * @param schedule 동기화할 스케줄
+ */
+ private void syncScheduleToQuartz(Schedule schedule) {
+ if (schedule.isActive()) {
+ // 활성화: Quartz에 등록 또는 업데이트
+ quartzScheduleService.addOrUpdateSchedule(schedule);
+ log.debug("Quartz 스케줄 등록/업데이트: Workflow ID {}", schedule.getWorkflowId());
+ } else {
+ // 비활성화: Quartz에서 제거
+ quartzScheduleService.deleteSchedule(schedule.getWorkflowId());
+ log.debug("Quartz 스케줄 제거: Workflow ID {}", schedule.getWorkflowId());
+ }
+ }
+
+ /**
+ * Quartz 크론 표현식 유효성 검증
+ *
+ * @param cronExpression 검증할 크론 표현식
+ * @return 유효하면 true
+ */
+ private boolean isValidCronExpression(String cronExpression) {
+ try {
+ new CronExpression(cronExpression);
+ return true;
+ } catch (Exception e) {
+ log.warn("유효하지 않은 크론 표현식: {}", cronExpression, e);
+ return false;
+ }
+ }
+
+ /**
+ * 스케줄 목록을 검증하고 등록합니다.
+ *
+ * @param workflowId 워크플로우 ID
+ * @param scheduleDtos 등록할 스케줄 목록
+ * @param userId 생성자 ID
+ * @throws IllegalArgumentException 유효하지 않은 크론식
+ * @throws DuplicateDataException 중복 크론식 발견
+ */
+ @Transactional
+ public void validateAndRegisterSchedules(
+ Long workflowId, List scheduleDtos, Long userId) {
+
+ // 1. 검증
+ validateSchedules(scheduleDtos);
+
+ // 2. 등록
+ for (ScheduleCreateDto dto : scheduleDtos) {
+ createSchedule(workflowId, dto, userId);
+ }
+ }
+
+ /** 스케줄 목록 검증 (크론 표현식 유효성 및 중복 검사) */
+ public void validateSchedules(List schedules) {
+ if (schedules == null || schedules.isEmpty()) {
+ return;
+ }
+
+ Set cronExpressions = new HashSet<>();
+
+ for (ScheduleCreateDto schedule : schedules) {
+ String cron = schedule.getCronExpression();
+
+ // 크론 표현식 유효성 검증
+ if (!isValidCronExpression(cron)) {
+ throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + cron);
+ }
+
+ // 중복 크론식 검사
+ if (cronExpressions.contains(cron)) {
+ throw new DuplicateDataException("중복된 크론 표현식이 있습니다: " + cron);
+ }
+ cronExpressions.add(cron);
+ }
+ }
+
+ /** 워크플로우의 모든 스케줄을 비활성화합니다. */
+ @Transactional
+ public void deactivateAllByWorkflowId(Long workflowId) {
+ log.info("워크플로우 스케줄 일괄 비활성화: Workflow ID {}", workflowId);
+
+ // DB 비활성화
+ scheduleMapper.deactivateAllByWorkflowId(workflowId);
+
+ // Quartz 제거
+ quartzScheduleService.deleteSchedule(workflowId);
+ }
+
+ /** 워크플로우의 활성 스케줄을 Quartz에 재등록합니다. */
+ @Transactional
+ public int reactivateAllByWorkflowId(Long workflowId) {
+ log.info("워크플로우 스케줄 일괄 재활성화: Workflow ID {}", workflowId);
+
+ List activeSchedules = scheduleMapper.findAllByWorkflowId(workflowId);
+ int count = 0;
+
+ for (Schedule schedule : activeSchedules) {
+ if (schedule.isActive()) {
+ quartzScheduleService.addOrUpdateSchedule(schedule);
+ count++;
+ }
+ }
+
+ log.info("Quartz 재등록 완료: {}개 스케줄", count);
+ return count;
+ }
+}
diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java
index 40550e44..e5c4057b 100644
--- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java
+++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java
@@ -42,10 +42,6 @@ public ApiResponseDto> getWorkflowList(
public ApiResponseDto createWorkflow(
@Valid @RequestBody WorkflowCreateDto workflowCreateDto,
@AuthenticationPrincipal AuthCredential authCredential) {
- // 인증 체크
- if (authCredential == null) {
- throw new IllegalArgumentException("로그인이 필요합니다");
- }
// AuthCredential에서 userId 추출
BigInteger userId = authCredential.getId();
@@ -69,4 +65,64 @@ public ApiResponseDto getWorkflowDetail(
WorkflowDetailCardDto result = workflowService.getWorkflowDetail(workflowId);
return ApiResponseDto.success(result);
}
+
+ /**
+ * 워크플로우를 삭제합니다 (논리 삭제).
+ *
+ *