대규모 데이터 엑셀 다운로드 시 서버 부하 방지를 위한 큐 기반 처리 최적화

최근 서비스 내 수십만 건 이상의 데이터를 일괄 다운로드하는 기능에 대한 요구가 증가하고 있으며, 특히 여러 사용자가 동시에 엑셀 추출을 요청할 경우 서버 리소스 과부하로 인한 장애 위험이 존재합니다. 이는 DB 조회 I/O와 파일 스트림 생성이라는 두 가지 무거운 작업이 병행되기 때문입니다. 이를 해결하기 위해 동시 처리량을 제어하고, 요청을 순차적으로 안정적으로 처리할 수 있는 큐(Queue) 기반 아키텍처를 설계하였습니다.

핵심 설계 목표

  • 동시 다수 요청에 의한 서버 다운 방지
  • 사용자에게 대기 상태 및 처리 진행 상황 제공
  • 대용량 데이터 처리를 위한 효율적인 스트리밍 방식 채택
  • 확장성 고려: 향후 분산 환경에서도 동작 가능한 구조

주요 구성 요소

시스템은 다음 세 가지 핵심 컴포넌트로 구성됩니다:

    ProcessingQueue: 최대 10명까지 수용 가능한 유한 큐. 초과 요청은 블로킹되어 대기 상태로 전환. DataExporter: 실제 데이터 추출 로직을 담당하며, 비동기로 실행되며 완료 후 다음 작업을 폴링. ExportTask: 사용자 정보 및 작업 파라미터를 포함한 작업 단위 객체.

큐 관리 클래스 구현

@Component
public class ProcessingQueue {

    private static final int MAX_SIZE = 10;
    private final Queue<ExportTask> taskQueue = new LinkedList<>();

    public synchronized void enqueue(ExportTask task) {
        while (taskQueue.size() >= MAX_SIZE) {
            try {
                log.info("처리 큐가 포화 상태입니다. 대기 중...");
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
        taskQueue.offer(task);
        log.info("새 작업 추가됨. 현재 대기 수: {}", taskQueue.size());
        notifyAll();
    }

    public synchronized ExportTask dequeue() {
        while (taskQueue.isEmpty()) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return null;
            }
        }
        ExportTask task = taskQueue.poll();
        notifyAll();
        return task;
    }

    public synchronized int getCurrentSize() {
        return taskQueue.size();
    }
}
    

추출 로직 추상 클래스

EasyExcel을 활용해 백만 단위 데이터도 스트리밍 방식으로 처리 가능하도록 설계:

@Slf4j
public abstract class DataExporter<Criteria, RowData> {

    protected abstract long calculateTotalCount(Criteria criteria);

    protected abstract List<RowData> fetchPageData(Criteria criteria, int offset, int limit);

    protected abstract Class<RowData> getRowClass();

    public final void performExport(HttpServletResponse response, Criteria criteria, String sheetName) throws IOException {
        ExcelWriter writer = null;
        try {
            writer = createWriter(response, sheetName);
            long totalCount = calculateTotalCount(criteria);
            int pageSize = 5000;
            int totalPages = (int) Math.ceil((double) totalCount / pageSize);

            for (int i = 0; i < totalPages; i++) {
                int offset = i * pageSize;
                BeanUtil.setProperty(criteria, "offset", offset);
                BeanUtil.setProperty(criteria, "limit", pageSize);

                List<RowData> pageData = fetchPageData(criteria, offset, pageSize);
                WriteSheet sheet = EasyExcel.writerSheet(sheetName).head(getRowClass()).build();
                writer.write(pageData, sheet);
            }
        } catch (Exception e) {
            log.error("엑셀 생성 중 오류 발생", e);
            throw new IOException("파일 생성 실패: " + e.getMessage());
        } finally {
            if (writer != null) {
                writer.finish();
            }
        }
    }

    private ExcelWriter createWriter(HttpServletResponse response, String fileName) throws IOException {
        String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()).replace("+", "%20");
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName + ".xlsx");

        return EasyExcel.write(response.getOutputStream()).build();
    }
}
    

비즈니스 로직 구현 예시

@Service
public class UserExportService extends DataExporter<UserQuery, UserExportDTO> {

    @Autowired
    private ProcessingQueue processingQueue;

    @Override
    protected long calculateTotalCount(UserQuery query) {
        return userRepository.countByConditions(query);
    }

    @Override
    protected List<UserExportDTO> fetchPageData(UserQuery query, int offset, int limit) {
        return userRepository.findExportList(query, offset, limit);
    }

    @Override
    protected Class<UserExportDTO> getRowClass() {
        return UserExportDTO.class;
    }

    @Async
    public void initiateExport(ExportTask task) {
        try {
            log.info("{} 사용자의 내보내기 시작", task.getUsername());
            ExportTask current;

            do {
                current = processingQueue.dequeue();
                if (current != null && current.getId().equals(task.getId())) {
                    performExport(current.getResponse(), current.getQuery(), "사용자목록");
                    log.info("내보내기 완료: {}", current.getUsername());
                    break;
                } else if (current != null) {
                    // 다른 사용자 작업이라면 다시 큐에 넣음
                    processingQueue.enqueue(current);
                }
            } while (true);
        } catch (IOException e) {
            log.error("응답 처리 실패", e);
        }
    }
}
    

컨트롤러 테스트 코드

@RestController
@RequestMapping("/data")
public class ExportController {

    @Autowired
    private UserExportService exporter;

    @Autowired
    private ProcessingQueue queue;

    @PostMapping("/users/export")
    public ResponseEntity<String> requestExport(@RequestBody UserQuery query, HttpServletRequest request) {
        HttpServletResponse response = ((ServletServerHttpResponse) 
            new ServletServerHttpRequest(request)).getServletResponse();

        ExportTask task = new ExportTask()
            .setId(UUID.randomUUID().toString())
            .setUsername(SecurityUtil.getCurrentUsername())
            .setQuery(query)
            .setResponse(response);

        queue.enqueue(task);
        exporter.initiateExport(task);

        return ResponseEntity.ok("내보내기 요청이 접수되었습니다. 현재 대기열: " + queue.getCurrentSize());
    }
}
    

테스트 결과 및 검증

동시에 15건의 요청을 보냈을 때, 처음 10건은 정상적으로 큐에 등록되었으며, 이후 5건은 큐가 여유 생길 때까지 블로킹된 것을 확인했습니다. 선입선출(FIFO) 방식으로 처리되며, 하나의 작업 완료 후 다음 작업이 자동으로 시작되는 것이 로그를 통해 확인되었습니다.

향후 개선 방향

현재는 싱글 노드 메모리 기반 큐를 사용하고 있으나, 향후 다음과 같은 확장을 고려할 수 있습니다:

  • Redis 기반의 분산 큐 도입으로 클러스터 환경 지원
  • 작업 상태 저장을 위한 전용 테이블 설계
  • OSS 또는 S3에 파일 사전 저장 후 링크 제공 방식으로 변경
  • 웹소켓을 이용한 실시간 대기열 상태 알림

태그: java Spring Boot EasyExcel concurrency Queue

6월 28일 23:10에 게시됨