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)' 문제를 회피하고, 재시도 로직을 비즈니스 흐름과 분리하기 위해 별도의 서비스로 구현되었습니다. - * - *

주요 기능:

- * - * - * - * @author jihu0210@naver.com - * @since v0.1.0 - */ @Service @RequiredArgsConstructor public class TaskExecutionService { - /** 워크플로우 실행 이력 전용 로거 */ private static final Logger workflowLogger = LoggerFactory.getLogger("WORKFLOW_HISTORY"); - private final Map 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 엔진에 동기화하는 매우 중요한 역할을 수행합니다. * *

주요 기능:

* * @@ -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); + } +}