비동기 작업과 스케줄링
AFTER_COMMIT listener에서 보고서 전체를 바로 계산하면 요청을 처리한 스레드가 다시 오래 묶입니다. @Async는 작업을 다른 실행기로 넘겨 호출자를 빨리 돌려보낼 수 있지만, 그 순간 예외와 완료 시점도 호출 스택 밖으로 이동합니다.
따라서 비동기화의 출발점은 annotation이 아니라 작업 ID와 상태입니다. 큐가 가득 찼을 때 거절할지, 애플리케이션 종료 때 얼마나 기다릴지, 여러 replica의 scheduler가 같은 날짜 작업을 만들면 누가 이길지를 먼저 정합니다.
비동기는 실패와 완료 시점을 바꾼다
호출자가 run을 호출했다는 사실은 작업 성공을 뜻하지 않습니다. 등록된 job을 RUNNING으로 바꾸고, 생성 결과를 COMPLETED 또는 FAILED로 남겨야 다른 요청과 운영자가 나중에도 결과를 조회할 수 있습니다.
보고서 생성 작업과 scheduler를 구성한다
아래 구성은 큐 크기가 정해진 전용 실행기를 만들고 날짜별 job을 DB에서 한 번만 생성한 경우에만 worker를 호출합니다. createIfAbsent 구현에는 job_key UNIQUE 제약과 충돌을 “이미 존재함”으로 바꾸는 transaction이 필요합니다.
package com.andongmin.studylog.report;
import java.util.UUID;
public interface ReportJobRepository {
void markRunning(UUID jobId);
void generate(UUID jobId);
void markCompleted(UUID jobId);
void markFailed(UUID jobId, String reason);
}package com.andongmin.studylog.report;
import java.util.Optional;
import java.util.UUID;
public interface ReportJobService {
Optional<UUID> createIfAbsent(String jobKey);
}package com.andongmin.studylog.report;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration(proxyBeanMethods = false)
@EnableAsync
@EnableScheduling
public class AsyncConfiguration {
@Bean("reportExecutor")
AsyncTaskExecutor reportExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("report-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
return executor;
}
}package com.andongmin.studylog.report;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class ReportJobWorker {
private final ReportJobRepository jobs;
public ReportJobWorker(ReportJobRepository jobs) {
this.jobs = jobs;
}
@Async("reportExecutor")
public CompletableFuture<UUID> run(UUID jobId) {
jobs.markRunning(jobId);
try {
jobs.generate(jobId);
jobs.markCompleted(jobId);
return CompletableFuture.completedFuture(jobId);
} catch (RuntimeException failure) {
jobs.markFailed(jobId, failure.getClass().getSimpleName());
return CompletableFuture.failedFuture(failure);
}
}
}package com.andongmin.studylog.report;
import java.time.Clock;
import java.time.LocalDate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class DailyReportScheduler {
private final ReportJobService jobs;
private final ReportJobWorker worker;
private final Clock clock;
public DailyReportScheduler(ReportJobService jobs,
ReportJobWorker worker, Clock clock) {
this.jobs = jobs;
this.worker = worker;
this.clock = clock;
}
@Scheduled(cron = "0 5 0 * * *", zone = "UTC")
public void schedule() {
jobs.createIfAbsent("daily-report:" + LocalDate.now(clock))
.ifPresent(worker::run);
}
}worker가 자기 클래스 안에서 run을 직접 호출하면 Spring proxy를 거치지 않아 비동기로 실행되지 않습니다. scheduler가 별도 bean인 worker를 주입받아 호출하는 구조는 이 self-invocation 함정을 피합니다.
중복·timeout·종료 시 작업을 검증한다
제공한 코드는 실행 경계를 보여 주지만 repository 구현과 테스트 파일까지 만들지는 않습니다. 먼저 컴파일로 wiring을 확인한 뒤, 구현 단계에서는 날짜가 같은 두 동시 요청, generate 예외, 30초를 넘는 종료 작업, 큐 100개 초과를 각각 독립된 시나리오로 검증합니다.
./mvnw -q compile비동기 실패는 DB의 FAILED 상태와 `CompletableFuture` 양쪽에 남아야 합니다. 여러 replica가 같은 시각에 scheduler를 실행해도 DB UNIQUE 제약을 통과한 한 호출만 job ID를 받고 worker를 시작해야 합니다.실행기 종료와 timeout은 같은 문제가 아니다
awaitTerminationSeconds(30)은 애플리케이션 종료 때 기존 작업을 기다리는 시간입니다. 개별 작업의 timeout은 따로 구현해야 하며, CompletableFuture.orTimeout이 호출자 대기만 끝내고 실제 계산은 계속할 수 있다는 점도 구분해야 합니다. 장시간 작업은 중단 플래그나 외부 job 시스템을 검토합니다.
비동기 job이 완료 사실을 남기면, 다음 절에서는 그 사건을 조회에 알맞은 별도 집계 모델로 반영합니다.