내가 개발한 모의고사 성적표 웹뷰 출력< 기능 관련해
학원기획팀에서 들어온 요구사항이 있었다.
> 학생 전체 성적표를 한 번에 출력할 수 있게 해주세요.<
학원 한 곳당 약 200~300명, 많은곳은 500명까지 요구사항이 도착하였는데
기존 구현했던 성적표 서비스는 HTML 기반 단 1개의 출력물이었다.
문제는 이 요청을 기존 구조 그대로 확장할 수 없었다는 점이였고..
(현재 프로젝트에서 구현하고 있는 스펙은 java springboot + thymeleaft 기반으로 구현 되어 있음)
대용량 처리로 인해 발생한 문제점들
성적표 HTML의 평균 크기는 약 2MB...
- 1명: 약 2MB
- 300명: 약 600MB
- 서버 Heap 메모리: 512MB
처음 접근한 방식은
300명의 성적표 HTML을 한 번에 생성 → 메모리에 적재 → 응답 으로
단순하게 기존 성적표 1개를 생성하는 로직에서 반복문을 돌리는.. (제일 공수가 적게 들것이므로..) 방식이였는데 사실 말이 안되긴 했다;;
- 학생별 성적 데이터 조회
- 템플릿에 모델로 전달
- TemplateEngine.process();
- 이걸 반복문으로 돌려 HTML을 생성해서 response함
실제로 구현 시 OutOfMemoryError가 발생....
이를 해결하기 위해서 여러가지 방법을 생각해봤는데
① 배치 처리로 부하를 쪼개기
일단 처음 접근한 방법은 서버 부하 관리에 있어서 매우 기본적이고 단순한 방법으로,
기존 요청수에 맞게 반복문을 돌리는 방식에서 수를 분할해 부하를 쪼개는 방식을 사용했다.
(전체 데이터를 한 번에 처리하는 대신, 예측 가능한 단위로 나누어 처리하는 방식을 선택)
- 전체 학생 300명
- 10명 단위로 분할 → 30개 배치
- 배치 간: 순차 처리
- 배치 내: 병렬 처리
List batches = Lists.partition(students, 10);
for (List batch : batches) {
processBatchAsync(batch).join(); // 배치 단위 순차 처리
}
②: CompletableFuture 기반 병렬 처리
배치 내부에서는 CompletableFuture를 활용해 병렬로 성적표를 생성했다.
Executor executor = threadPoolTaskExecutor;
List futures = batch.stream()
.map(student -> CompletableFuture.runAsync(() -> generateReport(student), executor)
)
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
서버가 감당 가능한 수준에서 동시에 여러 작업을 처리하되 리소스를 예측 가능하게 사용하기 위해 비동기 전략을 사용했는데,
사실 비동기 처리를 하지 않아도 OutOfMemory는 피할 수 있었고 시스템 안정성도 충분히 확보할 수 있었다
그럼에도 비동기 처리 로직을 추가한건... 처리시간 감축을 위해서였는데
배치 크기를 10명으로 설정했을 때, 한 배치 처리시간은 3초, 100배치가 돈다면 300초 => 5분까지 확장되므로,
출력 버튼을 누르고 5분 가까이 기다려야 결과가 나오는 구조였다.
거기다가 출력 요청 중에는 서버 리소스가 그 작업에 계속 묶여있으므로 요청 스레드가 불필요하게 오래 점유되지 않고, 서버 응답성 유지 기대효과를 위해 비동기 로직까지 추가하기로 했다.
결과적으로 전체 처리 시간은 100명 학생의 성적표 화면을 출력할 때, 약 300초 → 30초로 감소할 수 있었다
③: StreamingResponseBody로 메모리 사용 최소화
배치 + 비동기를 적용했음에도 여전히 메모리 사용량이 부담스러웠는데,
모든 HTML을 다 만든 뒤 응답하는 구조이다보니 HTML 렌더링 결과가 생각보다 오래 메모리에 남아 있을 수 밖에 없었다.
처음 구조는 단순하게..
1. 학생 전체 성적표 HTML 생성
2. List<String>에 저장
3. 컨트롤러에서 한 번에 Response로 반환
구조였는데 이거의 문제는 HTML 용량이 커질수록 메모리 점유가 증가하고, GC가 돌떄까지 해제가 되지 않아 보관시간이 선형적으로 증가한다는 것에 있었다
그래서 저장하는 게 아니라 응답을 바로 보내는 스트리밍 방식을 생각해보았다
@GetMapping("/print/reports")
public StreamingResponseBody printReports(HttpServletResponse response) {
response.setContentType("text/html;charset=UTF-8");
return outputStream -> {
for (List<Student> batch : batches) {
processBatchAndStream(batch, outputStream);
outputStream.flush();
}
};
}
private void processBatchAndStream(List<Student> batch, OutputStream os) {
batch.parallelStream().forEach(student -> {
String html = generateReportHtml(student);
os.write(html.getBytes(StandardCharsets.UTF_8));
});
}
이 방식의 핵심은 다음과 같다.
- Thymeleaf는 한 명씩 렌더링
- 렌더링 결과를 즉시 OutputStream으로 전송
- HTML을 리스트나 컬렉션에 쌓아두지 않음
사실 스트리밍 처리까지 할 필요가 있을까?싶긴 했는데 메모리 모니터링 해보면서 코드를 간소화하든가 해야겠음
무튼 이 작업을 통해 얻은 결론은 다음과 같다
- 배치처리는 당연히
- 대용량 처리 문제는 대부분 처리 흐름 설계의 문제
- OOM은 메모리 증설로 해결할 대상이 아님
- 한 번에 처리해야 하는가? < 를 먼저 의심
비슷한 문제를 겪고 있다면, 배치 + 스트리밍 구조는 충분히 고려해볼 만한 선택지라고 생각한다.
'업무 기록' 카테고리의 다른 글
| 임상 메타데이터 검색엔진 서비스 구축 (0) | 2026.01.11 |
|---|---|
| kafka 기반 로그 스트리밍 시스템 구축하기 (0) | 2026.01.04 |
| 다중 로그인 제어 적용하기 (0) | 2024.06.29 |
| spring event로 이벤트 아키텍처 적용하기 (0) | 2024.02.02 |
| 클린 아키텍처 적용하기 (0) | 2024.01.16 |