Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -18,80 +15,45 @@
import site.icebang.domain.workflow.model.TaskRun;
import site.icebang.domain.workflow.runner.TaskRunner;

/**
* 워크플로우 내 개별 Task의 실행과 재시도 정책을 전담하는 서비스입니다.
*
* <p>이 클래스는 {@code WorkflowExecutionService}로부터 Task 실행 책임을 위임받습니다. Spring AOP의 '자기
* 호출(Self-invocation)' 문제를 회피하고, 재시도 로직을 비즈니스 흐름과 분리하기 위해 별도의 서비스로 구현되었습니다.
*
* <h2>주요 기능:</h2>
*
* <ul>
* <li>{@code @Retryable} 어노테이션을 통한 선언적 재시도 처리
* <li>{@code @Recover} 어노테이션을 이용한 최종 실패 시 복구 로직 수행
* <li>Task 타입에 맞는 적절한 {@code TaskRunner} 선택 및 실행
* </ul>
*
* @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<String, TaskRunner> taskRunners;
private final RetryTemplate taskExecutionRetryTemplate; // 📌 RetryTemplate 주입

/**
* 지정된 Task를 재시도 정책을 적용하여 실행합니다.
*
* <p>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} 재시도가 모두 실패했을 때 호출되는 복구 메소드입니다.
*
* <p>이 메소드는 {@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());
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,13 +14,13 @@
/**
* 애플리케이션 시작 시 데이터베이스에 저장된 스케줄을 Quartz 스케줄러에 동적으로 등록하는 초기화 클래스입니다.
*
* <p>이 클래스는 {@code CommandLineRunner}를 구현하여, Spring Boot 애플리케이션이 완전히
* 로드된 후 단 한 번 실행됩니다. 데이터베이스의 {@code schedule} 테이블을 'Source of Truth'로 삼아,
* 활성화된 모든 스케줄을 읽어와 Quartz 엔진에 동기화하는 매우 중요한 역할을 수행합니다.
* <p>이 클래스는 {@code ApplicationListener<ContextRefreshedEvent>}를 구현하여, Spring의 ApplicationContext가
* 완전히 초기화되고 모든 Bean이 준비되었을 때 단 한 번 실행됩니다. 데이터베이스의 {@code schedule} 테이블을
* 'Source of Truth'로 삼아, 활성화된 모든 스케줄을 읽어와 Quartz 엔진에 동기화하는 매우 중요한 역할을 수행합니다.
*
* <h2>주요 기능:</h2>
* <ul>
* <li>애플리케이션 시작 시점에 DB의 활성 스케줄 조회</li>
* <li>애플리케이션 컨텍스트 초기화 완료 시점에 DB의 활성 스케줄 조회</li>
* <li>조회된 스케줄을 {@code QuartzScheduleService}를 통해 Quartz 엔진에 등록</li>
* </ul>
*
Expand All @@ -28,22 +30,22 @@
@Slf4j
@Component
@RequiredArgsConstructor
public class QuartzSchedulerInitializer implements CommandLineRunner {
public class QuartzSchedulerInitializer implements ApplicationListener<ContextRefreshedEvent> {

private final ScheduleMapper scheduleMapper;
private final QuartzScheduleService quartzScheduleService;

/**
* Spring Boot 애플리케이션 시작 시 호출되는 메인 실행 메소드입니다.
* Spring ApplicationContext가 완전히 새로고침(초기화)될 때 호출되는 이벤트 핸들러 메소드입니다.
*
* <p>데이터베이스에서 활성화된 모든 스케줄을 조회하고, 각 스케줄을
* {@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<Schedule> activeSchedules = scheduleMapper.findAllActive();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ mybatis:
map-underscore-to-camel-case: true

logging:
config: classpath:log4j2-test-unit.yml
config: classpath:log4j2-test-integration.yml
77 changes: 77 additions & 0 deletions apps/user-service/src/main/resources/log4j2-test-integration.yml
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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("최대 재시도 횟수 초과");
}
}
Loading
Loading