최근 서비스 내 수십만 건 이상의 데이터를 일괄 다운로드하는 기능에 대한 요구가 증가하고 있으며, 특히 여러 사용자가 동시에 엑셀 추출을 요청할 경우 서버 리소스 과부하로 인한 장애 위험이 존재합니다. 이는 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에 파일 사전 저장 후 링크 제공 방식으로 변경
- 웹소켓을 이용한 실시간 대기열 상태 알림