From 0cdebe9c0c3c19b5d3f1cd3a8f1cf98762f9e8a9 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Thu, 25 Sep 2025 18:00:22 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20integration=20log4j2=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application-test-integration.yml | 2 +- .../resources/log4j2-test-integration.yml | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 apps/user-service/src/main/resources/log4j2-test-integration.yml 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 From 4b43214de93224fbc2bad4d7d2adebb0bb3d6b25 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Thu, 25 Sep 2025 18:03:54 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20RetryConfig=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TaskExecutionService.java | 82 ++++++++++--------- .../global/config/retry/RetryConfig.java | 28 +++++++ 2 files changed, 71 insertions(+), 39 deletions(-) create mode 100644 apps/user-service/src/main/java/site/icebang/global/config/retry/RetryConfig.java 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 80cf44a3..627550b6 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 @@ -1,55 +1,59 @@ package site.icebang.domain.workflow.service; -import java.util.Map; - +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; 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; // 📌 RetryTemplate import import org.springframework.stereotype.Service; import org.springframework.web.client.RestClientException; - -import com.fasterxml.jackson.databind.node.ObjectNode; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.domain.workflow.model.Task; import site.icebang.domain.workflow.model.TaskRun; +import site.icebang.domain.workflow.model.Task; import site.icebang.domain.workflow.runner.TaskRunner; -@Slf4j +import java.util.Map; + @Service @RequiredArgsConstructor -public class TaskExecutionService { // 📌 클래스 이름 변경 +public class TaskExecutionService { private static final Logger workflowLogger = LoggerFactory.getLogger("WORKFLOW_HISTORY"); private final Map taskRunners; + private final RetryTemplate taskExecutionRetryTemplate; // 📌 RetryTemplate 주입 - /** RestClientException 발생 시, 5초 간격으로 최대 3번 재시도합니다. */ - @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); - } - - /** 모든 재시도가 실패했을 때 마지막으로 호출될 복구 메소드입니다. */ - @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()); + Task task, TaskRun taskRun, ObjectNode requestBody) { + + // 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()); + } + ); } -} +} \ No newline at end of file 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..654ee8db --- /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; + } +} \ No newline at end of file From 44c86eb6e2a3b3ce130834319ff089f18ed8c439 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Thu, 25 Sep 2025 18:19:12 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20Workflow=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20api=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkflowRunApiIntegrationTest.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java 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..e2c6ab20 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java @@ -0,0 +1,63 @@ +package site.icebang.integration.tests.workflow; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +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 site.icebang.domain.workflow.service.WorkflowExecutionService; +import site.icebang.integration.setup.support.IntegrationTestSupport; + +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; + +@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); + } +} \ No newline at end of file From f72b5009e43fadadc3a834befcc8ac4e6bd7a4c9 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Thu, 25 Sep 2025 18:19:55 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20task=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TaskExecutionServiceIntegrationTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskExecutionServiceIntegrationTest.java 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..3157722d --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskExecutionServiceIntegrationTest.java @@ -0,0 +1,59 @@ +package site.icebang.integration.tests.workflow; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +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 site.icebang.domain.workflow.model.TaskRun; +import site.icebang.domain.workflow.model.Task; +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * 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("최대 재시도 횟수 초과"); + } +} \ No newline at end of file From 7e172e19f785e46e53a391184f97b3197b6fdc01 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Thu, 25 Sep 2025 18:28:35 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20ApplicationListener=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icebang/global/config/QuartzSchedulerInitializer.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 233f5834..08e78769 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 @@ @Slf4j @Component @RequiredArgsConstructor -public class QuartzSchedulerInitializer implements CommandLineRunner { +public class QuartzSchedulerInitializer implements ApplicationListener { private final ScheduleMapper scheduleMapper; private final QuartzScheduleService quartzScheduleService; @Override - public void run(String... args) { + public void onApplicationEvent(ContextRefreshedEvent event) { log.info("Quartz 스케줄러 초기화 시작: DB 스케줄을 등록합니다."); try { List activeSchedules = scheduleMapper.findAllActive(); From 8c5e8c2ff96cd15c5afc4a5928b461bdf6c16949 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Thu, 25 Sep 2025 18:30:37 +0900 Subject: [PATCH 6/7] refactor: Code Formatting --- .../service/TaskExecutionService.java | 76 +++++++-------- .../global/config/retry/RetryConfig.java | 28 +++--- .../TaskExecutionServiceIntegrationTest.java | 85 +++++++++-------- .../WorkflowRunApiIntegrationTest.java | 95 ++++++++++--------- 4 files changed, 143 insertions(+), 141 deletions(-) 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 627550b6..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 @@ -1,18 +1,20 @@ package site.icebang.domain.workflow.service; -import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.RequiredArgsConstructor; +import java.util.Map; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.retry.support.RetryTemplate; // 📌 RetryTemplate import +import org.springframework.retry.support.RetryTemplate; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClientException; -import site.icebang.domain.workflow.model.TaskRun; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; + import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.model.TaskRun; import site.icebang.domain.workflow.runner.TaskRunner; -import java.util.Map; - @Service @RequiredArgsConstructor public class TaskExecutionService { @@ -22,38 +24,36 @@ public class TaskExecutionService { // 📌 @Retryable, @Recover 어노테이션 제거 public TaskRunner.TaskExecutionResult executeWithRetry( - Task task, TaskRun taskRun, ObjectNode requestBody) { + Task task, TaskRun taskRun, ObjectNode requestBody) { // 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()); - } - ); + // 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()); + }); } -} \ No newline at end of file +} 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 index 654ee8db..98cda2bc 100644 --- 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 @@ -9,20 +9,20 @@ @Configuration public class RetryConfig { - @Bean - public RetryTemplate taskExecutionRetryTemplate() { - RetryTemplate retryTemplate = new RetryTemplate(); + @Bean + public RetryTemplate taskExecutionRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); - // 1. 재시도 정책 설정: 최대 3번 시도 - SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(); - retryPolicy.setMaxAttempts(3); - retryTemplate.setRetryPolicy(retryPolicy); + // 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); + // 2. 재시도 간격 설정: 5초 고정 간격 + FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy(); + backOffPolicy.setBackOffPeriod(5000L); // 5000ms = 5초 + retryTemplate.setBackOffPolicy(backOffPolicy); - return retryTemplate; - } -} \ No newline at end of file + return retryTemplate; + } +} 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 index 3157722d..8308fe0d 100644 --- 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 @@ -1,59 +1,60 @@ package site.icebang.integration.tests.workflow; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; +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 site.icebang.domain.workflow.model.TaskRun; + +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; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - /** - * TaskExecutionService의 재시도 로직에 대한 통합 테스트 클래스입니다. - * 실제 Spring 컨텍스트를 로드하여 RetryTemplate 기반의 재시도 기능이 정상 동작하는지 검증합니다. + * 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("최대 재시도 횟수 초과"); - } -} \ No newline at end of file + @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 index e2c6ab20..2daa4db1 100644 --- 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 @@ -1,6 +1,13 @@ package site.icebang.integration.tests.workflow; -import com.epages.restdocs.apispec.ResourceSnippetParameters; +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; @@ -8,56 +15,50 @@ 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; -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; - @Sql( - value = { - "classpath:sql/01-insert-internal-users.sql", - "classpath:sql/03-insert-workflow.sql" - }, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + 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); - } -} \ No newline at end of file + @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); + } +} From 6d4ffa52aca33ad78a24ce3601d5b5fb5d2de6ba Mon Sep 17 00:00:00 2001 From: jihukimme Date: Thu, 25 Sep 2025 18:42:54 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20javadoc=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/QuartzSchedulerInitializer.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 0973c585..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 @@ -14,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 엔진에 등록
  • *
* @@ -36,12 +36,12 @@ public class QuartzSchedulerInitializer implements ApplicationListener데이터베이스에서 활성화된 모든 스케줄을 조회하고, 각 스케줄을 * {@code QuartzScheduleService}를 통해 Quartz 스케줄러에 등록합니다. * - * @param args 애플리케이션 실행 시 전달되는 인자 + * @param event 발생한 ContextRefreshedEvent 객체 * @since v0.1.0 */ @Override