diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java
index 29f28d98..62b72fed 100644
--- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java
+++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/TaskExecutionService.java
@@ -4,11 +4,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.retry.annotation.Backoff;
-import org.springframework.retry.annotation.Recover;
-import org.springframework.retry.annotation.Retryable;
+import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
-import org.springframework.web.client.RestClientException;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -18,80 +15,45 @@
import site.icebang.domain.workflow.model.TaskRun;
import site.icebang.domain.workflow.runner.TaskRunner;
-/**
- * 워크플로우 내 개별 Task의 실행과 재시도 정책을 전담하는 서비스입니다.
- *
- *
이 클래스는 {@code WorkflowExecutionService}로부터 Task 실행 책임을 위임받습니다. Spring AOP의 '자기
- * 호출(Self-invocation)' 문제를 회피하고, 재시도 로직을 비즈니스 흐름과 분리하기 위해 별도의 서비스로 구현되었습니다.
- *
- *
taskRunners;
+ private final RetryTemplate taskExecutionRetryTemplate; // 📌 RetryTemplate 주입
- /**
- * 지정된 Task를 재시도 정책을 적용하여 실행합니다.
- *
- * HTTP 통신 오류 등 {@code RestClientException} 발생 시, 5초의 고정된 간격({@code Backoff})으로 최대 3회({@code
- * maxAttempts})까지 실행을 재시도합니다. 지원하지 않는 Task 타입의 경우 재시도 없이 즉시 {@code IllegalArgumentException}을
- * 발생시킵니다.
- *
- * @param task 실행할 Task의 도메인 모델
- * @param taskRun 현재 실행에 대한 기록 객체
- * @param requestBody 동적으로 생성된 요청 Body
- * @return Task 실행 결과
- * @throws IllegalArgumentException 지원하지 않는 Task 타입일 경우
- * @since v0.1.0
- */
- @Retryable(
- value = {RestClientException.class},
- maxAttempts = 3,
- backoff = @Backoff(delay = 5000))
+ // 📌 @Retryable, @Recover 어노테이션 제거
public TaskRunner.TaskExecutionResult executeWithRetry(
Task task, TaskRun taskRun, ObjectNode requestBody) {
- workflowLogger.info("Task 실행 시도: TaskId={}, TaskRunId={}", task.getId(), taskRun.getId());
-
- String runnerBeanName = task.getType().toLowerCase() + "TaskRunner";
- TaskRunner runner = taskRunners.get(runnerBeanName);
-
- if (runner == null) {
- throw new IllegalArgumentException("지원하지 않는 Task 타입: " + task.getType());
- }
-
- return runner.execute(task, taskRun, requestBody);
- }
- /**
- * {@code @Retryable} 재시도가 모두 실패했을 때 호출되는 복구 메소드입니다.
- *
- *
이 메소드는 {@code executeWithRetry} 메소드와 동일한 파라미터 시그니처를 가지며, 발생한 예외를 첫 번째 파라미터로 추가로 받습니다. 최종 실패
- * 상태를 기록하고 실패 결과를 반환하는 역할을 합니다.
- *
- * @param e 재시도를 유발한 마지막 예외 객체
- * @param task 실패한 Task의 도메인 모델
- * @param taskRun 실패한 실행의 기록 객체
- * @param requestBody 실패 당시 사용된 요청 Body
- * @return 최종 실패를 나타내는 Task 실행 결과
- * @since v0.1.0
- */
- @Recover
- public TaskRunner.TaskExecutionResult recover(
- RestClientException e, Task task, TaskRun taskRun, ObjectNode requestBody) {
- workflowLogger.error("최종 Task 실행 실패 (모든 재시도 소진): TaskRunId={}", taskRun.getId(), e);
- return TaskRunner.TaskExecutionResult.failure("최대 재시도 횟수 초과: " + e.getMessage());
+ // RetryTemplate을 사용하여 실행 로직을 감쌉니다.
+ return taskExecutionRetryTemplate.execute(
+ // 1. 재시도할 로직 (RetryCallback)
+ context -> {
+ // 📌 이 블록은 재시도할 때마다 실행되므로, 로그가 누락되지 않습니다.
+ workflowLogger.info(
+ "Task 실행 시도 #{}: TaskId={}, TaskRunId={}",
+ context.getRetryCount() + 1,
+ task.getId(),
+ taskRun.getId());
+
+ String runnerBeanName = task.getType().toLowerCase() + "TaskRunner";
+ TaskRunner runner = taskRunners.get(runnerBeanName);
+
+ if (runner == null) {
+ throw new IllegalArgumentException("지원하지 않는 Task 타입: " + task.getType());
+ }
+
+ // 이 부분에서 RestClientException 발생 시 재시도됩니다.
+ return runner.execute(task, taskRun, requestBody);
+ },
+ // 2. 모든 재시도가 실패했을 때 실행될 로직 (RecoveryCallback)
+ context -> {
+ Throwable lastThrowable = context.getLastThrowable();
+ workflowLogger.error(
+ "최종 Task 실행 실패 (모든 재시도 소진): TaskRunId={}", taskRun.getId(), lastThrowable);
+ return TaskRunner.TaskExecutionResult.failure(
+ "최대 재시도 횟수 초과: " + lastThrowable.getMessage());
+ });
}
}
diff --git a/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java b/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java
index bdca3015..9ebd150f 100644
--- a/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java
+++ b/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java
@@ -3,6 +3,8 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.ApplicationListener;
+import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import site.icebang.domain.schedule.model.Schedule;
import site.icebang.domain.schedule.mapper.ScheduleMapper;
@@ -12,13 +14,13 @@
/**
* 애플리케이션 시작 시 데이터베이스에 저장된 스케줄을 Quartz 스케줄러에 동적으로 등록하는 초기화 클래스입니다.
*
- *
이 클래스는 {@code CommandLineRunner}를 구현하여, Spring Boot 애플리케이션이 완전히
- * 로드된 후 단 한 번 실행됩니다. 데이터베이스의 {@code schedule} 테이블을 'Source of Truth'로 삼아,
- * 활성화된 모든 스케줄을 읽어와 Quartz 엔진에 동기화하는 매우 중요한 역할을 수행합니다.
+ *
이 클래스는 {@code ApplicationListener}를 구현하여, Spring의 ApplicationContext가
+ * 완전히 초기화되고 모든 Bean이 준비되었을 때 단 한 번 실행됩니다. 데이터베이스의 {@code schedule} 테이블을
+ * 'Source of Truth'로 삼아, 활성화된 모든 스케줄을 읽어와 Quartz 엔진에 동기화하는 매우 중요한 역할을 수행합니다.
*
* 주요 기능:
*
- * - 애플리케이션 시작 시점에 DB의 활성 스케줄 조회
+ * - 애플리케이션 컨텍스트 초기화 완료 시점에 DB의 활성 스케줄 조회
* - 조회된 스케줄을 {@code QuartzScheduleService}를 통해 Quartz 엔진에 등록
*
*
@@ -28,22 +30,22 @@
@Slf4j
@Component
@RequiredArgsConstructor
-public class QuartzSchedulerInitializer implements CommandLineRunner {
+public class QuartzSchedulerInitializer implements ApplicationListener {
private final ScheduleMapper scheduleMapper;
private final QuartzScheduleService quartzScheduleService;
/**
- * Spring Boot 애플리케이션 시작 시 호출되는 메인 실행 메소드입니다.
+ * Spring ApplicationContext가 완전히 새로고침(초기화)될 때 호출되는 이벤트 핸들러 메소드입니다.
*
* 데이터베이스에서 활성화된 모든 스케줄을 조회하고, 각 스케줄을
* {@code QuartzScheduleService}를 통해 Quartz 스케줄러에 등록합니다.
*
- * @param args 애플리케이션 실행 시 전달되는 인자
+ * @param event 발생한 ContextRefreshedEvent 객체
* @since v0.1.0
*/
@Override
- public void run(String... args) {
+ public void onApplicationEvent(ContextRefreshedEvent event) {
log.info("Quartz 스케줄러 초기화 시작: DB 스케줄을 등록합니다.");
try {
List activeSchedules = scheduleMapper.findAllActive();
diff --git a/apps/user-service/src/main/java/site/icebang/global/config/retry/RetryConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/retry/RetryConfig.java
new file mode 100644
index 00000000..98cda2bc
--- /dev/null
+++ b/apps/user-service/src/main/java/site/icebang/global/config/retry/RetryConfig.java
@@ -0,0 +1,28 @@
+package site.icebang.global.config.retry;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.retry.backoff.FixedBackOffPolicy;
+import org.springframework.retry.policy.SimpleRetryPolicy;
+import org.springframework.retry.support.RetryTemplate;
+
+@Configuration
+public class RetryConfig {
+
+ @Bean
+ public RetryTemplate taskExecutionRetryTemplate() {
+ RetryTemplate retryTemplate = new RetryTemplate();
+
+ // 1. 재시도 정책 설정: 최대 3번 시도
+ SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
+ retryPolicy.setMaxAttempts(3);
+ retryTemplate.setRetryPolicy(retryPolicy);
+
+ // 2. 재시도 간격 설정: 5초 고정 간격
+ FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy();
+ backOffPolicy.setBackOffPeriod(5000L); // 5000ms = 5초
+ retryTemplate.setBackOffPolicy(backOffPolicy);
+
+ return retryTemplate;
+ }
+}
diff --git a/apps/user-service/src/main/resources/application-test-integration.yml b/apps/user-service/src/main/resources/application-test-integration.yml
index 526cf151..6eccdace 100644
--- a/apps/user-service/src/main/resources/application-test-integration.yml
+++ b/apps/user-service/src/main/resources/application-test-integration.yml
@@ -39,4 +39,4 @@ mybatis:
map-underscore-to-camel-case: true
logging:
- config: classpath:log4j2-test-unit.yml
\ No newline at end of file
+ config: classpath:log4j2-test-integration.yml
\ No newline at end of file
diff --git a/apps/user-service/src/main/resources/log4j2-test-integration.yml b/apps/user-service/src/main/resources/log4j2-test-integration.yml
new file mode 100644
index 00000000..e28b7e24
--- /dev/null
+++ b/apps/user-service/src/main/resources/log4j2-test-integration.yml
@@ -0,0 +1,77 @@
+Configuration:
+ name: test
+
+ properties:
+ property:
+ - name: "log-path"
+ value: "./logs"
+ - name: "charset-UTF-8"
+ value: "UTF-8"
+ # 통일된 콘솔 패턴 - 모든 로그에 RequestId 포함
+ - name: "console-layout-pattern"
+ value: "%highlight{[%-5level]} [%X{id}] %d{MM-dd HH:mm:ss} [%t] %n %msg%n%n"
+
+ # [Appenders] 로그 기록방식 정의
+ Appenders:
+ # 통일된 콘솔 출력
+ Console:
+ name: console-appender
+ target: SYSTEM_OUT
+ PatternLayout:
+ pattern: ${console-layout-pattern}
+
+ # [Loggers] 로그 출력 범위를 정의
+ Loggers:
+ # [Loggers - Root] 모든 로그를 기록하는 최상위 로그를 정의
+ Root:
+ level: OFF
+ AppenderRef:
+ - ref: console-appender
+
+ # [Loggers - Loggers] 특정 패키지나 클래스에 대한 로그를 정의
+ Logger:
+ # 1. Spring Framework 로그
+ - name: org.springframework
+ additivity: "false"
+ level: INFO
+ AppenderRef:
+ - ref: console-appender
+
+ # 2. 애플리케이션 로그
+ - name: site.icebang
+ additivity: "false"
+ level: INFO
+ AppenderRef:
+ - ref: console-appender
+
+ # 3. HikariCP 로그 비활성화
+ - name: com.zaxxer.hikari
+ level: OFF
+
+ # 4. Spring Security 로그 - 인증/인가 추적에 중요
+ - name: org.springframework.security
+ level: INFO
+ additivity: "false"
+ AppenderRef:
+ - ref: console-appender
+
+ # 5. 웹 요청 로그 - 요청 처리 과정 추적
+ - name: org.springframework.web
+ level: INFO
+ additivity: "false"
+ AppenderRef:
+ - ref: console-appender
+
+ # 6. 트랜잭션 로그 - DB 작업 추적
+ - name: org.springframework.transaction
+ level: INFO
+ additivity: "false"
+ AppenderRef:
+ - ref: console-appender
+
+ # 7. WORKFLOW_HISTORY 로그 - 워크플로우 기록
+ - name: "WORKFLOW_HISTORY"
+ level: "INFO"
+ AppenderRef:
+ - ref: "console-appender"
+ additivity: "false"
\ No newline at end of file
diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskExecutionServiceIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskExecutionServiceIntegrationTest.java
new file mode 100644
index 00000000..8308fe0d
--- /dev/null
+++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskExecutionServiceIntegrationTest.java
@@ -0,0 +1,60 @@
+package site.icebang.integration.tests.workflow;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.web.client.RestClientException;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import site.icebang.domain.workflow.model.Task;
+import site.icebang.domain.workflow.model.TaskRun;
+import site.icebang.domain.workflow.runner.TaskRunner;
+import site.icebang.domain.workflow.runner.fastapi.FastApiTaskRunner;
+import site.icebang.domain.workflow.service.TaskExecutionService;
+import site.icebang.integration.setup.support.IntegrationTestSupport;
+
+/**
+ * TaskExecutionService의 재시도 로직에 대한 통합 테스트 클래스입니다. 실제 Spring 컨텍스트를 로드하여 RetryTemplate 기반의 재시도 기능이 정상
+ * 동작하는지 검증합니다.
+ */
+public class TaskExecutionServiceIntegrationTest extends IntegrationTestSupport {
+
+ @Autowired private TaskExecutionService taskExecutionService;
+
+ @MockitoBean(name = "fastapiTaskRunner")
+ private FastApiTaskRunner mockFastApiTaskRunner;
+
+ @Test
+ @DisplayName("Task 실행이 3번 모두 실패하면, 재시도 로그가 3번 기록되고 최종 FAILED 결과를 반환해야 한다")
+ void executeWithRetry_shouldLogRetries_andFail_afterAllRetries() {
+ // given
+ Task testTask = new Task(1L, "테스트 태스크", "FastAPI", null, null, null, null);
+ TaskRun testTaskRun = new TaskRun();
+ ObjectNode testRequestBody = new ObjectMapper().createObjectNode();
+
+ // Mock Runner가 호출될 때마다 예외를 던지도록 설정
+ when(mockFastApiTaskRunner.execute(any(Task.class), any(TaskRun.class), any(ObjectNode.class)))
+ .thenThrow(new RestClientException("Connection failed"));
+
+ // when
+ // RetryTemplate이 적용된 실제 서비스를 호출
+ TaskRunner.TaskExecutionResult finalResult =
+ taskExecutionService.executeWithRetry(testTask, testTaskRun, testRequestBody);
+
+ // then
+ // 1. Runner의 execute 메소드가 RetryTemplate 정책에 따라 3번 호출되었는지 검증
+ verify(mockFastApiTaskRunner, times(3))
+ .execute(any(Task.class), any(TaskRun.class), any(ObjectNode.class));
+
+ // 2. RecoveryCallback이 반환한 최종 결과가 FAILED인지 검증
+ assertThat(finalResult.isFailure()).isTrue();
+ assertThat(finalResult.message()).contains("최대 재시도 횟수 초과");
+ }
+}
diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java
new file mode 100644
index 00000000..2daa4db1
--- /dev/null
+++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java
@@ -0,0 +1,64 @@
+package site.icebang.integration.tests.workflow;
+
+import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document;
+import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.security.test.context.support.WithUserDetails;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.epages.restdocs.apispec.ResourceSnippetParameters;
+
+import site.icebang.domain.workflow.service.WorkflowExecutionService;
+import site.icebang.integration.setup.support.IntegrationTestSupport;
+
+@Sql(
+ value = {"classpath:sql/01-insert-internal-users.sql", "classpath:sql/03-insert-workflow.sql"},
+ executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+@Transactional
+public class WorkflowRunApiIntegrationTest extends IntegrationTestSupport {
+
+ @MockitoBean private WorkflowExecutionService mockWorkflowExecutionService;
+
+ @Test
+ @DisplayName("워크플로우 수동 실행 API 호출 성공")
+ @WithUserDetails("admin@icebang.site")
+ void runWorkflow_success() throws Exception {
+ // given
+ Long workflowId = 1L;
+
+ // when & then
+ mockMvc
+ .perform(
+ post(getApiUrlForDocs("/v0/workflows/{workflowId}/run"), workflowId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .header("Origin", "https://admin.icebang.site")
+ .header("Referer", "https://admin.icebang.site/"))
+ .andExpect(status().isAccepted()) // 📌 1. 즉시 202 Accepted 응답을 받는지 확인
+ .andDo(
+ document(
+ "workflow-run",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ resource(
+ ResourceSnippetParameters.builder()
+ .tag("Workflow Execution")
+ .summary("워크플로우 수동 실행")
+ .description(
+ "지정된 ID의 워크플로우를 즉시 비동기적으로 실행합니다. "
+ + "성공 시 202 Accepted를 반환하며, 실제 실행은 백그라운드에서 진행됩니다.")
+ .build())));
+
+ // 📌 2. 비동기 호출된 executeWorkflow 메소드가 1초 이내에 1번 실행되었는지 검증
+ verify(mockWorkflowExecutionService, timeout(1000).times(1)).executeWorkflow(workflowId);
+ }
+}