Spring @Async 기반 비동기 파일 처리 구현 가이드

대용량 파일 업로드 시 동기 방식의 이벤트 처리는 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());
    }
}

완전한 비동기 흐름

최종 아키텍처는 다음 단계로 구성됩니다:

  1. 클라이언트가 파일을 업로드하면 컨트롤러는 즉시 202 Accepted와 처리 ID를 반환
  2. FileUploadedEvent가 발행되고 fileProcessor 스레드 풀의 워커가 이벤트를 구독
  3. HTTP 스레드는 풀로 반환되어 새 요청을 처리할 수 있음
  4. 클라이언트는 폴링 또는 WebSocket으로 처리 상태를 조회

이 구조를 통해 수 GB급 파일 업로드 시에도 서버는 수 초 내에 응답을 반환하고, 실제 변환·검증·저장 작업은 백그라운드에서 순차적으로 처리됩니다.

태그: Spring Async ThreadPoolTaskExecutor event-driven architecture File Upload Java Concurrency

9월 17일 13:30에 게시됨