대용량 파일 업로드 시 동기 방식의 이벤트 처리는 HTTP 요청 스레드를 장시간 점유하여 사용자 경험을 저하시킵니다. 본 문서에서는 Spring의 @Async를 활용해 파일 수신과 후처리를 완전히 분리하는 비동기 아키텍처를 구축하는 방법을 다룹니다.
동기 처리의 한계 분석
기존 @EventListener 방식은 이벤트 발행과 처리가 같은 스레드에서 실행됩니다. 35초 이상 소요되는 파일 파싱 로직이 포함된 경우, Tomcat의 HTTP 스레드(http-nio-8080-exec-N)가 해당 시간 동안 블로킹 상태에 빠집니다.
@EventListener
public void handleFileUploaded(FileUploadedEvent evt) {
// HTTP 스레드에서 직접 실행 - 블로킹 발생
parserService.parse(evt.getFileMeta());
}
이 구조에서는 동시 접속자 수가 스레드 풀 크기에 의해 제한되며, 긴 처리 시간 동안 클라이언트는 응답 대기 상태를 유지해야 합니다.
비동기 전환: 기본 설정
메서드 레벨에서 비동기 실행을 선언하려면 @Async를 적용하고, 애플리케이션에 @EnableAsync를 활성화합니다.
@Configuration
@EnableAsync
public class AsyncConfig {
}
@Async
@EventListener
public void handleFileUploaded(FileUploadedEvent evt) {
// 별도 스레드에서 실행
parserService.parse(evt.getFileMeta());
}
이 설정만으로도 HTTP 스레드가 즉시 반환되지만, 기본 SimpleAsyncTaskExecutor는 매 요청마다 새 스레드를 생성하여 리소스 낭비가 발생합니다.
전용 스레드 풀 구성
파일 처리 전용 스레드 풀을 정의하여 다른 비동기 작업과 격리하고, 리소스 사용을 제어합니다.
@Configuration
@EnableAsync
public class FileProcessingExecutorConfig implements AsyncConfigurer {
private static final int CORE_SIZE = 16;
private static final int MAX_SIZE = 64;
private static final int QUEUE_CAP = 500;
private static final int ALIVE_SEC = 600;
@Bean("fileProcessor")
public ThreadPoolTaskExecutor fileTaskExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(CORE_SIZE);
exec.setMaxPoolSize(MAX_SIZE);
exec.setQueueCapacity(QUEUE_CAP);
exec.setKeepAliveSeconds(ALIVE_SEC);
exec.setThreadNamePrefix("file-worker-");
exec.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
exec.setWaitForTasksToCompleteOnShutdown(true);
exec.setAwaitTerminationSeconds(60);
exec.initialize();
return exec;
}
@Override
public Executor getAsyncExecutor() {
return new ThreadPoolTaskExecutor(); // 기본값
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
LoggerFactory.getLogger(method.getDeclaringClass())
.error("비동기 처리 실패: {}.{}",
method.getDeclaringClass().getSimpleName(),
method.getName(), ex);
}
}
스레드 풀 파라미터는 다음 기준으로 산정합니다:
- corePoolSize: 동시 처리 가능한 파일 수의 기준선
- queueCapacity: 처리 지연이 예상될 때 버퍼링할 최대 대기 건수
- CallerRunsPolicy: 과부하 시 호출자 스레드가 처리하여 백프레셔 역할
스레드 풀 연결 및 실행 검증
정의한 실행자를 @Async의 값으로 지정하여 연결합니다.
@Async("fileProcessor")
@EventListener
public void handleFileUploaded(FileUploadedEvent evt) {
log.info("처리 스레드: {}", Thread.currentThread().getName());
FileMetadata meta = evt.getFileMeta();
ProcessingPipeline pipeline = resolver.findPipeline(meta.getExt());
pipeline.execute(meta);
}
실행 로그에서 스레드 명칭이 file-worker-N으로 출력되는지 확인합니다:
// HTTP 요청 즉시 반환
[http-nio-8080-exec-5] INFO c.e.FileController - 업로드 완료, 응답 반환
// 별도 스레드에서 후처리 진행
[file-worker-3] INFO c.e.FileEventHandler - 처리 스레드: file-worker-3
[file-worker-3] INFO c.e.ProcessingPipeline - 파싱 시작: data.csv
[file-worker-3] INFO c.e.ProcessingPipeline - 파싱 완료: 4200ms
예외 처리 및 모니터링
비동기 컨텍스트에서 발생한 예외는 호출자에게 전파되지 않으므로, 전용 핸들러로 로깅 및 알림을 구성합니다.
@Component
public class FileProcessingErrorHandler implements AsyncUncaughtExceptionHandler {
@Autowired private AlertService alertService;
@Autowired private FileStatusRepository statusRepo;
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
FileMetadata failed = (FileMetadata) params[0];
statusRepo.updateStatus(failed.getId(), Status.FAILED, ex.getMessage());
alertService.notifyOps("파일 처리 실패", failed.getOriginalName(), ex);
log.error("파일 처리 중 오류: {}", failed.getId(), ex);
}
}
스레드 풀 상태를 Actuator로 노출하면 운영 중 모니터링이 가능합니다:
@Component
public class FileExecutorMetrics implements MeterBinder {
@Autowired @Qualifier("fileProcessor")
private ThreadPoolTaskExecutor executor;
@Override
public void bindTo(MeterRegistry registry) {
ThreadPoolExecutor pool = executor.getThreadPoolExecutor();
registry.gauge("file.queue.size", pool, p -> p.getQueue().size());
registry.gauge("file.active.threads", pool, p -> p.getActiveCount());
registry.gauge("file.completed.tasks", pool, p -> p.getCompletedTaskCount());
}
}
완전한 비동기 흐름
최종 아키텍처는 다음 단계로 구성됩니다:
- 클라이언트가 파일을 업로드하면 컨트롤러는 즉시
202 Accepted와 처리 ID를 반환 FileUploadedEvent가 발행되고fileProcessor스레드 풀의 워커가 이벤트를 구독- HTTP 스레드는 풀로 반환되어 새 요청을 처리할 수 있음
- 클라이언트는 폴링 또는 WebSocket으로 처리 상태를 조회
이 구조를 통해 수 GB급 파일 업로드 시에도 서버는 수 초 내에 응답을 반환하고, 실제 변환·검증·저장 작업은 백그라운드에서 순차적으로 처리됩니다.